diff --git a/CHANGES.md b/CHANGES.md index 9300839618..8117220348 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -66,6 +66,10 @@ - Fix tooltip activated when tab change [Taiga #13627](https://tree.taiga.io/project/penpot/issue/13627) - Fix title on shared button [Taiga #13730](https://tree.taiga.io/project/penpot/issue/13730) - Fix hover on layers [Taiga #13799](https://tree.taiga.io/project/penpot/issue/13799) +- Fix highlight after name edition [Taiga #13783](https://tree.taiga.io/project/penpot/issue/13783) +- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534) +- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962) +- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961) ## 2.14.2 diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 65e49ac443..859b74fb84 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -735,7 +735,8 @@ (update :is-source d/nilv false) (update :external-id #(or % (str new-id))) (update :modified-at #(or % (ct/now))) - (update :sets #(into #{} (filter some?) %)) + (update :sets #(into #{} (comp (filter some?) + (map normalize-set-name)) %)) (check-token-theme-attrs) (map->TokenTheme)))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index b215dce2fe..f2b65a158c 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -500,6 +500,7 @@ (dissoc :current-file-id :workspace-editor-state + :workspace-wasm-editor-styles :workspace-media-objects :workspace-persistence :workspace-presence @@ -657,6 +658,7 @@ ;; Update the component in case shape is a main instance (when (and (some? component-id) (ctc/main-instance? shape)) (dwl/rename-component component-id clean-name)) + (dwh/dehighlight-shape shape-id) (dwu/commit-undo-transaction undo-id)))))))))) (defn rename-shape-or-variant 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/path/drawing.cljs b/frontend/src/app/main/data/workspace/path/drawing.cljs index 19923b5264..9ea648aba7 100644 --- a/frontend/src/app/main/data/workspace/path/drawing.cljs +++ b/frontend/src/app/main/data/workspace/path/drawing.cljs @@ -58,12 +58,12 @@ last-point (get-in state [:workspace-local :edit-path id :last-point]) position (cond-> (gpt/point x y) fix-angle? (path.helpers/position-fixed-angle last-point)) - shape (st/get-path state) + content (st/get-path state :content) {:keys [last-point prev-handler]} (get-in state [:workspace-local :edit-path id]) - segment (path/next-node shape position last-point prev-handler)] + segment (path/next-node content position last-point prev-handler)] (assoc-in state [:workspace-local :edit-path id :preview] segment))))) (defn add-node 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 68c389d467..cc0b46dde5 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -85,7 +85,13 @@ (effect [_ state _] (let [editor (:workspace-editor state) element (when editor (.-element editor))] - (when (and element (.-focus element)) + (cond + ;; V1 (DraftEditor) + (.-focus editor) + (ts/schedule #(.focus ^js editor)) + + ;; V2 + (and element (.-focus element)) (ts/schedule #(.focus ^js element))))))) (defn gen-name @@ -250,6 +256,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 +280,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 +297,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 +496,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 +537,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) @@ -788,14 +817,15 @@ (when (features/active-feature? state "render-wasm/v1") (rx/concat ;; Apply style to selected spans and sync content - (when (wasm.api/text-editor-is-active?) - (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)] - (when result - (rx/of (v2-update-text-shape-content - (:shape-id result) (:content result) - :update-name? true))))))) + (let [has-selection? (wasm.api/text-editor-has-selection?)] + (when has-selection? + (let [span-attrs (select-keys attrs txt/text-node-attrs)] + (when (not (empty? 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) + :update-name? true)))))))) ;; Resize (with delay for font-id changes) (cond->> (rx/of (dwwt/resize-wasm-text id)) (contains? attrs :font-id) @@ -909,7 +939,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] @@ -1124,3 +1154,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 1db210344a..0c80841cf6 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -384,6 +384,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 bfc2e07947..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 @@ -120,7 +120,7 @@ on-copy (mf/use-fn (fn [^js event] - (when (text-editor/text-editor-is-active?) + (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) (when (text-editor/text-editor-get-selection) (let [text (text-editor/text-editor-export-selection)] @@ -129,7 +129,7 @@ on-cut (mf/use-fn (fn [^js event] - (when (text-editor/text-editor-is-active?) + (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) (when (text-editor/text-editor-get-selection) (let [text (text-editor/text-editor-export-selection)] @@ -144,7 +144,7 @@ on-key-down (mf/use-fn (fn [^js event] - (when (and (text-editor/text-editor-is-active?) + (when (and (text-editor/text-editor-has-focus?) (not @composing?)) (let [key (.-key event) ctrl? (or (.-ctrlKey event) (.-metaKey event)) @@ -283,13 +283,13 @@ on-focus (mf/use-fn (fn [^js _event] - (wasm.api/text-editor-start shape-id))) + (wasm.api/text-editor-focus shape-id))) on-blur (mf/use-fn (fn [^js _event] (sync-wasm-text-editor-content! {:finalize? true}) - (wasm.api/text-editor-stop))) + (wasm.api/text-editor-blur))) style #js {:pointerEvents "all" "--editor-container-width" (dm/str width "px") @@ -312,7 +312,7 @@ (fn [] (let [timeout-id (atom nil) schedule-blink (fn schedule-blink [] - (when (text-editor/text-editor-is-active?) + (when (text-editor/text-editor-has-focus?) (wasm.api/request-render "cursor-blink")) (reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))] (schedule-blink) 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 acf7c6c61f..0a19ee1ec1 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/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index f9f9319b06..f0711768b2 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -292,7 +292,7 @@ (when left-click? (st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?)) - (when (wasm.api/text-editor-is-active?) + (when (wasm.api/text-editor-has-focus?) (wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt)))) (when middle-click? @@ -369,7 +369,8 @@ ;; in the future (when we handle the UI in the render) should be better to ;; have a "wasm.api/pointer-move" function that works as an entry point for ;; all the pointer-move events. - (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt)) + (when (wasm.api/text-editor-has-focus?) + (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))) (rx/push! move-stream pt) (reset! last-position raw-pt) diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index 9f1a0ab873..7a25682209 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -164,14 +164,16 @@ (let [name-input (mf/ref-val ref) name (str/trim (dom/get-value name-input))] (reset! edition* false) - (st/emit! (dw/end-rename-shape frame-id name))))) + (st/emit! (dw/end-rename-shape frame-id name)) + (on-frame-leave frame-id)))) cancel-edit (mf/use-fn (mf/deps frame-id) (fn [] (reset! edition* false) - (st/emit! (dw/end-rename-shape frame-id nil)))) + (st/emit! (dw/end-rename-shape frame-id nil)) + (on-frame-leave frame-id))) on-key-down (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index ab06a89709..973a3cf35e 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -173,7 +173,6 @@ (get base-objects parent-id))))) zoom (d/check-num zoom 1) - prev-zoom (mf/use-ref zoom) drawing-tool (:tool drawing) drawing-obj (:object drawing) @@ -377,7 +376,7 @@ (wasm.api/request-render "content")))))) (mf/with-effect [vport] - (when @canvas-init? + (when (and @canvas-init? @initialized?) (wasm.api/resize-viewbox (:width vport) (:height vport)))) (mf/with-effect [@canvas-init? preview-blend] @@ -404,12 +403,11 @@ (wasm.api/set-focus-mode focus))))) (mf/with-effect [vbox zoom] - (when (and @canvas-init? initialized?) - (wasm.api/set-view-box (mf/ref-val prev-zoom) zoom vbox)) - (mf/set-ref-val! prev-zoom zoom)) + (when (and @canvas-init? @initialized?) + (wasm.api/set-view-box zoom vbox))) (mf/with-effect [background] - (when (and @canvas-init? initialized?) + (when (and @canvas-init? @initialized?) (wasm.api/set-canvas-background background))) (mf/with-effect [@canvas-init? hover-grid? @hover-top-frame-id] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 0d2214acde..c3a783040b 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,20 +87,29 @@ ;; 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) (def clear-canvas-pixels webgl/clear-canvas-pixels) ;; 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-focus text-editor/text-editor-focus) +(def text-editor-blur text-editor/text-editor-blur) (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) (def text-editor-pointer-up text-editor/text-editor-pointer-up) -(def text-editor-is-active? text-editor/text-editor-is-active?) +(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) (def text-editor-select-word-boundary text-editor/text-editor-select-word-boundary) (def text-editor-sync-content text-editor/text-editor-sync-content) @@ -136,6 +146,13 @@ :fill "none"} [:& shape-wrapper {:shape shape}]])) +(defn is-text-editor-wasm-enabled + [state] + (let [runtime-features (get state :features-runtime) + enabled-features (get state :features)] + (or (contains? runtime-features "text-editor-wasm/v1") + (contains? enabled-features "text-editor-wasm/v1")))) + (defn get-static-markup [shape] (-> @@ -154,17 +171,23 @@ ;; Update text editor blink (so cursor toggles) using the same timestamp (try - (when wasm/context-initialized? + (when (is-text-editor-wasm-enabled @st/state) (text-editor/text-editor-update-blink timestamp) - ;; 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 (text-editor-wasm?) - (text-editor/text-editor-render-overlay)) + (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)) + (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))) @@ -271,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] @@ -965,34 +989,17 @@ (render (js/performance.now))))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) -(def render-pan - (letfn [(do-render-pan [ts] - ;; Check if context is still initialized before executing - ;; to prevent errors when navigating quickly - (when wasm/context-initialized? - (perf/begin-measure "render-pan") - (render ts) - (perf/end-measure "render-pan")))] - (fns/throttle do-render-pan THROTTLE_DELAY_MS))) - (defn set-view-box - [prev-zoom zoom vbox] - (let [is-pan (mth/close? prev-zoom zoom)] - (perf/begin-measure "set-view-box") - (h/call wasm/internal-module "_set_view_start") - (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) + [zoom vbox] + (perf/begin-measure "set-view-box") + (h/call wasm/internal-module "_set_view_start") + (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) + (perf/end-measure "set-view-box") - (if is-pan - (do (perf/end-measure "set-view-box") - (perf/begin-measure "set-view-box::pan") - (render-pan) - (render-finish) - (perf/end-measure "set-view-box::pan")) - (do (perf/end-measure "set-view-box") - (perf/begin-measure "set-view-box::zoom") - (h/call wasm/internal-module "_render_from_cache" 0) - (render-finish) - (perf/end-measure "set-view-box::zoom"))))) + (perf/begin-measure "render-from-cache") + (h/call wasm/internal-module "_render_from_cache" 0) + (render-finish) + (perf/end-measure "render-from-cache")) (defn update-text-rect! [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 70c225ced2..cadbd72d31 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -7,21 +7,129 @@ (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])) -(defn text-editor-start +(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? (let [buffer (uuid/get-u32 id)] - (when-not (h/call wasm/internal-module "_text_editor_start" + (when-not (h/call wasm/internal-module "_text_editor_focus" (aget buffer 0) (aget buffer 1) (aget buffer 2) (aget buffer 3)) - (throw (js/Error. "TextEditor initialization failed")))))) + (throw (js/Error. "TextEditor focus failed")))))) (defn text-editor-set-cursor-from-offset "Sets caret position from shape relative coordinates" @@ -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)) @@ -142,26 +387,36 @@ (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_select_word_boundary" x y))) -(defn text-editor-stop +(defn text-editor-blur [] (when wasm/context-initialized? - (when-not (h/call wasm/internal-module "_text_editor_stop") - (throw (js/Error. "TextEditor finalization failed"))))) + (when-not (h/call wasm/internal-module "_text_editor_blur") + (throw (js/Error. "TextEditor blur failed"))))) -(defn text-editor-is-active? +(defn text-editor-dispose + [] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_dispose"))) + +(defn text-editor-has-focus? ([id] (when wasm/context-initialized? - (not (zero? (h/call wasm/internal-module "_text_editor_is_active_with_id" id))))) + (not (zero? (h/call wasm/internal-module "_text_editor_has_focus_with_id" id))))) ([] (when wasm/context-initialized? - (not (zero? (h/call wasm/internal-module "_text_editor_is_active")))))) + (not (zero? (h/call wasm/internal-module "_text_editor_has_focus")))))) + +(defn text-editor-has-selection? + ([] + (when wasm/context-initialized? + (not (zero? (h/call wasm/internal-module "_text_editor_has_selection")))))) (defn text-editor-export-content [] (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)))))) @@ -171,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))))) @@ -198,18 +453,20 @@ (defn text-editor-get-selection [] (when wasm/context-initialized? - (let [byte-offset (mem/alloc 16) - u32-offset (mem/->offset-32 byte-offset) - heap (mem/get-heap-u32) - active? (h/call wasm/internal-module "_text_editor_get_selection" byte-offset)] - (try - (when (= active? 1) - {:anchor-para (aget heap u32-offset) - :anchor-offset (aget heap (+ u32-offset 1)) - :focus-para (aget heap (+ u32-offset 2)) - :focus-offset (aget heap (+ u32-offset 3))}) - (finally - (mem/free)))))) + (let [byte-offset (mem/alloc 16) + u32-offset (mem/->offset-32 byte-offset) + heap (mem/get-heap-u32) + has-selection? (h/call wasm/internal-module "_text_editor_get_selection" byte-offset)] + (if has-selection? + (let [result {:anchor-para (aget heap u32-offset) + :anchor-offset (aget heap (+ u32-offset 1)) + :focus-para (aget heap (+ u32-offset 2)) + :focus-offset (aget heap (+ u32-offset 3))}] + (mem/free) + result) + (do + (mem/free) + nil))))) ;; This is used as a intermediate cache between Clojure global state and WASM state. (def ^:private shape-text-contents (atom {})) @@ -260,7 +517,7 @@ shape-id and the fully merged content map ready for v2-update-text-shape-content." [] - (when (and wasm/context-initialized? (text-editor-is-active?)) + (when (and wasm/context-initialized? (text-editor-has-focus?)) (let [shape-id (text-editor-get-active-shape-id) new-texts (text-editor-export-content)] (when (and shape-id new-texts) @@ -330,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 (and wasm/context-initialized? (text-editor-is-active?)) - (let [shape-id (text-editor-get-active-shape-id) - sel (text-editor-get-selection)] - (when (and shape-id sel) + (when wasm/context-initialized? + (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/frontend/src/debug.cljs b/frontend/src/debug.cljs index e586dc3d1a..ee3f58b74c 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -31,6 +31,9 @@ [app.main.errors :as errors] [app.main.repo :as rp] [app.main.store :as st] + [app.render-wasm.helpers :as wasm.h] + [app.render-wasm.mem :as wasm.mem] + [app.render-wasm.wasm :as wasm] [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.http :as http] @@ -117,6 +120,43 @@ (js/console.log str (json/->js val)) val)) +(defn- wasm-read-len-prefixed-utf8 + "Reads a `[u32 byte_len][utf8 bytes...]` buffer returned by WASM and frees it. + Returns a JS string (possibly empty)." + [ptr] + (when (and ptr (not (zero? ptr))) + (let [heap-u8 (wasm.mem/get-heap-u8) + heap-u32 (wasm.mem/get-heap-u32) + len (aget heap-u32 (wasm.mem/->offset-32 ptr)) + start (+ ptr 4) + end (+ start len) + decoder (js/TextDecoder. "utf-8") + text (.decode decoder (.subarray heap-u8 start end))] + (wasm.mem/free) + text))) + +(defn ^:export wasmCacheConsole + "Logs the current render-wasm cache surface as an image in the JS console." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_cache_console"))] + (if (fn? f) + (wasm.h/call module "_debug_cache_console") + (js/console.warn "[debug] render-wasm module not ready or missing _debug_cache_console")))) + +(defn ^:export wasmCacheBase64 + "Returns the cache surface PNG base64 (empty string if missing/empty)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_cache_base64"))] + (if (fn? f) + (let [ptr (wasm.h/call module "_debug_cache_base64") + s (or (wasm-read-len-prefixed-utf8 ptr) "")] + s) + (do + (js/console.warn "[debug] render-wasm module not ready or missing _debug_cache_base64") + "")))) + (when (exists? js/window) (set! (.-dbg ^js js/window) json/->js) (set! (.-pp ^js js/window) pprint)) diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 8c98662396..a27fab4c2d 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -1238,7 +1238,11 @@ export class SelectionController extends EventTarget { textSpan.childNodes.length === 0 ) { textSpan.remove(); - return this.collapse(nextTextNode, 0); + // nextTextNode can be null when deleting the last text node in the last + // span of the paragraph; fall back to the last text node of the + // preceding sibling span so the cursor stays within the paragraph. + const forwardTarget = nextTextNode ?? paragraph.lastChild?.lastChild; + return this.collapse(forwardTarget, 0); } return this.collapse(this.focusNode, this.focusOffset); } @@ -1284,9 +1288,14 @@ export class SelectionController extends EventTarget { textSpan.childNodes.length === 0 ) { textSpan.remove(); + // previousTextNode can be null when deleting the first text node in + // the paragraph (no preceding sibling text node exists). Fall back + // to the first text node of the now-first remaining span so the + // cursor stays within the paragraph. + const backwardTarget = previousTextNode ?? paragraph.firstChild?.firstChild; return this.collapse( - previousTextNode, - getTextNodeLength(previousTextNode), + backwardTarget, + getTextNodeLength(backwardTarget), ); } @@ -1358,9 +1367,13 @@ export class SelectionController extends EventTarget { textSpan.childNodes.length === 0 ) { textSpan.remove(); + // previousTextNode can be null when the deleted node was the first + // in the paragraph. Fall back to the first text node of the + // now-first remaining span. + const backwardTarget = previousTextNode ?? paragraph.firstChild?.firstChild; return this.collapse( - previousTextNode, - getTextNodeLength(previousTextNode), + backwardTarget, + getTextNodeLength(backwardTarget), ); } } diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index 5d5b4a7c8c..e549646039 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -581,6 +581,27 @@ describe("SelectionController", () => { expect(textEditorMock.root.textContent).toBe(""); }); + test("`removeBackwardText` should not throw when deleting the first character of the first span in a multi-span paragraph", () => { + // Regression test: previousNode() returns null when the cursor is at the + // very first text node; passing null to getTextNodeLength used to throw + // "TypeError: Invalid text node". + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["A", "B"], + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + // Focus at offset 1 of the first span's text node ("A"), then delete backward + const firstTextNode = root.firstChild.firstChild.firstChild; + focus(selection, textEditorMock, firstTextNode, 1); + expect(() => selectionController.removeBackwardText()).not.toThrow(); + // "A" is removed; the paragraph should keep the second span with "B" + expect(textEditorMock.root.textContent).toBe("B"); + }); + test("`insertParagraph` should insert a new paragraph in an empty editor", () => { const textEditorMock = TextEditorMock.createTextEditorMockEmpty(); const root = textEditorMock.root; @@ -875,6 +896,26 @@ describe("SelectionController", () => { ); }); + test("`removeForwardText` should not throw when deleting the last character of the last span in a multi-span paragraph", () => { + // Regression test: nextNode() returns null when the cursor is at the + // very last text node; passing null to collapse used to crash. + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["A", "B"], + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + // Focus at offset 0 of the second span's text node ("B"), then delete forward + const secondTextNode = root.firstChild.lastChild.firstChild; + focus(selection, textEditorMock, secondTextNode, 0); + expect(() => selectionController.removeForwardText()).not.toThrow(); + // "B" is removed; the paragraph should keep the first span with "A" + expect(textEditorMock.root.textContent).toBe("A"); + }); + test("`replaceText` should replace the selected text", () => { const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello, World!"); diff --git a/mcp/README.md b/mcp/README.md index 4ae97c8c69..250bf792c5 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -61,11 +61,11 @@ version that matches your Penpot version. * If you are using the latest Penpot release, e.g. as served on [design.penpot.app](https://design.penpot.app), run: ```shell - npx -y @penpot/mcp@">=0" + npx -y @penpot/mcp@latest ``` * If you are participating in the MCP beta-test, which uses [test-mcp.penpot.dev](https://test-mcp.penpot.dev), run: ```shell - npx -y @penpot/mcp@"*" + npx -y @penpot/mcp@beta ``` Once the servers are running, continue with step 2. @@ -157,6 +157,19 @@ This bootstrap command will: ### 3. Connect an MCP Client +> [!IMPORTANT] +> **Use an appropriate model.** +> +> We recommend that you ... +> * use the most capable model at your disposal. +> You will achieve the best results with frontier models, +> especially when dealing with more complex tasks. +> Weaker models, including most locally hosted ones, +> are unlikely to produce usable results for anything beyond simple tasks. +> * use a vision language model (VLM), as many design tasks necessitate visual +> inspection. +> (If you are using a standard commercial model, it almost certainly supports vision already.) + By default, the server runs on port 4401 and provides: - **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp` @@ -174,14 +187,9 @@ NOTE: only relevant if you are executing this outside of devenv The `mcp-remote` package can proxy stdio transport to HTTP/SSE, allowing clients that support only stdio to connect to the MCP server indirectly. +Use it to provide the launch command for your MCP client as follows: -1. Install `mcp-remote` globally if you haven't already: - - npm install -g mcp-remote - -2. Use `mcp-remote` to provide the launch command for your MCP client: - - npx -y mcp-remote http://localhost:4401/sse --allow-http + npx -y mcp-remote http://localhost:4401/mcp --allow-http #### Example: Claude Desktop @@ -204,7 +212,7 @@ Add a `penpot` entry under `mcpServers` with the following content: "mcpServers": { "penpot": { "command": "npx", - "args": ["-y", "mcp-remote", "http://localhost:4401/sse", "--allow-http"] + "args": ["-y", "mcp-remote", "http://localhost:4401/mcp", "--allow-http"] } } } 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/render.rs b/render-wasm/src/render.rs index c66bcdf2b3..cd53be9a97 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -337,6 +337,9 @@ pub(crate) struct RenderState { /// Preview render mode - when true, uses simplified rendering for progressive loading pub preview_mode: bool, pub export_context: Option<(Rect, f32)>, + /// Cleared at the beginning of a render pass; set to true after we clear Cache the first + /// time we are about to blit a tile into Cache for this pass. + pub cache_cleared_this_render: bool, } pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { @@ -411,6 +414,7 @@ impl RenderState { ignore_nested_blurs: false, preview_mode: false, export_context: None, + cache_cleared_this_render: false, }) } @@ -665,6 +669,13 @@ impl RenderState { } pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> { + // Decide *now* (at the first real cache blit) whether we need to clear Cache. + // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), + // while still preventing stale pixels from surviving across full-quality renders. + if !self.options.is_fast_mode() && !self.cache_cleared_this_render { + self.surfaces.clear_cache(self.background_color); + self.cache_cleared_this_render = true; + } let tile_rect = self.get_current_aligned_tile_bounds()?; self.surfaces.cache_current_tile_texture( &self.tile_viewbox, @@ -1497,6 +1508,7 @@ impl RenderState { performance::begin_measure!("render"); performance::begin_measure!("start_render_loop"); + self.cache_cleared_this_render = false; self.reset_canvas(); let surface_ids = SurfaceId::Strokes as u32 | SurfaceId::Fills as u32 @@ -2443,6 +2455,10 @@ impl RenderState { &node_render_state, target_surface, )?; + } else { + // This is necessary or the later flush_and_submit will be very slow + self.surfaces + .draw_into(SurfaceId::DropShadows, target_surface, None); } self.render_shape( diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 624cdaef78..47b739b484 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -1,4 +1,7 @@ use super::{tiles, RenderState, SurfaceId}; +use crate::with_state_mut; +use crate::STATE; +use macros::wasm_error; use skia_safe::{self as skia, Rect}; #[cfg(target_arch = "wasm32")] @@ -210,3 +213,13 @@ pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId, run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')")) } } + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_cache_console() -> Result<()> { + with_state_mut!(state, { + console_debug_surface(state.render_state_mut(), SurfaceId::Cache); + }); + Ok(()) +} diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 2ab32d1a61..3b7d4bcbbc 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -533,6 +533,15 @@ impl Surfaces { self.clear_all_dirty(); } + /// Clears the whole cache surface without disturbing its configured transform. + pub fn clear_cache(&mut self, color: skia::Color) { + let canvas = self.cache.canvas(); + canvas.save(); + canvas.reset_matrix(); + canvas.clear(color); + canvas.restore(); + } + pub fn cache_current_tile_texture( &mut self, tile_viewbox: &TileViewbox, diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 75178d146b..a9acc7bd57 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -4,7 +4,7 @@ use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; use skia_safe::{BlendMode, Canvas, Paint, Rect}; pub fn render_overlay(canvas: &Canvas, editor_state: &TextEditorState, shape: &Shape) { - if !editor_state.is_active { + if !editor_state.has_focus { return; } 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 1c40c25053..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, @@ -165,13 +320,14 @@ pub struct TextEditorState { pub theme: TextEditorTheme, pub selection: TextSelection, pub composition: TextComposition, - pub is_active: bool, + pub has_focus: bool, // This property indicates that we've started // selecting something with the pointer. pub is_pointer_selection_active: bool, pub active_shape_id: Option, pub cursor_visible: bool, pub last_blink_time: f64, + pub current_styles: TextEditorStyles, pending_events: Vec, } @@ -185,17 +341,18 @@ impl TextEditorState { }, selection: TextSelection::new(), composition: TextComposition::new(), - is_active: false, + has_focus: false, is_pointer_selection_active: false, active_shape_id: None, cursor_visible: true, last_blink_time: 0.0, pending_events: Vec::new(), + current_styles: TextEditorStyles::new(), } } - pub fn start(&mut self, shape_id: Uuid) { - self.is_active = true; + pub fn focus(&mut self, shape_id: Uuid) { + self.has_focus = true; self.active_shape_id = Some(shape_id); self.cursor_visible = true; self.last_blink_time = 0.0; @@ -204,8 +361,18 @@ impl TextEditorState { self.pending_events.clear(); } - pub fn stop(&mut self) { - self.is_active = false; + pub fn blur(&mut self) { + self.has_focus = 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(); + } + + pub fn dispose(&mut self) { + self.has_focus = false; self.active_shape_id = None; self.cursor_visible = false; self.last_blink_time = 0.0; @@ -230,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)] @@ -254,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; } @@ -285,6 +453,7 @@ impl TextEditorState { position.paragraph, 0, )); + self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); return; @@ -306,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; @@ -329,6 +499,7 @@ impl TextEditorState { position.paragraph, end, )); + self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } @@ -343,8 +514,185 @@ 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.is_active { + if !self.has_focus { return; } @@ -379,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 1c6de52fe8..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)] @@ -44,7 +47,7 @@ pub extern "C" fn text_editor_apply_theme( } #[no_mangle] -pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool { +pub extern "C" fn text_editor_focus(a: u32, b: u32, c: u32, d: u32) -> bool { with_state_mut!(state, { let shape_id = uuid_from_u32_quartet(a, b, c, d); @@ -56,35 +59,48 @@ pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool { return false; } - state.text_editor_state.start(shape_id); + state.text_editor_state.focus(shape_id); true }) } #[no_mangle] -pub extern "C" fn text_editor_stop() -> bool { +pub extern "C" fn text_editor_blur() -> bool { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return false; } - state.text_editor_state.stop(); + state.text_editor_state.blur(); true }) } #[no_mangle] -pub extern "C" fn text_editor_is_active() -> bool { - with_state!(state, { state.text_editor_state.is_active }) +pub extern "C" fn text_editor_dispose() -> bool { + with_state_mut!(state, { + state.text_editor_state.dispose(); + true + }) } #[no_mangle] -pub extern "C" fn text_editor_is_active_with_id(a: u32, b: u32, c: u32, d: u32) -> bool { +pub extern "C" fn text_editor_has_selection() -> bool { + with_state!(state, { state.text_editor_state.selection.is_selection() }) +} + +#[no_mangle] +pub extern "C" fn text_editor_has_focus() -> bool { + with_state!(state, { state.text_editor_state.has_focus }) +} + +#[no_mangle] +pub extern "C" fn text_editor_has_focus_with_id(a: u32, b: u32, c: u32, d: u32) -> bool { with_state!(state, { let shape_id = uuid_from_u32_quartet(a, b, c, d); let Some(active_shape_id) = state.text_editor_state.active_shape_id else { return false; }; - state.text_editor_state.is_active && active_shape_id == shape_id + state.text_editor_state.has_focus && active_shape_id == shape_id }) } @@ -106,7 +122,7 @@ pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) { #[no_mangle] pub extern "C" fn text_editor_select_all() -> bool { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return false; } @@ -128,7 +144,7 @@ pub extern "C" fn text_editor_select_all() -> bool { #[no_mangle] pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return; } @@ -165,7 +181,7 @@ pub extern "C" fn text_editor_poll_event() -> u8 { #[no_mangle] pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return; } let Some(shape_id) = state.text_editor_state.active_shape_id else { @@ -181,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); } }); } @@ -188,7 +205,7 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { #[no_mangle] pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return; } let point = Point::new(x, y); @@ -209,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); } }); } @@ -216,7 +234,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { #[no_mangle] pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return; } let point = Point::new(x, y); @@ -236,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(); }); @@ -244,7 +263,7 @@ 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 { + if !state.text_editor_state.has_focus { return; } @@ -267,7 +286,7 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { #[no_mangle] pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return; } @@ -299,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(); @@ -318,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(()); } @@ -379,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(()); } @@ -435,7 +454,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { }; with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return Ok(()); } @@ -473,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); }); @@ -488,7 +507,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { #[no_mangle] pub extern "C" fn text_editor_delete_backward(word_boundary: bool) { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return; } @@ -504,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); }); } @@ -541,7 +533,7 @@ pub extern "C" fn text_editor_delete_backward(word_boundary: bool) { #[no_mangle] pub extern "C" fn text_editor_delete_forward(word_boundary: bool) { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return; } @@ -557,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); }); } @@ -594,7 +559,7 @@ pub extern "C" fn text_editor_delete_forward(word_boundary: bool) { #[no_mangle] pub extern "C" fn text_editor_insert_paragraph() { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return; } @@ -610,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); }); } @@ -652,7 +591,7 @@ pub extern "C" fn text_editor_move_cursor( extend_selection: bool, ) { with_state_mut!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return; } @@ -668,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, + ); }); } @@ -735,7 +623,7 @@ pub extern "C" fn text_editor_move_cursor( #[no_mangle] pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { with_state_mut!(state, { - if !state.text_editor_state.is_active || !state.text_editor_state.cursor_visible { + if !state.text_editor_state.has_focus || !state.text_editor_state.cursor_visible { return std::ptr::null_mut(); } @@ -766,10 +654,175 @@ 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, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return std::ptr::null_mut(); } @@ -791,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(); } @@ -818,10 +870,6 @@ pub extern "C" fn text_editor_update_blink(timestamp_ms: f64) { #[no_mangle] pub extern "C" fn text_editor_render_overlay() { with_state_mut!(state, { - if !state.text_editor_state.is_active { - return; - } - let Some(shape_id) = state.text_editor_state.active_shape_id else { return; }; @@ -858,7 +906,7 @@ pub extern "C" fn text_editor_render_overlay() { #[no_mangle] pub extern "C" fn text_editor_export_content() -> *mut u8 { with_state!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return std::ptr::null_mut(); } @@ -901,7 +949,7 @@ pub extern "C" fn text_editor_export_content() -> *mut u8 { pub extern "C" fn text_editor_export_selection() -> *mut u8 { use std::ptr; with_state!(state, { - if !state.text_editor_state.is_active { + if !state.text_editor_state.has_focus { return ptr::null_mut(); } let Some(shape_id) = state.text_editor_state.active_shape_id else { @@ -976,10 +1024,10 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 { } #[no_mangle] -pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 { +pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool { with_state!(state, { - if !state.text_editor_state.is_active { - return 0; + if !state.text_editor_state.selection.is_selection() { + return false; } let sel = &state.text_editor_state.selection; unsafe { @@ -988,7 +1036,7 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 { *buffer_ptr.add(2) = sel.focus.paragraph as u32; *buffer_ptr.add(3) = sel.focus.offset as u32; } - 1 + true }) }