diff --git a/backend/src/app/migrations/clj/migration_0023.clj b/backend/src/app/migrations/clj/migration_0023.clj index 6e928028c6..b2027d2275 100644 --- a/backend/src/app/migrations/clj/migration_0023.clj +++ b/backend/src/app/migrations/clj/migration_0023.clj @@ -58,4 +58,3 @@ (when (nil? (:data file)) (migrate-file conn file))) (db/exec-one! conn ["drop table page cascade;"]))) - diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index a4624ce37c..92f9b9bef2 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -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) { diff --git a/frontend/playwright/ui/specs/variants.spec.js b/frontend/playwright/ui/specs/variants.spec.js index 9cbe4ca440..b5dc3d751d 100644 --- a/frontend/playwright/ui/specs/variants.spec.js +++ b/frontend/playwright/ui/specs/variants.spec.js @@ -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(); diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 68240aafac..a761328eb2 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -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. diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 706a7577a8..58982b33f0 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -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)) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 50cdd0e1b6..359be1179f 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -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)) diff --git a/frontend/src/app/main/ui/inspect/left_sidebar.cljs b/frontend/src/app/main/ui/inspect/left_sidebar.cljs index 6f521decc5..e8ef179e92 100644 --- a/frontend/src/app/main/ui/inspect/left_sidebar.cljs +++ b/frontend/src/app/main/ui/inspect/left_sidebar.cljs @@ -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} diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index 550e072d07..837065eda3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -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)}])])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs index ffe019638d..2c8e78fc57 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs @@ -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)} "*"])]))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index dabe7aae77..49433489c6 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -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}]])])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs index 58912a2101..7500f45db3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs @@ -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?) diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index e18b5f5e63..8028316e03 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -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(); diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 8d4243f80b..2cf7eeab0c 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -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 }; diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 1cad369768..b18a7b4aab 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -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, )); } diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 8c384db2b3..dc0f4152d4 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -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, pub cursor_visible: bool, pub last_blink_time: f64, - pending_events: Vec, + pending_events: Vec, } impl TextEditorState { @@ -189,56 +165,44 @@ 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); + pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { + self.selection.set_caret(*position); self.reset_blink(); - self.push_event(EditorEvent::SelectionChanged); + 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); + pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) { + self.selection.extend_to(*position); self.reset_blink(); - self.push_event(EditorEvent::SelectionChanged); + self.push_event(TextEditorEvent::SelectionChanged); } pub fn update_blink(&mut self, timestamp_ms: f64) { @@ -264,41 +228,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 - } } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 771ee82627..c7285345e5 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -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}; @@ -132,7 +132,7 @@ pub extern "C" fn text_editor_pointer_down(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); } }); } @@ -168,7 +168,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { { state .text_editor_state - .extend_selection_from_position(position); + .extend_selection_from_position(&position); } }); } @@ -203,7 +203,7 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { { state .text_editor_state - .extend_selection_from_position(position); + .extend_selection_from_position(&position); } state.text_editor_state.stop_pointer_selection(); }); @@ -231,7 +231,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 +276,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 +287,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 +337,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 +385,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 +424,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 +435,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 +496,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 +713,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 +766,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 +778,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 { +fn get_cursor_rect( + text_content: &TextContent, + cursor: &TextPositionWithAffinity, + shape: &Shape, +) -> Option { let paragraphs = text_content.paragraphs(); if cursor.paragraph >= paragraphs.len() { return None; @@ -794,7 +800,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 +875,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 +920,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(¶graphs[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(¶graphs[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 = ¶graphs[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 +970,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(¶graphs[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(¶graphs[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(¶graphs[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(¶graphs[cursor.paragraph]); - TextCursor::new(cursor.paragraph, char_count) + TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) } // ============================================================================ @@ -1028,7 +1049,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 { let paragraphs = text_content.paragraphs_mut(); @@ -1048,7 +1069,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 +1084,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 +1098,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(¶graphs[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 +1206,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 { - if cursor.char_offset > 0 { +fn delete_char_before( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, +) -> Option { + 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 +1234,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 +1252,9 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) { let para_len = paragraph_char_count(¶graphs[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 +1267,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 +1278,7 @@ fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor let para = ¶graphs[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; };