Compare commits

...

1 Commits

Author SHA1 Message Date
Elena Torro
e46697ca8a 🔧 Set up embedded editor 2026-01-29 17:13:12 +01:00
17 changed files with 2667 additions and 186 deletions

View File

@@ -55,6 +55,7 @@
"design-tokens/v1"
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"render-wasm/v1"
"variants/v1"})
@@ -78,6 +79,7 @@
"plugins/runtime"
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"tokens/numeric-input"
"render-wasm/v1"})
@@ -127,6 +129,7 @@
:feature-design-tokens "design-tokens/v1"
:feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-text-editor-wasm "text-editor-wasm/v1"
:feature-render-wasm "render-wasm/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"

View File

@@ -821,11 +821,20 @@
(rx/of (v2-update-text-editor-styles id attrs)))
(when (features/active-feature? state "render-wasm/v1")
;; This delay is to give time for the font to be correctly rendered
;; in wasm.
(cond->> (rx/of (resize-wasm-text id))
(contains? attrs :font-id)
(rx/delay 200)))))))
(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)))))))
;; Resize (with delay for font-id changes)
(cond->> (rx/of (resize-wasm-text id))
(contains? attrs :font-id)
(rx/delay 200))))))))
ptk/EffectEvent
(effect [_ state _]

View File

@@ -345,11 +345,9 @@
max-height (max height selrect-height)
valign (-> shape :content :vertical-align)
y (:y selrect)
y (if (and valign (> height selrect-height))
(case valign
"bottom" (- y (- height selrect-height))
"center" (- y (/ (- height selrect-height) 2))
y)
y (case valign
"bottom" (+ y (- selrect-height height))
"center" (+ y (/ (- selrect-height height) 2))
y)]
[(assoc selrect :y y :width max-width :height max-height) transform])

View File

@@ -19,10 +19,14 @@
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.path :as dwdp]
[app.main.data.workspace.specialized-panel :as-alias dwsp]
[app.main.data.workspace.texts :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.workspace.sidebar.assets.components :as wsac]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm.wasm]
[app.util.dom :as dom]
[app.util.dom.dnd :as dnd]
[app.util.dom.normalize-wheel :as nw]
@@ -91,7 +95,17 @@
::dwsp/interrupt)
(when (and (not= edition id) (or text-editing? grid-editing?))
(st/emit! (dw/clear-edition-mode)))
(st/emit! (dw/clear-edition-mode))
;; Sync and stop WASM text editor when exiting edit mode
(when (and text-editing?
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? true)))
(wasm.api/text-editor-stop)))
(when (and (not text-editing?)
(not blocked)
@@ -184,6 +198,20 @@
(not drawing-tool))
(st/emit! (dw/select-shape (:id @hover) shift?)))
;; If clicking on a text shape and wasm render is enabled, forward cursor position
(when (and hovering?
(not @space?)
edition ;; Only when already in edit mode
(not drawing-path?)
(not drawing-tool))
(let [hover-shape @hover]
(when (and (= :text (:type hover-shape))
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(let [raw-pt (dom/get-client-position event)]
;; FIXME
(wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt))))))
(when (and @z?
(not @space?)
(not edition)
@@ -223,8 +251,17 @@
(when (and (not drawing-path?) shape)
(cond
(and editable? (not= id edition) (not read-only?))
(st/emit! (dw/select-shape id)
(dw/start-editing-selected))
(do
(st/emit! (dw/select-shape id)
(dw/start-editing-selected))
;; If using wasm text-editor, notify WASM to start editing this shape
;; and set cursor position from the double-click location
(when (and (= type :text)
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(let [raw-pt (dom/get-client-position event)
viewport-pt (uwvv/point->viewport-relative raw-pt)]
(wasm.api/text-editor-start id))))
(some? selected-shape)
(do

View File

@@ -22,6 +22,7 @@
[app.main.data.workspace.path.shortcuts :as psc]
[app.main.data.workspace.shortcuts :as wsc]
[app.main.data.workspace.text.shortcuts :as tsc]
[app.main.data.workspace.texts :as dwt]
[app.main.features :as features]
[app.main.store :as st]
[app.main.streams :as ms]
@@ -31,6 +32,7 @@
[app.main.ui.workspace.viewport.utils :as utils]
[app.main.worker :as mw]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm.wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.globals :as globals]
@@ -164,7 +166,6 @@
;; for the release of the z key
(when-not ^boolean value
(reset! z* false))))
(hooks/use-stream kbd-zoom-s
(fn [kevent]
(dom/prevent-default kevent)

View File

@@ -66,6 +66,14 @@
(gpt/divide zoom)
(gpt/add box))))))
(defn point->viewport-relative
"Convert client coordinates to viewport-relative coordinates.
Unlike point->viewport, this does NOT convert to canvas coordinates -
it just subtracts the viewport's bounding rect offset."
[pt]
(when (some? @viewport-brect)
(gpt/subtract pt @viewport-brect)))
(defn inside-viewport?
[target]
(dom/is-child? @viewport-ref target))

View File

