From 4c21923c4a228ed620ea214c03c607d72917e558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 20 Jul 2026 12:16:52 +0200 Subject: [PATCH] :bug: Fix accent menu (MacOS) inserting an extra character (#10637) --- .../ui/workspace/shapes/text/v3_editor.cljs | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) 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 55310b0b87..84e786d8cf 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 @@ -61,6 +61,20 @@ (.removeAllRanges sel) (.addRange sel range))))) +(defn- keep-input-alive + "Keeps the capture surface able to receive further input WITHOUT clearing it. + + Unlike `reset-input-node`, this does not empty the surface. The macOS + press-and-hold accent menu replaces the previously typed base character (its + 'marked text' when an accent is chosen, and that replacement only works + while the base character is still present in the surface DOM. Clearing it + drops the marked text, so the accent gets appended instead of replacing it, + producing e.g. 'oö' instead of 'ö'." + [^js node] + (when (and (some? node) + (not= (.-activeElement js/document) node)) + (.focus node))) + (defn- font-family-from-font-id [font-id] (if (str/includes? font-id "gfont-noto-sans") (let [lang (str/replace font-id #"gfont\-noto\-sans\-" "")] @@ -94,6 +108,11 @@ contenteditable-ref (mf/use-ref nil) + ;; Number of characters the browser is about to replace via marked text + ;; (macOS press-and-hold accent menu). Set on `beforeinput`, consumed on + ;; `input`. See on-before-input / on-input. + pending-replace-ref (mf/use-ref 0) + fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false) fallback-families (map (fn [font] (font-family-from-font-id (:font-id font))) fallback-fonts) @@ -279,6 +298,32 @@ ;; Let contenteditable handle text input via on-input :else nil))))) + ;; Native `beforeinput` listener (see the use-effect that registers it). + ;; We use the native event, not React's synthetic `onBeforeInput`, because + ;; only the native event reliably exposes `getTargetRanges()`. + ;; + ;; The macOS press-and-hold accent menu does NOT use composition events: + ;; picking an accent arrives as a plain `insertText` whose target range + ;; spans the previously typed base character (marked text), so the browser + ;; replaces it instead of appending. We remember how many characters that + ;; range covers so `on-input` can delete them from the WASM editor before + ;; inserting the accented one. We ignore it while the WASM editor has an + ;; active selection, since that selection is replaced by `insert-text` + ;; itself and deleting extra characters would corrupt the content. + on-before-input + (mf/use-fn + (fn [^js native] + (mf/set-ref-val! pending-replace-ref 0) + (when (and (= (.-inputType native) "insertText") + (not (.-isComposing native)) + (not (text-editor/text-editor-has-selection?))) + (let [ranges (.getTargetRanges native)] + (when (pos? (.-length ranges)) + (let [range (aget ranges 0) + n (- (.-endOffset range) (.-startOffset range))] + (when (pos? n) + (mf/set-ref-val! pending-replace-ref n)))))))) + on-input (mf/use-fn (fn [^js event] @@ -289,10 +334,19 @@ (when (and (not (composing-event? event)) (not= input-type "insertCompositionText")) (when (and data (seq data)) + ;; Marked-text replacement (macOS accent menu): remove the base + ;; character(s) the browser is replacing before inserting. + (let [pending (mf/ref-val pending-replace-ref)] + (dotimes [_ pending] + (text-editor/text-editor-delete-backward))) (text-editor/text-editor-insert-text data) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-input")) - (reset-input-node (mf/ref-val contenteditable-ref)))))) + (mf/set-ref-val! pending-replace-ref 0) + ;; IMPORTANT: do NOT clear the surface here (see keep-input-alive): + ;; the browser must retain the just-typed character so the macOS + ;; accent menu can replace it on the next input. + (keep-input-alive (mf/ref-val contenteditable-ref)))))) on-pointer-down (mf/use-fn @@ -345,6 +399,19 @@ "--editor-container-height" (dm/str height "px") "--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}] + ;; Register the native `beforeinput` listener. React's synthetic + ;; `onBeforeInput` does not expose `getTargetRanges()`, even with + ;; nativeEvent (it's fully synthetic, composed of other two events). + ;; We need `getTargetRranges` to detect macOS accent-menu replacements. + ;; See https://github.com/react/react/issues/11211 + (mf/use-effect + (mf/deps on-before-input) + (fn [] + (when-let [node (mf/ref-val contenteditable-ref)] + (.addEventListener node "beforeinput" on-before-input) + (fn [] + (.removeEventListener node "beforeinput" on-before-input))))) + ;; Focus contenteditable on mount (mf/use-effect (mf/deps contenteditable-ref) @@ -394,6 +461,13 @@ {:ref contenteditable-ref :contentEditable true :suppressContentEditableWarning true + ;; The surface retains typed text between keystrokes (see + ;; keep-input-alive), so disable text assistance that would otherwise + ;; rewrite that retained text and desync the WASM editor. + ;; NOTE: this was already not working in v1/v2 + :spellCheck false + :autoCorrect "off" + :autoCapitalize "off" :on-composition-start on-composition-start :on-composition-update on-composition-update :on-composition-end on-composition-end