diff --git a/src/main/window/createMainWindow-markdown-editor-focus.test.ts b/src/main/window/createMainWindow-markdown-editor-focus.test.ts index 8386aa11391..2237c376232 100644 --- a/src/main/window/createMainWindow-markdown-editor-focus.test.ts +++ b/src/main/window/createMainWindow-markdown-editor-focus.test.ts @@ -36,7 +36,9 @@ function openMainWindowForContextMenu() { openDevTools: vi.fn(), closeDevTools: vi.fn(), replaceMisspelling: vi.fn(), - session: { addWordToSpellCheckerDictionary: vi.fn() } + session: { addWordToSpellCheckerDictionary: vi.fn() }, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), + isLoadingMainFrame: vi.fn(() => true) } const browserWindowInstance = { webContents, @@ -531,7 +533,12 @@ describe('createMainWindow', () => { expect.objectContaining({ label: 'Table' }) ]) ) - expect(menuPopupMock).toHaveBeenCalledWith({ window: browserWindowInstance, x: 42, y: 84 }) + expect(menuPopupMock).toHaveBeenCalledWith({ + window: browserWindowInstance, + x: 42, + y: 84, + callback: expect.any(Function) + }) }) it('builds the plain edit menu for the one right-click the renderer marks as plain text', () => { @@ -567,4 +574,56 @@ describe('createMainWindow', () => { expect.arrayContaining([expect.objectContaining({ label: 'Format' })]) ) }) + + it('retires the edit menu with its document but not on same-document navigation', () => { + const { webContents } = openMainWindowForContextMenu() + const emit = (event: string, ...args: unknown[]): void => { + for (const [name, handler] of webContents.on.mock.calls) { + if (name === event) { + handler(...args) + } + } + } + const openMenu = () => { + const closePopup = vi.fn() + buildFromTemplateMock.mockImplementationOnce(() => ({ + popup: menuPopupMock, + closePopup + })) + emit( + 'context-menu', + {}, + { + x: 1, + y: 2, + isEditable: true, + formControlType: 'input', + dictionarySuggestions: [], + misspelledWord: '' + } + ) + const template = buildFromTemplateMock.mock.calls.at(-1)?.[0] + const paste = Array.isArray(template) + ? template.find((item) => item.label === 'Paste') + : undefined + expect(paste?.click).toBeTypeOf('function') + return { closePopup, paste } + } + const nextDocument = 'file:///opt/orca/renderer/index.html?reload' + + const first = openMenu() + emit('did-start-navigation', {}, `${nextDocument}#hash`, true, true) + expect(first.closePopup).not.toHaveBeenCalled() + emit('did-start-navigation', {}, nextDocument, false, true) + expect(first.closePopup).toHaveBeenCalledTimes(1) + + // The old document still takes right-clicks until the pending reload commits. + const pending = openMenu() + emit('did-frame-navigate', {}, nextDocument, 200, 'OK', false) + expect(pending.closePopup).not.toHaveBeenCalled() + emit('did-frame-navigate', {}, nextDocument, 200, 'OK', true) + expect(pending.closePopup).toHaveBeenCalledTimes(1) + pending.paste.click() + expect(webContents.send).not.toHaveBeenCalledWith('ui:editableContextPaste', expect.anything()) + }) }) diff --git a/src/main/window/createMainWindow-test-harness.ts b/src/main/window/createMainWindow-test-harness.ts index 0a5211d2ca3..6f428270450 100644 --- a/src/main/window/createMainWindow-test-harness.ts +++ b/src/main/window/createMainWindow-test-harness.ts @@ -16,9 +16,9 @@ export const attachRouteGuestMock: MainWindowSpy = vi.fn(() => false) export const retireRouteRendererMock: MainWindowSpy = vi.fn() export const attachClientPageRendererMock: MainWindowSpy = vi.fn() export const retireClientPageRendererMock: MainWindowSpy = vi.fn() -export const buildFromTemplateMock: Mock<(...args: unknown[]) => { popup: MainWindowSpy }> = vi.fn( - () => ({ popup: menuPopupMock }) -) +export const buildFromTemplateMock: Mock< + (...args: unknown[]) => { popup: MainWindowSpy; closePopup: MainWindowSpy } +> = vi.fn(() => ({ popup: menuPopupMock, closePopup: vi.fn() })) export const notificationMock: Mock<(...args: unknown[]) => { show: MainWindowSpy }> = vi.fn( function () { return { show: notificationShowMock } diff --git a/src/main/window/editable-context-menu-popup.ts b/src/main/window/editable-context-menu-popup.ts new file mode 100644 index 00000000000..74ffbea634a --- /dev/null +++ b/src/main/window/editable-context-menu-popup.ts @@ -0,0 +1,79 @@ +import { + Menu, + type BrowserWindow, + type MenuItemConstructorOptions, + type WebContents +} from 'electron' + +type EditableContextMenuPopup = { + open: (template: MenuItemConstructorOptions[], point: { x: number; y: number }) => void + close: () => void +} + +type Retirement = { retired: boolean } +type OpenMenu = { menu: Menu; retirement: Retirement } + +// macOS closePopup is posted asynchronously, so a click can still land after retirement. +function retirableTemplate( + template: MenuItemConstructorOptions[], + retirement: Retirement +): MenuItemConstructorOptions[] { + return template.map((item) => { + const { click, submenu } = item + return { + ...item, + ...(click + ? { + click: (...args: Parameters) => { + if (!retirement.retired) { + click(...args) + } + } + } + : {}), + ...(Array.isArray(submenu) ? { submenu: retirableTemplate(submenu, retirement) } : {}) + } + }) +} + +export function createEditableContextMenuPopup( + window: BrowserWindow, + webContents: Pick +): EditableContextMenuPopup { + let current: OpenMenu | null = null + const close = (): void => { + const opened = current + current = null + if (opened) { + opened.retirement.retired = true + opened.menu.closePopup() + } + } + // A menu opened while a reload is pending belongs to the old document; retire it at commit. + webContents.on('did-frame-navigate', (_event, _url, _code, _status, isMainFrame) => { + if (isMainFrame) { + close() + } + }) + + return { + open: (template, point) => { + close() + const retirement: Retirement = { retired: false } + const menu = Menu.buildFromTemplate(retirableTemplate(template, retirement)) + const opened: OpenMenu = { menu, retirement } + current = opened + menu.popup({ + window, + x: point.x, + y: point.y, + callback: () => { + if (current === opened) { + current = null + } + } + }) + }, + close + } +} diff --git a/src/main/window/main-window-focus-lifecycle.ts b/src/main/window/main-window-focus-lifecycle.ts index 66127f9fefc..819b7f4fa39 100644 --- a/src/main/window/main-window-focus-lifecycle.ts +++ b/src/main/window/main-window-focus-lifecycle.ts @@ -1,4 +1,4 @@ -import { ipcMain, Menu, type BrowserWindow } from 'electron' +import { ipcMain, type BrowserWindow } from 'electron' import { isCrashReportReason } from '../../shared/crash-reporting' import { plainTextContextMenuTargetChannel, @@ -16,6 +16,7 @@ import { matchingRichMarkdownContextMenuTableTarget, parseRichMarkdownContextMenuTableTarget } from './editable-context-menu' +import { createEditableContextMenuPopup } from './editable-context-menu-popup' import type { CreateMainWindowOptions, MainWindowLoadObserver } from './main-window-contracts' import { browserRouteWebContentsRegistry } from '../browser/browser-route-session-runtime' import { @@ -114,6 +115,7 @@ export function installMainWindowFocusLifecycle(args: { } } ipcMain.on(plainTextContextMenuTargetChannel, onPlainTextContextMenuTarget) + const editableContextMenuPopup = createEditableContextMenuPopup(mainWindow, rendererWebContents) const onMainContextMenu = (_event: Electron.Event, params: Electron.ContextMenuParams): void => { const tableTarget = matchingRichMarkdownContextMenuTableTarget( params, @@ -130,12 +132,13 @@ export function installMainWindowFocusLifecycle(args: { return } // Context menu may precede focus mirroring; trust Electron's editable params. - Menu.buildFromTemplate(template).popup({ window: mainWindow, x: params.x, y: params.y }) + editableContextMenuPopup.open(template, params) } rendererWebContents.on('context-menu', onMainContextMenu) // Dead renderers cannot clear focus mirrors; reset carve-outs so later lifecycles retain app shortcuts. const resetMarkdownEditorFocus = (): void => { + editableContextMenuPopup.close() markdownEditorFocused = false pendingRichMarkdownContextMenuTableTarget = null pendingPlainTextContextMenuTarget = false