-
Notifications
You must be signed in to change notification settings - Fork 114
fix(editor): make undo/redo actually apply #439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
1e2cf87
fix(editor): make undo/redo actually apply
EtienneLescot f85a82d
fix(editor): record undo history only after a write lands, and force …
EtienneLescot 643c1cf
fix(editor): make every write name its own history decision, wrapper …
EtienneLescot a60552c
fix(editor): point the modal-guard test at the module that still expo…
EtienneLescot 6cacc35
fix(undo): close the two holes in the write audit's guarantee
EtienneLescot 2db57e3
fix(editor): stop a drag's snapshot outliving the drag, and the project
EtienneLescot db65171
test(editor): say what the write audit checks, and stop claiming the …
EtienneLescot b7ded2f
fix(editor): give useTimeline's two drag commits the guard the other …
EtienneLescot c808e62
test(editor): count the third writer the audit could not see
EtienneLescot 5755345
docs(editor): fix the mount count, and say where the parity stops
EtienneLescot fc38f17
docs(editor): drop the delete route from the reachability comment
EtienneLescot a4f21da
fix(editor): drop an asset import the user has already moved on from
EtienneLescot ef3fb00
test(editor): follow main's Edit Clip Apply into the write audit
EtienneLescot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| // Regression cover for the macOS half of #433. | ||
| // | ||
| // The Edit menu used to carry `role: "undo"` / `role: "redo"` with | ||
| // `registerAccelerator: false`. That field is documented `@platform linux,win32`, | ||
| // so on darwin it does nothing at all: AppKit still matches the menu's Cmd+Z key | ||
| // equivalent inside `-[NSApplication sendEvent:]`, before the key event reaches | ||
| // the web contents, and the editor's own keydown handler never runs. | ||
| // | ||
| // These tests pin the shape that actually reaches the renderer on every platform: | ||
| // an explicit accelerator and a click that dispatches to the editor -- and, in the | ||
| // second describe, where that dispatch actually lands. Dropping the roles took | ||
| // `webContents.undo()` away from every window, so the non-editor fallback is not a | ||
| // detail: it is the half of the design that keeps the launch and notes windows | ||
| // working. | ||
|
|
||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { | ||
| buildEditMenuSubmenu, | ||
| type EditorUndoRedoChannel, | ||
| routeEditorUndoRedo, | ||
| type UndoRedoWindow, | ||
| } from "./edit-menu"; | ||
|
|
||
| function build() { | ||
| const dispatch = vi.fn<(channel: EditorUndoRedoChannel) => void>(); | ||
| const items = buildEditMenuSubmenu({ | ||
| label: (_key, fallback) => fallback, | ||
| dispatch, | ||
| }); | ||
| return { items, dispatch }; | ||
| } | ||
|
|
||
| describe("buildEditMenuSubmenu", () => { | ||
| it("owns Cmd+Z itself instead of leaning on registerAccelerator", () => { | ||
| const { items } = build(); | ||
| const undoItem = items.find((i) => i.label === "Undo"); | ||
|
|
||
| expect(undoItem?.accelerator).toBe("CmdOrCtrl+Z"); | ||
| // The two things that made the previous version a no-op on macOS. | ||
| expect(undoItem?.role).toBeUndefined(); | ||
| expect(undoItem?.registerAccelerator).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("owns Shift+Cmd+Z for redo on the same terms", () => { | ||
| const { items } = build(); | ||
| const redoItem = items.find((i) => i.label === "Redo"); | ||
|
|
||
| expect(redoItem?.accelerator).toBe("Shift+CmdOrCtrl+Z"); | ||
| expect(redoItem?.role).toBeUndefined(); | ||
| expect(redoItem?.registerAccelerator).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("routes both to the editor renderer, which owns the document's undo stack", () => { | ||
| // `webContents.undo()` -- what the roles ran -- is the WEB EDITING undo. It does | ||
| // nothing outside a focused text field, so on macOS Cmd+Z was swallowed by a menu | ||
| // item that could not have serviced it anyway. | ||
| const { items, dispatch } = build(); | ||
|
|
||
| items | ||
| .find((i) => i.label === "Undo") | ||
| ?.click?.( | ||
| // The click signature carries a menu item, a window and the event; none of | ||
| // them are read here. | ||
| undefined as never, | ||
| undefined as never, | ||
| undefined as never, | ||
| ); | ||
| expect(dispatch).toHaveBeenCalledWith("menu-undo"); | ||
|
|
||
| items | ||
| .find((i) => i.label === "Redo") | ||
| ?.click?.(undefined as never, undefined as never, undefined as never); | ||
| expect(dispatch).toHaveBeenCalledWith("menu-redo"); | ||
| }); | ||
|
|
||
| it("leaves the clipboard items as roles", () => { | ||
| // They act on the focused text selection, which is exactly what the roles do -- | ||
| // and nothing in the editor shadows them. | ||
| const { items } = build(); | ||
| expect(items.map((i) => i.role).filter(Boolean)).toEqual(["cut", "copy", "paste", "selectAll"]); | ||
| }); | ||
| }); | ||
|
|
||
| function editorWindow() { | ||
| const webContents = { | ||
| send: vi.fn<(channel: EditorUndoRedoChannel) => void>(), | ||
| undo: vi.fn(), | ||
| redo: vi.fn(), | ||
| }; | ||
| const isDestroyed = vi.fn(() => false); | ||
| return { | ||
| window: { isDestroyed, webContents } satisfies UndoRedoWindow, | ||
| webContents, | ||
| isDestroyed, | ||
| }; | ||
| } | ||
|
|
||
| describe("routeEditorUndoRedo", () => { | ||
| it("hands the editor window the request over IPC", () => { | ||
| // The editor renderer owns the document's undo stack, and applies the text-field | ||
| // rule its own keydown path applies. `webContents.undo()` could not do either. | ||
| const target = editorWindow(); | ||
|
|
||
| routeEditorUndoRedo("menu-undo", target.window, () => true); | ||
| routeEditorUndoRedo("menu-redo", target.window, () => true); | ||
|
|
||
| expect(target.webContents.send.mock.calls).toEqual([["menu-undo"], ["menu-redo"]]); | ||
| expect(target.webContents.undo).not.toHaveBeenCalled(); | ||
| expect(target.webContents.redo).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("falls back to the web-editing undo when the focused window is not the editor", () => { | ||
| // The case the whole design rests on. Dropping `role: "undo"` took the menu's | ||
| // built-in `webContents.undo()` away from EVERY window, so the launch window and | ||
| // the notes window -- where the role was the right answer -- get it back here. | ||
| const target = editorWindow(); | ||
|
|
||
| routeEditorUndoRedo("menu-undo", target.window, () => false); | ||
| expect(target.webContents.undo).toHaveBeenCalledOnce(); | ||
| expect(target.webContents.redo).not.toHaveBeenCalled(); | ||
|
|
||
| routeEditorUndoRedo("menu-redo", target.window, () => false); | ||
| expect(target.webContents.redo).toHaveBeenCalledOnce(); | ||
|
|
||
| expect(target.webContents.send).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("does nothing when there is no window, or it has been destroyed", () => { | ||
| // Unlike `sendEditorMenuAction` this never creates one: Cmd+Z is not a request to | ||
| // open the editor. And the destroyed check runs before anything reads the window, | ||
| // which is why `isEditor` is a callback -- `webContents.getURL()` on a destroyed | ||
| // window throws. | ||
| const isEditor = vi.fn(() => true); | ||
| expect(() => routeEditorUndoRedo("menu-undo", null, isEditor)).not.toThrow(); | ||
|
|
||
| const target = editorWindow(); | ||
| target.isDestroyed.mockReturnValue(true); | ||
| routeEditorUndoRedo("menu-undo", target.window, isEditor); | ||
|
|
||
| expect(target.webContents.send).not.toHaveBeenCalled(); | ||
| expect(target.webContents.undo).not.toHaveBeenCalled(); | ||
| expect(isEditor).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| // The application menu's Edit submenu, split out of `main.ts` so it can be tested | ||
| // (same shape as `about.ts`). | ||
| // | ||
| // Undo/Redo are deliberately NOT `role: "undo"` / `role: "redo"`. | ||
| // | ||
| // Those roles run `webContents.undo()`, the WEB EDITING undo, which does nothing | ||
| // at all outside a focused text field. On macOS their Cmd+Z key equivalent is | ||
| // matched by AppKit inside `-[NSApplication sendEvent:]`, BEFORE the key event | ||
| // reaches the web contents, so the editor's own document-level handler | ||
| // (`useUndoRedoShortcuts`) never sees the keydown and Ctrl+Z silently did | ||
| // nothing (#433). | ||
| // | ||
| // `registerAccelerator: false` does not fix that. Electron annotates the field | ||
| // `@platform linux,win32` (see `MenuItemConstructorOptions` in electron.d.ts), | ||
| // so on darwin it is ignored outright and the menu keeps the key equivalent. | ||
| // And on Windows and Linux the roles were never the problem: menu accelerators | ||
| // there are dispatched from the unhandled-keyboard-event path, i.e. AFTER the | ||
| // renderer, which the renderer's own `preventDefault()` already suppresses. | ||
| // | ||
| // So the items own the accelerator on every platform and forward to the editor | ||
| // renderer, which applies exactly the rule its keydown path applies: a focused | ||
| // text field gets the browser's text undo, anything else gets the document undo. | ||
| // `dispatch` is what falls back to `webContents.undo()` when the focused window | ||
| // is not the editor at all. | ||
|
|
||
| import type { MenuItemConstructorOptions } from "electron"; | ||
|
|
||
| /** IPC channels the Edit menu forwards to the editor renderer. */ | ||
| export type EditorUndoRedoChannel = "menu-undo" | "menu-redo"; | ||
|
|
||
| export interface EditMenuOptions { | ||
| /** Localised label for `key`, falling back to `fallback` when untranslated. */ | ||
| label: (key: string, fallback: string) => string; | ||
| /** Route an undo/redo request to whichever window should service it. */ | ||
| dispatch: (channel: EditorUndoRedoChannel) => void; | ||
| } | ||
|
|
||
| /** The slice of `WebContents` the routing below touches. */ | ||
| export interface UndoRedoWebContents { | ||
| send: (channel: EditorUndoRedoChannel) => void; | ||
| undo: () => void; | ||
| redo: () => void; | ||
| } | ||
|
|
||
| /** The slice of `BrowserWindow` the routing below touches. */ | ||
| export interface UndoRedoWindow { | ||
| isDestroyed: () => boolean; | ||
| webContents: UndoRedoWebContents; | ||
| } | ||
|
|
||
| /** | ||
| * Deliver an undo/redo request to the window that should service it. | ||
| * | ||
| * Lives here rather than in `main.ts` for the reason `about.ts` does: `main.ts` | ||
| * calls `app.requestSingleInstanceLock()` at import time and cannot be loaded by a | ||
| * test, so anything left in it is untested by construction. | ||
| * | ||
| * Three cases, and the middle one is the whole reason the menu items dropped their | ||
| * `role`. There is no window, or it is gone: nothing to do — unlike | ||
| * `sendEditorMenuAction` this never CREATES an editor window, because Cmd+Z is not | ||
| * a request to open the editor. The window is not the editor — the launch window, | ||
| * the notes window — so the web-editing undo the `undo` role used to provide is the | ||
| * right one after all, and it is reached directly. Otherwise it is the editor, and | ||
| * the editor's renderer owns the document's undo stack. | ||
| * | ||
| * `isEditor` is a callback, not a flag, so nothing reads the window's URL before | ||
| * the destroyed check has run. | ||
| */ | ||
| export function routeEditorUndoRedo( | ||
| channel: EditorUndoRedoChannel, | ||
| window: UndoRedoWindow | null | undefined, | ||
| isEditor: () => boolean, | ||
| ): void { | ||
| if (!window || window.isDestroyed()) return; | ||
| if (!isEditor()) { | ||
| if (channel === "menu-undo") window.webContents.undo(); | ||
| else window.webContents.redo(); | ||
| return; | ||
| } | ||
| window.webContents.send(channel); | ||
| } | ||
|
|
||
| export function buildEditMenuSubmenu({ | ||
| label, | ||
| dispatch, | ||
| }: EditMenuOptions): MenuItemConstructorOptions[] { | ||
| return [ | ||
| { | ||
| label: label("actions.undo", "Undo"), | ||
| accelerator: "CmdOrCtrl+Z", | ||
| click: () => dispatch("menu-undo"), | ||
| }, | ||
| { | ||
| label: label("actions.redo", "Redo"), | ||
| accelerator: "Shift+CmdOrCtrl+Z", | ||
| click: () => dispatch("menu-redo"), | ||
| }, | ||
| { type: "separator" }, | ||
| // The clipboard roles keep theirs: they act on the focused text selection, | ||
| // which is precisely what `webContents.cut/copy/paste` do, and the editor has | ||
| // no document-level meaning for them to shadow. | ||
| { role: "cut", label: label("actions.cut", "Cut") }, | ||
| { role: "copy", label: label("actions.copy", "Copy") }, | ||
| { role: "paste", label: label("actions.paste", "Paste") }, | ||
| { role: "selectAll", label: label("actions.selectAll", "Select All") }, | ||
| ]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.