From 3cca956ee7443a4a40268296ee609319edb4a789 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 14:21:39 -0400 Subject: [PATCH] Preserve native undo/redo in markdown toolbar buttons (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit insertMarkdownFormat, insertMarkdownText, toolbarList, toolbarHeading, and toolbarQuote all set textarea.value = ... directly. Assigning .value programmatically discards the browser's entire native undo stack (vs. document.execCommand('insertText', ...), which preserves it) — e.g. type a paragraph, click Bold, then Ctrl+Z undid the whole paragraph instead of just the bold markup. Added insertTextPreservingUndo(), which selects the exact range being replaced and routes through execCommand('insertText', ...) — the same mechanism real typing uses — falling back to the old direct assignment (losing undo, matching prior behavior) only if execCommand is unavailable or unsuccessful. Verified with a jsdom harness that the fallback path (jsdom doesn't implement execCommand, since native undo is a real-browser-only feature untestable via jsdom) produces byte-identical resulting text and cursor positions to the original implementation across all 5 toolbar functions, for both selected and cursor-only cases. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- assets/js/markdown.js | 47 +++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/assets/js/markdown.js b/assets/js/markdown.js index 7bba4ee..b9653ce 100644 --- a/assets/js/markdown.js +++ b/assets/js/markdown.js @@ -354,6 +354,33 @@ window.renderMarkdownElements = renderMarkdownElements; // Rich Text Editor Toolbar Functions // ======================================== +/** + * Replace textarea.value.substring(selStart, selEnd) with replacementText, + * preserving the browser's native undo/redo stack via + * document.execCommand('insertText', ...) -- the same mechanism real typing + * uses -- instead of a direct .value assignment, which discards the entire + * undo history. Falls back to a direct assignment (losing undo, matching the + * old behavior) only if execCommand is unavailable or unsuccessful. + */ +function insertTextPreservingUndo(textarea, replacementText, selStart, selEnd) { + textarea.focus(); + textarea.setSelectionRange(selStart, selEnd); + + let inserted = false; + if (typeof document.execCommand === 'function') { + try { + inserted = document.execCommand('insertText', false, replacementText); + } catch (e) { + inserted = false; + } + } + + if (!inserted) { + const text = textarea.value; + textarea.value = text.substring(0, selStart) + replacementText + text.substring(selEnd); + } +} + /** * Insert markdown formatting around selection */ @@ -363,16 +390,13 @@ function insertMarkdownFormat(textareaId, prefix, suffix) { const start = textarea.selectionStart; const end = textarea.selectionEnd; - const text = textarea.value; - const selectedText = text.substring(start, end); + const selectedText = textarea.value.substring(start, end); - // Insert formatting - const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end); - textarea.value = newText; + insertTextPreservingUndo(textarea, prefix + selectedText + suffix, start, end); // Set cursor position if (selectedText) { - textarea.setSelectionRange(start + prefix.length, end + prefix.length); + textarea.setSelectionRange(start + prefix.length, start + prefix.length + selectedText.length); } else { textarea.setSelectionRange(start + prefix.length, start + prefix.length); } @@ -391,9 +415,10 @@ function insertMarkdownText(textareaId, text) { if (!textarea) return; const start = textarea.selectionStart; - const value = textarea.value; - textarea.value = value.substring(0, start) + text + value.substring(start); + // Matches the prior behavior: insert before the selection start without + // deleting any currently-selected text (a collapsed replace range). + insertTextPreservingUndo(textarea, text, start, start); textarea.setSelectionRange(start + text.length, start + text.length); textarea.focus(); @@ -453,7 +478,7 @@ function toolbarList(textareaId) { } // Insert list marker at beginning of line - textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart); + insertTextPreservingUndo(textarea, '- ', lineStart, lineStart); textarea.setSelectionRange(start + 2, start + 2); textarea.focus(); @@ -474,7 +499,7 @@ function toolbarHeading(textareaId) { } // Insert heading marker at beginning of line - textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart); + insertTextPreservingUndo(textarea, '## ', lineStart, lineStart); textarea.setSelectionRange(start + 3, start + 3); textarea.focus(); @@ -495,7 +520,7 @@ function toolbarQuote(textareaId) { } // Insert quote marker at beginning of line - textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart); + insertTextPreservingUndo(textarea, '> ', lineStart, lineStart); textarea.setSelectionRange(start + 2, start + 2); textarea.focus();