| author | |
| committer | |
| log | 2731929610ecb64617ff5e066980302da328f658 |
| tree | 5121be704e7ca8499b0f684195df04ed49011594 |
| parent | 20012ebd80b06a4c10ab0e78893822e7f873b2a8 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 254 insertions(+), 2 deletions(-)
apps/web/src/components/ComposerPromptEditorTiptap.tsx+47-2| ... | @@ -56,6 +56,12 @@ import { | ... | @@ -56,6 +56,12 @@ import { |
| 56 | serializeEditorDoc, | 56 | serializeEditorDoc, |
| 57 | type SkillMeta, | 57 | type SkillMeta, |
| 58 | } from "~/composer-rich-text-doc"; | 58 | } from "~/composer-rich-text-doc"; |
| 59 | import { | ||
| 60 | COMPOSER_UNDO_GROUP_DELAY, | ||
| 61 | type ComposerChangeKind, | ||
| 62 | groupUndoByChangeKind, | ||
| 63 | markAsClipboardEdit, | ||
| 64 | } from "~/composer-undo-grouping"; | ||
| 59 | import { collectInlineContextIds } from "~/lib/composerContextReferences"; | 65 | import { collectInlineContextIds } from "~/lib/composerContextReferences"; |
| 60 | import { cn, isMacPlatform } from "~/lib/utils"; | 66 | import { cn, isMacPlatform } from "~/lib/utils"; |
| 61 | import { basenameOfPath } from "~/pierre-icons"; | 67 | import { basenameOfPath } from "~/pierre-icons"; |
| ... | @@ -586,6 +592,26 @@ export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { | ... | @@ -586,6 +592,26 @@ export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { |
| 586 | ); | 592 | ); |
| 587 | } | 593 | } |
| 588 | 594 | ||
| 595 | /** | ||
| 596 | * Starts a new undo step when the kind of change switches (typing, deleting, | ||
| 597 | * a paste or a store rewrite), the way the Lexical composer grouped undo. | ||
| 598 | * Runs as dispatch middleware because the grouping has to be decided before | ||
| 599 | * the history plugin applies the transaction. | ||
| 600 | */ | ||
| 601 | const ComposerUndoGroupingExtension = Extension.create< | ||
| 602 | Record<string, never>, | ||
| 603 | { previous: ComposerChangeKind | null } | ||
| 604 | >({ | ||
| 605 | name: "composer-undo-grouping", | ||
| 606 | addStorage() { | ||
| 607 | return { previous: null }; | ||
| 608 | }, | ||
| 609 | dispatchTransaction({ transaction, next }) { | ||
| 610 | this.storage.previous = groupUndoByChangeKind(transaction, this.storage.previous); | ||
| 611 | next(transaction); | ||
| 612 | }, | ||
| 613 | }); | ||
| 614 | |||
| 589 | function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | 615 | function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 590 | const { | 616 | const { |
| 591 | value, | 617 | value, |
| ... | @@ -798,9 +824,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -798,9 +824,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 798 | gapcursor: false, | 824 | gapcursor: false, |
| 799 | trailingNode: false, | 825 | trailingNode: false, |
| 800 | code: false, | 826 | code: false, |
| 827 | undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY }, | ||
| 801 | // Plain mode has no marks: typed markers stay literal characters. | 828 | // Plain mode has no marks: typed markers stay literal characters. |
| 802 | ...(richText ? {} : { bold: false, italic: false, strike: false }), | 829 | ...(richText ? {} : { bold: false, italic: false, strike: false }), |
| 803 | }), | 830 | }), |
| 831 | ComposerUndoGroupingExtension, | ||
| 804 | ComposerMentionExtension, | 832 | ComposerMentionExtension, |
| 805 | ComposerSkillExtension, | 833 | ComposerSkillExtension, |
| 806 | ComposerCitationExtension, | 834 | ComposerCitationExtension, |
| ... | @@ -1048,7 +1076,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1048,7 +1076,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1048 | const editorInstance = editorHolder.current; | 1076 | const editorInstance = editorHolder.current; |
| 1049 | if (editorInstance) { | 1077 | if (editorInstance) { |
| 1050 | insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => { | 1078 | insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => { |
| 1051 | editorInstance.commands.insertContent(content); | 1079 | // Tagged on the same transaction insertContent builds, so the |
| 1080 | // paste is one undo step of its own. | ||
| 1081 | editorInstance | ||
| 1082 | .chain() | ||
| 1083 | .command(({ tr }) => { | ||
| 1084 | markAsClipboardEdit(tr, "paste"); | ||
| 1085 | return true; | ||
| 1086 | }) | ||
| 1087 | .insertContent(content) | ||
| 1088 | .run(); | ||
| 1052 | }); | 1089 | }); |
| 1053 | scrollTiptapCaretIntoView(editorInstance); | 1090 | scrollTiptapCaretIntoView(editorInstance); |
| 1054 | } | 1091 | } |
| ... | @@ -1309,7 +1346,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1309,7 +1346,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1309 | clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment)); | 1346 | clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment)); |
| 1310 | } | 1347 | } |
| 1311 | if (cut) { | 1348 | if (cut) { |
| 1312 | editor.chain().focus().deleteSelection().run(); | 1349 | editor |
| 1350 | .chain() | ||
| 1351 | .focus() | ||
| 1352 | .command(({ tr }) => { | ||
| 1353 | markAsClipboardEdit(tr, "cut"); | ||
| 1354 | return true; | ||
| 1355 | }) | ||
| 1356 | .deleteSelection() | ||
| 1357 | .run(); | ||
| 1313 | } | 1358 | } |
| 1314 | }, | 1359 | }, |
| 1315 | [editor], | 1360 | [editor], |
apps/web/src/composer-undo-grouping.test.ts created+143| ... | @@ -0,0 +1,143 @@ | ||
| 1 | import { getSchema } from "@tiptap/core"; | ||
| 2 | import { history, undo } from "@tiptap/pm/history"; | ||
| 3 | import { EditorState, TextSelection, type Transaction } from "@tiptap/pm/state"; | ||
| 4 | import StarterKit from "@tiptap/starter-kit"; | ||
| 5 | import { describe, expect, it } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | COMPOSER_UNDO_GROUP_DELAY, | ||
| 9 | type ComposerChangeKind, | ||
| 10 | groupUndoByChangeKind, | ||
| 11 | markAsClipboardEdit, | ||
| 12 | } from "./composer-undo-grouping"; | ||
| 13 | |||
| 14 | const schema = getSchema([StarterKit]); | ||
| 15 | |||
| 16 | /** | ||
| 17 | * A composer-shaped editor state with the real history plugin, dispatching | ||
| 18 | * through the same grouping rule the composer's extension applies. Keystrokes | ||
| 19 | * land 80ms apart unless a gap says otherwise. | ||
| 20 | */ | ||
| 21 | function composer(text = "") { | ||
| 22 | let clock = 0; | ||
| 23 | let previous: ComposerChangeKind | null = null; | ||
| 24 | let state = EditorState.create({ | ||
| 25 | schema, | ||
| 26 | doc: schema.node("doc", null, [ | ||
| 27 | schema.node("paragraph", null, text ? [schema.text(text)] : []), | ||
| 28 | ]), | ||
| 29 | plugins: [history({ newGroupDelay: COMPOSER_UNDO_GROUP_DELAY })], | ||
| 30 | }); | ||
| 31 | const dispatch = (tr: Transaction) => { | ||
| 32 | tr.setTime(clock); | ||
| 33 | previous = groupUndoByChangeKind(tr, previous); | ||
| 34 | state = state.apply(tr); | ||
| 35 | }; | ||
| 36 | dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc))); | ||
| 37 | const api = { | ||
| 38 | text: () => state.doc.textContent, | ||
| 39 | wait: (ms: number) => ((clock += ms), api), | ||
| 40 | type: (s: string, gap = 80) => { | ||
| 41 | for (const ch of s) { | ||
| 42 | clock += gap; | ||
| 43 | dispatch(state.tr.insertText(ch)); | ||
| 44 | } | ||
| 45 | return api; | ||
| 46 | }, | ||
| 47 | backspace: (n: number) => { | ||
| 48 | for (let i = 0; i < n; i += 1) { | ||
| 49 | clock += 80; | ||
| 50 | const at = state.selection.from; | ||
| 51 | dispatch(state.tr.delete(at - 1, at)); | ||
| 52 | } | ||
| 53 | return api; | ||
| 54 | }, | ||
| 55 | /** | ||
| 56 | * The transaction the composer's `handlePaste` dispatches: a plain insert, | ||
| 57 | * tagged by `markAsClipboardEdit`. ProseMirror's own paste path would tag | ||
| 58 | * it, but the composer takes every text paste before that path runs. | ||
| 59 | */ | ||
| 60 | paste: (s: string) => { | ||
| 61 | clock += 80; | ||
| 62 | dispatch(markAsClipboardEdit(state.tr.insertText(s), "paste")); | ||
| 63 | return api; | ||
| 64 | }, | ||
| 65 | /** The composer's cut handler: a plain delete of the selection, tagged. */ | ||
| 66 | cut: (n: number) => { | ||
| 67 | clock += 80; | ||
| 68 | const at = state.selection.from; | ||
| 69 | dispatch(markAsClipboardEdit(state.tr.delete(at - n, at), "cut")); | ||
| 70 | return api; | ||
| 71 | }, | ||
| 72 | /** A store-driven rewrite: autocomplete inserting a chip, list continuation. */ | ||
| 73 | rewrite: (next: string) => { | ||
| 74 | clock += 80; | ||
| 75 | dispatch( | ||
| 76 | state.tr.replaceWith( | ||
| 77 | 0, | ||
| 78 | state.doc.content.size, | ||
| 79 | schema.node("paragraph", null, [schema.text(next)]), | ||
| 80 | ), | ||
| 81 | ); | ||
| 82 | dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc))); | ||
| 83 | return api; | ||
| 84 | }, | ||
| 85 | /** IME input replaces its own range on every keystroke. */ | ||
| 86 | compose: (s: string) => { | ||
| 87 | const start = state.selection.from; | ||
| 88 | for (let i = 1; i <= s.length; i += 1) { | ||
| 89 | clock += 80; | ||
| 90 | dispatch( | ||
| 91 | state.tr.insertText(s.slice(0, i), start, start + i - 1).setMeta("composition", 1), | ||
| 92 | ); | ||
| 93 | } | ||
| 94 | return api; | ||
| 95 | }, | ||
| 96 | undo: () => (undo(state, dispatch), api), | ||
| 97 | }; | ||
| 98 | return api; | ||
| 99 | } | ||
| 100 | |||
| 101 | describe("composer undo grouping", () => { | ||
| 102 | it("keeps a continuous run of typing as one step", () => { | ||
| 103 | expect(composer().type("hello world").undo().text()).toBe(""); | ||
| 104 | }); | ||
| 105 | |||
| 106 | it("starts a new step when typing turns into deleting, and back", () => { | ||
| 107 | const editor = composer().type("hello world").backspace(5).type("there"); | ||
| 108 | expect(editor.text()).toBe("hello there"); | ||
| 109 | expect(editor.undo().text()).toBe("hello "); | ||
| 110 | expect(editor.undo().text()).toBe("hello world"); | ||
| 111 | expect(editor.undo().text()).toBe(""); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("gives a store rewrite its own step, and typing after it another", () => { | ||
| 115 | const editor = composer("draft ").type("one").rewrite("draft one @chip ").type("then"); | ||
| 116 | expect(editor.undo().text()).toBe("draft one @chip "); | ||
| 117 | expect(editor.undo().text()).toBe("draft one"); | ||
| 118 | expect(editor.undo().text()).toBe("draft "); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it("gives a paste its own step", () => { | ||
| 122 | const editor = composer().type("see ").paste("pasted text").type(" ok"); | ||
| 123 | expect(editor.undo().text()).toBe("see pasted text"); | ||
| 124 | expect(editor.undo().text()).toBe("see "); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it("gives a cut its own step between deletions", () => { | ||
| 128 | const editor = composer().type("hello world").backspace(1).cut(3).backspace(1); | ||
| 129 | expect(editor.text()).toBe("hello "); | ||
| 130 | expect(editor.undo().text()).toBe("hello w"); | ||
| 131 | expect(editor.undo().text()).toBe("hello worl"); | ||
| 132 | expect(editor.undo().text()).toBe("hello world"); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it("keeps IME composition as one run of typing", () => { | ||
| 136 | expect(composer().type("go ").compose("にほん").undo().text()).toBe(""); | ||
| 137 | }); | ||
| 138 | |||
| 139 | it("merges typing across a short pause and splits it across a long one", () => { | ||
| 140 | expect(composer().type("hello").wait(700).type(" world").undo().text()).toBe(""); | ||
| 141 | expect(composer().type("hello").wait(1100).type(" world").undo().text()).toBe("hello"); | ||
| 142 | }); | ||
| 143 | }); | ||
apps/web/src/composer-undo-grouping.ts created+64| ... | @@ -0,0 +1,64 @@ | ||
| 1 | import { closeHistory, isHistoryTransaction } from "@tiptap/pm/history"; | ||
| 2 | import type { Transaction } from "@tiptap/pm/state"; | ||
| 3 | import { ReplaceStep } from "@tiptap/pm/transform"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Undo grouping matching the Lexical composer it replaced. | ||
| 7 | * | ||
| 8 | * ProseMirror's history only starts a new undo step after a pause or when an | ||
| 9 | * edit is not next to the last one, so typing, backspacing and typing again | ||
| 10 | * without pausing is one step, and a store rewrite of the whole document | ||
| 11 | * (autocomplete inserting a chip, list continuation) makes every later edit | ||
| 12 | * "adjacent" and joins it. Lexical instead started a new step whenever the | ||
| 13 | * kind of change switched, and after one second without changes. | ||
| 14 | */ | ||
| 15 | export const COMPOSER_UNDO_GROUP_DELAY = 1000; | ||
| 16 | |||
| 17 | export type ComposerChangeKind = "insert" | "delete" | "other"; | ||
| 18 | |||
| 19 | /** | ||
| 20 | * Tags a transaction as a paste or cut, so it becomes its own undo step. | ||
| 21 | * ProseMirror tags these itself only on its built-in clipboard paths; the | ||
| 22 | * composer's `handlePaste` and cut handler replace those paths, so each of | ||
| 23 | * their dispatches must call this, or the change merges with the typing or | ||
| 24 | * deleting around it. | ||
| 25 | */ | ||
| 26 | export function markAsClipboardEdit(tr: Transaction, event: "paste" | "cut"): Transaction { | ||
| 27 | return tr.setMeta("uiEvent", event); | ||
| 28 | } | ||
| 29 | |||
| 30 | function changeKindOf(tr: Transaction): ComposerChangeKind | null { | ||
| 31 | if (!tr.docChanged) return null; | ||
| 32 | const uiEvent = tr.getMeta("uiEvent"); | ||
| 33 | if (uiEvent === "paste" || uiEvent === "drop" || uiEvent === "cut") return "other"; | ||
| 34 | // IME input replaces its own range on each keystroke; it is still typing. | ||
| 35 | if (tr.getMeta("composition") != null) return "insert"; | ||
| 36 | let kind: ComposerChangeKind | null = null; | ||
| 37 | for (const step of tr.steps) { | ||
| 38 | if (!(step instanceof ReplaceStep)) return "other"; | ||
| 39 | const inserts = step.slice.size > 0; | ||
| 40 | const deletes = step.to > step.from; | ||
| 41 | const stepKind = inserts && !deletes ? "insert" : deletes && !inserts ? "delete" : "other"; | ||
| 42 | if (kind !== null && kind !== stepKind) return "other"; | ||
| 43 | kind = stepKind; | ||
| 44 | } | ||
| 45 | return kind; | ||
| 46 | } | ||
| 47 | |||
| 48 | /** | ||
| 49 | * Closes the current undo step before `tr` when its kind of change differs | ||
| 50 | * from the last one, or when it is a one-off change such as a paste or a | ||
| 51 | * store rewrite, and returns the kind to remember for the next transaction. | ||
| 52 | * Undo and redo themselves count as one-off, so typing after them starts a | ||
| 53 | * fresh step. Must run before the transaction is applied. | ||
| 54 | */ | ||
| 55 | export function groupUndoByChangeKind( | ||
| 56 | tr: Transaction, | ||
| 57 | previous: ComposerChangeKind | null, | ||
| 58 | ): ComposerChangeKind | null { | ||
| 59 | if (isHistoryTransaction(tr)) return "other"; | ||
| 60 | const kind = changeKindOf(tr); | ||
| 61 | if (kind === null) return previous; | ||
| 62 | if (previous !== null && (kind !== previous || kind === "other")) closeHistory(tr); | ||
| 63 | return kind; | ||
| 64 | } | ||