Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ import {
serializeEditorDoc,
type SkillMeta,
} from "~/composer-rich-text-doc";
import {
COMPOSER_UNDO_GROUP_DELAY,
type ComposerChangeKind,
groupUndoByChangeKind,
markAsPaste,
} from "~/composer-undo-grouping";
import { collectInlineContextIds } from "~/lib/composerContextReferences";
import { cn, isMacPlatform } from "~/lib/utils";
import { basenameOfPath } from "~/pierre-icons";
Expand Down Expand Up @@ -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<string, never>,
{ 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }) => {
markAsPaste(tr);
return true;
})
.insertContent(content)
.run();
});
scrollTiptapCaretIntoView(editorInstance);
}
Expand Down
128 changes: 128 additions & 0 deletions apps/web/src/composer-undo-grouping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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,
markAsPaste,
} 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 `markAsPaste`. 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(markAsPaste(state.tr.insertText(s)));
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("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");
});
});
63 changes: 63 additions & 0 deletions apps/web/src/composer-undo-grouping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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, so it becomes its own undo step. ProseMirror
* tags pastes itself only on its built-in paste path; the composer's
* `handlePaste` takes every text paste before that path runs, so each of its
* dispatches must call this, or the paste merges with the typing around it.
*/
export function markAsPaste(tr: Transaction): Transaction {
return tr.setMeta("uiEvent", "paste");
}

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";
Comment thread
chrisdeeming marked this conversation as resolved.
// 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;
}
Loading