Compare commits

...

23 Commits

Author SHA1 Message Date
Aitor Moreno
55539e83bd WIP 2026-02-27 13:58:44 +01:00
Aitor Moreno
740e790585 🎉 Add active-features? helper function (#8490) 2026-02-27 12:12:27 +01:00
Elena Torró
ed23c55550 Merge pull request #8483 from penpot/superalex-fix-opacity-for-dotted-strokes
🐛 Fix opacity for dotted strokes
2026-02-26 13:41:43 +01:00
Alejandro Alonso
5b5c868a87 🐛 Fix opacity for dotted strokes 2026-02-26 13:31:12 +01:00
Alejandro Alonso
1a3ac6bdf8 Merge pull request #8475 from penpot/elenatorro-13524-fix-token-highlight
🐛 Fix rotation token highlight and its application on the text-ed…
2026-02-26 13:00:45 +01:00
Elena Torró
de5d4f4292 Merge pull request #8460 from penpot/azazeln28-refactor-text-cursor
♻️ Refactor TextCursor and TextPositionWithAffinity
2026-02-26 12:29:43 +01:00
Elena Torro
2bd7c10e09 🔧 Fix variable name from wrong merge 2026-02-26 12:19:20 +01:00
Elena Torro
495371c079 🐛 Fix rotation token highlight and its application on the text-editor-v2 2026-02-26 11:57:11 +01:00
Elena Torró
75b1c0c1b1 Merge pull request #8280 from penpot/niwinz-layers-sidebar-changes
 Add serveral performance optimization to layers sidebar
2026-02-26 11:37:57 +01:00
Andrey Antukh
5ea4b03108 📎 Fix e2e tests 2026-02-26 11:13:31 +01:00
Andrey Antukh
0fef5b7e5d Memoize variant props on layer-item 2026-02-26 11:13:31 +01:00
Andrey Antukh
8a1fdd9dd1 Reduce watchers for layer-item rename mechanism 2026-02-26 11:13:31 +01:00
Andrey Antukh
a080a9e646 Add micro optimizations to layer-item component 2026-02-26 11:13:31 +01:00
Andrey Antukh
a728d5a5f2 💄 Add minor cosmetic changes to filters-tree component 2026-02-26 11:13:30 +01:00
Andrey Antukh
6072234230 Add more selective debouncing for layers-tree 2026-02-26 11:13:30 +01:00
Andrey Antukh
41f2877801 Reduce allocation on layers-tree component 2026-02-26 11:13:30 +01:00
Andrey Antukh
e2576d049a 💄 Add minor cosmetic changes on event listening 2026-02-26 11:13:30 +01:00
Andrey Antukh
4db9c373e6 💄 Fix component naming style related to layer-item 2026-02-26 11:13:30 +01:00
Andrey Antukh
09a9407867 💄 Change props naming on layer-item and related components 2026-02-26 11:13:30 +01:00
Andrey Antukh
7be03e2ea6 Remove usage of use-var on layer-item
Focus on use more basic primitves on performance
sensitive components
2026-02-26 11:13:30 +01:00
Aitor Moreno
05165ce014 🐛 Fix board title cropped using wrong side 2026-02-26 09:35:56 +01:00
Aitor Moreno
96677713fc 🐛 Fix 45 rotated board doesn't show title properly 2026-02-26 09:34:15 +01:00
Aitor Moreno
a12b59d101 ♻️ Refactor TextCursor and TextPositionWithAffinity 2026-02-25 09:59:02 +01:00
29 changed files with 1144 additions and 600 deletions

View File

@@ -31,6 +31,7 @@
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306)
## 2.13.3

View File

@@ -58,4 +58,3 @@
(when (nil? (:data file))
(migrate-file conn file)))
(db/exec-one! conn ["drop table page cascade;"])))

View File

