From 2731929610ecb64617ff5e066980302da328f658 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 2 Oct 2026 05:37:45 +0100 Subject: [PATCH] fix(web): group composer undo the way the Lexical composer did (#14674) --- .../components/ComposerPromptEditorTiptap.tsx | 49 +++++- apps/web/src/composer-undo-grouping.test.ts | 143 ++++++++++++++++++ apps/web/src/composer-undo-grouping.ts | 64 ++++++++ 3 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/composer-undo-grouping.test.ts create mode 100644 apps/web/src/composer-undo-grouping.ts diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index c8866e7fc41324db82ff1b1ba6959bc2330aea0d..aff70b58b1b81006f62107ac04b9573d5867774f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -56,6 +56,12 @@ import { serializeEditorDoc, type SkillMeta, } from "~/composer-rich-text-doc"; +import { + COMPOSER_UNDO_GROUP_DELAY, + type ComposerChangeKind, + groupUndoByChangeKind, + markAsClipboardEdit, +} from "~/composer-undo-grouping"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; @@ -586,6 +592,26 @@ export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { ); } +/** + * Starts a new undo step when the kind of change switches (typing, deleting, + * a paste or a store rewrite), the way the Lexical composer grouped undo. + * Runs as dispatch middleware because the grouping has to be decided before + * the history plugin applies the transaction. + */ +const ComposerUndoGroupingExtension = Extension.create< + Record, + { previous: ComposerChangeKind | null } +>({ + name: "composer-undo-grouping", + addStorage() { + return { previous: null }; + }, + dispatchTransaction({ transaction, next }) { + this.storage.previous = groupUndoByChangeKind(transaction, this.storage.previous); + next(transaction); + }, +}); + function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const { value, @@ -798,9 +824,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { gapcursor: false, trailingNode: false, code: false, + undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY }, // Plain mode has no marks: typed markers stay literal characters. ...(richText ? {} : { bold: false, italic: false, strike: false }), }), + ComposerUndoGroupingExtension, ComposerMentionExtension, ComposerSkillExtension, ComposerCitationExtension, @@ -1048,7 +1076,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const editorInstance = editorHolder.current; if (editorInstance) { insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => { - editorInstance.commands.insertContent(content); + // Tagged on the same transaction insertContent builds, so the + // paste is one undo step of its own. + editorInstance + .chain() + .command(({ tr }) => { + markAsClipboardEdit(tr, "paste"); + return true; + }) + .insertContent(content) + .run(); }); scrollTiptapCaretIntoView(editorInstance); } @@ -1309,7 +1346,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment)); } if (cut) { - editor.chain().focus().deleteSelection().run(); + editor + .chain() + .focus() + .command(({ tr }) => { + markAsClipboardEdit(tr, "cut"); + return true; + }) + .deleteSelection() + .run(); } }, [editor], diff --git a/apps/web/src/composer-undo-grouping.test.ts b/apps/web/src/composer-undo-grouping.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e65b18579e3afe71527b6a4c1e1ab7ee8a0a5d6a --- /dev/null +++ b/apps/web/src/composer-undo-grouping.test.ts @@ -0,0 +1,143 @@ +import { getSchema } from "@tiptap/core"; +import { history, undo } from "@tiptap/pm/history"; +import { EditorState, TextSelection, type Transaction } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vite-plus/test"; + +import { + COMPOSER_UNDO_GROUP_DELAY, + type ComposerChangeKind, + groupUndoByChangeKind, + markAsClipboardEdit, +} from "./composer-undo-grouping"; + +const schema = getSchema([StarterKit]); + +/** + * A composer-shaped editor state with the real history plugin, dispatching + * through the same grouping rule the composer's extension applies. Keystrokes + * land 80ms apart unless a gap says otherwise. + */ +function composer(text = "") { + let clock = 0; + let previous: ComposerChangeKind | null = null; + let state = EditorState.create({ + schema, + doc: schema.node("doc", null, [ + schema.node("paragraph", null, text ? [schema.text(text)] : []), + ]), + plugins: [history({ newGroupDelay: COMPOSER_UNDO_GROUP_DELAY })], + }); + const dispatch = (tr: Transaction) => { + tr.setTime(clock); + previous = groupUndoByChangeKind(tr, previous); + state = state.apply(tr); + }; + dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc))); + const api = { + text: () => state.doc.textContent, + wait: (ms: number) => ((clock += ms), api), + type: (s: string, gap = 80) => { + for (const ch of s) { + clock += gap; + dispatch(state.tr.insertText(ch)); + } + return api; + }, + backspace: (n: number) => { + for (let i = 0; i < n; i += 1) { + clock += 80; + const at = state.selection.from; + dispatch(state.tr.delete(at - 1, at)); + } + return api; + }, + /** + * The transaction the composer's `handlePaste` dispatches: a plain insert, + * tagged by `markAsClipboardEdit`. ProseMirror's own paste path would tag + * it, but the composer takes every text paste before that path runs. + */ + paste: (s: string) => { + clock += 80; + dispatch(markAsClipboardEdit(state.tr.insertText(s), "paste")); + return api; + }, + /** The composer's cut handler: a plain delete of the selection, tagged. */ + cut: (n: number) => { + clock += 80; + const at = state.selection.from; + dispatch(markAsClipboardEdit(state.tr.delete(at - n, at), "cut")); + return api; + }, + /** A store-driven rewrite: autocomplete inserting a chip, list continuation. */ + rewrite: (next: string) => { + clock += 80; + dispatch( + state.tr.replaceWith( + 0, + state.doc.content.size, + schema.node("paragraph", null, [schema.text(next)]), + ), + ); + dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc))); + return api; + }, + /** IME input replaces its own range on every keystroke. */ + compose: (s: string) => { + const start = state.selection.from; + for (let i = 1; i <= s.length; i += 1) { + clock += 80; + dispatch( + state.tr.insertText(s.slice(0, i), start, start + i - 1).setMeta("composition", 1), + ); + } + return api; + }, + undo: () => (undo(state, dispatch), api), + }; + return api; +} + +describe("composer undo grouping", () => { + it("keeps a continuous run of typing as one step", () => { + expect(composer().type("hello world").undo().text()).toBe(""); + }); + + it("starts a new step when typing turns into deleting, and back", () => { + const editor = composer().type("hello world").backspace(5).type("there"); + expect(editor.text()).toBe("hello there"); + expect(editor.undo().text()).toBe("hello "); + expect(editor.undo().text()).toBe("hello world"); + expect(editor.undo().text()).toBe(""); + }); + + it("gives a store rewrite its own step, and typing after it another", () => { + const editor = composer("draft ").type("one").rewrite("draft one @chip ").type("then"); + expect(editor.undo().text()).toBe("draft one @chip "); + expect(editor.undo().text()).toBe("draft one"); + expect(editor.undo().text()).toBe("draft "); + }); + + it("gives a paste its own step", () => { + const editor = composer().type("see ").paste("pasted text").type(" ok"); + expect(editor.undo().text()).toBe("see pasted text"); + expect(editor.undo().text()).toBe("see "); + }); + + it("gives a cut its own step between deletions", () => { + const editor = composer().type("hello world").backspace(1).cut(3).backspace(1); + expect(editor.text()).toBe("hello "); + expect(editor.undo().text()).toBe("hello w"); + expect(editor.undo().text()).toBe("hello worl"); + expect(editor.undo().text()).toBe("hello world"); + }); + + it("keeps IME composition as one run of typing", () => { + expect(composer().type("go ").compose("にほん").undo().text()).toBe(""); + }); + + it("merges typing across a short pause and splits it across a long one", () => { + expect(composer().type("hello").wait(700).type(" world").undo().text()).toBe(""); + expect(composer().type("hello").wait(1100).type(" world").undo().text()).toBe("hello"); + }); +}); diff --git a/apps/web/src/composer-undo-grouping.ts b/apps/web/src/composer-undo-grouping.ts new file mode 100644 index 0000000000000000000000000000000000000000..f895b8d7afc6029e1dbf4763640ae87a6c61f6ef --- /dev/null +++ b/apps/web/src/composer-undo-grouping.ts @@ -0,0 +1,64 @@ +import { closeHistory, isHistoryTransaction } from "@tiptap/pm/history"; +import type { Transaction } from "@tiptap/pm/state"; +import { ReplaceStep } from "@tiptap/pm/transform"; + +/** + * Undo grouping matching the Lexical composer it replaced. + * + * ProseMirror's history only starts a new undo step after a pause or when an + * edit is not next to the last one, so typing, backspacing and typing again + * without pausing is one step, and a store rewrite of the whole document + * (autocomplete inserting a chip, list continuation) makes every later edit + * "adjacent" and joins it. Lexical instead started a new step whenever the + * kind of change switched, and after one second without changes. + */ +export const COMPOSER_UNDO_GROUP_DELAY = 1000; + +export type ComposerChangeKind = "insert" | "delete" | "other"; + +/** + * Tags a transaction as a paste or cut, so it becomes its own undo step. + * ProseMirror tags these itself only on its built-in clipboard paths; the + * composer's `handlePaste` and cut handler replace those paths, so each of + * their dispatches must call this, or the change merges with the typing or + * deleting around it. + */ +export function markAsClipboardEdit(tr: Transaction, event: "paste" | "cut"): Transaction { + return tr.setMeta("uiEvent", event); +} + +function changeKindOf(tr: Transaction): ComposerChangeKind | null { + if (!tr.docChanged) return null; + const uiEvent = tr.getMeta("uiEvent"); + if (uiEvent === "paste" || uiEvent === "drop" || uiEvent === "cut") return "other"; + // IME input replaces its own range on each keystroke; it is still typing. + if (tr.getMeta("composition") != null) return "insert"; + let kind: ComposerChangeKind | null = null; + for (const step of tr.steps) { + if (!(step instanceof ReplaceStep)) return "other"; + const inserts = step.slice.size > 0; + const deletes = step.to > step.from; + const stepKind = inserts && !deletes ? "insert" : deletes && !inserts ? "delete" : "other"; + if (kind !== null && kind !== stepKind) return "other"; + kind = stepKind; + } + return kind; +} + +/** + * Closes the current undo step before `tr` when its kind of change differs + * from the last one, or when it is a one-off change such as a paste or a + * store rewrite, and returns the kind to remember for the next transaction. + * Undo and redo themselves count as one-off, so typing after them starts a + * fresh step. Must run before the transaction is applied. + */ +export function groupUndoByChangeKind( + tr: Transaction, + previous: ComposerChangeKind | null, +): ComposerChangeKind | null { + if (isHistoryTransaction(tr)) return "other"; + const kind = changeKindOf(tr); + if (kind === null) return previous; + if (previous !== null && (kind !== previous || kind === "other")) closeHistory(tr); + return kind; +} -- 2.54.0