From 95aa63374cbd06760b99a2376ccb311213954460 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Fri, 27 Feb 2026 11:25:19 +0100 Subject: [PATCH] :recycle: Refactor Text Editor v3 --- .../ui/workspace/shapes/text/v3_editor.cljs | 314 ++++++++++++++++++ .../ui/workspace/shapes/text/v3_editor.scss | 13 + .../main/ui/workspace/viewport/actions.cljs | 50 +-- .../app/main/ui/workspace/viewport_wasm.cljs | 27 +- frontend/src/app/render_wasm/api.cljs | 1 + frontend/src/app/render_wasm/text_editor.cljs | 22 +- .../app/render_wasm/text_editor_input.cljs | 241 -------------- render-wasm/src/shapes/text.rs | 7 + render-wasm/src/state/text_editor.rs | 12 +- render-wasm/src/wasm/text_editor.rs | 57 ++-- 10 files changed, 406 insertions(+), 338 deletions(-) create mode 100644 frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs create mode 100644 frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss delete mode 100644 frontend/src/app/render_wasm/text_editor_input.cljs 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 new file mode 100644 index 0000000000..d1d3cd477b --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -0,0 +1,314 @@ +;; 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 INC + +(ns app.main.ui.workspace.shapes.text.v3-editor + "Contenteditable DOM element for WASM text editor input" + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.main.data.helpers :as dsh] + [app.main.data.workspace.texts :as dwt] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.css-cursors :as cur] + [app.render-wasm.api :as wasm.api] + [app.render-wasm.text-editor :as text-editor] + [app.util.dom :as dom] + [app.util.object :as obj] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(def caret-blink-interval-ms 250) + +(defn- sync-wasm-text-editor-content! + "Sync WASM text editor content back to the shape via the standard + commit pipeline. Called after every text-modifying input." + [& {:keys [finalize?]}] + (when-let [{:keys [shape-id content]} + (text-editor/text-editor-sync-content)] + (st/emit! (dwt/v2-update-text-shape-content + shape-id content + :update-name? true + :finalize? finalize?)))) + +(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\-" "")] + (if (>= (count lang) 3) (str/capital lang) (str/upper lang))) + "Noto Color Emoji")) + +(mf/defc text-editor + "Contenteditable element positioned over the text shape to capture input events." + {::mf/wrap-props false} + [props] + (let [shape (obj/get props "shape") + shape-id (dm/get-prop shape :id) + + clip-id (dm/str "text-edition-clip" shape-id) + + contenteditable-ref (mf/use-ref nil) + composing? (mf/use-state false) + + 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) + + [{:keys [x y width height]} transform] + (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id) + selrect-transform (mf/deref refs/workspace-selrect) + [selrect transform] (dsh/get-selrect selrect-transform shape) + selrect-height (:height selrect) + selrect-width (:width selrect) + max-width (max width selrect-width) + max-height (max height selrect-height) + valign (-> shape :content :vertical-align) + y (:y selrect) + y (case valign + "bottom" (+ y (- selrect-height height)) + "center" (+ y (/ (- selrect-height height) 2)) + y)] + [(assoc selrect :y y :width max-width :height max-height) transform]) + + on-composition-start + (mf/use-fn + (fn [_event] + (reset! composing? true))) + + on-composition-end + (mf/use-fn + (fn [^js event] + (reset! composing? false) + (let [data (.-data event)] + (when data + (text-editor/text-editor-insert-text data) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-composition")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))) + + on-paste + (mf/use-fn + (fn [^js event] + (dom/prevent-default event) + (let [clipboard-data (.-clipboardData event) + text (.getData clipboard-data "text/plain")] + (when (and text (seq text)) + (text-editor/text-editor-insert-text text) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-paste")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))) + + on-copy + (mf/use-fn + (fn [^js event] + (when (text-editor/text-editor-is-active?) + (dom/prevent-default event) + (when (text-editor/text-editor-get-selection) + (let [text (text-editor/text-editor-export-selection)] + (.setData (.-clipboardData event) "text/plain" text)))))) + + on-key-down + (mf/use-fn + (fn [^js event] + (when (and (text-editor/text-editor-is-active?) + (not @composing?)) + (let [key (.-key event) + ctrl? (or (.-ctrlKey event) (.-metaKey event)) + shift? (.-shiftKey event)] + + (cond + ;; Escape: finalize and stop + (= key "Escape") + (do + (dom/prevent-default event) + (when-let [node (mf/ref-val contenteditable-ref)] + (.blur node))) + + ;; Ctrl+A: select all (key is "a" or "A" depending on platform) + (and ctrl? (= (str/lower key) "a")) + (do + (dom/prevent-default event) + (text-editor/text-editor-select-all) + (wasm.api/request-render "text-select-all")) + + ;; Enter + (= key "Enter") + (do + (dom/prevent-default event) + (text-editor/text-editor-insert-paragraph) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-paragraph")) + + ;; Backspace + (= key "Backspace") + (do + (dom/prevent-default event) + (text-editor/text-editor-delete-backward) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-delete-backward")) + + ;; Delete + (= key "Delete") + (do + (dom/prevent-default event) + (text-editor/text-editor-delete-forward) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-delete-forward")) + + ;; Arrow keys + (= key "ArrowLeft") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 0 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowRight") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 1 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowUp") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 2 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowDown") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 3 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "Home") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 4 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "End") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 5 shift?) + (wasm.api/request-render "text-cursor-move")) + + ;; Let contenteditable handle text input via on-input + :else nil))))) + + on-input + (mf/use-fn + (fn [^js event] + (let [native-event (.-nativeEvent event) + input-type (.-inputType native-event) + data (.-data native-event)] + ;; Skip composition-related input events - composition-end handles those + (when (and (not @composing?) + (not= input-type "insertCompositionText")) + (when (and data (seq data)) + (text-editor/text-editor-insert-text data) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-input")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) "")))))) + + on-pointer-down + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt))))) + + on-pointer-move + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))))) + + on-pointer-up + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt))))) + + on-click + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-set-cursor-from-offset (.-x off-pt) (.-y off-pt))))) + + on-focus + (mf/use-fn + (fn [^js _event] + (wasm.api/text-editor-start shape-id))) + + on-blur + (mf/use-fn + (fn [^js _event] + (sync-wasm-text-editor-content! {:finalize? true}) + (wasm.api/text-editor-stop))) + + style #js {:pointerEvents "all" + "--editor-container-width" (dm/str width "px") + "--editor-container-height" (dm/str height "px") + "--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}] + + ;; Focus contenteditable on mount + (mf/use-effect + (mf/deps contenteditable-ref) + (fn [] + (when-let [node (mf/ref-val contenteditable-ref)] + (.focus node)))) + + (mf/use-effect + (fn [] + (let [timeout-id (atom nil) + schedule-blink (fn schedule-blink [] + (when (text-editor/text-editor-is-active?) + (wasm.api/request-render "cursor-blink")) + (reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))] + (schedule-blink) + (fn [] + (when @timeout-id + (js/clearTimeout @timeout-id)))))) + + ;; Composition and input events + [:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id) + :transform (dm/str transform) + :data-testid "text-editor"} + [:defs + [:clipPath {:id clip-id} + [:rect {:x x :y y :width width :height height}]]] + + [:foreignObject {:x x :y y :width width :height height} + [:div {:on-click on-click + :on-pointer-down on-pointer-down + :on-pointer-move on-pointer-move + :on-pointer-up on-pointer-up + :class (stl/css :text-editor) + :style style} + [:div + {:ref contenteditable-ref + :contentEditable true + :suppressContentEditableWarning true + :on-composition-start on-composition-start + :on-composition-end on-composition-end + :on-key-down on-key-down + :on-input on-input + :on-paste on-paste + :on-copy on-copy + :on-focus on-focus + :on-blur on-blur + ;; FIXME on-click + ;; :on-click on-click + :id "text-editor-wasm-input" + :class (dm/str (cur/get-dynamic "text" (:rotation shape)) + " " + (stl/css :text-editor-container)) + :data-testid "text-editor-container"}]]]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss new file mode 100644 index 0000000000..8539a7ca29 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss @@ -0,0 +1,13 @@ +.text-editor { + height: 100%; +} + +.text-editor-container { + width: 100%; + height: 100%; + position: absolute; + + opacity: 0; + overflow: hidden; + white-space: pre; +} diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index 041cb6f53a..50cd0acec5 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -19,13 +19,11 @@ [app.main.data.workspace.media :as dwm] [app.main.data.workspace.path :as dwdp] [app.main.data.workspace.specialized-panel :as-alias dwsp] - [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.workspace.sidebar.assets.components :as wsac] [app.main.ui.workspace.viewport.viewport-ref :as uwvv] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm.wasm] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] [app.util.dom.normalize-wheel :as nw] @@ -74,7 +72,6 @@ shift? (kbd/shift? native-event) alt? (kbd/alt? native-event) mod? (kbd/mod? native-event) - off-pt (dom/get-offset-position native-event) left-click? (and (not panning) (dom/left-mouse? event)) middle-click? (and (not panning) (dom/middle-mouse? event))] @@ -94,23 +91,8 @@ (st/emit! (mse/->MouseEvent :down ctrl? shift? alt? meta?) ::dwsp/interrupt) - (when (wasm.api/text-editor-is-active?) - (wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt))) - (when (and (not= edition id) (or text-editing? grid-editing?)) - (st/emit! (dw/clear-edition-mode)) - ;; FIXME: I think this is not completely correct because this - ;; is going to happen even when clicking or selecting text. - ;; Sync and stop WASM text editor when exiting edit mode - #_(when (and text-editing? - (features/active-feature? @st/state "render-wasm/v1") - wasm.wasm/context-initialized?) - (when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :finalize? true))) - (wasm.api/text-editor-stop))) + (st/emit! (dw/clear-edition-mode))) (when (and (not text-editing?) (not blocked) @@ -192,8 +174,6 @@ alt? (kbd/alt? event) meta? (kbd/meta? event) hovering? (some? @hover) - native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event) raw-pt (dom/get-client-position event) pt (uwvv/point->viewport raw-pt)] (st/emit! (mse/->MouseEvent :click ctrl? shift? alt? meta?)) @@ -207,20 +187,6 @@ (not drawing-tool)) (st/emit! (dw/select-shape (:id @hover) shift?))) - ;; FIXME: Maybe we can move into a function of the kind - ;; "text-editor-on-click" - ;; If clicking on a text shape and wasm render is enabled, forward cursor position - (when (and hovering? - (not @space?) - edition ;; Only when already in edit mode - (not drawing-path?) - (not drawing-tool)) - (let [hover-shape @hover] - (when (and (= :text (:type hover-shape)) - (features/active-feature? @st/state "text-editor-wasm/v1") - wasm.wasm/context-initialized?) - (wasm.api/text-editor-set-cursor-from-point (.-x off-pt) (.-y off-pt))))) - (when (and @z? (not @space?) (not edition) @@ -262,19 +228,7 @@ (and editable? (not= id edition) (not read-only?)) (do (st/emit! (dw/select-shape id) - (dw/start-editing-selected)) - ;; If using wasm text-editor, notify WASM to start editing this shape - ;; and set cursor position from the double-click location - (when (and (= type :text) - (features/active-feature? @st/state "text-editor-wasm/v1") - wasm.wasm/context-initialized?) - (wasm.api/text-editor-start id))) - - (and editable? (= id edition) (not read-only?) - (= type :text) - (features/active-feature? @st/state "text-editor-wasm/v1") - wasm.wasm/context-initialized?) - (wasm.api/text-editor-select-all) + (dw/start-editing-selected))) (some? selected-shape) (do diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index b120658a5b..52255eac22 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -30,6 +30,7 @@ [app.main.ui.workspace.shapes.text.editor :as editor-v1] [app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline]] [app.main.ui.workspace.shapes.text.v2-editor :as editor-v2] + [app.main.ui.workspace.shapes.text.v3-editor :as editor-v3] [app.main.ui.workspace.top-toolbar :refer [top-toolbar*]] [app.main.ui.workspace.viewport.actions :as actions] [app.main.ui.workspace.viewport.comments :as comments] @@ -54,7 +55,6 @@ [app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]] [app.main.ui.workspace.viewport.widgets :as widgets] [app.render-wasm.api :as wasm.api] - [app.render-wasm.text-editor-input :refer [text-editor-input]] [app.util.debug :as dbg] [app.util.text-editor :as ted] [beicon.v2.core :as rx] @@ -417,14 +417,7 @@ (when picking-color? [:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref - :canvas-ref canvas-ref}]) - - ;; WASM text editor contenteditable (must be outside SVG to work) - (when (and show-text-editor? - (features/active-feature? @st/state "text-editor-wasm/v1")) - [:& text-editor-input {:shape editing-shape - :zoom zoom - :vbox vbox}])] + :canvas-ref canvas-ref}])] [:canvas {:id "render" :data-testid "canvas-wasm-shapes" @@ -471,14 +464,20 @@ [:g {:style {:pointer-events (if disable-events? "none" "auto")}} ;; Text editor handling: ;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM) - (when (and show-text-editor? - (not (features/active-feature? @st/state "text-editor-wasm/v1"))) - (if (features/active-feature? @st/state "text-editor/v2") + (when show-text-editor? + (cond + (features/active-feature? @st/state "text-editor-wasm/v1") + [:& editor-v3/text-editor {:shape editing-shape + :canvas-ref canvas-ref + :ref text-editor-ref}] + + (features/active-feature? @st/state "text-editor/v2") [:& editor-v2/text-editor {:shape editing-shape :canvas-ref canvas-ref :ref text-editor-ref}] - [:& editor-v1/text-editor-svg {:shape editing-shape - :ref text-editor-ref}])) + + :else [:& editor-v1/text-editor-svg {:shape editing-shape + :ref text-editor-ref}])) (when show-frame-outline? (let [outlined-frame-id diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index e3f55b0d37..4e5513c2b9 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -86,6 +86,7 @@ ;; Re-export public text editor functions (def text-editor-start text-editor/text-editor-start) (def text-editor-stop text-editor/text-editor-stop) +(def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset) (def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point) (def text-editor-pointer-down text-editor/text-editor-pointer-down) (def text-editor-pointer-move text-editor/text-editor-pointer-move) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 21bcca45d2..4277062278 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -16,13 +16,21 @@ [id] (when wasm/context-initialized? (let [buffer (uuid/get-u32 id)] - (h/call wasm/internal-module "_text_editor_start" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3))))) + (when-not (h/call wasm/internal-module "_text_editor_start" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3)) + (throw (js/Error. "TextEditor initialization failed")))))) + +(defn text-editor-set-cursor-from-offset + "Sets caret position from shape relative coordinates" + [x y] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_set_cursor_from_offset" x y))) (defn text-editor-set-cursor-from-point + "Sets caret position from screen (canvas) coordinates" [x y] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y))) @@ -95,7 +103,8 @@ (defn text-editor-stop [] (when wasm/context-initialized? - (h/call wasm/internal-module "_text_editor_stop"))) + (when-not (h/call wasm/internal-module "_text_editor_stop") + (throw (js/Error. "TextEditor finalization failed"))))) (defn text-editor-is-active? ([id] @@ -160,6 +169,7 @@ (finally (mem/free)))))) +;; This is used as a intermediate cache between Clojure global state and WASM state. (def ^:private shape-text-contents (atom {})) (defn- merge-exported-texts-into-content diff --git a/frontend/src/app/render_wasm/text_editor_input.cljs b/frontend/src/app/render_wasm/text_editor_input.cljs deleted file mode 100644 index 7eced4ab16..0000000000 --- a/frontend/src/app/render_wasm/text_editor_input.cljs +++ /dev/null @@ -1,241 +0,0 @@ -;; 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 INC - -(ns app.render-wasm.text-editor-input - "Contenteditable DOM element for WASM text editor input" - (:require - [app.common.geom.shapes :as gsh] - [app.main.data.workspace.texts :as dwt] - [app.main.store :as st] - [app.render-wasm.api :as wasm.api] - [app.render-wasm.text-editor :as text-editor] - [app.util.dom :as dom] - [app.util.object :as obj] - [cuerdas.core :as str] - [goog.events :as events] - [rumext.v2 :as mf]) - (:import goog.events.EventType)) - -(def caret-blink-interval-ms 250) - -(defn- sync-wasm-text-editor-content! - "Sync WASM text editor content back to the shape via the standard - commit pipeline. Called after every text-modifying input." - [& {:keys [finalize?]}] - (when-let [{:keys [shape-id content]} (text-editor/text-editor-sync-content)] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :finalize? finalize?)))) - -(mf/defc text-editor-input - "Contenteditable element positioned over the text shape to capture input events." - {::mf/wrap-props false} - [props] - (let [shape (obj/get props "shape") - zoom (obj/get props "zoom") - vbox (obj/get props "vbox") - - contenteditable-ref (mf/use-ref nil) - composing? (mf/use-state false) - - ;; Calculate screen position from shape bounds - shape-bounds (gsh/shape->rect shape) - screen-x (* (- (:x shape-bounds) (:x vbox)) zoom) - screen-y (* (- (:y shape-bounds) (:y vbox)) zoom) - screen-w (* (:width shape-bounds) zoom) - screen-h (* (:height shape-bounds) zoom)] - - ;; Focus contenteditable on mount - (mf/use-effect - (fn [] - (when-let [node (mf/ref-val contenteditable-ref)] - (.focus node)) - js/undefined)) - - (mf/use-effect - (fn [] - (let [timeout-id (atom nil) - schedule-blink (fn schedule-blink [] - (when (text-editor/text-editor-is-active?) - (wasm.api/request-render "cursor-blink")) - (reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))] - (schedule-blink) - (fn [] - (when @timeout-id - (js/clearTimeout @timeout-id)))))) - - ;; Document-level keydown handler for control keys - (mf/use-effect - (fn [] - (let [on-doc-keydown - (fn [e] - (when (and (text-editor/text-editor-is-active?) - (not @composing?)) - (let [key (.-key e) - ctrl? (or (.-ctrlKey e) (.-metaKey e)) - shift? (.-shiftKey e)] - (cond - ;; Escape: finalize and stop - (= key "Escape") - (do - (dom/prevent-default e) - (sync-wasm-text-editor-content! :finalize? true) - (text-editor/text-editor-stop)) - - ;; Ctrl+A: select all (key is "a" or "A" depending on platform) - (and ctrl? (= (str/lower key) "a")) - (do - (dom/prevent-default e) - (text-editor/text-editor-select-all) - (wasm.api/request-render "text-select-all")) - - ;; Enter - (= key "Enter") - (do - (dom/prevent-default e) - (text-editor/text-editor-insert-paragraph) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-paragraph")) - - ;; Backspace - (= key "Backspace") - (do - (dom/prevent-default e) - (text-editor/text-editor-delete-backward) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-delete-backward")) - - ;; Delete - (= key "Delete") - (do - (dom/prevent-default e) - (text-editor/text-editor-delete-forward) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-delete-forward")) - - ;; Arrow keys - (= key "ArrowLeft") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 0 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "ArrowRight") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 1 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "ArrowUp") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 2 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "ArrowDown") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 3 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "Home") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 4 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "End") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 5 shift?) - (wasm.api/request-render "text-cursor-move")) - - ;; Let contenteditable handle text input via on-input - :else nil))))] - (events/listen js/document EventType.KEYDOWN on-doc-keydown true) - (fn [] - (events/unlisten js/document EventType.KEYDOWN on-doc-keydown true))))) - - ;; Composition and input events - (let [on-composition-start - (mf/use-fn - (fn [_event] - (reset! composing? true))) - - on-composition-end - (mf/use-fn - (fn [^js event] - (reset! composing? false) - (let [data (.-data event)] - (when data - (text-editor/text-editor-insert-text data) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-composition")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))) - - on-paste - (mf/use-fn - (fn [^js event] - (dom/prevent-default event) - (let [clipboard-data (.-clipboardData event) - text (.getData clipboard-data "text/plain")] - (when (and text (seq text)) - (text-editor/text-editor-insert-text text) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-paste")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))) - - on-copy - (mf/use-fn - (fn [^js event] - (when (text-editor/text-editor-is-active?) - (dom/prevent-default event) - (when (text-editor/text-editor-get-selection) - (let [text (text-editor/text-editor-export-selection)] - (.setData (.-clipboardData event) "text/plain" text)))))) - - on-input - (mf/use-fn - (fn [^js event] - (let [native-event (.-nativeEvent event) - input-type (.-inputType native-event) - data (.-data native-event)] - ;; Skip composition-related input events - composition-end handles those - (when (and (not @composing?) - (not= input-type "insertCompositionText")) - (when (and data (seq data)) - (text-editor/text-editor-insert-text data) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-input")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))))] - - [:div - {:ref contenteditable-ref - :contentEditable true - :suppressContentEditableWarning true - :on-composition-start on-composition-start - :on-composition-end on-composition-end - :on-input on-input - :on-paste on-paste - :on-copy on-copy - ;; FIXME on-click - ;; :on-click on-click - :id "text-editor-wasm-input" - ;; FIXME - :style {:position "absolute" - :left (str screen-x "px") - :top (str screen-y "px") - :width (str screen-w "px") - :height (str screen-h "px") - :opacity 0 - :overflow "hidden" - :white-space "pre" - :cursor "text" - :z-index 10}}]))) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index b18a7b4aab..ed5c6c290d 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -161,6 +161,13 @@ impl TextPositionWithAffinity { offset, } } + + pub fn reset(&mut self) { + self.position_with_affinity.position = 0; + self.position_with_affinity.affinity = Affinity::Downstream; + self.paragraph = 0; + self.offset = 0; + } } #[derive(Debug)] diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index dc0f4152d4..f68e84ba87 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -33,6 +33,11 @@ impl TextSelection { !self.is_collapsed() } + pub fn reset(&mut self) { + self.anchor.reset(); + self.focus.reset(); + } + pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) { self.anchor = cursor; self.focus = cursor; @@ -133,7 +138,7 @@ impl TextEditorState { self.active_shape_id = Some(shape_id); self.cursor_visible = true; self.last_blink_time = 0.0; - self.selection = TextSelection::new(); + self.selection.reset(); self.is_pointer_selection_active = false; self.pending_events.clear(); } @@ -142,9 +147,10 @@ impl TextEditorState { self.is_active = false; self.active_shape_id = None; self.cursor_visible = false; + self.last_blink_time = 0.0; + self.selection.reset(); self.is_pointer_selection_active = false; self.pending_events.clear(); - self.reset_blink(); } pub fn start_pointer_selection(&mut self) -> bool { @@ -195,13 +201,11 @@ impl TextEditorState { pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.set_caret(*position); - self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.extend_to(*position); - self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index c7285345e5..f153672fb4 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -42,10 +42,14 @@ pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool { } #[no_mangle] -pub extern "C" fn text_editor_stop() { +pub extern "C" fn text_editor_stop() -> bool { with_state_mut!(state, { + if !state.text_editor_state.is_active { + return false; + } state.text_editor_state.stop(); - }); + true + }) } #[no_mangle] @@ -126,12 +130,8 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { return; }; let point = Point::new(x, y); - let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); - let shape_matrix = shape.get_matrix(); state.text_editor_state.start_pointer_selection(); - if let Some(position) = - text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) - { + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state.text_editor_state.set_caret_from_position(&position); } }); @@ -143,7 +143,6 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { if !state.text_editor_state.is_active { return; } - let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); let point = Point::new(x, y); let Some(shape_id) = state.text_editor_state.active_shape_id else { return; @@ -151,11 +150,6 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { let Some(shape) = state.shapes.get(&shape_id) else { return; }; - let shape_matrix = shape.get_matrix(); - let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix) - else { - return; - }; if !state.text_editor_state.is_pointer_selection_active { return; } @@ -163,9 +157,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { return; }; - if let Some(position) = - text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) - { + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state .text_editor_state .extend_selection_from_position(&position); @@ -179,7 +171,6 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { if !state.text_editor_state.is_active { return; } - let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); let point = Point::new(x, y); let Some(shape_id) = state.text_editor_state.active_shape_id else { return; @@ -187,20 +178,13 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { let Some(shape) = state.shapes.get(&shape_id) else { return; }; - let shape_matrix = shape.get_matrix(); - let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix) - else { - return; - }; if !state.text_editor_state.is_pointer_selection_active { return; } let Type::Text(text_content) = &shape.shape_type else { return; }; - if let Some(position) = - text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) - { + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state .text_editor_state .extend_selection_from_position(&position); @@ -209,6 +193,29 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { }); } +#[no_mangle] +pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let point = Point::new(x, y); + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + state.text_editor_state.set_caret_from_position(&position); + } + }); +} + #[no_mangle] pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { with_state_mut!(state, {