From c30bd6bf2a1a0d76238616179d4aaf30c4e71bc2 Mon Sep 17 00:00:00 2001 From: sssssirius7 Date: Wed, 23 Sep 2026 23:54:24 +0800 Subject: [PATCH] fix(toolbar): isolate consecutive command history --- packages/core/src/editor.ts | 8 +- packages/core/src/types.ts | 11 +- packages/core/test/editor.test.ts | 16 ++ packages/plugin-toolbar/src/formatting.ts | 30 +- .../plugin-toolbar/src/toolbar-commands.ts | 26 +- packages/plugin-toolbar/src/toolbar-ui.ts | 5 +- .../test/plugin-toolbar.test.ts | 270 +++++++++++++++++- 7 files changed, 324 insertions(+), 42 deletions(-) diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 4d356ea2..46bacaf8 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -5,7 +5,7 @@ import { Annotation, EditorSelection, EditorState } from "@codemirror/state"; // no onChange emission, no AST reparse for the onChange pipeline. const silentDocChange = Annotation.define(); import { EditorView, keymap, dropCursor, lineNumbers, type Direction } from "@codemirror/view"; -import { indentWithTab, undo as cmUndo, redo as cmRedo } from "@codemirror/commands"; +import { indentWithTab, undo as cmUndo, redo as cmRedo, isolateHistory } from "@codemirror/commands"; import { closeBrackets } from "@codemirror/autocomplete"; import type { Root } from "mdast"; import type { Heading } from "mdast"; @@ -879,13 +879,17 @@ export function createEditor(config: EditorConfig): EditorAPI { replaceRange(from, to, insert, selection, opts) { if (destroyed) return; const silent = opts?.silent === true; + const annotations = [ + ...(silent ? [silentDocChange.of(true)] : []), + ...(opts?.isolateHistory ? [isolateHistory.of("full")] : []), + ]; view.dispatch({ changes: { from, to, insert }, selection: selection ? { anchor: selection.anchor, head: selection.head ?? selection.anchor } : undefined, scrollIntoView: true, - annotations: silent ? silentDocChange.of(true) : undefined, + annotations: annotations.length > 0 ? annotations : undefined, }); if (silent) { const next = view.state.doc.toString(); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b40ebffd..bba159f5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -417,8 +417,13 @@ export interface EditorAPI { * The AST is still resynced inline so `getAst()` stays consistent for * immediate callers. Intended for non-user edits only (file-open, seeding). * Plugin code should leave `silent` unset. - * - Positions (`from`, `to`, and `selection` offsets) are in pre-edit doc - * coordinates — the same coordinate space as `getSelection()` returns. + * - `isolateHistory` prevents this edit from being merged with adjacent history + * events. Toolbar commands use it so consecutive commands can be undone + * independently; when omitted, it preserves the default CodeMirror grouping. + * + * - `from` and `to` are pre-edit doc coordinates. `selection` offsets are + * post-edit coordinates, matching CodeMirror's transaction contract and + * the final selection returned by `getSelection()`. * - Bounds: callers are responsible for valid offsets. CM6 throws * `RangeError` on out-of-bounds values — identical trust model to * `setSelection`. No double validation is performed in this layer. @@ -431,7 +436,7 @@ export interface EditorAPI { to: number, insert: string, selection?: { anchor: number; head?: number }, - opts?: { silent?: boolean } + opts?: { silent?: boolean; isolateHistory?: boolean } ): void; undo(): boolean; redo(): boolean; diff --git a/packages/core/test/editor.test.ts b/packages/core/test/editor.test.ts index 85505eba..a324c665 100644 --- a/packages/core/test/editor.test.ts +++ b/packages/core/test/editor.test.ts @@ -608,6 +608,22 @@ describe("createEditor", () => { editor.destroy(); }); + it("replaceRange: isolateHistory keeps adjacent edits undoable separately", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello world", plugins: [createHistoryPlugin()] }); + + editor.replaceRange(6, 11, "earth", undefined, { isolateHistory: true }); + editor.replaceRange(6, 11, "mars", undefined, { isolateHistory: true }); + + expect(editor.getDocument()).toBe("hello mars"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("hello earth"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("hello world"); + expect(editor.undo()).toBe(false); + editor.destroy(); + }); + // ── HTML export ── it("exports markdown to semantic HTML", () => { diff --git a/packages/plugin-toolbar/src/formatting.ts b/packages/plugin-toolbar/src/formatting.ts index d261c381..308470c1 100644 --- a/packages/plugin-toolbar/src/formatting.ts +++ b/packages/plugin-toolbar/src/formatting.ts @@ -1,5 +1,7 @@ import type { EditorAPI } from "@floatboat/nexus-core"; +const TOOLBAR_REPLACE_OPTIONS = { isolateHistory: true } as const; + interface LineRange { lineStart: number; lineEnd: number; @@ -55,7 +57,7 @@ function applyLines(editor: EditorAPI, lines: LineRange[], newLines: string[]): const selection = lines.length === 1 ? { anchor: first.lineStart + newLines[0].length } : { anchor: first.lineStart, head: first.lineStart + newBlock.length }; - editor.replaceRange(first.lineStart, last.lineEnd, newBlock, selection); + editor.replaceRange(first.lineStart, last.lineEnd, newBlock, selection, TOOLBAR_REPLACE_OPTIONS); return true; } @@ -69,9 +71,7 @@ function toggleLinePrefix(editor: EditorAPI, prefix: string): boolean { const line = doc.slice(lineStart, lineEnd); const newLine = line.startsWith(prefix) ? line.slice(prefix.length) : prefix + line; - const newDoc = doc.slice(0, lineStart) + newLine + doc.slice(lineEnd); - editor.setDocument(newDoc); - editor.setSelection(lineStart + newLine.length); + editor.replaceRange(lineStart, lineEnd, newLine, { anchor: lineStart + newLine.length }, TOOLBAR_REPLACE_OPTIONS); return true; } @@ -135,12 +135,9 @@ export function insertCodeBlock(editor: EditorAPI): boolean { "```\n" + (selected || "") + "\n```" + (needsTrailingNewline ? "\n" : ""); - const newDoc = doc.slice(0, from) + block + doc.slice(to); - editor.setDocument(newDoc); - // Place cursor on the language line (after ```) const langPos = from + (needsLeadingNewline ? 1 : 0) + 3; - editor.setSelection(langPos); + editor.replaceRange(from, to, block, { anchor: langPos }, TOOLBAR_REPLACE_OPTIONS); return true; } @@ -153,12 +150,9 @@ export function insertImage(editor: EditorAPI): boolean { const alt = selected || "alt text"; const md = `![${alt}](url)`; - const newDoc = doc.slice(0, from) + md + doc.slice(to); - editor.setDocument(newDoc); - // Select the "url" part const urlStart = from + alt.length + 4; - editor.setSelection(urlStart, urlStart + 3); + editor.replaceRange(from, to, md, { anchor: urlStart, head: urlStart + 3 }, TOOLBAR_REPLACE_OPTIONS); return true; } @@ -172,10 +166,8 @@ export function applyTextColor(editor: EditorAPI, color: string): boolean { if (from === to) return false; const wrapped = `${selected}`; - const newDoc = doc.slice(0, from) + wrapped + doc.slice(to); - editor.setDocument(newDoc); const innerStart = from + ``.length; - editor.setSelection(innerStart, innerStart + selected.length); + editor.replaceRange(from, to, wrapped, { anchor: innerStart, head: innerStart + selected.length }, TOOLBAR_REPLACE_OPTIONS); return true; } @@ -189,10 +181,8 @@ export function applyHighlight(editor: EditorAPI, color: string): boolean { if (from === to) return false; const wrapped = `${selected}`; - const newDoc = doc.slice(0, from) + wrapped + doc.slice(to); - editor.setDocument(newDoc); const innerStart = from + ``.length; - editor.setSelection(innerStart, innerStart + selected.length); + editor.replaceRange(from, to, wrapped, { anchor: innerStart, head: innerStart + selected.length }, TOOLBAR_REPLACE_OPTIONS); return true; } @@ -203,8 +193,6 @@ export function insertHorizontalRule(editor: EditorAPI): boolean { const needsLeadingNewline = anchor > 0 && doc[anchor - 1] !== "\n"; const hr = (needsLeadingNewline ? "\n" : "") + "---\n"; - const newDoc = doc.slice(0, anchor) + hr + doc.slice(anchor); - editor.setDocument(newDoc); - editor.setSelection(anchor + hr.length); + editor.replaceRange(anchor, anchor, hr, { anchor: anchor + hr.length }, TOOLBAR_REPLACE_OPTIONS); return true; } diff --git a/packages/plugin-toolbar/src/toolbar-commands.ts b/packages/plugin-toolbar/src/toolbar-commands.ts index a77b7615..6667294e 100644 --- a/packages/plugin-toolbar/src/toolbar-commands.ts +++ b/packages/plugin-toolbar/src/toolbar-commands.ts @@ -9,6 +9,8 @@ import { toggleUnorderedList, } from "./formatting"; +const TOOLBAR_REPLACE_OPTIONS = { isolateHistory: true } as const; + export function toggleWrap(editor: EditorAPI, marker: string): boolean { const doc = editor.getDocument(); const { anchor, head } = editor.getSelection(); @@ -19,15 +21,23 @@ export function toggleWrap(editor: EditorAPI, marker: string): boolean { const after = doc.slice(to, to + marker.length); if (before === marker && after === marker) { - editor.setDocument( - doc.slice(0, from - marker.length) + selected + doc.slice(to + marker.length), + editor.replaceRange( + from - marker.length, + to + marker.length, + selected, + { anchor: from - marker.length, head: to - marker.length }, + TOOLBAR_REPLACE_OPTIONS, ); - editor.setSelection(from - marker.length, to - marker.length); return true; } - editor.setDocument(doc.slice(0, from) + marker + selected + marker + doc.slice(to)); - editor.setSelection(from + marker.length, to + marker.length); + editor.replaceRange( + from, + to, + marker + selected + marker, + { anchor: from + marker.length, head: to + marker.length }, + TOOLBAR_REPLACE_OPTIONS, + ); return true; } @@ -56,9 +66,8 @@ export function insertLink(editor: EditorAPI): boolean { const linkText = selected || "link text"; const markdown = `[${linkText}](url)`; - editor.setDocument(doc.slice(0, from) + markdown + doc.slice(to)); const urlStart = from + linkText.length + 3; - editor.setSelection(urlStart, urlStart + 3); + editor.replaceRange(from, to, markdown, { anchor: urlStart, head: urlStart + 3 }, TOOLBAR_REPLACE_OPTIONS); return true; } @@ -75,8 +84,7 @@ export function toggleHeading(editor: EditorAPI, level: number): boolean { ? line.slice(prefix.length) : prefix + (headingMatch ? line.slice(headingMatch[0].length) : line); - editor.setDocument(doc.slice(0, lineStart) + newLine + doc.slice(end)); - editor.setSelection(lineStart + newLine.length); + editor.replaceRange(lineStart, end, newLine, { anchor: lineStart + newLine.length }, TOOLBAR_REPLACE_OPTIONS); return true; } diff --git a/packages/plugin-toolbar/src/toolbar-ui.ts b/packages/plugin-toolbar/src/toolbar-ui.ts index a56ae862..0c76d131 100644 --- a/packages/plugin-toolbar/src/toolbar-ui.ts +++ b/packages/plugin-toolbar/src/toolbar-ui.ts @@ -40,6 +40,8 @@ import { iconFullscreen, } from "./icons"; +const TOOLBAR_REPLACE_OPTIONS = { isolateHistory: true } as const; + export interface ToolbarButton { id: string; title: string; @@ -303,8 +305,7 @@ function showHeadingDropdown( const m = line.match(/^#{1,6}\s/); if (m) { const newLine = line.slice(m[0].length); - editor.setDocument(doc.slice(0, lineStart) + newLine + doc.slice(lineEnd)); - editor.setSelection(lineStart + newLine.length); + editor.replaceRange(lineStart, lineEnd, newLine, { anchor: lineStart + newLine.length }, TOOLBAR_REPLACE_OPTIONS); } } editor.focus(); diff --git a/packages/plugin-toolbar/test/plugin-toolbar.test.ts b/packages/plugin-toolbar/test/plugin-toolbar.test.ts index 3fc97f53..3eae111f 100644 --- a/packages/plugin-toolbar/test/plugin-toolbar.test.ts +++ b/packages/plugin-toolbar/test/plugin-toolbar.test.ts @@ -1,19 +1,252 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { createEditor } from "@floatboat/nexus-core"; +import { createEditor, type EditorAPI } from "@floatboat/nexus-core"; import { createHistoryPlugin } from "@floatboat/nexus-plugin-history"; import { - toggleBold, - toggleItalic, - toggleInlineCode, + applyHighlight, + applyTextColor, + insertCodeBlock, + insertHorizontalRule, + insertImage, insertLink, + toggleBlockquote, + toggleBold, toggleHeading, + toggleInlineCode, + toggleItalic, toggleOrderedList, + toggleStrikethrough, toggleUnorderedList, createToolbarPlugin, createToolbarUI, } from "../src/index"; +type AtomicCase = { + initial: string; + selection: { anchor: number; head?: number }; + expected: string; + expectedSelection: { anchor: number; head?: number }; + run: (editor: EditorAPI) => void; +}; + +describe("toolbar atomic transactions", () => { + it("preserves migrated command output, selection, and undo behavior", () => { + const colorPrefix = ''; + const highlightPrefix = ''; + const cases: AtomicCase[] = [ + { + initial: "hello world", + selection: { anchor: 6, head: 11 }, + expected: "hello **world**", + expectedSelection: { anchor: 8, head: 13 }, + run: toggleBold, + }, + { + initial: "hello world", + selection: { anchor: 6, head: 11 }, + expected: "hello *world*", + expectedSelection: { anchor: 7, head: 12 }, + run: toggleItalic, + }, + { + initial: "hello world", + selection: { anchor: 6, head: 11 }, + expected: "hello ~~world~~", + expectedSelection: { anchor: 8, head: 13 }, + run: toggleStrikethrough, + }, + { + initial: "hello world", + selection: { anchor: 6, head: 11 }, + expected: "hello `world`", + expectedSelection: { anchor: 7, head: 12 }, + run: toggleInlineCode, + }, + { + initial: "click here", + selection: { anchor: 6, head: 10 }, + expected: "click [here](url)", + expectedSelection: { anchor: 13, head: 16 }, + run: insertLink, + }, + { + initial: "Title", + selection: { anchor: 2 }, + expected: "## Title", + expectedSelection: { anchor: 8 }, + run: (editor) => toggleHeading(editor, 2), + }, + { + initial: "## Title", + selection: { anchor: 5 }, + expected: "Title", + expectedSelection: { anchor: 5 }, + run: (editor) => toggleHeading(editor, 2), + }, + { + initial: "Quote", + selection: { anchor: 2 }, + expected: "> Quote", + expectedSelection: { anchor: 7 }, + run: toggleBlockquote, + }, + { + initial: "> Quote", + selection: { anchor: 4 }, + expected: "Quote", + expectedSelection: { anchor: 5 }, + run: toggleBlockquote, + }, + { + initial: "one\ntwo", + selection: { anchor: 0, head: 7 }, + expected: "1. one\n2. two", + expectedSelection: { anchor: 0, head: 13 }, + run: toggleOrderedList, + }, + { + initial: "hello", + selection: { anchor: 0, head: 5 }, + expected: "```\nhello\n```", + expectedSelection: { anchor: 3 }, + run: insertCodeBlock, + }, + { + initial: "hello", + selection: { anchor: 0, head: 5 }, + expected: "![hello](url)", + expectedSelection: { anchor: 9, head: 12 }, + run: insertImage, + }, + { + initial: "hello", + selection: { anchor: 0, head: 5 }, + expected: `${colorPrefix}hello`, + expectedSelection: { anchor: colorPrefix.length, head: colorPrefix.length + 5 }, + run: (editor) => applyTextColor(editor, "red"), + }, + { + initial: "hello", + selection: { anchor: 0, head: 5 }, + expected: `${highlightPrefix}hello`, + expectedSelection: { anchor: highlightPrefix.length, head: highlightPrefix.length + 5 }, + run: (editor) => applyHighlight(editor, "yellow"), + }, + { + initial: "Title", + selection: { anchor: 5 }, + expected: "Title\n---\n", + expectedSelection: { anchor: 10 }, + run: insertHorizontalRule, + }, + ]; + + for (const testCase of cases) { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: testCase.initial, + plugins: [createHistoryPlugin()], + }); + + editor.setSelection(testCase.selection.anchor, testCase.selection.head); + testCase.run(editor); + expect(editor.getDocument()).toBe(testCase.expected); + expect(editor.getSelection()).toEqual({ + anchor: testCase.expectedSelection.anchor, + head: testCase.expectedSelection.head ?? testCase.expectedSelection.anchor, + }); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe(testCase.initial); + expect(editor.undo()).toBe(false); + editor.destroy(); + } + }); + + it("keeps consecutive toolbar commands in separate transactions and undo entries", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "hello world", + plugins: [createHistoryPlugin()], + }); + const updates: string[] = []; + editor.getContributionSink().registerUpdateListener("toolbar-history-test", (update) => { + if (update.changes.length > 0) updates.push(update.documentAfter); + }); + + editor.setSelection(6, 11); + updates.length = 0; + toggleBold(editor); + insertLink(editor); + + expect(editor.getDocument()).toBe("hello **[world](url)**"); + expect(updates).toEqual(["hello **world**", "hello **[world](url)**"]); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("hello **world**"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("hello world"); + expect(editor.undo()).toBe(false); + editor.destroy(); + }); + + it("removes inline formatting in one undoable transaction", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "hello **world**", + plugins: [createHistoryPlugin()], + }); + + editor.setSelection(8, 13); + toggleBold(editor); + + expect(editor.getDocument()).toBe("hello world"); + expect(editor.getSelection()).toEqual({ anchor: 6, head: 11 }); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("hello **world**"); + expect(editor.undo()).toBe(false); + editor.destroy(); + }); + + it("keeps the document, callback, and undo state correct across IME composition", async () => { + const container = document.createElement("div"); + const changes: string[] = []; + const editor = createEditor({ + container, + initialValue: "hello world", + onChange: (doc) => changes.push(doc), + plugins: [createHistoryPlugin()], + }); + const content = container.querySelector(".cm-content"); + expect(content).not.toBeNull(); + + vi.useFakeTimers(); + try { + editor.setSelection(6, 11); + content?.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + expect(editor.isComposing()).toBe(true); + + toggleBold(editor); + + expect(editor.getDocument()).toBe("hello **world**"); + expect(changes).toHaveLength(0); + + content?.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true })); + await vi.runAllTimersAsync(); + + expect(editor.isComposing()).toBe(false); + expect(changes).toContain("hello **world**"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("hello world"); + expect(editor.undo()).toBe(false); + } finally { + vi.useRealTimers(); + editor.destroy(); + } + }); +}); + describe("toggleBold", () => { it("wraps selected text with **", () => { const container = document.createElement("div"); @@ -494,6 +727,33 @@ describe("createToolbarUI", () => { toolbar.destroy(); editor.destroy(); }); + + it("removes a heading through the Normal text menu in one undo step", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "## Title", + plugins: [createHistoryPlugin()], + }); + editor.setSelection(5); + const toolbar = createToolbarUI(editor); + document.body.appendChild(toolbar.element); + + const headingMenu = toolbar.element.querySelector('[data-toolbar-action="heading-menu"]'); + headingMenu?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + const normalText = Array.from(document.querySelectorAll(".nexus-toolbar-dropdown button")) + .find((button) => button.textContent === "Normal text"); + expect(normalText).not.toBeUndefined(); + + normalText?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + + expect(editor.getDocument()).toBe("Title"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("## Title"); + expect(editor.undo()).toBe(false); + toolbar.destroy(); + editor.destroy(); + }); }); describe("toggleUnorderedList — atomic undo", () => {