@@ -54,6 +54,7 @@
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.widgets :as widgets]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor-input :refer [text-editor-input]]
[app.util.debug :as dbg]
[app.util.text-editor :as ted]
[beicon.v2.core :as rx]
@@ -407,7 +408,14 @@
(when picking-color?
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
:canvas-ref canvas-ref}])]
:canvas-ref canvas-ref}])
;; WASM text editor contenteditable (must be outside SVG to work)
(when (and show-text-editor?
(features/active-feature? @st/state "text-editor-wasm/v1"))
[:& text-editor-input {:shape editing-shape
:zoom zoom
:vbox vbox}])]
[:canvas {:id "render"
:data-testid "canvas-wasm-shapes"
@@ -452,7 +460,10 @@
:height (max 0 (- (:height vbox) rule-area-size))}]]]
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
(when show-text-editor?
;; Text editor handling:
;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
(when (and show-text-editor?
(not (features/active-feature? @st/state "text-editor-wasm/v1")))
(if (features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref

View File

@@ -39,6 +39,7 @@
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.text-editor :as text-editor]
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
@@ -53,6 +54,10 @@
(def use-dpr? (contains? cf/flags :render-wasm-dpr))
;; Cache of text content per shape-id, populated when content is sent to WASM.
;; Used by the text editor sync to reconstruct the full content tree with styling.
(def ^:private shape-text-contents (atom {}))
(def ^:const UUID-U8-SIZE 16)
(def ^:const UUID-U32-SIZE (/ UUID-U8-SIZE 4))
@@ -74,6 +79,18 @@
;; Threshold below which we use synchronous processing (no chunking overhead)
(def ^:const ASYNC_THRESHOLD 100)
;; 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-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
(def text-editor-is-active? text-editor/text-editor-is-active?)
(def text-editor-sync-content text-editor/text-editor-sync-content)
(def dpr
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
@@ -109,11 +126,36 @@
(mf/element object-svg #js {:shape shape})
(rds/renderToStaticMarkup)))
;; forward declare helpers so render can call them
(declare request-render)
(declare set-shape-vertical-align fonts-from-text-content)
;; This should never be called from the outside.
(defn- render
[timestamp]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render" timestamp)
;; Update text editor blink (so cursor toggles) using the same timestamp
(try
(when wasm/context-initialized?
(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.
(let [runtime-features (get @st/state :features-runtime)
enabled-features (get @st/state :features)]
(when (or (contains? runtime-features "text-editor-wasm/v1")
(contains? enabled-features "text-editor-wasm/v1"))
(text-editor/text-editor-render-overlay)))
;; Poll for editor events; if any event occurs, trigger a re-render
(let [ev (text-editor/text-editor-poll-event)]
(when (and ev (not= ev 0))
(request-render "text-editor-event"))))
(catch :default e
(js/console.error "text-editor overlay/update failed:" e)))
(set! wasm/internal-frame-id nil)
(ug/dispatch! (ug/event "penpot:wasm:render"))))
@@ -187,23 +229,6 @@
(declare get-text-dimensions)
(defn update-text-rect!
[id]
(when wasm/context-initialized?
(mw/emit!
{:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))
(defn- ensure-text-content
"Guarantee that the shape always sends a valid text tree to WASM. When the
content is nil (freshly created text) we fall back to
tc/default-text-content so the renderer receives typography information."
[content]
(or content (tc/v2-default-text-content)))
(defn use-shape
[id]
(when wasm/context-initialized?
@@ -214,6 +239,47 @@
(aget buffer 2)
(aget buffer 3)))))
(defn set-shape-text-content
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
[shape-id content]
;; Cache content for text editor sync
(text-editor/cache-shape-text-content! shape-id content)
(h/call wasm/internal-module "_clear_shape_text")
(set-shape-vertical-align (get content :vertical-align))
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts shape-id all-fonts)]
(f/load-fallback-fonts-for-editor! fallback-fonts)
(h/call wasm/internal-module "_update_shape_text_layout")
result))
(defn apply-style-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))
(defn update-text-rect!
[id]
(when wasm/context-initialized?
(mw/emit!
{:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))
(defn- ensure-text-content
"Guarantee that the shape always sends a valid text tree to WASM. When the
content is nil (freshly created text) we fall back to
tc/default-text-content so the renderer receives typography information."
[content]
(or content (tc/v2-default-text-content)))
(defn set-parent-id
[id]
(let [buffer (uuid/get-u32 id)]
@@ -857,22 +923,6 @@
(if fallback-fonts-only? updated-fonts fallback-fonts))))))
(defn set-shape-text-content
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
[shape-id content]
(h/call wasm/internal-module "_clear_shape_text")
(set-shape-vertical-align (get content :vertical-align))
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts shape-id all-fonts)]
(f/load-fallback-fonts-for-editor! fallback-fonts)
(h/call wasm/internal-module "_update_shape_text_layout")
result))
(defn set-shape-grow-type
[grow-type]
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
@@ -1539,33 +1589,41 @@
(persistent! result)))
result
(->> result
(mapv
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
(let [content (:content shape)
element (-> content :children
(get 0) :children ;; paragraph-set
(get paragraph) :children ;; paragraph
(get span))
text (subs (:text element) start-pos end-pos)]
(into []
(keep
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
(let [content (:content shape)
element (-> content :children
(get 0) :children ;; paragraph-set
(get paragraph) :children ;; paragraph
(get span))
element-text (:text element)]
(d/patch-object
txt/default-text-attrs
(d/without-nils
{:x x
:y (+ y height)
:width width
:height height
:direction (dr/translate-direction direction)
:font-family (get element :font-family)
:font-size (get element :font-size)
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (get element :fills)
:text text}))))))]
;; Add comprehensive nil-safety checks
(when (and element
element-text
(>= start-pos 0)
(<= end-pos (count element-text))
(<= start-pos end-pos))
(let [text (subs element-text start-pos end-pos)]
(d/patch-object
txt/default-text-attrs
(d/without-nils
{:x x
:y (+ y height)
:width width
:height height
:direction (dr/translate-direction direction)
:font-family (get element :font-family)
:font-size (get element :font-size)
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (get element :fills)
:text text})))))))
result)]
(mem/free)
result)))
@@ -1599,7 +1657,4 @@
(p/resolved false)))))
(p/resolved false))))
;; 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)

