🐛 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
This commit is contained in:
alonso.torres 2026-10-21 10:55:58 +00:00
parent 516dae5319
commit 09212dcf75
4 changed files with 82 additions and 12 deletions

View File

@ -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))

View File

@ -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))

View File

@ -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.

View File

@ -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.