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
|
// 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
|
* Insert markdown formatting around selection
|
||||||
*/
|
*/
|
||||||
@@ -363,16 +390,13 @@ function insertMarkdownFormat(textareaId, prefix, suffix) {
|
|||||||
|
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
const end = textarea.selectionEnd;
|
const end = textarea.selectionEnd;
|
||||||
const text = textarea.value;
|
const selectedText = textarea.value.substring(start, end);
|
||||||
const selectedText = text.substring(start, end);
|
|
||||||
|
|
||||||
// Insert formatting
|
insertTextPreservingUndo(textarea, prefix + selectedText + suffix, start, end);
|
||||||
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
|
|
||||||
textarea.value = newText;
|
|
||||||
|
|
||||||
// Set cursor position
|
// Set cursor position
|
||||||
if (selectedText) {
|
if (selectedText) {
|
||||||
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
|
textarea.setSelectionRange(start + prefix.length, start + prefix.length + selectedText.length);
|
||||||
} else {
|
} else {
|
||||||
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||||
}
|
}
|
||||||
@@ -391,9 +415,10 @@ function insertMarkdownText(textareaId, text) {
|
|||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
|
||||||
const start = textarea.selectionStart;
|
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.setSelectionRange(start + text.length, start + text.length);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
@@ -453,7 +478,7 @@ function toolbarList(textareaId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert list marker at beginning of line
|
// 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.setSelectionRange(start + 2, start + 2);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
@@ -474,7 +499,7 @@ function toolbarHeading(textareaId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert heading marker at beginning of line
|
// 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.setSelectionRange(start + 3, start + 3);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
@@ -495,7 +520,7 @@ function toolbarQuote(textareaId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert quote marker at beginning of line
|
// 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.setSelectionRange(start + 2, start + 2);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user