From 09212dcf754f89c6423cd4b966213daaf36233fb Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 21 Oct 2026 10:55:58 +0000 Subject: [PATCH] :bug: Restore selected text when composition is cancelled Restore the pre-composition content and UTF-16 selection when an IME ends with an empty replacement. Keep styles, selection direction and redo intact without recording cancellation as an edit. Share snapshot restoration with editor undo and redo. Add a regression for styled selections and cancellation after undo. Verify commit, cancellation, deletion and history in four saved showcase examples, including emoji and decomposed Korean Jamo in both writing directions. AI-assisted-by: GPT-6 --- .../ui/workspace/shapes/text/v3_editor.cljs | 2 +- frontend/src/app/render_wasm/api.cljs | 4 ++ frontend/src/app/render_wasm/text_editor.cljs | 38 ++++++++++---- .../render_wasm/texts_test.cljs | 50 +++++++++++++++++++ 4 files changed, 82 insertions(+), 12 deletions(-) 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 0efcb5bda6..81df3f1421 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 @@ -562,7 +562,7 @@ (fn [^js event] (mf/set-ref-val! composing-ref false) (let [data (or (.-data event) "")] - (text-editor/text-editor-composition-end data) + (wasm.api/finish-text-editor-composition data) (sync-wasm-text-editor-content!) (wasm.api/request-render-preserving-target "text-composition")) (reset-input-node (mf/ref-val contenteditable-ref)) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 2ac2ec1bce..11f94a7037 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 finish-text-editor-composition + [text] + (text-editor/finish-composition text wselect/use-shape set-shape-text-content)) + (defn restore-text-editor-history [direction] (text-editor/restore-history direction wselect/use-shape set-shape-text-content)) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 706a1c6e7b..b2118ccd8a 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -639,6 +639,30 @@ (let [shape-id (:shape-id @editing-history)] (record-content-history shape-id (get-cached-content shape-id)))))))) +(defn- restore-snapshot + [shape-id snapshot use-shape set-content] + (swap! editing-history assoc :restoring true) + (try + (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)))) + +(defn finish-composition + "Restore the source and selection when the IME cancels its replacement." + [text use-shape set-content] + (when (wasm/ready?) + (let [{:keys [shape-id before composing]} @editing-history] + ;; End the WASM preview before restoring selection; restoration is + ;; deliberately blocked while composition remains active. + (text-editor-composition-end text) + (when (and composing before (empty? text)) + (restore-snapshot shape-id before use-shape set-content))))) + (defn restore-history "Restore one edit from this session. An empty history never reaches canvas undo." [direction use-shape set-content] @@ -646,17 +670,9 @@ (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))))))) + (reset! editing-history history) + (clear-pending-caret-styles!) + (restore-snapshot shape-id snapshot use-shape set-content))))) ;; 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. diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs index a4b4ac6df8..e2f5530b3a 100644 --- a/frontend/test/frontend_tests/render_wasm/texts_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs @@ -308,6 +308,56 @@ (reset! cache old-cache) (reset! styles old-styles))))) +(t/deftest ^:async cancelled-composition-restores-styled-source-without-changing-history + (let [edits @#'text-editor/editing-history + cache @#'text-editor/shape-text-contents + old-edits @edits + old-cache @cache + source (editor-content [{:text "A"} + {:text "中" :fills [{:fill-color "#b84821"}]} + {:text "😀文" :font-weight "700"} + {:text "B"}]) + deleted (editor-content [{:text "AB"}]) + engine (atom source) + range (atom nil) + redo [{:content (editor-content [{:text "你好"}]) + :selection (selection 0 2 0 2)}]] + (try + (await + (mock/with-mocks* + {wasm/ready? (constantly true) + wasm/internal-module + #js {"_text_editor_composition_start" (fn [] nil) + "_text_editor_composition_end" (fn [] (reset! engine deleted)) + "_text_editor_restore_selection" + (fn [ap ao fp fo] (reset! range (selection ap ao fp fo)))} + text-editor/text-editor-encode-text-pre (constantly nil) + text-editor/text-editor-encode-text-post (constantly nil) + text-editor/text-editor-get-selection (fn [] @range)} + (doseq [selected [(selection 0 1 0 5) (selection 0 5 0 1) + (selection 0 1 0 1)]] + (reset! engine source) + (reset! range selected) + (reset! cache {:fixture source}) + (reset! edits (assoc history/empty-history :shape-id :fixture :future redo)) + (text-editor/text-editor-composition-start) + (text-editor/finish-composition + "" identity + (fn [shape-id content] + (reset! engine content) + (text-editor/cache-shape-text-content! shape-id content))) + ;; The usual compositionend sync exports the current WASM tree. + (text-editor/update-cached-content! :fixture @engine) + (t/is (= source @engine)) + (t/is (= selected @range)) + (t/is (= source (text-editor/get-cached-content :fixture))) + (t/is (empty? (:past @edits))) + (t/is (= redo (:future @edits))) + (t/is (not (:composing @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.