From 1d45ca701994e24ba17c136fa63cf2f13c650b0c Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 5 Feb 2026 14:27:27 +0100 Subject: [PATCH 1/7] :bug: Fix problem propagating geometry changes to instances --- .../app/main/data/workspace/modifiers.cljs | 72 ++++++++++++++++--- 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 4b55c9985c..717b2b03dd 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -179,6 +179,56 @@ (map #(get objects %)) (reduce get-ignore-tree nil)))) +(defn calculate-ignore-tree-wasm + "Retrieves a map with the flag `ignore-geometry?` given a tree of modifiers" + [transforms objects] + + (letfn [(get-ignore-tree + ([ignore-tree shape] + (let [shape-id (dm/get-prop shape :id) + transformed-shape (gsh/apply-transform shape (get transforms shape-id)) + + root + (if (:component-root shape) + shape + (ctn/get-component-shape objects shape {:allow-main? true})) + + transformed-root + (if (:component-root shape) + transformed-shape + (gsh/apply-transform root (get transforms (:id root))))] + + (get-ignore-tree ignore-tree shape transformed-shape root transformed-root))) + + ([ignore-tree shape root transformed-root] + (let [shape-id (dm/get-prop shape :id) + transformed-shape (gsh/apply-transform shape (get transforms shape-id))] + (get-ignore-tree ignore-tree shape transformed-shape root transformed-root))) + + ([ignore-tree shape transformed-shape root transformed-root] + (let [shape-id (dm/get-prop shape :id) + + ignore-tree + (cond-> ignore-tree + (and (some? root) (ctk/in-component-copy? shape)) + (assoc + shape-id + (check-delta shape root transformed-shape transformed-root))) + + set-child + (fn [ignore-tree child] + (get-ignore-tree ignore-tree child root transformed-root))] + + (->> (:shapes shape) + (map (d/getf objects)) + (reduce set-child ignore-tree)))))] + + ;; we check twice because we want only to search parents of components but once the + ;; tree is traversed we only want to process the objects in components + (->> (keys transforms) + (map #(get objects %)) + (reduce get-ignore-tree nil)))) + (defn assoc-position-data [shape position-data old-shape] (let [deltav (gpt/to-vec (gpt/point (:selrect old-shape)) @@ -625,17 +675,6 @@ (let [objects (dsh/lookup-page-objects state) - ignore-tree - (calculate-ignore-tree modif-tree objects) - - options - (-> params - (assoc :reg-objects? true) - (assoc :ignore-tree ignore-tree) - ;; Attributes that can change in the transform. This - ;; way we don't have to check all the attributes - (assoc :attrs transform-attrs)) - geometry-entries (parse-geometry-modifiers modif-tree) @@ -645,6 +684,17 @@ transforms (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)) + ignore-tree + (calculate-ignore-tree-wasm transforms objects) + + options + (-> params + (assoc :reg-objects? true) + (assoc :ignore-tree ignore-tree) + ;; Attributes that can change in the transform. This + ;; way we don't have to check all the attributes + (assoc :attrs transform-attrs)) + modif-tree (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) From 8a72eb64c30532952a6f40398aa650065f096c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 5 Feb 2026 16:16:30 +0100 Subject: [PATCH 2/7] :sparkles: Add integration test for 13267 --- .../data/components/get-file-13267.json | 146 ++++++++++++++++++ frontend/playwright/ui/pages/WorkspacePage.js | 9 +- .../playwright/ui/specs/components.spec.js | 33 ++++ .../sidebar/options/menus/measures.cljs | 3 +- 4 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 frontend/playwright/data/components/get-file-13267.json create mode 100644 frontend/playwright/ui/specs/components.spec.js diff --git a/frontend/playwright/data/components/get-file-13267.json b/frontend/playwright/data/components/get-file-13267.json new file mode 100644 index 0000000000..07bdf3e246 --- /dev/null +++ b/frontend/playwright/data/components/get-file-13267.json @@ -0,0 +1,146 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u99e49e93-362f-80ef-8007-3450ea52c9a4", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "BUG 13267", + "~:revn": 3, + "~:modified-at": "~m1770302832804", + "~:vern": 0, + "~:id": "~ue9c84e12-dd29-80fc-8007-86d559dced7f", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node" + ] + }, + "~:version": 67, + "~:project-id": "~ufc576d2f-8d02-8101-8007-70ec5793bd81", + "~:created-at": "~m1770302800755", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~ue9c84e12-dd29-80fc-8007-86d559dced80" + ], + "~:pages-index": { + "~ue9c84e12-dd29-80fc-8007-86d559dced80": { + "~:objects": { + "~#penpot/objects-map/v2": { + "~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~udc075bef-4a1f-8056-8007-86d562cf43b7\"]]]", + "~udc075bef-4a1f-8056-8007-86d55e028ccb": "[\"~#shape\",[\"^ \",\"~:y\",234,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",117,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",574,\"~:y\",234]],[\"^<\",[\"^ \",\"~:x\",691,\"~:y\",234]],[\"^<\",[\"^ \",\"~:x\",691,\"~:y\",316]],[\"^<\",[\"^ \",\"~:x\",574,\"~:y\",316]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:scale\",\"~:constraints-h\",\"^B\",\"~:r1\",0,\"~:id\",\"~udc075bef-4a1f-8056-8007-86d55e028ccb\",\"~:parent-id\",\"~udc075bef-4a1f-8056-8007-86d562cf43b7\",\"~:frame-id\",\"~udc075bef-4a1f-8056-8007-86d562cf43b7\",\"~:strokes\",[],\"~:x\",574,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",574,\"~:y\",234,\"^8\",117,\"~:height\",82,\"~:x1\",574,\"~:y1\",234,\"~:x2\",691,\"~:y2\",316]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",82,\"~:flip-y\",null]]", + "~udc075bef-4a1f-8056-8007-86d562cf43b7": "[\"~#shape\",[\"^ \",\"~:y\",234,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"A Component\",\"~:width\",117,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",574,\"~:y\",234]],[\"^;\",[\"^ \",\"~:x\",691,\"~:y\",234]],[\"^;\",[\"^ \",\"~:x\",691,\"~:y\",316]],[\"^;\",[\"^ \",\"~:x\",574,\"~:y\",316]]],\"~:r2\",0,\"~:component-root\",true,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~udc075bef-4a1f-8056-8007-86d562cf43b7\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:component-id\",\"~udc075bef-4a1f-8056-8007-86d562d06904\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",574,\"~:main-instance\",true,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",574,\"~:y\",234,\"^7\",117,\"~:height\",82,\"~:x1\",574,\"~:y1\",234,\"~:x2\",691,\"~:y2\",316]],\"~:fills\",[],\"~:flip-x\",null,\"^M\",82,\"~:component-file\",\"~ue9c84e12-dd29-80fc-8007-86d559dced7f\",\"~:flip-y\",null,\"~:shapes\",[\"~udc075bef-4a1f-8056-8007-86d55e028ccb\"]]]" + } + }, + "~:id": "~ue9c84e12-dd29-80fc-8007-86d559dced80", + "~:name": "Page 1" + } + }, + "~:id": "~ue9c84e12-dd29-80fc-8007-86d559dced7f", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + }, + "~:components": { + "~udc075bef-4a1f-8056-8007-86d562d06904": { + "~:id": "~udc075bef-4a1f-8056-8007-86d562d06904", + "~:name": "A Component", + "~:path": "", + "~:modified-at": "~m1770302824566", + "~:main-instance-id": "~udc075bef-4a1f-8056-8007-86d562cf43b7", + "~:main-instance-page": "~ue9c84e12-dd29-80fc-8007-86d559dced80" + } + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index a9d6e1d939..9bc6e1b19b 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -459,8 +459,8 @@ export class WorkspacePage extends BaseWebSocketPage { await this.page.mouse.up(); } - async clickLeafLayer(name, clickOptions = {}) { - const layer = this.layers.getByText(name).first(); + async clickLeafLayer(name, clickOptions = {}, index = 0) { + const layer = this.layers.getByText(name).nth(index); await layer.waitFor(); await layer.click(clickOptions); await this.page.waitForTimeout(500); @@ -471,10 +471,11 @@ export class WorkspacePage extends BaseWebSocketPage { await this.clickLeafLayer(name, clickOptions); } - async clickToggableLayer(name, clickOptions = {}) { + async clickToggableLayer(name, clickOptions = {}, index = 0) { const layer = this.layers .getByTestId("layer-row") - .filter({ hasText: name }); + .filter({ hasText: name }) + .nth(index); const button = layer.getByTestId("toggle-content"); await expect(button).toBeVisible(); diff --git a/frontend/playwright/ui/specs/components.spec.js b/frontend/playwright/ui/specs/components.spec.js new file mode 100644 index 0000000000..50adc17eae --- /dev/null +++ b/frontend/playwright/ui/specs/components.spec.js @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; +import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; + +test.beforeEach(async ({ page }) => { + await WasmWorkspacePage.init(page); +}); + +test("BUG 13267 - Component instance is not synced with parent for geometry changes", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(page); + await workspacePage.mockGetFile("components/get-file-13267.json"); + + await workspacePage.goToWorkspace({ + fileId: "e9c84e12-dd29-80fc-8007-86d559dced7f", + pageId: "e9c84e12-dd29-80fc-8007-86d559dced80", + }); + + // Create a component instance + await workspacePage.clickLeafLayer("A Component"); + await workspacePage.page.keyboard.press("ControlOrMeta+d"); + + // Select the main component + await workspacePage.clickLeafLayer("A Component", {}, 1); + const rotationInput = workspacePage.rightSidebar.getByTestId("rotation").getByRole("textbox"); + await rotationInput.fill("45"); + await rotationInput.press("Enter"); + + // Select the instance rect + await workspacePage.clickToggableLayer("A Component", {}, 0); + await workspacePage.clickLeafLayer("Rectangle"); + + await expect(rotationInput).toHaveValue("45"); +}); \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs index 813faf0847..c224e952d4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs @@ -590,7 +590,8 @@ :values values}] [:div {:class (stl/css :rotation) - :title (tr "workspace.options.rotation")} + :title (tr "workspace.options.rotation") + :data-testid "rotation"} [:span {:class (stl/css :icon)} deprecated-icon/rotation] [:> deprecated-input/numeric-input* {:no-validate true From 53c2acb3e6a4956b2f20d5c29fc9c438ef484ef8 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 3 Feb 2026 17:30:54 +0100 Subject: [PATCH 3/7] :bug: Fix several problems with layouts and texts --- common/src/app/common/types/text.cljc | 4 +- .../app/main/data/workspace/shape_layout.cljs | 2 +- .../src/app/main/data/workspace/texts.cljs | 6 +- .../app/main/data/workspace/wasm_text.cljs | 59 +++++++++++++++ .../app/main/ui/workspace/viewport/utils.cljs | 49 +++++++------ frontend/src/app/plugins/api.cljs | 16 +++-- frontend/src/app/render_wasm/api.cljs | 14 ++-- render-wasm/src/shapes.rs | 4 ++ render-wasm/src/shapes/modifiers.rs | 72 ++++++++++--------- .../src/shapes/modifiers/flex_layout.rs | 19 +++-- .../src/shapes/modifiers/grid_layout.rs | 5 +- render-wasm/src/shapes/transform.rs | 6 +- render-wasm/src/state/shapes_pool.rs | 20 ++++++ 13 files changed, 191 insertions(+), 85 deletions(-) diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 9a56504f0e..7c140136ca 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -407,17 +407,19 @@ (defn change-text "Changes the content of the text shape to use the text as argument. Will use the styles of the first paragraph and text that is present in the shape (and override the rest)" - [content text] + [content text & {:as styles}] (let [root-styles (select-keys content root-attrs) paragraph-style (merge default-text-attrs + styles (select-keys (->> content (node-seq is-paragraph-node?) first) text-all-attrs)) text-style (merge default-text-attrs + styles (select-keys (->> content (node-seq is-text-node?) first) text-all-attrs)) paragraph-texts diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index e31b892a8f..163195f11f 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -104,7 +104,7 @@ (watch [_ state _] (let [page-id (or page-id (:current-page-id state)) objects (dsh/lookup-page-objects state page-id) - ids (->> ids (filter #(contains? objects %)))] + ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))] (if (d/not-empty? ids) (let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))] (if (features/active-feature? state "render-wasm/v1") diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 54fcf70abc..76b888e238 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -776,11 +776,7 @@ (rx/of (v2-update-text-editor-styles id attrs))) (when (features/active-feature? state "render-wasm/v1") - ;; This delay is to give time for the font to be correctly rendered - ;; in wasm. - (cond->> (rx/of (dwwt/resize-wasm-text id)) - (contains? attrs :font-id) - (rx/delay 200))))))) + (rx/of (dwwt/resize-wasm-text-debounce id))))))) ptk/EffectEvent (effect [_ state _] diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 2174ba7161..fd814b45f5 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -62,6 +62,65 @@ (rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape))) (rx/empty)))))) +(defn resize-wasm-text-debounce-commit + [] + (ptk/reify ::resize-wasm-text + ptk/WatchEvent + (watch [_ state _] + (let [ids (get state ::resize-wasm-text-debounce-ids) + objects (dsh/lookup-page-objects state) + + modifiers + (reduce + (fn [modifiers id] + (let [shape (get objects id)] + (cond-> modifiers + (and (some? shape) + (cfh/text-shape? shape) + (not= :fixed (:grow-type shape))) + (merge (resize-wasm-text-modifiers shape))))) + {} + ids)] + (if (not (empty? modifiers)) + (rx/of (dwm/apply-wasm-modifiers modifiers)) + (rx/empty)))))) + +;; This event will debounce the resize events so, if there are many, they +;; are processed at the same time and not one-by-one. This will improve +;; performance because it's better to make only one layout calculation instead +;; of (potentialy) hundreds. +(defn resize-wasm-text-debounce + [id] + (let [cur-event (js/Symbol)] + (ptk/reify ::resize-wasm-text-debounce + ptk/UpdateEvent + (update [_ state] + (-> state + (update ::resize-wasm-text-debounce-ids (fnil conj []) id) + (cond-> (nil? (::resize-wasm-text-debounce-event state)) + (assoc ::resize-wasm-text-debounce-event cur-event)))) + + ptk/WatchEvent + (watch [_ state stream] + (if (= (::resize-wasm-text-debounce-event state) cur-event) + (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] + (rx/concat + (rx/merge + (->> stream + (rx/filter (ptk/type? ::resize-wasm-text-debounce)) + (rx/debounce 20) + (rx/take 1) + (rx/delay 200) + (rx/map #(resize-wasm-text-debounce-commit)) + (rx/take-until stopper)) + + (rx/of (resize-wasm-text-debounce id))) + + (rx/of #(dissoc % + ::resize-wasm-text-debounce-ids + ::resize-wasm-text-debounce-event)))) + (rx/empty)))))) + (defn resize-wasm-text-all "Resize all text shapes (auto-width/auto-height) from a collection of ids." [ids] diff --git a/frontend/src/app/main/ui/workspace/viewport/utils.cljs b/frontend/src/app/main/ui/workspace/viewport/utils.cljs index fac3c4ba00..a4ac92b6c2 100644 --- a/frontend/src/app/main/ui/workspace/viewport/utils.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/utils.cljs @@ -75,32 +75,31 @@ [{:keys [points] :as shape} zoom grid-edition?] (let [leftmost (->> points (reduce left?)) topmost (->> points (remove #{leftmost}) (reduce top?)) - rightmost (->> points (remove #{leftmost topmost}) (reduce right?)) + rightmost (->> points (remove #{leftmost topmost}) (reduce right?))] + (when (and (some? leftmost) (some? topmost) (some? rightmost)) + (let [left-top (gpt/to-vec leftmost topmost) + left-top-angle (gpt/angle left-top) - left-top (gpt/to-vec leftmost topmost) - left-top-angle (gpt/angle left-top) + top-right (gpt/to-vec topmost rightmost) + top-right-angle (gpt/angle top-right) - top-right (gpt/to-vec topmost rightmost) - top-right-angle (gpt/angle top-right) + ;; Choose the position that creates the less angle between left-side and top-side + [label-pos angle h-pos v-pos] + (if (< (mth/abs left-top-angle) (mth/abs top-right-angle)) + [leftmost left-top-angle left-top (gpt/perpendicular left-top)] + [topmost top-right-angle top-right (gpt/perpendicular top-right)]) - ;; Choose the position that creates the less angle between left-side and top-side - [label-pos angle h-pos v-pos] - (if (< (mth/abs left-top-angle) (mth/abs top-right-angle)) - [leftmost left-top-angle left-top (gpt/perpendicular left-top)] - [topmost top-right-angle top-right (gpt/perpendicular top-right)]) + delta-x (if grid-edition? 40 0) + delta-y (if grid-edition? 50 10) - delta-x (if grid-edition? 40 0) - delta-y (if grid-edition? 50 10) - - label-pos - (-> label-pos - (gpt/subtract (gpt/scale (gpt/unit v-pos) (/ delta-y zoom))) - (gpt/subtract (gpt/scale (gpt/unit h-pos) (/ delta-x zoom))))] - - (dm/fmt "rotate(% %,%) scale(%, %) translate(%, %)" - ;; rotate - angle (:x label-pos) (:y label-pos) - ;; scale - (/ 1 zoom) (/ 1 zoom) - ;; translate - (* zoom (:x label-pos)) (* zoom (:y label-pos))))) + label-pos + (-> label-pos + (gpt/subtract (gpt/scale (gpt/unit v-pos) (/ delta-y zoom))) + (gpt/subtract (gpt/scale (gpt/unit h-pos) (/ delta-x zoom))))] + (dm/fmt "rotate(% %,%) scale(%, %) translate(%, %)" + ;; rotate + angle (:x label-pos) (:y label-pos) + ;; scale + (/ 1 zoom) (/ 1 zoom) + ;; translate + (* zoom (:x label-pos)) (* zoom (:y label-pos))))))) diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index be97f52a78..8e977858c1 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -26,6 +26,7 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.wasm-text :as dwwt] [app.main.fonts :refer [fetch-font-css]] [app.main.router :as rt] [app.main.store :as st] @@ -338,9 +339,14 @@ :else (let [page (dsh/lookup-page @st/state) - shape (-> (cts/setup-shape {:type :text :x 0 :y 0 :grow-type :auto-width}) - (update :content txt/change-text text) - (assoc :position-data nil)) + shape (-> (cts/setup-shape {:type :text + :x 0 :y 0 + :width 1 :height 1 + :grow-type :auto-width}) + (update :content txt/change-text text + ;; Text should be given a color by default + {:fills [{:fill-color "#000000" :fill-opacity 1}]}) + (dissoc :position-data)) changes (-> (cb/empty-changes) @@ -348,7 +354,9 @@ (cb/with-objects (:objects page)) (cb/add-object shape))] - (st/emit! (ch/commit-changes changes)) + (st/emit! + (ch/commit-changes changes) + (dwwt/resize-wasm-text-debounce (:id shape))) (shape/shape-proxy plugin-id (:id shape))))) :createShapeFromSvg diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 5d1ddbd731..bbe548835c 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -190,11 +190,13 @@ (defn update-text-rect! [id] (when wasm/context-initialized? - (mw/emit! - {:cmd :index/update-text-rect - :page-id (:current-page-id @st/state) - :shape-id id - :dimensions (get-text-dimensions id)}))) + (let [dimensions (get-text-dimensions id) + page-id (:current-page-id @st/state)] + (mw/emit! + {:cmd :index/update-text-rect + :page-id page-id + :shape-id id + :dimensions dimensions})))) (defn- ensure-text-content @@ -1564,7 +1566,7 @@ :text-decoration (get element :text-decoration) :letter-spacing (get element :letter-spacing) :font-style (get element :font-style) - :fills (get element :fills) + :fills (d/nilv (get element :fills) [{:fill-color "#000000"}]) :text text}))))))] (mem/free) diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index adcff410d2..48c3bda1c7 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1074,6 +1074,10 @@ impl Shape { self.children.first() } + pub fn children_count(&self) -> usize { + self.children_ids_iter(false).count() + } + pub fn children_ids(&self, include_hidden: bool) -> Vec { if include_hidden { return self.children.iter().rev().copied().collect(); diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index d0db679cf7..b9cc7201bc 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -264,7 +264,7 @@ fn propagate_transform( // If this is a layout and we're only moving don't need to reflow if shape.has_layout() && is_resize { - entries.push_back(Modifier::reflow(shape.id)); + entries.push_back(Modifier::reflow(shape.id, false)); } if let Some(parent) = shape.parent_id.and_then(|id| shapes.get(&id)) { @@ -272,7 +272,7 @@ fn propagate_transform( // if the current transformation is not a move propagation. // If it's a move propagation we don't need to reflow, the parent is already changed. if (parent.has_layout() || parent.is_group_like()) && (is_resize || !is_propagate) { - entries.push_back(Modifier::reflow(parent.id)); + entries.push_back(Modifier::reflow(parent.id, false)); } } } @@ -282,7 +282,7 @@ fn propagate_reflow( state: &State, entries: &mut VecDeque, bounds: &mut HashMap, - layout_reflows: &mut Vec, + layout_reflows: &mut HashSet, reflown: &mut HashSet, modifiers: &HashMap, ) { @@ -300,20 +300,7 @@ fn propagate_reflow( Type::Frame(Frame { layout: Some(_), .. }) => { - let mut skip_reflow = false; - if shape.is_layout_horizontal_fill() || shape.is_layout_vertical_fill() { - if let Some(parent_id) = shape.parent_id { - if parent_id != Uuid::nil() && !reflown.contains(&parent_id) { - // If this is a fill layout but the parent has not been reflown yet - // we wait for the next iteration for reflow - skip_reflow = true; - } - } - } - - if !skip_reflow { - layout_reflows.push(*id); - } + layout_reflows.insert(*id); } Type::Group(Group { masked: true }) => { let children_ids = shape.children_ids(true); @@ -340,7 +327,7 @@ fn propagate_reflow( if let Some(parent) = shape.parent_id.and_then(|id| shapes.get(&id)) { if parent.has_layout() || parent.is_group_like() { - entries.push_back(Modifier::reflow(parent.id)); + entries.push_back(Modifier::reflow(parent.id, false)); } } } @@ -382,19 +369,20 @@ pub fn propagate_modifiers( let mut entries: VecDeque<_> = modifiers .iter() .map(|entry| { - // If we receibe a identity matrix we force a reflow + // If we receive a identity matrix we force a reflow if math::identitish(&entry.transform) { - Modifier::Reflow(entry.id) + Modifier::Reflow(entry.id, false) } else { Modifier::Transform(*entry) } }) .collect(); + let shapes = &state.shapes; let mut modifiers = HashMap::::new(); let mut bounds = HashMap::::new(); let mut reflown = HashSet::::new(); - let mut layout_reflows = Vec::::new(); + let mut layout_reflows = HashSet::::new(); // We first propagate the transforms to the children and then after // recalculate the layouts. The layout can create further transforms that @@ -412,25 +400,43 @@ pub fn propagate_modifiers( &mut bounds, &mut modifiers, ), - Modifier::Reflow(id) => propagate_reflow( - &id, - state, - &mut entries, - &mut bounds, - &mut layout_reflows, - &mut reflown, - &modifiers, - ), + Modifier::Reflow(id, force_reflow) => { + if force_reflow { + reflown.remove(&id); + } + + propagate_reflow( + &id, + state, + &mut entries, + &mut bounds, + &mut layout_reflows, + &mut reflown, + &modifiers, + ) + }, } } - for id in layout_reflows.iter() { + let mut layout_reflows_vec: Vec = layout_reflows.into_iter().collect(); + + // We sort the reflows so they are process first the ones that are more + // deep in the tree structure. This way we can be sure that the children layouts + // are already reflowed. + layout_reflows_vec.sort_unstable_by(|id_a, id_b| { + let da = shapes.get_depth(id_a); + let db = shapes.get_depth(id_b); + db.cmp(&da) + }); + + let mut bounds_temp = bounds.clone(); + for id in layout_reflows_vec.iter() { if reflown.contains(id) { continue; } - reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds); + reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds_temp); } - layout_reflows = Vec::new(); + layout_reflows = HashSet::new(); } modifiers diff --git a/render-wasm/src/shapes/modifiers/flex_layout.rs b/render-wasm/src/shapes/modifiers/flex_layout.rs index 9742227833..6377379306 100644 --- a/render-wasm/src/shapes/modifiers/flex_layout.rs +++ b/render-wasm/src/shapes/modifiers/flex_layout.rs @@ -61,6 +61,7 @@ impl LayoutAxis { layout_data: &LayoutData, flex_data: &FlexData, ) -> Self { + let num_child = shape.children_count(); if flex_data.is_row() { Self { main_size: layout_bounds.width(), @@ -73,8 +74,8 @@ impl LayoutAxis { padding_across_end: layout_data.padding_bottom, gap_main: layout_data.column_gap, gap_across: layout_data.row_gap, - is_auto_main: shape.is_layout_horizontal_auto(), - is_auto_across: shape.is_layout_vertical_auto(), + is_auto_main: num_child > 0 && shape.is_layout_horizontal_auto(), + is_auto_across: num_child > 0 && shape.is_layout_vertical_auto(), } } else { Self { @@ -88,8 +89,8 @@ impl LayoutAxis { padding_across_end: layout_data.padding_right, gap_main: layout_data.row_gap, gap_across: layout_data.column_gap, - is_auto_main: shape.is_layout_vertical_auto(), - is_auto_across: shape.is_layout_horizontal_auto(), + is_auto_main: num_child > 0 && shape.is_layout_vertical_auto(), + is_auto_across: num_child > 0 && shape.is_layout_horizontal_auto(), } } } @@ -345,7 +346,10 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat let mut size = track.across_size - child.margin_across_start - child.margin_across_end; size = size.clamp(child.min_across_size, child.max_across_size); - size = f32::min(size, layout_axis.across_space()); + + if !layout_axis.is_auto_across { + size = f32::min(size, layout_axis.across_space()); + } child.across_size = size; } } @@ -620,9 +624,12 @@ pub fn reflow_flex_layout( let mut transform = Matrix::default(); + let mut force_reflow = false; if (new_width - child_bounds.width()).abs() > MIN_SIZE || (new_height - child_bounds.height()).abs() > MIN_SIZE { + // When the child is fill we need to force a reflow + force_reflow = true; transform.post_concat(&math::resize_matrix( layout_bounds, child_bounds, @@ -637,7 +644,7 @@ pub fn reflow_flex_layout( result.push_back(Modifier::transform_propagate(child.id, transform)); if child.has_layout() { - result.push_back(Modifier::reflow(child.id)); + result.push_back(Modifier::reflow(child.id, force_reflow)); } shape_anchor = next_anchor( diff --git a/render-wasm/src/shapes/modifiers/grid_layout.rs b/render-wasm/src/shapes/modifiers/grid_layout.rs index 3fe8e8f6bf..93e7ac571e 100644 --- a/render-wasm/src/shapes/modifiers/grid_layout.rs +++ b/render-wasm/src/shapes/modifiers/grid_layout.rs @@ -765,9 +765,12 @@ pub fn reflow_grid_layout( let mut transform = Matrix::default(); + let mut force_reflow = false; if (new_width - child_bounds.width()).abs() > MIN_SIZE || (new_height - child_bounds.height()).abs() > MIN_SIZE { + // When the child is a fill it needs to be reflown + force_reflow = true; transform.post_concat(&math::resize_matrix( &layout_bounds, &child_bounds, @@ -793,7 +796,7 @@ pub fn reflow_grid_layout( result.push_back(Modifier::transform_propagate(child.id, transform)); if child.has_layout() { - result.push_back(Modifier::reflow(child.id)); + result.push_back(Modifier::reflow(child.id, force_reflow)); } } diff --git a/render-wasm/src/shapes/transform.rs b/render-wasm/src/shapes/transform.rs index d6997599d8..61ed53e891 100644 --- a/render-wasm/src/shapes/transform.rs +++ b/render-wasm/src/shapes/transform.rs @@ -8,7 +8,7 @@ use skia::Matrix; #[derive(PartialEq, Debug, Clone)] pub enum Modifier { Transform(TransformEntry), - Reflow(Uuid), + Reflow(Uuid, bool), } impl Modifier { @@ -18,8 +18,8 @@ impl Modifier { pub fn parent(id: Uuid, transform: Matrix) -> Self { Modifier::Transform(TransformEntry::parent(id, transform)) } - pub fn reflow(id: Uuid) -> Self { - Modifier::Reflow(id) + pub fn reflow(id: Uuid, force_reflow: bool) -> Self { + Modifier::Reflow(id, force_reflow) } } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 6587a23de2..436d57f2ea 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -177,6 +177,26 @@ impl ShapesPoolImpl { } } + // Given an id, returns the depth in the tree-shaped structure + // of shapes. + pub fn get_depth(&self, id: &Uuid) -> usize { + if id == &Uuid::nil() { + return 0; + } + + let Some(idx) = self.uuid_to_idx.get(id) else { + return 0; + }; + + let shape = &self.shapes[*idx]; + + let Some(parent_id) = shape.parent_id else { + return 0; + }; + + self.get_depth(&parent_id) + 1 + } + #[allow(dead_code)] pub fn iter(&self) -> std::slice::Iter<'_, Shape> { self.shapes.iter() From fd3d549f9ce4f0248c7d1fe368fe79450879e5a7 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 5 Feb 2026 16:46:15 +0100 Subject: [PATCH 4/7] :sparkles: Batch text layout updates --- .../app/main/data/workspace/wasm_text.cljs | 39 +++++-- frontend/src/app/render_wasm/api.cljs | 8 +- frontend/src/app/render_wasm/api/fonts.cljs | 106 ++++++++++-------- render-wasm/src/main.rs | 8 ++ render-wasm/src/shapes/modifiers.rs | 2 +- render-wasm/src/wasm/fonts.rs | 17 +-- render-wasm/src/wasm/text.rs | 1 + 7 files changed, 111 insertions(+), 70 deletions(-) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index fd814b45f5..ee262123cf 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -10,6 +10,7 @@ This exists to avoid circular deps: workspace.texts -> workspace.libraries -> workspace.texts" (:require + [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] @@ -17,6 +18,7 @@ [app.main.data.helpers :as dsh] [app.main.data.workspace.modifiers :as dwm] [app.render-wasm.api :as wasm.api] + [app.render-wasm.api.fonts :as wasm.fonts] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -64,7 +66,7 @@ (defn resize-wasm-text-debounce-commit [] - (ptk/reify ::resize-wasm-text + (ptk/reify ::resize-wasm-text-debounce-commit ptk/WatchEvent (watch [_ state _] (let [ids (get state ::resize-wasm-text-debounce-ids) @@ -89,10 +91,10 @@ ;; are processed at the same time and not one-by-one. This will improve ;; performance because it's better to make only one layout calculation instead ;; of (potentialy) hundreds. -(defn resize-wasm-text-debounce +(defn resize-wasm-text-debounce-inner [id] (let [cur-event (js/Symbol)] - (ptk/reify ::resize-wasm-text-debounce + (ptk/reify ::resize-wasm-text-debounce-inner ptk/UpdateEvent (update [_ state] (-> state @@ -107,20 +109,39 @@ (rx/concat (rx/merge (->> stream - (rx/filter (ptk/type? ::resize-wasm-text-debounce)) - (rx/debounce 20) + (rx/filter (ptk/type? ::resize-wasm-text-debounce-inner)) + (rx/debounce 40) (rx/take 1) - (rx/delay 200) (rx/map #(resize-wasm-text-debounce-commit)) (rx/take-until stopper)) - - (rx/of (resize-wasm-text-debounce id))) - + (rx/of (resize-wasm-text-debounce-inner id))) (rx/of #(dissoc % ::resize-wasm-text-debounce-ids ::resize-wasm-text-debounce-event)))) (rx/empty)))))) +(defn resize-wasm-text-debounce + [id] + (ptk/reify ::resize-wasm-text-debounce + ptk/WatchEvent + (watch [_ state _] + (let [page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + content (dm/get-in objects [id :content]) + fonts (wasm.fonts/get-content-fonts content) + + fonts-loaded? + (->> fonts + (every? + (fn [font] + (let [font-data (wasm.fonts/make-font-data font)] + (wasm.fonts/font-stored? font-data (:emoji? font-data))))))] + + (if (not fonts-loaded?) + (->> (rx/of (resize-wasm-text-debounce id)) + (rx/delay 20)) + (rx/of (resize-wasm-text-debounce-inner id))))))) + (defn resize-wasm-text-all "Resize all text shapes (auto-width/auto-height) from a collection of ids." [ids] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index bbe548835c..64e509e7f5 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -867,12 +867,12 @@ (set-shape-vertical-align (get content :vertical-align)) - (let [fonts (f/get-content-fonts content) + (let [fonts (f/get-content-fonts content) fallback-fonts (fonts-from-text-content content true) - all-fonts (concat fonts fallback-fonts) - result (f/store-fonts shape-id all-fonts)] + all-fonts (concat fonts fallback-fonts) + result (f/store-fonts all-fonts)] (f/load-fallback-fonts-for-editor! fallback-fonts) - (h/call wasm/internal-module "_update_shape_text_layout") + (f/update-text-layout shape-id) result)) (defn set-shape-grow-type diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 0e939634f9..c520923f74 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -97,9 +97,8 @@ ;; IMPORTANT: Only TTF fonts can be stored. (defn- store-font-buffer - [shape-id font-data font-array-buffer emoji? fallback?] + [font-data font-array-buffer emoji? fallback?] (let [font-id-buffer (:family-id-buffer font-data) - shape-id-buffer (uuid/get-u32 shape-id) size (.-byteLength font-array-buffer) ptr (h/call wasm/internal-module "_alloc_bytes" size) heap (gobj/get ^js wasm/internal-module "HEAPU8") @@ -107,10 +106,6 @@ (.set mem (js/Uint8Array. font-array-buffer)) (h/call wasm/internal-module "_store_font" - (aget shape-id-buffer 0) - (aget shape-id-buffer 1) - (aget shape-id-buffer 2) - (aget shape-id-buffer 3) (aget font-id-buffer 0) (aget font-id-buffer 1) (aget font-id-buffer 2) @@ -119,24 +114,31 @@ (:style font-data) emoji? fallback?) - - (update-text-layout shape-id) - true)) +;; This variable will store the fonts that are currently being fetched +;; so we don't fetch more than once the same font +(def fetching (atom #{})) + (defn- fetch-font - [shape-id font-data font-url emoji? fallback?] - {:key font-url - :callback #(->> (http/send! {:method :get - :uri font-url - :response-type :buffer}) - (rx/map (fn [{:keys [body]}] - (store-font-buffer shape-id font-data body emoji? fallback?))) - (rx/catch (fn [cause] - (log/error :hint "Could not fetch font" - :font-url font-url - :cause cause) - (rx/empty))))}) + [font-data font-url emoji? fallback?] + (when-not (contains? @fetching font-url) + (swap! fetching conj font-url) + {:key font-url + :callback + (fn [] + (->> (http/send! {:method :get + :uri font-url + :response-type :buffer}) + (rx/map (fn [{:keys [body]}] + (swap! fetching disj font-url) + (store-font-buffer font-data body emoji? fallback?))) + (rx/catch (fn [cause] + (swap! fetching disj font-url) + (log/error :hint "Could not fetch font" + :font-url font-url + :cause cause) + (rx/empty)))))})) (defn- google-font-ttf-url [font-id font-variant-id font-weight font-style] @@ -155,22 +157,31 @@ :builtin (dm/str (u/join cf/public-uri "fonts/" asset-id)))) +(defn font-stored? + [font-data emoji?] + (when-let [id-buffer (uuid/get-u32 (:wasm-id font-data))] + (not= 0 (h/call wasm/internal-module "_is_font_uploaded" + (aget id-buffer 0) + (aget id-buffer 1) + (aget id-buffer 2) + (aget id-buffer 3) + (:weight font-data) + (:style font-data) + emoji?)))) + (defn- store-font-id - [shape-id font-data asset-id emoji? fallback?] + [font-data asset-id emoji? fallback?] (when asset-id - (let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data) (:weight font-data) (:style-name font-data)) + (let [uri (font-id->ttf-url + (:font-id font-data) asset-id + (:font-variant-id font-data) + (:weight font-data) + (:style-name font-data)) id-buffer (uuid/get-u32 (:wasm-id font-data)) font-data (assoc font-data :family-id-buffer id-buffer) - font-stored? (not= 0 (h/call wasm/internal-module "_is_font_uploaded" - (aget id-buffer 0) - (aget id-buffer 1) - (aget id-buffer 2) - (aget id-buffer 3) - (:weight font-data) - (:style font-data) - emoji?))] + font-stored? (font-stored? font-data emoji?)] (when-not font-stored? - (fetch-font shape-id font-data uri emoji? fallback?))))) + (fetch-font font-data uri emoji? fallback?))))) (defn serialize-font-style [font-style] @@ -280,8 +291,8 @@ "regular" font-variant-id)) -(defn store-font - [shape-id font] +(defn make-font-data + [font] (let [font-id (get font :font-id) font-variant-id (get font :font-variant-id) normalized-variant-id (when font-variant-id @@ -301,14 +312,21 @@ (str/includes? raw-weight "italic") "italic" :else font-style-fallback) variant-id (or (:id font-data) normalized-variant-id) - asset-id (font-id->asset-id font-id variant-id raw-weight style) - font-data {:wasm-id wasm-id - :font-id font-id - :font-variant-id variant-id - :style (serialize-font-style style) - :style-name style - :weight weight}] - (store-font-id shape-id font-data asset-id emoji? fallback?))) + asset-id (font-id->asset-id font-id variant-id raw-weight style)] + {:wasm-id wasm-id + :font-id font-id + :font-variant-id variant-id + :style (serialize-font-style style) + :style-name style + :weight weight + :emoji? emoji? + :fallbck? fallback? + :asset-id asset-id})) + +(defn store-font + [font] + (let [{:keys [asset-id emoji? fallback?] :as font-data} (make-font-data font)] + (store-font-id font-data asset-id emoji? fallback?))) ;; FIXME: This is a temporary function to load the fallback fonts for the editor. ;; Once we render the editor content within wasm, we can remove this function. @@ -341,8 +359,8 @@ #{})))) (defn store-fonts - [shape-id fonts] - (keep (fn [font] (store-font shape-id font)) fonts)) + [fonts] + (keep (fn [font] (store-font font)) fonts)) (defn add-emoji-font [fonts] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index b571aea098..e8aa0640f7 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -346,6 +346,14 @@ pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) { }); } +#[no_mangle] +pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) { + with_state_mut!(state, { + let shape_id = uuid_from_u32_quartet(a, b, c, d); + state.touch_shape(shape_id); + }); +} + #[no_mangle] pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) { with_state_mut!(state, { diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index b9cc7201bc..1af06713a7 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -414,7 +414,7 @@ pub fn propagate_modifiers( &mut reflown, &modifiers, ) - }, + } } } diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index 43d8d5e3f7..b4a604e0e5 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -31,21 +31,17 @@ impl From for FontStyle { #[no_mangle] pub extern "C" fn store_font( - a1: u32, - b1: u32, - c1: u32, - d1: u32, - a2: u32, - b2: u32, - c2: u32, - d2: u32, + a: u32, + b: u32, + c: u32, + d: u32, weight: u32, style: u8, is_emoji: bool, is_fallback: bool, ) { with_state_mut!(state, { - let id = uuid_from_u32_quartet(a2, b2, c2, d2); + let id = uuid_from_u32_quartet(a, b, c, d); let font_bytes = mem::bytes(); let font_style = RawFontStyle::from(style); @@ -57,9 +53,6 @@ pub extern "C" fn store_font( .add(family, &font_bytes, is_emoji, is_fallback); mem::free_bytes(); - - let shape_id = uuid_from_u32_quartet(a1, b1, c1, d1); - state.touch_shape(shape_id); }); } diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index df2e72f841..e4617575aa 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -384,6 +384,7 @@ pub extern "C" fn update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) { if let Some(shape) = state.shapes.get_mut(&shape_id) { update_text_layout(shape); } + state.touch_shape(shape_id); }); } From 7c7e32d85f4a7e941431dcd1deeb20c61d116c0a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 5 Feb 2026 13:51:36 +0100 Subject: [PATCH 5/7] :bug: Fix grid lines --- .../app/main/data/workspace/modifiers.cljs | 4 ++- .../app/main/ui/workspace/viewport_wasm.cljs | 4 ++- render-wasm/src/render/grid_layout.rs | 17 ++++++++--- render-wasm/src/render/ui.rs | 28 ++++++++++++++++++- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 717b2b03dd..c53fa6e58c 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -618,7 +618,9 @@ (extract-property-changes modif-tree)] (-> state (assoc :prev-wasm-props (:wasm-props state)) - (assoc :wasm-props property-changes)))) + (assoc :wasm-props property-changes) + ;; Keep overlays in sync during WASM transforms. + (assoc :workspace-modifiers modif-tree)))) ptk/WatchEvent (watch [_ state _] diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 3e516e923f..26bab1150d 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -98,6 +98,7 @@ drawing (mf/deref refs/workspace-drawing) focus (mf/deref refs/workspace-focus-selected) wasm-modifiers (mf/deref refs/workspace-wasm-modifiers) + modifiers (mf/deref refs/workspace-modifiers) workspace-editor-state (mf/deref refs/workspace-editor-state) @@ -704,7 +705,8 @@ (when show-grid-editor? [:& grid-layout/editor {:zoom zoom - :objects objects-modified + :objects base-objects + :modifiers modifiers :shape (or (get base-objects edition) (get base-objects @hover-top-frame-id)) :view-only (not show-grid-editor?)}])] diff --git a/render-wasm/src/render/grid_layout.rs b/render-wasm/src/render/grid_layout.rs index c319e5410f..699aea8cde 100644 --- a/render-wasm/src/render/grid_layout.rs +++ b/render-wasm/src/render/grid_layout.rs @@ -1,21 +1,30 @@ use skia_safe::{self as skia}; -use crate::math::Rect; use crate::shapes::modifiers::grid_layout::grid_cell_data; use crate::shapes::Shape; use crate::state::ShapesPoolRef; pub fn render_overlay(zoom: f32, canvas: &skia::Canvas, shape: &Shape, shapes: ShapesPoolRef) { - let cells = grid_cell_data(shape, shapes, true); + let cells: Vec> = grid_cell_data(shape, shapes, true); + let bounds = shape.bounds(); let mut paint = skia::Paint::default(); paint.set_style(skia::PaintStyle::Stroke); paint.set_color(skia::Color::from_rgb(255, 111, 224)); + paint.set_anti_alias(shape.should_use_antialias(zoom)); paint.set_stroke_width(1.0 / zoom); for cell in cells.iter() { - let rect = Rect::from_xywh(cell.anchor.x, cell.anchor.y, cell.width, cell.height); - canvas.draw_rect(rect, &paint); + let hv = bounds.hv(cell.width); + let vv = bounds.vv(cell.height); + let points = [ + cell.anchor, + cell.anchor + hv, + cell.anchor + hv + vv, + cell.anchor + vv, + ]; + let polygon = skia::Path::polygon(&points, true, None, None); + canvas.draw_path(&polygon, &paint); } } diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index 3f662bc009..97c8dd4867 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -2,6 +2,7 @@ use skia_safe::{self as skia, Color4f}; use super::{RenderState, ShapesPoolRef, SurfaceId}; use crate::render::grid_layout; +use crate::shapes::{Layout, Type}; pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { let canvas = render_state.surfaces.canvas(SurfaceId::UI); @@ -18,12 +19,37 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { let canvas = render_state.surfaces.canvas(SurfaceId::UI); - if let Some(id) = render_state.show_grid { + let show_grid_id = render_state.show_grid; + + if let Some(id) = show_grid_id { if let Some(shape) = shapes.get(&id) { grid_layout::render_overlay(zoom, canvas, shape, shapes); } } + // Render overlays for empty grid frames + for shape in shapes.iter() { + if shape.id.is_nil() || !shape.children.is_empty() { + continue; + } + + if show_grid_id == Some(shape.id) { + continue; + } + + let Type::Frame(frame) = &shape.shape_type else { + continue; + }; + + if !matches!(frame.layout, Some(Layout::GridLayout(_, _))) { + continue; + } + + if let Some(shape) = shapes.get(&shape.id) { + grid_layout::render_overlay(zoom, canvas, shape, shapes); + } + } + canvas.restore(); render_state.surfaces.draw_into( SurfaceId::UI, From af5dbf2fbcde189412cb9dcaa220a835056b362a Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 5 Feb 2026 17:24:43 +0100 Subject: [PATCH 6/7] :bug: Set objects modified instead of modif-tree --- frontend/src/app/main/data/workspace/modifiers.cljs | 4 +--- frontend/src/app/main/ui/workspace/viewport_wasm.cljs | 8 +++----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index c53fa6e58c..717b2b03dd 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -618,9 +618,7 @@ (extract-property-changes modif-tree)] (-> state (assoc :prev-wasm-props (:wasm-props state)) - (assoc :wasm-props property-changes) - ;; Keep overlays in sync during WASM transforms. - (assoc :workspace-modifiers modif-tree)))) + (assoc :wasm-props property-changes)))) ptk/WatchEvent (watch [_ state _] diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 26bab1150d..c86d80f998 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -98,7 +98,6 @@ drawing (mf/deref refs/workspace-drawing) focus (mf/deref refs/workspace-focus-selected) wasm-modifiers (mf/deref refs/workspace-wasm-modifiers) - modifiers (mf/deref refs/workspace-modifiers) workspace-editor-state (mf/deref refs/workspace-editor-state) @@ -705,10 +704,9 @@ (when show-grid-editor? [:& grid-layout/editor {:zoom zoom - :objects base-objects - :modifiers modifiers - :shape (or (get base-objects edition) - (get base-objects @hover-top-frame-id)) + :objects objects-modified + :shape (or (get objects-modified edition) + (get objects-modified @hover-top-frame-id)) :view-only (not show-grid-editor?)}])] [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} From a7c1de647888b95f7f5c82b51b156aa4d0420d34 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 2 Feb 2026 17:13:53 +0100 Subject: [PATCH 7/7] :bug: Fix lazy load intersection on dragging at the beginning --- .../main/ui/workspace/sidebar/layer_item.cljs | 22 +++++++++---------- .../main/ui/workspace/sidebar/layer_item.scss | 4 ++++ .../app/main/ui/workspace/sidebar/layers.cljs | 3 +-- 3 files changed, 16 insertions(+), 13 deletions(-) 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 d65b1cbfc8..e295cdad91 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -302,7 +302,7 @@ on-drop (mf/use-fn - (mf/deps id index objects expanded? selected) + (mf/deps id objects expanded? selected) (fn [side _data] (let [single? (= (count selected) 1) same? (and single? (= (first selected) id))] @@ -323,14 +323,18 @@ [parent-id _] (ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files) - parent (get objects parent-id) + 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)) - (= side :top) (inc index) - :else index)] - (st/emit! (dw/relocate-selected-shapes parent-id to-index))))))) + ;; 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 @@ -419,11 +423,7 @@ current @children-count* new-count (min total (max current chunk-size min-count))] (reset! children-count* new-count)) - (reset! children-count* 0))) - (fn [] - (when-let [obs ^js @observer-var] - (.disconnect obs) - (reset! observer-var nil)))) + (reset! children-count* 0)))) ;; Re-observe sentinel whenever children-count changes (sentinel moves) ;; and (shapes item) to reconnect observer after shape changes @@ -504,4 +504,4 @@ :component-child? component-tree?}]))) (when (< children-count (count (:shapes item))) [:div {:ref lazy-ref - :style {:min-height 1}}])])])) + :class (stl/css :lazy-load-sentinel)}])])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss index 43455c1bd0..749f25bf61 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss @@ -270,3 +270,7 @@ .filtered { min-width: deprecated.$s-12; } +.lazy-load-sentinel { + min-height: 1px; + pointer-events: none; +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 398473f54c..dabe7aae77 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -522,8 +522,7 @@ [:& filters-tree {:objects filtered-objects :key (dm/str (:id page)) :parent-size size-parent}] - [:div {:ref lazy-load-ref - :style {:min-height 16}}]] + [:div {:ref lazy-load-ref}]] [:div {:on-scroll on-scroll :class (stl/css :tool-window-content) :data-scroll-container true