From d7527b63a2bfa292eabcba4136074c719a7367d8 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Wed, 23 Sep 2026 22:16:17 +0900 Subject: [PATCH] :bug: Fix Japanese IME Enter duplication in comment input (#11768) * :bug: Fix Japanese IME Enter duplication in comment input Comment keydown handler treated every Enter as a Penpot line-break action, so confirming an IME composition duplicated the text with an extra newline and a zero-width space. Guard the whole custom keydown processing while the event belongs to an active IME composition, mirroring the v3 text-editor precedent. Closes #11757 Signed-off-by: Junsoo Choi AI-assisted-by: muse-spark-1.3-contributor * :bug: Keep composing Escape from closing comment thread The parent floating-thread keydown handler closed the thread on every Escape, including one that cancels an active IME composition. Apply the same composition guard so composing Escape stays owned by the IME while plain Escape still closes the thread. Closes #11757 Signed-off-by: Junsoo Choi AI-assisted-by: muse-spark-1.3-contributor * :bug: Test comment IME guard through key-action resolver The composition predicate test only verified the predicate itself, so a guard moved to the wrong place or a handler bypassing it would stay green. Resolve comment and thread keydowns through a pure resolve-comment-key-action seam and verify the observable behavior: composing keys yield :ime-owned with zero Penpot side effects while the same plain keys keep their existing commands. Closes #11757 Signed-off-by: Junsoo Choi AI-assisted-by: muse-spark-1.3-contributor * :bug: Test comment IME handlers through direct calls The key-action resolver only verified a return value, so handler wiring regressions would stay green, and it read the mention snapshot before handle-select ran, changing the existing ordering. Remove the resolver, extract the two handler bodies as directly callable fns with the original select-first ordering, and assert the fired side effects instead. Closes #11757 Signed-off-by: Junsoo Choi AI-assisted-by: muse-spark-1.3-contributor * :books: Remove text-editor v3 references from comment IME docs The comment IME guard is specific to the comment editor, so the docstrings no longer present it as following a v3 text-editor or render-engine precedent. Reviewers read that wording as tying this comment bug fix to unrelated subsystems. Only docstring text changes; handler logic and test assertions are untouched. Closes #11757 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: makesomethingshit * :bug: Review comments, and fix edge case --------- Signed-off-by: Junsoo Choi Signed-off-by: makesomethingshit Co-authored-by: alonso.torres --- frontend/src/app/main/ui/comments.cljs | 146 +++++++++++------- frontend/test/frontend_tests/runner.cljs | 2 + .../ui/comment_input_ime_test.cljs | 129 ++++++++++++++++ 3 files changed, 220 insertions(+), 57 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/comment_input_ime_test.cljs diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 2d6b061ea0..da0510654e 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -152,6 +152,62 @@ (and (= (count content) 1) (= (first content) zero-width-space))))) +(defn- composing-event? + "True when a keydown belongs to an active IME composition. keyCode 229 + covers browsers that send the confirming keydown with isComposing + false." + [^js event] + (or (.. event -nativeEvent -isComposing) + (= 229 (.-keyCode event)))) + +(defn handle-comment-input-key-down + "Keydown handler for the comment input. Does nothing while an IME + composition owns the key. Otherwise calls `on-select` and then, with + the caret span from `get-span`, routes the key: mention-panel keys + while a mention is open, Escape, mod+Enter, Enter and Backspace. + + `cur-mention` holds the open mention text or nil and is read after + `on-select`, which may update it. `on-newline` and `on-backspace` + receive `{:event :node :span-node :offset}`." + [event {:keys [node cur-mention mentions-s on-select get-span + on-esc on-ctrl-enter on-newline on-backspace]}] + (when-not (composing-event? event) + (on-select event) + (when (some? node) + (when-let [[span-node offset] (get-span node)] + (let [mention-cmd (when @cur-mention + (cond + (kbd/enter? event) :insert-selected-mention + (kbd/down-arrow? event) :insert-next-mention + (kbd/up-arrow? event) :insert-prev-mention + (kbd/esc? event) :hide-mentions)) + ctx {:event event :node node :span-node span-node :offset offset}] + (cond + (some? mention-cmd) + (do (dom/prevent-default event) + (dom/stop-propagation event) + (rx/push! mentions-s {:type mention-cmd})) + + (and (kbd/esc? event) (fn? on-esc)) + (on-esc event) + + (and (kbd/mod? event) (kbd/enter? event) (fn? on-ctrl-enter)) + (on-ctrl-enter event) + + (kbd/enter? event) + (on-newline ctx) + + (kbd/backspace? event) + (on-backspace ctx))))))) + +(defn handle-thread-key-down + "Keydown handler for the floating thread: calls `on-close` on Escape + unless an IME composition owns the key." + [event on-close] + (when (and (kbd/esc? event) + (not (composing-event? event))) + (on-close event))) + ;; Component that renders the component content (mf/defc comment-content* {::mf/private true} @@ -356,59 +412,35 @@ (mf/use-fn (mf/deps on-esc on-ctrl-enter handle-select handle-input) (fn [event] - (handle-select event) - (when-let [node (mf/ref-val local-ref)] - (when-let [[span-node offset] (current-text-node node)] - (cond - (and @cur-mention (kbd/enter? event)) - (do (dom/prevent-default event) - (dom/stop-propagation event) - (rx/push! mentions-s {:type :insert-selected-mention})) - - (and @cur-mention (kbd/down-arrow? event)) - (do (dom/prevent-default event) - (dom/stop-propagation event) - (rx/push! mentions-s {:type :insert-next-mention})) - - (and @cur-mention (kbd/up-arrow? event)) - (do (dom/prevent-default event) - (dom/stop-propagation event) - (rx/push! mentions-s {:type :insert-prev-mention})) - - (and @cur-mention (kbd/esc? event)) - (do (dom/prevent-default event) - (dom/stop-propagation event) - (rx/push! mentions-s {:type :hide-mentions})) - - (and (kbd/esc? event) (fn? on-esc)) - (on-esc event) - - (and (kbd/mod? event) (kbd/enter? event) (fn? on-ctrl-enter)) - (on-ctrl-enter event) - - (kbd/enter? event) - (let [sel (wapi/get-selection) - range (.getRangeAt sel 0)] + (handle-comment-input-key-down + event + {:node (mf/ref-val local-ref) + :cur-mention cur-mention + :mentions-s mentions-s + :on-select handle-select + :get-span current-text-node + :on-esc on-esc + :on-ctrl-enter on-ctrl-enter + :on-newline + (fn [{:keys [event span-node offset]}] + (let [range (.getRangeAt (wapi/get-selection) 0)] + (dom/prevent-default event) + (dom/stop-propagation event) + (.deleteContents range) + (handle-input) + (let [txt (.-textContent span-node)] + (dom/set-html! span-node (dm/str (dom/escape-html (subs txt 0 offset)) "\n" zero-width-space (dom/escape-html (subs txt offset)))) + (wapi/set-cursor! span-node (inc offset)) + (handle-input)))) + :on-backspace + (fn [{:keys [event node span-node offset]}] + (let [prev-node (get-prev-node node span-node)] + (when (and (some? prev-node) + (= "mention" (dom/get-data prev-node "type")) + (= offset 1)) (dom/prevent-default event) (dom/stop-propagation event) - (let [[span-node offset] (current-text-node node)] - (.deleteContents range) - (handle-input) - - (when span-node - (let [txt (.-textContent span-node)] - (dom/set-html! span-node (dm/str (dom/escape-html (subs txt 0 offset)) "\n" zero-width-space (dom/escape-html (subs txt offset)))) - (wapi/set-cursor! span-node (inc offset)) - (handle-input))))) - - (kbd/backspace? event) - (let [prev-node (get-prev-node node span-node)] - (when (and (some? prev-node) - (= "mention" (dom/get-data prev-node "type")) - (= offset 1)) - (dom/prevent-default event) - (dom/stop-propagation event) - (.remove prev-node))))))))] + (.remove prev-node))))})))] (mf/with-layout-effect [autofocus] (when ^boolean autofocus @@ -1022,8 +1054,6 @@ [thread-id] (l/derived (l/in [:comments thread-id]) st/state)) - - (mf/defc comment-floating-thread* {::mf/wrap [mf/memo]} [{:keys [thread zoom origin position-modifier viewport]}] @@ -1066,10 +1096,12 @@ on-key-down (mf/use-fn (fn [event] - (when (kbd/esc? event) - (dom/prevent-default event) - (dom/stop-propagation event) - (st/emit! (dcm/close-thread))))) + (handle-thread-key-down + event + (fn [event] + (dom/prevent-default event) + (dom/stop-propagation event) + (st/emit! (dcm/close-thread)))))) on-cancel (mf/use-fn #(st/emit! (dcm/close-thread)))] diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 18dc4472ed..78e7b11f20 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -93,6 +93,7 @@ [frontend-tests.tokens.workspace-tokens-remap-test] [frontend-tests.ui.check-updates-test] [frontend-tests.ui.colorpicker-token-set-order-test] + [frontend-tests.ui.comment-input-ime-test] [frontend-tests.ui.comments-clustering-test] [frontend-tests.ui.comments-position-modifier-test] [frontend-tests.ui.ds-controls-numeric-input-test] @@ -214,6 +215,7 @@ 'frontend-tests.tokens.workspace-tokens-remap-test 'frontend-tests.ui.check-updates-test 'frontend-tests.ui.colorpicker-token-set-order-test + 'frontend-tests.ui.comment-input-ime-test 'frontend-tests.ui.comments-clustering-test 'frontend-tests.ui.comments-position-modifier-test 'frontend-tests.ui.ds-controls-numeric-input-test diff --git a/frontend/test/frontend_tests/ui/comment_input_ime_test.cljs b/frontend/test/frontend_tests/ui/comment_input_ime_test.cljs new file mode 100644 index 0000000000..cafce64b15 --- /dev/null +++ b/frontend/test/frontend_tests/ui/comment_input_ime_test.cljs @@ -0,0 +1,129 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.ui.comment-input-ime-test + "Keydown handling of the comment input and floating thread: keys owned + by an IME composition run nothing, all other keys keep their + commands." + (:require + [app.main.ui.comments :as cmt] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true])) + +(defn- keydown + "Fake keyboard event. preventDefault/stopPropagation append to `log`." + [key {:keys [composing? key-code mod? log]}] + #js {:key key + :keyCode (or key-code 0) + :ctrlKey (boolean mod?) + :metaKey (boolean mod?) + :preventDefault #(some-> log (swap! conj :prevent-default)) + :stopPropagation #(some-> log (swap! conj :stop-propagation)) + :nativeEvent #js {:isComposing (boolean composing?)}}) + +(def ^:private composition-modes + {"isComposing" {:composing? true} + "keyCode 229" {:key-code 229}}) + +(def ^:private input-keys + ["Enter" "Escape" "ArrowDown" "ArrowUp" "Backspace"]) + +(defn- run-input! + "Runs the comment-input handler for `key` and returns the side-effect + log. Mention-panel commands are observed on a real subject and the + open mention lives in an atom, as in the component." + [key {:keys [event-opts mention select-sets-mention node span on-esc? on-ctrl-enter?] + :or {node :node span [:span 3] on-esc? true on-ctrl-enter? true}}] + (let [log (atom []) + cur-mention (atom mention) + mentions-s (rx/subject) + sub (rx/sub! mentions-s #(swap! log conj [:mention (:type %)])) + event (keydown key (assoc event-opts :log log))] + (cmt/handle-comment-input-key-down + event + {:node node + :cur-mention cur-mention + :mentions-s mentions-s + :on-select (fn [e] + (t/is (identical? event e)) + (swap! log conj :select) + (some->> select-sets-mention (reset! cur-mention))) + :get-span (fn [n] + (t/is (= node n)) + span) + :on-esc (when on-esc? #(swap! log conj :on-esc)) + :on-ctrl-enter (when on-ctrl-enter? #(swap! log conj :on-ctrl-enter)) + :on-newline #(swap! log conj [:newline (dissoc % :event)]) + :on-backspace #(swap! log conj [:backspace (dissoc % :event)])}) + (rx/dispose! sub) + @log)) + +(defn- run-thread! + [key event-opts] + (let [log (atom [])] + (cmt/handle-thread-key-down (keydown key event-opts) + #(swap! log conj :close)) + @log)) + +(def ^:private ctx {:node :node :span-node :span :offset 3}) + +(t/deftest composing-keys-run-nothing + (doseq [[mode opts] composition-modes + key input-keys + mention [nil "@bob"]] + (t/testing (str key " via " mode ", mention " (pr-str mention)) + (t/is (= [] (run-input! key {:event-opts opts :mention mention})))))) + +(t/deftest plain-keys-keep-their-commands + (t/testing "Enter inserts a line break at the caret span" + (t/is (= [:select [:newline ctx]] (run-input! "Enter" {})))) + (t/testing "mod+Enter submits" + (t/is (= [:select :on-ctrl-enter] + (run-input! "Enter" {:event-opts {:mod? true}})))) + (t/testing "mod+Enter without on-ctrl-enter falls back to a line break" + (t/is (= [:select [:newline ctx]] + (run-input! "Enter" {:event-opts {:mod? true} :on-ctrl-enter? false})))) + (t/testing "Escape calls on-esc" + (t/is (= [:select :on-esc] (run-input! "Escape" {})))) + (t/testing "Escape without on-esc does nothing else" + (t/is (= [:select] (run-input! "Escape" {:on-esc? false})))) + (t/testing "Backspace runs the mention-deletion check" + (t/is (= [:select [:backspace ctx]] (run-input! "Backspace" {})))) + (t/testing "other keys only sync the selection" + (t/is (= [:select] (run-input! "a" {}))))) + +(t/deftest open-mention-routes-panel-keys + (doseq [[key cmd] {"Enter" :insert-selected-mention + "ArrowDown" :insert-next-mention + "ArrowUp" :insert-prev-mention + "Escape" :hide-mentions}] + (t/testing key + (t/is (= [:select :prevent-default :stop-propagation [:mention cmd]] + (run-input! key {:mention "@bob"}))))) + (t/testing "Backspace is not a panel key" + (t/is (= [:select [:backspace ctx]] + (run-input! "Backspace" {:mention "@bob"}))))) + +(t/deftest mention-is-read-after-select + (t/testing "a mention opened by on-select routes the same key" + (t/is (= [:select :prevent-default :stop-propagation + [:mention :insert-selected-mention]] + (run-input! "Enter" {:select-sets-mention "@new"}))))) + +(t/deftest missing-caret-target-runs-only-select + (t/testing "no input node" + (t/is (= [:select] (run-input! "Enter" {:node nil})))) + (t/testing "caret outside a text span" + (t/is (= [:select] (run-input! "Enter" {:span nil}))))) + +(t/deftest floating-thread-escape + (doseq [[mode opts] composition-modes] + (t/testing (str "Escape via " mode " keeps the thread open") + (t/is (= [] (run-thread! "Escape" opts))))) + (t/testing "plain Escape closes the thread" + (t/is (= [:close] (run-thread! "Escape" {})))) + (t/testing "other keys do nothing" + (t/is (= [] (run-thread! "Enter" {})))))