View File

@@ -61,6 +61,30 @@
[]
(h/call wasm/internal-module "_free_bytes"))
(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)
length (- end-idx ptr)
bytes (.slice heap ptr end-idx)
;; Decode using TextDecoder
decoder (js/TextDecoder. "utf-8")]
(.decode decoder bytes))))
(defn slice
"Returns a copy of a portion of a typed array into a new typed array
object selected from start to end."

View File

@@ -0,0 +1,299 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.text-editor
"Text editor WASM bindings"
(:require
[app.common.uuid :as uuid]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.wasm :as wasm]))
(defn text-editor-start
[id]
(when wasm/context-initialized?
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_text_editor_start"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)))))
(defn text-editor-set-cursor-from-point
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
(defn text-editor-update-blink
[timestamp-ms]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms)))
(defn text-editor-render-overlay
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_render_overlay")))
(defn text-editor-poll-event
[]
(when wasm/context-initialized?
(let [res (h/call wasm/internal-module "_text_editor_poll_event")]
res)))
(defn text-editor-insert-text
[text]
(when wasm/context-initialized?
(let [encoder (js/TextEncoder.)
buf (.encode encoder text)
heapu8 (mem/get-heap-u8)
size (mem/size buf)
offset (mem/alloc size)]
(mem/write-buffer offset heapu8 buf)
(h/call wasm/internal-module "_text_editor_insert_text")
(mem/free))))
(defn text-editor-delete-backward []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_backward")))
(defn text-editor-delete-forward []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_forward")))
(defn text-editor-insert-paragraph []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
(defn text-editor-move-cursor
[direction extend-selection]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_move_cursor" direction (if extend-selection 1 0))))
(defn text-editor-select-all
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_select_all")))
(defn text-editor-stop
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_stop")))
(defn text-editor-is-active?
[]
(when wasm/context-initialized?
(not (zero? (h/call wasm/internal-module "_text_editor_is_active")))))
(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)]
(mem/free)
(js/JSON.parse json-str))))))
(defn text-editor-export-selection
"Export only the currently selected text as plain text from the WASM editor. Requires WASM support (_text_editor_export_selection)."
[]
(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)]
(mem/free)
text)))))
(defn text-editor-get-active-shape-id
[]
(when wasm/context-initialized?
(try
(let [byte-offset (mem/alloc 16)
u32-offset (mem/->offset-32 byte-offset)
heap (mem/get-heap-u32)]
(h/call wasm/internal-module "_text_editor_get_active_shape_id" byte-offset)
(let [a (aget heap u32-offset)
b (aget heap (+ u32-offset 1))
c (aget heap (+ u32-offset 2))
d (aget heap (+ u32-offset 3))
result (when (or (not= a 0) (not= b 0) (not= c 0) (not= d 0))
(uuid/from-unsigned-parts a b c d))]
(mem/free)
result))
(catch js/Error e
(js/console.error "[text-editor-get-active-shape-id] Error:" e)
nil))))
(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)]
(let [result (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))})]
(mem/free)
result))))
(def ^:private shape-text-contents (atom {}))
(defn- merge-exported-texts-into-content
"Merge exported span texts back into the existing content tree.
The WASM editor may split or merge paragraphs (Enter / Backspace at
paragraph boundary), so the exported structure can differ from the
original. When extra paragraphs or spans appear we clone styling from
the nearest existing sibling; when fewer appear we truncate.
exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]]
content existing Penpot content map (root -> paragraph-set -> …)"
[content exported-texts]
(let [para-set (first (get content :children))
orig-paras (get para-set :children)
num-orig (count orig-paras)
last-orig-para (when (seq orig-paras) (last orig-paras))
template-span (when last-orig-para
(-> last-orig-para :children last))
new-paras
(mapv (fn [para-idx exported-span-texts]
(let [orig-para (if (< para-idx num-orig)
(nth orig-paras para-idx)
(dissoc last-orig-para :children))
orig-spans (get orig-para :children)
num-orig-spans (count orig-spans)
last-orig-span (when (seq orig-spans) (last orig-spans))]
(assoc orig-para :children
(mapv (fn [span-idx new-text]
(let [orig-span (if (< span-idx num-orig-spans)
(nth orig-spans span-idx)
(or last-orig-span template-span))]
(assoc orig-span :text new-text)))
(range (count exported-span-texts))
exported-span-texts))))
(range (count exported-texts))
exported-texts)
new-para-set (assoc para-set :children new-paras)]
(assoc content :children [new-para-set])))
(defn text-editor-sync-content
"Sync text content from the WASM text editor back to the frontend shape.
Exports the current span texts from WASM, merges them into the shape's
cached content tree (preserving per-span styling), and returns the
shape-id and the fully merged content map ready for
v2-update-text-shape-content."
[]
(when (and wasm/context-initialized? (text-editor-is-active?))
(let [shape-id (text-editor-get-active-shape-id)
new-texts (text-editor-export-content)]
(when (and shape-id new-texts)
(let [texts-clj (js->clj new-texts)
content (get @shape-text-contents shape-id)]
(when content
(let [merged (merge-exported-texts-into-content content texts-clj)]
(swap! shape-text-contents assoc shape-id merged)
{:shape-id shape-id
:content merged})))))))
(defn cache-shape-text-content!
[shape-id content]
(when (some? content)
(swap! shape-text-contents assoc shape-id content)))
(defn get-cached-content
[shape-id]
(get @shape-text-contents shape-id))
(defn update-cached-content!
[shape-id content]
(swap! shape-text-contents assoc shape-id content))
(defn- normalize-selection
"Given anchor/focus para+offset, return {:start-para :start-offset :end-para :end-offset}
ordered so start <= end."
[{:keys [anchor-para anchor-offset focus-para focus-offset]}]
(if (or (< anchor-para focus-para)
(and (= anchor-para focus-para) (<= anchor-offset focus-offset)))
{:start-para anchor-para :start-offset anchor-offset
:end-para focus-para :end-offset focus-offset}
{:start-para focus-para :start-offset focus-offset
:end-para anchor-para :end-offset anchor-offset}))
(defn- apply-attrs-to-paragraph
"Apply attrs to spans within [sel-start, sel-end) char range of a single paragraph.
Splits spans at boundaries as needed."
[para sel-start sel-end attrs]
(let [spans (:children para)
result (loop [spans spans
pos 0
acc []]
(if (empty? spans)
acc
(let [span (first spans)
text (:text span)
span-len (count text)
span-end (+ pos span-len)
ol-start (max pos sel-start)
ol-end (min span-end sel-end)
has-overlap? (< ol-start ol-end)]
(if (not has-overlap?)
(recur (rest spans) span-end (conj acc span))
(let [before (when (> ol-start pos)
(assoc span :text (subs text 0 (- ol-start pos))))
selected (merge span attrs
{:text (subs text (- ol-start pos) (- ol-end pos))})
after (when (< ol-end span-end)
(assoc span :text (subs text (- ol-end pos))))]
(recur (rest spans) span-end
(-> acc
(into (keep identity [before selected after])))))))))]
(assoc para :children result)))
(defn- para-char-count
[para]
(apply + (map (fn [span] (count (:text span))) (:children para))))
(defn apply-style-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)
(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
(when (not collapsed?)
(mapv (fn [idx para]
(cond
(or (< idx start-para) (> idx end-para))
para
(= start-para end-para)
(apply-attrs-to-paragraph para start-offset end-offset attrs)
(= idx start-para)
(apply-attrs-to-paragraph para start-offset (para-char-count para) attrs)
(= idx end-para)
(apply-attrs-to-paragraph para 0 end-offset attrs)
:else
(apply-attrs-to-paragraph para 0 (para-char-count para) attrs)))
(range (count paras))
paras))
new-content (when new-paras
(assoc content :children
[(assoc para-set :children new-paras)]))]
(when new-content
(swap! shape-text-contents assoc shape-id new-content)
(use-shape-fn shape-id)
(set-shape-text-content-fn shape-id new-content)
{:shape-id shape-id
:content new-content}))))))))

