🐛 Restore undo and redo inside the WASM text editor

Keep bounded content and selection history within each editing session.
Group committed compositions and styled insertions, omit IME previews,
and reset history when external content changes the editing baseline.

Route keyboard, workspace menu and browser history input to the editor.
Restore UTF-16 selections through a new WASM binding, preserving styles,
readings and paragraph settings without reaching earlier canvas edits.

Verify 1,587 frontend tests, 671 renderer tests and both WASM builds.
Add four editable showcase examples and verify them after reopening.

AI-assisted-by: GPT-6
This commit is contained in:
alonso.torres 2026-10-21 10:21:16 +00:00
parent c229239753
commit 3e35a949d7
7 changed files with 340 additions and 31 deletions

View File

@ -1617,16 +1617,29 @@
(when-let [{:keys [shape-id content]} (wasm.text-editor/text-editor-sync-content)]
(v3-update-editor-content shape-id content :finalize? finalize?)))
(defn v3-restore-history
[direction]
(ptk/reify ::v3-restore-history
ptk/WatchEvent
(watch [_ _ _]
(if-let [{:keys [shape-id content]} (wasm.api/restore-text-editor-history direction)]
(do
(wasm.api/request-render-preserving-target "text-editor-history")
(rx/of (v3-update-editor-content shape-id content)))
(rx/empty)))))
(defn v3-apply-pending-caret-styles
"Event saving an insertion made at `before` that consumed the pending caret style:
the inserted text gets that style as its own span. Nil when nothing changed."
[shape-id before]
(let [range (wasm.text-editor/typed-range before (wasm.text-editor/caret-position))]
;; Sync first so the cached content stays index-aligned with WASM.
(wasm.text-editor/text-editor-sync-content)
(if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)]
(v3-update-editor-content shape-id content)
(v3-sync-editor-content))))
(wasm.text-editor/with-history-edit
(fn []
(let [range (wasm.text-editor/typed-range before (wasm.text-editor/caret-position))]
;; Sync first so the cached content stays index-aligned with WASM.
(wasm.text-editor/text-editor-sync-content)
(if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)]
(v3-update-editor-content shape-id content)
(v3-sync-editor-content))))))
(defn- sync-editor-content-stream
"Stream of the sync event for `reason`, after asking WASM to repaint."
@ -1853,31 +1866,33 @@
"Insert the text of `fragment` at the caret and restyle it: with its own styles
or overrides, else with the pending caret style. Returns the event saving it."
[state {:keys [fragment penpot?] :as paste}]
(let [shape-id (wasm.text-editor/text-editor-get-active-shape-id)
start (wasm.text-editor/selection-start)
styled? (or penpot? (text-paste/styled? fragment))
pending? (some? (wasm.text-editor/get-pending-caret-styles shape-id))]
(when styled?
(wasm.text-editor/clear-pending-caret-styles!))
(wasm.text-editor/text-editor-insert-text (text-paste/fragment->text fragment))
(cond
(nil? start)
(v3-sync-editor-content)
(wasm.text-editor/with-history-edit
(fn []
(let [shape-id (wasm.text-editor/text-editor-get-active-shape-id)
start (wasm.text-editor/selection-start)
styled? (or penpot? (text-paste/styled? fragment))
pending? (some? (wasm.text-editor/get-pending-caret-styles shape-id))]
(when styled?
(wasm.text-editor/clear-pending-caret-styles!))
(wasm.text-editor/text-editor-insert-text (text-paste/fragment->text fragment))
(cond
(nil? start)
(v3-sync-editor-content)
styled?
(do
;; Sync first so the cached content stays index-aligned with WASM.
(wasm.text-editor/text-editor-sync-content)
(if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start penpot?)]
(let [shape (dsh/lookup-shape state shape-id)]
(v3-update-editor-content shape-id (move-pasted-fills shape content paste start)))
(v3-sync-editor-content)))
styled?
(do
;; Sync first so the cached content stays index-aligned with WASM.
(wasm.text-editor/text-editor-sync-content)
(if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start penpot?)]
(let [shape (dsh/lookup-shape state shape-id)]
(v3-update-editor-content shape-id (move-pasted-fills shape content paste start)))
(v3-sync-editor-content)))
pending?
(v3-apply-pending-caret-styles shape-id start)
pending?
(v3-apply-pending-caret-styles shape-id start)
:else
(v3-sync-editor-content))))
:else
(v3-sync-editor-content))))))
(defn v3-paste
"Paste clipboard `html` and `text` at the caret, replacing the selection."

View File

@ -25,7 +25,9 @@
[app.util.keyboard :as kbd]
[app.util.text.geometry :as text.geom]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(def caret-blink-interval-ms 250)
@ -609,6 +611,7 @@
;; makes ibus abort and commit. The surface already sits on the
;; caret (see `update-ime-caret!`).
(when (and (text-editor/text-editor-has-focus?)
(not (mf/ref-val composing-ref))
(not (composing-event? event)))
(let [key (.-key event)
ctrl? (or (.-ctrlKey event) (.-metaKey event))
@ -620,6 +623,12 @@
(cond
;; NOTE: Escape is handled in a document key-up listener (see effect below).
(and ctrl? (contains? #{"z" "y"} (str/lower key)))
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(st/emit! (if (or shift? (= (str/lower key) "y")) dwu/redo dwu/undo)))
;; Ctrl+A: select all (key is "a" or "A" depending on platform)
(and ctrl? (= (str/lower key) "a"))
(do
@ -727,6 +736,11 @@
(mf/use-fn
(fn [^js native]
(mf/set-ref-val! pending-replace-ref 0)
(when (and (contains? #{"historyUndo" "historyRedo"} (.-inputType native))
(not (mf/ref-val composing-ref))
(not (.-isComposing native)))
(dom/prevent-default native)
(st/emit! (if (= (.-inputType native) "historyUndo") dwu/undo dwu/redo)))
(when (and (= (.-inputType native) "insertText")
(not (.-isComposing native))
(not (text-editor/text-editor-has-selection?)))
@ -917,6 +931,22 @@
;; Keep the caret-translation origin in sync with the current render.
(mf/set-ref-val! origin-ref {:x x :y y :width ime-width :vertical? vertical?})
;; The workspace leaves undo to the active editor. Handle the same events
;; for keyboard shortcuts, native history input and workspace menu actions.
(mf/use-effect
(mf/deps)
(fn []
(let [subscription
(->> st/stream
(rx/filter #(or (ptk/type? ::dwu/undo %) (ptk/type? ::dwu/redo %)))
(rx/subs! (fn [event]
(when-not (mf/ref-val composing-ref)
(st/emit! (dwt/v3-restore-history
(if (ptk/type? ::dwu/undo event) :undo :redo)))
(reset-input-node (mf/ref-val contenteditable-ref))
(schedule-ime-caret!)))))]
#(rx/dispose! subscription))))
;; Exit on Escape via a document key-up listener (like v2). On key-down the trailing
;; key-up is read as a non-editing Escape and deselects the shape.
(mf/use-effect

View File

@ -769,6 +769,10 @@
(h/call wasm/internal-module "_update_shape_text_layout")
result)))
(defn restore-text-editor-history
[direction]
(text-editor/restore-history direction wselect/use-shape set-shape-text-content))
(defn apply-styles-to-selection
"Apply style attrs to the currently selected text spans.
Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving.

View File

@ -20,6 +20,7 @@
;; Required for side effects: binds the generated enums.
[app.render-wasm.api.enums]
[app.render-wasm.api.fonts :as fonts]
[app.render-wasm.text-editor-history :as history]
[app.util.color :as uc]
[app.util.dom :as dom]))
@ -199,6 +200,12 @@
(when selection
(h/call wasm/internal-module "_text_editor_apply_theme" selection color invert?)))))
(def ^:private editing-history (atom nil))
(declare checkpoint-history record-content-history get-cached-content
default-empty-text-content text-editor-get-active-shape-id
text-editor-get-selection clear-pending-caret-styles!)
(defn text-editor-focus
[id]
(when (wasm/ready?)
@ -208,7 +215,9 @@
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))
(throw (js/Error. "TextEditor focus failed"))))))
(throw (js/Error. "TextEditor focus failed")))
(when (not= id (:shape-id @editing-history))
(reset! editing-history (assoc history/empty-history :shape-id id))))))
(defn text-editor-set-cursor-from-offset
"Sets caret position from shape relative coordinates"
@ -417,6 +426,8 @@
(defn text-editor-composition-start
[]
(when (wasm/ready?)
(checkpoint-history)
(swap! editing-history assoc :composing true)
(h/call wasm/internal-module "_text_editor_composition_start")))
(defn text-editor-composition-update
@ -437,11 +448,13 @@
(when (wasm/ready?)
(text-editor-encode-text-pre text)
(h/call wasm/internal-module "_text_editor_composition_end")
(text-editor-encode-text-post text)))
(text-editor-encode-text-post text)
(swap! editing-history assoc :composing false)))
(defn text-editor-insert-text
[text]
(when (wasm/ready?)
(checkpoint-history)
(text-editor-encode-text-pre text)
(h/call wasm/internal-module "_text_editor_insert_text")
(text-editor-encode-text-post text)))
@ -451,6 +464,7 @@
(text-editor-delete-backward false))
([word-boundary]
(when (wasm/ready?)
(checkpoint-history)
(h/call wasm/internal-module "_text_editor_delete_backward" word-boundary))))
(defn text-editor-delete-forward
@ -458,10 +472,12 @@
(text-editor-delete-forward false))
([word-boundary]
(when (wasm/ready?)
(checkpoint-history)
(h/call wasm/internal-module "_text_editor_delete_forward" word-boundary))))
(defn text-editor-insert-paragraph []
(when (wasm/ready?)
(checkpoint-history)
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
(defn text-editor-move-cursor
@ -493,7 +509,8 @@
(defn text-editor-dispose
[]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_dispose")))
(h/call wasm/internal-module "_text_editor_dispose"))
(reset! editing-history nil))
(defn text-editor-has-focus?
([id]
@ -571,6 +588,12 @@
(defn cache-shape-text-content!
[shape-id content]
(when (some? content)
;; Local edits update the cache before their store commit. A different
;; incoming tree starts a new baseline, so undo cannot revert remote edits.
(when (and (= shape-id (:shape-id @editing-history))
(not (:restoring @editing-history))
(not= content (get @shape-text-contents shape-id)))
(reset! editing-history (assoc history/empty-history :shape-id shape-id)))
(swap! shape-text-contents assoc shape-id content)))
(defn get-cached-content
@ -579,8 +602,62 @@
(defn update-cached-content!
[shape-id content]
(record-content-history shape-id content)
(swap! shape-text-contents assoc shape-id content))
(defn- history-snapshot
[shape-id]
{:content (or (get-cached-content shape-id) (default-empty-text-content))
:selection (text-editor-get-selection)})
(defn- checkpoint-history
[]
(when-let [{:keys [shape-id composing before]} @editing-history]
(when (and (not composing) (nil? before))
(swap! editing-history assoc :before (history-snapshot shape-id)))))
(defn- record-content-history
[shape-id content]
(let [{active-id :shape-id :keys [composing restoring grouped before] :as edits} @editing-history]
(when (and (= active-id shape-id) (not composing) (not restoring) (not grouped))
(let [before (or before (history-snapshot shape-id))
after {:content content :selection (text-editor-get-selection)}]
(reset! editing-history (dissoc (history/record edits before after) :before))))))
(defn with-history-edit
"Keep insertion and its style updates in one history entry."
[f]
(if (or (nil? @editing-history) (:grouped @editing-history))
(f)
(do
(checkpoint-history)
(swap! editing-history assoc :grouped true)
(try
(f)
(finally
(swap! editing-history dissoc :grouped)
(let [shape-id (:shape-id @editing-history)]
(record-content-history shape-id (get-cached-content shape-id))))))))
(defn restore-history
"Restore one edit from this session. An empty history never reaches canvas undo."
[direction use-shape set-content]
(when (and (wasm/ready?) (text-editor-has-focus?)
(not (:composing @editing-history)))
(let [{:keys [shape-id] :as edits} @editing-history]
(when-let [{:keys [history snapshot]} (history/step edits direction (history-snapshot shape-id))]
(reset! editing-history (assoc history :restoring true))
(try
(clear-pending-caret-styles!)
(use-shape shape-id)
(set-content shape-id (:content snapshot))
(let [{:keys [anchor-para anchor-offset focus-para focus-offset]} (:selection snapshot)]
(h/call wasm/internal-module "_text_editor_restore_selection"
anchor-para anchor-offset focus-para focus-offset))
{:shape-id shape-id :content (:content snapshot)}
(finally
(swap! editing-history dissoc :restoring :before)))))))
;; Typography chosen at a collapsed caret: not applied to existing text, but
;; picked up (as a new span) by the next inserted text. Keyed by shape-id.
(def ^:private pending-caret-styles (atom {}))
@ -690,6 +767,7 @@
(default-empty-text-content))]
(when content
(let [merged (merge-exported-texts-into-content content texts-clj)]
(record-content-history shape-id merged)
(swap! shape-text-contents assoc shape-id merged)
{:shape-id shape-id
:content merged})))))))

