diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index dd03d7601e..162471be14 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -509,6 +509,7 @@ (dissoc :current-file-id :workspace-editor-state + :workspace-wasm-editor-styles :workspace-media-objects :workspace-persistence :workspace-presence diff --git a/frontend/src/app/main/data/workspace/edition.cljs b/frontend/src/app/main/data/workspace/edition.cljs index 5e36dc1771..cff9bf9c9d 100644 --- a/frontend/src/app/main/data/workspace/edition.cljs +++ b/frontend/src/app/main/data/workspace/edition.cljs @@ -8,6 +8,9 @@ (:require [app.main.data.helpers :as dsh] [app.main.data.workspace.path.common :as dwpc] + [app.main.features :as features] + [app.render-wasm.api :as wasm.api] + [app.render-wasm.text-editor :as text-editor] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -50,12 +53,19 @@ (-> state (update :workspace-local dissoc :edition :edit-path) (update :workspace-drawing dissoc :tool :object :lock) - (dissoc :workspace-grid-edition))) + (dissoc :workspace-grid-edition) + (dissoc :workspace-wasm-editor-styles))) ptk/WatchEvent (watch [_ state _] (let [id (get-in state [:workspace-local :edition])] (rx/concat (when (some? id) - (dwpc/finish-path))))))) + (dwpc/finish-path))))) + + ptk/EffectEvent + (effect [_ state _] + (when (features/active-feature? state "text-editor-wasm/v1") + (text-editor/text-editor-dispose) + (wasm.api/request-render "clear-edition-mode"))))) diff --git a/frontend/src/app/main/data/workspace/text/shortcuts.cljs b/frontend/src/app/main/data/workspace/text/shortcuts.cljs index 72cc1d396c..ca2dd351b9 100644 --- a/frontend/src/app/main/data/workspace/text/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/text/shortcuts.cljs @@ -114,11 +114,22 @@ (defn calculate-text-values [shape] - (let [state-map (if (features/active-feature? @st/state "text-editor/v2") + (let [state-map (cond + (features/active-feature? @st/state "text-editor-wasm/v1") + (deref refs/workspace-wasm-editor-styles) + + (features/active-feature? @st/state "text-editor/v2") (deref refs/workspace-v2-editor-state) + + :else (deref refs/workspace-editor-state)) + + editor-styles (when (features/active-feature? @st/state "text-editor-wasm/v1") + (get state-map (:id shape))) + editor-state (when-not (features/active-feature? @st/state "text-editor/v2") (get state-map (:id shape))) + editor-instance (when (features/active-feature? @st/state "text-editor/v2") (deref refs/workspace-editor))] (d/merge @@ -126,12 +137,14 @@ {:shape shape :attrs txt/root-attrs}) (dwt/current-paragraph-values - {:editor-state editor-state + {:editor-styles editor-styles + :editor-state editor-state :editor-instance editor-instance :shape shape :attrs txt/paragraph-attrs}) (dwt/current-text-values - {:editor-state editor-state + {:editor-styles editor-styles + :editor-state editor-state :editor-instance editor-instance :shape shape :attrs txt/text-node-attrs})))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index ea48693eb2..7f3b156b26 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -250,6 +250,14 @@ [{:keys [attrs shape]}] (shape-current-values shape txt/is-root-node? attrs)) +(defn v3-current-text-values + [{:keys [editor-styles attrs]}] + (let [result (-> editor-styles + ;; If we use dm/select-keys compilation fails + (select-keys attrs)) + result (if (empty? result) txt/default-text-attrs result)] + result)) + (defn v2-current-text-values [{:keys [editor-instance attrs]}] (let [result (-> (.-currentStyle editor-instance) @@ -266,8 +274,9 @@ (shape-current-values shape txt/is-paragraph-node? attrs))) (defn current-paragraph-values - [{:keys [editor-state editor-instance attrs shape] :as options}] + [{:keys [editor-styles editor-state editor-instance attrs shape] :as options}] (cond + (some? editor-styles) (v3-current-text-values options) (some? editor-instance) (v2-current-text-values options) (some? editor-state) (v1-current-paragraph-values options) :else (shape-current-values shape txt/is-paragraph-node? attrs))) @@ -282,8 +291,9 @@ result)) (defn current-text-values - [{:keys [editor-state editor-instance attrs shape] :as options}] + [{:keys [editor-styles editor-state editor-instance attrs shape] :as options}] (cond + (some? editor-styles) (v3-current-text-values options) (some? editor-instance) (v2-current-text-values options) (some? editor-state) (v1-current-text-values options) :else (shape-current-values shape txt/is-text-node? attrs))) @@ -480,13 +490,21 @@ (ptk/reify ::update-text-with-function ptk/UpdateEvent (update [_ state] + ;; This is only called when `[:workspace-editor-state id]` is set, this property + ;; keeps a Draft.js EditorState object. (d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-inline-styles-fn (comp update-node-fn migrate-node))) ptk/WatchEvent (watch [_ state _] (when (or - (and (features/active-feature? state "text-editor/v2") (nil? (:workspace-editor state))) - (and (not (features/active-feature? state "text-editor/v2")) (nil? (get-in state [:workspace-editor-state id])))) + (and (features/active-feature? state "text-editor-wasm/v1") + (nil? (get-in state [:workspace-wasm-editor-styles id]))) + (and (features/active-feature? state "text-editor/v2") + (not (features/active-feature? state "text-editor-wasm/v1")) + (nil? (:workspace-editor state))) + (and (not (features/active-feature? state "text-editor/v2")) + (not (features/active-feature? state "text-editor-wasm/v1")) + (nil? (get-in state [:workspace-editor-state id])))) (let [page-id (or (get options :page-id) (get state :current-page-id)) objects (dsh/lookup-page-objects state page-id) @@ -513,7 +531,12 @@ ptk/EffectEvent (effect [_ state _] - (when (features/active-feature? state "text-editor/v2") + (cond + (features/active-feature? state "text-editor-wasm/v1") + (let [styles ((comp update-node-fn migrate-node))] + (wasm.api/apply-styles-to-selection styles)) + + (features/active-feature? state "text-editor/v2") (when-let [instance (:workspace-editor state)] (let [styles (some-> (editor.v2/getCurrentStyle instance) (styles/get-styles-from-style-declaration :removed-mixed true) @@ -792,7 +815,7 @@ (when has-selection? (let [span-attrs (select-keys attrs txt/text-node-attrs)] (when (not (empty? span-attrs)) - (let [result (wasm.api/apply-style-to-selection span-attrs)] + (let [result (wasm.api/apply-styles-to-selection span-attrs)] (when result (rx/of (v2-update-text-shape-content (:shape-id result) (:content result) @@ -905,7 +928,7 @@ {:typography-ref-id typ-id :typography-ref-file file-id})))))))) -;; -- New Editor +;; -- Text Editor v2 (defn v2-update-text-editor-styles [id new-styles] @@ -1121,3 +1144,7 @@ (cond-> (or (some? width) (some? height)) (gsh/transform-shape (ctm/change-size shape width height)))))) {:undo-group (when new-shape? id)}))))))) + +;; -- Text Editor v3 + +;; @see texts_v3.cljs diff --git a/frontend/src/app/main/data/workspace/texts_v3.cljs b/frontend/src/app/main/data/workspace/texts_v3.cljs new file mode 100644 index 0000000000..4f0834d7d2 --- /dev/null +++ b/frontend/src/app/main/data/workspace/texts_v3.cljs @@ -0,0 +1,20 @@ +;; 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.data.workspace.texts-v3 + (:require + [app.common.types.text :as txt] + [potok.v2.core :as ptk])) + +(defn v3-update-text-editor-styles + [id new-styles] + (ptk/reify ::v3-update-text-editor-styles + ptk/UpdateEvent + (update [_ state] + (let [merged-styles (merge (txt/get-default-text-attrs) + (get-in state [:workspace-global :default-font]) + new-styles)] + (update-in state [:workspace-wasm-editor-styles id] (fnil merge {}) merged-styles))))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 124ee35b6e..58fc3d020b 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -378,6 +378,9 @@ (def workspace-modifiers (l/derived :workspace-modifiers st/state)) +(def workspace-wasm-editor-styles + (l/derived :workspace-wasm-editor-styles st/state)) + (def workspace-wasm-modifiers (l/derived :workspace-wasm-modifiers st/state)) 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 1b77ee8660..8a2138bb99 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 @@ -317,10 +317,6 @@ (reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))] (schedule-blink) (fn [] - ;; ESTO ES JUSTO LO QUE NO QUIERO, NO QUIERO QUE SE HAGA - ;; DISPOSE CUANDO SE DESMONTA EL COMPONENTE. - #_(when (text-editor/text-editor-dispose) - (wasm.api/request-render "text-editor-dispose")) (when @timeout-id (js/clearTimeout @timeout-id)))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 38c6f86cd7..7cc34e5713 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -305,6 +305,7 @@ (when (not= "INPUT" (-> (dom/get-active) (dom/get-tag-name))) (let [node (txu/get-text-editor-content)] (dom/focus! node))))))}] + (hooks/use-stream expand-stream #(swap! state* assoc-in [:more-options] true)) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs index 21cd88b881..41673ddd89 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs @@ -6,6 +6,7 @@ (ns app.main.ui.workspace.sidebar.options.shapes.text (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.types.shape.layout :as ctl] [app.common.types.text :as txt] @@ -85,10 +86,20 @@ (mf/deref parents-by-ids-ref) state-map - (if (features/active-feature? @st/state "text-editor/v2") + (cond + (features/active-feature? @st/state "text-editor-wasm/v1") + (mf/deref refs/workspace-wasm-editor-styles) + + (features/active-feature? @st/state "text-editor/v2") (mf/deref refs/workspace-v2-editor-state) + + :else (mf/deref refs/workspace-editor-state)) + editor-styles + (when (features/active-feature? @st/state "text-editor-wasm/v1") + (get state-map id)) + editor-state (when (not (features/active-feature? @st/state "text-editor/v2")) (get state-map id)) @@ -99,25 +110,28 @@ fill-values (dwt/current-text-values - {:editor-state editor-state + {:editor-styles editor-styles + :editor-state editor-state :editor-instance editor-instance :shape shape :attrs (conj txt/text-fill-attrs :fills)}) text-values - (merge + (d/merge (select-keys shape [:grow-type]) (select-keys shape fill/fill-attrs) (dwt/current-root-values {:shape shape :attrs txt/root-attrs}) (dwt/current-paragraph-values - {:editor-state editor-state + {:editor-styles editor-styles + :editor-state editor-state :editor-instance editor-instance :shape shape :attrs txt/paragraph-attrs}) (dwt/current-text-values - {:editor-state editor-state + {:editor-styles editor-styles + :editor-state editor-state :editor-instance editor-instance :shape shape :attrs txt/text-node-attrs}))] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 27164d94f0..0e66daf383 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -22,6 +22,7 @@ [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.config :as cf] + [app.main.data.workspace.texts-v3 :as texts] [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] @@ -51,6 +52,7 @@ [cuerdas.core :as str] [promesa.core :as p] [rumext.v2 :as mf])) + (def use-dpr? (contains? cf/flags :render-wasm-dpr)) (defn text-editor-wasm? @@ -71,7 +73,6 @@ (def ^:const INPUT-MODIFIER-U8-SIZE 44) (def ^:const INPUT-MODIFIER-U32-SIZE (/ INPUT-MODIFIER-U8-SIZE 4)) - (def ^:const GRID-LAYOUT-ROW-U8-SIZE 8) (def ^:const GRID-LAYOUT-COLUMN-U8-SIZE 8) (def ^:const GRID-LAYOUT-CELL-U8-SIZE 36) @@ -86,6 +87,13 @@ ;; Threshold below which we use synchronous processing (no chunking overhead) (def ^:const ASYNC_THRESHOLD 100) +;; Text editor events. +(def ^:const TEXT_EDITOR_EVENT_NONE 0) +(def ^:const TEXT_EDITOR_EVENT_CONTENT_CHANGED 1) +(def ^:const TEXT_EDITOR_EVENT_SELECTION_CHANGED 2) +(def ^:const TEXT_EDITOR_EVENT_STYLES_CHANGED 3) +(def ^:const TEXT_EDITOR_EVENT_NEEDS_LAYOUT 4) + ;; Re-export public WebGL functions (def capture-canvas-pixels webgl/capture-canvas-pixels) (def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels) @@ -99,6 +107,7 @@ (def text-editor-pointer-down text-editor/text-editor-pointer-down) (def text-editor-pointer-move text-editor/text-editor-pointer-move) (def text-editor-pointer-up text-editor/text-editor-pointer-up) +(def text-editor-get-current-styles text-editor/text-editor-get-current-styles) (def text-editor-has-focus? text-editor/text-editor-has-focus?) (def text-editor-has-selection? text-editor/text-editor-has-selection?) (def text-editor-select-all text-editor/text-editor-select-all) @@ -162,18 +171,24 @@ ;; Update text editor blink (so cursor toggles) using the same timestamp (try - (when wasm/context-initialized? - ;; Render text editor overlay on top of main canvas (only if feature enabled) - ;; Determine if text-editor-wasm feature is active without requiring - ;; app.main.features to avoid circular dependency: check runtime and - ;; persisted feature sets in the store state. - (when (is-text-editor-wasm-enabled @st/state) - (text-editor/text-editor-update-blink timestamp) - (text-editor/text-editor-render-overlay) - ;; Poll for editor events; if any event occurs, trigger a re-render - (let [ev (text-editor/text-editor-poll-event)] - (when (and ev (not= ev 0)) - (request-render "text-editor-event"))))) + (when (is-text-editor-wasm-enabled @st/state) + (text-editor/text-editor-update-blink timestamp) + (text-editor/text-editor-render-overlay) + ;; Poll for editor events; if any event occurs, trigger a re-render + (let [ev (text-editor/text-editor-poll-event)] + (when (and ev (not= ev TEXT_EDITOR_EVENT_NONE)) + ;; When StylesChanged, get the current styles. + (case ev + ;; StylesChanged Event + TEXT_EDITOR_EVENT_STYLES_CHANGED + (let [current-styles (text-editor/text-editor-get-current-styles) + shape-id (text-editor/text-editor-get-active-shape-id)] + (st/emit! (texts/v3-update-text-editor-styles shape-id current-styles))) + + ;; Default case + nil) + + (request-render "text-editor-event")))) (catch :default e (js/console.error "text-editor overlay/update failed:" e))) @@ -279,11 +294,12 @@ (h/call wasm/internal-module "_update_shape_text_layout") result)) -(defn apply-style-to-selection +(defn apply-styles-to-selection "Apply style attrs to the currently selected text spans. Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving." [attrs] - (text-editor/apply-style-to-selection attrs use-shape set-shape-text-content)) + (text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content) + (request-render "apply-styles-to-selection")) (defn set-parent-id [id] diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 9eab0582f5..474a705979 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -75,6 +75,20 @@ :builtin uuid/zero)) +(defn uuid->font-id + [font-uuid] + (if (= font-uuid uuid/zero) + "sourcesanspro" + (or (:id (fonts/find-font-data {:uuid font-uuid})) + (dm/str "custom-" font-uuid)))) + +(defn uuid->font-variant-id + [font-id font-variant-uuid] + (if (= font-variant-uuid uuid/zero) + "regular" + (or (:id (d/seek #(= (:uuid %) font-variant-uuid) + (:variants (fonts/get-font-data font-id)))) + "regular"))) (defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style] (case (font-backend font-id) diff --git a/frontend/src/app/render_wasm/mem.cljs b/frontend/src/app/render_wasm/mem.cljs index 4a9b7aa5e3..5b65471a22 100644 --- a/frontend/src/app/render_wasm/mem.cljs +++ b/frontend/src/app/render_wasm/mem.cljs @@ -64,25 +64,12 @@ (defn read-string "Read a UTF-8 string from WASM memory given a byte pointer/offset. Uses Emscripten's UTF8ToString to decode the string." - [ptr] - (h/call wasm/internal-module "UTF8ToString" ptr)) - -(defn read-null-terminated-string - "Read a null-terminated UTF-8 string from WASM memory. - Manually reads bytes until null terminator and decodes using TextDecoder." - [ptr] - (when (and ptr (not (zero? ptr))) - (let [heap (get-heap-u8) - ;; Find the null terminator - end-idx (loop [idx ptr] - (if (zero? (aget heap idx)) - idx - (recur (inc idx)))) - ;; Extract the bytes (excluding null terminator) - bytes (.slice heap ptr end-idx) - ;; Decode using TextDecoder - decoder (js/TextDecoder. "utf-8")] - (.decode decoder bytes)))) + ([ptr max-bytes ignore-null] + (h/call wasm/internal-module "UTF8ToString" ptr max-bytes ignore-null)) + ([ptr max-bytes] + (h/call wasm/internal-module "UTF8ToString" ptr max-bytes)) + ([ptr] + (h/call wasm/internal-module "UTF8ToString" ptr))) (defn slice "Returns a copy of a portion of a typed array into a new typed array diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 75c8f4cdf8..cadbd72d31 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -7,11 +7,119 @@ (ns app.render-wasm.text-editor "Text editor WASM bindings" (:require + [app.common.types.fills.impl :as types.fills.impl] [app.common.uuid :as uuid] + [app.main.fonts :as main-fonts] + [app.render-wasm.api.fonts :as fonts] [app.render-wasm.helpers :as h] [app.render-wasm.mem :as mem] [app.render-wasm.wasm :as wasm])) +(def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 30 4)) +(def ^:const TEXT_EDITOR_STYLES_FILL_SOLID 0) +(def ^:const TEXT_EDITOR_STYLES_FILL_LINEAR_GRADIENT 1) +(def ^:const TEXT_EDITOR_STYLES_FILL_RADIAL_GRADIENT 2) +(def ^:const TEXT_EDITOR_STYLES_FILL_IMAGE 3) + +(defn- rgba->fill-color + [rgba] + (let [rgb (bit-and rgba 0x00ffffff) + hex (.toString rgb 16)] + (str "#" (.padStart hex 6 "0")))) + +(defn- rgba->opacity + [rgba] + (let [alpha (bit-and (bit-shift-right rgba 24) 0xff)] + (/ (js/Math.round (* (/ alpha 255) 100)) 100))) + +(defn- u8->opacity + [alpha] + (/ (js/Math.round (* (/ alpha 255) 100)) 100)) + +(defn- read-fill-from-heap + [heap-u8 heap-u32 heap-i32 heap-f32 fill-byte-offset] + (let [fill-type (aget heap-u8 fill-byte-offset) + fill-u32-offset (mem/->offset-32 fill-byte-offset)] + (case fill-type + TEXT_EDITOR_STYLES_FILL_SOLID + (let [rgba (aget heap-u32 (+ fill-u32-offset 1))] + {:fill-color (rgba->fill-color rgba) + :fill-opacity (rgba->opacity rgba)}) + + TEXT_EDITOR_STYLES_FILL_LINEAR_GRADIENT + (let [gradient-u32-offset (mem/->offset-32 (+ fill-byte-offset 4)) + start-x (aget heap-f32 gradient-u32-offset) + start-y (aget heap-f32 (+ gradient-u32-offset 1)) + end-x (aget heap-f32 (+ gradient-u32-offset 2)) + end-y (aget heap-f32 (+ gradient-u32-offset 3)) + alpha (aget heap-u8 (+ fill-byte-offset 20)) + width (aget heap-f32 (+ gradient-u32-offset 5)) + stop-count (aget heap-u8 (+ fill-byte-offset 28)) + stops (->> (range stop-count) + (map (fn [idx] + (let [stop-offset (+ fill-byte-offset 32 (* idx 8)) + stop-u32-offset (mem/->offset-32 stop-offset) + rgba (aget heap-u32 stop-u32-offset) + offset (aget heap-f32 (+ stop-u32-offset 1))] + {:color (rgba->fill-color rgba) + :opacity (rgba->opacity rgba) + :offset (/ (js/Math.round (* offset 100)) 100)}))) + (into []))] + {:fill-opacity (u8->opacity alpha) + :fill-color-gradient {:start-x start-x + :start-y start-y + :end-x end-x + :end-y end-y + :width width + :stops stops + :type :linear}}) + + TEXT_EDITOR_STYLES_FILL_RADIAL_GRADIENT + (let [gradient-u32-offset (mem/->offset-32 (+ fill-byte-offset 4)) + start-x (aget heap-f32 gradient-u32-offset) + start-y (aget heap-f32 (+ gradient-u32-offset 1)) + end-x (aget heap-f32 (+ gradient-u32-offset 2)) + end-y (aget heap-f32 (+ gradient-u32-offset 3)) + alpha (aget heap-u8 (+ fill-byte-offset 20)) + width (aget heap-f32 (+ gradient-u32-offset 5)) + stop-count (aget heap-u8 (+ fill-byte-offset 28)) + stops (->> (range stop-count) + (map (fn [idx] + (let [stop-offset (+ fill-byte-offset 32 (* idx 8)) + stop-u32-offset (mem/->offset-32 stop-offset) + rgba (aget heap-u32 stop-u32-offset) + offset (aget heap-f32 (+ stop-u32-offset 1))] + {:color (rgba->fill-color rgba) + :opacity (rgba->opacity rgba) + :offset (/ (js/Math.round (* offset 100)) 100)}))) + (into []))] + {:fill-opacity (u8->opacity alpha) + :fill-color-gradient {:start-x start-x + :start-y start-y + :end-x end-x + :end-y end-y + :width width + :stops stops + :type :radial}}) + + TEXT_EDITOR_STYLES_FILL_IMAGE + (let [a (aget heap-u32 (+ fill-u32-offset 1)) + b (aget heap-u32 (+ fill-u32-offset 2)) + c (aget heap-u32 (+ fill-u32-offset 3)) + d (aget heap-u32 (+ fill-u32-offset 4)) + alpha (aget heap-u8 (+ fill-byte-offset 20)) + flags (aget heap-u8 (+ fill-byte-offset 21)) + width (aget heap-i32 (+ fill-u32-offset 6)) + height (aget heap-i32 (+ fill-u32-offset 7))] + {:fill-opacity (u8->opacity alpha) + :fill-image {:id (uuid/from-unsigned-parts a b c d) + :width width + :height height + :keep-aspect-ratio (not (zero? (bit-and flags 0x01))) + :name "sample"}}) + + nil))) + (defn text-editor-focus [id] (when wasm/context-initialized? @@ -66,6 +174,143 @@ (let [res (h/call wasm/internal-module "_text_editor_poll_event")] res))) +(defn- text-editor-get-style-property + ([state value] + (text-editor-get-style-property state value value)) + ([state value default-value] + (case state + 0 default-value + 1 value + 2 :multiple + 0))) + +(defn- text-editor-translate-vertical-align + [vertical-align] + (case vertical-align + 0 "top" + 1 "center" + 2 "bottom")) + +(defn- text-editor-translate-text-align + [text-align] + (case text-align + 0 "left" + 1 "center" + 2 "right" + text-align)) + +(defn- text-editor-translate-text-direction + [text-direction] + (case text-direction + 0 "ltr" + 1 "rtl" + text-direction)) + +(defn- text-editor-translate-text-transform + [text-transform] + (case text-transform + 0 "none" + 1 "lowercase" + 2 "uppercase" + 3 "capitalize" + text-transform)) + +(defn- text-editor-translate-text-decoration + [text-decoration] + (case text-decoration + 0 "none" + 1 "underline" + 2 "linethrough" + 3 "overline" + text-decoration)) + +(defn- text-editor-translate-font-style + [font-style] + (case font-style + 0 "normal" + 1 "italic" + font-style)) + +(defn- text-editor-compute-font-variant-id + [font-id font-weight font-style] + (let [font-data (main-fonts/get-font-data font-id) + variant (main-fonts/find-closest-variant font-data font-weight font-style)] + (or (:id variant) + (:name variant) + "regular"))) + +(defn text-editor-get-current-styles + [] + (when wasm/context-initialized? + (let [ptr (h/call wasm/internal-module "_text_editor_get_current_styles")] + (when (and ptr (not (zero? ptr))) + (let [heap-u8 (mem/get-heap-u8) + heap-u32 (mem/get-heap-u32) + heap-i32 (mem/get-heap-i32) + heap-f32 (mem/get-heap-f32) + u32-offset (mem/->offset-32 ptr) + vertical-align (aget heap-u32 u32-offset) + text-align-state (aget heap-u32 (+ u32-offset 1)) + text-direction-state (aget heap-u32 (+ u32-offset 2)) + text-decoration-state (aget heap-u32 (+ u32-offset 3)) + text-transform-state (aget heap-u32 (+ u32-offset 4)) + font-family-state (aget heap-u32 (+ u32-offset 5)) + font-size-state (aget heap-u32 (+ u32-offset 6)) + font-weight-state (aget heap-u32 (+ u32-offset 7)) + font-variant-id-state (aget heap-u32 (+ u32-offset 8)) + line-height-state (aget heap-u32 (+ u32-offset 9)) + letter-spacing-state (aget heap-u32 (+ u32-offset 10)) + num-fills (aget heap-u32 (+ u32-offset 11)) + + text-align-value (aget heap-u32 (+ u32-offset 12)) + text-direction-value (aget heap-u32 (+ u32-offset 13)) + text-decoration-value (aget heap-u32 (+ u32-offset 14)) + text-transform-value (aget heap-u32 (+ u32-offset 15)) + font-family-id-a (aget heap-u32 (+ u32-offset 16)) + font-family-id-b (aget heap-u32 (+ u32-offset 17)) + font-family-id-c (aget heap-u32 (+ u32-offset 18)) + font-family-id-d (aget heap-u32 (+ u32-offset 19)) + font-family-id-value (uuid/from-unsigned-parts font-family-id-a font-family-id-b font-family-id-c font-family-id-d) + font-family-style-value (aget heap-u32 (+ u32-offset 20)) + _font-family-weight-value (aget heap-u32 (+ u32-offset 21)) + font-size-value (aget heap-f32 (+ u32-offset 22)) + font-weight-value (aget heap-i32 (+ u32-offset 23)) + line-height-value (aget heap-f32 (+ u32-offset 28)) + letter-spacing-value (aget heap-f32 (+ u32-offset 29)) + font-id (fonts/uuid->font-id font-family-id-value) + font-style-value (text-editor-translate-font-style (text-editor-get-style-property font-family-state font-family-style-value)) + font-variant-id-computed (text-editor-compute-font-variant-id font-id font-weight-value font-style-value) + + fills (->> (range num-fills) + (map (fn [idx] + (read-fill-from-heap + heap-u8 heap-u32 heap-i32 heap-f32 + (+ ptr + TEXT_EDITOR_STYLES_METADATA_SIZE + (* idx types.fills.impl/FILL-U8-SIZE))))) + (filter some?) + (into [])) + + result {:vertical-align (text-editor-translate-vertical-align vertical-align) + :text-align (text-editor-translate-text-align (text-editor-get-style-property text-align-state text-align-value)) + :text-direction (text-editor-translate-text-direction (text-editor-get-style-property text-direction-state text-direction-value)) + :text-decoration (text-editor-translate-text-decoration (text-editor-get-style-property text-decoration-state text-decoration-value)) + :text-transform (text-editor-translate-text-transform (text-editor-get-style-property text-transform-state text-transform-value)) + :line-height (text-editor-get-style-property line-height-state line-height-value) + :letter-spacing (text-editor-get-style-property letter-spacing-state letter-spacing-value) + :font-size (text-editor-get-style-property font-size-state font-size-value) + :font-weight (text-editor-get-style-property font-weight-state font-weight-value) + :font-style font-style-value + :font-family (text-editor-get-style-property font-family-state font-id) + :font-id (text-editor-get-style-property font-family-state font-id) + :font-variant-id (text-editor-get-style-property font-variant-id-state font-variant-id-computed) + :typography-ref-file nil + :typography-ref-id nil + :fills fills}] + + (mem/free) + result))))) + (defn text-editor-encode-text-pre [text] (when (and (not (empty? text)) @@ -171,7 +416,7 @@ (when wasm/context-initialized? (let [ptr (h/call wasm/internal-module "_text_editor_export_content")] (when (and ptr (not (zero? ptr))) - (let [json-str (mem/read-null-terminated-string ptr)] + (let [json-str (mem/read-string ptr)] (mem/free) (js/JSON.parse json-str)))))) @@ -181,7 +426,7 @@ (when wasm/context-initialized? (let [ptr (h/call wasm/internal-module "_text_editor_export_selection")] (when (and ptr (not (zero? ptr))) - (let [text (mem/read-null-terminated-string ptr)] + (let [text (mem/read-string ptr)] (mem/free) text))))) @@ -342,38 +587,52 @@ [para] (apply + (map (fn [span] (count (:text span))) (:children para)))) -(defn apply-style-to-selection +(defn apply-styles-to-selection [attrs use-shape-fn set-shape-text-content-fn] (when wasm/context-initialized? - (let [shape-id (text-editor-get-active-shape-id) - sel (text-editor-get-selection)] - (when (and shape-id sel) + (let [shape-id (text-editor-get-active-shape-id) + selection (text-editor-get-selection)] + + (when (and shape-id selection) (let [content (get @shape-text-contents shape-id)] (when content - (let [{:keys [start-para start-offset end-para end-offset]} - (normalize-selection sel) - collapsed? (and (= start-para end-para) (= start-offset end-offset)) - para-set (first (:children content)) - paras (:children para-set) - new-paras + (let [normalized-selection (normalize-selection selection) + {:keys [start-para start-offset end-para end-offset]} normalized-selection + + collapsed? (and (= start-para end-para) (= start-offset end-offset)) + + paragraph-set (first (:children content)) + paragraphs (:children paragraph-set) + + new-paragraphs (when (not collapsed?) (mapv (fn [idx para] (cond + ;; paragraph outside the range of paragraphs. (or (< idx start-para) (> idx end-para)) para + + ;; same paragraph. (= start-para end-para) (apply-attrs-to-paragraph para start-offset end-offset attrs) + + ;; first paragraph (= idx start-para) (apply-attrs-to-paragraph para start-offset (para-char-count para) attrs) + + ;; final paragraph (= idx end-para) (apply-attrs-to-paragraph para 0 end-offset attrs) + + ;; any other paragraph :else (apply-attrs-to-paragraph para 0 (para-char-count para) attrs))) - (range (count paras)) - paras)) - new-content (when new-paras + + (range (count paragraphs)) + paragraphs)) + new-content (when new-paragraphs (assoc content :children - [(assoc para-set :children new-paras)]))] + [(assoc paragraph-set :children new-paragraphs)]))] (when new-content (swap! shape-text-contents assoc shape-id new-content) (use-shape-fn shape-id) diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 37f56c1887..1082af1aac 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -39,7 +39,7 @@ export EMCC_CFLAGS="--no-entry \ -sERROR_ON_UNDEFINED_SYMBOLS=0 \ -sMAX_WEBGL_VERSION=2 \ -sEXPORT_NAME=createRustSkiaModule \ - -sEXPORTED_RUNTIME_METHODS=GL,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \ + -sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \ -sENVIRONMENT=web \ -sMODULARIZE=1 \ -sEXPORT_ES6=1"; diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 7b8f8e495a..9e17de41ee 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -153,10 +153,11 @@ pub enum ConstraintH { } #[derive(Debug, Clone, PartialEq, Copy)] +#[repr(u8)] pub enum VerticalAlign { - Top, - Center, - Bottom, + Top = 0, + Center = 1, + Bottom = 2, } #[derive(Debug, Clone, PartialEq, Copy)] diff --git a/render-wasm/src/shapes/fonts.rs b/render-wasm/src/shapes/fonts.rs index 86ab5d3897..f4da4daa6c 100644 --- a/render-wasm/src/shapes/fonts.rs +++ b/render-wasm/src/shapes/fonts.rs @@ -3,9 +3,10 @@ use std::fmt; use crate::uuid::Uuid; #[derive(Debug, PartialEq, Clone, Copy)] +#[repr(u8)] pub enum FontStyle { - Normal, - Italic, + Normal = 0, + Italic = 1, } impl fmt::Display for FontStyle { @@ -33,6 +34,16 @@ impl FontFamily { pub fn alias(&self) -> String { format!("{}", self) } + + pub fn id(&self) -> Uuid { + self.id + } + pub fn style(&self) -> FontStyle { + self.style + } + pub fn weight(&self) -> u32 { + self.weight + } } impl fmt::Display for FontFamily { diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index cd65e92068..82e7daf1ad 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -1,8 +1,14 @@ #![allow(dead_code)] -use crate::shapes::{TextContent, TextPositionWithAffinity}; +use macros::ToJs; + +use crate::shapes::{ + Fill, FontFamily, TextAlign, TextContent, TextDecoration, TextDirection, + TextPositionWithAffinity, TextTransform, VerticalAlign, +}; use crate::uuid::Uuid; -use crate::wasm::text::helpers as text_helpers; +use crate::wasm::text::helpers::{self as text_helpers, find_text_span_at_offset}; +use crate::wasm::text_editor::CursorDirection; use skia_safe::{ textlayout::{Affinity, PositionWithAffinity}, Color, @@ -82,13 +88,14 @@ impl TextSelection { } /// Events that the text editor can emit for frontend synchronization -#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, ToJs)] pub enum TextEditorEvent { None = 0, ContentChanged = 1, SelectionChanged = 2, - NeedsLayout = 3, + StylesChanged = 3, + NeedsLayout = 4, } /// FIXME: It should be better to get these constants from the frontend through the API. @@ -97,6 +104,154 @@ const CURSOR_WIDTH: f32 = 1.5; const CURSOR_COLOR: Color = Color::BLACK; const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0; +#[derive(Debug)] +pub struct TextEditorStyles { + pub vertical_align: VerticalAlign, + pub text_align: Multiple, // Multiple + pub text_direction: Multiple, // Multiple + pub text_decoration: Multiple, + pub text_transform: Multiple, + pub font_family: Multiple, + pub font_size: Multiple, + pub font_weight: Multiple, + pub font_variant_id: Multiple, + pub line_height: Multiple, + pub letter_spacing: Multiple, + pub fills: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[repr(u8)] +pub enum MultipleState { + Undefined = 0, + Single = 1, + Multiple = 2, +} + +#[derive(Debug)] +pub struct Multiple { + state: MultipleState, + value: Option, +} + +impl Multiple { + pub fn empty() -> Self { + Self { + state: MultipleState::Undefined, + value: None, + } + } + + pub fn new(state: MultipleState, value: Option) -> Self { + Self { state, value } + } + + pub fn state(&self) -> &MultipleState { + &self.state + } + + pub fn value(&self) -> &Option { + &self.value + } + + pub fn is_undefined(&self) -> bool { + self.state == MultipleState::Undefined + } + + pub fn is_multiple(&self) -> bool { + self.state == MultipleState::Multiple + } + + pub fn is_single(&self) -> bool { + self.state == MultipleState::Single + } + + pub fn is_single_some(&self) -> bool { + !self.is_undefined() && self.value.is_some() + } + + pub fn is_single_none(&self) -> bool { + !self.is_undefined() && self.value.is_none() + } + + pub fn reset(&mut self) { + self.state = MultipleState::Undefined; + self.value = None; + } + + pub fn set(&mut self, value: Option) { + if self.state == MultipleState::Undefined || self.state == MultipleState::Multiple { + self.state = MultipleState::Single; + } + self.value = value; + } + + pub fn set_single(&mut self, value: Option) { + self.state = MultipleState::Single; + self.value = value; + } + + pub fn set_multiple(&mut self) { + self.state = MultipleState::Multiple; + self.value = None; + } + + pub fn merge(&mut self, value: Option) -> bool + where + T: PartialEq, + { + if self.state == MultipleState::Multiple { + return false; + } + + if self.state == MultipleState::Undefined { + self.set_single(value); + return true; + } + + if self.value.as_ref() != value.as_ref() { + self.set_multiple(); + return false; + } + + self.value = value; + true + } +} + +impl TextEditorStyles { + pub fn new() -> Self { + Self { + vertical_align: VerticalAlign::Top, + text_align: Multiple::empty(), + text_direction: Multiple::empty(), + text_decoration: Multiple::empty(), + text_transform: Multiple::empty(), + font_family: Multiple::empty(), + font_size: Multiple::empty(), + font_weight: Multiple::empty(), + font_variant_id: Multiple::empty(), + line_height: Multiple::empty(), + letter_spacing: Multiple::empty(), + fills: Vec::new(), + } + } + + pub fn reset(&mut self) { + self.text_align.reset(); + self.text_direction.reset(); + self.text_decoration.reset(); + self.text_transform.reset(); + self.font_family.reset(); + self.font_size.reset(); + self.font_weight.reset(); + self.font_variant_id.reset(); + self.line_height.reset(); + self.letter_spacing.reset(); + self.fills.clear(); + } +} + pub struct TextEditorTheme { pub selection_color: Color, pub cursor_width: f32, @@ -172,6 +327,7 @@ pub struct TextEditorState { pub active_shape_id: Option, pub cursor_visible: bool, pub last_blink_time: f64, + pub current_styles: TextEditorStyles, pending_events: Vec, } @@ -191,6 +347,7 @@ impl TextEditorState { cursor_visible: true, last_blink_time: 0.0, pending_events: Vec::new(), + current_styles: TextEditorStyles::new(), } } @@ -240,11 +397,11 @@ impl TextEditorState { true } - pub fn select_all(&mut self, content: &TextContent) -> bool { + pub fn select_all(&mut self, text_content: &TextContent) -> bool { self.is_pointer_selection_active = false; self.set_caret_from_position(&TextPositionWithAffinity::empty()); - let num_paragraphs = content.paragraphs().len() - 1; - let Some(last_paragraph) = content.paragraphs().last() else { + let num_paragraphs = text_content.paragraphs().len() - 1; + let Some(last_paragraph) = text_content.paragraphs().last() else { return false; }; #[allow(dead_code)] @@ -264,20 +421,21 @@ impl TextEditorState { num_paragraphs, offset, )); + self.update_styles(text_content); self.reset_blink(); - self.push_event(crate::state::TextEditorEvent::SelectionChanged); + self.push_event(TextEditorEvent::SelectionChanged); true } pub fn select_word_boundary( &mut self, - content: &TextContent, + text_content: &TextContent, position: &TextPositionWithAffinity, ) { self.is_pointer_selection_active = false; - let paragraphs = content.paragraphs(); + let paragraphs = text_content.paragraphs(); if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { return; } @@ -295,6 +453,7 @@ impl TextEditorState { position.paragraph, 0, )); + self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); return; @@ -316,6 +475,7 @@ impl TextEditorState { position.paragraph, position.offset.min(chars.len()), )); + self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); return; @@ -339,6 +499,7 @@ impl TextEditorState { position.paragraph, end, )); + self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } @@ -353,6 +514,183 @@ impl TextEditorState { self.push_event(TextEditorEvent::SelectionChanged); } + fn update_styles_from_selection(&mut self, text_content: &TextContent) -> bool { + let paragraphs = text_content.paragraphs(); + if paragraphs.is_empty() { + return false; + } + + let start = self.selection.start(); + if start.paragraph >= paragraphs.len() { + return false; + } + + let end = self.selection.end(); + let end_paragraph = end.paragraph.min(paragraphs.len() - 1); + + self.current_styles.reset(); + + let mut has_selected_content = false; + let mut has_fills = false; + let mut fills_are_multiple = false; + + for (para_idx, paragraph) in paragraphs + .iter() + .enumerate() + .take(end_paragraph + 1) + .skip(start.paragraph) + { + let paragraph_char_count: usize = paragraph + .children() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + + let range_start = if para_idx == start.paragraph { + start.offset.min(paragraph_char_count) + } else { + 0 + }; + + let range_end = if para_idx == end.paragraph { + end.offset.min(paragraph_char_count) + } else { + paragraph_char_count + }; + + if range_start >= range_end { + continue; + } + + has_selected_content = true; + self.current_styles + .text_align + .merge(Some(paragraph.text_align())); + + let mut char_pos = 0; + for span in paragraph.children() { + let span_len = span.text.chars().count(); + let span_start = char_pos; + let span_end = char_pos + span_len; + char_pos += span_len; + + let selected_start = range_start.max(span_start); + let selected_end = range_end.min(span_end); + if selected_start >= selected_end { + continue; + } + + self.current_styles + .text_direction + .merge(Some(span.text_direction)); + self.current_styles + .text_decoration + .merge(span.text_decoration); + self.current_styles + .text_transform + .merge(span.text_transform); + self.current_styles + .font_family + .merge(Some(span.font_family)); + self.current_styles.font_size.merge(Some(span.font_size)); + self.current_styles + .font_weight + .merge(Some(span.font_weight)); + self.current_styles + .font_variant_id + .merge(Some(span.font_variant_id)); + self.current_styles + .line_height + .merge(Some(span.line_height)); + self.current_styles + .letter_spacing + .merge(Some(span.letter_spacing)); + + if !fills_are_multiple { + if !has_fills { + self.current_styles.fills = span.fills.clone(); + has_fills = true; + } else if self.current_styles.fills != span.fills { + fills_are_multiple = true; + self.current_styles.fills.clear(); + } + } + } + } + + has_selected_content + } + + fn update_styles_from_caret(&mut self, text_content: &TextContent) -> bool { + let focus = self.selection.focus; + let paragraphs = text_content.paragraphs(); + let Some(current_paragraph) = paragraphs.get(focus.paragraph) else { + return false; + }; + let current_offset = focus.offset; + let current_text_span = find_text_span_at_offset(current_paragraph, current_offset); + + self.current_styles + .text_align + .set_single(Some(current_paragraph.text_align())); + if let Some((text_span_index, _)) = current_text_span { + if let Some(text_span) = current_paragraph.children().get(text_span_index) { + self.current_styles + .text_direction + .set_single(Some(text_span.text_direction)); + self.current_styles + .text_decoration + .set_single(text_span.text_decoration); + self.current_styles + .text_transform + .set_single(text_span.text_transform); + self.current_styles + .font_family + .set_single(Some(text_span.font_family)); + self.current_styles + .font_size + .set_single(Some(text_span.font_size)); + self.current_styles + .font_weight + .set_single(Some(text_span.font_weight)); + self.current_styles + .font_variant_id + .set_single(Some(text_span.font_variant_id)); + self.current_styles + .line_height + .set_single(Some(text_span.line_height)); + self.current_styles + .letter_spacing + .set_single(Some(text_span.letter_spacing)); + self.current_styles.fills = text_span.fills.clone(); + } + } else { + self.current_styles + .line_height + .set_single(Some(current_paragraph.line_height())); + self.current_styles + .letter_spacing + .set_single(Some(current_paragraph.letter_spacing())); + } + true + } + + pub fn update_styles(&mut self, text_content: &TextContent) -> bool { + if self.selection.is_selection() { + let styles_were_updated = self.update_styles_from_selection(text_content); + if styles_were_updated { + self.push_event(TextEditorEvent::StylesChanged); + } + return styles_were_updated; + } + // It is a caret. + let styles_were_updated = self.update_styles_from_caret(text_content); + if styles_were_updated { + self.push_event(TextEditorEvent::StylesChanged); + } + styles_were_updated + } + pub fn update_blink(&mut self, timestamp_ms: f64) { if !self.has_focus { return; @@ -389,6 +727,145 @@ impl TextEditorState { pub fn has_pending_events(&self) -> bool { !self.pending_events.is_empty() } + + pub fn delete_backward(&mut self, text_content: &mut TextContent, word_boundary: bool) { + if self.selection.is_selection() { + text_helpers::delete_selection_range(text_content, &self.selection); + let start = self.selection.start(); + let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); + self.selection.set_caret(clamped); + } else if word_boundary { + let cursor = self.selection.focus; + if let Some(new_cursor) = text_helpers::delete_word_before(text_content, &cursor) { + self.selection.set_caret(new_cursor); + } + } else { + let cursor = self.selection.focus; + if let Some(new_cursor) = text_helpers::delete_char_before(text_content, &cursor) { + self.selection.set_caret(new_cursor); + } + } + + text_content.layout.paragraphs.clear(); + text_content.layout.paragraph_builders.clear(); + + self.reset_blink(); + self.push_event(TextEditorEvent::ContentChanged); + self.push_event(TextEditorEvent::NeedsLayout); + } + + pub fn delete_forward(&mut self, text_content: &mut TextContent, word_boundary: bool) { + if self.selection.is_selection() { + text_helpers::delete_selection_range(text_content, &self.selection); + let start = self.selection.start(); + let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); + self.selection.set_caret(clamped); + } else if word_boundary { + let cursor = self.selection.focus; + text_helpers::delete_word_after(text_content, &cursor); + let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); + self.selection.set_caret(clamped); + } else { + let cursor = self.selection.focus; + text_helpers::delete_char_after(text_content, &cursor); + let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); + self.selection.set_caret(clamped); + } + + text_content.layout.paragraphs.clear(); + text_content.layout.paragraph_builders.clear(); + + self.reset_blink(); + self.push_event(TextEditorEvent::ContentChanged); + self.push_event(TextEditorEvent::NeedsLayout); + } + + pub fn insert_paragraph(&mut self, text_content: &mut TextContent) { + if self.selection.is_selection() { + text_helpers::delete_selection_range(text_content, &self.selection); + let start = self.selection.start(); + self.selection.set_caret(start); + } + + let cursor = self.selection.focus; + if text_helpers::split_paragraph_at_cursor(text_content, &cursor) { + let new_cursor = + TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); + self.selection.set_caret(new_cursor); + } + + text_content.layout.paragraphs.clear(); + text_content.layout.paragraph_builders.clear(); + + self.reset_blink(); + self.push_event(TextEditorEvent::ContentChanged); + self.push_event(TextEditorEvent::NeedsLayout); + } + + pub fn move_cursor( + &mut self, + text_content: &TextContent, + direction: CursorDirection, + word_boundary: bool, + extend_selection: bool, + ) -> bool { + let paragraphs = text_content.paragraphs(); + if paragraphs.is_empty() { + return false; + } + + let focus = self.selection.focus; + + // Get the text direction of the span at the current cursor position + let text_span_text_direction = if focus.paragraph < paragraphs.len() { + text_helpers::get_text_span_text_direction_at_offset( + ¶graphs[focus.paragraph], + focus.offset, + ) + } else { + TextDirection::LTR + }; + + // For horizontal navigation, swap Backward/Forward when in RTL text + let adjusted_direction = if text_span_text_direction == TextDirection::RTL { + match direction { + CursorDirection::Backward => CursorDirection::Forward, + CursorDirection::Forward => CursorDirection::Backward, + other => other, + } + } else { + direction + }; + + let new_cursor = match adjusted_direction { + CursorDirection::Backward => { + text_helpers::move_cursor_backward(&focus, paragraphs, word_boundary) + } + CursorDirection::Forward => { + text_helpers::move_cursor_forward(&focus, paragraphs, word_boundary) + } + CursorDirection::LineBefore => { + text_helpers::move_cursor_up(&focus, paragraphs, text_content) + } + CursorDirection::LineAfter => { + text_helpers::move_cursor_down(&focus, paragraphs, text_content) + } + CursorDirection::LineStart => text_helpers::move_cursor_line_start(&focus, paragraphs), + CursorDirection::LineEnd => text_helpers::move_cursor_line_end(&focus, paragraphs), + }; + + if extend_selection { + self.selection.extend_to(new_cursor); + } else { + self.selection.set_caret(new_cursor); + } + + self.update_styles(text_content); + + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + true + } } fn is_word_char(c: char) -> bool { diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index 8513856455..19fc49e575 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -36,12 +36,44 @@ impl From for shapes::Fill { } } +impl TryFrom<&shapes::Fill> for RawFillData { + type Error = String; + + fn try_from(fill: &shapes::Fill) -> Result { + match fill { + shapes::Fill::Solid(shapes::SolidColor(color)) => { + Ok(RawFillData::Solid(solid::RawSolidData { + color: ((color.a() as u32) << 24) + | ((color.r() as u32) << 16) + | ((color.g() as u32) << 8) + | (color.b() as u32), + })) + } + shapes::Fill::LinearGradient(_) => { + Err("LinearGradient serialization is not implemented".to_string()) + } + shapes::Fill::RadialGradient(_) => { + Err("RadialGradient serialization is not implemented".to_string()) + } + shapes::Fill::Image(_) => { + Err("Image fill serialization is not implemented".to_string()) + } + } + } +} + impl From<[u8; RAW_FILL_DATA_SIZE]> for RawFillData { fn from(bytes: [u8; RAW_FILL_DATA_SIZE]) -> Self { unsafe { std::mem::transmute(bytes) } } } +impl From for [u8; RAW_FILL_DATA_SIZE] { + fn from(fill_data: RawFillData) -> Self { + unsafe { std::mem::transmute(fill_data) } + } +} + impl TryFrom<&[u8]> for RawFillData { type Error = String; fn try_from(bytes: &[u8]) -> Result { @@ -54,7 +86,7 @@ impl TryFrom<&[u8]> for RawFillData { } // FIXME: return Result -pub fn parse_fills_from_bytes(buffer: &[u8], num_fills: usize) -> Vec { +pub fn read_fills_from_bytes(buffer: &[u8], num_fills: usize) -> Vec { buffer .chunks_exact(RAW_FILL_DATA_SIZE) .take(num_fills) @@ -74,7 +106,7 @@ pub extern "C" fn set_shape_fills() -> Result<()> { // The first byte contains the actual number of fills let num_fills = bytes.first().copied().unwrap_or(0) as usize; // Skip the first 4 bytes (header with fill count) and parse only the actual fills - let fills = parse_fills_from_bytes(&bytes[4..], num_fills); + let fills = read_fills_from_bytes(&bytes[4..], num_fills); shape.set_fills(fills); mem::free_bytes()?; }); diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index 88ec07754c..b0c57c5288 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -1,4 +1,4 @@ -use crate::shapes::{Paragraph, TextContent, TextPositionWithAffinity}; +use crate::shapes::{Paragraph, TextContent, TextDirection, TextPositionWithAffinity}; use crate::state::TextSelection; /// Get total character count in a paragraph. @@ -10,11 +10,11 @@ pub fn paragraph_char_count(para: &Paragraph) -> usize { } /// Get the text direction of the span at a given offset in a paragraph. -pub fn get_span_text_direction_at_offset( +pub fn get_text_span_text_direction_at_offset( para: &Paragraph, char_offset: usize, -) -> skia_safe::textlayout::TextDirection { - if let Some((span_idx, _)) = find_span_at_offset(para, char_offset) { +) -> TextDirection { + if let Some((span_idx, _)) = find_text_span_at_offset(para, char_offset) { if let Some(span) = para.children().get(span_idx) { return span.text_direction; } @@ -258,7 +258,7 @@ pub fn paragraph_text_char_at(para: &Paragraph, offset: usize) -> Option { None } -pub fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, usize)> { +pub fn find_text_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, usize)> { let children = para.children(); let mut accumulated = 0; for (span_idx, span) in children.iter().enumerate() { @@ -338,7 +338,7 @@ pub fn insert_text_at_cursor( return Some(text.chars().count()); } - let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.offset)?; + let (span_idx, offset_in_span) = find_text_span_at_offset(para, cursor.offset)?; let children = para.children_mut(); let span = &mut children[span_idx]; @@ -690,7 +690,7 @@ pub fn split_paragraph_at_cursor( let para = ¶graphs[cursor.paragraph]; - let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else { + let Some((span_idx, offset_in_span)) = find_text_span_at_offset(para, cursor.offset) else { return false; }; diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 3312d62131..9e364e8fb7 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -4,13 +4,16 @@ use crate::math::{Matrix, Point, Rect}; use crate::mem; use crate::render::text_editor as text_editor_render; use crate::render::SurfaceId; -use crate::shapes::{Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; -use crate::state::TextSelection; +use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; +use crate::state::{TextEditorEvent, TextSelection}; use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; -use crate::wasm::text::helpers as text_helpers; +use crate::wasm::fills::RawFillData; +use crate::wasm::text::{ + helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform, +}; use crate::{with_state, with_state_mut, STATE}; -use skia_safe::{textlayout::TextDirection, Color}; +use skia_safe::Color; #[derive(PartialEq, ToJs)] #[repr(u8)] @@ -194,6 +197,7 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { state.text_editor_state.start_pointer_selection(); if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state.text_editor_state.set_caret_from_position(&position); + state.text_editor_state.update_styles(text_content); } }); } @@ -222,6 +226,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { state .text_editor_state .extend_selection_from_position(&position); + state.text_editor_state.update_styles(text_content); } }); } @@ -249,6 +254,7 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { state .text_editor_state .extend_selection_from_position(&position); + state.text_editor_state.update_styles(text_content); } state.text_editor_state.stop_pointer_selection(); }); @@ -312,7 +318,7 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { #[wasm_error] pub extern "C" fn text_editor_composition_start() -> Result<()> { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return Ok(()); } state.text_editor_state.composition.start(); @@ -331,7 +337,7 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { }; with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return Ok(()); } @@ -392,7 +398,7 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { }; with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return Ok(()); } @@ -486,10 +492,10 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { state.text_editor_state.reset_blink(); state .text_editor_state - .push_event(crate::state::TextEditorEvent::ContentChanged); + .push_event(TextEditorEvent::ContentChanged); state .text_editor_state - .push_event(crate::state::TextEditorEvent::NeedsLayout); + .push_event(TextEditorEvent::NeedsLayout); state.render_state.mark_touched(shape_id); }); @@ -517,36 +523,9 @@ pub extern "C" fn text_editor_delete_backward(word_boundary: bool) { return; }; - let selection = state.text_editor_state.selection; - - if selection.is_selection() { - text_helpers::delete_selection_range(text_content, &selection); - let start = selection.start(); - let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); - state.text_editor_state.selection.set_caret(clamped); - } else if word_boundary { - let cursor = selection.focus; - if let Some(new_cursor) = text_helpers::delete_word_before(text_content, &cursor) { - state.text_editor_state.selection.set_caret(new_cursor); - } - } else { - let cursor = selection.focus; - if let Some(new_cursor) = text_helpers::delete_char_before(text_content, &cursor) { - state.text_editor_state.selection.set_caret(new_cursor); - } - } - - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); - - state.text_editor_state.reset_blink(); state .text_editor_state - .push_event(crate::state::TextEditorEvent::ContentChanged); - state - .text_editor_state - .push_event(crate::state::TextEditorEvent::NeedsLayout); - + .delete_backward(text_content, word_boundary); state.render_state.mark_touched(shape_id); }); } @@ -570,36 +549,9 @@ pub extern "C" fn text_editor_delete_forward(word_boundary: bool) { return; }; - let selection = state.text_editor_state.selection; - - if selection.is_selection() { - text_helpers::delete_selection_range(text_content, &selection); - let start = selection.start(); - let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); - state.text_editor_state.selection.set_caret(clamped); - } else if word_boundary { - let cursor = selection.focus; - text_helpers::delete_word_after(text_content, &cursor); - let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); - state.text_editor_state.selection.set_caret(clamped); - } else { - let cursor = selection.focus; - text_helpers::delete_char_after(text_content, &cursor); - let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); - state.text_editor_state.selection.set_caret(clamped); - } - - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); - - state.text_editor_state.reset_blink(); state .text_editor_state - .push_event(crate::state::TextEditorEvent::ContentChanged); - state - .text_editor_state - .push_event(crate::state::TextEditorEvent::NeedsLayout); - + .delete_forward(text_content, word_boundary); state.render_state.mark_touched(shape_id); }); } @@ -623,33 +575,7 @@ pub extern "C" fn text_editor_insert_paragraph() { return; }; - let selection = state.text_editor_state.selection; - - if selection.is_selection() { - text_helpers::delete_selection_range(text_content, &selection); - let start = selection.start(); - state.text_editor_state.selection.set_caret(start); - } - - let cursor = state.text_editor_state.selection.focus; - - if text_helpers::split_paragraph_at_cursor(text_content, &cursor) { - let new_cursor = - TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); - state.text_editor_state.selection.set_caret(new_cursor); - } - - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); - - state.text_editor_state.reset_blink(); - state - .text_editor_state - .push_event(crate::state::TextEditorEvent::ContentChanged); - state - .text_editor_state - .push_event(crate::state::TextEditorEvent::NeedsLayout); - + state.text_editor_state.insert_paragraph(text_content); state.render_state.mark_touched(shape_id); }); } @@ -681,63 +607,12 @@ pub extern "C" fn text_editor_move_cursor( return; }; - let paragraphs = text_content.paragraphs(); - if paragraphs.is_empty() { - return; - } - - let current = state.text_editor_state.selection.focus; - - // Get the text direction of the span at the current cursor position - let span_text_direction = if current.paragraph < paragraphs.len() { - text_helpers::get_span_text_direction_at_offset( - ¶graphs[current.paragraph], - current.offset, - ) - } else { - TextDirection::LTR - }; - - // For horizontal navigation, swap Backward/Forward when in RTL text - let adjusted_direction = if span_text_direction == TextDirection::RTL { - match direction { - CursorDirection::Backward => CursorDirection::Forward, - CursorDirection::Forward => CursorDirection::Backward, - other => other, - } - } else { - direction - }; - - let new_cursor = match adjusted_direction { - CursorDirection::Backward => { - text_helpers::move_cursor_backward(¤t, paragraphs, word_boundary) - } - CursorDirection::Forward => { - text_helpers::move_cursor_forward(¤t, paragraphs, word_boundary) - } - CursorDirection::LineBefore => { - text_helpers::move_cursor_up(¤t, paragraphs, text_content) - } - CursorDirection::LineAfter => { - text_helpers::move_cursor_down(¤t, paragraphs, text_content) - } - CursorDirection::LineStart => { - text_helpers::move_cursor_line_start(¤t, paragraphs) - } - CursorDirection::LineEnd => text_helpers::move_cursor_line_end(¤t, paragraphs), - }; - - if extend_selection { - state.text_editor_state.selection.extend_to(new_cursor); - } else { - state.text_editor_state.selection.set_caret(new_cursor); - } - - state.text_editor_state.reset_blink(); - state - .text_editor_state - .push_event(crate::state::TextEditorEvent::SelectionChanged); + state.text_editor_state.move_cursor( + text_content, + direction, + word_boundary, + extend_selection, + ); }); } @@ -779,6 +654,171 @@ pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { }) } +#[no_mangle] +pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { + with_state_mut!(state, { + if !state.text_editor_state.has_focus { + return std::ptr::null_mut(); + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return std::ptr::null_mut(); + }; + + let Some(shape) = state.shapes.get(&shape_id) else { + return std::ptr::null_mut(); + }; + + let Type::Text(_text_content) = &shape.shape_type else { + return std::ptr::null_mut(); + }; + + let styles = &state.text_editor_state.current_styles; + + let vertical_align = match styles.vertical_align { + VerticalAlign::Top => 0_u32, + VerticalAlign::Center => 1_u32, + VerticalAlign::Bottom => 2_u32, + }; + + let text_align = styles + .text_align + .value() + .as_ref() + .map(|value| match value { + TextAlign::Left => RawTextAlign::Left as u32, + TextAlign::Start => RawTextAlign::Left as u32, + TextAlign::Center => RawTextAlign::Center as u32, + TextAlign::Right => RawTextAlign::Right as u32, + TextAlign::End => RawTextAlign::Right as u32, + TextAlign::Justify => RawTextAlign::Justify as u32, + }) + .unwrap_or(0); + + let text_direction = styles + .text_direction + .value() + .as_ref() + .map(|value| match value { + skia_safe::textlayout::TextDirection::LTR => RawTextDirection::Ltr as u32, + skia_safe::textlayout::TextDirection::RTL => RawTextDirection::Rtl as u32, + }) + .unwrap_or(0); + + let text_decoration = styles + .text_decoration + .value() + .as_ref() + .map(|value| { + if *value == skia_safe::textlayout::TextDecoration::UNDERLINE { + RawTextDecoration::Underline as u32 + } else if *value == skia_safe::textlayout::TextDecoration::LINE_THROUGH { + RawTextDecoration::LineThrough as u32 + } else if *value == skia_safe::textlayout::TextDecoration::OVERLINE { + RawTextDecoration::Overline as u32 + } else { + RawTextDecoration::None as u32 + } + }) + .unwrap_or(RawTextDecoration::None as u32); + + let text_transform = styles + .text_transform + .value() + .as_ref() + .map(|value| match value { + crate::shapes::TextTransform::Uppercase => RawTextTransform::Uppercase as u32, + crate::shapes::TextTransform::Lowercase => RawTextTransform::Lowercase as u32, + crate::shapes::TextTransform::Capitalize => RawTextTransform::Capitalize as u32, + }) + .unwrap_or(RawTextTransform::None as u32); + + let font_family_id = styles + .font_family + .value() + .as_ref() + .map(|value| { + let (a, b, c, d) = uuid_to_u32_quartet(&value.id()); + [a, b, c, d] + }) + .unwrap_or_default(); + + let font_family_weight = styles + .font_family + .value() + .as_ref() + .map(|value| value.weight()) + .unwrap_or_default(); + + let font_family_style = styles + .font_family + .value() + .as_ref() + .map(|value| value.style() as u32) + .unwrap_or_default(); + + let font_size = styles.font_size.value().unwrap_or(0.0); + let font_weight = styles.font_weight.value().unwrap_or(0); + let line_height = styles.line_height.value().unwrap_or(0.0); + let letter_spacing = styles.letter_spacing.value().unwrap_or(0.0); + + let mut font_variant_id = [0_u32; 4]; + if let Some(value) = styles.font_variant_id.value().as_ref() { + let (a, b, c, d) = uuid_to_u32_quartet(value); + font_variant_id = [a, b, c, d]; + } + + let mut fill_bytes = Vec::new(); + let mut fill_count: u32 = 0; + for fill in &styles.fills { + if let Ok(raw_fill) = RawFillData::try_from(fill) { + fill_bytes + .extend_from_slice(&<[u8; std::mem::size_of::()]>::from(raw_fill)); + fill_count += 1; + } + } + + // Layout: 48-byte fixed header + fixed values + serialized fills. + let mut bytes = Vec::with_capacity(132 + fill_bytes.len()); + + bytes.extend_from_slice(&vertical_align.to_le_bytes()); + bytes.extend_from_slice(&(*styles.text_align.state() as u32).to_le_bytes()); + bytes.extend_from_slice(&(*styles.text_direction.state() as u32).to_le_bytes()); + bytes.extend_from_slice(&(*styles.text_decoration.state() as u32).to_le_bytes()); + bytes.extend_from_slice(&(*styles.text_transform.state() as u32).to_le_bytes()); + bytes.extend_from_slice(&(*styles.font_family.state() as u32).to_le_bytes()); + bytes.extend_from_slice(&(*styles.font_size.state() as u32).to_le_bytes()); + bytes.extend_from_slice(&(*styles.font_weight.state() as u32).to_le_bytes()); + bytes.extend_from_slice(&(*styles.font_variant_id.state() as u32).to_le_bytes()); + bytes.extend_from_slice(&(*styles.line_height.state() as u32).to_le_bytes()); + bytes.extend_from_slice(&(*styles.letter_spacing.state() as u32).to_le_bytes()); + bytes.extend_from_slice(&fill_count.to_le_bytes()); + + // Value section. + bytes.extend_from_slice(&text_align.to_le_bytes()); + bytes.extend_from_slice(&text_direction.to_le_bytes()); + bytes.extend_from_slice(&text_decoration.to_le_bytes()); + bytes.extend_from_slice(&text_transform.to_le_bytes()); + bytes.extend_from_slice(&font_family_id[0].to_le_bytes()); + bytes.extend_from_slice(&font_family_id[1].to_le_bytes()); + bytes.extend_from_slice(&font_family_id[2].to_le_bytes()); + bytes.extend_from_slice(&font_family_id[3].to_le_bytes()); + bytes.extend_from_slice(&font_family_style.to_le_bytes()); + bytes.extend_from_slice(&font_family_weight.to_le_bytes()); + bytes.extend_from_slice(&font_size.to_le_bytes()); + bytes.extend_from_slice(&font_weight.to_le_bytes()); + bytes.extend_from_slice(&font_variant_id[0].to_le_bytes()); + bytes.extend_from_slice(&font_variant_id[1].to_le_bytes()); + bytes.extend_from_slice(&font_variant_id[2].to_le_bytes()); + bytes.extend_from_slice(&font_variant_id[3].to_le_bytes()); + bytes.extend_from_slice(&line_height.to_le_bytes()); + bytes.extend_from_slice(&letter_spacing.to_le_bytes()); + bytes.extend_from_slice(&fill_bytes); + + mem::write_bytes(bytes) + }) +} + #[no_mangle] pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 { with_state_mut!(state, { @@ -804,7 +844,6 @@ pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 { let selection = &state.text_editor_state.selection; let rects = get_selection_rects(text_content, selection, shape); - if rects.is_empty() { return std::ptr::null_mut(); }