diff --git a/CoreEditor/package.json b/CoreEditor/package.json index b8d6bf82b..8706fefc8 100644 --- a/CoreEditor/package.json +++ b/CoreEditor/package.json @@ -40,7 +40,7 @@ "globals": "^15.15.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", - "markedit-api": "https://github.com/MarkEdit-app/MarkEdit-api#v0.37.0", + "markedit-api": "https://github.com/MarkEdit-app/MarkEdit-api#v0.38.0", "ts-gyb": "^0.12.0", "ts-jest": "^29.4.5", "tslib": "^2.6.2", diff --git a/CoreEditor/src/api/ui.ts b/CoreEditor/src/api/ui.ts index 7a552b7eb..65a04f469 100644 --- a/CoreEditor/src/api/ui.ts +++ b/CoreEditor/src/api/ui.ts @@ -1,5 +1,5 @@ import { EditorView } from '@codemirror/view'; -import { MenuItem, MenuItemState, Alert, TextBox, OpenPanelOptions, PrintPanelOptions, SavePanelOptions } from 'markedit-api'; +import { MenuItem, MenuItemState, ContextMenuOptions, Alert, TextBox, OpenPanelOptions, PrintPanelOptions, SavePanelOptions } from 'markedit-api'; import { WebMenuItem } from '../@types/WebMenuItem'; import { WebPoint } from '../@types/WebPoint'; @@ -15,12 +15,19 @@ export function addMainMenuItem(spec: MenuItem | MenuItem[]): void { }); } -export function showContextMenu(items: MenuItem[], location?: WebPoint) { +export function showContextMenu(items: MenuItem[], location?: WebPoint, options?: ContextMenuOptions) { + let { highlightedIndex } = options ?? {}; + if (highlightedIndex !== undefined && !Number.isSafeInteger(highlightedIndex)) { + console.error('showContextMenu: highlightedIndex must be a safe integer.', highlightedIndex); + highlightedIndex = undefined; + } + const editor = focusedEditor() ?? window.editor; const caretPos = editor.state.selection.main.head; const invokeNative = () => { window.nativeModules.api.showContextMenu({ items: items.map(item => createMenuItem(item, contextActions)), + highlightedIndex: highlightedIndex === undefined ? undefined : (highlightedIndex as CodeGen_Int), location: location ?? (() => { const coords = editor.coordsAtPos(caretPos); if (coords === null) { diff --git a/CoreEditor/src/bridge/native/api.ts b/CoreEditor/src/bridge/native/api.ts index 65ce01400..ad03fbe5d 100644 --- a/CoreEditor/src/bridge/native/api.ts +++ b/CoreEditor/src/bridge/native/api.ts @@ -14,7 +14,7 @@ export interface NativeModuleAPI extends NativeModule { closeDocument(): Promise; recentDocumentPaths(): Promise; addMainMenuItems({ items }: { items: WebMenuItem[] }): void; - showContextMenu(args: { items: WebMenuItem[]; location: WebPoint }): void; + showContextMenu(args: { items: WebMenuItem[]; location: WebPoint; highlightedIndex?: CodeGen_Int }): void; showAlert(args: { title?: string; message?: string; buttons?: string[] }): Promise; showTextBox(args: { title?: string; placeholder?: string; defaultValue?: string }): Promise; showOpenPanel({ options }: { options: OpenPanelOptions }): Promise; diff --git a/CoreEditor/test/nestedEditors.test.ts b/CoreEditor/test/nestedEditors.test.ts index 9d7715890..ff17cc198 100644 --- a/CoreEditor/test/nestedEditors.test.ts +++ b/CoreEditor/test/nestedEditors.test.ts @@ -235,6 +235,99 @@ describe('context menu caret geometry', () => { expect(dispatch).not.toHaveBeenCalled(); }); + test.each([{}, { highlightedIndex: 0 }])('uses the cell caret with options %j', options => { + jest.useFakeTimers(); + const show = prepareMenu(); + showContextMenu([{ title: 'First' }], undefined, options); + expect(show).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(50); + expect(show).toHaveBeenCalledWith({ + items: [expect.objectContaining({ title: 'First' })], + location: { x: 80, y: 70 }, + highlightedIndex: options.highlightedIndex, + }); + }); + + test.each([undefined, 0, 1, 2])('uses an explicit location with highlighted index %s', highlightedIndex => { + const show = prepareMenu(); + const dispatch = jest.spyOn(cell, 'dispatch'); + + showContextMenu([{ title: 'First' }, { separator: true }, { title: 'Last' }], { x: 1, y: 2 }, { + highlightedIndex, + }); + + expect(show).toHaveBeenCalledWith({ + items: [expect.objectContaining({ title: 'First' }), expect.objectContaining({ separator: true }), expect.objectContaining({ title: 'Last' })], + location: { x: 1, y: 2 }, + highlightedIndex, + }); + + expect(dispatch).not.toHaveBeenCalled(); + }); + + test.each([-1, 3, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER])( + 'forwards out-of-range integer %s for native fallback', + highlightedIndex => { + const show = prepareMenu(); + showContextMenu([ + { title: 'First' }, + { separator: true }, + { title: 'Disabled', state: () => ({ isEnabled: false }) }, + ], { x: 1, y: 2 }, { highlightedIndex }); + + expect(show).toHaveBeenCalledWith(expect.objectContaining({ + location: { x: 1, y: 2 }, + highlightedIndex, + })); + }, + ); + + test.each([0.5, NaN, Infinity, -Infinity, Number.MAX_VALUE, Number.MAX_SAFE_INTEGER + 1, Number.MIN_SAFE_INTEGER - 1])( + 'opens menus without a highlight for non-safe integer %s', + highlightedIndex => { + jest.useFakeTimers(); + const show = prepareMenu(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + for (const location of [undefined, { x: 1, y: 2 }]) { + showContextMenu([{ title: 'First' }], location, { highlightedIndex }); + } + + expect(show).toHaveBeenCalledTimes(1); + expect(show).toHaveBeenNthCalledWith(1, expect.objectContaining({ + items: [expect.objectContaining({ title: 'First' })], + location: { x: 1, y: 2 }, + highlightedIndex: undefined, + })); + + jest.advanceTimersByTime(50); + expect(show).toHaveBeenCalledTimes(2); + expect(show).toHaveBeenNthCalledWith(2, expect.objectContaining({ + items: [expect.objectContaining({ title: 'First' })], + location: { x: 80, y: 70 }, + highlightedIndex: undefined, + })); + }, + ); + + test('forwards an integer index for native fallback in an empty menu', () => { + const show = prepareMenu(); + showContextMenu([], { x: 1, y: 2 }, { highlightedIndex: 0 }); + expect(show).toHaveBeenCalledWith({ items: [], location: { x: 1, y: 2 }, highlightedIndex: 0 }); + }); + + test('leaves item state evaluation to native code after scrolling to the caret', () => { + jest.useFakeTimers(); + const show = prepareMenu(); + const state = jest.fn(() => ({ isEnabled: false })); + showContextMenu([{ title: 'First', state }], undefined, { highlightedIndex: 0 }); + expect(state).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(50); + expect(state).not.toHaveBeenCalled(); + expect(show).toHaveBeenCalledWith(expect.objectContaining({ highlightedIndex: 0 })); + }); + test('falls back to document geometry when no editor has focus', () => { jest.useFakeTimers(); const show = prepareMenu(); diff --git a/CoreEditor/yarn.lock b/CoreEditor/yarn.lock index ad18814f6..c09131f0a 100644 --- a/CoreEditor/yarn.lock +++ b/CoreEditor/yarn.lock @@ -4178,9 +4178,9 @@ __metadata: languageName: node linkType: hard -"markedit-api@https://github.com/MarkEdit-app/MarkEdit-api#v0.37.0": - version: 0.37.0 - resolution: "markedit-api@https://github.com/MarkEdit-app/MarkEdit-api.git#commit=7bce1077f0471a7b92d63e7e3ee0c084cea23c61" +"markedit-api@https://github.com/MarkEdit-app/MarkEdit-api#v0.38.0": + version: 0.38.0 + resolution: "markedit-api@https://github.com/MarkEdit-app/MarkEdit-api.git#commit=3f8f22bcb1067c00e1c22ae425a06ed8da710d89" peerDependencies: "@codemirror/autocomplete": ^6.0.0 "@codemirror/commands": ^6.0.0 @@ -4193,7 +4193,7 @@ __metadata: "@lezer/highlight": ^1.0.0 "@lezer/lr": ^1.0.0 "@lezer/markdown": ^1.0.0 - checksum: 10c0/f3b25689cc34e12c8ece581e7bcfeed43b343f64892c21cf52e86a7ffa4d5e2d9abb608f957bcb660fcebb4300b85da25fc1ddcdc3927732b052bc76b3251906 + checksum: 10c0/15973f74e46056ebe74b3f9bac8fc250b8acafc226e0f363576e82016d7492c9ba54f017f38d10c5ed21727dd286f2022e6b591de51c6a619b189aae5f8a5e26 languageName: node linkType: hard @@ -4225,7 +4225,7 @@ __metadata: globals: "npm:^15.15.0" jest: "npm:^30.2.0" jest-environment-jsdom: "npm:^30.2.0" - markedit-api: "https://github.com/MarkEdit-app/MarkEdit-api#v0.37.0" + markedit-api: "https://github.com/MarkEdit-app/MarkEdit-api#v0.38.0" ts-gyb: "npm:^0.12.0" ts-jest: "npm:^29.4.5" tslib: "npm:^2.6.2" diff --git a/MarkEdit.xcodeproj/project.pbxproj b/MarkEdit.xcodeproj/project.pbxproj index 0334c50bb..047ad1f55 100644 --- a/MarkEdit.xcodeproj/project.pbxproj +++ b/MarkEdit.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 0A1100013010000000000001 /* QuickSearchUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0A1100013010000000000002 /* QuickSearchUI */; }; + 0A1100013010000000000003 /* AppDelegate+QuickSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1100013010000000000004 /* AppDelegate+QuickSearch.swift */; }; + 0A1100023010000000000001 /* QuickSearchCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0A1100023010000000000002 /* QuickSearchCore */; }; 84FB00F02C6C4AED00850E94 /* AppWritingTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FB00EF2C6C4AED00850E94 /* AppWritingTools.swift */; }; 8701D0A42CC12321006AF8C2 /* FileVersion in Frameworks */ = {isa = PBXBuildFile; productRef = 8701D0A32CC12321006AF8C2 /* FileVersion */; }; 8703C175303E7DD300A896D0 /* QuickLookWindowSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8703C171303E7DCF00A896D0 /* QuickLookWindowSize.swift */; }; @@ -24,9 +27,6 @@ 870F17682985285B00003DA7 /* EditorViewController+Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 870F17672985285B00003DA7 /* EditorViewController+Config.swift */; }; 870F177D29855CA500003DA7 /* MarkEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 870F177C29855CA500003DA7 /* MarkEditKit */; }; 870F178E2985775B00003DA7 /* SharedUI in Frameworks */ = {isa = PBXBuildFile; productRef = 870F178D2985775B00003DA7 /* SharedUI */; }; - 0A1100013010000000000001 /* QuickSearchUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0A1100013010000000000002 /* QuickSearchUI */; }; - 0A1100023010000000000001 /* QuickSearchCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0A1100023010000000000002 /* QuickSearchCore */; }; - 0A1100013010000000000003 /* AppDelegate+QuickSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1100013010000000000004 /* AppDelegate+QuickSearch.swift */; }; 870F17902985775B00003DA7 /* AppKitExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 870F178F2985775B00003DA7 /* AppKitExtensions */; }; 870F17962985775B00003DA7 /* SettingsUI in Frameworks */ = {isa = PBXBuildFile; productRef = 870F17952985775B00003DA7 /* SettingsUI */; }; 87132AA32C188FE900FD262B /* AppShortcuts.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 87132AA22C188FE900FD262B /* AppShortcuts.xcstrings */; }; @@ -95,7 +95,6 @@ 87AF5957302379C4006E8C6A /* AppRelauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87AF5956302379C3006E8C6A /* AppRelauncher.swift */; }; 87B000000000000000000102 /* BookmarkStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B000000000000000000101 /* BookmarkStore.swift */; }; 87B000000000000000000104 /* BookmarkStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B000000000000000000103 /* BookmarkStoreTests.swift */; }; - 87D700000000000000000001 /* QuickSearchMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D700000000000000000002 /* QuickSearchMenuTests.swift */; }; 87B0D1022F1A4C0100B0D101 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 87B0D1012F1A4C0100B0D101 /* Localizable.xcstrings */; }; 87B3ED5A2CBCA97D001FA6CE /* AppDocumentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B3ED592CBCA97D001FA6CE /* AppDocumentController.swift */; }; 87B3ED5D2CBCADD6001FA6CE /* NSDocumentController+Extras.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B3ED5C2CBCADD6001FA6CE /* NSDocumentController+Extras.swift */; }; @@ -105,6 +104,8 @@ 87BEF30129A88F6800596E17 /* AppCustomization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87BEF30029A88F6800596E17 /* AppCustomization.swift */; }; 87C600000000000000000001 /* EditorDocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C600000000000000000002 /* EditorDocumentTests.swift */; }; 87CC48FD29CC01E100BE1441 /* TextBundle in Frameworks */ = {isa = PBXBuildFile; productRef = 87CC48FC29CC01E100BE1441 /* TextBundle */; }; + 87D700000000000000000001 /* QuickSearchMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D700000000000000000002 /* QuickSearchMenuTests.swift */; }; + 87D800000000000000000001 /* ContextMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D800000000000000000002 /* ContextMenuTests.swift */; }; 87D9322E2A925C6700B20D84 /* EditorReplaceTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D9322D2A925C6700B20D84 /* EditorReplaceTextField.swift */; }; 87DA0000000000000000E00B /* UpdateInstaller.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 87DA0000000000000000E000 /* UpdateInstaller.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 87DA0000000000000000E020 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DA0000000000000000E010 /* main.swift */; }; @@ -189,6 +190,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0A1100013010000000000004 /* AppDelegate+QuickSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+QuickSearch.swift"; sourceTree = ""; }; 84FB00EF2C6C4AED00850E94 /* AppWritingTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppWritingTools.swift; sourceTree = ""; }; 8703C171303E7DCF00A896D0 /* QuickLookWindowSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookWindowSize.swift; sourceTree = ""; }; 87067E2629755D5C0052E795 /* EditorStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorStatusView.swift; sourceTree = ""; }; @@ -276,7 +278,6 @@ 87AF5956302379C3006E8C6A /* AppRelauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRelauncher.swift; sourceTree = ""; }; 87B000000000000000000101 /* BookmarkStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkStore.swift; sourceTree = ""; }; 87B000000000000000000103 /* BookmarkStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkStoreTests.swift; sourceTree = ""; }; - 87D700000000000000000002 /* QuickSearchMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSearchMenuTests.swift; sourceTree = ""; }; 87B0D1012F1A4C0100B0D101 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 87B3ED592CBCA97D001FA6CE /* AppDocumentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDocumentController.swift; sourceTree = ""; }; 87B3ED5C2CBCADD6001FA6CE /* NSDocumentController+Extras.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSDocumentController+Extras.swift"; sourceTree = ""; }; @@ -287,6 +288,8 @@ 87C3CBFA29545944002A3436 /* MarkEditKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MarkEditKit; sourceTree = ""; }; 87C600000000000000000002 /* EditorDocumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorDocumentTests.swift; sourceTree = ""; }; 87C600000000000000000004 /* MarkEditMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MarkEditMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 87D700000000000000000002 /* QuickSearchMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSearchMenuTests.swift; sourceTree = ""; }; + 87D800000000000000000002 /* ContextMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuTests.swift; sourceTree = ""; }; 87D9322D2A925C6700B20D84 /* EditorReplaceTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorReplaceTextField.swift; sourceTree = ""; }; 87DA0000000000000000E000 /* UpdateInstaller.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = UpdateInstaller.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; 87DA0000000000000000E010 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; @@ -307,7 +310,6 @@ B0E02F672D8F77A50060DEA5 /* MarkEdit.sdef */ = {isa = PBXFileReference; lastKnownFileType = text; path = MarkEdit.sdef; sourceTree = ""; }; CC000002300000000000001B /* EditorDocument+ClosedTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EditorDocument+ClosedTab.swift"; sourceTree = ""; }; CC000003300000000000001B /* AppDelegate+ClosedTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+ClosedTab.swift"; sourceTree = ""; }; - 0A1100013010000000000004 /* AppDelegate+QuickSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+QuickSearch.swift"; sourceTree = ""; }; CC000004300000000000001B /* EditorSelectionHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorSelectionHistory.swift; sourceTree = ""; }; CC000006300000000000001B /* EditorClosedTabHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorClosedTabHistory.swift; sourceTree = ""; }; EE100007EEEE000000000007 /* ExtensionInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionInstaller.swift; sourceTree = ""; }; @@ -727,6 +729,7 @@ 87C600000000000000000002 /* EditorDocumentTests.swift */, 87B000000000000000000103 /* BookmarkStoreTests.swift */, 87D700000000000000000002 /* QuickSearchMenuTests.swift */, + 87D800000000000000000002 /* ContextMenuTests.swift */, ); path = Tests; sourceTree = ""; @@ -1082,6 +1085,7 @@ 87C600000000000000000001 /* EditorDocumentTests.swift in Sources */, 87B000000000000000000104 /* BookmarkStoreTests.swift in Sources */, 87D700000000000000000001 /* QuickSearchMenuTests.swift in Sources */, + 87D800000000000000000001 /* ContextMenuTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MarkEditKit/Sources/Bridge/Native/Generated/NativeModuleAPI.swift b/MarkEditKit/Sources/Bridge/Native/Generated/NativeModuleAPI.swift index f44e09136..902adb7e3 100644 --- a/MarkEditKit/Sources/Bridge/Native/Generated/NativeModuleAPI.swift +++ b/MarkEditKit/Sources/Bridge/Native/Generated/NativeModuleAPI.swift @@ -17,7 +17,7 @@ public protocol NativeModuleAPI: NativeModule { func closeDocument() async -> Bool func recentDocumentPaths() async -> [String] func addMainMenuItems(items: [WebMenuItem]) - func showContextMenu(items: [WebMenuItem], location: WebPoint) + func showContextMenu(items: [WebMenuItem], location: WebPoint, highlightedIndex: Int?) func showAlert(title: String?, message: String?, buttons: [String]?) async -> Int func showTextBox(title: String?, placeholder: String?, defaultValue: String?) async -> String? func showOpenPanel(options: OpenPanelOptions) async -> [String]? @@ -191,11 +191,13 @@ final class NativeBridgeAPI: NativeBridge { struct Message: Decodable { var items: [WebMenuItem] var location: WebPoint + var highlightedIndex: Int? init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: BridgeFieldKey.self) items = try container.value("items") location = try container.value("location") + highlightedIndex = try container.value("highlightedIndex") } } @@ -207,7 +209,7 @@ final class NativeBridgeAPI: NativeBridge { return .failure(error) } - module.showContextMenu(items: message.items, location: message.location) + module.showContextMenu(items: message.items, location: message.location, highlightedIndex: message.highlightedIndex) return .success(nil) } diff --git a/MarkEditKit/Sources/Bridge/Native/Modules/EditorModuleAPI.swift b/MarkEditKit/Sources/Bridge/Native/Modules/EditorModuleAPI.swift index a95e4c6b7..055b0ec1b 100644 --- a/MarkEditKit/Sources/Bridge/Native/Modules/EditorModuleAPI.swift +++ b/MarkEditKit/Sources/Bridge/Native/Modules/EditorModuleAPI.swift @@ -21,7 +21,7 @@ public protocol EditorModuleAPIDelegate: AnyObject { func editorAPISaveDocument(_ sender: EditorModuleAPI) async -> Bool func editorAPICloseDocument(_ sender: EditorModuleAPI) -> Bool func editorAPI(_ sender: EditorModuleAPI, addMainMenuItems items: [(String, WebMenuItem)]) - func editorAPI(_ sender: EditorModuleAPI, showContextMenu items: [WebMenuItem], location: WebPoint) + func editorAPI(_ sender: EditorModuleAPI, showContextMenu items: [WebMenuItem], location: WebPoint, highlightedIndex: Int?) func editorAPI( _ sender: EditorModuleAPI, alertWith title: String?, @@ -80,8 +80,8 @@ public final class EditorModuleAPI: NativeModuleAPI { }) } - public func showContextMenu(items: [WebMenuItem], location: WebPoint) { - delegate?.editorAPI(self, showContextMenu: items, location: location) + public func showContextMenu(items: [WebMenuItem], location: WebPoint, highlightedIndex: Int?) { + delegate?.editorAPI(self, showContextMenu: items, location: location, highlightedIndex: highlightedIndex) } public func showAlert(title: String?, message: String?, buttons: [String]?) async -> Int { diff --git a/MarkEditKit/Tests/MoveFileTests.swift b/MarkEditKit/Tests/MoveFileTests.swift index 7b0095a7f..3a302a228 100644 --- a/MarkEditKit/Tests/MoveFileTests.swift +++ b/MarkEditKit/Tests/MoveFileTests.swift @@ -163,7 +163,7 @@ private extension MoveFileTests { func editorAPISaveDocument(_ sender: EditorModuleAPI) async -> Bool { false } func editorAPICloseDocument(_ sender: EditorModuleAPI) -> Bool { false } func editorAPI(_ sender: EditorModuleAPI, addMainMenuItems items: [(String, WebMenuItem)]) {} - func editorAPI(_ sender: EditorModuleAPI, showContextMenu items: [WebMenuItem], location: WebPoint) {} + func editorAPI(_ sender: EditorModuleAPI, showContextMenu items: [WebMenuItem], location: WebPoint, highlightedIndex: Int?) {} func editorAPI( _ sender: EditorModuleAPI, alertWith title: String?, message: String?, buttons: [String]? ) async -> Int { 0 } diff --git a/MarkEditMac/Modules/Tests/FileVersionTests.swift b/MarkEditMac/Modules/Tests/FileVersionTests.swift index 77822879b..2c12f591a 100644 --- a/MarkEditMac/Modules/Tests/FileVersionTests.swift +++ b/MarkEditMac/Modules/Tests/FileVersionTests.swift @@ -175,7 +175,7 @@ private final class EditorModuleAPIDelegateStub: EditorModuleAPIDelegate { func editorAPISaveDocument(_ sender: EditorModuleAPI) async -> Bool { true } func editorAPICloseDocument(_ sender: EditorModuleAPI) -> Bool { true } func editorAPI(_ sender: EditorModuleAPI, addMainMenuItems items: [(String, WebMenuItem)]) {} - func editorAPI(_ sender: EditorModuleAPI, showContextMenu items: [WebMenuItem], location: WebPoint) {} + func editorAPI(_ sender: EditorModuleAPI, showContextMenu items: [WebMenuItem], location: WebPoint, highlightedIndex: Int?) {} func editorAPI( _ sender: EditorModuleAPI, alertWith title: String?, diff --git a/MarkEditMac/Sources/Editor/Controllers/EditorViewController+Delegate.swift b/MarkEditMac/Sources/Editor/Controllers/EditorViewController+Delegate.swift index 2d3e9b77d..ef1128414 100644 --- a/MarkEditMac/Sources/Editor/Controllers/EditorViewController+Delegate.swift +++ b/MarkEditMac/Sources/Editor/Controllers/EditorViewController+Delegate.swift @@ -388,8 +388,8 @@ extension EditorViewController: EditorModuleAPIDelegate { addMainMenuItems(items: items) } - func editorAPI(_ sender: EditorModuleAPI, showContextMenu items: [WebMenuItem], location: WebPoint) { - showContextMenu(items: items, location: location.cgPoint) + func editorAPI(_ sender: EditorModuleAPI, showContextMenu items: [WebMenuItem], location: WebPoint, highlightedIndex: Int?) { + showContextMenu(items: items, location: location.cgPoint, highlightedIndex: highlightedIndex) } func editorAPI( diff --git a/MarkEditMac/Sources/Editor/Controllers/EditorViewController+UI.swift b/MarkEditMac/Sources/Editor/Controllers/EditorViewController+UI.swift index d004b7928..3f85e816a 100644 --- a/MarkEditMac/Sources/Editor/Controllers/EditorViewController+UI.swift +++ b/MarkEditMac/Sources/Editor/Controllers/EditorViewController+UI.swift @@ -434,12 +434,45 @@ extension EditorViewController { resetUserDefinedMenuItems() } - func showContextMenu(items: [WebMenuItem], location: CGPoint) { + func showContextMenu(items: [WebMenuItem], location: CGPoint, highlightedIndex: Int?) { let menu = createMenu(items: items, handler: bridge.api.handleContextMenuAction) menu.identifier = EditorWebView.userDefinedContextMenuID - NSCursor.arrow.push() - menu.popUp(positioning: nil, at: location, in: webView) + let highlightedItem: NSMenuItem? = { + guard let highlightedIndex, menu.items.indices.contains(highlightedIndex) else { + return nil + } + + menu.update() + let item = menu.items[highlightedIndex] + return item.isEnabled && !item.isSeparatorItem ? item : nil + }() + + let popUp = { (item: NSMenuItem?) in + NSCursor.arrow.push() + menu.popUp(positioning: item, at: location, in: self.webView) + } + + guard let item = highlightedItem as? UserDefinedMenuItem, + let stateGetterID = item.stateGetterID else { + return popUp(highlightedItem) + } + + Task { + var itemToHighlight: NSMenuItem? + do { + let state = try await bridge.api.getMenuItemState(id: stateGetterID) + itemToHighlight = state.isEnabled == false ? nil : item + } catch { + Logger.log(.error, "Failed to retrieve context menu item state: \(error)") + } + + guard view.window != nil else { + return Logger.log(.debug, "Context menu dismissed because its editor is no longer in a window") + } + + popUp(itemToHighlight) + } } func showAlert(title: String?, message: String?, buttons: [String]?) async -> NSApplication.ModalResponse { diff --git a/MarkEditMac/Tests/ContextMenuTests.swift b/MarkEditMac/Tests/ContextMenuTests.swift new file mode 100644 index 000000000..72a90fd65 --- /dev/null +++ b/MarkEditMac/Tests/ContextMenuTests.swift @@ -0,0 +1,132 @@ +// +// ContextMenuTests.swift +// +// Created by cyan on 10/13/26. +// + +import AppKit +import MarkEditKit +import XCTest +@testable import MarkEdit + +@MainActor +final class ContextMenuTests: XCTestCase { + func testContextMenuAPIHighlightsRequestedItem() async throws { + let window = NSPanel( + contentRect: NSRect(x: 20, y: 20, width: 600, height: 400), + styleMask: [.titled, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + let editor = EditorViewController(preloadDelay: 60) + window.contentViewController = editor + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + let api = EditorModuleAPI(delegate: editor) + let cases: [(index: Int?, expected: Int?)] = [ + (nil, nil), (0, 0), (2, 2), (1, nil), (3, nil), (-1, nil), (100, nil), + ] + + for testCase in cases { + var parameters: [String: Any] = [ + "items": [ + ["title": "First", "separator": false, "actionID": "first"], + ["separator": true], + ["title": "Last", "separator": false, "actionID": "last"], + ["title": "Disabled", "separator": false], + ], + "location": ["x": 20, "y": 20], + ] + + parameters["highlightedIndex"] = testCase.index + try await checkHighlight(in: editor, expectedIndex: testCase.expected) { + let result = await api.bridge.invoke( + method: "showContextMenu", + parameters: try JSONSerialization.data(withJSONObject: parameters) + ) + + _ = try XCTUnwrap(result).get() + } + } + } + + func testContextMenuAwaitsJavaScriptItemState() async throws { + let window = NSPanel( + contentRect: NSRect(x: 20, y: 20, width: 600, height: 400), + styleMask: [.titled, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + let editor = EditorViewController() + window.contentViewController = editor + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + let deadline = ContinuousClock.now + .seconds(10) + while !editor.hasFinishedLoading, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertTrue(editor.hasFinishedLoading) + _ = try await editor.bridge.core.resetEditor(text: "Test", selectionRange: nil, documentChanged: true) + + let cases: [(stateBody: String, expected: Int?)] = [ + ("return {};", 0), + ("return { isEnabled: true };", 0), + ("return { isEnabled: false };", nil), + ("throw new Error('Expected state failure');", nil), + ] + + for testCase in cases { + try await checkHighlight(in: editor, expectedIndex: testCase.expected) { + _ = try await editor.webView.evaluateJavaScript(""" + MarkEdit.showContextMenu([ + { title: 'First', action() {}, state() { \(testCase.stateBody) } } + ], { x: 20, y: 20 }, { highlightedIndex: 0 }); + true; + """) + } + } + } + + private func checkHighlight( + in editor: EditorViewController, + expectedIndex: Int?, + showMenu: () async throws -> Void + ) async throws { + let checked = expectation(description: "Checked context menu highlight") + let timer = Timer( + timeInterval: 0.1, + target: self, + selector: #selector(checkContextMenuHighlight(_:)), + userInfo: (editor, expectedIndex, checked), + repeats: false + ) + + RunLoop.main.add(timer, forMode: .eventTracking) + defer { timer.invalidate() } + + try await showMenu() + await fulfillment(of: [checked], timeout: 2) + } + + @objc private func checkContextMenuHighlight(_ timer: Timer) { + guard let (editor, expectedIndex, checked) = timer.userInfo as? (EditorViewController, Int?, XCTestExpectation), + let menu = editor.presentedMenu else { + XCTFail("Missing context menu") + return + } + + if let expectedIndex { + XCTAssertIdentical(menu.highlightedItem, menu.items[expectedIndex]) + } else { + XCTAssertNil(menu.highlightedItem) + } + + checked.fulfill() + menu.cancelTracking() + } +}