Preserve native undo/redo in markdown toolbar buttons (#107)
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 29s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m32s
Lint / Deploy (push) Successful in 2s
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 29s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m32s
Lint / Deploy (push) Successful in 2s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
This commit is contained in:
+36
-11
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user