Defect #43488
openUndo/Redo produces unexpected results when the text in a textarea is modified automatically
Description
When using the Wiki toolbar or keyboard shortcuts that modify text in a textarea, the undo/redo operations (Command/Control + Z and Shift + Command/Control + Z) behave inconsistently. They function, but the results are not what you would expect.
Here are some examples:- Applying bold using the Wiki toolbar :
Select part of the text and click the Bold button. After this action, both Command/Control + Z (undo) and Shift + Command/Control + Z (redo) behave in unexpected ways. - Applying italic using a keyboard shortcut :
Select part of the text and press Command/Control + I. After this action, both Command/Control + Z (undo) and Shift + Command/Control + Z (redo) behave in unexpected ways. - Automatic bullet insertion :
Create a bulleted list and press Enter to trigger automatic insertion of the next bullet point. After this action, both Command/Control + Z (undo) and Shift + Command/Control + Z (redo) behave in unexpected ways.
These behaviors occur when the Wiki toolbar, keyboard shortcuts, or auto-completion trigger JavaScript-based text modifications (i.e., changes not made through direct user input).
Such programmatic text changes may not be recorded in the browser’s native undo/redo stack as expected.
For example, when using APIs like setRangeText(...) or assigning to input.value=, the changes are not recognized as “user actions,” which leads to inconsistencies in the undo/redo history.
Reference: https://stackoverflow.com/questions/73129862/is-it-possible-to-undo-setrangetext
Files
Updated by Jonathan Cormier 18 days ago
Patch attached for the wiki-toolbar half of this (bold/italic/etc. from the toolbar
buttons or their keyboard shortcuts). The automatic bullet insertion mentioned in the
description is a separate code path and was fixed by the patch in #44336; the toolbar path
is untouched by it.
Where it happens¶
Toolbar edits are written by encloseSelection and encloseLineSelection inapp/assets/javascripts/jstoolbar/jstoolbar.js:
this.textarea.value = this.textarea.value.substring(0, start) + subst + this.textarea.value.substring(end);
Assigning textarea.value programmatically resets the browser's undo stack, so a toolbar
edit both discards the history before it and leaves redo desynced.
This code is unchanged in 5.1-stable, 6.0-stable, 6.1-stable and current master, so it is
not a regression from #43095 - it is just more noticeable now that the list autofill sits
next to it. (The affected version field on this issue is empty; the behavior goes back at
least to 5.1.)
Reproduction (Chromium, 6.1.3, textile)¶
- Open a new issue form and click into the description field.
- Type
hello. - Click Strong (or press Ctrl+B). The textarea holds
"hello **". - Press Ctrl+Z ten times: the textarea still holds
"hello **"- neither the inserted
markup nor the typed text can be undone. - Press Ctrl+Shift+Z ten times: the textarea now holds
"hello **hellohello"- redo
replays the pre-assignment typing on top of the inserted markup.
Same in common_mark, and same for every other button (italic, underline, deleted, code,
preformatted, highlighted code, quote, bullet/numbered/task lists, headings) and for the
Ctrl+B / Ctrl+I / Ctrl+U shortcuts.
Patch¶
jstoolbar.js gains one helper:
replaceRange: function(start, end, subst) {
if (typeof(document.execCommand) != 'function') { return false; }
this.textarea.setSelectionRange(start, end);
if (subst.length > 0) {
return document.execCommand('insertText', false, subst);
} else if (start != end) {
return document.execCommand('delete');
}
return true;
},
encloseSelection and encloseLineSelection call it instead of assigningtextarea.value, and keep the assignment as a fallback where execCommand is
unavailable. Caret handling is unchanged: after insertText the caret already sits atstart + subst.length, so only the "collapse between the markers" case still needs an
explicit setSelectionRange. The patch applies cleanly to 6.1-stable and to current
master.
Verified (6.1.3, headless Chromium driven by Playwright, textile and common_mark)¶
- inserted text and caret position are identical to the current behavior for every toolbar
button and shortcut - 17 scenarios in textile, 18 in common_mark - including selection
wrapping and the highlighted-code language menu - Ctrl+Z reverts a toolbar insertion in one step and then continues into the text typed
before it; Ctrl+Shift+Z restores exactly the post-button text - the values asserted in
test/system/keyboard_shortcuts_test.rbcome out unchanged
(checked in the browser rather than by running the suite):**/++/__for textile,****/<u></u>/**for common_mark - scroll position is unchanged when a button is pressed with the caret at the bottom of a
scrolled textarea execCommandis deprecated but universally supported for insertText/delete in
textareas, and core already relies on it (copy-to-clipboard)
One side benefit: execCommand edits fire an input event, which a value assignment does
not, so anything listening for editor changes (draft autosave, plugins) finally sees text
inserted by a toolbar button.
Updated by Yasu Saku 11 days ago
As an alternative approach that does not use execCommand , I have a plugin that adds Undo/Redo functionality to the jsToolBar, and its implementation may be relevant to this issue.
The plugin maintains its own history of the textarea value and selection range, and records changes triggered by keyboard input, paste/cut operations, and jsToolBar button clicks. It also periodically checks the textarea while it is focused to detect changes made programmatically.
This approach allows Undo/Redo to work even when the textarea is modified by JavaScript rather than directly by the user.
As a side note, this plugin works with Redmine 5.0 through the latest trunk.
I hope the implementation may provide some useful ideas for resolving this issue.
Updated by Jonathan Cormier 5 days ago
Found a slight modification:
Using setSelectionRange causes the linux middle-click buffer to get filled with whatever we are deleting, instead using setRangeText avoids this.
- setSelectionRange is a UI action that X11 monitors to sync the "Primary Selection" (middle-click buffer).
- setRangeText is a programmatic DOM manipulation method. When used with an empty string and the 'end' (or 'preserve') parameter, it modifies the internal value buffer of the textarea without triggering the system-level selection broadcast that "pollutes" the middle-click buffer.
Updated by Jonathan Cormier 5 days ago
Ex: textarea.setRangeText('', lineStart, lineEnd, 'end');
Updated by Jonathan Cormier 1 day ago
nvm, setRangeText just brings back the undo problems.