@@ -404,6 +404,8 @@ export class WorkspacePage extends BaseWebSocketPage {
return content !== "";
}, { timeout: 1000 });
await this.page.waitForTimeout(3000);
}
/**
@@ -417,7 +419,8 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.viewport.click({ button: "right" });
return this.page.getByText("Paste", { exact: true }).click();
}
return this.page.keyboard.press("ControlOrMeta+V");
await this.page.keyboard.press("ControlOrMeta+V");
await this.page.waitForTimeout(3000);
}
async panOnViewportAt(x, y, width, height) {

View File

@@ -383,24 +383,26 @@ test("User cut paste a component with path inside a variant", async ({
const variant = await findVariant(workspacePage, 0);
//Create a component
// Create a component
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(3000);
//Rename the component
// Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();
await workspacePage.page
.getByTestId("layer-item")
.getByRole("textbox")
.pressSequentially("button / hover");
await workspacePage.page.keyboard.press("Enter");
await workspacePage.page.waitForTimeout(3000);
//Cut the component
// Cut the component
await workspacePage.cut("keyboard");
//Paste the component inside the variant
// Paste the component inside the variant
await variant.container.click();
await workspacePage.paste("keyboard");
@@ -427,6 +429,7 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(3000);
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();

View File

@@ -620,61 +620,68 @@
ptk/WatchEvent
(watch [_ state _]
;; We do not allow to apply tokens while text editor is open.
(when (empty? (get state :workspace-editor-state))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca
(cond
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
:else attributes-to-remove)]
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
objects (dsh/lookup-page-objects state)
selected-shapes (select-keys objects shape-ids)
;; The classic text editor sets :workspace-editor-state; the WASM text editor
;; does not, so we also check :workspace-local :edition for text shapes.
(let [edition (get-in state [:workspace-local :edition])
objects (dsh/lookup-page-objects state)
text-editing? (and (some? edition)
(= :text (:type (get objects edition))))]
(when (and (empty? (get state :workspace-editor-state))
(not text-editing?))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca
(cond
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
:else attributes-to-remove)]
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
objects (dsh/lookup-page-objects state)
selected-shapes (select-keys objects shape-ids)
shapes (->> selected-shapes
(filter (fn [[_ shape]]
(or
(and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
shapes (->> selected-shapes
(filter (fn [[_ shape]]
(or
(and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
(st/emit! (ev/event {::ev/name "apply-tokens"
:type type
:applied-to attributes
:applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape
attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always
(update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id)))))))))))))
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
(st/emit! (ev/event {::ev/name "apply-tokens"
:type type
:applied-to attributes
:applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape
attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always
(update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id))))))))))))))
(defn apply-spacing-token-separated
"Handles edge-case for spacing token when applying token via toggle button.

View File

@@ -548,7 +548,7 @@
modif-tree
(dwm/build-modif-tree ids objects get-modifier)]
(rx/of (dwm/apply-wasm-modifiers modif-tree)))
(rx/of (dwm/apply-wasm-modifiers modif-tree :ignore-touched (:ignore-touched options))))
(let [page-id (or (:page-id options)
(:current-page-id state))

View File

@@ -86,6 +86,24 @@
:else
(enabled-by-flags? state feature))))
(defn active-features?
"Given a state and a set of features, check if the features are all enabled."
([state a]
(js/console.warn "Please, use active-feature? instead")
(active-feature? state a))
([state a b]
(and ^boolean (active-feature? state a)
^boolean (active-feature? state b)))
([state a b c]
(and ^boolean (active-feature? state a)
^boolean (active-feature? state b)
^boolean (active-feature? state c)))
([state a b c & others]
(and ^boolean (active-feature? state a)
^boolean (active-feature? state b)
^boolean (active-feature? state c)
^boolean (every? #(active-feature? state %) others))))
(def ^:private features-ref
(l/derived (l/key :features) st/state))

View File

@@ -183,9 +183,6 @@
[id]
(l/derived #(contains? % id) selected-shapes))
(def highlighted-shapes
(l/derived :highlighted workspace-local))
(def export-in-progress?
(l/derived :export-in-progress? export))

View File

@@ -12,7 +12,7 @@
[app.common.types.component :as ctk]
[app.main.data.viewer :as dv]
[app.main.store :as st]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner]]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner*]]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[okulary.core :as l]
@@ -26,7 +26,6 @@
(mf/defc layer-item
[{:keys [item selected objects depth component-child? hide-toggle?] :as props}]
(let [id (:id item)
hidden? (:hidden item)
selected? (contains? selected id)
item-ref (mf/use-ref nil)
depth (+ depth 1)
@@ -68,18 +67,17 @@
(when (and (= (count selected) 1) selected?)
(dom/scroll-into-view-if-needed! (mf/ref-val item-ref) true))))
[:& layer-item-inner
[:> layer-item-inner*
{:ref item-ref
:item item
:depth depth
:read-only? true
:highlighted? false
:selected? selected?
:component-tree? component-tree?
:hidden? hidden?
:filtered? false
:expanded? expanded?
:hide-toggle? hide-toggle?
:is-read-only true
:is-highlighted false
:is-selected selected?
:is-component-tree component-tree?
:is-filtered false
:is-expanded expanded?
:hide-toggle hide-toggle?
:on-select-shape select-shape
:on-toggle-collapse toggle-collapse}

View File

@@ -131,7 +131,8 @@
on-style-change
(fn [event]
(let [styles (styles/get-styles-from-event event)]
(let [
styles (styles/get-styles-from-event event)]
(st/emit! (dwt/v2-update-text-editor-styles shape-id styles))))
on-needs-layout

View File

@@ -0,0 +1,338 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.shapes.text.v3-editor
"Contenteditable DOM element for WASM text editor input"
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.texts :as dwt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.css-cursors :as cur]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor :as text-editor]
[app.util.dom :as dom]
[app.util.object :as obj]
[cuerdas.core :as str]
[rumext.v2 :as mf]
[app.main.ui.ds.foundations.assets.icon :as i]))
(def caret-blink-interval-ms 250)
(defn- sync-wasm-text-editor-content!
"Sync WASM text editor content back to the shape via the standard
commit pipeline. Called after every text-modifying input."
[& {:keys [finalize?]}]
(when-let [{:keys [shape-id content]}
(text-editor/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? finalize?))))
(defn- font-family-from-font-id [font-id]
(if (str/includes? font-id "gfont-noto-sans")
(let [lang (str/replace font-id #"gfont\-noto\-sans\-" "")]
(if (>= (count lang) 3) (str/capital lang) (str/upper lang)))
"Noto Color Emoji"))
(mf/defc text-editor
"Contenteditable element positioned over the text shape to capture input events."
{::mf/wrap-props false}
[props]
(let [shape (obj/get props "shape")
shape-id (dm/get-prop shape :id)
clip-id (dm/str "text-edition-clip" shape-id)
contenteditable-ref (mf/use-ref nil)
composing? (mf/use-state false)
fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false)
fallback-families (map (fn [font]
(font-family-from-font-id (:font-id font))) fallback-fonts)
;; Calculate screen position from shape bounds
bounds (gsh/shape->rect shape)
x (mth/min (dm/get-prop bounds :x)
(dm/get-prop shape :x))
y (mth/min (dm/get-prop bounds :y)
(dm/get-prop shape :y))
width (mth/max (dm/get-prop bounds :width)
(dm/get-prop shape :width))
height (mth/max (dm/get-prop bounds :height)
(dm/get-prop shape :height))
[{:keys [x y width height]} transform]
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)
selrect-height (:height selrect)
selrect-width (:width selrect)
max-width (max width selrect-width)
max-height (max height selrect-height)
valign (-> shape :content :vertical-align)
y (:y selrect)
y (case valign
"bottom" (+ y (- selrect-height height))
"center" (+ y (/ (- selrect-height height) 2))
y)]
[(assoc selrect :y y :width max-width :height max-height) transform])
on-composition-start
(mf/use-fn
(fn [_event]
(reset! composing? true)))
on-composition-end
(mf/use-fn
(fn [^js event]
(reset! composing? false)
(let [data (.-data event)]
(when data
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-composition"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
on-paste
(mf/use-fn
(fn [^js event]
(dom/prevent-default event)
(let [clipboard-data (.-clipboardData event)
text (.getData clipboard-data "text/plain")]
(when (and text (seq text))
(text-editor/text-editor-insert-text text)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paste"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
on-copy
(mf/use-fn
(fn [^js event]
(when (text-editor/text-editor-is-active?)
(dom/prevent-default event)
(when (text-editor/text-editor-get-selection)
(let [text (text-editor/text-editor-export-selection)]
(.setData (.-clipboardData event) "text/plain" text))))))
on-key-down
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event) (.-key event))
(when (and (text-editor/text-editor-is-active?)
(not @composing?))
(let [key (.-key event)
ctrl? (or (.-ctrlKey event) (.-metaKey event))
shift? (.-shiftKey event)]
(cond
;; Escape: finalize and stop
(= key "Escape")
(do
(dom/prevent-default event)
(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 event)
(text-editor/text-editor-select-all)
(wasm.api/request-render "text-select-all"))
;; Enter
(= key "Enter")
(do
(dom/prevent-default event)
(text-editor/text-editor-insert-paragraph)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paragraph"))
;; Backspace
(= key "Backspace")
(do
(dom/prevent-default event)
(text-editor/text-editor-delete-backward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-backward"))
;; Delete
(= key "Delete")
(do
(dom/prevent-default event)
(text-editor/text-editor-delete-forward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-forward"))
;; Arrow keys
(= key "ArrowLeft")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 0 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowRight")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 1 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowUp")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 2 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowDown")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 3 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "Home")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 4 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "End")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 5 shift?)
(wasm.api/request-render "text-cursor-move"))
;; Let contenteditable handle text input via on-input
:else nil)))))
on-input
(mf/use-fn
(fn [^js event]
(js/console.log "event" event)
(let [native-event (.-nativeEvent event)
input-type (.-inputType native-event)
data (.-data native-event)]
;; Skip composition-related input events - composition-end handles those
(when (and (not @composing?)
(not= input-type "insertCompositionText"))
(when (and data (seq data))
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-input"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) ""))))))
on-pointer-down
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event))
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt)))))
on-pointer-move
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event))
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt)))))
on-pointer-up
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event))
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt)))))
on-click
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event))
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-set-cursor-from-offset (.-x off-pt) (.-y off-pt)))))
on-focus
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event) event)))
on-blur
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event) event)
(sync-wasm-text-editor-content!)
(wasm.api/text-editor-stop)))
style #js {:pointerEvents "all"
"--editor-container-width" (dm/str width "px")
"--editor-container-height" (dm/str height "px")
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}]
;; Focus contenteditable on mount
(mf/use-effect
(mf/deps contenteditable-ref)
(fn []
(when-let [node (mf/ref-val contenteditable-ref)]
(js/console.log "focusing")
(.focus node))))
(mf/use-effect
(fn []
(let [timeout-id (atom nil)
schedule-blink (fn schedule-blink []
(when (text-editor/text-editor-is-active?)
(wasm.api/request-render "cursor-blink"))
(reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))]
(schedule-blink)
(fn []
(when @timeout-id
(js/clearTimeout @timeout-id))))))
;; Composition and input events
[:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id)
:transform (dm/str transform)
:data-testid "text-editor"}
[:defs
[:clipPath {:id clip-id}
[:rect {:x x :y y :width width :height height}]]]
[:foreignObject {:x x :y y :width width :height height}
[:div {:on-click on-click
:on-pointer-down on-pointer-down
:on-pointer-move on-pointer-move
:on-pointer-up on-pointer-up
:class (stl/css :text-editor)
:style style}
[:div
{:ref contenteditable-ref
:contentEditable true
:suppressContentEditableWarning true
:on-composition-start on-composition-start
:on-composition-end on-composition-end
:on-key-down on-key-down
:on-input on-input
:on-paste on-paste
:on-copy on-copy
:on-focus on-focus
:on-blur on-blur
;; FIXME on-click
;; :on-click on-click
:id "text-editor-wasm-input"
:class (dm/str (cur/get-dynamic "text" (:rotation shape))
" "
(stl/css :text-editor-container))
:data-testid "text-editor-container"}]]]]))

View File

@@ -0,0 +1,12 @@
.text-editor {
height: 100%;
}
.text-editor-container {
height: 100%;
position: absolute;
opacity: 0;
overflow: hidden;
white-space: pre;
}

View File

@@ -10,6 +10,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.math :as mth]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.common.types.container :as ctn]
@@ -37,6 +38,8 @@
(defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}}))
(defonce ^:private sidebar-hover-pending? (atom false))
(def ^:const default-chunk-size 50)
(defn- schedule-sidebar-hover-flush []
(when (compare-and-set! sidebar-hover-pending? false true)
(ts/raf
@@ -48,12 +51,11 @@
(when (seq enter)
(apply st/emit! (map dw/highlight-shape enter))))))))
(mf/defc layer-item-inner
{::mf/wrap-props false}
[{:keys [item depth parent-size name-ref children ref style
(mf/defc layer-item-inner*
[{:keys [item depth parent-size name-ref children ref style rename-id
;; Flags
read-only? highlighted? selected? component-tree?
filtered? expanded? dnd-over? dnd-over-top? dnd-over-bot? hide-toggle?
is-read-only is-highlighted is-selected is-component-tree
is-filtered is-expanded dnd-over dnd-over-top dnd-over-bot hide-toggle
;; Callbacks
on-select-shape on-context-menu on-pointer-enter on-pointer-leave on-zoom-to-selected
on-toggle-collapse on-enable-drag on-disable-drag on-toggle-visibility on-toggle-blocking]}]
@@ -64,7 +66,7 @@
hidden? (:hidden item)
has-shapes? (-> item :shapes seq boolean)
touched? (-> item :touched seq boolean)
parent-board? (and (cfh/frame-shape? item)
root-board? (and (cfh/frame-shape? item)
(= uuid/zero (:parent-id item)))
absolute? (ctl/item-absolute? item)
is-variant? (ctk/is-variant? item)
@@ -73,9 +75,11 @@
variant-name (when is-variant? (:variant-name item))
variant-error (when is-variant? (:variant-error item))
data (deref refs/workspace-data)
component (ctkl/get-component data (:component-id item))
variant-properties (:variant-properties component)
component-id (get item :component-id)
data (mf/deref refs/workspace-data)
variant-properties (-> (ctkl/get-component data component-id)
(get :variant-properties))
icon-shape (usi/get-shape-icon item)]
[:*
@@ -85,30 +89,30 @@
:on-context-menu on-context-menu
:data-testid "layer-row"
:role "checkbox"
:aria-checked selected?
:aria-checked is-selected
:class (stl/css-case
:layer-row true
:highlight highlighted?
:highlight is-highlighted
:component (ctk/instance-head? item)
:masked (:masked-group item)
:selected selected?
:selected is-selected
:type-frame (cfh/frame-shape? item)
:type-bool (cfh/bool-shape? item)
:type-comp (or component-tree? is-variant-container?)
:type-comp (or is-component-tree is-variant-container?)
:hidden hidden?
:dnd-over dnd-over?
:dnd-over-top dnd-over-top?
:dnd-over-bot dnd-over-bot?
:root-board parent-board?)
:dnd-over dnd-over
:dnd-over-top dnd-over-top
:dnd-over-bot dnd-over-bot
:root-board root-board?)
:style style}
[:span {:class (stl/css-case
:tab-indentation true
:filtered filtered?)
:filtered is-filtered)
:style {"--depth" depth}}]
[:div {:class (stl/css-case
:element-list-body true
:filtered filtered?
:selected selected?
:filtered is-filtered
:selected is-selected
:icon-layer (= (:type item) :icon))
:style {"--depth" depth}
:on-pointer-enter on-pointer-enter
@@ -117,12 +121,12 @@
(if (< 0 (count (:shapes item)))
[:div {:class (stl/css :button-content)}
(when (and (not hide-toggle?) (not filtered?))
(when (and (not hide-toggle) (not is-filtered))
[:button {:class (stl/css-case
:toggle-content true
:inverse expanded?)
:inverse is-expanded)
:data-testid "toggle-content"
:aria-expanded expanded?
:aria-expanded is-expanded
:on-click on-toggle-collapse}
deprecated-icon/arrow])
@@ -133,7 +137,7 @@
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]]
[:div {:class (stl/css :button-content)}
(when (not ^boolean filtered?)
(when (not ^boolean is-filtered)
[:span {:class (stl/css :toggle-content)}])
[:div {:class (stl/css :icon-shape)
:on-double-click on-zoom-to-selected}
@@ -142,25 +146,26 @@
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]])
[:> layer-name* {:ref name-ref
:rename-id rename-id
:shape-id id
:shape-name name
:is-shape-touched touched?
:disabled-double-click read-only?
:disabled-double-click is-read-only
:on-start-edit on-disable-drag
:on-stop-edit on-enable-drag
:depth depth
:is-blocked blocked?
:parent-size parent-size
:is-selected selected?
:type-comp (or component-tree? is-variant-container?)
:is-selected is-selected
:type-comp (or is-component-tree is-variant-container?)
:type-frame (cfh/frame-shape? item)
:variant-id variant-id
:variant-name variant-name
:variant-properties variant-properties
:variant-error variant-error
:component-id (:id component)
:component-id component-id
:is-hidden hidden?}]]
(when (not read-only?)
(when (not ^boolean is-read-only)
[:div {:class (stl/css-case
:element-actions true
:is-parent has-shapes?
@@ -185,41 +190,86 @@
children]))
;; Memoized for performance
(mf/defc layer-item
{::mf/props :obj
::mf/wrap [mf/memo]}
[{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted style render-children?]
:or {render-children? true}}]
(let [id (:id item)
blocked? (:blocked item)
hidden? (:hidden item)
(mf/defc layer-item*
{::mf/wrap [mf/memo]}
[{:keys [index item selected objects rename-id
is-sortable is-filtered depth is-component-child
highlighted style render-children parent-size]
:or {render-children true}}]
(let [id (get item :id)
blocked? (get item :blocked)
hidden? (get item :hidden)
shapes (get item :shapes)
shapes (mf/with-memo [shapes objects]
(loop [counter 0
shapes (seq shapes)
result (list)]
(if-let [id (first shapes)]
(if-let [obj (get objects id)]
(do
;; NOTE: this is a bit hacky, but reduces substantially
;; the allocation; If we use enumeration, we allocate
;; new sequence and add one iteration on each render,
;; independently if objects are changed or not. If we
;; store counter on metadata, we still need to create a
;; new allocation for each shape; with this method we
;; bypass this by mutating a private property on the
;; object removing extra allocation and extra iteration
;; on every request.
(unchecked-set obj "__$__counter" counter)
(recur (inc counter)
(rest shapes)
(conj result obj)))
(recur (inc counter)
(rest shapes)
result))
(-> result vec not-empty))))
drag-disabled* (mf/use-state false)
drag-disabled? (deref drag-disabled*)
scroll-to-middle? (mf/use-var true)
scroll-middle-ref (mf/use-ref true)
expanded-iref (mf/with-memo [id]
(-> (l/in [:expanded id])
(l/derived refs/workspace-local)))
expanded? (mf/deref expanded-iref)
(l/derived #(dm/get-in % [:expanded id]) refs/workspace-local))
is-expanded (mf/deref expanded-iref)
selected? (contains? selected id)
highlighted? (contains? highlighted id)
is-selected (contains? selected id)
is-highlighted (contains? highlighted id)
container? (or (cfh/frame-shape? item)
(cfh/group-shape? item))
read-only? (mf/use-ctx ctx/workspace-read-only?)
parent-board? (and (cfh/frame-shape? item)
is-read-only (mf/use-ctx ctx/workspace-read-only?)
root-board? (and (cfh/frame-shape? item)
(= uuid/zero (:parent-id item)))
name-node-ref (mf/use-ref)
depth (+ depth 1)
is-component-tree (or ^boolean is-component-child
^boolean (ctk/instance-root? item)
^boolean (ctk/instance-head? item))
enable-drag (mf/use-fn #(reset! drag-disabled* false))
disable-drag (mf/use-fn #(reset! drag-disabled* true))
;; Lazy loading of child elements via IntersectionObserver
children-count* (mf/use-state 0)
children-count (deref children-count*)
lazy-ref (mf/use-ref nil)
observer-ref (mf/use-ref nil)
toggle-collapse
(mf/use-fn
(mf/deps expanded?)
(mf/deps is-expanded)
(fn [event]
(dom/stop-propagation event)
(if (and expanded? (kbd/shift? event))
(if (and is-expanded (kbd/shift? event))
(st/emit! (dwc/collapse-all))
(st/emit! (dwc/toggle-collapse id)))))
@@ -244,13 +294,13 @@
select-shape
(mf/use-fn
(mf/deps id filtered? objects)
(mf/deps id is-filtered objects)
(fn [event]
(dom/prevent-default event)
(reset! scroll-to-middle? false)
(mf/set-ref-val! scroll-middle-ref false)
(cond
(kbd/shift? event)
(if filtered?
(if is-filtered
(st/emit! (dw/shift-select-shapes id objects))
(st/emit! (dw/shift-select-shapes id)))
@@ -285,11 +335,11 @@
on-context-menu
(mf/use-fn
(mf/deps item read-only?)
(mf/deps item is-read-only)
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(when-not read-only?
(when-not is-read-only
(let [pos (dom/get-client-position event)]
(st/emit! (dw/show-shape-context-menu {:position pos :shape item}))))))
@@ -302,7 +352,7 @@
on-drop
(mf/use-fn
(mf/deps id objects expanded? selected)
(mf/deps id objects is-expanded selected)
(fn [side _data]
(let [single? (= (count selected) 1)
same? (and single? (= (first selected) id))]
@@ -315,32 +365,34 @@
(= side :center)
id
(and expanded? (= side :bot) (d/not-empty? (:shapes shape)))
(and is-expanded (= side :bot) (d/not-empty? (:shapes shape)))
id
:else
(cfh/get-parent-id objects id))
[parent-id _] (ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
[parent-id _]
(ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
parent (get objects parent-id)
current-index (d/index-of (:shapes parent) id)
to-index (cond
(= side :center) 0
(and expanded? (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
(and is-expanded (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
;; target not found in parent (while lazy loading)
(neg? current-index) nil
(= side :top) (inc current-index)
:else current-index)]
(when (some? to-index)
(st/emit! (dw/relocate-selected-shapes parent-id to-index))))))))
on-hold
(mf/use-fn
(mf/deps id expanded?)
(mf/deps id is-expanded)
(fn []
(when-not expanded?
(when-not is-expanded
(st/emit! (dwc/toggle-collapse id)))))
zoom-to-selected
@@ -361,112 +413,114 @@
:data {:id (:id item)
:index index
:name (:name item)}
:draggable? (and
sortable?
(not read-only?)
(not (ctn/has-any-copy-parent? objects item)))) ;; We don't want to change the structure of component copies
;; We don't want to change the structure of component copies
:draggable? (and ^boolean is-sortable
^boolean (not is-read-only)
^boolean (not (ctn/has-any-copy-parent? objects item))))]
ref (mf/use-ref)
depth (+ depth 1)
component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item))
enable-drag (mf/use-fn #(reset! drag-disabled* false))
disable-drag (mf/use-fn #(reset! drag-disabled* true))
;; Lazy loading of child elements via IntersectionObserver
children-count* (mf/use-state 0)
children-count (deref children-count*)
lazy-ref (mf/use-ref nil)
observer-var (mf/use-var nil)
chunk-size 50]
(mf/with-effect [selected? selected]
(mf/with-effect [is-selected selected]
(let [single? (= (count selected) 1)
node (mf/ref-val ref)
scroll-node (dom/get-parent-with-data node "scroll-container")
parent-node (dom/get-parent-at node 2)
first-child-node (dom/get-first-child parent-node)
node (mf/ref-val name-node-ref)
scroll-node (dom/get-parent-with-data node "scroll-container")
parent-node (dom/get-parent-at node 2)
first-child-node (dom/get-first-child parent-node)
scroll-to-middle? (mf/ref-val scroll-middle-ref)
subid
(when (and single? selected? @scroll-to-middle?)
(when (and ^boolean single?
^boolean is-selected
^boolean scroll-to-middle?)
(ts/schedule
100
#(when (and node scroll-node)
(let [scroll-distance-ratio (dom/get-scroll-distance-ratio node scroll-node)
scroll-behavior (if (> scroll-distance-ratio 1) "instant" "smooth")]
(dom/scroll-into-view-if-needed! first-child-node #js {:block "center" :behavior scroll-behavior :inline "start"})
(reset! scroll-to-middle? true)))))]
(mf/set-ref-val! scroll-middle-ref true)))))]
#(when (some? subid)
(rx/dispose! subid))))
;; Setup scroll-driven lazy loading when expanded
;; and ensures selected children are loaded immediately
(mf/with-effect [expanded? (:shapes item) selected]
(let [shapes-vec (:shapes item)
total (count shapes-vec)]
(if expanded?
(mf/with-effect [is-expanded shapes selected]
(let [total (count shapes)]
(if ^boolean is-expanded
(let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec
;; Find if any selected id is a direct child and get its render index
selected-child-render-idx
(when (and (> total chunk-size) (seq selected))
(let [shapes-reversed (vec (reverse shapes-vec))]
(some (fn [sel-id]
(let [idx (.indexOf shapes-reversed sel-id)]
(when (>= idx 0) idx)))
selected)))
(when (> total default-chunk-size)
(some (fn [sel-id]
(let [idx (.indexOf shapes sel-id)]
(when (>= idx 0) idx)))
selected))
;; Load at least enough to include the selected child plus extra
;; for context (so it can be centered in the scroll view)
min-count (if selected-child-render-idx
(+ selected-child-render-idx chunk-size)
chunk-size)
current @children-count*
new-count (min total (max current chunk-size min-count))]
min-count
(if selected-child-render-idx
(+ selected-child-render-idx default-chunk-size)
default-chunk-size)
current-count
@children-count*
new-count
(mth/min total (mth/max current-count default-chunk-size min-count))]
(reset! children-count* new-count))
(reset! children-count* 0))))
(reset! children-count* 0))
(fn []
(when-let [obs (mf/ref-val observer-ref)]
(.disconnect obs)
(mf/set-ref-val! obs nil)))))
;; Re-observe sentinel whenever children-count changes (sentinel moves)
;; and (shapes item) to reconnect observer after shape changes
(mf/with-effect [children-count expanded? (:shapes item)]
(let [total (count (:shapes item))
node (mf/ref-val ref)
scroll-node (dom/get-parent-with-data node "scroll-container")
lazy-node (mf/ref-val lazy-ref)]
(mf/with-effect [children-count is-expanded shapes]
(let [total (count shapes)
name-node (mf/ref-val name-node-ref)
scroll-node (dom/get-parent-with-data name-node "scroll-container")
lazy-node (mf/ref-val lazy-ref)]
;; Disconnect previous observer
(when-let [obs ^js @observer-var]
(when-let [obs (mf/ref-val observer-ref)]
(.disconnect obs)
(reset! observer-var nil))
(mf/set-ref-val! observer-ref nil))
;; Setup new observer if there are more children to load
(when (and expanded?
(< children-count total)
scroll-node
lazy-node)
(when (and ^boolean is-expanded
^boolean (< children-count total)
^boolean scroll-node
^boolean lazy-node)
(let [cb (fn [entries]
(when (and (seq entries)
(.-isIntersecting (first entries)))
(when (and (pos? (alength entries))
(.-isIntersecting ^js (aget entries 0)))
;; Load next chunk when sentinel intersects
(let [current @children-count*
next-count (min total (+ current chunk-size))]
(let [next-count (mth/min total (+ children-count default-chunk-size))]
(reset! children-count* next-count))))
observer (js/IntersectionObserver. cb #js {:root scroll-node})]
(.observe observer lazy-node)
(reset! observer-var observer)))))
(mf/set-ref-val! observer-ref observer)))))
[:& layer-item-inner
[:> layer-item-inner*
{:ref dref
:item item
:depth depth
:parent-size parent-size
:name-ref ref
:read-only? read-only?
:highlighted? highlighted?
:selected? selected?
:component-tree? component-tree?
:filtered? filtered?
:expanded? expanded?
:dnd-over? (= (:over dprops) :center)
:dnd-over-top? (= (:over dprops) :top)
:dnd-over-bot? (= (:over dprops) :bot)
:name-ref name-node-ref
:rename-id rename-id
:is-read-only is-read-only
:is-highlighted is-highlighted
:is-selected is-selected
:is-component-tree is-component-tree
:is-filtered is-filtered
:is-expanded is-expanded
:dnd-over (= (:over dprops) :center)
:dnd-over-top (= (:over dprops) :top)
:dnd-over-bot (= (:over dprops) :bot)
:on-select-shape select-shape
:on-context-menu on-context-menu
:on-pointer-enter on-pointer-enter
@@ -479,29 +533,28 @@
:on-toggle-blocking toggle-blocking
:style style}
(when (and render-children?
(:shapes item)
expanded?)
(when (and ^boolean render-children
^boolean shapes
^boolean is-expanded)
[:div {:class (stl/css-case
:element-children true
:parent-selected selected?
:sticky-children parent-board?)
:parent-selected is-selected
:sticky-children root-board?)
:data-testid (dm/str "children-" id)}
(let [all-children (reverse (d/enumerate (:shapes item)))
visible (take children-count all-children)]
(for [[index id] visible]
(when-let [item (get objects id)]
[:& layer-item
{:item item
:highlighted highlighted
:selected selected
:index index
:objects objects
:key (dm/str id)
:sortable? sortable?
:depth depth
:parent-size parent-size
:component-child? component-tree?}])))
(when (< children-count (count (:shapes item)))
(for [item (take children-count shapes)]
[:> layer-item*
{:item item
:rename-id rename-id
:highlighted highlighted
:selected selected
:index (unchecked-get item "__$__counter")
:objects objects
:key (dm/str (get item :id))
:is-sortable is-sortable
:depth depth
:parent-size parent-size
:is-component-child is-component-tree}])
(when (< children-count (count shapes))
[:div {:ref lazy-ref
:class (stl/css :lazy-load-sentinel)}])])]))

View File

@@ -16,39 +16,35 @@
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private space-for-icons 110)
(def lens:shape-for-rename
(-> (l/in [:workspace-local :shape-for-rename])
(l/derived st/state)))
(def ^:private ^:const space-for-icons 110)
(mf/defc layer-name*
{::mf/wrap-props false
::mf/forward-ref true}
[{:keys [shape-id shape-name is-shape-touched disabled-double-click
[{:keys [shape-id rename-id shape-name is-shape-touched disabled-double-click
on-start-edit on-stop-edit depth parent-size is-selected
type-comp type-frame component-id is-hidden is-blocked
variant-id variant-name variant-properties variant-error]} external-ref]
variant-id variant-name variant-properties variant-error ref]}]
(let [edition* (mf/use-state false)
edition? (deref edition*)
local-ref (mf/use-ref)
ref (d/nilv external-ref local-ref)
ref (d/nilv ref local-ref)
shape-for-rename (mf/deref lens:shape-for-rename)
shape-name
(if variant-id
(d/nilv variant-error variant-name)
shape-name)
shape-name (if variant-id
(d/nilv variant-error variant-name)
shape-name)
default-value
(mf/with-memo [variant-id variant-error variant-properties]
(if variant-id
(or variant-error (ctv/properties-map->formula variant-properties))
shape-name))
default-value (if variant-id
(or variant-error (ctv/properties-map->formula variant-properties))
shape-name)
has-path? (str/includes? shape-name "/")
has-path?
(str/includes? shape-name "/")
start-edit
(mf/use-fn
@@ -85,10 +81,11 @@
(when (kbd/enter? event) (accept-edit))
(when (kbd/esc? event) (cancel-edit))))
parent-size (dm/str (- parent-size space-for-icons) "px")]
parent-size
(dm/str (- parent-size space-for-icons) "px")]
(mf/with-effect [shape-for-rename edition? start-edit shape-id]
(when (and (= shape-for-rename shape-id)
(mf/with-effect [rename-id edition? start-edit shape-id]
(when (and (= rename-id shape-id)
(not ^boolean edition?))
(start-edit)))
@@ -110,21 +107,24 @@
:auto-focus true
:id (dm/str "layer-name-" shape-id)
:default-value (d/nilv default-value "")}]
[:*
[:span
{:class (stl/css-case
:element-name true
:left-ellipsis has-path?
:selected is-selected
:hidden is-hidden
:type-comp type-comp
:type-frame type-frame)
:id (dm/str "layer-name-" shape-id)
:style {"--depth" depth "--parent-size" parent-size}
:ref ref
:on-double-click start-edit}
(if (dbg/enabled? :show-ids)
(str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24))
[:span {:class (stl/css-case
:element-name true
:left-ellipsis has-path?
:selected is-selected
:hidden is-hidden
:type-comp type-comp
:type-frame type-frame)
:id (dm/str "layer-name-" shape-id)
:style {"--depth" depth "--parent-size" parent-size}
:ref ref
:on-double-click start-edit}
(if ^boolean (dbg/enabled? :show-ids)
(dm/str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24))
(d/nilv shape-name ""))]
(when (and (dbg/enabled? :show-touched) ^boolean is-shape-touched)
(when (and ^boolean (dbg/enabled? :show-touched)
^boolean is-shape-touched)
[:span {:class (stl/css :element-name-touched)} "*"])])))

View File

@@ -21,7 +21,7 @@
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.hooks :as hooks]
[app.main.ui.notifications.badge :refer [badge-notification]]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item]]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item*]]
[app.util.dom :as dom]
[app.util.globals :as globals]
[app.util.i18n :as i18n :refer [tr]]
@@ -31,92 +31,160 @@
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[goog.events :as events]
[rumext.v2 :as mf])
(:import
goog.events.EventType))
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private ref:highlighted-shapes
(l/derived (fn [local]
(-> local
(get :highlighted)
(not-empty)))
refs/workspace-local))
(def ^:private ref:shape-for-rename
(l/derived (l/key :shape-for-rename) refs/workspace-local))
(defn- use-selected-shapes
"A convencience hook wrapper for get selected shapes"
[]
(let [selected (mf/deref refs/selected-shapes)]
(hooks/use-equal-memo selected)))
;; This components is a piece for sharding equality check between top
;; level frames and try to avoid rerender frames that are does not
;; affected by the selected set.
(mf/defc frame-wrapper
{::mf/props :obj}
(mf/defc frame-wrapper*
[{:keys [selected] :as props}]
(let [pending-selected (mf/use-var selected)
current-selected (mf/use-state selected)
props (mf/spread-object props {:selected @current-selected})
(let [pending-selected-ref
(mf/use-ref selected)
current-selected
(mf/use-state selected)
props
(mf/spread-object props {:selected @current-selected})
set-selected
(mf/use-memo
(fn []
(throttle-fn
50
#(when-let [pending-selected @pending-selected]
(reset! current-selected pending-selected)))))]
(mf/with-memo []
(throttle-fn 50 #(when-let [pending-selected (mf/ref-val pending-selected-ref)]
(reset! current-selected pending-selected))))]
(mf/with-effect [selected set-selected]
(reset! pending-selected selected)
(set-selected)
(mf/set-ref-val! pending-selected-ref selected)
(^function set-selected)
(fn []
(reset! pending-selected nil)
#(rx/dispose! set-selected)))
(mf/set-ref-val! pending-selected-ref nil)
(rx/dispose! set-selected)))
[:> layer-item props]))
[:> layer-item* props]))
(mf/defc layers-tree*
{::mf/wrap [mf/memo]}
[{:keys [objects is-filtered parent-size] :as props}]
(let [selected (use-selected-shapes)
highlighted (mf/deref ref:highlighted-shapes)
root (get objects uuid/zero)
rename-id (mf/deref ref:shape-for-rename)
shapes (get root :shapes)
shapes (mf/with-memo [shapes objects]
(loop [counter 0
shapes (seq shapes)
result (list)]
(if-let [id (first shapes)]
(if-let [obj (get objects id)]
(do
;; NOTE: this is a bit hacky, but reduces substantially
;; the allocation; If we use enumeration, we allocate
;; new sequence and add one iteration on each render,
;; independently if objects are changed or not. If we
;; store counter on metadata, we still need to create a
;; new allocation for each shape; with this method we
;; bypass this by mutating a private property on the
;; object removing extra allocation and extra iteration
;; on every request.
(unchecked-set obj "__$__counter" counter)
(recur (inc counter)
(rest shapes)
(conj result obj)))
(recur (inc counter)
(rest shapes)
result))
result)))]
(mf/defc layers-tree
{::mf/wrap [mf/memo #(mf/throttle % 200)]
::mf/wrap-props false}
[{:keys [objects filtered? parent-size] :as props}]
(let [selected (mf/deref refs/selected-shapes)
selected (hooks/use-equal-memo selected)
highlighted (mf/deref refs/highlighted-shapes)
highlighted (hooks/use-equal-memo highlighted)
root (get objects uuid/zero)]
[:div {:class (stl/css :element-list) :data-testid "layer-item"}
[:> hooks/sortable-container* {}
(for [[index id] (reverse (d/enumerate (:shapes root)))]
(when-let [obj (get objects id)]
(if (cfh/frame-shape? obj)
[:& frame-wrapper
{:item obj
:selected selected
:highlighted highlighted
:index index
:objects objects
:key id
:sortable? true
:filtered? filtered?
:parent-size parent-size
:depth -1}]
[:& layer-item
{:item obj
:selected selected
:highlighted highlighted
:index index
:objects objects
:key id
:sortable? true
:filtered? filtered?
:depth -1
:parent-size parent-size}])))]]))
(for [obj shapes]
(if (cfh/frame-shape? obj)
[:> frame-wrapper*
{:item obj
:rename-id rename-id
:selected selected
:highlighted highlighted
:index (unchecked-get obj "__$__counter")
:objects objects
:key (dm/str (get obj :id))
:is-sortable true
:is-filtered is-filtered
:parent-size parent-size
:depth -1}]
[:> layer-item*
{:item obj
:rename-id rename-id
:selected selected
:highlighted highlighted
:index (unchecked-get obj "__$__counter")
:objects objects
:key (dm/str (get obj :id))
:is-sortable true
:is-filtered is-filtered
:depth -1
:parent-size parent-size}]))]]))
(mf/defc filters-tree
{::mf/wrap [mf/memo #(mf/throttle % 200)]
::mf/wrap-props false}
(mf/defc layers-tree-wrapper*
{::mf/private true}
[{:keys [objects] :as props}]
;; This is a performance sensitive componet, so we use lower-level primitives for
;; reduce residual allocation for this specific case
(let [state-tmp (mf/useState objects)
objects' (aget state-tmp 0)
set-objects (aget state-tmp 1)
subject-s (mf/with-memo []
(rx/subject))
changes-s (mf/with-memo [subject-s]
(->> subject-s
(rx/debounce 500)))
props (mf/spread-props props {:objects objects'})]
(mf/with-effect [objects subject-s]
(rx/push! subject-s objects))
(mf/with-effect [changes-s]
(let [sub (rx/subscribe changes-s set-objects)]
#(rx/dispose! sub)))
[:> layers-tree* props]))
(mf/defc filters-tree*
{::mf/wrap [mf/memo #(mf/throttle % 300)]
::mf/private true}
[{:keys [objects parent-size]}]
(let [selected (mf/deref refs/selected-shapes)
selected (hooks/use-equal-memo selected)
root (get objects uuid/zero)]
(let [selected (use-selected-shapes)
root (get objects uuid/zero)]
[:ul {:class (stl/css :element-list)}
(for [[index id] (d/enumerate (:shapes root))]
(when-let [obj (get objects id)]
[:& layer-item
[:> layer-item*
{:item obj
:selected selected
:index index
:objects objects
:key id
:sortable? false
:filtered? true
:is-sortable false
:is-filtered true
:depth -1
:parent-size parent-size}]))]))
@@ -132,6 +200,7 @@
keys
(filter #(not= uuid/zero %))
vec)]
(update reparented-objects uuid/zero assoc :shapes reparented-shapes)))
;; --- Layers Toolbox
@@ -277,9 +346,11 @@
(swap! state* update :num-items + 100))))]
(mf/with-effect []
(let [keys [(events/listen globals/document EventType.KEYDOWN on-key-down)
(events/listen globals/document EventType.CLICK hide-menu)]]
(fn [] (doseq [key keys] (events/unlistenByKey key)))))
(let [key1 (events/listen globals/document "keydown" on-key-down)
key2 (events/listen globals/document "click" hide-menu)]
(fn []
(events/unlistenByKey key1)
(events/unlistenByKey key2))))
[filtered-objects
handle-show-more
@@ -464,6 +535,8 @@
{::mf/wrap [mf/memo]}
[{:keys [size-parent]}]
(let [page (mf/deref refs/workspace-page)
page-id (get page :id)
focus (mf/deref refs/workspace-focus-selected)
objects (hooks/with-focus-objects (:objects page) focus)
@@ -473,7 +546,8 @@
observer-var (mf/use-var nil)
lazy-load-ref (mf/use-ref nil)
[filtered-objects show-more filter-component] (use-search page objects)
[filtered-objects show-more filter-component]
(use-search page objects)
intersection-callback
(fn [entries]
@@ -519,25 +593,25 @@
[:div {:class (stl/css :tool-window-content)
:data-scroll-container true
:ref on-render-container}
[:& filters-tree {:objects filtered-objects
:key (dm/str (:id page))
:parent-size size-parent}]
[:> filters-tree* {:objects filtered-objects
:key (dm/str page-id)
:parent-size size-parent}]
[:div {:ref lazy-load-ref}]]
[:div {:on-scroll on-scroll
:class (stl/css :tool-window-content)
:data-scroll-container true
:style {:display (when (some? filtered-objects) "none")}}
[:& layers-tree {:objects filtered-objects
:key (dm/str (:id page))
:filtered? true
:parent-size size-parent}]]]
[:> layers-tree-wrapper* {:objects filtered-objects
:key (dm/str page-id)
:is-filtered true
:parent-size size-parent}]]]
[:div {:on-scroll on-scroll
:class (stl/css :tool-window-content)
:data-scroll-container true
:style {:display (when (some? filtered-objects) "none")}}
[:& layers-tree {:objects objects
:key (dm/str (:id page))
:filtered? false
:parent-size size-parent}]])]))
[:> layers-tree-wrapper* {:objects objects
:key (dm/str page-id)
:is-filtered false
:parent-size size-parent}]])]))

View File

@@ -92,6 +92,19 @@
(def ^:private xf:map-type (map :type))
(def ^:private xf:mapcat-type-to-options (mapcat type->options))
(defn fixed-decimal-value
"Fixes the amount of decimals that are kept"
([value]
(fixed-decimal-value value 2))
([value decimals]
(cond
(string? value)
(fixed-decimal-value (parse-double value) decimals)
(number? value)
(parse-double (.toFixed value decimals)))))
(mf/defc measures-menu*
[{:keys [ids values applied-tokens type shapes]}]
(let [token-numeric-inputs
@@ -300,7 +313,7 @@
(mf/deps ids)
(fn [value]
(if (or (string? value) (number? value))
(do
(let [value (fixed-decimal-value value)]
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(st/emit! (udw/increase-rotation ids value)))
(st/emit! (udw/trigger-bounding-box-cloaking ids)

View File

@@ -75,7 +75,11 @@
is-type-unfolded (contains? (set unfolded-token-paths) (name type))
editing-ref (mf/deref refs/workspace-editor-state)
not-editing? (empty? editing-ref)
edition (mf/deref refs/selected-edition)
objects (mf/deref refs/workspace-page-objects)
not-editing? (and (empty? editing-ref)
(not (and (some? edition)
(= :text (:type (get objects edition))))))
can-edit?
(mf/use-ctx ctx/can-edit?)

View File

@@ -94,23 +94,8 @@
(st/emit! (mse/->MouseEvent :down ctrl? shift? alt? meta?)
::dwsp/interrupt)
(when (wasm.api/text-editor-is-active?)
(wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt)))
(when (and (not= edition id) (or text-editing? grid-editing?))
(st/emit! (dw/clear-edition-mode))
;; FIXME: I think this is not completely correct because this
;; is going to happen even when clicking or selecting text.
;; Sync and stop WASM text editor when exiting edit mode
#_(when (and text-editing?
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? true)))
(wasm.api/text-editor-stop)))
(st/emit! (dw/clear-edition-mode)))
(when (and (not text-editing?)
(not blocked)

View File

@@ -55,7 +55,7 @@
(defn top?
[cur cand]
(let [closey? (mth/close? (:y cand) (:y cur))]
(let [closey? (mth/close? (:y cand) (:y cur) 0.01)]
(cond
(and closey? (< (:x cand) (:x cur))) cand
closey? cur
@@ -64,13 +64,19 @@
(defn right?
[cur cand]
(let [closex? (mth/close? (:x cand) (:x cur))]
(let [closex? (mth/close? (:x cand) (:x cur) 0.01)]
(cond
(and closex? (< (:y cand) (:y cur))) cand
closex? cur
(> (:x cand) (:x cur)) cand
:else cur)))
(defn title-transform-use-width?
[{:keys [rotation] :as shape}]
(let [side (mth/ceil (/ (- rotation 45) 90))
use-width? (even? side)]
use-width?))
(defn title-transform
[{:keys [points] :as shape} zoom grid-edition?]
(let [leftmost (->> points (reduce left?))

View File

@@ -129,13 +129,15 @@
(fn [_]
(on-frame-leave (:id frame))))
main-instance? (ctk/main-instance? frame)
is-variant? (:is-variant-container frame)
main-instance? (ctk/main-instance? frame)
is-variant? (:is-variant-container frame)
text-width (* (:width frame) zoom)
show-icon? (and (or (:use-for-thumbnail frame) is-grid-edition main-instance? is-variant?)
(not (<= text-width 15)))
text-pos-x (if show-icon? 15 0)
use-width? (vwu/title-transform-use-width? frame)
text-width (* (if use-width? (:width frame) (:height frame)) zoom)
show-icon? (and (or (:use-for-thumbnail frame) is-grid-edition main-instance? is-variant?)
(not (<= text-width 15)))
text-pos-x (if show-icon? 15 0)
edition* (mf/use-state false)
edition? (deref edition*)
@@ -178,7 +180,6 @@
(when (kbd/enter? event) (accept-edit))
(when (kbd/esc? event) (cancel-edit))))]
(when (not (:hidden frame))
[:g.frame-title {:id (dm/str "frame-title-" (:id frame))
:data-edit-grid is-grid-edition

View File

@@ -30,6 +30,7 @@
[app.main.ui.workspace.shapes.text.editor :as editor-v1]
[app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline]]
[app.main.ui.workspace.shapes.text.v2-editor :as editor-v2]
[app.main.ui.workspace.shapes.text.v3-editor :as editor-v3]
[app.main.ui.workspace.top-toolbar :refer [top-toolbar*]]
[app.main.ui.workspace.viewport.actions :as actions]
[app.main.ui.workspace.viewport.comments :as comments]
@@ -54,7 +55,6 @@
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.widgets :as widgets]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor-input :refer [text-editor-input]]
[app.util.debug :as dbg]
[app.util.text-editor :as ted]
[beicon.v2.core :as rx]
@@ -328,10 +328,14 @@
(mf/with-effect [show-text-editor? workspace-editor-state edition]
(let [active-editor-state (get workspace-editor-state edition)]
(js/console.log "show-text-editor?" show-text-editor?)
(when (and show-text-editor? active-editor-state)
(let [content (-> active-editor-state
(ted/get-editor-current-content)
(ted/export-content))]
(when-not (wasm.api/text-editor-is-active? edition)
(prn "hola")
(wasm.api/text-editor-start edition))
(wasm.api/use-shape edition)
(wasm.api/set-shape-text-content edition content)
(let [dimension (wasm.api/get-text-dimensions)]
@@ -417,14 +421,7 @@
(when picking-color?
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
:canvas-ref canvas-ref}])
;; WASM text editor contenteditable (must be outside SVG to work)
(when (and show-text-editor?
(features/active-feature? @st/state "text-editor-wasm/v1"))
[:& text-editor-input {:shape editing-shape
:zoom zoom
:vbox vbox}])]
:canvas-ref canvas-ref}])]
[:canvas {:id "render"
:data-testid "canvas-wasm-shapes"
@@ -471,14 +468,20 @@
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
;; Text editor handling:
;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
(when (and show-text-editor?
(not (features/active-feature? @st/state "text-editor-wasm/v1")))
(if (features/active-feature? @st/state "text-editor/v2")
(when show-text-editor?
(cond
(features/active-feature? @st/state "text-editor-wasm/v1")
[:& editor-v3/text-editor {:shape editing-shape
:canvas-ref canvas-ref
:ref text-editor-ref}]
(features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref
:ref text-editor-ref}]
[:& editor-v1/text-editor-svg {:shape editing-shape
:ref text-editor-ref}]))
:else [:& editor-v1/text-editor-svg {:shape editing-shape
:ref text-editor-ref}]))
(when show-frame-outline?
(let [outlined-frame-id

View File

@@ -86,6 +86,7 @@
;; Re-export public text editor functions
(def text-editor-start text-editor/text-editor-start)
(def text-editor-stop text-editor/text-editor-stop)
(def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset)
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
(def text-editor-pointer-down text-editor/text-editor-pointer-down)
(def text-editor-pointer-move text-editor/text-editor-pointer-move)

View File

@@ -22,7 +22,14 @@
(aget buffer 2)
(aget buffer 3)))))
(defn text-editor-set-cursor-from-offset
"Sets caret position from shape relative coordinates"
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_offset" x y)))
(defn text-editor-set-cursor-from-point
"Sets caret position from screen (canvas) coordinates"
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))

View File

@@ -203,6 +203,7 @@
on-input
(mf/use-fn
(fn [^js event]
(js/console.log "event" event)
(let [native-event (.-nativeEvent event)
input-type (.-inputType native-event)
data (.-data native-event)]
@@ -214,7 +215,17 @@
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-input"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) ""))))))]
(set! (.-textContent node) ""))))))
on-focus
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event) event)))
on-blur
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event) event)))]
[:div
{:ref contenteditable-ref
@@ -225,6 +236,8 @@
:on-input on-input
:on-paste on-paste
:on-copy on-copy
:on-focus on-focus
:on-blur on-blur
;; FIXME on-click
;; :on-click on-click
:id "text-editor-wasm-input"

View File

@@ -41,8 +41,13 @@ fn draw_stroke_on_rect(
}
};
// By default just draw the rect. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
// Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying
// stroke alpha twice for dotted inner/outer strokes.
let layer_rec = skia::canvas::SaveLayerRec::default();
canvas.save_layer(&layer_rec);
match corners {
Some(radii) => {
@@ -81,7 +86,10 @@ fn draw_stroke_on_circle(
// By default just draw the circle. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
// Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying
// stroke alpha twice for dotted inner/outer strokes.
let layer_rec = skia::canvas::SaveLayerRec::default();
canvas.save_layer(&layer_rec);
let clip_path = {
let mut pb = skia::PathBuilder::new();

View File

@@ -111,7 +111,7 @@ fn calculate_cursor_rect(
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;
let char_pos = cursor.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
@@ -209,13 +209,13 @@ fn calculate_selection_rects(
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
end.offset
} else {
para_char_count
};

View File

@@ -11,6 +11,7 @@ use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{
self as skia,
paint::{self, Paint},
textlayout::Affinity,
textlayout::ParagraphBuilder,
textlayout::ParagraphStyle,
textlayout::PositionWithAffinity,
@@ -112,31 +113,51 @@ impl TextContentSize {
}
}
#[derive(Debug, Copy, Clone)]
#[derive(Debug, Clone, Copy, Default)]
pub struct TextPositionWithAffinity {
#[allow(dead_code)]
pub position_with_affinity: PositionWithAffinity,
pub paragraph: i32,
#[allow(dead_code)]
pub span: i32,
#[allow(dead_code)]
pub span_relative_offset: i32,
pub offset: i32,
pub paragraph: usize,
pub offset: usize,
}
impl PartialEq for TextPositionWithAffinity {
fn eq(&self, other: &Self) -> bool {
self.paragraph == other.paragraph && self.offset == other.offset
}
}
impl TextPositionWithAffinity {
pub fn new(
position_with_affinity: PositionWithAffinity,
paragraph: i32,
span: i32,
span_relative_offset: i32,
offset: i32,
paragraph: usize,
offset: usize,
) -> Self {
Self {
position_with_affinity,
paragraph,
span,
span_relative_offset,
offset,
}
}
pub fn empty() -> Self {
Self {
position_with_affinity: PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
},
paragraph: 0,
offset: 0,
}
}
pub fn new_without_affinity(paragraph: usize, offset: usize) -> Self {
Self {
position_with_affinity: PositionWithAffinity {
position: offset as i32,
affinity: Affinity::Downstream,
},
paragraph,
offset,
}
}
@@ -433,10 +454,11 @@ impl TextContent {
let mut offset_y = 0.0;
let layout_paragraphs = self.layout.paragraphs.iter().flatten();
let mut paragraph_index: i32 = -1;
let mut span_index: i32 = -1;
for layout_paragraph in layout_paragraphs {
paragraph_index += 1;
// IMPORTANT! I'm keeping this because I think it should be better to have the span index
// cached the same way we keep the paragraph index.
#[allow(dead_code)]
let mut _span_index: usize = 0;
for (paragraph_index, layout_paragraph) in layout_paragraphs.enumerate() {
let start_y = offset_y;
let end_y = offset_y + layout_paragraph.height();
@@ -453,20 +475,22 @@ impl TextContent {
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) {
if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
// Computed position keeps the current position in terms
// of number of characters of text. This is used to know
// in which span we are.
let mut computed_position = 0;
let mut span_offset = 0;
let mut computed_position: usize = 0;
// This could be useful in the future as part of the TextPositionWithAffinity.
#[allow(dead_code)]
let mut _span_offset: usize = 0;
// If paragraph has no spans, default to span 0, offset 0
if paragraph.children().is_empty() {
span_index = 0;
span_offset = 0;
_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;
@@ -475,27 +499,26 @@ impl TextContent {
// 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;
_span_offset = 0;
break;
}
if start_position <= current_position
&& end_position >= current_position
{
span_offset =
position_with_affinity.position - start_position as i32;
_span_offset =
position_with_affinity.position as usize - start_position;
break;
}
computed_position += length;
_span_index += 1;
}
}
return Some(TextPositionWithAffinity::new(
position_with_affinity,
paragraph_index,
span_index,
span_offset,
position_with_affinity.position,
position_with_affinity.position as usize,
));
}
}
@@ -516,9 +539,7 @@ impl TextContent {
return Some(TextPositionWithAffinity::new(
default_position,
0, // paragraph 0
0, // span 0
0, // offset 0
0,
));
}

View File

@@ -7,34 +7,10 @@ use skia_safe::{
Color,
};
/// 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,
pub anchor: TextPositionWithAffinity,
pub focus: TextPositionWithAffinity,
}
impl TextSelection {
@@ -42,10 +18,10 @@ impl TextSelection {
Self::default()
}
pub fn from_cursor(cursor: TextCursor) -> Self {
pub fn from_position_with_affinity(position: TextPositionWithAffinity) -> Self {
Self {
anchor: cursor,
focus: cursor,
anchor: position,
focus: position,
}
}
@@ -57,12 +33,12 @@ impl TextSelection {
!self.is_collapsed()
}
pub fn set_caret(&mut self, cursor: TextCursor) {
pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) {
self.anchor = cursor;
self.focus = cursor;
}
pub fn extend_to(&mut self, cursor: TextCursor) {
pub fn extend_to(&mut self, cursor: TextPositionWithAffinity) {
self.focus = cursor;
}
@@ -74,24 +50,24 @@ impl TextSelection {
self.focus = self.anchor;
}
pub fn start(&self) -> TextCursor {
pub fn start(&self) -> TextPositionWithAffinity {
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 {
} else if self.anchor.offset <= self.focus.offset {
self.anchor
} else {
self.focus
}
}
pub fn end(&self) -> TextCursor {
pub fn end(&self) -> TextPositionWithAffinity {
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 {
} else if self.anchor.offset >= self.focus.offset {
self.anchor
} else {
self.focus
@@ -102,7 +78,7 @@ impl TextSelection {
/// Events that the text editor can emit for frontend synchronization
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum EditorEvent {
pub enum TextEditorEvent {
None = 0,
ContentChanged = 1,
SelectionChanged = 2,
@@ -131,7 +107,7 @@ pub struct TextEditorState {
pub active_shape_id: Option<Uuid>,
pub cursor_visible: bool,
pub last_blink_time: f64,
pending_events: Vec<EditorEvent>,
pending_events: Vec<TextEditorEvent>,
}
impl TextEditorState {
@@ -166,9 +142,9 @@ impl TextEditorState {
self.is_active = false;
self.active_shape_id = None;
self.cursor_visible = false;
self.last_blink_time = 0.0;
self.is_pointer_selection_active = false;
self.pending_events.clear();
self.reset_blink();
}
pub fn start_pointer_selection(&mut self) -> bool {
@@ -189,56 +165,42 @@ impl TextEditorState {
pub fn select_all(&mut self, content: &TextContent) -> bool {
self.is_pointer_selection_active = false;
self.set_caret_from_position(TextPositionWithAffinity::new(
PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
},
0,
0,
0,
0,
));
let num_paragraphs = (content.paragraphs().len() - 1) as i32;
self.set_caret_from_position(&TextPositionWithAffinity::empty());
let num_paragraphs = content.paragraphs().len() - 1;
let Some(last_paragraph) = content.paragraphs().last() else {
return false;
};
let num_spans = (last_paragraph.children().len() - 1) as i32;
let Some(last_text_span) = last_paragraph.children().last() else {
#[allow(dead_code)]
let _num_spans = last_paragraph.children().len() - 1;
let Some(_last_text_span) = last_paragraph.children().last() else {
return false;
};
let mut offset = 0;
for span in last_paragraph.children() {
offset += span.text.len();
}
self.extend_selection_from_position(TextPositionWithAffinity::new(
self.extend_selection_from_position(&TextPositionWithAffinity::new(
PositionWithAffinity {
position: offset as i32,
affinity: Affinity::Upstream,
},
num_paragraphs,
num_spans,
last_text_span.text.len() as i32,
offset as i32,
offset,
));
self.reset_blink();
self.push_event(crate::state::EditorEvent::SelectionChanged);
self.push_event(crate::state::TextEditorEvent::SelectionChanged);
true
}
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.push_event(EditorEvent::SelectionChanged);
pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.set_caret(*position);
self.push_event(TextEditorEvent::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 extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.extend_to(*position);
self.push_event(TextEditorEvent::SelectionChanged);
}
pub fn update_blink(&mut self, timestamp_ms: f64) {
@@ -264,41 +226,17 @@ impl TextEditorState {
self.last_blink_time = 0.0;
}
pub fn push_event(&mut self, event: EditorEvent) {
pub fn push_event(&mut self, event: TextEditorEvent) {
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 poll_event(&mut self) -> TextEditorEvent {
self.pending_events.pop().unwrap_or(TextEditorEvent::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,
pub span: i32,
}
impl TextNodePosition {
pub fn new(paragraph: i32, span: i32) -> Self {
Self { paragraph, span }
}
pub fn is_invalid(&self) -> bool {
self.paragraph < 0 || self.span < 0
}
}

View File

@@ -1,7 +1,7 @@
use crate::math::{Matrix, Point, Rect};
use crate::mem;
use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign};
use crate::state::{TextCursor, TextSelection};
use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
use crate::state::TextSelection;
use crate::utils::uuid_from_u32_quartet;
use crate::utils::uuid_to_u32_quartet;
use crate::{with_state, with_state_mut, STATE};
@@ -126,13 +126,11 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
return;
};
let point = Point::new(x, y);
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let shape_matrix = shape.get_matrix();
state.text_editor_state.start_pointer_selection();
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
text_content.get_caret_position_from_shape_coords(&point)
{
state.text_editor_state.set_caret_from_position(position);
state.text_editor_state.set_caret_from_position(&position);
}
});
}
@@ -143,7 +141,6 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
if !state.text_editor_state.is_active {
return;
}
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
@@ -151,11 +148,6 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let shape_matrix = shape.get_matrix();
let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix)
else {
return;
};
if !state.text_editor_state.is_pointer_selection_active {
return;
}
@@ -164,11 +156,11 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
};
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
text_content.get_caret_position_from_shape_coords(&point)
{
state
.text_editor_state
.extend_selection_from_position(position);
.extend_selection_from_position(&position);
}
});
}
@@ -179,7 +171,6 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
if !state.text_editor_state.is_active {
return;
}
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
@@ -187,11 +178,6 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let shape_matrix = shape.get_matrix();
let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix)
else {
return;
};
if !state.text_editor_state.is_pointer_selection_active {
return;
}
@@ -199,16 +185,41 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
return;
};
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
text_content.get_caret_position_from_shape_coords(&point)
{
state
.text_editor_state
.extend_selection_from_position(position);
.extend_selection_from_position(&position);
}
state.text_editor_state.stop_pointer_selection();
});
}
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let Type::Text(text_content) = &shape.shape_type else {
return;
};
if let Some(position) =
text_content.get_caret_position_from_shape_coords(&point)
{
state.text_editor_state.set_caret_from_position(&position);
}
});
}
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
with_state_mut!(state, {
@@ -231,7 +242,7 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state.text_editor_state.set_caret_from_position(position);
state.text_editor_state.set_caret_from_position(&position);
}
});
}
@@ -276,7 +287,8 @@ pub extern "C" fn text_editor_insert_text() {
let cursor = state.text_editor_state.selection.focus;
if let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) {
let new_cursor = TextCursor::new(cursor.paragraph, new_offset);
let new_cursor =
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, new_offset);
state.text_editor_state.selection.set_caret(new_cursor);
}
@@ -286,10 +298,10 @@ pub extern "C" fn text_editor_insert_text() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -336,10 +348,10 @@ pub extern "C" fn text_editor_delete_backward() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -384,10 +396,10 @@ pub extern "C" fn text_editor_delete_forward() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -423,7 +435,8 @@ pub extern "C" fn text_editor_insert_paragraph() {
let cursor = state.text_editor_state.selection.focus;
if split_paragraph_at_cursor(text_content, &cursor) {
let new_cursor = TextCursor::new(cursor.paragraph + 1, 0);
let new_cursor =
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0);
state.text_editor_state.selection.set_caret(new_cursor);
}
@@ -433,10 +446,10 @@ pub extern "C" fn text_editor_insert_paragraph() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -494,7 +507,7 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::SelectionChanged);
.push_event(crate::state::TextEditorEvent::SelectionChanged);
});
}
@@ -711,12 +724,12 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 {
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
end.offset
} else {
para_char_count
};
@@ -764,9 +777,9 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
let sel = &state.text_editor_state.selection;
unsafe {
*buffer_ptr = sel.anchor.paragraph as u32;
*buffer_ptr.add(1) = sel.anchor.char_offset as u32;
*buffer_ptr.add(1) = sel.anchor.offset as u32;
*buffer_ptr.add(2) = sel.focus.paragraph as u32;
*buffer_ptr.add(3) = sel.focus.char_offset as u32;
*buffer_ptr.add(3) = sel.focus.offset as u32;
}
1
})
@@ -776,7 +789,11 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
// HELPERS: Cursor & Selection
// ============================================================================
fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shape) -> Option<Rect> {
fn get_cursor_rect(
text_content: &TextContent,
cursor: &TextPositionWithAffinity,
shape: &Shape,
) -> Option<Rect> {
let paragraphs = text_content.paragraphs();
if cursor.paragraph >= paragraphs.len() {
return None;
@@ -794,7 +811,7 @@ fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shap
let mut y_offset = valign_offset;
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.char_offset;
let char_pos = cursor.offset;
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let rects = laid_out_para.get_rects_for_range(
@@ -869,13 +886,13 @@ fn get_selection_rects(
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
end.offset
} else {
para_char_count
};
@@ -914,40 +931,49 @@ fn paragraph_char_count(para: &Paragraph) -> usize {
}
/// Clamp a cursor position to valid bounds within the text content.
fn clamp_cursor(cursor: TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
fn clamp_cursor(
position: TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
if paragraphs.is_empty() {
return TextCursor::new(0, 0);
return TextPositionWithAffinity::new_without_affinity(0, 0);
}
let para_idx = cursor.paragraph.min(paragraphs.len() - 1);
let para_idx = position.paragraph.min(paragraphs.len() - 1);
let para_len = paragraph_char_count(&paragraphs[para_idx]);
let char_offset = cursor.char_offset.min(para_len);
let char_offset = position.offset.min(para_len);
TextCursor::new(para_idx, char_offset)
TextPositionWithAffinity::new_without_affinity(para_idx, char_offset)
}
/// Move cursor left by one character.
fn move_cursor_backward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
if cursor.char_offset > 0 {
TextCursor::new(cursor.paragraph, cursor.char_offset - 1)
fn move_cursor_backward(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
if cursor.offset > 0 {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset - 1)
} else if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
TextCursor::new(prev_para, char_count)
TextPositionWithAffinity::new_without_affinity(prev_para, char_count)
} else {
*cursor
}
}
/// Move cursor right by one character.
fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
fn move_cursor_forward(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
let para = &paragraphs[cursor.paragraph];
let char_count = paragraph_char_count(para);
if cursor.char_offset < char_count {
TextCursor::new(cursor.paragraph, cursor.char_offset + 1)
if cursor.offset < char_count {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset + 1)
} else if cursor.paragraph < paragraphs.len() - 1 {
TextCursor::new(cursor.paragraph + 1, 0)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0)
} else {
*cursor
}
@@ -955,52 +981,58 @@ fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCur
/// Move cursor up by one line.
fn move_cursor_up(
cursor: &TextCursor,
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
_text_content: &TextContent,
_shape: &Shape,
) -> TextCursor {
) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
let new_offset = cursor.char_offset.min(char_count);
TextCursor::new(prev_para, new_offset)
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(prev_para, new_offset)
} else {
TextCursor::new(cursor.paragraph, 0)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
}
}
/// Move cursor down by one line.
fn move_cursor_down(
cursor: &TextCursor,
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
_text_content: &TextContent,
_shape: &Shape,
) -> TextCursor {
) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph < paragraphs.len() - 1 {
let next_para = cursor.paragraph + 1;
let char_count = paragraph_char_count(&paragraphs[next_para]);
let new_offset = cursor.char_offset.min(char_count);
TextCursor::new(next_para, new_offset)
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(next_para, new_offset)
} else {
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextCursor::new(cursor.paragraph, char_count)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
}
}
/// Move cursor to start of current line.
fn move_cursor_line_start(cursor: &TextCursor, _paragraphs: &[Paragraph]) -> TextCursor {
fn move_cursor_line_start(
cursor: &TextPositionWithAffinity,
_paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
// TODO: Implement proper line-start using line metrics
TextCursor::new(cursor.paragraph, 0)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
}
/// Move cursor to end of current line.
fn move_cursor_line_end(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
fn move_cursor_line_end(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
// TODO: Implement proper line-end using line metrics
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextCursor::new(cursor.paragraph, char_count)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
}
// ============================================================================
@@ -1028,7 +1060,7 @@ fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, u
/// Insert text at a cursor position. Returns the new character offset after insertion.
fn insert_text_at_cursor(
text_content: &mut TextContent,
cursor: &TextCursor,
cursor: &TextPositionWithAffinity,
text: &str,
) -> Option<usize> {
let paragraphs = text_content.paragraphs_mut();
@@ -1048,7 +1080,7 @@ fn insert_text_at_cursor(
return Some(text.chars().count());
}
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.char_offset)?;
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.offset)?;
let children = para.children_mut();
let span = &mut children[span_idx];
@@ -1063,7 +1095,7 @@ fn insert_text_at_cursor(
new_text.insert_str(byte_offset, text);
span.set_text(new_text);
Some(cursor.char_offset + text.chars().count())
Some(cursor.offset + text.chars().count())
}
/// Delete a range of text specified by a selection.
@@ -1077,20 +1109,16 @@ fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelect
}
if start.paragraph == end.paragraph {
delete_range_in_paragraph(
&mut paragraphs[start.paragraph],
start.char_offset,
end.char_offset,
);
delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset);
} else {
let start_para_len = paragraph_char_count(&paragraphs[start.paragraph]);
delete_range_in_paragraph(
&mut paragraphs[start.paragraph],
start.char_offset,
start.offset,
start_para_len,
);
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.char_offset);
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset);
if end.paragraph < paragraphs.len() {
let end_para_children: Vec<_> =
@@ -1189,13 +1217,19 @@ fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offs
}
/// Delete the character before the cursor. Returns the new cursor position.
fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Option<TextCursor> {
if cursor.char_offset > 0 {
fn delete_char_before(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
) -> Option<TextPositionWithAffinity> {
if cursor.offset > 0 {
let paragraphs = text_content.paragraphs_mut();
let para = &mut paragraphs[cursor.paragraph];
let delete_pos = cursor.char_offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.char_offset);
Some(TextCursor::new(cursor.paragraph, delete_pos))
let delete_pos = cursor.offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.offset);
Some(TextPositionWithAffinity::new_without_affinity(
cursor.paragraph,
delete_pos,
))
} else if cursor.paragraph > 0 {
let prev_para_idx = cursor.paragraph - 1;
let paragraphs = text_content.paragraphs_mut();
@@ -1211,14 +1245,17 @@ fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Op
paragraphs.remove(cursor.paragraph);
Some(TextCursor::new(prev_para_idx, prev_para_len))
Some(TextPositionWithAffinity::new_without_affinity(
prev_para_idx,
prev_para_len,
))
} else {
None
}
}
/// Delete the character after the cursor.
fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return;
@@ -1226,9 +1263,9 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
let para_len = paragraph_char_count(&paragraphs[cursor.paragraph]);
if cursor.char_offset < para_len {
if cursor.offset < para_len {
let para = &mut paragraphs[cursor.paragraph];
delete_range_in_paragraph(para, cursor.char_offset, cursor.char_offset + 1);
delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1);
} else if cursor.paragraph < paragraphs.len() - 1 {
let next_para_idx = cursor.paragraph + 1;
let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
@@ -1241,7 +1278,10 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
}
/// Split a paragraph at the cursor position. Returns true if split was successful.
fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor) -> bool {
fn split_paragraph_at_cursor(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
) -> bool {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return false;
@@ -1249,7 +1289,7 @@ fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor
let para = &paragraphs[cursor.paragraph];
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.char_offset) else {
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else {
return false;
};