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.