View File

@@ -0,0 +1,242 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.text-editor-input
"Contenteditable DOM element for WASM text editor input"
(:require
[app.common.geom.shapes :as gsh]
[app.main.data.workspace.texts :as dwt]
[app.main.store :as st]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor :as text-editor]
[app.util.dom :as dom]
[app.util.object :as obj]
[cuerdas.core :as str]
[goog.events :as events]
[rumext.v2 :as mf])
(:import goog.events.EventType))
(defn- sync-wasm-text-editor-content!
"Sync WASM text editor content back to the shape via the standard
commit pipeline. Called after every text-modifying input."
[& {:keys [finalize?]}]
(when-let [{:keys [shape-id content]} (text-editor/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? finalize?))))
(mf/defc text-editor-input
"Contenteditable element positioned over the text shape to capture input events."
{::mf/wrap-props false}
[props]
(let [shape (obj/get props "shape")
zoom (obj/get props "zoom")
vbox (obj/get props "vbox")
contenteditable-ref (mf/use-ref nil)
composing? (mf/use-state false)
;; Calculate screen position from shape bounds
shape-bounds (gsh/shape->rect shape)
screen-x (* (- (:x shape-bounds) (:x vbox)) zoom)
screen-y (* (- (:y shape-bounds) (:y vbox)) zoom)
screen-w (* (:width shape-bounds) zoom)
screen-h (* (:height shape-bounds) zoom)]
;; Focus contenteditable on mount
(mf/use-effect
(fn []
(when-let [node (mf/ref-val contenteditable-ref)]
(.focus node))
js/undefined))
;; Animation loop for cursor blink
(mf/use-effect
(fn []
(let [raf-id (atom nil)
animate (fn animate []
(when (text-editor/text-editor-is-active?)
(wasm.api/request-render "cursor-blink")
(reset! raf-id (js/requestAnimationFrame animate))))]
(animate)
(fn []
(when @raf-id
(js/cancelAnimationFrame @raf-id))))))
;; Document-level keydown handler for control keys
(mf/use-effect
(fn []
(let [on-doc-keydown
(fn [e]
(when (and (text-editor/text-editor-is-active?)
(not @composing?))
(let [key (.-key e)
ctrl? (or (.-ctrlKey e) (.-metaKey e))
shift? (.-shiftKey e)]
(cond
;; Escape: finalize and stop
(= key "Escape")
(do
(dom/prevent-default e)
(sync-wasm-text-editor-content! :finalize? true)
(text-editor/text-editor-stop))
;; Ctrl+A: select all (key is "a" or "A" depending on platform)
(and ctrl? (= (str/lower key) "a"))
(do
(dom/prevent-default e)
(text-editor/text-editor-select-all)
(wasm.api/request-render "text-select-all"))
;; Enter
(= key "Enter")
(do
(dom/prevent-default e)
(text-editor/text-editor-insert-paragraph)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paragraph"))
;; Backspace
(= key "Backspace")
(do
(dom/prevent-default e)
(text-editor/text-editor-delete-backward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-backward"))
;; Delete
(= key "Delete")
(do
(dom/prevent-default e)
(text-editor/text-editor-delete-forward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-forward"))
;; Arrow keys
(= key "ArrowLeft")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 0 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowRight")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 1 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowUp")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 2 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowDown")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 3 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "Home")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 4 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "End")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 5 shift?)
(wasm.api/request-render "text-cursor-move"))
;; Let contenteditable handle text input via on-input
:else nil))))]
(events/listen js/document EventType.KEYDOWN on-doc-keydown true)
(fn []
(events/unlisten js/document EventType.KEYDOWN on-doc-keydown true)))))
;; Composition and input events
(let [on-composition-start
(mf/use-fn
(fn [_event]
(reset! composing? true)))
on-composition-end
(mf/use-fn
(fn [^js event]
(reset! composing? false)
(let [data (.-data event)]
(when data
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-composition"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
on-paste
(mf/use-fn
(fn [^js event]
(dom/prevent-default event)
(let [clipboard-data (.-clipboardData event)
text (.getData clipboard-data "text/plain")]
(when (and text (seq text))
(text-editor/text-editor-insert-text text)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paste"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
on-copy
(mf/use-fn
(fn [^js event]
(when (text-editor/text-editor-is-active?)
(dom/prevent-default event)
(when-let [selection (text-editor/text-editor-get-selection)]
(let [text (text-editor/text-editor-export-selection)]
(let [clipboard-data (.-clipboardData event)]
(.setData clipboard-data "text/plain" text)))))))
on-input
(mf/use-fn
(fn [^js event]
(let [native-event (.-nativeEvent event)
input-type (.-inputType native-event)
data (.-data native-event)]
;; Skip composition-related input events - composition-end handles those
(when (and (not @composing?)
(not= input-type "insertCompositionText"))
(when (and data (seq data))
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-input"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) ""))))))]
[:div
{:ref contenteditable-ref
:contentEditable true
:suppressContentEditableWarning true
:on-composition-start on-composition-start
:on-composition-end on-composition-end
:on-input on-input
:on-paste on-paste
:on-copy on-copy
;; FIXME on-click
;; :on-click on-click
:id "text-editor-wasm-input"
;; FIXME
:style {:position "absolute"
:left (str screen-x "px")
:top (str screen-y "px")
:width (str screen-w "px")
:height (str screen-h "px")
:opacity 0
:overflow "hidden"
:white-space "pre"
:cursor "text"
:z-index 10}}])))

View File

@@ -10,6 +10,7 @@ mod shadows;
mod strokes;
mod surfaces;
pub mod text;
pub mod text_editor;
mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect};

View File

@@ -0,0 +1,238 @@
use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
use crate::state::{TextCursor, TextEditorState, TextSelection};
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{Canvas, Color, Matrix, Paint, Rect};
const CURSOR_WIDTH: f32 = 1.5;
/// FIXME: Use theme color, take into account background color for contrast
const SELECTION_COLOR: Color = Color::from_argb(80, 66, 133, 244);
const CURSOR_COLOR: Color = Color::BLACK;
pub fn render_overlay(
canvas: &Canvas,
editor_state: &TextEditorState,
shape: &Shape,
transform: &Matrix,
) {
if !editor_state.is_active {
return;
}
let Type::Text(text_content) = &shape.shape_type else {
return;
};
canvas.save();
canvas.concat(transform);
if editor_state.selection.is_selection() {
render_selection(canvas, &editor_state.selection, text_content, shape);
}
if editor_state.cursor_visible {
render_cursor(canvas, &editor_state.selection.focus, text_content, shape);
}
canvas.restore();
}
fn render_cursor(canvas: &Canvas, cursor: &TextCursor, text_content: &TextContent, shape: &Shape) {
let Some(rect) = calculate_cursor_rect(cursor, text_content, shape) else {
return;
};
let mut paint = Paint::default();
paint.set_color(CURSOR_COLOR);
paint.set_anti_alias(true);
canvas.draw_rect(rect, &paint);
}
fn render_selection(
canvas: &Canvas,
selection: &TextSelection,
text_content: &TextContent,
shape: &Shape,
) {
let rects = calculate_selection_rects(selection, text_content, shape);
if rects.is_empty() {
return;
}
let mut paint = Paint::default();
paint.set_color(SELECTION_COLOR);
paint.set_anti_alias(true);
for rect in rects {
canvas.draw_rect(rect, &paint);
}
}
fn vertical_align_offset(
shape: &Shape,
layout_paragraphs: &[&skia_safe::textlayout::Paragraph],
) -> f32 {
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
match shape.vertical_align() {
VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0,
VerticalAlign::Bottom => shape.selrect().height() - total_height,
_ => 0.0,
}
}
fn calculate_cursor_rect(
cursor: &TextCursor,
text_content: &TextContent,
shape: &Shape,
) -> Option<Rect> {
let paragraphs = text_content.paragraphs();
if cursor.paragraph >= paragraphs.len() {
return None;
}
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
if cursor.paragraph >= layout_paragraphs.len() {
return None;
}
let selrect = shape.selrect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.char_offset;
// For cursor, we get a zero-width range at the position
// We need to handle edge cases:
// - At start of paragraph: use position 0
// - At end of paragraph: use last position
let para = &paragraphs[cursor.paragraph];
let para_char_count: usize = para
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let (cursor_x, cursor_height) = if para_char_count == 0 {
// Empty paragraph - use default height
(0.0, laid_out_para.height())
} else if char_pos == 0 {
let rects = laid_out_para.get_rects_for_range(
0..1,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
} else {
(0.0, laid_out_para.height())
}
} else if char_pos >= para_char_count {
let rects = laid_out_para.get_rects_for_range(
para_char_count.saturating_sub(1)..para_char_count,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.right(), rects[0].rect.height())
} else {
(laid_out_para.longest_line(), laid_out_para.height())
}
} else {
let rects = laid_out_para.get_rects_for_range(
char_pos..char_pos + 1,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
} else {
// Fallback: use glyph position
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
(pos.position as f32, laid_out_para.height())
}
};
return Some(Rect::from_xywh(
selrect.x() + cursor_x,
selrect.y() + y_offset,
CURSOR_WIDTH,
cursor_height,
));
}
y_offset += laid_out_para.height();
}
None
}
fn calculate_selection_rects(
selection: &TextSelection,
text_content: &TextContent,
shape: &Shape,
) -> Vec<Rect> {
let mut rects = Vec::new();
let start = selection.start();
let end = selection.end();
let paragraphs = text_content.paragraphs();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let selrect = shape.selrect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
let para_height = laid_out_para.height();
// Check if this paragraph is in selection range
if para_idx < start.paragraph || para_idx > end.paragraph {
y_offset += para_height;
continue;
}
// Calculate character range for this paragraph
let para = &paragraphs[para_idx];
let para_char_count: usize = para
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
} else {
para_char_count
};
if range_start < range_end {
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let text_boxes = laid_out_para.get_rects_for_range(
range_start..range_end,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
for text_box in text_boxes {
let r = text_box.rect;
rects.push(Rect::from_xywh(
selrect.x() + r.left(),
selrect.y() + y_offset + r.top(),
r.width(),
r.height(),
));
}
}
y_offset += para_height;
}
rects
}

View File

@@ -116,6 +116,7 @@ impl TextContentSize {
pub struct TextPositionWithAffinity {
pub position_with_affinity: PositionWithAffinity,
pub paragraph: i32,
#[allow(dead_code)]
pub span: i32,
pub offset: i32,
}
@@ -316,6 +317,10 @@ impl TextContent {
&self.paragraphs
}
pub fn paragraphs_mut(&mut self) -> &mut Vec<Paragraph> {
&mut self.paragraphs
}
pub fn width(&self) -> f32 {
self.size.width
}
@@ -428,8 +433,16 @@ impl TextContent {
let end_y = offset_y + layout_paragraph.height();
// We only test against paragraphs that can contain the current y
// coordinate.
if point.y > start_y && point.y < end_y {
// coordinate. Use >= for start and handle zero-height paragraphs.
let paragraph_height = layout_paragraph.height();
let matches = if paragraph_height > 0.0 {
point.y >= start_y && point.y < end_y
} else {
// For zero-height paragraphs (empty lines), match if we're at the start position
point.y >= start_y && point.y <= start_y + 1.0
};
if matches {
let position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate(*point);
if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) {
@@ -438,18 +451,37 @@ impl TextContent {
// in which span we are.
let mut computed_position = 0;
let mut span_offset = 0;
for span in paragraph.children() {
span_index += 1;
let length = span.text.len();
let start_position = computed_position;
let end_position = computed_position + length;
let current_position = position_with_affinity.position as usize;
if start_position <= current_position && end_position >= current_position {
span_offset = position_with_affinity.position - start_position as i32;
break;
// If paragraph has no spans, default to span 0, offset 0
if paragraph.children().is_empty() {
span_index = 0;
span_offset = 0;
} else {
for span in paragraph.children() {
span_index += 1;
let length = span.text.chars().count();
let start_position = computed_position;
let end_position = computed_position + length;
let current_position = position_with_affinity.position as usize;
// Handle empty spans: if the span is empty and current position
// matches the start, this is the right span
if length == 0 && current_position == start_position {
span_offset = 0;
break;
}
if start_position <= current_position
&& end_position >= current_position
{
span_offset =
position_with_affinity.position - start_position as i32;
break;
}
computed_position += length;
}
computed_position += length;
}
return Some(TextPositionWithAffinity::new(
position_with_affinity,
paragraph_index,
@@ -460,6 +492,26 @@ impl TextContent {
}
offset_y += layout_paragraph.height();
}
// Handle completely empty text shapes: if there are no paragraphs or all paragraphs
// are empty, and the click is within the text shape bounds, return a default position
if (self.paragraphs().is_empty() || self.layout.paragraphs.is_empty())
&& self.bounds.contains(*point)
{
// Create a default position at the start of the text
use skia_safe::textlayout::Affinity;
let default_position = PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
};
return Some(TextPositionWithAffinity::new(
default_position,
0, // paragraph 0
0, // span 0
0, // offset 0
));
}
None
}
@@ -838,6 +890,10 @@ impl Paragraph {
&self.children
}
pub fn children_mut(&mut self) -> &mut Vec<TextSpan> {
&mut self.children
}
#[allow(dead_code)]
fn add_span(&mut self, span: TextSpan) {
self.children.push(span);
@@ -847,6 +903,26 @@ impl Paragraph {
self.line_height
}
pub fn letter_spacing(&self) -> f32 {
self.letter_spacing
}
pub fn text_align(&self) -> TextAlign {
self.text_align
}
pub fn text_direction(&self) -> TextDirection {
self.text_direction
}
pub fn text_decoration(&self) -> Option<TextDecoration> {
self.text_decoration
}
pub fn text_transform(&self) -> Option<TextTransform> {
self.text_transform
}
pub fn paragraph_to_style(&self) -> ParagraphStyle {
let mut style = ParagraphStyle::default();
@@ -1228,14 +1304,21 @@ pub fn calculate_text_layout_data(
let current_y = para_layout.y;
let text_paragraph = text_paragraphs.get(paragraph_index);
if let Some(text_para) = text_paragraph {
let mut span_ranges: Vec<(usize, usize, usize)> = vec![];
let mut span_ranges: Vec<(usize, usize, usize, String, String)> = vec![];
let mut cur = 0;
for (span_index, span) in text_para.children().iter().enumerate() {
let text: String = span.apply_text_transform();
span_ranges.push((cur, cur + text.len(), span_index));
cur += text.len();
let transformed_text: String = span.apply_text_transform();
let original_text = span.text.clone();
let text = transformed_text.clone();
let text_len = text.len();
span_ranges.push((cur, cur + text_len, span_index, text, original_text));
cur += text_len;
}
for (start, end, span_index) in span_ranges {
for (start, end, span_index, transformed_text, original_text) in span_ranges {
// Skip empty spans to avoid invalid rect calculations
if start >= end {
continue;
}
let rects = para_layout.paragraph.get_rects_for_range(
start..end,
RectHeightStyle::Tight,
@@ -1245,22 +1328,43 @@ pub fn calculate_text_layout_data(
let direction = textbox.direct;
let mut rect = textbox.rect;
let cy = rect.top + rect.height() / 2.0;
let start_pos = para_layout
// Get byte positions from Skia's transformed text layout
let glyph_start = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.left + 0.1, cy))
.position as usize;
let end_pos = para_layout
let glyph_end = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.right - 0.1, cy))
.position as usize;
let start_pos = start_pos.saturating_sub(start);
let end_pos = end_pos.saturating_sub(start);
// Convert to byte positions relative to this span
let byte_start = glyph_start.saturating_sub(start);
let byte_end = glyph_end.saturating_sub(start);
// Convert byte positions to character positions in ORIGINAL text
// This handles multi-byte UTF-8 and text transform differences
let char_start = transformed_text
.char_indices()
.position(|(i, _)| i >= byte_start)
.unwrap_or(0);
let char_end = transformed_text
.char_indices()
.position(|(i, _)| i >= byte_end)
.unwrap_or_else(|| transformed_text.chars().count());
// Clamp to original text length for safety
let original_char_count = original_text.chars().count();
let final_start = char_start.min(original_char_count);
let final_end = char_end.min(original_char_count);
rect.offset((x, current_y));
position_data.push(PositionData {
paragraph: paragraph_index as u32,
span: span_index as u32,
start_pos: start_pos as u32,
end_pos: end_pos as u32,
start_pos: final_start as u32,
end_pos: final_end as u32,
x: rect.x(),
y: rect.y(),
width: rect.width(),

View File

@@ -1,9 +1,218 @@
#![allow(dead_code)]
use crate::shapes::TextPositionWithAffinity;
use crate::uuid::Uuid;
/// TODO: Now this is just a tuple with 2 i32 working
/// as indices (paragraph and span).
/// Cursor position within text content.
/// Uses character offsets for precise positioning.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
pub struct TextCursor {
pub paragraph: usize,
pub char_offset: usize,
}
impl TextCursor {
pub fn new(paragraph: usize, char_offset: usize) -> Self {
Self {
paragraph,
char_offset,
}
}
pub fn zero() -> Self {
Self {
paragraph: 0,
char_offset: 0,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TextSelection {
pub anchor: TextCursor,
pub focus: TextCursor,
}
impl TextSelection {
pub fn new() -> Self {
Self::default()
}
pub fn from_cursor(cursor: TextCursor) -> Self {
Self {
anchor: cursor,
focus: cursor,
}
}
pub fn is_collapsed(&self) -> bool {
self.anchor == self.focus
}
pub fn is_selection(&self) -> bool {
!self.is_collapsed()
}
pub fn set_caret(&mut self, cursor: TextCursor) {
self.anchor = cursor;
self.focus = cursor;
}
pub fn extend_to(&mut self, cursor: TextCursor) {
self.focus = cursor;
}
pub fn collapse_to_focus(&mut self) {
self.anchor = self.focus;
}
pub fn collapse_to_anchor(&mut self) {
self.focus = self.anchor;
}
pub fn start(&self) -> TextCursor {
if self.anchor.paragraph < self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph > self.focus.paragraph {
self.focus
} else if self.anchor.char_offset <= self.focus.char_offset {
self.anchor
} else {
self.focus
}
}
pub fn end(&self) -> TextCursor {
if self.anchor.paragraph > self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph < self.focus.paragraph {
self.focus
} else if self.anchor.char_offset >= self.focus.char_offset {
self.anchor
} else {
self.focus
}
}
}
/// Events that the text editor can emit for frontend synchronization
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum EditorEvent {
None = 0,
ContentChanged = 1,
SelectionChanged = 2,
NeedsLayout = 3,
}
pub struct TextEditorState {
pub selection: TextSelection,
pub is_active: bool,
pub active_shape_id: Option<Uuid>,
pub cursor_visible: bool,
pub last_blink_time: f64,
pub x_affinity: Option<f32>,
pending_events: Vec<EditorEvent>,
}
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
impl TextEditorState {
pub fn new() -> Self {
Self {
selection: TextSelection::new(),
is_active: false,
active_shape_id: None,
cursor_visible: true,
last_blink_time: 0.0,
x_affinity: None,
pending_events: Vec::new(),
}
}
pub fn start(&mut self, shape_id: Uuid) {
self.is_active = true;
self.active_shape_id = Some(shape_id);
self.cursor_visible = true;
self.last_blink_time = 0.0;
self.selection = TextSelection::new();
self.x_affinity = None;
self.pending_events.clear();
}
pub fn stop(&mut self) {
self.is_active = false;
self.active_shape_id = None;
self.cursor_visible = false;
self.x_affinity = None;
self.pending_events.clear();
}
pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.set_caret(cursor);
self.reset_blink();
self.clear_x_affinity();
self.push_event(EditorEvent::SelectionChanged);
}
pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.extend_to(cursor);
self.reset_blink();
self.push_event(EditorEvent::SelectionChanged);
}
pub fn update_blink(&mut self, timestamp_ms: f64) {
if !self.is_active {
return;
}
if self.last_blink_time == 0.0 {
self.last_blink_time = timestamp_ms;
self.cursor_visible = true;
return;
}
let elapsed = timestamp_ms - self.last_blink_time;
if elapsed >= CURSOR_BLINK_INTERVAL_MS {
self.cursor_visible = !self.cursor_visible;
self.last_blink_time = timestamp_ms;
}
}
pub fn reset_blink(&mut self) {
self.cursor_visible = true;
self.last_blink_time = 0.0;
}
pub fn clear_x_affinity(&mut self) {
self.x_affinity = None;
}
pub fn push_event(&mut self, event: EditorEvent) {
if self.pending_events.last() != Some(&event) {
self.pending_events.push(event);
}
}
pub fn poll_event(&mut self) -> EditorEvent {
self.pending_events.pop().unwrap_or(EditorEvent::None)
}
pub fn has_pending_events(&self) -> bool {
!self.pending_events.is_empty()
}
pub fn set_caret_position_from(
&mut self,
text_position_with_affinity: TextPositionWithAffinity,
) {
self.set_caret_from_position(text_position_with_affinity);
}
}
/// TODO: Remove legacy code
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct TextNodePosition {
pub paragraph: i32,
@@ -15,89 +224,7 @@ impl TextNodePosition {
Self { paragraph, span }
}
#[allow(dead_code)]
pub fn is_invalid(&self) -> bool {
self.paragraph < 0 || self.span < 0
}
}
pub struct TextPosition {
node: Option<TextNodePosition>,
offset: i32,
}
impl TextPosition {
pub fn new() -> Self {
Self {
node: None,
offset: -1,
}
}
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.node = node;
self.offset = offset;
}
}
pub struct TextSelection {
focus: TextPosition,
anchor: TextPosition,
}
impl TextSelection {
pub fn new() -> Self {
Self {
focus: TextPosition::new(),
anchor: TextPosition::new(),
}
}
#[allow(dead_code)]
pub fn is_caret(&self) -> bool {
self.focus.node == self.anchor.node && self.focus.offset == self.anchor.offset
}
#[allow(dead_code)]
pub fn is_selection(&self) -> bool {
!self.is_caret()
}
pub fn set_focus(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.focus.set(node, offset);
}
pub fn set_anchor(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.anchor.set(node, offset);
}
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.set_focus(node, offset);
self.set_anchor(node, offset);
}
}
pub struct TextEditorState {
selection: TextSelection,
}
impl TextEditorState {
pub fn new() -> Self {
Self {
selection: TextSelection::new(),
}
}
pub fn set_caret_position_from(
&mut self,
text_position_with_affinity: TextPositionWithAffinity,
) {
self.selection.set(
Some(TextNodePosition::new(
text_position_with_affinity.paragraph,
text_position_with_affinity.span,
)),
text_position_with_affinity.offset,
);
}
}

View File

@@ -9,3 +9,4 @@ pub mod shapes;
pub mod strokes;
pub mod svg_attrs;
pub mod text;
pub mod text_editor;

View File

File diff suppressed because it is too large Load Diff