View File

@ -0,0 +1,26 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.text-editor-history)
(def empty-history {:past [] :future []})
(defn record
[history before after]
(if (= (:content before) (:content after))
history
(-> history
(update :past #(conj (vec (take-last 99 %)) before))
(assoc :future []))))
(defn step
[history direction current]
(let [[from to] (case direction :undo [:past :future] :redo [:future :past])]
(when-let [snapshot (peek (get history from))]
{:snapshot snapshot
:history (-> history
(update from pop)
(update to conj current))})))

View File

@ -20,6 +20,7 @@
[app.render-wasm.api :as api]
[app.render-wasm.api.texts :as texts]
[app.render-wasm.text-editor :as text-editor]
[app.render-wasm.text-editor-history :as history]
[app.util.i18n :as i18n]
[app.util.text.content.styles :as content-styles]
[cljs.test :as t :include-macros true]
@ -170,6 +171,80 @@
(text-editor/selection-japanese-styles
content (selection 0 2 0 3)))))))
(t/deftest editor-history-restores-styled-content-and-selection
(let [before {:content (editor-content [{:text "A😀B" :ruby "a"}])
:selection (selection 0 1 0 3)}
after {:content (editor-content [{:text "A你好B" :ruby "a"}])
:selection (selection 0 3 0 3)}
edits (history/record history/empty-history before after)
undone (history/step edits :undo after)]
(t/is (= before (:snapshot undone)))
(t/is (= after (:snapshot (history/step (:history undone) :redo before))))))
(t/deftest editor-history-keeps-no-ops-out-and-discards-redo-after-editing
(let [a {:content "A" :selection (selection 0 1 0 1)}
b {:content "AB" :selection (selection 0 2 0 2)}
c {:content "AC" :selection (selection 0 2 0 2)}
edited (history/record history/empty-history a b)
undone (:history (history/step edited :undo b))]
(t/is (= undone (history/record undone a a)))
(t/is (nil? (history/step (history/record undone a c) :redo c)))
(t/is (nil? (history/step history/empty-history :undo a)))))
(t/deftest editor-history-bounds-the-number-of-saved-edits
(let [edits (reduce (fn [edits n]
(history/record edits {:content n} {:content (inc n)}))
history/empty-history (range 150))]
(t/is (= 100 (count (:past edits))))
(t/is (= 50 (:content (first (:past edits)))))))
(t/deftest ^:async editor-history-groups-style-updates-and-committed-composition
(let [edits @#'text-editor/editing-history
cache @#'text-editor/shape-text-contents
old-edits @edits
old-cache @cache
before (editor-content [{:text "A😀B"}])
inserted (editor-content [{:text "A你好B"}])
styled (editor-content [{:text "A你好B" :ruby "nǐ hǎo"}])
range (selection 0 1 0 3)]
(try
(await
(mock/with-mocks*
{text-editor/text-editor-get-selection (constantly range)}
(reset! cache {:fixture before})
(reset! edits (assoc history/empty-history :shape-id :fixture))
(text-editor/with-history-edit
(fn []
(text-editor/update-cached-content! :fixture inserted)
(text-editor/update-cached-content! :fixture styled)))
(t/is (= [{:content before :selection range}] (:past @edits)))
(t/is (= styled (text-editor/get-cached-content :fixture)))
;; Preview updates must never enter history. Commit the whole final
;; reading once; a cancelled composition is a no-op and keeps redo.
(reset! cache {:fixture before})
(reset! edits (assoc history/empty-history :shape-id :fixture))
(@#'text-editor/checkpoint-history)
(swap! edits assoc :composing true)
(@#'text-editor/record-content-history :fixture inserted)
(t/is (empty? (:past @edits)))
(swap! edits assoc :composing false)
(text-editor/update-cached-content! :fixture inserted)
(t/is (= [before] (mapv :content (:past @edits))))
(reset! cache {:fixture before})
(reset! edits (assoc history/empty-history :shape-id :fixture
:future [{:content inserted :selection range}]))
(@#'text-editor/checkpoint-history)
(text-editor/update-cached-content! :fixture before)
(t/is (= [inserted] (mapv :content (:future @edits))))
;; Incoming content that bypasses local editor edits must not become
;; a local undo entry or leave redo able to overwrite the new tree.
(text-editor/cache-shape-text-content! :fixture styled)
(t/is (empty? (:past @edits)))
(t/is (empty? (:future @edits)))))
(finally
(reset! edits old-edits)
(reset! cache old-cache)))))
(t/deftest composition-caret-goes-to-the-end-of-the-changed-part
;; Typing appends, converting replaces all, and a candidate for a middle
;; clause changes only that clause.

View File

@ -1174,12 +1174,93 @@ pub extern "C" fn text_get_range_origin(
})
}
fn restore_editor_selection(
editor: &mut TextEditorState,
content: &TextContent,
anchor_para: u32,
anchor_offset: u32,
focus_para: u32,
focus_offset: u32,
) -> bool {
let paragraphs = content.paragraphs();
if paragraphs.is_empty() {
return false;
}
let position = |paragraph: u32, offset: u32| {
let paragraph = (paragraph as usize).min(paragraphs.len() - 1);
let mut utf16 = 0;
let offset = paragraphs[paragraph]
.children()
.iter()
.flat_map(|span| span.text.chars())
.take_while(|ch| {
utf16 += ch.len_utf16();
utf16 <= offset as usize
})
.count();
TextPositionWithAffinity::new_downstream_affinity(paragraph, offset)
};
editor.selection.anchor = position(anchor_para, anchor_offset);
editor.selection.focus = position(focus_para, focus_offset);
editor.update_styles(content);
editor.reset_blink();
editor.push_event(TextEditorEvent::SelectionChanged);
true
}
/// Restore the host's UTF-16 selection after restoring a history entry.
#[no_mangle]
pub extern "C" fn text_editor_restore_selection(
anchor_para: u32,
anchor_offset: u32,
focus_para: u32,
focus_offset: u32,
) -> bool {
with_state!(state, {
let editor = get_text_editor_state();
if !editor.has_focus || editor.composition.is_composing {
return false;
}
let Some(shape) = editor.active_shape_id.and_then(|id| state.shapes.get(&id)) else {
return false;
};
let Type::Text(content) = &shape.shape_type else {
return false;
};
restore_editor_selection(
editor,
content,
anchor_para,
anchor_offset,
focus_para,
focus_offset,
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::{Group, GrowType, Paragraph, TextDirection, TextSpan, WritingMode};
use crate::tiles;
#[test]
fn history_selection_restores_utf16_ranges_without_splitting_surrogates() {
let mut resources = crate::render::RenderResources::try_new_headless().expect("resources");
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
let (mut state, id) = preview_state(GrowType::Fixed, WritingMode::VerticalRl);
let content = text_content_for_edit(&mut state, id).unwrap();
content.paragraphs_mut()[0].children_mut()[0].text = "A😀한B".into();
let mut editor = TextEditorState::new();
assert!(restore_editor_selection(&mut editor, content, 0, 1, 0, 3));
assert_eq!(editor.selection.anchor.offset, 1);
assert_eq!(editor.selection.focus.offset, 2);
assert!(restore_editor_selection(&mut editor, content, 0, 2, 9, 99));
assert_eq!(editor.selection.anchor.offset, 1);
assert_eq!(editor.selection.focus.paragraph, 0);
assert_eq!(editor.selection.focus.offset, 4);
}
fn preview_state(grow_type: GrowType, writing_mode: WritingMode) -> (State, Uuid) {
let id = Uuid::new_v4();
let mut state = State::new();