diff --git a/packages/plugin-toolbar/src/formatting.ts b/packages/plugin-toolbar/src/formatting.ts index d261c381..5e4234e0 100644 --- a/packages/plugin-toolbar/src/formatting.ts +++ b/packages/plugin-toolbar/src/formatting.ts @@ -69,9 +69,8 @@ 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); + const selection = { anchor: lineStart + newLine.length }; + editor.replaceRange(lineStart, lineEnd, newLine, selection); return true; } @@ -135,12 +134,10 @@ 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); + const selection = { anchor: langPos }; + editor.replaceRange(from, to, block, selection); return true; } @@ -153,12 +150,14 @@ 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); + const selection = { + anchor: urlStart, + head: urlStart + 3, + }; + editor.replaceRange(from, to, md, selection); return true; } @@ -172,10 +171,12 @@ 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); + const selection = { + anchor: innerStart, + head: innerStart + selected.length, + }; + editor.replaceRange(from, to, wrapped, selection); return true; } @@ -189,10 +190,12 @@ 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); + const selection = { + anchor: innerStart, + head: innerStart + selected.length, + }; + editor.replaceRange(from, to, wrapped, selection); return true; } @@ -203,8 +206,7 @@ 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); + const selection = { anchor: anchor + hr.length }; + editor.replaceRange(anchor, anchor, hr, selection); return true; } diff --git a/packages/plugin-toolbar/src/index.ts b/packages/plugin-toolbar/src/index.ts index 444d71d3..338aaffc 100644 --- a/packages/plugin-toolbar/src/index.ts +++ b/packages/plugin-toolbar/src/index.ts @@ -26,20 +26,23 @@ export function toggleWrap(editor: EditorAPI, marker: string): boolean { if (before === marker && after === marker) { // Already wrapped — remove markers - const newDoc = - doc.slice(0, from - marker.length) + - selected + - doc.slice(to + marker.length); - editor.setDocument(newDoc); - editor.setSelection(from - marker.length, to - marker.length); + const replaceFrom = from - marker.length; + const replaceTo = to + marker.length; + const selection = { + anchor: replaceFrom, + head: replaceFrom + selected.length, + }; + editor.replaceRange(replaceFrom, replaceTo, selected, selection); return true; } // Wrap selection with markers - const newDoc = - doc.slice(0, from) + marker + selected + marker + doc.slice(to); - editor.setDocument(newDoc); - editor.setSelection(from + marker.length, to + marker.length); + const newContent = marker + selected + marker; + const selection = { + anchor: from + marker.length, + head: from + marker.length + selected.length, + }; + editor.replaceRange(from, to, newContent, selection); return true; } @@ -68,12 +71,14 @@ export function insertLink(editor: EditorAPI): boolean { const linkText = selected || "link text"; const md = `[${linkText}](url)`; - const newDoc = doc.slice(0, from) + md + doc.slice(to); - editor.setDocument(newDoc); // Select the "url" part for easy replacement const urlStart = from + linkText.length + 3; - editor.setSelection(urlStart, urlStart + 3); + const selection = { + anchor: urlStart, + head: urlStart + 3, + }; + editor.replaceRange(from, to, md, selection); return true; } @@ -100,9 +105,8 @@ export function toggleHeading(editor: EditorAPI, level: number): boolean { newLine = prefix + line; } - const newDoc = doc.slice(0, lineStart) + newLine + doc.slice(end); - editor.setDocument(newDoc); - editor.setSelection(lineStart + newLine.length); + const selection = { anchor: lineStart + newLine.length }; + editor.replaceRange(lineStart, end, newLine, selection); return true; } diff --git a/packages/plugin-toolbar/test/plugin-toolbar.test.ts b/packages/plugin-toolbar/test/plugin-toolbar.test.ts index 3fc97f53..53159546 100644 --- a/packages/plugin-toolbar/test/plugin-toolbar.test.ts +++ b/packages/plugin-toolbar/test/plugin-toolbar.test.ts @@ -606,3 +606,66 @@ describe("toggleUnorderedList — atomic undo", () => { editor.destroy(); }); }); + +describe("toggleBold — atomic undo", () => { + it("bold toggle → single undo fully restores document", () => { + const container = document.createElement("div"); + const original = "hello world"; + const editor = createEditor({ container, initialValue: original, plugins: [createHistoryPlugin()] }); + + editor.setSelection(6, 11); + toggleBold(editor); + expect(editor.getDocument()).toBe("hello **world**"); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe(original); + expect(editor.undo()).toBe(false); + editor.destroy(); + }); + + it("bold toggle → undo → redo recovers bold state in one step", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello world", plugins: [createHistoryPlugin()] }); + + editor.setSelection(6, 11); + toggleBold(editor); + editor.undo(); + expect(editor.redo()).toBe(true); + expect(editor.getDocument()).toBe("hello **world**"); + editor.destroy(); + }); +}); + +describe("insertLink — atomic undo", () => { + it("insert link → single undo fully restores document", () => { + const container = document.createElement("div"); + const original = "click here"; + const editor = createEditor({ container, initialValue: original, plugins: [createHistoryPlugin()] }); + + editor.setSelection(6, 10); + insertLink(editor); + expect(editor.getDocument()).toBe("click [here](url)"); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe(original); + expect(editor.undo()).toBe(false); + editor.destroy(); + }); +}); + +describe("toggleHeading — atomic undo", () => { + it("heading toggle → single undo fully restores document", () => { + const container = document.createElement("div"); + const original = "Title"; + const editor = createEditor({ container, initialValue: original, plugins: [createHistoryPlugin()] }); + + editor.setSelection(2); + toggleHeading(editor, 2); + expect(editor.getDocument()).toBe("## Title"); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe(original); + expect(editor.undo()).toBe(false); + editor.destroy(); + }); +});