authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-10-02 05:37:45+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 21:37:45-07:00
log2731929610ecb64617ff5e066980302da328f658
tree5121be704e7ca8499b0f684195df04ed49011594
parent20012ebd80b06a4c10ab0e78893822e7f873b2a8
signature Signed by PGP key B5690EEEBB952194

fix(web): group composer undo the way the Lexical composer did (#14674)


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";
59import {
60 COMPOSER_UNDO_GROUP_DELAY,
61 type ComposerChangeKind,
62 groupUndoByChangeKind,
63 markAsClipboardEdit,
64} from "~/composer-undo-grouping";
59import { collectInlineContextIds } from "~/lib/composerContextReferences";65import { collectInlineContextIds } from "~/lib/composerContextReferences";
60import { cn, isMacPlatform } from "~/lib/utils";66import { cn, isMacPlatform } from "~/lib/utils";
61import { basenameOfPath } from "~/pierre-icons";67import { 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}
588594
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 */
601const 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
589function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {615function 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 @@
1import { getSchema } from "@tiptap/core";
2import { history, undo } from "@tiptap/pm/history";
3import { EditorState, TextSelection, type Transaction } from "@tiptap/pm/state";
4import StarterKit from "@tiptap/starter-kit";
5import { describe, expect, it } from "vite-plus/test";
6
7import {
8 COMPOSER_UNDO_GROUP_DELAY,
9 type ComposerChangeKind,
10 groupUndoByChangeKind,
11 markAsClipboardEdit,
12} from "./composer-undo-grouping";
13
14const 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 */
21function 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
101describe("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 @@
1import { closeHistory, isHistoryTransaction } from "@tiptap/pm/history";
2import type { Transaction } from "@tiptap/pm/state";
3import { 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 */
15export const COMPOSER_UNDO_GROUP_DELAY = 1000;
16
17export 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 */
26export function markAsClipboardEdit(tr: Transaction, event: "paste" | "cut"): Transaction {
27 return tr.setMeta("uiEvent", event);
28}
29
30function 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 */
55export 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}