From 3e35a949d70a09f16fa665145c7427a7b9fd3dcf Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 21 Oct 2026 10:21:16 +0000 Subject: [PATCH] :bug: 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 --- .../src/app/main/data/workspace/texts.cljs | 71 +++++++++------- .../ui/workspace/shapes/text/v3_editor.cljs | 30 +++++++ frontend/src/app/render_wasm/api.cljs | 4 + frontend/src/app/render_wasm/text_editor.cljs | 84 ++++++++++++++++++- .../app/render_wasm/text_editor_history.cljs | 26 ++++++ .../render_wasm/texts_test.cljs | 75 +++++++++++++++++ render-wasm/src/wasm/text_editor.rs | 81 ++++++++++++++++++ 7 files changed, 340 insertions(+), 31 deletions(-) create mode 100644 frontend/src/app/render_wasm/text_editor_history.cljs diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 16e52c4c71..593c1fed7e 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -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." diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 6d497fef9c..0efcb5bda6 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -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 diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 7c0406a6ba..2ac2ec1bce 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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. diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index d2e44f5562..706a1c6e7b 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -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}))))))) diff --git a/frontend/src/app/render_wasm/text_editor_history.cljs b/frontend/src/app/render_wasm/text_editor_history.cljs new file mode 100644 index 0000000000..e239a690c3 --- /dev/null +++ b/frontend/src/app/render_wasm/text_editor_history.cljs @@ -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))}))) diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs index 2adbea2f47..d1689734ef 100644 --- a/frontend/test/frontend_tests/render_wasm/texts_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs @@ -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. diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index bd875ffdcb..d4c4345a2d 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -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();