From cfe11a930cc9abda1bedc540d053cc120549a0da Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 2 Mar 2026 09:29:13 +0100 Subject: [PATCH 01/26] :bug: Fix frame clipping artifact --- render-wasm/src/render.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1493b9851e..66ab0af689 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -745,16 +745,17 @@ impl RenderState { s.canvas().concat(transform); }); + // Hard clip edge (antialias = false) to avoid alpha seam when clipping + // semi-transparent content larger than the frame. if let Some(corners) = corners { let rrect = RRect::new_rect_radii(*bounds, corners); self.surfaces.apply_mut(surface_ids, |s| { - s.canvas() - .clip_rrect(rrect, skia::ClipOp::Intersect, antialias); + s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false); }); } else { self.surfaces.apply_mut(surface_ids, |s| { s.canvas() - .clip_rect(*bounds, skia::ClipOp::Intersect, antialias); + .clip_rect(*bounds, skia::ClipOp::Intersect, false); }); } From 585a2d75235f5a4cf94b5aeef34c121313baef4e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 2 Mar 2026 13:35:36 +0100 Subject: [PATCH 02/26] :bug: Fix merge issues --- .../app/main/data/workspace/tokens/application.cljs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 95b0a2cf2c..8bd421391a 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -681,12 +681,12 @@ (if (rx/observable? res) res (rx/of res)))) - (rx/of (dwu/commit-undo-transaction undo-id)))))))))) + (rx/of (dwu/commit-undo-transaction undo-id))))))))) - (rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition") - :type :toast - :level :warning - :timeout 3000})))))) + (rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition") + :type :toast + :level :warning + :timeout 3000}))))))) (defn apply-spacing-token-separated "Handles edge-case for spacing token when applying token via toggle button. From 9fa027c1df5f00fa24d2c1152a7dca6efe34f939 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 3 Mar 2026 08:32:28 +0100 Subject: [PATCH 03/26] :bug: Fix blur affecting extra shapes --- ...t-file-blurs-affecting-other-elements.json | 3583 +++++++++++++++++ .../ui/render-wasm-specs/shapes.spec.js | 20 + ...551---Blurs-affecting-other-elements-1.png | Bin 0 -> 11562 bytes render-wasm/src/render.rs | 10 +- 4 files changed, 3611 insertions(+), 2 deletions(-) create mode 100644 frontend/playwright/data/render-wasm/get-file-blurs-affecting-other-elements.json create mode 100644 frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13551---Blurs-affecting-other-elements-1.png diff --git a/frontend/playwright/data/render-wasm/get-file-blurs-affecting-other-elements.json b/frontend/playwright/data/render-wasm/get-file-blurs-affecting-other-elements.json new file mode 100644 index 0000000000..21aba9692b --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-blurs-affecting-other-elements.json @@ -0,0 +1,3583 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~ueba8fa2e-4140-8084-8005-448635d7a724", + "~: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": "Bad blur bad blur", + "~:revn": 127, + "~:modified-at": "~m1772523623921", + "~:vern": 0, + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc677169cd", + "~: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", + "0004-clean-shadow-color", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "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": "~ueba8fa2e-4140-8084-8005-448635da32b4", + "~:created-at": "~m1772519242179", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~ua5508528-5928-8008-8007-a7de9feef61b" + ], + "~:pages-index": { + "~ua5508528-5928-8008-8007-a7de9feef61b": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 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, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 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, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~ua5508528-5928-8008-8007-a7e03d2ac912", + "~ua5508528-5928-8008-8007-a7e0e62b1820" + ] + } + }, + "~ua5508528-5928-8008-8007-a7e03d2ac912": { + "~#shape": { + "~:y": 470, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 233, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 408, + "~:y": 470 + } + }, + { + "~#point": { + "~:x": 641, + "~:y": 470 + } + }, + { + "~#point": { + "~:x": 641, + "~:y": 628 + } + }, + { + "~#point": { + "~:x": 408, + "~:y": 628 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ua5508528-5928-8008-8007-a7e03d2ac912", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 408, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 408, + "~:y": 470, + "~:width": 233, + "~:height": 158, + "~:x1": 408, + "~:y1": 470, + "~:x2": 641, + "~:y2": 628 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 158, + "~:flip-y": null + } + }, + "~ua5508528-5928-8008-8007-a7e0e62b1820": { + "~#shape": { + "~:y": 457, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 557, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 771, + "~:y": 457 + } + }, + { + "~#point": { + "~:x": 1328, + "~:y": 457 + } + }, + { + "~#point": { + "~:x": 1328, + "~:y": 781 + } + }, + { + "~#point": { + "~:x": 771, + "~:y": 781 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:blur": { + "~:id": "~ua5508528-5928-8008-8007-a7e0ef6b5783", + "~:type": "~:layer-blur", + "~:value": 4, + "~:hidden": false + }, + "~:r1": 0, + "~:id": "~ua5508528-5928-8008-8007-a7e0e62b1820", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 771, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 771, + "~:y": 457, + "~:width": 557, + "~:height": 324, + "~:x1": 771, + "~:y1": 457, + "~:x2": 1328, + "~:y2": 781 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 324, + "~:flip-y": null, + "~:shapes": [ + "~ua5508528-5928-8008-8007-a7e0e89a5a24" + ] + } + }, + "~ua5508528-5928-8008-8007-a7e0e89a5a24": { + "~#shape": { + "~:y": 496.000003814697, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": true, + "~:name": "Board", + "~:width": 212.000012099743, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 838.000027060509, + "~:y": 496.000003814697 + } + }, + { + "~#point": { + "~:x": 1050.00003916025, + "~:y": 496.000003814697 + } + }, + { + "~#point": { + "~:x": 1050.00003916025, + "~:y": 619.000005245209 + } + }, + { + "~#point": { + "~:x": 838.000027060509, + "~:y": 619.000005245209 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ua5508528-5928-8008-8007-a7e0e89a5a24", + "~:parent-id": "~ua5508528-5928-8008-8007-a7e0e62b1820", + "~:frame-id": "~ua5508528-5928-8008-8007-a7e0e62b1820", + "~:strokes": [], + "~:x": 838.000027060509, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 838.000027060509, + "~:y": 496.000003814697, + "~:width": 212.000012099743, + "~:height": 123.000001430511, + "~:x1": 838.000027060509, + "~:y1": 496.000003814697, + "~:x2": 1050.00003916025, + "~:y2": 619.000005245209 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 123.000001430511, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:id": "~ua5508528-5928-8008-8007-a7de9feef61b", + "~:name": "Page 1" + } + }, + "~:tokens-lib": { + "~#penpot/tokens-lib": { + "~:sets": { + "~#ordered-map": [ + [ + "S-Global", + { + "~#penpot/token-set": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781f1b3", + "~:name": "Global", + "~:description": "", + "~:modified-at": "~m1772519242247", + "~:tokens": { + "~#ordered-map": [ + [ + "COLOR-2", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc67817448", + "~:name": "COLOR-2", + "~:type": "~:color", + "~:value": "rgb(0, 239, 255)", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "SIZING-2", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc67817449", + "~:name": "SIZING-2", + "~:type": "~:sizing", + "~:value": "2", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "DIMENSIONS-1", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744a", + "~:name": "DIMENSIONS-1", + "~:type": "~:dimensions", + "~:value": "10", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "SIZING-0.5", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744f", + "~:name": "SIZING-0.5", + "~:type": "~:sizing", + "~:value": "0.5", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "ROTATION-60", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744b", + "~:name": "ROTATION-60", + "~:type": "~:rotation", + "~:value": "60", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "LETTER-SPACING-10", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744c", + "~:name": "LETTER-SPACING-10", + "~:type": "~:letter-spacing", + "~:value": "10", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "OPACITY-40", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744d", + "~:name": "OPACITY-40", + "~:type": "~:opacity", + "~:value": "40%", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "OPACITY-20", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744e", + "~:name": "OPACITY-20", + "~:type": "~:opacity", + "~:value": "20%", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "SPACING-20", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc67817450", + "~:name": "SPACING-20", + "~:type": "~:spacing", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "BORDER-RADIUS-3", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc67817451", + "~:name": "BORDER-RADIUS-3", + "~:type": "~:border-radius", + "~:value": "30", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "SPACING-10", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc67817452", + "~:name": "SPACING-10", + "~:type": "~:spacing", + "~:value": "10", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "font-family-3", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186d5", + "~:name": "font-family-3", + "~:type": "~:font-family", + "~:value": [ + "Alexandria" + ], + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "FONT-SIZE-150", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186d6", + "~:name": "FONT-SIZE-150", + "~:type": "~:font-size", + "~:value": "150", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "NUMBER-16", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186d7", + "~:name": "NUMBER-16", + "~:type": "~:number", + "~:value": "16", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "OPACITY-60", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186d8", + "~:name": "OPACITY-60", + "~:type": "~:opacity", + "~:value": "0.6", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "NUMBER-4", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186d9", + "~:name": "NUMBER-4", + "~:type": "~:number", + "~:value": "4", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "font-family-2", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186da", + "~:name": "font-family-2", + "~:type": "~:font-family", + "~:value": [ + "Abel" + ], + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "SIZING-4", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186db", + "~:name": "SIZING-4", + "~:type": "~:sizing", + "~:value": "4", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "COLOR-1", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186dc", + "~:name": "COLOR-1", + "~:type": "~:color", + "~:value": "rgb(255, 0, 0)", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "BORDER-RADIUS-2", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186dd", + "~:name": "BORDER-RADIUS-2", + "~:type": "~:border-radius", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "BORDER-RADIUS-1", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186de", + "~:name": "BORDER-RADIUS-1", + "~:type": "~:border-radius", + "~:value": "10", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "NUMBER-8", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186df", + "~:name": "NUMBER-8", + "~:type": "~:number", + "~:value": "8", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "LETTER-SPACING-30", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e0", + "~:name": "LETTER-SPACING-30", + "~:type": "~:letter-spacing", + "~:value": "30", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "SPACING-5", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e1", + "~:name": "SPACING-5", + "~:type": "~:spacing", + "~:value": "5", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "font-family-1", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e2", + "~:name": "font-family-1", + "~:type": "~:font-family", + "~:value": [ + "ABeeZee" + ], + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "LETTER-SPACING-20", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e3", + "~:name": "LETTER-SPACING-20", + "~:type": "~:letter-spacing", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "FONT-SIZE-100", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e4", + "~:name": "FONT-SIZE-100", + "~:type": "~:font-size", + "~:value": "100", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "FONT-SIZE-40", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e5", + "~:name": "FONT-SIZE-40", + "~:type": "~:font-size", + "~:value": "40", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "ROTATION-30", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e6", + "~:name": "ROTATION-30", + "~:type": "~:rotation", + "~:value": "30", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "ROTATION-15", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e7", + "~:name": "ROTATION-15", + "~:type": "~:rotation", + "~:value": "15", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "COLOR-3", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e8", + "~:name": "COLOR-3", + "~:type": "~:color", + "~:value": "rgb(0, 255, 4)", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "DIMENSIONS-3", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e9", + "~:name": "DIMENSIONS-3", + "~:type": "~:dimensions", + "~:value": "30", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "DIMENSIONS-2", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186ea", + "~:name": "DIMENSIONS-2", + "~:type": "~:dimensions", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ] + ] + } + } + } + ] + ] + }, + "~:themes": { + "~#ordered-map": [ + [ + "", + { + "~#ordered-map": [ + [ + "__PENPOT__HIDDEN__TOKEN__THEME__", + { + "~#penpot/token-theme": { + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:name": "__PENPOT__HIDDEN__TOKEN__THEME__", + "~:group": "", + "~:description": "", + "~:is-source": false, + "~:external-id": "", + "~:modified-at": "~m1772519242248", + "~:sets": { + "~#set": [ + "Global" + ] + } + } + } + ] + ] + } + ] + ] + }, + "~:active-themes": { + "~#set": [ + "/__PENPOT__HIDDEN__TOKEN__THEME__" + ] + } + } + }, + "~:components": { + "~uade8229e-4891-80f7-8007-a6c641aa24c2": { + "~:path": "Modal / actions", + "~:deleted": true, + "~:main-instance-id": "~uade8229e-4891-80f7-8007-a6c641a6a32c", + "~:objects": { + "~uade8229e-4891-80f7-8007-a6c641a6a32f": { + "~#shape": { + "~:y": 1251.30205598607, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:layout-item-hsizing": "fill", + "~:layout-padding": { + "~:p1": 5.6843418860808e-14, + "~:p2": 0, + "~:p3": 5.6843418860808e-14, + "~:p4": 0 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "description", + "~:layout-align-items": "~:start", + "~:width": 465, + "~:layout-padding-type": "~:simple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1251.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1251.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1271.30205598607 + } + }, + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1271.30205598607 + } + } + ], + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:layout-justify-content": "~:start", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a32f", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:layout-flex-dir": "~:column", + "~:layout-align-content": "~:stretch", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:strokes": [], + "~:x": 931.380862910156, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 931.380862910156, + "~:y": 1251.30205598607, + "~:width": 465, + "~:height": 20.0000000000005, + "~:x1": 931.380862910156, + "~:y1": 1251.30205598607, + "~:x2": 1396.38086291016, + "~:y2": 1271.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 20.0000000000005, + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a339" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a32e": { + "~#shape": { + "~:y": 1287.30205598607, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:layout-item-hsizing": "auto", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "Board", + "~:layout-align-items": "~:start", + "~:width": 196, + "~:layout-padding-type": "~:simple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1200.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1327.30205598607 + } + }, + { + "~#point": { + "~:x": 1200.38086291016, + "~:y": 1327.30205598607 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:column-gap": 12, + "~:row-gap": 12 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:r3": 0, + "~:layout-justify-content": "~:end", + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:layout-flex-dir": "~:row-reverse", + "~:applied-tokens": { + "~:column-gap": "xx.alias.spacing.sm", + "~:row-gap": "xx.alias.spacing.sm" + }, + "~:layout-align-content": "~:stretch", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:strokes": [], + "~:x": 1200.38086291016, + "~:proportion": 1, + "~:r4": 0, + "~:layout-item-align-self": "~:end", + "~:selrect": { + "~#rect": { + "~:x": 1200.38086291016, + "~:y": 1287.30205598607, + "~:width": 196, + "~:height": 40, + "~:x1": 1200.38086291016, + "~:y1": 1287.30205598607, + "~:x2": 1396.38086291016, + "~:y2": 1327.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 40, + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a331", + "~uade8229e-4891-80f7-8007-a6c641a6a332" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a32d": { + "~#shape": { + "~:y": 1207.18039402734, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:layout-item-hsizing": "fill", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "content", + "~:layout-align-items": "~:start", + "~:width": 465, + "~:layout-padding-type": "~:simple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1207.18039402734 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1207.18039402734 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1327.30205598607 + } + }, + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1327.30205598607 + } + } + ], + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:column-gap": 16, + "~:row-gap": 16 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:layout-justify-content": "~:start", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32c", + "~:layout-flex-dir": "~:column", + "~:applied-tokens": { + "~:column-gap": "xx.alias.spacing.md", + "~:row-gap": "xx.alias.spacing.md" + }, + "~:layout-align-content": "~:stretch", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32c", + "~:strokes": [], + "~:x": 931.380862910156, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 931.380862910156, + "~:y": 1207.18039402734, + "~:width": 465, + "~:height": 120.121661958726, + "~:x1": 931.380862910156, + "~:y1": 1207.18039402734, + "~:x2": 1396.38086291016, + "~:y2": 1327.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 120.121661958726, + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~uade8229e-4891-80f7-8007-a6c641a6a32f", + "~uade8229e-4891-80f7-8007-a6c641a6a330" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a32c": { + "~#shape": { + "~:y": 1183.18039402734, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:rx": 20, + "~:layout-item-hsizing": "fix", + "~:layout-padding": { + "~:p2": 24, + "~:p4": 24, + "~:p3": 24, + "~:p1": 24 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "Modal / actions / Destructive", + "~:layout-align-items": "~:center", + "~:width": 513, + "~:layout-padding-type": "~:multiple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 907.380862910156, + "~:y": 1183.18039402734 + } + }, + { + "~#point": { + "~:x": 1420.38086291016, + "~:y": 1183.18039402734 + } + }, + { + "~#point": { + "~:x": 1420.38086291016, + "~:y": 1351.30205598607 + } + }, + { + "~#point": { + "~:x": 907.380862910156, + "~:y": 1351.30205598607 + } + } + ], + "~:r2": 8, + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:column-gap": 16, + "~:row-gap": 16 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:r3": 8, + "~:layout-justify-content": "~:start", + "~:r1": 8, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a32c", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:layout-flex-dir": "~:row", + "~:applied-tokens": { + "~:p2": "xx.alias.spacing.lg", + "~:p4": "xx.alias.spacing.lg", + "~:p3": "xx.alias.spacing.lg", + "~:stroke-color": "xx.alias.color.background.lowEmphasis", + "~:fill": "xx.alias.color.background.body", + "~:r2": "xx.alias.border.radius.md", + "~:p1": "xx.alias.spacing.lg", + "~:column-gap": "xx.alias.spacing.md", + "~:r3": "xx.alias.border.radius.md", + "~:r1": "xx.alias.border.radius.md", + "~:r4": "xx.alias.border.radius.md", + "~:row-gap": "xx.alias.spacing.md" + }, + "~:layout-align-content": "~:stretch", + "~:component-id": "~uade8229e-4891-80f7-8007-a6c641aa24c2", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1, + "~:stroke-color": "#dcdcdd", + "~:stroke-opacity": 1 + } + ], + "~:x": 907.380862910156, + "~:main-instance": true, + "~:proportion": 1, + "~:shadow": [ + { + "~:color": { + "~:opacity": 0.11, + "~:color": "#18141f" + }, + "~:spread": 0, + "~:offset-y": 16, + "~:style": "~:drop-shadow", + "~:blur": 36, + "~:hidden": false, + "~:id": "~u3fc22407-7a7d-80f6-8005-a2e2d45449f1", + "~:offset-x": 0 + }, + { + "~:color": { + "~:opacity": 0.06, + "~:color": "#18141f" + }, + "~:spread": 0, + "~:offset-y": 0, + "~:style": "~:drop-shadow", + "~:blur": 2, + "~:hidden": false, + "~:id": "~u3fc22407-7a7d-80f6-8005-a2e2aecba2f2", + "~:offset-x": 0 + } + ], + "~:r4": 8, + "~:selrect": { + "~#rect": { + "~:x": 907.380862910156, + "~:y": 1183.18039402734, + "~:width": 513, + "~:height": 168.121661958726, + "~:x1": 907.380862910156, + "~:y1": 1183.18039402734, + "~:x2": 1420.38086291016, + "~:y2": 1351.30205598607 + } + }, + "~:fills": [ + { + "~:fill-color": "#f3f3f4", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 20, + "~:height": 168.121661958726, + "~:component-file": "~ueffcbebc-b8c8-802f-8007-a7dc677169cd", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a32d" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a33c": { + "~#shape": { + "~:y": null, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAABmU6xErMGXRAMAAADnRqxELbWXRKQyrEQttZdEJSasRKzBl0QDAAAApRmsRCzOl0SlGaxEb+KXRCUmrETu7pdEAgAAAAAAAAAAAAAAAAAAAAAAAADv3qxEuKeYRAIAAAAAAAAAAAAAAAAAAAAAAAAAJSasRIJgmUQDAAAAphmsRAFtmUSmGaxERYGZRCUmrETEjZlEAwAAAKQyrERDmplE50asREOamURmU6xExI2ZRAIAAAAAAAAAAAAAAAAAAAAAAAAAMAytRPnUmEQCAAAAAAAAAAAAAAAAAAAAAAAAAPvErUTEjZlEAwAAAHrRrURDmplEveWtREOamUQ88q1ExI2ZRAMAAAC7/q1ERYGZRLv+rUQBbZlEPPKtRIJgmUQCAAAAAAAAAAAAAAAAAAAAAAAAAHE5rUS4p5hEAgAAAAAAAAAAAAAAAAAAAAAAAAA98q1E7u6XRAMAAAC8/q1Eb+KXRLz+rUQszpdEPfKtRKzBl0QDAAAAvuWtRC21l0R60a1ELbWXRPvErUSswZdEAgAAAAAAAAAAAAAAAAAAAAAAAAAwDK1Ed3qYRAIAAAAAAAAAAAAAAAAAAAAAAAAAZlOsRKzBl0QEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "~:name": "svg-path", + "~:width": null, + "~:type": "~:path", + "~:svg-attrs": { + + }, + "~:touched": { + "~#set": [ + "~:geometry-group", + "~:content-group", + "~:fill-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1376.89905291016, + "~:y": 1213.75941500671 + } + }, + { + "~#point": { + "~:x": 1391.86273791016, + "~:y": 1213.75941500671 + } + }, + { + "~#point": { + "~:x": 1391.86273791016, + "~:y": 1228.72300000671 + } + }, + { + "~#point": { + "~:x": 1376.89905291016, + "~:y": 1228.72300000671 + } + } + ], + "~:shape-ref": "~u5b5dd81f-49d7-8083-8005-9f14d5aadf2a", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:constraints-v": "~:scale", + "~:svg-transform": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + }, + "~:constraints-h": "~:scale", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a33c", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a33a", + "~:svg-viewbox": { + "~:y": 4.51819, + "~:y1": 4.51819, + "~:width": 14.963685, + "~:x": 4.51819, + "~:x1": 4.51819, + "~:y2": 19.481775, + "~:x2": 19.481875, + "~:height": 14.963585 + }, + "~:applied-tokens": { + "~:fill": "xx.alias.color.border.heavy" + }, + "~:svg-defs": { + + }, + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a33a", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-color": "#8b898f", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1 + } + ], + "~:x": null, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1376.89905291016, + "~:y": 1213.75941500671, + "~:width": 14.9636849999988, + "~:height": 14.9635850000004, + "~:x1": 1376.89905291016, + "~:y1": 1213.75941500671, + "~:x2": 1391.86273791016, + "~:y2": 1228.72300000671 + } + }, + "~:fills": [ + { + "~:fill-color": "#49454e", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a33b": { + "~#shape": { + "~:y": 1208.24122500671, + "~:layout-item-hsizing": "fill", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-height", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.3", + "~:path": "font-screen-lg / headline", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.3", + "~:path": "font-screen-lg / headline", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:font-size": "20", + "~:font-weight": "400", + "~:modified-at": "2025-01-24T18:57:33.017Z", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans", + "~:text": "Title" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:key": "daugc", + "~:font-size": "20", + "~:font-weight": "400", + "~:type": "paragraph", + "~:modified-at": "2025-01-24T18:57:33.017Z", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans" + } + ] + } + ], + "~:fills": [] + }, + "~:hide-in-viewer": false, + "~:name": "Title", + "~:width": 441, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1208.24122500671 + } + }, + { + "~#point": { + "~:x": 1372.38086291016, + "~:y": 1208.24122500671 + } + }, + { + "~#point": { + "~:x": 1372.38086291016, + "~:y": 1234.24122500671 + } + }, + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1234.24122500671 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a33b", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a330", + "~:applied-tokens": { + "~:fill": "xx.alias.color.text.emphasis" + }, + "~:position-data": [ + { + "~:y": 1234.26123046875, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "20", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 38.8800048828125, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:x": 931.300842285156, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "DM Sans", + "~:height": 26.0400390625, + "~:text": "Title" + } + ], + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a330", + "~:strokes": [], + "~:x": 931.380862910156, + "~:selrect": { + "~#rect": { + "~:x": 931.380862910156, + "~:y": 1208.24122500671, + "~:width": 441, + "~:height": 26, + "~:x1": 931.380862910156, + "~:y1": 1208.24122500671, + "~:x2": 1372.38086291016, + "~:y2": 1234.24122500671 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 26, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a33a": { + "~#shape": { + "~:y": 1209.24122500671, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "Icons / 24 / close", + "~:width": 24, + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:geometry-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1372.38086291016, + "~:y": 1209.24122500671 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1209.24122500671 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1233.24122500671 + } + }, + { + "~#point": { + "~:x": 1372.38086291016, + "~:y": 1233.24122500671 + } + } + ], + "~:shape-ref": "~u5b5dd81f-49d7-8083-8005-9f14d5aadf0e", + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a33a", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a330", + "~:applied-tokens": { + + }, + "~:component-id": "~u5b5dd81f-49d7-8083-8005-9f14d5abeaac", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a330", + "~:strokes": [], + "~:x": 1372.38086291016, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1372.38086291016, + "~:y": 1209.24122500671, + "~:width": 24, + "~:height": 24, + "~:x1": 1372.38086291016, + "~:y1": 1209.24122500671, + "~:x2": 1396.38086291016, + "~:y2": 1233.24122500671 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 24, + "~:component-file": "~u29d026bf-3f3f-8055-8006-518212e12739", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a33c" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a339": { + "~#shape": { + "~:y": 1251.30205598607, + "~:layout-item-hsizing": "fill", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-height", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.25", + "~:path": "font-screen-lg / hyperlink", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.25", + "~:path": "font-screen-lg / hyperlink", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:font-size": "16", + "~:font-weight": "400", + "~:modified-at": "2025-01-24T18:57:33.140Z", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:fills": [ + { + "~:fill-color": "#747279", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans", + "~:text": "Notification description text" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:key": "daugc", + "~:font-size": "16", + "~:font-weight": "400", + "~:type": "paragraph", + "~:modified-at": "2025-01-24T18:57:33.140Z", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:fills": [ + { + "~:fill-color": "#747279", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans" + } + ] + } + ], + "~:vertical-align": "center", + "~:fills": [] + }, + "~:hide-in-viewer": false, + "~:name": "Notification description text", + "~:width": 465, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1251.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1251.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1271.30205598607 + } + }, + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1271.30205598607 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a339", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32f", + "~:applied-tokens": { + "~:fill": "xx.alias.color.text.subtle" + }, + "~:position-data": [ + { + "~:y": 1271.72204589844, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "16", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 203.309997558594, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:x": 931.300842285156, + "~:fills": [ + { + "~:fill-color": "#747279", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "DM Sans", + "~:height": 20.840087890625, + "~:text": "Notification description text" + } + ], + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32f", + "~:strokes": [], + "~:x": 931.380862910156, + "~:selrect": { + "~#rect": { + "~:x": 931.380862910156, + "~:y": 1251.30205598607, + "~:width": 465, + "~:height": 20, + "~:x1": 931.380862910156, + "~:y1": 1251.30205598607, + "~:x2": 1396.38086291016, + "~:y2": 1271.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 20, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a338": { + "~#shape": { + "~:y": null, + "~:stroke-cap-start": "round", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAACGYaxEAP+iRAIAAAAAAAAAAAAAAAAAAAAAAAAA27atRAD/okQBAAAAAAAAAAAAAAAAAAAAAAAAAIbhrERVVKNEAgAAAAAAAAAAAAAAAAAAAAAAAACG4axEVdSjRAEAAAAAAAAAAAAAAAAAAAAAAAAA2zatRFVUo0QCAAAAAAAAAAAAAAAAAAAAAAAAANs2rURV1KNEAQAAAAAAAAAAAAAAAAAAAAAAAADbdqxEAP+iRAIAAAAAAAAAAAAAAAAAAAAAAAAAMIysRAD/o0QDAAAAMIysRJAWpERKn6xEqimkRNu2rESqKaREAgAAAAAAAAAAAAAAAAAAAAAAAACGYa1EqimkRAMAAAAWea1EqimkRDCMrUSQFqREMIytRAD/o0QCAAAAAAAAAAAAAAAAAAAAAAAAAIahrUQA/6JEAQAAAAAAAAAAAAAAAAAAAAAAAAAwzKxEAP+iRAIAAAAAAAAAAAAAAAAAAAAAAAAAMMysRAC/okQDAAAAMMysRDizokS91axEqqmiRIbhrESqqaJEAgAAAAAAAAAAAAAAAAAAAAAAAADbNq1EqqmiRAMAAACjQq1EqqmiRDBMrUQ4s6JEMEytRAC/okQCAAAAAAAAAAAAAAAAAAAAAAAAADBMrUQA/6JE" + }, + "~:name": "svg-path", + "~:width": null, + "~:type": "~:path", + "~:svg-attrs": { + "~:fill": "none", + "~:stroke-linejoin": "round" + }, + "~:touched": { + "~#set": [ + "~:geometry-group", + "~:content-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1379.04752957682, + "~:y": 1301.30205598607 + } + }, + { + "~#point": { + "~:x": 1389.71419624349, + "~:y": 1301.30205598607 + } + }, + { + "~#point": { + "~:x": 1389.71419624349, + "~:y": 1313.30205598607 + } + }, + { + "~#point": { + "~:x": 1379.04752957682, + "~:y": 1313.30205598607 + } + } + ], + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18bbd9cffcc", + "~:proportion-lock": false, + "~:stroke-cap-end": "round", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:constraints-v": "~:scale", + "~:svg-transform": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + }, + "~:constraints-h": "~:scale", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a338", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a335", + "~:svg-viewbox": { + "~:y": 3, + "~:y1": 3, + "~:width": 16, + "~:x": 4, + "~:x1": 4, + "~:y2": 21, + "~:x2": 20, + "~:height": 18 + }, + "~:applied-tokens": { + "~:stroke-color": "xx.alias.color.purpose.criticalLowEmphasis" + }, + "~:svg-defs": { + + }, + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a335", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1.5, + "~:stroke-cap-start": "~:round", + "~:stroke-cap-end": "~:round", + "~:stroke-color": "#ba5a56", + "~:stroke-opacity": 1 + } + ], + "~:x": null, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1379.04752957682, + "~:y": 1301.30205598607, + "~:width": 10.6666666666667, + "~:height": 12, + "~:x1": 1379.04752957682, + "~:y1": 1301.30205598607, + "~:x2": 1389.71419624349, + "~:y2": 1313.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a337": { + "~#shape": { + "~:y": null, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAAD/kK5ETdCiRAMAAACqiK5E+ceiRCh7rkT5x6JE03KuRE3QokQDAAAAfmquRKLYokR+aq5EJOaiRNNyrkR57qJEAgAAAAAAAAAAAAAAAAAAAAAAAAAF7q5EqmmjRAIAAAAAAAAAAAAAAAAAAAAAAAAA03KuRNzko0QDAAAAf2quRDHto0R/aq5Es/qjRNNyrkQHA6REAwAAACh7rkRcC6REqoiuRFwLpET/kK5EBwOkRAIAAAAAAAAAAAAAAAAAAAAAAAAAMAyvRNaHo0QCAAAAAAAAAAAAAAAAAAAAAAAAAGKHr0QHA6REAwAAALaPr0RcC6REOZ2vRFwLpESNpa9EBwOkRAMAAADira9Es/qjROKtr0Qx7aNEjaWvRNzko0QCAAAAAAAAAAAAAAAAAAAAAAAAAFwqr0SqaaNEAgAAAAAAAAAAAAAAAAAAAAAAAACOpa9Eee6iRAMAAADira9EJOaiROKtr0Si2KJEjqWvRE3QokQDAAAAOZ2vRPnHokS3j69E+ceiRGKHr0RN0KJEAgAAAAAAAAAAAAAAAAAAAAAAAAAwDK9Ef0ujRAIAAAAAAAAAAAAAAAAAAAAAAAAA/5CuRE3QokQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "~:name": "svg-path", + "~:width": null, + "~:type": "~:path", + "~:svg-attrs": { + + }, + "~:touched": { + "~#set": [ + "~:geometry-group", + "~:content-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1395.39298957682, + "~:y": 1302.31418265274 + } + }, + { + "~#point": { + "~:x": 1405.36877957682, + "~:y": 1302.31418265274 + } + }, + { + "~#point": { + "~:x": 1405.36877957682, + "~:y": 1312.28990598607 + } + }, + { + "~#point": { + "~:x": 1395.39298957682, + "~:y": 1312.28990598607 + } + } + ], + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18b7a1196dd", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:constraints-v": "~:scale", + "~:svg-transform": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + }, + "~:constraints-h": "~:scale", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a337", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a334", + "~:svg-viewbox": { + "~:y": 4.51819, + "~:y1": 4.51819, + "~:width": 14.963685, + "~:x": 4.51819, + "~:x1": 4.51819, + "~:y2": 19.481775, + "~:x2": 19.481875, + "~:height": 14.963585 + }, + "~:applied-tokens": { + "~:stroke-color": "xx.alias.color.purpose.criticalLowEmphasis" + }, + "~:svg-defs": { + + }, + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a334", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1, + "~:stroke-color": "#ba5a56", + "~:stroke-opacity": 1 + } + ], + "~:x": null, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1395.39298957682, + "~:y": 1302.31418265274, + "~:width": 9.97578999999928, + "~:height": 9.97572333333369, + "~:x1": 1395.39298957682, + "~:y1": 1302.31418265274, + "~:x2": 1405.36877957682, + "~:y2": 1312.28990598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a336": { + "~#shape": { + "~:y": 1295.30205598607, + "~:layout-item-hsizing": "fill", + "~:layout-padding": { + "~:p3": 24, + "~:p1": 24, + "~:p2": 12 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.5", + "~:path": "font-screen-lg / label", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.5", + "~:path": "font-screen-lg / label", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:font-size": "16", + "~:font-weight": "500", + "~:modified-at": "2025-01-24T19:39:31.048Z", + "~:font-variant-id": "500", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:fills": [ + { + "~:fill-color": "#ba5a56", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans", + "~:text": "Label" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:key": "daugc", + "~:font-size": "16", + "~:font-weight": "500", + "~:type": "paragraph", + "~:modified-at": "2025-01-24T19:39:31.048Z", + "~:font-variant-id": "500", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:fills": [ + { + "~:fill-color": "#ba5a56", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans" + } + ] + } + ], + "~:fills": [] + }, + "~:hide-in-viewer": false, + "~:name": "Label", + "~:width": 42, + "~:type": "~:text", + "~:touched": { + "~#set": [ + "~:geometry-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1326.38086291016, + "~:y": 1295.30205598607 + } + }, + { + "~#point": { + "~:x": 1368.38086291016, + "~:y": 1295.30205598607 + } + }, + { + "~#point": { + "~:x": 1368.38086291016, + "~:y": 1319.30205598607 + } + }, + { + "~#point": { + "~:x": 1326.38086291016, + "~:y": 1319.30205598607 + } + } + ], + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18b7a11d7ad", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a336", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:applied-tokens": { + "~:fill": "xx.alias.color.purpose.criticalLowEmphasis" + }, + "~:position-data": [ + { + "~:y": 1317.72204589844, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "16", + "~:font-weight": "500", + "~:text-direction": "ltr", + "~:width": 41.1199951171875, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:x": 1326.4208984375, + "~:fills": [ + { + "~:fill-color": "#ba5a56", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "DM Sans", + "~:height": 20.840087890625, + "~:text": "Label" + } + ], + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:strokes": [], + "~:x": 1326.38086291016, + "~:layout-item-margin": { + "~:m2": 8, + "~:m4": 8 + }, + "~:selrect": { + "~#rect": { + "~:x": 1326.38086291016, + "~:y": 1295.30205598607, + "~:width": 42, + "~:height": 24, + "~:x1": 1326.38086291016, + "~:y1": 1295.30205598607, + "~:x2": 1368.38086291016, + "~:y2": 1319.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 24, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a335": { + "~#shape": { + "~:y": 1299.30205598607, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "Icons / 16 / Trash", + "~:width": 16, + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:geometry-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1376.38086291016, + "~:y": 1299.30205598607 + } + }, + { + "~#point": { + "~:x": 1392.38086291016, + "~:y": 1299.30205598607 + } + }, + { + "~#point": { + "~:x": 1392.38086291016, + "~:y": 1315.30205598607 + } + }, + { + "~#point": { + "~:x": 1376.38086291016, + "~:y": 1315.30205598607 + } + } + ], + "~:r2": 0, + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18bbd9cffcb", + "~:show-content": true, + "~:proportion-lock": true, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:r3": 0, + "~:r1": 0, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a335", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:component-id": "~u829b5886-5b9d-80cc-8005-a17e67e9af2d", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:strokes": [], + "~:x": 1376.38086291016, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1376.38086291016, + "~:y": 1299.30205598607, + "~:width": 16, + "~:height": 16, + "~:x1": 1376.38086291016, + "~:y1": 1299.30205598607, + "~:x2": 1392.38086291016, + "~:y2": 1315.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 16, + "~:component-file": "~u29d026bf-3f3f-8055-8006-518212e12739", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a338" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a334": { + "~#shape": { + "~:y": 1299.30205598607, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "Icons / 24 / close", + "~:width": 16, + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:geometry-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1392.38086291016, + "~:y": 1299.30205598607 + } + }, + { + "~#point": { + "~:x": 1408.38086291016, + "~:y": 1299.30205598607 + } + }, + { + "~#point": { + "~:x": 1408.38086291016, + "~:y": 1315.30205598607 + } + }, + { + "~#point": { + "~:x": 1392.38086291016, + "~:y": 1315.30205598607 + } + } + ], + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18b7a1196dc", + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:hidden": true, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a334", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:applied-tokens": { + "~:width": "xx.alias.size.xxs", + "~:height": "xx.alias.size.xxs" + }, + "~:component-id": "~u5b5dd81f-49d7-8083-8005-9f14d5abeaac", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:strokes": [], + "~:x": 1392.38086291016, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1392.38086291016, + "~:y": 1299.30205598607, + "~:width": 16, + "~:height": 16, + "~:x1": 1392.38086291016, + "~:y1": 1299.30205598607, + "~:x2": 1408.38086291016, + "~:y2": 1315.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 16, + "~:component-file": "~u29d026bf-3f3f-8055-8006-518212e12739", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a337" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a333": { + "~#shape": { + "~:y": 1295.30205598607, + "~:layout-item-hsizing": "fill", + "~:layout-padding": { + "~:p3": 24, + "~:p1": 24, + "~:p2": 12 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.5", + "~:path": "font-screen-lg / label", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.5", + "~:path": "font-screen-lg / label", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:font-size": "16", + "~:font-weight": "500", + "~:modified-at": "2025-01-24T19:39:31.048Z", + "~:font-variant-id": "500", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:fills": [ + { + "~:fill-color": "#686fc8", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans", + "~:text": "Label" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:key": "daugc", + "~:font-size": "16", + "~:font-weight": "500", + "~:type": "paragraph", + "~:modified-at": "2025-01-24T19:39:31.048Z", + "~:font-variant-id": "500", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:fills": [ + { + "~:fill-color": "#686fc8", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans" + } + ] + } + ], + "~:fills": [] + }, + "~:hide-in-viewer": false, + "~:name": "Label", + "~:width": 42, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 1224.38086291016, + "~:y": 1295.30205598607 + } + }, + { + "~#point": { + "~:x": 1266.38086291016, + "~:y": 1295.30205598607 + } + }, + { + "~#point": { + "~:x": 1266.38086291016, + "~:y": 1319.30205598607 + } + }, + { + "~#point": { + "~:x": 1224.38086291016, + "~:y": 1319.30205598607 + } + } + ], + "~:shape-ref": "~u453f99db-c307-8059-8005-af2b8edb901b", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a333", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a331", + "~:applied-tokens": { + "~:fill": "xx.alias.color.primary.brand" + }, + "~:position-data": [ + { + "~:y": 1317.72204589844, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "16", + "~:font-weight": "500", + "~:text-direction": "ltr", + "~:width": 41.1199951171875, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:x": 1224.4208984375, + "~:fills": [ + { + "~:fill-color": "#686fc8", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "DM Sans", + "~:height": 20.840087890625, + "~:text": "Label" + } + ], + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a331", + "~:strokes": [], + "~:x": 1224.38086291016, + "~:layout-item-margin": { + "~:m2": 8, + "~:m4": 8 + }, + "~:selrect": { + "~#rect": { + "~:x": 1224.38086291016, + "~:y": 1295.30205598607, + "~:width": 42, + "~:height": 24, + "~:x1": 1224.38086291016, + "~:y1": 1295.30205598607, + "~:x2": 1266.38086291016, + "~:y2": 1319.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 24, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a332": { + "~#shape": { + "~:y": 1287.30205598607, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:rx": 20, + "~:layout-item-hsizing": "auto", + "~:layout-padding": { + "~:p2": 16, + "~:p4": 16, + "~:p3": 8, + "~:p1": 8 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "Button / Destructive / Label + Icon / Default", + "~:layout-align-items": "~:center", + "~:width": 94, + "~:layout-padding-type": "~:multiple", + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:geometry-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1302.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1327.30205598607 + } + }, + { + "~#point": { + "~:x": 1302.38086291016, + "~:y": 1327.30205598607 + } + } + ], + "~:r2": 8, + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18b7a1196db", + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:r3": 8, + "~:layout-justify-content": "~:start", + "~:r1": 8, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~:layout-flex-dir": "~:row", + "~:applied-tokens": { + "~:p2": "xx.alias.spacing.md", + "~:p4": "xx.alias.spacing.md", + "~:p3": "xx.alias.spacing.xs", + "~:fill": "xx.alias.color.purpose.onCritical", + "~:r2": "xx.alias.border.radius.md", + "~:p1": "xx.alias.spacing.xs", + "~:r3": "xx.alias.border.radius.md", + "~:r1": "xx.alias.border.radius.md", + "~:r4": "xx.alias.border.radius.md" + }, + "~:layout-align-content": "~:stretch", + "~:component-id": "~u829b5886-5b9d-80cc-8005-a18c797f2def", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~:strokes": [], + "~:x": 1302.38086291016, + "~:proportion": 1, + "~:shadow": [ + { + "~:color": { + "~:opacity": 0.2, + "~:color": "#000000" + }, + "~:spread": 0, + "~:offset-y": 1, + "~:style": "~:drop-shadow", + "~:blur": 7, + "~:hidden": false, + "~:id": "~uad03169f-c56c-8064-8004-8e08166c6d2a", + "~:offset-x": 0 + } + ], + "~:r4": 8, + "~:selrect": { + "~#rect": { + "~:x": 1302.38086291016, + "~:y": 1287.30205598607, + "~:width": 94, + "~:height": 40, + "~:x1": 1302.38086291016, + "~:y1": 1287.30205598607, + "~:x2": 1396.38086291016, + "~:y2": 1327.30205598607 + } + }, + "~:fills": [ + { + "~:fill-color": "#ffc7bf", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 20, + "~:height": 40, + "~:component-file": "~u29d026bf-3f3f-8055-8006-518212e12739", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a334", + "~uade8229e-4891-80f7-8007-a6c641a6a335", + "~uade8229e-4891-80f7-8007-a6c641a6a336" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a331": { + "~#shape": { + "~:y": 1287.30205598607, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:rx": 20, + "~:layout-item-hsizing": "auto", + "~:layout-padding": { + "~:p2": 16, + "~:p4": 16, + "~:p3": 8, + "~:p1": 8 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "Button / Ghost / Label / Default", + "~:layout-align-items": "~:center", + "~:width": 90, + "~:layout-padding-type": "~:multiple", + "~:type": "~:frame", + "~:touched": { + "~#set": [] + }, + "~:points": [ + { + "~#point": { + "~:x": 1200.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1290.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1290.38086291016, + "~:y": 1327.30205598607 + } + }, + { + "~#point": { + "~:x": 1200.38086291016, + "~:y": 1327.30205598607 + } + } + ], + "~:r2": 8, + "~:shape-ref": "~u453f99db-c307-8059-8005-af2b8edb9019", + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:r3": 8, + "~:layout-justify-content": "~:start", + "~:r1": 8, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a331", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~:layout-flex-dir": "~:row", + "~:applied-tokens": { + "~:p2": "xx.alias.spacing.md", + "~:p4": "xx.alias.spacing.md", + "~:p3": "xx.alias.spacing.xs", + "~:stroke-color": "xx.alias.color.primary.brand", + "~:r2": "xx.alias.border.radius.md", + "~:p1": "xx.alias.spacing.xs", + "~:r3": "xx.alias.border.radius.md", + "~:r1": "xx.alias.border.radius.md", + "~:r4": "xx.alias.border.radius.md" + }, + "~:layout-align-content": "~:stretch", + "~:component-id": "~u453f99db-c307-8059-8005-af2baa65f975", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1, + "~:stroke-color": "#686fc8", + "~:stroke-opacity": 1 + } + ], + "~:x": 1200.38086291016, + "~:proportion": 1, + "~:shadow": [], + "~:r4": 8, + "~:selrect": { + "~#rect": { + "~:x": 1200.38086291016, + "~:y": 1287.30205598607, + "~:width": 90, + "~:height": 40, + "~:x1": 1200.38086291016, + "~:y1": 1287.30205598607, + "~:x2": 1290.38086291016, + "~:y2": 1327.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:ry": 20, + "~:height": 40, + "~:component-file": "~u29d026bf-3f3f-8055-8006-518212e12739", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a333" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a330": { + "~#shape": { + "~:y": 1207.18039402734, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:layout-item-hsizing": "fill", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "header", + "~:layout-align-items": "~:center", + "~:width": 465, + "~:layout-padding-type": "~:simple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1207.18039402734 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1207.18039402734 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1235.30205598607 + } + }, + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1235.30205598607 + } + } + ], + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:layout-justify-content": "~:space-between", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a330", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:layout-flex-dir": "~:row", + "~:layout-align-content": "~:stretch", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:strokes": [], + "~:x": 931.380862910156, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 931.380862910156, + "~:y": 1207.18039402734, + "~:width": 465, + "~:height": 28.1216619587253, + "~:x1": 931.380862910156, + "~:y1": 1207.18039402734, + "~:x2": 1396.38086291016, + "~:y2": 1235.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 28.1216619587253, + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a33a", + "~uade8229e-4891-80f7-8007-a6c641a6a33b" + ] + } + } + }, + "~:name": "Destructive", + "~:modified-at": "~m1772446350100", + "~:main-instance-page": "~u73765590-e307-801e-8006-e4521313207b", + "~:id": "~uade8229e-4891-80f7-8007-a6c641aa24c2" + }, + "~uade8229e-4891-80f7-8007-a6c641a9b090": { + "~:path": "Modal / actions", + "~:deleted": true, + "~:main-instance-id": "~uade8229e-4891-80f7-8007-a6c641a6a31b", + "~:objects": { + "~uade8229e-4891-80f7-8007-a6c641a6a31b": { + "~#shape": { + "~:y": 352.999955487879, + "~:hide-fill-on-export": false, + "~:rx": 20, + "~:layout-item-hsizing": "fix", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "Modal / actions / Regular", + "~:width": 512.999938845635, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 981.00003269603, + "~:y": 352.999955487879 + } + }, + { + "~#point": { + "~:x": 1493.99997154166, + "~:y": 352.999955487879 + } + }, + { + "~#point": { + "~:x": 1493.99997154166, + "~:y": 520.999969122142 + } + }, + { + "~#point": { + "~:x": 981.00003269603, + "~:y": 520.999969122142 + } + } + ], + "~:r2": 0, + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u73765590-e307-801e-8006-e4521313207b", + "~:r3": 0, + "~:blur": { + "~:id": "~ua5508528-5928-8008-8007-a7de3e463903", + "~:type": "~:layer-blur", + "~:value": 4, + "~:hidden": true + }, + "~:r1": 0, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a31b", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:applied-tokens": { + "~:row-gap": "xx.alias.spacing.md" + }, + "~:component-id": "~uade8229e-4891-80f7-8007-a6c641a9b090", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 981.00003269603, + "~:main-instance": true, + "~:proportion": 1, + "~:shadow": [], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 981.00003269603, + "~:y": 352.999955487879, + "~:width": 512.999938845635, + "~:height": 168.000013634263, + "~:x1": 981.00003269603, + "~:y1": 352.999955487879, + "~:x2": 1493.99997154166, + "~:y2": 520.999969122142 + } + }, + "~:fills": [ + { + "~:fill-color": "#f3f3f4", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 20, + "~:height": 168.000013634263, + "~:component-file": "~ueffcbebc-b8c8-802f-8007-a7dc677169cd", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a31c" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a31c": { + "~#shape": { + "~:y": 376.999984733, + "~:hide-fill-on-export": false, + "~:layout-item-hsizing": "fill", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "content", + "~:width": 464.99994456768, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1005.00002988041, + "~:y": 376.999984733 + } + }, + { + "~#point": { + "~:x": 1469.99997444809, + "~:y": 376.999984733 + } + }, + { + "~#point": { + "~:x": 1469.99997444809, + "~:y": 496.999989763423 + } + }, + { + "~#point": { + "~:x": 1005.00002988041, + "~:y": 496.999989763423 + } + } + ], + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u73765590-e307-801e-8006-e4521313207b", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a31c", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a31b", + "~:applied-tokens": { + "~:column-gap": "xx.alias.spacing.md", + "~:row-gap": "xx.alias.spacing.md" + }, + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a31b", + "~:strokes": [], + "~:x": 1005.00002988041, + "~:proportion": 1, + "~:grids": [], + "~:shadow": [], + "~:selrect": { + "~#rect": { + "~:x": 1005.00002988041, + "~:y": 376.999984733, + "~:width": 464.99994456768, + "~:height": 120.000005030423, + "~:x1": 1005.00002988041, + "~:y1": 376.999984733, + "~:x2": 1469.99997444809, + "~:y2": 496.999989763423 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 120.000005030423, + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a31f" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a31f": { + "~#shape": { + "~:y": 376.999995172412, + "~:hide-fill-on-export": false, + "~:layout-item-hsizing": "fill", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "header", + "~:width": 464.99994456768, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1005.00002988041, + "~:y": 376.999995172412 + } + }, + { + "~#point": { + "~:x": 1469.99997444809, + "~:y": 376.999995172412 + } + }, + { + "~#point": { + "~:x": 1469.99997444809, + "~:y": 405.000011605939 + } + }, + { + "~#point": { + "~:x": 1005.00002988041, + "~:y": 405.000011605939 + } + } + ], + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u73765590-e307-801e-8006-e4521313207b", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a31f", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a31c", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a31c", + "~:strokes": [], + "~:x": 1005.00002988041, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1005.00002988041, + "~:y": 376.999995172412, + "~:width": 464.99994456768, + "~:height": 28.000016433527, + "~:x1": 1005.00002988041, + "~:y1": 376.999995172412, + "~:x2": 1469.99997444809, + "~:y2": 405.000011605939 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 28.000016433527, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:name": "Regular", + "~:modified-at": "~m1772519947685", + "~:main-instance-page": "~u73765590-e307-801e-8006-e4521313207b", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a9b090" + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc677169cd", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } + } \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 242f0bf6d2..f61afbfda4 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -455,4 +455,24 @@ test("Check inner stroke artifacts", async ({ maxDiffPixelRatio: 0, threshold: 0.1, }); +}); + +test("BUG 13551 - Blurs affecting other elements", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-blurs-affecting-other-elements.json"); + + await workspace.goToWorkspace({ + id: "effcbebc-b8c8-802f-8007-a7dc677169cd", + pageId: "a5508528-5928-8008-8007-a7de9feef61bd", + }); + await workspace.waitForFirstRenderWithoutUI(); + + // Stricter comparison: blur is very subtle + await expect(workspace.canvas).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0.1, + }); }); \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13551---Blurs-affecting-other-elements-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13551---Blurs-affecting-other-elements-1.png new file mode 100644 index 0000000000000000000000000000000000000000..2836870086319cfb8513d635939b7403f9c66e4e GIT binary patch literal 11562 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 Ag#Z8m literal 0 HcmV?d00001 diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1493b9851e..7f11bc944b 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -53,6 +53,8 @@ pub struct NodeRenderState { visited_mask: bool, // This bool indicates that we're drawing the mask shape. mask: bool, + // True when this container was flattened (enter/exit skipped). + flattened: bool, } /// Get simplified children of a container, flattening nested flattened containers @@ -1462,6 +1464,7 @@ impl RenderState { clip_bounds: None, visited_mask: true, mask: false, + flattened: false, }); if let Some(&mask_id) = element.mask_id() { self.pending_nodes.push(NodeRenderState { @@ -1470,6 +1473,7 @@ impl RenderState { clip_bounds: None, visited_mask: false, mask: true, + flattened: false, }); } } @@ -1999,8 +2003,7 @@ impl RenderState { } if visited_children { - // Skip render_shape_exit for flattened containers - if !element.can_flatten() { + if !node_render_state.flattened { self.render_shape_exit(element, visited_mask, clip_bounds); } continue; @@ -2149,6 +2152,7 @@ impl RenderState { clip_bounds: clip_bounds.clone(), visited_mask: false, mask, + flattened: can_flatten, }); if element.is_recursive() { @@ -2195,6 +2199,7 @@ impl RenderState { clip_bounds: children_clip_bounds.clone(), visited_mask: false, mask: false, + flattened: false, }); } } @@ -2309,6 +2314,7 @@ impl RenderState { clip_bounds: None, visited_mask: false, mask: false, + flattened: false, } })); } From 1800deddd52546d6f8547bf9e713383e7b4a9b34 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 3 Mar 2026 09:14:06 +0100 Subject: [PATCH 04/26] :wrench: Await promise correctly to fix tests flakyness --- frontend/playwright/ui/specs/text-editor-v2.spec.js | 7 ++----- frontend/playwright/ui/specs/variants.spec.js | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/frontend/playwright/ui/specs/text-editor-v2.spec.js b/frontend/playwright/ui/specs/text-editor-v2.spec.js index 197a19c743..c12fef1bba 100644 --- a/frontend/playwright/ui/specs/text-editor-v2.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v2.spec.js @@ -10,7 +10,7 @@ test.beforeEach(async ({ page, context }) => { }); test.afterEach(async ({ context }) => { - context.clearPermissions(); + await context.clearPermissions(); }); test("Create a new text shape", async ({ page }) => { @@ -27,7 +27,7 @@ test("Create a new text shape", async ({ page }) => { await workspace.waitForSelectedShapeName(initialText); }); -test("Create a new text shape from pasting text", async ({ page, context }) => { +test("Create a new text shape from pasting text", async ({ page }) => { const textToPaste = "Lorem ipsum"; const workspace = new WasmWorkspacePage(page, { textEditor: true, @@ -49,7 +49,6 @@ test("Create a new text shape from pasting text", async ({ page, context }) => { test("Create a new text shape from pasting text using context menu", async ({ page, - context, }) => { const textToPaste = "Lorem ipsum"; const workspace = new WasmWorkspacePage(page, { @@ -121,7 +120,6 @@ test.skip("Update an already created text shape by inserting text in between", a test("Update a new text shape appending text by pasting text", async ({ page, - context, }) => { const textToPaste = " dolor sit amet"; const workspace = new WasmWorkspacePage(page, { @@ -143,7 +141,6 @@ test("Update a new text shape appending text by pasting text", async ({ test.skip("Update a new text shape prepending text by pasting text", async ({ page, - context, }) => { const textToPaste = "Dolor sit amet "; const workspace = new WasmWorkspacePage(page, { diff --git a/frontend/playwright/ui/specs/variants.spec.js b/frontend/playwright/ui/specs/variants.spec.js index b5dc3d751d..0c50f7dc8b 100644 --- a/frontend/playwright/ui/specs/variants.spec.js +++ b/frontend/playwright/ui/specs/variants.spec.js @@ -11,7 +11,7 @@ test.beforeEach(async ({ page, context }) => { }); test.afterEach(async ({ context }) => { - context.clearPermissions(); + await context.clearPermissions(); }); const setupVariantsFile = async (workspacePage) => { @@ -176,7 +176,7 @@ test("User duplicates a variant container", async ({ page }) => { await validateVariant(variant_duplicate); }); -test("User copy paste a variant container", async ({ page, context }) => { +test("User copy paste a variant container", async ({ page }) => { const workspacePage = new WasmWorkspacePage(page); // Access to the read/write clipboard necesary for this functionality await setupVariantsFileWithVariant(workspacePage); From 95aa63374cbd06760b99a2376ccb311213954460 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Fri, 27 Feb 2026 11:25:19 +0100 Subject: [PATCH 05/26] :recycle: Refactor Text Editor v3 --- .../ui/workspace/shapes/text/v3_editor.cljs | 314 ++++++++++++++++++ .../ui/workspace/shapes/text/v3_editor.scss | 13 + .../main/ui/workspace/viewport/actions.cljs | 50 +-- .../app/main/ui/workspace/viewport_wasm.cljs | 27 +- frontend/src/app/render_wasm/api.cljs | 1 + frontend/src/app/render_wasm/text_editor.cljs | 22 +- .../app/render_wasm/text_editor_input.cljs | 241 -------------- render-wasm/src/shapes/text.rs | 7 + render-wasm/src/state/text_editor.rs | 12 +- render-wasm/src/wasm/text_editor.rs | 57 ++-- 10 files changed, 406 insertions(+), 338 deletions(-) create mode 100644 frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs create mode 100644 frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss delete mode 100644 frontend/src/app/render_wasm/text_editor_input.cljs diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs new file mode 100644 index 0000000000..d1d3cd477b --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -0,0 +1,314 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.workspace.shapes.text.v3-editor + "Contenteditable DOM element for WASM text editor input" + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.main.data.helpers :as dsh] + [app.main.data.workspace.texts :as dwt] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.css-cursors :as cur] + [app.render-wasm.api :as wasm.api] + [app.render-wasm.text-editor :as text-editor] + [app.util.dom :as dom] + [app.util.object :as obj] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(def caret-blink-interval-ms 250) + +(defn- sync-wasm-text-editor-content! + "Sync WASM text editor content back to the shape via the standard + commit pipeline. Called after every text-modifying input." + [& {:keys [finalize?]}] + (when-let [{:keys [shape-id content]} + (text-editor/text-editor-sync-content)] + (st/emit! (dwt/v2-update-text-shape-content + shape-id content + :update-name? true + :finalize? finalize?)))) + +(defn- font-family-from-font-id [font-id] + (if (str/includes? font-id "gfont-noto-sans") + (let [lang (str/replace font-id #"gfont\-noto\-sans\-" "")] + (if (>= (count lang) 3) (str/capital lang) (str/upper lang))) + "Noto Color Emoji")) + +(mf/defc text-editor + "Contenteditable element positioned over the text shape to capture input events." + {::mf/wrap-props false} + [props] + (let [shape (obj/get props "shape") + shape-id (dm/get-prop shape :id) + + clip-id (dm/str "text-edition-clip" shape-id) + + contenteditable-ref (mf/use-ref nil) + composing? (mf/use-state false) + + fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false) + fallback-families (map (fn [font] + (font-family-from-font-id (:font-id font))) fallback-fonts) + + [{:keys [x y width height]} transform] + (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id) + selrect-transform (mf/deref refs/workspace-selrect) + [selrect transform] (dsh/get-selrect selrect-transform shape) + selrect-height (:height selrect) + selrect-width (:width selrect) + max-width (max width selrect-width) + max-height (max height selrect-height) + valign (-> shape :content :vertical-align) + y (:y selrect) + y (case valign + "bottom" (+ y (- selrect-height height)) + "center" (+ y (/ (- selrect-height height) 2)) + y)] + [(assoc selrect :y y :width max-width :height max-height) transform]) + + on-composition-start + (mf/use-fn + (fn [_event] + (reset! composing? true))) + + on-composition-end + (mf/use-fn + (fn [^js event] + (reset! composing? false) + (let [data (.-data event)] + (when data + (text-editor/text-editor-insert-text data) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-composition")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))) + + on-paste + (mf/use-fn + (fn [^js event] + (dom/prevent-default event) + (let [clipboard-data (.-clipboardData event) + text (.getData clipboard-data "text/plain")] + (when (and text (seq text)) + (text-editor/text-editor-insert-text text) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-paste")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))) + + on-copy + (mf/use-fn + (fn [^js event] + (when (text-editor/text-editor-is-active?) + (dom/prevent-default event) + (when (text-editor/text-editor-get-selection) + (let [text (text-editor/text-editor-export-selection)] + (.setData (.-clipboardData event) "text/plain" text)))))) + + on-key-down + (mf/use-fn + (fn [^js event] + (when (and (text-editor/text-editor-is-active?) + (not @composing?)) + (let [key (.-key event) + ctrl? (or (.-ctrlKey event) (.-metaKey event)) + shift? (.-shiftKey event)] + + (cond + ;; Escape: finalize and stop + (= key "Escape") + (do + (dom/prevent-default event) + (when-let [node (mf/ref-val contenteditable-ref)] + (.blur node))) + + ;; Ctrl+A: select all (key is "a" or "A" depending on platform) + (and ctrl? (= (str/lower key) "a")) + (do + (dom/prevent-default event) + (text-editor/text-editor-select-all) + (wasm.api/request-render "text-select-all")) + + ;; Enter + (= key "Enter") + (do + (dom/prevent-default event) + (text-editor/text-editor-insert-paragraph) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-paragraph")) + + ;; Backspace + (= key "Backspace") + (do + (dom/prevent-default event) + (text-editor/text-editor-delete-backward) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-delete-backward")) + + ;; Delete + (= key "Delete") + (do + (dom/prevent-default event) + (text-editor/text-editor-delete-forward) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-delete-forward")) + + ;; Arrow keys + (= key "ArrowLeft") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 0 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowRight") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 1 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowUp") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 2 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowDown") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 3 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "Home") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 4 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "End") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 5 shift?) + (wasm.api/request-render "text-cursor-move")) + + ;; Let contenteditable handle text input via on-input + :else nil))))) + + on-input + (mf/use-fn + (fn [^js event] + (let [native-event (.-nativeEvent event) + input-type (.-inputType native-event) + data (.-data native-event)] + ;; Skip composition-related input events - composition-end handles those + (when (and (not @composing?) + (not= input-type "insertCompositionText")) + (when (and data (seq data)) + (text-editor/text-editor-insert-text data) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-input")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) "")))))) + + on-pointer-down + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt))))) + + on-pointer-move + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))))) + + on-pointer-up + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt))))) + + on-click + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-set-cursor-from-offset (.-x off-pt) (.-y off-pt))))) + + on-focus + (mf/use-fn + (fn [^js _event] + (wasm.api/text-editor-start shape-id))) + + on-blur + (mf/use-fn + (fn [^js _event] + (sync-wasm-text-editor-content! {:finalize? true}) + (wasm.api/text-editor-stop))) + + style #js {:pointerEvents "all" + "--editor-container-width" (dm/str width "px") + "--editor-container-height" (dm/str height "px") + "--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}] + + ;; Focus contenteditable on mount + (mf/use-effect + (mf/deps contenteditable-ref) + (fn [] + (when-let [node (mf/ref-val contenteditable-ref)] + (.focus node)))) + + (mf/use-effect + (fn [] + (let [timeout-id (atom nil) + schedule-blink (fn schedule-blink [] + (when (text-editor/text-editor-is-active?) + (wasm.api/request-render "cursor-blink")) + (reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))] + (schedule-blink) + (fn [] + (when @timeout-id + (js/clearTimeout @timeout-id)))))) + + ;; Composition and input events + [:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id) + :transform (dm/str transform) + :data-testid "text-editor"} + [:defs + [:clipPath {:id clip-id} + [:rect {:x x :y y :width width :height height}]]] + + [:foreignObject {:x x :y y :width width :height height} + [:div {:on-click on-click + :on-pointer-down on-pointer-down + :on-pointer-move on-pointer-move + :on-pointer-up on-pointer-up + :class (stl/css :text-editor) + :style style} + [:div + {:ref contenteditable-ref + :contentEditable true + :suppressContentEditableWarning true + :on-composition-start on-composition-start + :on-composition-end on-composition-end + :on-key-down on-key-down + :on-input on-input + :on-paste on-paste + :on-copy on-copy + :on-focus on-focus + :on-blur on-blur + ;; FIXME on-click + ;; :on-click on-click + :id "text-editor-wasm-input" + :class (dm/str (cur/get-dynamic "text" (:rotation shape)) + " " + (stl/css :text-editor-container)) + :data-testid "text-editor-container"}]]]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss new file mode 100644 index 0000000000..8539a7ca29 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss @@ -0,0 +1,13 @@ +.text-editor { + height: 100%; +} + +.text-editor-container { + width: 100%; + height: 100%; + position: absolute; + + opacity: 0; + overflow: hidden; + white-space: pre; +} diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index 041cb6f53a..50cd0acec5 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -19,13 +19,11 @@ [app.main.data.workspace.media :as dwm] [app.main.data.workspace.path :as dwdp] [app.main.data.workspace.specialized-panel :as-alias dwsp] - [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.workspace.sidebar.assets.components :as wsac] [app.main.ui.workspace.viewport.viewport-ref :as uwvv] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm.wasm] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] [app.util.dom.normalize-wheel :as nw] @@ -74,7 +72,6 @@ shift? (kbd/shift? native-event) alt? (kbd/alt? native-event) mod? (kbd/mod? native-event) - off-pt (dom/get-offset-position native-event) left-click? (and (not panning) (dom/left-mouse? event)) middle-click? (and (not panning) (dom/middle-mouse? event))] @@ -94,23 +91,8 @@ (st/emit! (mse/->MouseEvent :down ctrl? shift? alt? meta?) ::dwsp/interrupt) - (when (wasm.api/text-editor-is-active?) - (wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt))) - (when (and (not= edition id) (or text-editing? grid-editing?)) - (st/emit! (dw/clear-edition-mode)) - ;; FIXME: I think this is not completely correct because this - ;; is going to happen even when clicking or selecting text. - ;; Sync and stop WASM text editor when exiting edit mode - #_(when (and text-editing? - (features/active-feature? @st/state "render-wasm/v1") - wasm.wasm/context-initialized?) - (when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :finalize? true))) - (wasm.api/text-editor-stop))) + (st/emit! (dw/clear-edition-mode))) (when (and (not text-editing?) (not blocked) @@ -192,8 +174,6 @@ alt? (kbd/alt? event) meta? (kbd/meta? event) hovering? (some? @hover) - native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event) raw-pt (dom/get-client-position event) pt (uwvv/point->viewport raw-pt)] (st/emit! (mse/->MouseEvent :click ctrl? shift? alt? meta?)) @@ -207,20 +187,6 @@ (not drawing-tool)) (st/emit! (dw/select-shape (:id @hover) shift?))) - ;; FIXME: Maybe we can move into a function of the kind - ;; "text-editor-on-click" - ;; If clicking on a text shape and wasm render is enabled, forward cursor position - (when (and hovering? - (not @space?) - edition ;; Only when already in edit mode - (not drawing-path?) - (not drawing-tool)) - (let [hover-shape @hover] - (when (and (= :text (:type hover-shape)) - (features/active-feature? @st/state "text-editor-wasm/v1") - wasm.wasm/context-initialized?) - (wasm.api/text-editor-set-cursor-from-point (.-x off-pt) (.-y off-pt))))) - (when (and @z? (not @space?) (not edition) @@ -262,19 +228,7 @@ (and editable? (not= id edition) (not read-only?)) (do (st/emit! (dw/select-shape id) - (dw/start-editing-selected)) - ;; If using wasm text-editor, notify WASM to start editing this shape - ;; and set cursor position from the double-click location - (when (and (= type :text) - (features/active-feature? @st/state "text-editor-wasm/v1") - wasm.wasm/context-initialized?) - (wasm.api/text-editor-start id))) - - (and editable? (= id edition) (not read-only?) - (= type :text) - (features/active-feature? @st/state "text-editor-wasm/v1") - wasm.wasm/context-initialized?) - (wasm.api/text-editor-select-all) + (dw/start-editing-selected))) (some? selected-shape) (do diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index b120658a5b..52255eac22 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -30,6 +30,7 @@ [app.main.ui.workspace.shapes.text.editor :as editor-v1] [app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline]] [app.main.ui.workspace.shapes.text.v2-editor :as editor-v2] + [app.main.ui.workspace.shapes.text.v3-editor :as editor-v3] [app.main.ui.workspace.top-toolbar :refer [top-toolbar*]] [app.main.ui.workspace.viewport.actions :as actions] [app.main.ui.workspace.viewport.comments :as comments] @@ -54,7 +55,6 @@ [app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]] [app.main.ui.workspace.viewport.widgets :as widgets] [app.render-wasm.api :as wasm.api] - [app.render-wasm.text-editor-input :refer [text-editor-input]] [app.util.debug :as dbg] [app.util.text-editor :as ted] [beicon.v2.core :as rx] @@ -417,14 +417,7 @@ (when picking-color? [:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref - :canvas-ref canvas-ref}]) - - ;; WASM text editor contenteditable (must be outside SVG to work) - (when (and show-text-editor? - (features/active-feature? @st/state "text-editor-wasm/v1")) - [:& text-editor-input {:shape editing-shape - :zoom zoom - :vbox vbox}])] + :canvas-ref canvas-ref}])] [:canvas {:id "render" :data-testid "canvas-wasm-shapes" @@ -471,14 +464,20 @@ [:g {:style {:pointer-events (if disable-events? "none" "auto")}} ;; Text editor handling: ;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM) - (when (and show-text-editor? - (not (features/active-feature? @st/state "text-editor-wasm/v1"))) - (if (features/active-feature? @st/state "text-editor/v2") + (when show-text-editor? + (cond + (features/active-feature? @st/state "text-editor-wasm/v1") + [:& editor-v3/text-editor {:shape editing-shape + :canvas-ref canvas-ref + :ref text-editor-ref}] + + (features/active-feature? @st/state "text-editor/v2") [:& editor-v2/text-editor {:shape editing-shape :canvas-ref canvas-ref :ref text-editor-ref}] - [:& editor-v1/text-editor-svg {:shape editing-shape - :ref text-editor-ref}])) + + :else [:& editor-v1/text-editor-svg {:shape editing-shape + :ref text-editor-ref}])) (when show-frame-outline? (let [outlined-frame-id diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index e3f55b0d37..4e5513c2b9 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -86,6 +86,7 @@ ;; Re-export public text editor functions (def text-editor-start text-editor/text-editor-start) (def text-editor-stop text-editor/text-editor-stop) +(def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset) (def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point) (def text-editor-pointer-down text-editor/text-editor-pointer-down) (def text-editor-pointer-move text-editor/text-editor-pointer-move) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 21bcca45d2..4277062278 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -16,13 +16,21 @@ [id] (when wasm/context-initialized? (let [buffer (uuid/get-u32 id)] - (h/call wasm/internal-module "_text_editor_start" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3))))) + (when-not (h/call wasm/internal-module "_text_editor_start" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3)) + (throw (js/Error. "TextEditor initialization failed")))))) + +(defn text-editor-set-cursor-from-offset + "Sets caret position from shape relative coordinates" + [x y] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_set_cursor_from_offset" x y))) (defn text-editor-set-cursor-from-point + "Sets caret position from screen (canvas) coordinates" [x y] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y))) @@ -95,7 +103,8 @@ (defn text-editor-stop [] (when wasm/context-initialized? - (h/call wasm/internal-module "_text_editor_stop"))) + (when-not (h/call wasm/internal-module "_text_editor_stop") + (throw (js/Error. "TextEditor finalization failed"))))) (defn text-editor-is-active? ([id] @@ -160,6 +169,7 @@ (finally (mem/free)))))) +;; This is used as a intermediate cache between Clojure global state and WASM state. (def ^:private shape-text-contents (atom {})) (defn- merge-exported-texts-into-content diff --git a/frontend/src/app/render_wasm/text_editor_input.cljs b/frontend/src/app/render_wasm/text_editor_input.cljs deleted file mode 100644 index 7eced4ab16..0000000000 --- a/frontend/src/app/render_wasm/text_editor_input.cljs +++ /dev/null @@ -1,241 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) KALEIDOS INC - -(ns app.render-wasm.text-editor-input - "Contenteditable DOM element for WASM text editor input" - (:require - [app.common.geom.shapes :as gsh] - [app.main.data.workspace.texts :as dwt] - [app.main.store :as st] - [app.render-wasm.api :as wasm.api] - [app.render-wasm.text-editor :as text-editor] - [app.util.dom :as dom] - [app.util.object :as obj] - [cuerdas.core :as str] - [goog.events :as events] - [rumext.v2 :as mf]) - (:import goog.events.EventType)) - -(def caret-blink-interval-ms 250) - -(defn- sync-wasm-text-editor-content! - "Sync WASM text editor content back to the shape via the standard - commit pipeline. Called after every text-modifying input." - [& {:keys [finalize?]}] - (when-let [{:keys [shape-id content]} (text-editor/text-editor-sync-content)] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :finalize? finalize?)))) - -(mf/defc text-editor-input - "Contenteditable element positioned over the text shape to capture input events." - {::mf/wrap-props false} - [props] - (let [shape (obj/get props "shape") - zoom (obj/get props "zoom") - vbox (obj/get props "vbox") - - contenteditable-ref (mf/use-ref nil) - composing? (mf/use-state false) - - ;; Calculate screen position from shape bounds - shape-bounds (gsh/shape->rect shape) - screen-x (* (- (:x shape-bounds) (:x vbox)) zoom) - screen-y (* (- (:y shape-bounds) (:y vbox)) zoom) - screen-w (* (:width shape-bounds) zoom) - screen-h (* (:height shape-bounds) zoom)] - - ;; Focus contenteditable on mount - (mf/use-effect - (fn [] - (when-let [node (mf/ref-val contenteditable-ref)] - (.focus node)) - js/undefined)) - - (mf/use-effect - (fn [] - (let [timeout-id (atom nil) - schedule-blink (fn schedule-blink [] - (when (text-editor/text-editor-is-active?) - (wasm.api/request-render "cursor-blink")) - (reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))] - (schedule-blink) - (fn [] - (when @timeout-id - (js/clearTimeout @timeout-id)))))) - - ;; Document-level keydown handler for control keys - (mf/use-effect - (fn [] - (let [on-doc-keydown - (fn [e] - (when (and (text-editor/text-editor-is-active?) - (not @composing?)) - (let [key (.-key e) - ctrl? (or (.-ctrlKey e) (.-metaKey e)) - shift? (.-shiftKey e)] - (cond - ;; Escape: finalize and stop - (= key "Escape") - (do - (dom/prevent-default e) - (sync-wasm-text-editor-content! :finalize? true) - (text-editor/text-editor-stop)) - - ;; Ctrl+A: select all (key is "a" or "A" depending on platform) - (and ctrl? (= (str/lower key) "a")) - (do - (dom/prevent-default e) - (text-editor/text-editor-select-all) - (wasm.api/request-render "text-select-all")) - - ;; Enter - (= key "Enter") - (do - (dom/prevent-default e) - (text-editor/text-editor-insert-paragraph) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-paragraph")) - - ;; Backspace - (= key "Backspace") - (do - (dom/prevent-default e) - (text-editor/text-editor-delete-backward) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-delete-backward")) - - ;; Delete - (= key "Delete") - (do - (dom/prevent-default e) - (text-editor/text-editor-delete-forward) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-delete-forward")) - - ;; Arrow keys - (= key "ArrowLeft") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 0 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "ArrowRight") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 1 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "ArrowUp") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 2 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "ArrowDown") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 3 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "Home") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 4 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "End") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 5 shift?) - (wasm.api/request-render "text-cursor-move")) - - ;; Let contenteditable handle text input via on-input - :else nil))))] - (events/listen js/document EventType.KEYDOWN on-doc-keydown true) - (fn [] - (events/unlisten js/document EventType.KEYDOWN on-doc-keydown true))))) - - ;; Composition and input events - (let [on-composition-start - (mf/use-fn - (fn [_event] - (reset! composing? true))) - - on-composition-end - (mf/use-fn - (fn [^js event] - (reset! composing? false) - (let [data (.-data event)] - (when data - (text-editor/text-editor-insert-text data) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-composition")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))) - - on-paste - (mf/use-fn - (fn [^js event] - (dom/prevent-default event) - (let [clipboard-data (.-clipboardData event) - text (.getData clipboard-data "text/plain")] - (when (and text (seq text)) - (text-editor/text-editor-insert-text text) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-paste")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))) - - on-copy - (mf/use-fn - (fn [^js event] - (when (text-editor/text-editor-is-active?) - (dom/prevent-default event) - (when (text-editor/text-editor-get-selection) - (let [text (text-editor/text-editor-export-selection)] - (.setData (.-clipboardData event) "text/plain" text)))))) - - on-input - (mf/use-fn - (fn [^js event] - (let [native-event (.-nativeEvent event) - input-type (.-inputType native-event) - data (.-data native-event)] - ;; Skip composition-related input events - composition-end handles those - (when (and (not @composing?) - (not= input-type "insertCompositionText")) - (when (and data (seq data)) - (text-editor/text-editor-insert-text data) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-input")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))))] - - [:div - {:ref contenteditable-ref - :contentEditable true - :suppressContentEditableWarning true - :on-composition-start on-composition-start - :on-composition-end on-composition-end - :on-input on-input - :on-paste on-paste - :on-copy on-copy - ;; FIXME on-click - ;; :on-click on-click - :id "text-editor-wasm-input" - ;; FIXME - :style {:position "absolute" - :left (str screen-x "px") - :top (str screen-y "px") - :width (str screen-w "px") - :height (str screen-h "px") - :opacity 0 - :overflow "hidden" - :white-space "pre" - :cursor "text" - :z-index 10}}]))) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index b18a7b4aab..ed5c6c290d 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -161,6 +161,13 @@ impl TextPositionWithAffinity { offset, } } + + pub fn reset(&mut self) { + self.position_with_affinity.position = 0; + self.position_with_affinity.affinity = Affinity::Downstream; + self.paragraph = 0; + self.offset = 0; + } } #[derive(Debug)] diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index dc0f4152d4..f68e84ba87 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -33,6 +33,11 @@ impl TextSelection { !self.is_collapsed() } + pub fn reset(&mut self) { + self.anchor.reset(); + self.focus.reset(); + } + pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) { self.anchor = cursor; self.focus = cursor; @@ -133,7 +138,7 @@ impl TextEditorState { self.active_shape_id = Some(shape_id); self.cursor_visible = true; self.last_blink_time = 0.0; - self.selection = TextSelection::new(); + self.selection.reset(); self.is_pointer_selection_active = false; self.pending_events.clear(); } @@ -142,9 +147,10 @@ impl TextEditorState { self.is_active = false; self.active_shape_id = None; self.cursor_visible = false; + self.last_blink_time = 0.0; + self.selection.reset(); self.is_pointer_selection_active = false; self.pending_events.clear(); - self.reset_blink(); } pub fn start_pointer_selection(&mut self) -> bool { @@ -195,13 +201,11 @@ impl TextEditorState { pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.set_caret(*position); - self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.extend_to(*position); - self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index c7285345e5..f153672fb4 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -42,10 +42,14 @@ pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool { } #[no_mangle] -pub extern "C" fn text_editor_stop() { +pub extern "C" fn text_editor_stop() -> bool { with_state_mut!(state, { + if !state.text_editor_state.is_active { + return false; + } state.text_editor_state.stop(); - }); + true + }) } #[no_mangle] @@ -126,12 +130,8 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { return; }; let point = Point::new(x, y); - let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); - let shape_matrix = shape.get_matrix(); state.text_editor_state.start_pointer_selection(); - if let Some(position) = - text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) - { + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state.text_editor_state.set_caret_from_position(&position); } }); @@ -143,7 +143,6 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { if !state.text_editor_state.is_active { return; } - let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); let point = Point::new(x, y); let Some(shape_id) = state.text_editor_state.active_shape_id else { return; @@ -151,11 +150,6 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { let Some(shape) = state.shapes.get(&shape_id) else { return; }; - let shape_matrix = shape.get_matrix(); - let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix) - else { - return; - }; if !state.text_editor_state.is_pointer_selection_active { return; } @@ -163,9 +157,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { return; }; - if let Some(position) = - text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) - { + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state .text_editor_state .extend_selection_from_position(&position); @@ -179,7 +171,6 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { if !state.text_editor_state.is_active { return; } - let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); let point = Point::new(x, y); let Some(shape_id) = state.text_editor_state.active_shape_id else { return; @@ -187,20 +178,13 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { let Some(shape) = state.shapes.get(&shape_id) else { return; }; - let shape_matrix = shape.get_matrix(); - let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix) - else { - return; - }; if !state.text_editor_state.is_pointer_selection_active { return; } let Type::Text(text_content) = &shape.shape_type else { return; }; - if let Some(position) = - text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) - { + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state .text_editor_state .extend_selection_from_position(&position); @@ -209,6 +193,29 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { }); } +#[no_mangle] +pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let point = Point::new(x, y); + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + state.text_editor_state.set_caret_from_position(&position); + } + }); +} + #[no_mangle] pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { with_state_mut!(state, { From ccb272784f5b9936a78edc673ef62a8c12727411 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 2 Mar 2026 17:37:44 +0100 Subject: [PATCH 06/26] :tada: Add TextEditor theme customization --- render-wasm/src/render/text_editor.rs | 2 +- render-wasm/src/state/text_editor.rs | 2 +- render-wasm/src/wasm/text_editor.rs | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 2cf7eeab0c..9c1cdaf526 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -66,7 +66,7 @@ fn render_selection( } let mut paint = Paint::default(); - paint.set_blend_mode(BlendMode::Multiply); + paint.set_blend_mode(BlendMode::default()); paint.set_color(editor_state.theme.selection_color); paint.set_anti_alias(true); diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index f68e84ba87..1f5e03ac96 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -91,7 +91,7 @@ pub enum TextEditorEvent { } /// FIXME: It should be better to get these constants from the frontend through the API. -const SELECTION_COLOR: Color = Color::from_argb(255, 0, 209, 184); +const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184); const CURSOR_WIDTH: f32 = 1.5; const CURSOR_COLOR: Color = Color::BLACK; const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0; diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index f153672fb4..2728ba9f2f 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -6,6 +6,7 @@ use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; use crate::{with_state, with_state_mut, STATE}; use macros::ToJs; +use skia_safe::Color; #[derive(PartialEq, ToJs)] #[repr(u8)] @@ -23,6 +24,21 @@ pub enum CursorDirection { // STATE MANAGEMENT // ============================================================================ +#[no_mangle] +pub extern "C" fn text_editor_apply_theme( + selection_color: u32, + cursor_width: f32, + cursor_color: u32, +) { + with_state_mut!(state, { + // NOTE: In the future could be interesting to fill al this data from + // a structure pointer. + state.text_editor_state.theme.selection_color = Color::new(selection_color); + state.text_editor_state.theme.cursor_width = cursor_width; + state.text_editor_state.theme.cursor_color = Color::new(cursor_color); + }) +} + #[no_mangle] pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool { with_state_mut!(state, { From 287b9d459793b185fb9a5d5bc4c06d324b38aaa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Wed, 4 Mar 2026 08:28:58 +0100 Subject: [PATCH 07/26] :wrench: Remove deleting node_modules on frontend watch script (#8525) --- frontend/scripts/watch | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/scripts/watch b/frontend/scripts/watch index 78de971cba..80d579b73f 100755 --- a/frontend/scripts/watch +++ b/frontend/scripts/watch @@ -4,8 +4,6 @@ TARGET=${1:-app}; set -ex -rm -rf node_modules; - corepack enable; corepack install; pnpm install; From 86e851f408c6cf7e1b2f8afec66a6ef1b32a2af2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 4 Mar 2026 09:27:51 +0100 Subject: [PATCH 08/26] :bug: Fix incorrect version visibility on workspace (#8463) * :bug: Add missing order by clause to snapshot query This fixes the incorrect snapshot visibility when file has a lot of versions. * :zap: Reduce allocation on milestone-group* component * :bug: Fix milestone group timestamp formatting * :paperclip: Update changelog * :bug: Fix scroll on history panel --------- Co-authored-by: Eva Marco --- CHANGES.md | 7 +++++++ backend/src/app/features/file_snapshots.clj | 4 ++-- common/src/app/common/time.cljc | 2 +- .../src/app/main/ui/ds/layout/tab_switcher.scss | 1 + .../app/main/ui/ds/product/milestone_group.cljs | 17 +++++++++++------ frontend/src/app/main/ui/ds/utilities/date.cljs | 13 +++---------- frontend/src/app/main/ui/workspace/sidebar.scss | 4 ++++ 7 files changed, 29 insertions(+), 19 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 824a7a8da4..0111be3ce4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2.13.4 + +### :bug: Bugs fixed + +- Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463) + + ## 2.13.3 ### :bug: Bugs fixed diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index cf40a08a79..192030cbf8 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -138,6 +138,7 @@ c.deleted_at FROM snapshots1 AS c WHERE c.file_id = ? + ORDER BY c.created_at DESC ), snapshots3 AS ( (SELECT * FROM snapshots2 WHERE created_by = 'system' @@ -150,8 +151,7 @@ AND deleted_at IS NULL LIMIT 500) ) - SELECT * FROM snapshots3 - ORDER BY created_at DESC")) + SELECT * FROM snapshots3;")) (defn get-visible-snapshots "Return a list of snapshots fecheable from the API, it has a limited diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc index b0e2b6fe84..de46d0aaba 100644 --- a/common/src/app/common/time.cljc +++ b/common/src/app/common/time.cljc @@ -208,7 +208,7 @@ (dfn-format v "p") :localized-date-time - (dfn-format v "PPPp") + (dfn-format v "PPP . p") (if (string? fmt) (dfn-format v fmt) diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss index f25fc4ccd9..56ecfe27f0 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss @@ -114,4 +114,5 @@ width: 100%; height: 100%; outline: $b-1 solid var(--tab-panel-outline-color); + overflow-y: auto; } diff --git a/frontend/src/app/main/ui/ds/product/milestone_group.cljs b/frontend/src/app/main/ui/ds/product/milestone_group.cljs index 2b8944d0fd..73452fc30b 100644 --- a/frontend/src/app/main/ui/ds/product/milestone_group.cljs +++ b/frontend/src/app/main/ui/ds/product/milestone_group.cljs @@ -39,7 +39,6 @@ (mf/spread-props props {:class [class class'] :data-testid "milestone"}) - open* (mf/use-state false) @@ -57,7 +56,13 @@ (dom/get-data "index") (d/parse-integer))] (when (fn? on-menu-click) - (on-menu-click index event)))))] + (on-menu-click index event))))) + + snapshots + (mf/with-memo [snapshots] + (map-indexed (fn [index date] + (d/vec2 date index)) + snapshots))] [:> :div props [:> text* {:as "span" :typography t/body-small :class (stl/css :name)} label] @@ -76,14 +81,14 @@ :icon-arrow-toggled open?)}]] (when ^boolean open? - (for [[idx d] (d/enumerate snapshots)] - [:div {:key (dm/str "entry-" idx) + (for [[date index] snapshots] + [:div {:key (dm/str "entry-" index) :class (stl/css :version-entry)} - [:> date* {:date d :class (stl/css :date) :typography t/body-small}] + [:> date* {:date date :class (stl/css :date) :typography t/body-small}] [:> icon-button* {:class (stl/css :entry-button) :variant "ghost" :icon i/menu :aria-label (tr "workspace.versions.version-menu") - :data-index idx + :data-index index :on-click on-menu-click}]]))]])) diff --git a/frontend/src/app/main/ui/ds/utilities/date.cljs b/frontend/src/app/main/ui/ds/utilities/date.cljs index f24cd11ac6..eadeeb187a 100644 --- a/frontend/src/app/main/ui/ds/utilities/date.cljs +++ b/frontend/src/app/main/ui/ds/utilities/date.cljs @@ -6,10 +6,8 @@ (ns app.main.ui.ds.utilities.date (:require-macros - [app.common.data.macros :as dm] [app.main.style :as stl]) (:require - [app.common.data :as d] [app.common.time :as ct] [app.main.ui.ds.foundations.typography :as t] [app.main.ui.ds.foundations.typography.text :refer [text*]] @@ -30,15 +28,10 @@ (mf/defc date* {::mf/schema schema:date} [{:keys [class date selected typography] :rest props}] - (let [class (d/append-class class (stl/css-case :date true :is-selected selected)) - date (cond-> date (not (ct/inst? date)) ct/inst) + (let [date (cond-> date (not (ct/inst? date)) ct/inst) typography (or typography t/body-medium)] [:> text* {:as "time" :typography typography - :class class + :class [class (stl/css-case :date true :is-selected selected)] :date-time (ct/format-inst date :iso)} - (dm/str - (ct/format-inst date :localized-date) - " . " - (ct/format-inst date :localized-time) - "h")])) + (ct/format-inst date :localized-date-time)])) diff --git a/frontend/src/app/main/ui/workspace/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar.scss index acceaf4f7d..3c5360b4f4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/sidebar.scss @@ -159,3 +159,7 @@ overflow: hidden; height: calc(100vh - deprecated.$s-88); } + +.history-tab { + overflow-y: auto; +} From 0b41a910bfeb2f6192969e6c44fecab683839aa1 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Wed, 4 Mar 2026 10:44:00 +0100 Subject: [PATCH 09/26] :tada: Add word boundary selection --- .../ui/workspace/shapes/text/v3_editor.cljs | 16 +++- frontend/src/app/render_wasm/api.cljs | 1 + frontend/src/app/render_wasm/text_editor.cljs | 15 ++-- render-wasm/src/state/text_editor.rs | 74 +++++++++++++++++++ render-wasm/src/wasm/text_editor.rs | 28 +++++++ 5 files changed, 125 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index d1d3cd477b..dd95025896 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -220,28 +220,35 @@ (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt))))) + (wasm.api/text-editor-pointer-down off-pt)))) on-pointer-move (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))))) + (wasm.api/text-editor-pointer-move off-pt)))) on-pointer-up (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt))))) + (wasm.api/text-editor-pointer-up off-pt)))) on-click (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-set-cursor-from-offset (.-x off-pt) (.-y off-pt))))) + (wasm.api/text-editor-set-cursor-from-offset off-pt)))) + + on-double-click + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-select-word-boundary off-pt)))) on-focus (mf/use-fn @@ -288,6 +295,7 @@ [:foreignObject {:x x :y y :width width :height height} [:div {:on-click on-click + :on-double-click on-double-click :on-pointer-down on-pointer-down :on-pointer-move on-pointer-move :on-pointer-up on-pointer-up diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 4e5513c2b9..8f037e2f9d 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -93,6 +93,7 @@ (def text-editor-pointer-up text-editor/text-editor-pointer-up) (def text-editor-is-active? text-editor/text-editor-is-active?) (def text-editor-select-all text-editor/text-editor-select-all) +(def text-editor-select-word-boundary text-editor/text-editor-select-word-boundary) (def text-editor-sync-content text-editor/text-editor-sync-content) (def dpr diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 4277062278..94ad28b690 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -25,28 +25,28 @@ (defn text-editor-set-cursor-from-offset "Sets caret position from shape relative coordinates" - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_set_cursor_from_offset" x y))) (defn text-editor-set-cursor-from-point "Sets caret position from screen (canvas) coordinates" - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y))) (defn text-editor-pointer-down - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_pointer_down" x y))) (defn text-editor-pointer-move - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_pointer_move" x y))) (defn text-editor-pointer-up - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_pointer_up" x y))) @@ -100,6 +100,11 @@ (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_select_all"))) +(defn text-editor-select-word-boundary + [{:keys [x y]}] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_select_word_boundary" x y))) + (defn text-editor-stop [] (when wasm/context-initialized? diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 1f5e03ac96..34f2d12239 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -199,6 +199,80 @@ impl TextEditorState { true } + pub fn select_word_boundary( + &mut self, + content: &TextContent, + position: &TextPositionWithAffinity, + ) { + fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' + } + + self.is_pointer_selection_active = false; + + let paragraphs = content.paragraphs(); + if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { + return; + } + + let paragraph = ¶graphs[position.paragraph]; + let paragraph_text: String = paragraph + .children() + .iter() + .map(|span| span.text.as_str()) + .collect(); + + let chars: Vec = paragraph_text.chars().collect(); + if chars.is_empty() { + self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + 0, + )); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + return; + } + + let mut offset = position.offset.min(chars.len()); + + if offset == chars.len() { + offset = offset.saturating_sub(1); + } else if !is_word_char(chars[offset]) && offset > 0 && is_word_char(chars[offset - 1]) { + offset -= 1; + } + + if !is_word_char(chars[offset]) { + self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + position.offset.min(chars.len()), + )); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + return; + } + + let mut start = offset; + while start > 0 && is_word_char(chars[start - 1]) { + start -= 1; + } + + let mut end = offset + 1; + while end < chars.len() && is_word_char(chars[end]) { + end += 1; + } + + self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + start, + )); + self.extend_selection_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + end, + )); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + } + pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.set_caret(*position); self.push_event(TextEditorEvent::SelectionChanged); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 2728ba9f2f..385bd63a01 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -121,6 +121,34 @@ pub extern "C" fn text_editor_select_all() -> bool { }) } +#[no_mangle] +pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + + let point = Point::new(x, y); + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + state + .text_editor_state + .select_word_boundary(text_content, &position); + } + }) +} + #[no_mangle] pub extern "C" fn text_editor_poll_event() -> u8 { with_state_mut!(state, { state.text_editor_state.poll_event() as u8 }) From da372099f7441ef716c24f76c8e3b1a4b019fd07 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 4 Mar 2026 11:55:45 +0100 Subject: [PATCH 10/26] :bug: Fix cut copy paste --- .../ui/workspace/shapes/text/v3_editor.cljs | 16 ++++++ render-wasm/src/shapes/text.rs | 7 +++ render-wasm/src/wasm/text_editor.rs | 51 ++++++++++++++++--- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index d1d3cd477b..25a8c0c0fe 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -111,6 +111,21 @@ (let [text (text-editor/text-editor-export-selection)] (.setData (.-clipboardData event) "text/plain" text)))))) + on-cut + (mf/use-fn + (fn [^js event] + (when (text-editor/text-editor-is-active?) + (dom/prevent-default event) + (when (text-editor/text-editor-get-selection) + (let [text (text-editor/text-editor-export-selection)] + (.setData (.-clipboardData event) "text/plain" (or text "")) + (when (and text (seq text)) + (text-editor/text-editor-delete-backward) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-cut")))) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))) + on-key-down (mf/use-fn (fn [^js event] @@ -303,6 +318,7 @@ :on-input on-input :on-paste on-paste :on-copy on-copy + :on-cut on-cut :on-focus on-focus :on-blur on-blur ;; FIXME on-click diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index ed5c6c290d..298b44cc55 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -576,6 +576,7 @@ impl TextContent { for paragraph in self.paragraphs() { let paragraph_style = paragraph.paragraph_to_style(); let mut builder = ParagraphBuilder::new(¶graph_style, fonts); + let mut has_text = false; for span in paragraph.children() { let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent(); let text_style = span.to_style( @@ -585,9 +586,15 @@ impl TextContent { paragraph.line_height(), ); let text: String = span.apply_text_transform(); + if !text.is_empty() { + has_text = true; + } builder.push_style(&text_style); builder.add_text(&text); } + if !has_text { + builder.add_text(" "); + } paragraph_group.push(vec![builder]); } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index f153672fb4..9015c96487 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -282,9 +282,7 @@ 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 = - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, new_offset); + if let Some(new_cursor) = insert_text_with_newlines(text_content, &cursor, &text) { state.text_editor_state.selection.set_caret(new_cursor); } @@ -751,12 +749,10 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 { char_pos += span_len; } } - if !para_text.is_empty() { - if !result.is_empty() { - result.push('\n'); - } - result.push_str(¶_text); + if para_idx > start.paragraph { + result.push('\n'); } + result.push_str(¶_text); } let mut bytes = result.into_bytes(); bytes.push(0); @@ -1053,6 +1049,45 @@ fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, u None } +/// Insert text at a cursor position, splitting on newlines into multiple paragraphs. +/// Returns the final cursor position after insertion. +fn insert_text_with_newlines( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, + text: &str, +) -> Option { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + let lines: Vec<&str> = normalized.split('\n').collect(); + if lines.is_empty() { + return None; + } + + let mut current_cursor = *cursor; + + if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, lines[0]) { + current_cursor = + TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset); + } else { + return None; + } + + for line in lines.iter().skip(1) { + if !split_paragraph_at_cursor(text_content, ¤t_cursor) { + break; + } + current_cursor = + TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); + if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, line) { + current_cursor = TextPositionWithAffinity::new_without_affinity( + current_cursor.paragraph, + new_offset, + ); + } + } + + Some(current_cursor) +} + /// Insert text at a cursor position. Returns the new character offset after insertion. fn insert_text_at_cursor( text_content: &mut TextContent, From 2ace44c9e5ee3f56fc1b542d017541be4fa04daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 24 Feb 2026 14:55:04 +0100 Subject: [PATCH 11/26] :sparkles: Create wasm_error macro to handle Wasm errors differentiating critical vs recoverable --- .../data/text-editor/update-file-11552.json | 2 +- frontend/src/app/main/errors.cljs | 33 +- frontend/src/app/render_wasm/helpers.cljc | 29 +- render-wasm/Cargo.lock | 23 ++ render-wasm/Cargo.toml | 1 + render-wasm/macros/Cargo.lock | 2 + render-wasm/macros/Cargo.toml | 4 +- render-wasm/macros/src/lib.rs | 100 ++++++ render-wasm/src/error.rs | 25 ++ render-wasm/src/main.rs | 287 ++++++++++++------ render-wasm/src/mem.rs | 58 ++-- render-wasm/src/shapes.rs | 3 +- render-wasm/src/wasm.rs | 1 + render-wasm/src/wasm/fills.rs | 8 +- render-wasm/src/wasm/fills/image.rs | 21 +- render-wasm/src/wasm/fonts.rs | 8 +- render-wasm/src/wasm/layouts/grid.rs | 23 +- render-wasm/src/wasm/mem.rs | 38 +++ render-wasm/src/wasm/paths.rs | 8 +- render-wasm/src/wasm/paths/bools.rs | 19 +- render-wasm/src/wasm/text.rs | 20 +- render-wasm/src/wasm/text_editor.rs | 22 +- 22 files changed, 560 insertions(+), 175 deletions(-) create mode 100644 render-wasm/src/error.rs create mode 100644 render-wasm/src/wasm/mem.rs diff --git a/frontend/playwright/data/text-editor/update-file-11552.json b/frontend/playwright/data/text-editor/update-file-11552.json index e556b830cf..0967ef424b 100644 --- a/frontend/playwright/data/text-editor/update-file-11552.json +++ b/frontend/playwright/data/text-editor/update-file-11552.json @@ -1 +1 @@ -w +{} diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 51058b5fe4..6466e414da 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -116,6 +116,17 @@ (ex/print-throwable cause :prefix "Unexpected Error") (show-not-blocking-error cause)))) +(defmethod ptk/handle-error :wasm-non-blocking + [error] + (when-let [cause (::instance error)] + (show-not-blocking-error cause))) + +(defmethod ptk/handle-error :wasm-critical + [error] + (when-let [cause (::instance error)] + (ex/print-throwable cause :prefix "WASM critical error")) + (st/emit! (rt/assign-exception error))) + ;; We receive a explicit authentication error; If the uri is for ;; workspace, dashboard, viewer or settings, then assign the exception ;; for show the error page. Otherwise this explicitly clears all @@ -327,20 +338,24 @@ (str/starts-with? message "invalid props on component") (str/starts-with? message "Unexpected token ")))) + (handle-uncaught [cause] + (when cause + (set! last-exception cause) + (let [data (ex-data cause) + type (get data :type)] + (if (#{:wasm-critical :wasm-non-blocking} type) + (on-error cause) + (when-not (is-ignorable-exception? cause) + (ex/print-throwable cause :prefix "Uncaught Exception") + (ts/schedule #(show-not-blocking-error cause))))))) + (on-unhandled-error [event] (.preventDefault ^js event) - (when-let [cause (unchecked-get event "error")] - (set! last-exception cause) - (when-not (is-ignorable-exception? cause) - (ex/print-throwable cause :prefix "Uncaught Exception") - (ts/schedule #(show-not-blocking-error cause))))) + (handle-uncaught (unchecked-get event "error"))) (on-unhandled-rejection [event] (.preventDefault ^js event) - (when-let [cause (unchecked-get event "reason")] - (set! last-exception cause) - (ex/print-throwable cause :prefix "Uncaught Rejection") - (ts/schedule #(show-not-blocking-error cause))))] + (handle-uncaught (unchecked-get event "reason")))] (.addEventListener g/window "error" on-unhandled-error) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection) diff --git a/frontend/src/app/render_wasm/helpers.cljc b/frontend/src/app/render_wasm/helpers.cljc index 5cb9b5f5ac..5b973cd837 100644 --- a/frontend/src/app/render_wasm/helpers.cljc +++ b/frontend/src/app/render_wasm/helpers.cljc @@ -7,11 +7,30 @@ (ns app.render-wasm.helpers #?(:cljs (:require-macros [app.render-wasm.helpers]))) +(def ^:export error-code + "WASM error code constants (must match render-wasm/src/error.rs and mem.rs)." + {0x01 :wasm-non-blocking 0x02 :wasm-critical}) + (defmacro call - "A helper for easy call wasm defined function in a module." + "A helper for calling a wasm function. + Catches any exception thrown by the WASM function, reads the error code from + WASM when available, and routes it based on the error type: + - :wasm-non-blocking: call app.main.errors/on-error (eventually, shows a toast and logs the error) + - :wasm-critical or unknown: throws an exception to be handled by the global error handler (eventually, shows the internal error page)" [module name & params] - (let [fn-sym (with-meta (gensym "fn-") {:tag 'function})] + (let [fn-sym (with-meta (gensym "fn-") {:tag 'function}) + e-sym (gensym "e") + code-sym (gensym "code")] `(let [~fn-sym (cljs.core/unchecked-get ~module ~name)] - ;; DEBUG - ;; (println "##" ~name) - (~fn-sym ~@params)))) + (try + (~fn-sym ~@params) + (catch :default ~e-sym + (let [read-code# (cljs.core/unchecked-get ~module "_read_error_code") + ~code-sym (when read-code# (read-code#)) + type# (or (get app.render-wasm.helpers/error-code ~code-sym) :wasm-critical) + ex# (ex-info (str "WASM error (type: " type# ")") + {:fn ~name :type type# :message (.-message ~e-sym) :error-code ~code-sym} + ~e-sym)] + (if (= type# :wasm-non-blocking) + (@~'app.main.store/on-error ex#) + (throw ex#)))))))) diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock index e5c289d99e..5d749143fd 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -297,6 +297,8 @@ name = "macros" version = "0.1.0" dependencies = [ "heck", + "proc-macro2", + "quote", "syn", ] @@ -426,6 +428,7 @@ dependencies = [ "indexmap", "macros", "skia-safe", + "thiserror", "uuid", ] @@ -579,6 +582,26 @@ dependencies = [ "xattr", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "1.0.3+spec-1.1.0" diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index ca37fe4104..a26e798e13 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -32,6 +32,7 @@ skia-safe = { version = "0.93.1", default-features = false, features = [ "binary-cache", "webp", ] } +thiserror = "2.0.18" uuid = { version = "1.11.0", features = ["v4", "js"] } [profile.release] diff --git a/render-wasm/macros/Cargo.lock b/render-wasm/macros/Cargo.lock index 8b4fd748ae..8612f3a90b 100644 --- a/render-wasm/macros/Cargo.lock +++ b/render-wasm/macros/Cargo.lock @@ -13,6 +13,8 @@ name = "macros" version = "0.1.0" dependencies = [ "heck", + "proc-macro2", + "quote", "syn", ] diff --git a/render-wasm/macros/Cargo.toml b/render-wasm/macros/Cargo.toml index 6c2abd7509..f3738381b8 100644 --- a/render-wasm/macros/Cargo.toml +++ b/render-wasm/macros/Cargo.toml @@ -1,11 +1,13 @@ [package] name = "macros" version = "0.1.0" -edition = "2024" +edition = "2021" [lib] proc-macro = true [dependencies] heck = "0.5.0" +proc-macro2 = "1.0" +quote = "1.0" syn = "2.0.106" diff --git a/render-wasm/macros/src/lib.rs b/render-wasm/macros/src/lib.rs index a0eec23ca6..2d3536d3d1 100644 --- a/render-wasm/macros/src/lib.rs +++ b/render-wasm/macros/src/lib.rs @@ -6,9 +6,109 @@ use std::sync; use heck::{ToKebabCase, ToPascalCase}; use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, Block, GenericArgument, ItemFn, ReturnType, Type}; type Result = std::result::Result; +/// Attribute macro for WASM-exported functions. The function **must** return +/// `std::result::Result` where T is a C ABI type and E implements +/// `std::error::Error` and `Into`. The macro: +/// - Clears the error code at entry. +/// - Runs the body in `std::panic::catch_unwind`. +/// - Unwraps the Result: `Ok(x)` → return x; `Err(e)` → set error code in memory and panic +/// (so ClojureScript can catch the exception and read the code via `read_error_code`). +/// - On panic from the body: sets critical error code (0x02) and resumes unwind. +#[proc_macro_attribute] +pub fn wasm_error(_attr: TokenStream, item: TokenStream) -> TokenStream { + let mut input = parse_macro_input!(item as ItemFn); + let body = (*input.block).clone(); + + let (attrs, boxed_ty) = match &input.sig.output { + ReturnType::Type(attrs, boxed_ty) => (attrs, boxed_ty), + ReturnType::Default => { + return quote! { + compile_error!( + "#[wasm_error] requires the function to return std::result::Result where E: std::error::Error + Into" + ); + } + .into(); + } + }; + + let (inner_ty, error_ty) = match crate_error_result_inner_type(boxed_ty) { + Some(t) => (t, quote!(crate::error::Error)), + None => { + return quote! { + compile_error!( + "#[wasm_error] requires the function to return crate::error::Result. T must be a C ABI type (u32, u8, bool, (), etc.)" + ); + } + .into(); + } + }; + + let block: Block = syn::parse2(quote! { + { + crate::mem::clear_error_code(); + let __wasm_err_result = std::panic::catch_unwind(|| -> std::result::Result<#inner_ty, #error_ty> { + #body + }); + match __wasm_err_result { + Ok(__inner) => match __inner { + Ok(__val) => __val, + Err(__e) => { + let _: &dyn std::error::Error = &__e; + let __msg = __e.to_string(); + crate::mem::set_error_code(__e.into()); + panic!("WASM error: {}",__msg); + } + }, + Err(__payload) => { + crate::mem::set_error_code(0x02); // critical, same as Error::Critical + std::panic::resume_unwind(__payload); + } + } + } + }) + .expect("block parse"); + + input.sig.output = ReturnType::Type(attrs.clone(), Box::new(inner_ty.clone())); + input.block = Box::new(block); + quote! { #input }.into() +} + +/// If the type is crate::error::Result or a single-segment Result (e.g. with +/// `use crate::error::Result`), returns Some(T). Otherwise None. +fn crate_error_result_inner_type(ty: &Type) -> Option<&Type> { + let path = match ty { + Type::Path(tp) => &tp.path, + _ => return None, + }; + let segs: Vec<_> = path.segments.iter().collect(); + let last = path.segments.last()?; + if last.ident != "Result" { + return None; + } + let args = match &last.arguments { + syn::PathArguments::AngleBracketed(a) => &a.args, + _ => return None, + }; + if args.len() != 1 { + return None; + } + // Accept crate::error::Result or bare Result (from use) + let ok = segs.len() == 1 + || (segs.len() == 3 && segs[0].ident == "crate" && segs[1].ident == "error"); + if !ok { + return None; + } + match &args[0] { + GenericArgument::Type(t) => Some(t), + _ => None, + } +} + #[proc_macro_derive(ToJs)] pub fn derive_to_cljs(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as syn::DeriveInput); diff --git a/render-wasm/src/error.rs b/render-wasm/src/error.rs new file mode 100644 index 0000000000..413a81afff --- /dev/null +++ b/render-wasm/src/error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +pub const RECOVERABLE_ERROR: u8 = 0x01; +pub const CRITICAL_ERROR: u8 = 0x02; + +// This is not really dead code, #[wasm_error] macro replaces this by something else. +#[allow(dead_code)] +pub type Result = std::result::Result; + +#[derive(Error, Debug)] +pub enum Error { + #[error("[Recoverable] {0}")] + RecoverableError(String), + #[error("[Critical] {0}")] + CriticalError(String), +} + +impl From for u8 { + fn from(error: Error) -> Self { + match error { + Error::RecoverableError(_) => RECOVERABLE_ERROR, + Error::CriticalError(_) => CRITICAL_ERROR, + } + } +} diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index c435e03ded..6e519cc249 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -1,5 +1,6 @@ #[cfg(target_arch = "wasm32")] mod emscripten; +mod error; mod math; mod mem; mod options; @@ -14,12 +15,16 @@ mod view; mod wapi; mod wasm; +use std::collections::HashMap; + +#[allow(unused_imports)] +use crate::error::{Error, Result}; +use macros::wasm_error; use math::{Bounds, Matrix}; use mem::SerializableResult; use shapes::{StructureEntry, StructureEntryType, TransformEntry}; use skia_safe as skia; use state::State; -use std::collections::HashMap; use utils::uuid_from_u32_quartet; use uuid::Uuid; @@ -95,22 +100,27 @@ macro_rules! with_state_mut_current_shape { } #[no_mangle] -pub extern "C" fn init(width: i32, height: i32) { +#[wasm_error] +pub extern "C" fn init(width: i32, height: i32) -> Result<()> { let state_box = Box::new(State::new(width, height)); unsafe { STATE = Some(state_box); } + Ok(()) } #[no_mangle] -pub extern "C" fn set_browser(browser: u8) { +#[wasm_error] +pub extern "C" fn set_browser(browser: u8) -> Result<()> { with_state_mut!(state, { state.set_browser(browser); }); + Ok(()) } #[no_mangle] -pub extern "C" fn clean_up() { +#[wasm_error] +pub extern "C" fn clean_up() -> Result<()> { with_state_mut!(state, { // Cancel the current animation frame if it exists so // it won't try to render without context @@ -118,49 +128,60 @@ pub extern "C" fn clean_up() { render_state.cancel_animation_frame(); }); unsafe { STATE = None } - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] -pub extern "C" fn set_render_options(debug: u32, dpr: f32) { +#[wasm_error] +pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> { with_state_mut!(state, { let render_state = state.render_state_mut(); render_state.set_debug_flags(debug); render_state.set_dpr(dpr); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_canvas_background(raw_color: u32) { +#[wasm_error] +pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { with_state_mut!(state, { let color = skia::Color::new(raw_color); state.set_background_color(color); state.rebuild_tiles_shallow(); }); + + Ok(()) } #[no_mangle] -pub extern "C" fn render(_: i32) { +#[wasm_error] +pub extern "C" fn render(_: i32) -> Result<()> { with_state_mut!(state, { state.rebuild_touched_tiles(); state .start_render_loop(performance::get_time()) .expect("Error rendering"); }); + Ok(()) } #[no_mangle] -pub extern "C" fn render_sync() { +#[wasm_error] +pub extern "C" fn render_sync() -> Result<()> { with_state_mut!(state, { state.rebuild_tiles(); state .render_sync(performance::get_time()) .expect("Error rendering"); }); + Ok(()) } #[no_mangle] -pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) { +#[wasm_error] +pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { with_state_mut!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.use_shape(id); @@ -179,34 +200,42 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) { state.rebuild_tiles_from(Some(&id)); state .render_sync_shape(&id, performance::get_time()) - .expect("Error rendering"); + .map_err(|e| Error::RecoverableError(e.to_string()))?; }); + Ok(()) } #[no_mangle] -pub extern "C" fn render_from_cache(_: i32) { +#[wasm_error] +pub extern "C" fn render_from_cache(_: i32) -> Result<()> { with_state_mut!(state, { state.render_state.cancel_animation_frame(); state.render_from_cache(); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_preview_mode(enabled: bool) { +#[wasm_error] +pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> { with_state_mut!(state, { state.render_state.set_preview_mode(enabled); }); + Ok(()) } #[no_mangle] -pub extern "C" fn render_preview() { +#[wasm_error] +pub extern "C" fn render_preview() -> Result<()> { with_state_mut!(state, { state.render_preview(performance::get_time()); }); + Ok(()) } #[no_mangle] -pub extern "C" fn process_animation_frame(timestamp: i32) { +#[wasm_error] +pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> { let result = std::panic::catch_unwind(|| { with_state_mut!(state, { state @@ -225,37 +254,45 @@ pub extern "C" fn process_animation_frame(timestamp: i32) { std::panic::resume_unwind(err); } } + Ok(()) } #[no_mangle] -pub extern "C" fn reset_canvas() { +#[wasm_error] +pub extern "C" fn reset_canvas() -> Result<()> { with_state_mut!(state, { state.render_state_mut().reset_canvas(); }); + Ok(()) } #[no_mangle] -pub extern "C" fn resize_viewbox(width: i32, height: i32) { +#[wasm_error] +pub extern "C" fn resize_viewbox(width: i32, height: i32) -> Result<()> { with_state_mut!(state, { state.resize(width, height); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) { +#[wasm_error] +pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) -> Result<()> { with_state_mut!(state, { performance::begin_measure!("set_view"); let render_state = state.render_state_mut(); render_state.set_view(zoom, x, y); performance::end_measure!("set_view"); }); + Ok(()) } #[cfg(feature = "profile-macros")] static mut VIEW_INTERACTION_START: i32 = 0; #[no_mangle] -pub extern "C" fn set_view_start() { +#[wasm_error] +pub extern "C" fn set_view_start() -> Result<()> { with_state_mut!(state, { #[cfg(feature = "profile-macros")] unsafe { @@ -265,10 +302,12 @@ pub extern "C" fn set_view_start() { state.render_state.options.set_fast_mode(true); performance::end_measure!("set_view_start"); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_view_end() { +#[wasm_error] +pub extern "C" fn set_view_end() -> Result<()> { with_state_mut!(state, { let _end_start = performance::begin_timed_log!("set_view_end"); performance::begin_measure!("set_view_end"); @@ -304,17 +343,21 @@ pub extern "C" fn set_view_end() { performance::console_log!("[PERF] view_interaction: {}ms", total_time); } }); + Ok(()) } #[no_mangle] -pub extern "C" fn clear_focus_mode() { +#[wasm_error] +pub extern "C" fn clear_focus_mode() -> Result<()> { with_state_mut!(state, { state.clear_focus_mode(); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_focus_mode() { +#[wasm_error] +pub extern "C" fn set_focus_mode() -> Result<()> { let bytes = mem::bytes(); let entries: Vec = bytes @@ -325,83 +368,111 @@ pub extern "C" fn set_focus_mode() { with_state_mut!(state, { state.set_focus_mode(entries); }); + Ok(()) } #[no_mangle] -pub extern "C" fn init_shapes_pool(capacity: usize) { +#[wasm_error] +pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> { with_state_mut!(state, { state.init_shapes_pool(capacity); }); + Ok(()) } #[no_mangle] -pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) { +#[wasm_error] +pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { with_state_mut!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.use_shape(id); }); + Ok(()) } #[no_mangle] -pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) { +#[wasm_error] +pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { with_state_mut!(state, { let shape_id = uuid_from_u32_quartet(a, b, c, d); state.touch_shape(shape_id); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) { +#[wasm_error] +pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) -> Result<()> { with_state_mut!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.set_parent_for_current_shape(id); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_masked_group(masked: bool) { +#[wasm_error] +pub extern "C" fn set_shape_masked_group(masked: bool) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_masked(masked); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_selrect(left: f32, top: f32, right: f32, bottom: f32) { +#[wasm_error] +pub extern "C" fn set_shape_selrect(left: f32, top: f32, right: f32, bottom: f32) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_selrect(left, top, right, bottom); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_clip_content(clip_content: bool) { +#[wasm_error] +pub extern "C" fn set_shape_clip_content(clip_content: bool) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_clip(clip_content); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_rotation(rotation: f32) { +#[wasm_error] +pub extern "C" fn set_shape_rotation(rotation: f32) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_rotation(rotation); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_transform(a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) { +#[wasm_error] +pub extern "C" fn set_shape_transform( + a: f32, + b: f32, + c: f32, + d: f32, + e: f32, + f: f32, +) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_transform(a, b, c, d, e, f); }); + Ok(()) } #[no_mangle] -pub extern "C" fn add_shape_child(a: u32, b: u32, c: u32, d: u32) { +#[wasm_error] +pub extern "C" fn add_shape_child(a: u32, b: u32, c: u32, d: u32) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { let id = uuid_from_u32_quartet(a, b, c, d); shape.add_child(id); }); + Ok(()) } -fn set_children_set(entries: Vec) { +fn set_children_set(entries: Vec) -> Result<()> { let mut deleted = Vec::new(); let mut parent_id = None; @@ -420,7 +491,9 @@ fn set_children_set(entries: Vec) { with_state_mut!(state, { let Some(parent_id) = parent_id else { - return; + return Err(Error::RecoverableError( + "set_children_set: Parent ID not found".to_string(), + )); }; for id in deleted { @@ -428,21 +501,27 @@ fn set_children_set(entries: Vec) { state.touch_shape(id); } }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_children_0() { +#[wasm_error] +pub extern "C" fn set_children_0() -> Result<()> { let entries = vec![]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] -pub extern "C" fn set_children_1(a1: u32, b1: u32, c1: u32, d1: u32) { +#[wasm_error] +pub extern "C" fn set_children_1(a1: u32, b1: u32, c1: u32, d1: u32) -> Result<()> { let entries = vec![uuid_from_u32_quartet(a1, b1, c1, d1)]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] +#[wasm_error] pub extern "C" fn set_children_2( a1: u32, b1: u32, @@ -452,15 +531,17 @@ pub extern "C" fn set_children_2( b2: u32, c2: u32, d2: u32, -) { +) -> Result<()> { let entries = vec![ uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a2, b2, c2, d2), ]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] +#[wasm_error] pub extern "C" fn set_children_3( a1: u32, b1: u32, @@ -474,16 +555,18 @@ pub extern "C" fn set_children_3( b3: u32, c3: u32, d3: u32, -) { +) -> Result<()> { let entries = vec![ uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a2, b2, c2, d2), uuid_from_u32_quartet(a3, b3, c3, d3), ]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] +#[wasm_error] pub extern "C" fn set_children_4( a1: u32, b1: u32, @@ -501,17 +584,19 @@ pub extern "C" fn set_children_4( b4: u32, c4: u32, d4: u32, -) { +) -> Result<()> { let entries = vec![ uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a2, b2, c2, d2), uuid_from_u32_quartet(a3, b3, c3, d3), uuid_from_u32_quartet(a4, b4, c4, d4), ]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] +#[wasm_error] pub extern "C" fn set_children_5( a1: u32, b1: u32, @@ -533,7 +618,7 @@ pub extern "C" fn set_children_5( b5: u32, c5: u32, d5: u32, -) { +) -> Result<()> { let entries = vec![ uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a2, b2, c2, d2), @@ -541,11 +626,13 @@ pub extern "C" fn set_children_5( uuid_from_u32_quartet(a4, b4, c4, d4), uuid_from_u32_quartet(a5, b5, c5, d5), ]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] -pub extern "C" fn set_children() { +#[wasm_error] +pub extern "C" fn set_children() -> Result<()> { let bytes = mem::bytes_or_empty(); let entries: Vec = bytes @@ -553,58 +640,76 @@ pub extern "C" fn set_children() { .map(|data| Uuid::try_from(data).unwrap()) .collect(); - set_children_set(entries); + set_children_set(entries)?; if !bytes.is_empty() { - mem::free_bytes(); + mem::free_bytes()?; } + + Ok(()) } #[no_mangle] -pub extern "C" fn is_image_cached(a: u32, b: u32, c: u32, d: u32, is_thumbnail: bool) -> bool { +#[wasm_error] +pub extern "C" fn is_image_cached( + a: u32, + b: u32, + c: u32, + d: u32, + is_thumbnail: bool, +) -> Result { with_state_mut!(state, { let id = uuid_from_u32_quartet(a, b, c, d); - state.render_state().has_image(&id, is_thumbnail) + let result = state.render_state().has_image(&id, is_thumbnail); + Ok(result) }) } #[no_mangle] -pub extern "C" fn set_shape_svg_raw_content() { +#[wasm_error] +pub extern "C" fn set_shape_svg_raw_content() -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { let bytes = mem::bytes(); let svg_raw_content = String::from_utf8(bytes) - .unwrap() + .map_err(|e| Error::RecoverableError(e.to_string()))? .trim_end_matches('\0') .to_string(); - shape - .set_svg_raw_content(svg_raw_content) - .expect("Failed to set svg raw content"); + shape.set_svg_raw_content(svg_raw_content); }); + + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_opacity(opacity: f32) { +#[wasm_error] +pub extern "C" fn set_shape_opacity(opacity: f32) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_opacity(opacity); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_hidden(hidden: bool) { +#[wasm_error] +pub extern "C" fn set_shape_hidden(hidden: bool) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_hidden(hidden); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_corners(r1: f32, r2: f32, r3: f32, r4: f32) { +#[wasm_error] +pub extern "C" fn set_shape_corners(r1: f32, r2: f32, r3: f32, r4: f32) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_corners((r1, r2, r3, r4)); }); + Ok(()) } #[no_mangle] -pub extern "C" fn get_selection_rect() -> *mut u8 { +#[wasm_error] +pub extern "C" fn get_selection_rect() -> Result<*mut u8> { let bytes = mem::bytes(); let entries: Vec = bytes @@ -619,40 +724,41 @@ pub extern "C" fn get_selection_rect() -> *mut u8 { }) .collect(); - with_state_mut!(state, { + let result_bound = with_state_mut!(state, { let bbs: Vec<_> = entries .iter() .flat_map(|id| state.shapes.get(id).map(|b| b.bounds())) .collect(); - let result_bound = if bbs.len() == 1 { + if bbs.len() == 1 { bbs[0] } else { Bounds::join_bounds(&bbs) - }; + } + }); - let width = result_bound.width(); - let height = result_bound.height(); - let center = result_bound.center(); - let transform = result_bound.transform_matrix().unwrap_or(Matrix::default()); + let width = result_bound.width(); + let height = result_bound.height(); + let center = result_bound.center(); + let transform = result_bound.transform_matrix().unwrap_or(Matrix::default()); - let mut bytes = vec![0; 40]; - bytes[0..4].clone_from_slice(&width.to_le_bytes()); - bytes[4..8].clone_from_slice(&height.to_le_bytes()); - bytes[8..12].clone_from_slice(¢er.x.to_le_bytes()); - bytes[12..16].clone_from_slice(¢er.y.to_le_bytes()); - bytes[16..20].clone_from_slice(&transform[0].to_le_bytes()); - bytes[20..24].clone_from_slice(&transform[3].to_le_bytes()); - bytes[24..28].clone_from_slice(&transform[1].to_le_bytes()); - bytes[28..32].clone_from_slice(&transform[4].to_le_bytes()); - bytes[32..36].clone_from_slice(&transform[2].to_le_bytes()); - bytes[36..40].clone_from_slice(&transform[5].to_le_bytes()); - mem::write_bytes(bytes) - }) + let mut bytes = vec![0; 40]; + bytes[0..4].clone_from_slice(&width.to_le_bytes()); + bytes[4..8].clone_from_slice(&height.to_le_bytes()); + bytes[8..12].clone_from_slice(¢er.x.to_le_bytes()); + bytes[12..16].clone_from_slice(¢er.y.to_le_bytes()); + bytes[16..20].clone_from_slice(&transform[0].to_le_bytes()); + bytes[20..24].clone_from_slice(&transform[3].to_le_bytes()); + bytes[24..28].clone_from_slice(&transform[1].to_le_bytes()); + bytes[28..32].clone_from_slice(&transform[4].to_le_bytes()); + bytes[32..36].clone_from_slice(&transform[2].to_le_bytes()); + bytes[36..40].clone_from_slice(&transform[5].to_le_bytes()); + Ok(mem::write_bytes(bytes)) } #[no_mangle] -pub extern "C" fn set_structure_modifiers() { +#[wasm_error] +pub extern "C" fn set_structure_modifiers() -> Result<()> { let bytes = mem::bytes(); let entries: Vec<_> = bytes @@ -690,18 +796,22 @@ pub extern "C" fn set_structure_modifiers() { } }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] -pub extern "C" fn clean_modifiers() { +#[wasm_error] +pub extern "C" fn clean_modifiers() -> Result<()> { with_state_mut!(state, { state.shapes.clean_all(); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_modifiers() { +#[wasm_error] +pub extern "C" fn set_modifiers() -> Result<()> { let bytes = mem::bytes(); let entries: Vec<_> = bytes @@ -720,26 +830,31 @@ pub extern "C" fn set_modifiers() { state.set_modifiers(modifiers); state.rebuild_modifier_tiles(ids); }); + Ok(()) } #[no_mangle] -pub extern "C" fn start_temp_objects() { +#[wasm_error] +pub extern "C" fn start_temp_objects() -> Result<()> { unsafe { #[allow(static_mut_refs)] let mut state = STATE.take().expect("Got an invalid state pointer"); state = Box::new(state.start_temp_objects()); STATE = Some(state); } + Ok(()) } #[no_mangle] -pub extern "C" fn end_temp_objects() { +#[wasm_error] +pub extern "C" fn end_temp_objects() -> Result<()> { unsafe { #[allow(static_mut_refs)] let mut state = STATE.take().expect("Got an invalid state pointer"); state = Box::new(state.end_temp_objects()); STATE = Some(state); } + Ok(()) } fn main() { diff --git a/render-wasm/src/mem.rs b/render-wasm/src/mem.rs index 54cf4aa9d1..d03cb4fdc1 100644 --- a/render-wasm/src/mem.rs +++ b/render-wasm/src/mem.rs @@ -1,29 +1,29 @@ -use std::alloc::{alloc, Layout}; -use std::ptr; use std::sync::Mutex; -const LAYOUT_ALIGN: usize = 4; +use crate::error::{Error, Result, CRITICAL_ERROR}; -static BUFFERU8: Mutex>> = Mutex::new(None); +pub const LAYOUT_ALIGN: usize = 4; + +pub static BUFFERU8: Mutex>> = Mutex::new(None); +pub static BUFFER_ERROR: Mutex = Mutex::new(0x00); + +pub fn clear_error_code() { + let mut guard = BUFFER_ERROR.lock().unwrap(); + *guard = 0x00; +} + +/// Sets the error buffer from a byte. Used by #[wasm_error] when E: Into. +pub fn set_error_code(code: u8) { + let mut guard = BUFFER_ERROR.lock().unwrap(); + *guard = code; +} #[no_mangle] -pub extern "C" fn alloc_bytes(len: usize) -> *mut u8 { - let mut guard = BUFFERU8.lock().unwrap(); - - if guard.is_some() { - panic!("Bytes already allocated"); - } - - unsafe { - let layout = Layout::from_size_align_unchecked(len, LAYOUT_ALIGN); - let ptr = alloc(layout); - if ptr.is_null() { - panic!("Allocation failed"); - } - // TODO: Maybe this could be removed. - ptr::write_bytes(ptr, 0, len); - *guard = Some(Vec::from_raw_parts(ptr, len, len)); - ptr +pub extern "C" fn read_error_code() -> u8 { + if let Ok(guard) = BUFFER_ERROR.lock() { + *guard + } else { + CRITICAL_ERROR } } @@ -40,13 +40,6 @@ pub fn write_bytes(mut bytes: Vec) -> *mut u8 { ptr } -#[no_mangle] -pub extern "C" fn free_bytes() { - let mut guard = BUFFERU8.lock().unwrap(); - *guard = None; - std::mem::drop(guard); -} - pub fn bytes() -> Vec { let mut guard = BUFFERU8.lock().unwrap(); guard.take().expect("Buffer is not initialized") @@ -57,6 +50,15 @@ pub fn bytes_or_empty() -> Vec { guard.take().unwrap_or_default() } +pub fn free_bytes() -> Result<()> { + let mut guard = BUFFERU8 + .lock() + .map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?; + *guard = None; + std::mem::drop(guard); + Ok(()) +} + pub trait SerializableResult: From + Into { type BytesType; fn clone_to_slice(&self, slice: &mut [u8]); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 8e7e1e7c99..ef12164896 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -705,9 +705,8 @@ impl Shape { self.invalidate_extrect(); } - pub fn set_svg_raw_content(&mut self, content: String) -> Result<(), String> { + pub fn set_svg_raw_content(&mut self, content: String) { self.shape_type = Type::SVGRaw(SVGRaw::from_content(content)); - Ok(()) } pub fn set_blend_mode(&mut self, mode: BlendMode) { diff --git a/render-wasm/src/wasm.rs b/render-wasm/src/wasm.rs index 47578e5b9c..c33b614cd3 100644 --- a/render-wasm/src/wasm.rs +++ b/render-wasm/src/wasm.rs @@ -3,6 +3,7 @@ pub mod blurs; pub mod fills; pub mod fonts; pub mod layouts; +pub mod mem; pub mod paths; pub mod shadows; pub mod shapes; diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index 86f0e57e8e..8513856455 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -1,4 +1,4 @@ -use macros::ToJs; +use macros::{wasm_error, ToJs}; use crate::mem; use crate::shapes; @@ -67,7 +67,8 @@ pub fn parse_fills_from_bytes(buffer: &[u8], num_fills: usize) -> Vec Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { let bytes = mem::bytes(); // The first byte contains the actual number of fills @@ -75,8 +76,9 @@ pub extern "C" fn set_shape_fills() { // Skip the first 4 bytes (header with fill count) and parse only the actual fills let fills = parse_fills_from_bytes(&bytes[4..], num_fills); shape.set_fills(fills); - mem::free_bytes(); + mem::free_bytes()?; }); + Ok(()) } #[no_mangle] diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index 9c7a5d312b..f0e5b36526 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -1,5 +1,7 @@ use crate::mem; +use macros::wasm_error; // use crate::mem::SerializableResult; +use crate::error::Error; use crate::uuid::Uuid; use crate::with_state_mut; use crate::STATE; @@ -65,7 +67,8 @@ impl TryFrom> for ShapeImageIds { } #[no_mangle] -pub extern "C" fn store_image() { +#[wasm_error] +pub extern "C" fn store_image() -> crate::error::Result<()> { let bytes = mem::bytes(); let ids = ShapeImageIds::try_from(bytes[0..IMAGE_IDS_SIZE].to_vec()).unwrap(); @@ -87,7 +90,8 @@ pub extern "C" fn store_image() { state.touch_shape(ids.shape_id); }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } /// Stores an image from an existing WebGL texture, avoiding re-decoding @@ -99,13 +103,17 @@ pub extern "C" fn store_image() { /// - bytes 40-43: width (i32) /// - bytes 44-47: height (i32) #[no_mangle] -pub extern "C" fn store_image_from_texture() { +#[wasm_error] +pub extern "C" fn store_image_from_texture() -> crate::error::Result<()> { let bytes = mem::bytes(); if bytes.len() < 48 { + // FIXME: Review if this should be an critical or a recoverable error. eprintln!("store_image_from_texture: insufficient data"); - mem::free_bytes(); - return; + mem::free_bytes()?; + return Err(Error::RecoverableError( + "store_image_from_texture: insufficient data".to_string(), + )); } let ids = ShapeImageIds::try_from(bytes[0..IMAGE_IDS_SIZE].to_vec()).unwrap(); @@ -139,5 +147,6 @@ pub extern "C" fn store_image_from_texture() { state.touch_shape(ids.shape_id); }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index b4a604e0e5..f4723e20c3 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -1,4 +1,4 @@ -use macros::ToJs; +use macros::{wasm_error, ToJs}; use crate::mem; use crate::shapes::{FontFamily, FontStyle}; @@ -30,6 +30,7 @@ impl From for FontStyle { } #[no_mangle] +#[wasm_error] pub extern "C" fn store_font( a: u32, b: u32, @@ -39,7 +40,7 @@ pub extern "C" fn store_font( style: u8, is_emoji: bool, is_fallback: bool, -) { +) -> Result<()> { with_state_mut!(state, { let id = uuid_from_u32_quartet(a, b, c, d); let font_bytes = mem::bytes(); @@ -52,8 +53,9 @@ pub extern "C" fn store_font( .fonts_mut() .add(family, &font_bytes, is_emoji, is_fallback); - mem::free_bytes(); + mem::free_bytes()?; }); + Ok(()) } #[no_mangle] diff --git a/render-wasm/src/wasm/layouts/grid.rs b/render-wasm/src/wasm/layouts/grid.rs index 096468e514..d1a0476814 100644 --- a/render-wasm/src/wasm/layouts/grid.rs +++ b/render-wasm/src/wasm/layouts/grid.rs @@ -1,4 +1,4 @@ -use macros::ToJs; +use macros::{wasm_error, ToJs}; use crate::mem; use crate::shapes::{GridCell, GridDirection, GridTrack, GridTrackType}; @@ -7,6 +7,9 @@ use crate::{uuid_from_u32_quartet, with_current_shape_mut, with_state, with_stat use super::align; +#[allow(unused_imports)] +use crate::error::Result; + #[derive(Debug)] #[repr(C, align(1))] struct RawGridCell { @@ -168,7 +171,8 @@ pub extern "C" fn set_grid_layout_data( } #[no_mangle] -pub extern "C" fn set_grid_columns() { +#[wasm_error] +pub extern "C" fn set_grid_columns() -> Result<()> { let bytes = mem::bytes(); let entries: Vec = bytes @@ -181,11 +185,13 @@ pub extern "C" fn set_grid_columns() { shape.set_grid_columns(entries); }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] -pub extern "C" fn set_grid_rows() { +#[wasm_error] +pub extern "C" fn set_grid_rows() -> Result<()> { let bytes = mem::bytes(); let entries: Vec = bytes @@ -198,11 +204,13 @@ pub extern "C" fn set_grid_rows() { shape.set_grid_rows(entries); }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] -pub extern "C" fn set_grid_cells() { +#[wasm_error] +pub extern "C" fn set_grid_cells() -> Result<()> { let bytes = mem::bytes(); let cells: Vec = bytes @@ -215,7 +223,8 @@ pub extern "C" fn set_grid_cells() { shape.set_grid_cells(cells.into_iter().map(|raw| raw.into()).collect()); }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] diff --git a/render-wasm/src/wasm/mem.rs b/render-wasm/src/wasm/mem.rs new file mode 100644 index 0000000000..8f6b7508ef --- /dev/null +++ b/render-wasm/src/wasm/mem.rs @@ -0,0 +1,38 @@ +use std::alloc::{alloc, Layout}; +use std::ptr; + +#[allow(unused_imports)] +use crate::error::{Error, Result}; +use crate::mem::{BUFFERU8, LAYOUT_ALIGN}; +use macros::wasm_error; + +#[no_mangle] +#[wasm_error] +pub extern "C" fn alloc_bytes(len: usize) -> Result<*mut u8> { + let mut guard = BUFFERU8 + .lock() + .map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?; + + if guard.is_some() { + return Err(Error::CriticalError("Bytes already allocated".to_string())); + } + + unsafe { + let layout = Layout::from_size_align_unchecked(len, LAYOUT_ALIGN); + let ptr = alloc(layout); + if ptr.is_null() { + return Err(Error::CriticalError("Allocation failed".to_string())); + } + // TODO: Maybe this could be removed. + ptr::write_bytes(ptr, 0, len); + *guard = Some(Vec::from_raw_parts(ptr, len, len)); + Ok(ptr) + } +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn free_bytes() -> Result<()> { + crate::mem::free_bytes()?; + Ok(()) +} diff --git a/render-wasm/src/wasm/paths.rs b/render-wasm/src/wasm/paths.rs index 815c9d2804..0748111533 100644 --- a/render-wasm/src/wasm/paths.rs +++ b/render-wasm/src/wasm/paths.rs @@ -1,5 +1,5 @@ #![allow(unused_mut, unused_variables)] -use macros::ToJs; +use macros::{wasm_error, ToJs}; use mem::SerializableResult; use std::mem::size_of; use std::sync::{Mutex, OnceLock}; @@ -161,12 +161,14 @@ pub extern "C" fn start_shape_path_buffer() { } #[no_mangle] -pub extern "C" fn set_shape_path_chunk_buffer() { +#[wasm_error] +pub extern "C" fn set_shape_path_chunk_buffer() -> Result<()> { let bytes = mem::bytes(); let buffer = get_path_upload_buffer(); let mut buffer = buffer.lock().unwrap(); buffer.extend_from_slice(&bytes); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] diff --git a/render-wasm/src/wasm/paths/bools.rs b/render-wasm/src/wasm/paths/bools.rs index 36bd0e4440..c19791dc95 100644 --- a/render-wasm/src/wasm/paths/bools.rs +++ b/render-wasm/src/wasm/paths/bools.rs @@ -1,4 +1,4 @@ -use macros::ToJs; +use macros::{wasm_error, ToJs}; use super::RawSegmentData; use crate::math; @@ -8,6 +8,9 @@ use crate::{mem, SerializableResult}; use crate::{with_current_shape_mut, with_state, STATE}; use std::mem::size_of; +#[allow(unused_imports)] +use crate::error::{Error, Result}; + #[derive(Debug, Clone, Copy, PartialEq, ToJs)] #[repr(u8)] #[allow(dead_code)] @@ -43,15 +46,19 @@ pub extern "C" fn set_shape_bool_type(raw_bool_type: u8) { } #[no_mangle] -pub extern "C" fn calculate_bool(raw_bool_type: u8) -> *mut u8 { +#[wasm_error] +pub extern "C" fn calculate_bool(raw_bool_type: u8) -> Result<*mut u8> { let bytes = mem::bytes_or_empty(); let entries: Vec = bytes .chunks(size_of::<::BytesType>()) - .map(|data| Uuid::try_from(data).unwrap()) - .collect(); + .map(|data| { + // FIXME: Review if this should be an critical or a recoverable error. + Uuid::try_from(data).map_err(|_| Error::RecoverableError("Invalid UUID".to_string())) + }) + .collect::>>()?; - mem::free_bytes(); + mem::free_bytes()?; let bool_type = RawBoolType::from(raw_bool_type).into(); let result; @@ -64,5 +71,5 @@ pub extern "C" fn calculate_bool(raw_bool_type: u8) -> *mut u8 { .map(RawSegmentData::from_segment) .collect(); }); - mem::write_vec(result) + Ok(mem::write_vec(result)) } diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index ab4b14541e..737925b9b4 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -1,4 +1,4 @@ -use macros::ToJs; +use macros::{wasm_error, ToJs}; use super::{fills::RawFillData, fonts::RawFontStyle}; @@ -9,6 +9,8 @@ use crate::shapes::{ use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_mut, STATE}; +use crate::error::Error; + const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::(); const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::(); @@ -285,16 +287,22 @@ pub extern "C" fn clear_shape_text() { } #[no_mangle] -pub extern "C" fn set_shape_text_content() { +#[wasm_error] +pub extern "C" fn set_shape_text_content() -> crate::error::Result<()> { let bytes = mem::bytes(); with_current_shape_mut!(state, |shape: &mut Shape| { let raw_text_data = RawParagraph::try_from(&bytes).unwrap(); - if shape.add_paragraph(raw_text_data.into()).is_err() { - println!("Error with set_shape_text_content on {:?}", shape.id); - } + shape.add_paragraph(raw_text_data.into()).map_err(|_| { + Error::RecoverableError(format!( + "Error with set_shape_text_content on {:?}", + shape.id + )) + })?; }); - mem::free_bytes(); + + mem::free_bytes()?; + Ok(()) } #[no_mangle] diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 7d2b6aac93..e0512692ea 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1,3 +1,5 @@ +use macros::{wasm_error, ToJs}; + use crate::math::{Matrix, Point, Rect}; use crate::mem; use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; @@ -5,7 +7,6 @@ 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}; -use macros::ToJs; use skia_safe::Color; #[derive(PartialEq, ToJs)] @@ -263,29 +264,31 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { // TEXT OPERATIONS // ============================================================================ +// FIXME: Review if all the return Ok(()) should be Err instead. #[no_mangle] -pub extern "C" fn text_editor_insert_text() { +#[wasm_error] +pub extern "C" fn text_editor_insert_text() -> Result<()> { let bytes = crate::mem::bytes(); let text = match String::from_utf8(bytes) { - Ok(s) => s, - Err(_) => return, + Ok(text) => text, + Err(_) => return Ok(()), }; with_state_mut!(state, { if !state.text_editor_state.is_active { - return; + return Ok(()); } let Some(shape_id) = state.text_editor_state.active_shape_id else { - return; + return Ok(()); }; let Some(shape) = state.shapes.get_mut(&shape_id) else { - return; + return Ok(()); }; let Type::Text(text_content) = &mut shape.shape_type else { - return; + return Ok(()); }; let selection = state.text_editor_state.selection; @@ -316,7 +319,8 @@ pub extern "C" fn text_editor_insert_text() { state.render_state.mark_touched(shape_id); }); - crate::mem::free_bytes(); + crate::mem::free_bytes()?; + Ok(()) } #[no_mangle] From 84539dac1f62c961bc321b240372dcd73b2f9a21 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 9 Mar 2026 11:14:39 +0100 Subject: [PATCH 12/26] :bug: Fix non-uniform stroke scaling on path shapes in WASM renderer --- render-wasm/src/render/strokes.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 8028316e03..dc77e4e246 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -164,16 +164,25 @@ fn draw_stroke_on_path( blur: Option<&ImageFilter>, antialias: bool, ) { - let skia_path = path - .to_skia_path() - .make_transform(path_transform.unwrap_or(&Matrix::default())); - let is_open = path.is_open(); let mut draw_paint = paint.clone(); let filter = compose_filters(blur, shadow); draw_paint.set_image_filter(filter); + // Move path_transform from the path geometry to the canvas so the + // stroke width is not distorted by non-uniform shape scaling. + // The path coordinates are already in world space, so we draw the + // raw path on a canvas where the shape transform has been undone: + // canvas * path_transform = View × parents (no shape scale/rotation) + // This matches the SVG renderer, which bakes the transform into path + // coordinates and never sets a transform attribute on the element. + let save_count = canvas.save(); + if let Some(pt) = path_transform { + canvas.concat(pt); + } + let skia_path = path.to_skia_path(); + match stroke.render_kind(is_open) { StrokeKind::Inner => { draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias); @@ -187,6 +196,8 @@ fn draw_stroke_on_path( } handle_stroke_caps(&skia_path, stroke, canvas, is_open, paint, blur, antialias); + + canvas.restore_to_count(save_count); } fn handle_stroke_cap( From a2c89a816aa4045f97884e01a5eb132075a4cc85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 9 Mar 2026 16:50:55 +0100 Subject: [PATCH 13/26] :bug: Fix ordering of absolute shapes with no z-index --- render-wasm/src/render.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index ad621df836..12d4fcc2b7 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2180,13 +2180,13 @@ impl RenderState { ids.reverse(); } // Sort by z_index descending (higher z renders on top). - // When z_index is equal, absolute children go behind - // non-absolute children (false < true). + // When z_index is equal, absolute children go above + // non-absolute children ids.sort_by_key(|id| { let s = tree.get(id); let z = s.map(|s| s.z_index()).unwrap_or(0); let abs = s.map(|s| s.is_absolute()).unwrap_or(false); - (std::cmp::Reverse(z), abs) + (std::cmp::Reverse(z), !abs) }); ids } else { From d9487610908aea41cda4f55ee759274af998d7e9 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 9 Mar 2026 16:45:27 +0100 Subject: [PATCH 14/26] :bug: Fix WebGL context lost error to raise an exception and show the exception page --- .../playwright/ui/specs/render-wasm.spec.js | 23 +++++++++++++++++++ frontend/src/app/main/errors.cljs | 9 +++++++- frontend/src/app/main/ui/static.cljs | 8 +++++-- frontend/src/app/render_wasm/api.cljs | 4 +++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/frontend/playwright/ui/specs/render-wasm.spec.js b/frontend/playwright/ui/specs/render-wasm.spec.js index 1c336bf6a8..c764df70b1 100644 --- a/frontend/playwright/ui/specs/render-wasm.spec.js +++ b/frontend/playwright/ui/specs/render-wasm.spec.js @@ -16,6 +16,29 @@ test.skip("BUG 10867 - Crash when loading comments", async ({ page }) => { ).toBeVisible(); }); +test("BUG 13541 - Shows error page when WebGL context is lost", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + await workspacePage.waitForFirstRender(); + + // Simulate a WebGL context loss by dispatching the event on the canvas + await workspacePage.canvas.evaluate((canvas) => { + const event = new Event("webglcontextlost", { cancelable: true }); + canvas.dispatchEvent(event); + }); + + await expect( + page.getByText("Oops! The canvas context was lost"), + ).toBeVisible(); + await expect( + page.getByText("WebGL has stopped working"), + ).toBeVisible(); + await expect(page.getByText("Reload page")).toBeVisible(); +}); + test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({ page, }) => { diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 6466e414da..9968e4641a 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -127,6 +127,13 @@ (ex/print-throwable cause :prefix "WASM critical error")) (st/emit! (rt/assign-exception error))) +(defmethod ptk/handle-error :wasm-exception + [error] + (when-let [cause (::instance error)] + (let [prefix (or (:prefix error) "Exception")] + (ex/print-throwable cause :prefix prefix))) + (st/emit! (rt/assign-exception error))) + ;; We receive a explicit authentication error; If the uri is for ;; workspace, dashboard, viewer or settings, then assign the exception ;; for show the error page. Otherwise this explicitly clears all @@ -343,7 +350,7 @@ (set! last-exception cause) (let [data (ex-data cause) type (get data :type)] - (if (#{:wasm-critical :wasm-non-blocking} type) + (if (#{:wasm-critical :wasm-non-blocking :wasm-exception} type) (on-error cause) (when-not (is-ignorable-exception? cause) (ex/print-throwable cause :prefix "Uncaught Exception") diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 7f0020ccd3..e589322368 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -478,8 +478,12 @@ :service-unavailable [:> service-unavailable*] - :webgl-context-lost - [:> webgl-context-lost*] + :wasm-exception + (case (get data :exception-type) + :webgl-context-lost + [:> webgl-context-lost*] + + [:> internal-error* props]) [:> internal-error* props]))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 8f037e2f9d..82fb16ebaa 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1421,7 +1421,9 @@ (dom/prevent-default event) (reset! wasm/context-lost? true) (log/warn :hint "WebGL context lost") - (ex/raise :type :webgl-context-lost + (ex/raise :type :wasm-exception + :exception-type :webgl-context-lost + :prefix "WebGL context lost" :hint "WebGL context lost")) (defn init-canvas-context From 3e0cef4a3cfe0da3a54dfdd72729fe5ca1d44a74 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 10 Mar 2026 07:34:51 +0100 Subject: [PATCH 15/26] :bug: Fix embedded editor deselect text shape --- .../src/app/main/ui/workspace/shapes/text/v3_editor.cljs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index b97c703132..9fd4a23e99 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -286,7 +286,12 @@ (mf/deps contenteditable-ref) (fn [] (when-let [node (mf/ref-val contenteditable-ref)] - (.focus node)))) + (.focus node)) + ;; Explicitly call on-blur here instead of relying on browser blur events, + ;; because in Firefox blur is not reliably fired when leaving the text editor + ;; by clicking elsewhere. The component does unmount when the shape is + ;; deselected, so we can safely call the blur handler here to finalize the editor. + on-blur)) (mf/use-effect (fn [] From 024f779cabce4c4d8ef820182413835f0d0e7148 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 10 Mar 2026 09:13:26 +0100 Subject: [PATCH 16/26] :bug: Fix text stroke opacity causing different colors on overlapping glyphs --- ...strokes-and-not-100-percent-opacities.json | 737 ++++++++++++++++++ .../ui/render-wasm-specs/texts.spec.js | 20 + ...p-with-strokes-and-not-100-opacities-1.png | Bin 0 -> 80725 bytes render-wasm/src/render.rs | 28 +- render-wasm/src/render/shadows.rs | 2 + render-wasm/src/render/text.rs | 89 ++- render-wasm/src/shapes/fills.rs | 32 + 7 files changed, 885 insertions(+), 23 deletions(-) create mode 100644 frontend/playwright/data/render-wasm/get-file-strokes-and-not-100-percent-opacities.json create mode 100644 frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png diff --git a/frontend/playwright/data/render-wasm/get-file-strokes-and-not-100-percent-opacities.json b/frontend/playwright/data/render-wasm/get-file-strokes-and-not-100-percent-opacities.json new file mode 100644 index 0000000000..d5e9e7a363 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-strokes-and-not-100-percent-opacities.json @@ -0,0 +1,737 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u0b5bcbca-32ab-81eb-8005-a15fc4484678", + "~: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": "holadios", + "~:revn": 54, + "~:modified-at": "~m1773136426990", + "~:vern": 0, + "~:id": "~ueffcbebc-b8c8-802f-8007-b0ebecd7ebf4", + "~: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", + "0004-clean-shadow-color", + "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", + "0016-copy-fills-from-position-data-to-text-node", + "0015-clean-shadow-color" + ] + }, + "~:version": 67, + "~:project-id": "~u0b5bcbca-32ab-81eb-8005-a15fc448f334", + "~:created-at": "~m1773127290716", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~u3e9e17c3-fc57-80ce-8007-101743996fe9" + ], + "~:pages-index": { + "~u3e9e17c3-fc57-80ce-8007-101743996fe9": { + "~:id": "~u3e9e17c3-fc57-80ce-8007-101743996fe9", + "~:name": "Page 1", + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u3e9e17c3-fc57-80ce-8007-101743996fe9", + "~: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, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 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, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~u7d004cdb-8305-806a-8007-b0f01ee65230", + "~u2ae0abdc-99ff-8009-8007-b0f7f123b5ea", + "~u2ae0abdc-99ff-8009-8007-b0f7f45177dc" + ] + } + }, + "~u7d004cdb-8305-806a-8007-b0f01ee65230": { + "~#shape": { + "~:y": -161.000001410182, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "6hv3a5x8wb", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "219sqyfv11", + "~:font-size": "250", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "HELLO WORLD" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "21rct71nkal", + "~:font-size": "250", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "HELLO WORLD", + "~:width": 1529.00000393592, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 109.999998223851, + "~:y": -161.000001410182 + } + }, + { + "~#point": { + "~:x": 1639.00000215977, + "~:y": -161.000001410182 + } + }, + { + "~#point": { + "~:x": 1639.00000215977, + "~:y": 138.999988970841 + } + }, + { + "~#point": { + "~:x": 109.999998223851, + "~:y": 138.999988970841 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u7d004cdb-8305-806a-8007-b0f01ee65230", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": 150.869995117188, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "250", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 1528.56005859375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:x": 110, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 323.739990234375, + "~:text": "HELLO WORLD" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 50, + "~:stroke-color": "#43e50b", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 109.999998223851, + "~:selrect": { + "~#rect": { + "~:x": 109.999998223851, + "~:y": -161.000001410182, + "~:width": 1529.00000393592, + "~:height": 299.999990381022, + "~:x1": 109.999998223851, + "~:y1": -161.000001410182, + "~:x2": 1639.00000215977, + "~:y2": 138.999988970841 + } + }, + "~:flip-x": null, + "~:height": 299.999990381022, + "~:flip-y": null + } + }, + "~u2ae0abdc-99ff-8009-8007-b0f7f123b5ea": { + "~#shape": { + "~:y": -462.000004970439, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "6hv3a5x8wb", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "219sqyfv11", + "~:font-size": "250", + "~:font-weight": "400", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "HELLO WORLD" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "21rct71nkal", + "~:font-size": "250", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "HELLO WORLD", + "~:width": 1529.00000393592, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 92.9999982852505, + "~:y": -462.000004970439 + } + }, + { + "~#point": { + "~:x": 1622.00000222117, + "~:y": -462.000004970439 + } + }, + { + "~#point": { + "~:x": 1622.00000222117, + "~:y": -162.000040815452 + } + }, + { + "~#point": { + "~:x": 92.9999982852505, + "~:y": -162.000040815452 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u2ae0abdc-99ff-8009-8007-b0f7f123b5ea", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": -150.130004882813, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "250", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 1528.56005859375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:x": 93, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 323.739990234375, + "~:text": "HELLO WORLD" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 50, + "~:stroke-color": "#43e50b", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 92.9999982852505, + "~:selrect": { + "~#rect": { + "~:x": 92.9999982852505, + "~:y": -462.000004970439, + "~:width": 1529.00000393592, + "~:height": 299.999964154987, + "~:x1": 92.9999982852505, + "~:y1": -462.000004970439, + "~:x2": 1622.00000222117, + "~:y2": -162.000040815452 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 299.999964154987, + "~:flip-y": null + } + }, + "~u2ae0abdc-99ff-8009-8007-b0f7f45177dc": { + "~#shape": { + "~:y": 169.999996321908, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "6hv3a5x8wb", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "219sqyfv11", + "~:font-size": "250", + "~:font-weight": "400", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "HELLO WORLD" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "21rct71nkal", + "~:font-size": "250", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "HELLO WORLD", + "~:width": 1529.00000393592, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 92.9999984013972, + "~:y": 169.999996321908 + } + }, + { + "~#point": { + "~:x": 1622.00000233732, + "~:y": 169.999996321908 + } + }, + { + "~#point": { + "~:x": 1622.00000233732, + "~:y": 470.000003392238 + } + }, + { + "~#point": { + "~:x": 92.9999984013972, + "~:y": 470.000003392238 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u2ae0abdc-99ff-8009-8007-b0f7f45177dc", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": 481.869995117188, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "250", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 1528.56005859375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:x": 93, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 323.739990234375, + "~:text": "HELLO WORLD" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 50, + "~:stroke-color": "#43e50b", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 92.9999984013973, + "~:selrect": { + "~#rect": { + "~:x": 92.9999984013973, + "~:y": 169.999996321908, + "~:width": 1529.00000393592, + "~:height": 300.00000707033, + "~:x1": 92.9999984013973, + "~:y1": 169.999996321908, + "~:x2": 1622.00000233732, + "~:y2": 470.000003392238 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 300.00000707033, + "~:flip-y": null + } + } + } + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-b0ebecd7ebf4", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js b/frontend/playwright/ui/render-wasm-specs/texts.spec.js index d837a64834..23a4701bbd 100644 --- a/frontend/playwright/ui/render-wasm-specs/texts.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/texts.spec.js @@ -587,3 +587,23 @@ test.skip("Updates text alignment edition - part 3", async ({ page }) => { await expect(workspace.canvas).toHaveScreenshot({ timeout: 10000 }); }); + + +test("Renders a file with group with strokes and not 100% opacities", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-strokes-and-not-100-percent-opacities.json"); + + await workspace.goToWorkspace({ + id: "effcbebc-b8c8-802f-8007-b0ebecd7ebf4", + pageId: "3e9e17c3-fc57-80ce-8007-101743996fe9", + }); + + await workspace.waitForFirstRenderWithoutUI(); + await expect(workspace.canvas).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0.01, + }); +}); \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png new file mode 100644 index 0000000000000000000000000000000000000000..989559cc021e6376ce6ea68dc3d5e4347aab987e GIT binary patch literal 80725 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

3$QmQX<;^x) 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

  • X7W7-bdTTqv;xksSVoArBel|qDP)%XuFX}oQHPO)% zvXp1I)O$JzO%Q<9Jnyf((868oIGq!8r2?CxZPpQQDXiLKQ@wDGAwWQ@jd7rgdL#)RhTU@+0^fr{@FI zxm$d{Gn*|rzAQ4HPgy5xkhOy)u=}We>GthPtsRfk-P&lv-o<@(WV75LIUl^?sRV!8%&S&SaUx!z5*}prOT)cS7N0J?nK>CoCcwf>Bx4Qah z@w(ZeV#YylcUz433v%}oPd-G58M0E_XSj0Y9da&&~8 z@A+N9G7bIyklQ>9U)StpG&=bPkBI=OQNQIE{R;mO95wcV`y<%CyiK;`$j&1)=9EcI!q0!I4B^6Y(^b`+X z(A-ufo3uJ!RtKcLO?|Y=GJECxiw~}Gw5_+gfBaI_{A+LVqSvXNG^7cV;O014Bg@Gr z&D=Q7ShF&)To6vXk*;Jnwsl-CU)T}QYj_tW(Xl63X^SOSsRtn?)OXE~t;jpuhDoRM z%>KwZN!)d?lSIaE9Ko#Ymm-R$}pPm@=xh%`!;x=L8X_}w-ohRP%Zsj>0>wdsOVbXc3N?9305-Q z=Oi?IiShgLH9S&A5h$q$z=20uYx0%F^DK$&Pg*DCg?egNNQD_I(6b-Vv~2Q=*kf8h z1kY9r!S&kU&&?hxSSR;LTXV{72l^$YcT3x_k1Q@yi4Jz@(;u|H3hXZQH`QC4@OmnQ zhSz+%59`|Q#aVxH`QQOTo^Kf`60rMy&Je_Q_1>M^rW!m_Q6NPE$rUTOC}1HXjF*W6aLb zz;I`GS6Drm_Epe_va@3*77sg~IrR7UznGTRnwn*p7DNtP61{pgn7)$7%*-q&H$bP= zkr31MA1W#;0;nbJ$CA+{WqF>+azIqtJALbTajcBDsQ$nMgR$}T?IOO->F5Ab$_c~6 zU5T#P%_EYJpfI7>zIimqM7m)M$e7O%Z@C&~1kde^M4R!Y)FX4+g#*`C;>ADsycAYJ z=UdMAKYP;;h>7(TLjEu zl)C!Ppiz5!@7ZPA)b+|yN<=4vrW{l&xn*UZTWe#>%gYDh;qN6Z#Ns2#k%b!@#Dc!Q zKEyFMd0S$hA`SCp6M`w8XX@V>0f_u-?>ZbhBwn7zVH>Z(E`#0uK`#mHv>%teB@<+ zVkrn1HC>e>CPvgK-7>#Mln`87GHGc#rE7chDX|5}$3{n{92`Wo`}V2@u6^%2ezuR3 ztl;3H6PL4ee*c~S4JD9+i>va}CpB^lcHyb8<7pC1-x2t#x}gE<=SQh=j#f}rl{4|% z6ioCGOQfz(U>CL){pp-QC^1MoaxySqiJI zrKr@MhU174?~)x=)HDWdFBFUF?&;x=2^=Z3dGKUFg62xyz$`3G_jGqpd4*4mQ3B)Z z55Ir-k@kfAI&|M|dVJi<>ATl$EEbE=YNp`z;1S{cu5MzA&UmkZ%z9o?f|~EaKbv14 z`HK|1lC8ZmFBSf^fb*`l=0)f!daxC$Zb$;UV1R1`) z)Qj@>$24a8XbGucK%EAx(OB*zD9nM~I|a%jU?Pmx0VL(2><{9 literal 0 HcmV?d00001 diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 12d4fcc2b7..ce5781ca63 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -868,7 +868,7 @@ impl RenderState { let text_stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); - let mut stroke_paragraphs_list = shape + let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape .visible_strokes() .rev() .map(|stroke| { @@ -880,7 +880,7 @@ impl RenderState { None, ) }) - .collect::>(); + .unzip(); if fast_mode { // Fast path: render fills and strokes only (skip shadows/blur). text::render( @@ -892,9 +892,13 @@ impl RenderState { None, None, text_fill_inset, + None, ); - for stroke_paragraphs in stroke_paragraphs_list.iter_mut() { + for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list + .iter_mut() + .zip(stroke_opacities.iter()) + { text::render_with_bounds_outset( Some(self), None, @@ -905,6 +909,7 @@ impl RenderState { None, text_stroke_blur_outset, None, + *layer_opacity, ); } } else { @@ -918,7 +923,10 @@ impl RenderState { let blur_filter = shape.image_filter(1.); let mut paragraphs_with_shadows = text_content.paragraph_builder_group_from_text(Some(true)); - let mut stroke_paragraphs_with_shadows_list = shape + let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( + Vec<_>, + Vec<_>, + ) = shape .visible_strokes() .rev() .map(|stroke| { @@ -930,7 +938,7 @@ impl RenderState { Some(true), ) }) - .collect::>(); + .unzip(); if let Some(parent_shadows) = parent_shadows { if !shape.has_visible_strokes() { @@ -944,6 +952,7 @@ impl RenderState { Some(&shadow), blur_filter.as_ref(), None, + None, ); } } else { @@ -970,6 +979,7 @@ impl RenderState { Some(shadow), blur_filter.as_ref(), None, + None, ); } } @@ -984,6 +994,7 @@ impl RenderState { None, blur_filter.as_ref(), text_fill_inset, + None, ); // 3. Stroke drop shadows @@ -998,7 +1009,10 @@ impl RenderState { ); // 4. Stroke fills - for stroke_paragraphs in stroke_paragraphs_list.iter_mut() { + for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list + .iter_mut() + .zip(stroke_opacities.iter()) + { text::render_with_bounds_outset( Some(self), None, @@ -1009,6 +1023,7 @@ impl RenderState { blur_filter.as_ref(), text_stroke_blur_outset, None, + *layer_opacity, ); } @@ -1035,6 +1050,7 @@ impl RenderState { Some(shadow), blur_filter.as_ref(), None, + None, ); } } diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index b5077f0688..ea43322b70 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -155,6 +155,7 @@ pub fn render_text_shadows( None, blur_filter.as_ref(), None, + None, ); for stroke_paragraphs in stroke_paragraphs_group.iter_mut() { @@ -167,6 +168,7 @@ pub fn render_text_shadows( None, blur_filter.as_ref(), None, + None, ); } diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 42c35b450f..832503505d 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -20,11 +20,12 @@ pub fn stroke_paragraph_builder_group_from_text( bounds: &Rect, count_inner_strokes: usize, use_shadow: Option, -) -> Vec { +) -> (Vec, Option) { let fallback_fonts = get_fallback_fonts(); let fonts = get_font_collection(); let mut paragraph_group = Vec::new(); let remove_stroke_alpha = use_shadow.unwrap_or(false) && !stroke.is_transparent(); + let mut group_layer_opacity: Option = None; for paragraph in text_content.paragraphs() { let mut stroke_paragraphs_map: std::collections::HashMap = @@ -32,7 +33,7 @@ pub fn stroke_paragraph_builder_group_from_text( for span in paragraph.children().iter() { let text_paint: skia_safe::Handle<_> = merge_fills(span.fills(), *bounds); - let stroke_paints = get_text_stroke_paints( + let (stroke_paints, stroke_layer_opacity) = get_text_stroke_paints( stroke, bounds, &text_paint, @@ -40,6 +41,10 @@ pub fn stroke_paragraph_builder_group_from_text( remove_stroke_alpha, ); + if group_layer_opacity.is_none() { + group_layer_opacity = stroke_layer_opacity; + } + let text: String = span.apply_text_transform(); for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() { @@ -67,7 +72,7 @@ pub fn stroke_paragraph_builder_group_from_text( paragraph_group.push(stroke_paragraphs); } - paragraph_group + (paragraph_group, group_layer_opacity) } fn get_text_stroke_paints( @@ -76,8 +81,25 @@ fn get_text_stroke_paints( text_paint: &Paint, count_inner_strokes: usize, remove_stroke_alpha: bool, -) -> Vec { +) -> (Vec, Option) { let mut paints = Vec::new(); + let mut layer_opacity: Option = None; + + let stroke_opacity = stroke.fill.opacity(); + let needs_opacity_layer = stroke_opacity < 1.0 && !remove_stroke_alpha; + + let fill_for_paint = |paint: &mut Paint| { + if needs_opacity_layer { + let opaque_fill = stroke.fill.with_full_opacity(); + set_paint_fill(paint, &opaque_fill, bounds, remove_stroke_alpha); + } else { + set_paint_fill(paint, &stroke.fill, bounds, remove_stroke_alpha); + } + }; + + if needs_opacity_layer { + layer_opacity = Some(stroke_opacity); + } match stroke.kind { StrokeKind::Inner => { @@ -99,7 +121,7 @@ fn get_text_stroke_paints( paint.set_blend_mode(skia::BlendMode::SrcIn); paint.set_anti_alias(true); paint.set_stroke_width(stroke.width * 2.0); - set_paint_fill(&mut paint, &stroke.fill, bounds, remove_stroke_alpha); + fill_for_paint(&mut paint); paints.push(paint); } else { let mut paint = skia::Paint::default(); @@ -108,7 +130,12 @@ fn get_text_stroke_paints( paint.set_alpha(255); } else { paint = text_paint.clone(); - set_paint_fill(&mut paint, &stroke.fill, bounds, false); + if needs_opacity_layer { + let opaque_fill = stroke.fill.with_full_opacity(); + set_paint_fill(&mut paint, &opaque_fill, bounds, false); + } else { + set_paint_fill(&mut paint, &stroke.fill, bounds, false); + } } paint.set_style(skia::PaintStyle::Fill); @@ -132,7 +159,7 @@ fn get_text_stroke_paints( paint.set_style(skia::PaintStyle::Stroke); paint.set_anti_alias(true); paint.set_stroke_width(stroke.width); - set_paint_fill(&mut paint, &stroke.fill, bounds, remove_stroke_alpha); + fill_for_paint(&mut paint); paints.push(paint); } StrokeKind::Outer => { @@ -141,7 +168,7 @@ fn get_text_stroke_paints( paint.set_blend_mode(skia::BlendMode::DstOver); paint.set_anti_alias(true); paint.set_stroke_width(stroke.width * 2.0); - set_paint_fill(&mut paint, &stroke.fill, bounds, remove_stroke_alpha); + fill_for_paint(&mut paint); paints.push(paint); let mut paint = skia::Paint::default(); @@ -153,7 +180,7 @@ fn get_text_stroke_paints( } } - paints + (paints, layer_opacity) } #[allow(clippy::too_many_arguments)] @@ -167,6 +194,7 @@ pub fn render_with_bounds_outset( blur: Option<&ImageFilter>, stroke_bounds_outset: f32, fill_inset: Option, + layer_opacity: Option, ) { if let Some(render_state) = render_state { let target_surface = surface_id.unwrap_or(SurfaceId::Fills); @@ -195,6 +223,7 @@ pub fn render_with_bounds_outset( shadow, Some(&blur_filter_clone), fill_inset, + layer_opacity, ); }, ) { @@ -204,12 +233,28 @@ pub fn render_with_bounds_outset( } let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); - render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset); + render_text_on_canvas( + canvas, + shape, + paragraph_builders, + shadow, + blur, + fill_inset, + layer_opacity, + ); return; } if let Some(canvas) = canvas { - render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset); + render_text_on_canvas( + canvas, + shape, + paragraph_builders, + shadow, + blur, + fill_inset, + layer_opacity, + ); } } @@ -223,6 +268,7 @@ pub fn render( shadow: Option<&Paint>, blur: Option<&ImageFilter>, fill_inset: Option, + layer_opacity: Option, ) { render_with_bounds_outset( render_state, @@ -234,6 +280,7 @@ pub fn render( blur, 0.0, fill_inset, + layer_opacity, ); } @@ -244,6 +291,7 @@ fn render_text_on_canvas( shadow: Option<&Paint>, blur: Option<&ImageFilter>, fill_inset: Option, + layer_opacity: Option, ) { if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); @@ -255,7 +303,7 @@ fn render_text_on_canvas( if let Some(shadow_paint) = shadow { let layer_rec = SaveLayerRec::default().paint(shadow_paint); canvas.save_layer(&layer_rec); - draw_text(canvas, shape, paragraph_builders); + draw_text(canvas, shape, paragraph_builders, layer_opacity); canvas.restore(); } else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) { if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) { @@ -263,13 +311,13 @@ fn render_text_on_canvas( layer_paint.set_image_filter(erode); let layer_rec = SaveLayerRec::default().paint(&layer_paint); canvas.save_layer(&layer_rec); - draw_text(canvas, shape, paragraph_builders); + draw_text(canvas, shape, paragraph_builders, layer_opacity); canvas.restore(); } else { - draw_text(canvas, shape, paragraph_builders); + draw_text(canvas, shape, paragraph_builders, layer_opacity); } } else { - draw_text(canvas, shape, paragraph_builders); + draw_text(canvas, shape, paragraph_builders, layer_opacity); } if blur.is_some() { @@ -283,13 +331,20 @@ fn draw_text( canvas: &Canvas, shape: &Shape, paragraph_builder_groups: &mut [Vec], + layer_opacity: Option, ) { let text_content = shape.get_text_content(); let layout_info = calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true); - let layer_rec = SaveLayerRec::default(); - canvas.save_layer(&layer_rec); + if let Some(opacity) = layer_opacity { + let mut opacity_paint = Paint::default(); + opacity_paint.set_alpha_f(opacity); + let layer_rec = SaveLayerRec::default().paint(&opacity_paint); + canvas.save_layer(&layer_rec); + } else { + canvas.save_layer(&SaveLayerRec::default()); + } for para in &layout_info.paragraphs { para.paragraph.paint(canvas, (para.x, para.y)); diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index cf8a930894..5b61b3ee2a 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -140,6 +140,38 @@ pub enum Fill { } impl Fill { + pub fn opacity(&self) -> f32 { + match self { + Fill::Solid(SolidColor(color)) => color.a() as f32 / 255.0, + Fill::LinearGradient(g) => g.opacity as f32 / 255.0, + Fill::RadialGradient(g) => g.opacity as f32 / 255.0, + Fill::Image(i) => i.opacity as f32 / 255.0, + } + } + + pub fn with_full_opacity(&self) -> Fill { + match self { + Fill::Solid(SolidColor(color)) => Fill::Solid(SolidColor(skia::Color::from_argb( + 255, + color.r(), + color.g(), + color.b(), + ))), + Fill::LinearGradient(g) => Fill::LinearGradient(Gradient { + opacity: 255, + ..g.clone() + }), + Fill::RadialGradient(g) => Fill::RadialGradient(Gradient { + opacity: 255, + ..g.clone() + }), + Fill::Image(i) => Fill::Image(ImageFill { + opacity: 255, + ..i.clone() + }), + } + } + pub fn to_paint(&self, rect: &Rect, anti_alias: bool) -> skia::Paint { match self { Self::Solid(SolidColor(color)) => { From 0f34677ba780e732a209144e1bddc5ec74fd7eb8 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 10 Mar 2026 11:57:57 +0100 Subject: [PATCH 17/26] :bug: Fix negative insets --- .../get-file-huge-inner-strokes.json | 513 ++++++++++++++++++ .../ui/render-wasm-specs/shapes.spec.js | 15 + .../BUG-13610---Huge-inner-strokes-1.png | Bin 0 -> 16488 bytes render-wasm/src/render/strokes.rs | 55 +- 4 files changed, 579 insertions(+), 4 deletions(-) create mode 100644 frontend/playwright/data/render-wasm/get-file-huge-inner-strokes.json create mode 100644 frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13610---Huge-inner-strokes-1.png diff --git a/frontend/playwright/data/render-wasm/get-file-huge-inner-strokes.json b/frontend/playwright/data/render-wasm/get-file-huge-inner-strokes.json new file mode 100644 index 0000000000..8c6b439eea --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-huge-inner-strokes.json @@ -0,0 +1,513 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u0b5bcbca-32ab-81eb-8005-a15fc4484678", + "~: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": "New File 6", + "~:revn": 1, + "~:modified-at": "~m1773140377840", + "~:vern": 0, + "~:id": "~ueffcbebc-b8c8-802f-8007-b11dd34fe190", + "~: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": "~u0b5bcbca-32ab-81eb-8005-a15fc448f334", + "~:created-at": "~m1773140371775", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~ueffcbebc-b8c8-802f-8007-b11dd34fe191" + ], + "~:pages-index": { + "~ueffcbebc-b8c8-802f-8007-b11dd34fe191": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 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, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 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, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~ub952fb5e-cae5-8054-8007-b11dd63f79f9", + "~ub952fb5e-cae5-8054-8007-b11dd63f79fa", + "~ub952fb5e-cae5-8054-8007-b11dd63f79fb" + ] + } + }, + "~ub952fb5e-cae5-8054-8007-b11dd63f79f9": { + "~#shape": { + "~:y": 660.000001521671, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 99.9999986310249, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 989, + "~:y": 660.000001521671 + } + }, + { + "~#point": { + "~:x": 1088.99999863103, + "~:y": 660.000001521671 + } + }, + { + "~#point": { + "~:x": 1088.99999863103, + "~:y": 760.000000795896 + } + }, + { + "~#point": { + "~:x": 989, + "~:y": 760.000000795896 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ub952fb5e-cae5-8054-8007-b11dd63f79f9", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 100 + } + ], + "~:x": 989, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 989, + "~:y": 660.000001521671, + "~:width": 99.9999986310249, + "~:height": 99.9999992742251, + "~:x1": 989, + "~:y1": 660.000001521671, + "~:x2": 1088.99999863103, + "~:y2": 760.000000795896 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 99.9999992742251, + "~:flip-y": null + } + }, + "~ub952fb5e-cae5-8054-8007-b11dd63f79fa": { + "~#shape": { + "~:y": 457.999994456768, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 299.99999499321, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1171.99998355202, + "~:y": 457.999994456768 + } + }, + { + "~#point": { + "~:x": 1471.99997854523, + "~:y": 457.999994456768 + } + }, + { + "~#point": { + "~:x": 1471.99997854523, + "~:y": 757.999989449978 + } + }, + { + "~#point": { + "~:x": 1171.99998355202, + "~:y": 757.999989449978 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~ub952fb5e-cae5-8054-8007-b11dd63f79fa", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 400 + } + ], + "~:x": 1171.99998355202, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1171.99998355202, + "~:y": 457.999994456768, + "~:width": 299.99999499321, + "~:height": 299.99999499321, + "~:x1": 1171.99998355202, + "~:y1": 457.999994456768, + "~:x2": 1471.99997854523, + "~:y2": 757.999989449978 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 299.99999499321, + "~:flip-y": null + } + }, + "~ub952fb5e-cae5-8054-8007-b11dd63f79fb": { + "~#shape": { + "~:y": 444, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 100, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 989, + "~:y": 444 + } + }, + { + "~#point": { + "~:x": 1089, + "~:y": 444 + } + }, + { + "~#point": { + "~:x": 1089, + "~:y": 544 + } + }, + { + "~#point": { + "~:x": 989, + "~:y": 544 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ub952fb5e-cae5-8054-8007-b11dd63f79fb", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 200 + } + ], + "~:x": 989, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 989, + "~:y": 444, + "~:width": 100, + "~:height": 100, + "~:x1": 989, + "~:y1": 444, + "~:x2": 1089, + "~:y2": 544 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 100, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-b11dd34fe191", + "~:name": "Page 1" + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-b11dd34fe190", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index f61afbfda4..63e16a3966 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -475,4 +475,19 @@ test("BUG 13551 - Blurs affecting other elements", async ({ maxDiffPixelRatio: 0, threshold: 0.1, }); +}); + +test("BUG 13610 - Huge inner strokes", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-huge-inner-strokes.json"); + + await workspace.goToWorkspace({ + id: "effcbebc-b8c8-802f-8007-b11dd34fe190", + pageId: "effcbebc-b8c8-802f-8007-b11dd34fe191", + }); + await workspace.waitForFirstRenderWithoutUI(); + await expect(workspace.canvas).toHaveScreenshot(); }); \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13610---Huge-inner-strokes-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13610---Huge-inner-strokes-1.png new file mode 100644 index 0000000000000000000000000000000000000000..633bd8ad2dca740ce0b25565c89602f245f6f15a GIT binary patch literal 16488 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 literal 0 HcmV?d00001 diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index dc77e4e246..319b921ac5 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -41,8 +41,8 @@ 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. + // Dotted inner/outer strokes need clipping to prevent the dotted + // pattern from appearing in wrong areas. if let Some(clip_op) = stroke.clip_op() { // Use a neutral layer (no extra paint) so opacity and filters // come solely from the stroke paint. This avoids applying @@ -60,6 +60,35 @@ fn draw_stroke_on_rect( } draw_stroke(); canvas.restore(); + } else if stroke.kind == StrokeKind::Inner + && (stroke.width >= rect.width() || stroke.width >= rect.height()) + { + // When the inner stroke width exceeds a shape dimension, the inset + // rect goes negative and the stroke overflows outside the shape. + // Fall back to the same approach as the SVG renderer: draw with + // doubled width centered on the original shape and clip to it. + canvas.save(); + match corners { + Some(radii) => { + let rrect = RRect::new_rect_radii(*rect, radii); + canvas.clip_rrect(rrect, skia::ClipOp::Intersect, antialias); + } + None => { + canvas.clip_rect(*rect, skia::ClipOp::Intersect, antialias); + } + } + let mut inner_paint = paint.clone(); + inner_paint.set_stroke_width(stroke.width * 2.0); + match corners { + Some(radii) => { + let rrect = RRect::new_rect_radii(*rect, radii); + canvas.draw_rrect(rrect, &inner_paint); + } + None => { + canvas.draw_rect(*rect, &inner_paint); + } + } + canvas.restore(); } else { draw_stroke(); } @@ -83,8 +112,8 @@ fn draw_stroke_on_circle( let filter = compose_filters(blur, shadow); paint.set_image_filter(filter); - // By default just draw the circle. Only dotted inner/outer strokes need - // clipping to prevent the dotted pattern from appearing in wrong areas. + // Dotted inner/outer strokes need clipping to prevent the dotted + // pattern from appearing in wrong areas. if let Some(clip_op) = stroke.clip_op() { // Use a neutral layer (no extra paint) so opacity and filters // come solely from the stroke paint. This avoids applying @@ -99,6 +128,24 @@ fn draw_stroke_on_circle( canvas.clip_path(&clip_path, clip_op, antialias); canvas.draw_oval(stroke_rect, &paint); canvas.restore(); + } else if stroke.kind == StrokeKind::Inner + && (stroke.width >= rect.width() || stroke.width >= rect.height()) + { + // When the inner stroke width exceeds a shape dimension, the inset + // rect goes negative and the stroke overflows outside the shape. + // Fall back to the same approach as the SVG renderer: draw with + // doubled width centered on the original shape and clip to it. + canvas.save(); + let clip_path = { + let mut pb = skia::PathBuilder::new(); + pb.add_oval(rect, None, None); + pb.detach() + }; + canvas.clip_path(&clip_path, skia::ClipOp::Intersect, antialias); + let mut inner_paint = paint.clone(); + inner_paint.set_stroke_width(stroke.width * 2.0); + canvas.draw_oval(*rect, &inner_paint); + canvas.restore(); } else { canvas.draw_oval(stroke_rect, &paint); } From 32cf95265a69ef61ad24d6e281d7f2328b5f8c97 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 9 Mar 2026 16:23:28 +0100 Subject: [PATCH 18/26] :books: Add GitHub Copilot instructions (#8548) --- .github/workflows/tests.yml | 42 +++- .gitignore | 1 + AGENTS.md | 265 +++++++++++++++++++++++ backend/AGENTS.md | 87 ++++++++ backend/package.json | 5 +- common/package.json | 3 +- exporter/package.json | 3 +- frontend/package.json | 11 +- frontend/scripts/build-libs.js | 17 +- library/package.json | 3 +- package.json | 1 + pnpm-lock.yaml | 370 +++++++++++++++++++++++++++++++++ render-wasm/AGENTS.md | 61 ++++++ run-ci.sh | 50 ----- 14 files changed, 845 insertions(+), 74 deletions(-) create mode 100644 AGENTS.md create mode 100644 backend/AGENTS.md create mode 100644 pnpm-lock.yaml create mode 100644 render-wasm/AGENTS.md delete mode 100755 run-ci.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e4021568ca..9fa432e7d3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,9 +28,47 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Check clojure code format + - name: Lint Common + working-directory: ./common run: | - ./scripts/lint + corepack enable; + corepack install; + pnpm install; + pnpm run lint:clj + + - name: Lint Frontend + working-directory: ./frontend + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run lint:clj + pnpm run lint:js + pnpm run lint:scss + + - name: Lint Backend + working-directory: ./backend + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run lint:clj + + - name: Lint Exporter + working-directory: ./exporter + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run lint:clj + + - name: Lint Library + working-directory: ./library + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run lint:clj test-common: name: "Common Tests" diff --git a/.gitignore b/.gitignore index 224d199dc3..9958d90cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ /notes /playground/ /backend/*.md +!/backend/AGENTS.md /backend/*.sql /backend/*.txt /backend/assets/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..d126301300 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,265 @@ +# Penpot – Copilot Instructions + +## Architecture Overview + +Penpot is a full-stack design tool composed of several distinct components: + +| Component | Language | Role | +|-----------|----------|------| +| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | +| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | +| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | +| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | +| `render-wasm/` | Rust → WebAssembly | High-performance canvas renderer using Skia | +| `mcp/` | TypeScript | Model Context Protocol integration | +| `plugins/` | TypeScript | Plugin runtime and example plugins | + +The monorepo is managed with `pnpm` workspaces. The `manage.sh` +orchestrates cross-component builds. `run-ci.sh` defines the CI +pipeline. + +--- + +## Build, Test & Lint Commands + +### Frontend (`cd frontend`) + +Run `./scripts/setup` for setup all dependencies. + + +```bash +# Dev +pnpm run watch:app # Full dev build (WASM + CLJS + assets) + +# Production Build +./scripts/build + +# Tests +pnpm run test # Build ClojureScript tests + run node target/tests/test.js +pnpm run watch:test # Watch + auto-rerun on change +pnpm run test:e2e # Playwright e2e tests +pnpm run test:e2e --grep "pattern" # Single e2e test by pattern + +# Lint +pnpm run lint:js # format and linter check for JS +pnpm run lint:clj # format and linter check for CLJ +pnpm run lint:scss # prettier check for SCSS + +# Code formatting +pnpm run fmt:clj # Format CLJ +pnpm run fmt:js # prettier for JS +pnpm run fmt:scss # prettier for SCSS +``` + +To run a focused ClojureScript unit test: edit +`test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm +run build:test && node target/tests/test.js`. + + +### Backend (`cd backend`) + +```bash +# Tests (Kaocha) +clojure -M:dev:test # Full suite +clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace + +# Lint / Format +pnpm run lint:clj +pnpm run fmt:clj +``` + +Test config is in `backend/tests.edn`; test namespaces match `.*-test$` under `test/`. + + +### Common (`cd common`) + +```bash +pnpm run test # Build + run node target/tests/test.js +pnpm run watch:test # Watch mode +pnpm run lint:clj +pnpm run fmt:clj +``` + +### Render-WASM (`cd render-wasm`) + +```bash +./test # Rust unit tests (cargo test) +./build # Compile to WASM (requires Emscripten) +cargo fmt --check +./lint --debug +``` + +## Key Conventions + +### Namespace Structure + +**Backend:** +- `app.rpc.commands.*` – RPC command implementations (`auth`, `files`, `teams`, etc.) +- `app.http.*` – HTTP routes and middleware +- `app.db.*` – Database layer +- `app.tasks.*` – Background job tasks +- `app.main` – Integrant system setup and entrypoint +- `app.loggers` – Internal loggers (auditlog, mattermost, etc) (do not be confused with `app.common.loggin`) + +**Frontend:** +- `app.main.ui.*` – React UI components (`workspace`, `dashboard`, `viewer`) +- `app.main.data.*` – Potok event handlers (state mutations + side effects) +- `app.main.refs` – Reactive subscriptions (okulary lenses) +- `app.main.store` – Potok event store +- `app.util.*` – Utilities (DOM, HTTP, i18n, keyboard shortcuts) + +**Common:** +- `app.common.types.*` – Shared data types for shapes, files, pages +- `app.common.schema` – Malli validation schemas +- `app.common.geom.*` – Geometry utilities +- `app.common.data.macros` – Performance macros used everywhere + +### Backend RPC Commands + +All API calls go through a single RPC endpoint: `POST /api/rpc/command/`. + +```clojure +(sv/defmethod ::my-command + {::rpc/auth true ;; requires auth + ::doc/added "1.18" + ::sm/params [:map ...] ;; malli input schema + ::sm/result [:map ...]} ;; malli output schema + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] + ;; return a plain map or throw + {:id (uuid/next)}) +``` + +### Frontend State Management (Potok) + +State is a single atom managed by a Potok store. Events implement protocols: + +```clojure +(defn my-event [data] + (ptk/reify ::my-event + ptk/UpdateEvent + (update [_ state] ;; synchronous state transition + (assoc state :key data)) + + ptk/WatchEvent + (watch [_ state stream] ;; async: returns an observable + (->> (rp/cmd! :some-rpc-command params) + (rx/map success-event) + (rx/catch error-handler))) + + ptk/EffectEvent + (effect [_ state _] ;; pure side effects (DOM, logging) + (.focus (dom/get-element "id"))))) +``` + +Dispatch with `(st/emit! (my-event data))`. Read state via reactive +refs: `(deref refs/selected-shapes)`. Prefer helpers from +`app.util.dom` instead of using direct dom calls, if no helper is +available, prefer adding a new helper for handling it and the use the +new helper. + + +### CSS Modules Pattern + +Styles are co-located with components. Each `.cljs` file has a corresponding `.scss` file: + +```clojure +;; In the component namespace: +(require '[app.main.style :as stl]) + +;; In the render function: +[:div {:class (stl/css :container :active)}] + +;; Conditional: +[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}] + +;; When you need concat an existing class: +[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}] + +``` + +### Performance Macros (`app.common.data.macros`) + +Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript: + +```clojure +(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys +(dm/get-in obj [:a :b :c]) ;; faster than core/get-in +(dm/str "a" "b" "c") ;; string concatenation +``` + +### Shared Code (cljc) + +Files in `common/src/app/common/` use reader conditionals to target both runtimes: + +```clojure +#?(:clj (import java.util.UUID) + :cljs (:require [cljs.core :as core])) +``` + +Both frontend and backend depend on `common` as a local library (`penpot/common {:local/root "../common"}`). + + +### Component Definition (Rumext / React) + +The codebase has several kind of components, some of them use legacy +syntax. The current and the most recent syntax uses `*` suffix on the +name. This indicates to the `mf/defc` macro apply concrete rules on +how props should be treated. + +```clojure +(mf/defc my-component* + {::mf/wrap [mf/memo]} ;; React.memo + [{:keys [name on-click]}] + [:div {:class (stl/css :root) + :on-click on-click} + name]) +``` + +Hooks: `(mf/use-state)`, `(mf/use-effect)`, `(mf/use-memo)` – analgous to react hooks. + + +The component usage should always follow the `[:> my-component* +props]`, where props should be a map literal or symbol pointing to +javascript props objects. The javascript props object can be created +manually `#js {:data-foo "bar"}` or using `mf/spread-object` helper +macro. + +--- + +## Commit Guidelines + +Format: ` ` + +``` +:bug: Fix unexpected error on launching modal + +Optional body explaining the why. + +Signed-off-by: Fullname +``` + +**Subject rules:** imperative mood, capitalize first letter, no +trailing period, ≤ 80 characters. Add an entry to `CHANGES.md` if +applicable. + +**Code patches must include a DCO sign-off** (`git commit -s`). + +| Emoji | Emoji-Code | Use for | +|-------|------|---------| +| 🐛 | `:bug:` | Bug fix | +| ✨ | `:sparkles:` | Improvement | +| 🎉 | `:tada:` | New feature | +| ♻️ | `:recycle:` | Refactor | +| 💄 | `:lipstick:` | Cosmetic changes | +| 🚑 | `:ambulance:` | Critical bug fix | +| 📚 | `:books:` | Docs | +| 🚧 | `:construction:` | WIP | +| 💥 | `:boom:` | Breaking change | +| 🔧 | `:wrench:` | Config update | +| ⚡ | `:zap:` | Performance | +| 🐳 | `:whale:` | Docker | +| 📎 | `:paperclip:` | Other non-relevant changes | +| ⬆️ | `:arrow_up:` | Dependency upgrade | +| ⬇️ | `:arrow_down:` | Dependency downgrade | +| 🔥 | `:fire:` | Remove files or code | +| 🌐 | `:globe_with_meridians:` | Translations | diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000000..f0b4a7314c --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,87 @@ +# backend – Agent Instructions + +Clojure service running on the JVM. Uses Integrant for dependency injection, PostgreSQL for storage, and Redis for messaging/caching. + +## Commands + +```bash +# REPL (primary dev workflow) +./scripts/repl # Start nREPL + load dev/user.clj utilities + +# Tests (Kaocha) +clojure -M:dev:test # Full suite +clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace + +# Lint / Format +pnpm run lint:clj +pnpm run fmt:clj +``` + +Test namespaces match `.*-test$` under `test/`. Config is in `tests.edn`. + +## Integrant System + +`src/app/main.clj` declares the system map. Each key is a component; +values are config maps with `::ig/ref` for dependencies. Components +implement `ig/init-key` / `ig/halt-key!`. + +From the REPL (`dev/user.clj` is auto-loaded): +```clojure +(start!) ; boot the system +(stop!) ; halt the system +(restart!) ; stop + reload namespaces + start +``` + +## RPC Commands + +All API calls: `POST /api/rpc/command/`. + +```clojure +(sv/defmethod ::my-command + {::rpc/auth true ;; requires authentication (default) + ::doc/added "1.18" + ::sm/params [:map ...] ;; malli input schema + ::sm/result [:map ...]} ;; malli output schema + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] + ;; return a plain map; throw via ex/raise for errors + {:id (uuid/next)}) +``` + +Add new commands in `src/app/rpc/commands/`. + +## Database + +`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case. + +```clojure +;; Query helpers +(db/get pool :table {:id id}) ; fetch one row (throws if missing) +(db/get* pool :table {:id id}) ; fetch one row (returns nil) +(db/query pool :table {:team-id team-id}) ; fetch multiple rows +(db/insert! pool :table {:name "x" :team-id id}) ; insert +(db/update! pool :table {:name "y"} {:id id}) ; update +(db/delete! pool :table {:id id}) ; delete +;; Transactions +(db/tx-run cfg (fn [{:keys [::db/conn]}] + (db/insert! conn :table row))) +``` + +Almost all methods on `app.db` namespace accepts `pool`, `conn` or +`cfg` as params. + +Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup. + +## Error Handling + +```clojure +(ex/raise :type :not-found + :code :object-not-found + :hint "File does not exist" + :context {:id file-id}) +``` + +Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`. + +## Configuration + +`src/app/config.clj` reads `PENPOT_*` environment variables, validated with Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags :enable-smtp)`. diff --git a/backend/package.json b/backend/package.json index f3f4c18476..63bf06eddf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,8 +19,7 @@ "ws": "^8.17.0" }, "scripts": { - "fmt:clj:check": "cljfmt check --parallel=false src/ test/", - "fmt:clj": "cljfmt fix --parallel=true src/ test/", - "lint:clj": "clj-kondo --parallel --lint src/" + "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", + "fmt:clj": "cljfmt fix --parallel=true src/ test/" } } diff --git a/common/package.json b/common/package.json index 9e1343ef20..09de4e95aa 100644 --- a/common/package.json +++ b/common/package.json @@ -20,9 +20,8 @@ "date-fns": "^4.1.0" }, "scripts": { - "fmt:clj:check": "cljfmt check --parallel=false src/ test/", + "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel=true --lint src/", "fmt:clj": "cljfmt fix --parallel=true src/ test/", - "lint:clj": "clj-kondo --parallel=true --lint src/", "lint": "pnpm run lint:clj", "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"", "build:test": "clojure -M:dev:shadow-cljs compile test", diff --git a/exporter/package.json b/exporter/package.json index 9471814939..70b64bea7d 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -34,8 +34,7 @@ "watch": "pnpm run watch:app", "build:app": "clojure -M:dev:shadow-cljs release main", "build": "pnpm run clear:shadow-cache && pnpm run build:app", - "fmt:clj:check": "cljfmt check --parallel=false src/", "fmt:clj": "cljfmt fix --parallel=true src/", - "lint:clj": "clj-kondo --parallel --lint src/" + "lint:clj": "cljfmt check --parallel src/ && clj-kondo --parallel --lint src/" } } diff --git a/frontend/package.json b/frontend/package.json index 0d775f1a9e..f1fb0b3feb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,12 +24,11 @@ "build:app:worker": "clojure -M:dev:shadow-cljs release worker", "build:app": "pnpm run clear:shadow-cache && pnpm run build:app:main && pnpm run build:app:libs", "fmt:clj": "cljfmt fix --parallel=true src/ test/", - "fmt:clj:check": "cljfmt check --parallel=false src/ test/", - "fmt:js": "pnpx prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w", - "fmt:js:check": "pnpx prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js", - "lint:clj": "clj-kondo --parallel --lint src/", - "lint:scss": "pnpx prettier -c resources/styles -c src/**/*.scss", - "lint:scss:fix": "pnpx prettier -c resources/styles -c src/**/*.scss -w", + "fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w", + "fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w", + "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", + "lint:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js", + "lint:scss": "prettier -c resources/styles -c src/**/*.scss", "build:test": "clojure -M:dev:shadow-cljs compile test", "test": "pnpm run build:test && node target/tests/test.js", "test:storybook": "vitest run --project=storybook", diff --git a/frontend/scripts/build-libs.js b/frontend/scripts/build-libs.js index b2bbe30559..a1aff27f2b 100644 --- a/frontend/scripts/build-libs.js +++ b/frontend/scripts/build-libs.js @@ -5,14 +5,17 @@ import { readFile } from "node:fs/promises"; * esbuild plugin to watch a directory recursively */ const watchExtraDirPlugin = { - name: 'watch-extra-dir', + name: "watch-extra-dir", setup(build) { - build.onLoad({ filter: /target\/index.js/, namespace: 'file' }, async (args) => { - return { - watchDirs: ["packages/ui/dist"], - }; - }); - } + build.onLoad( + { filter: /target\/index.js/, namespace: "file" }, + async (args) => { + return { + watchDirs: ["packages/ui/dist"], + }; + }, + ); + }, }; const filter = diff --git a/library/package.json b/library/package.json index 46dc4fbac8..c3f3d1c32a 100644 --- a/library/package.json +++ b/library/package.json @@ -27,8 +27,7 @@ "build": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs release library", "build:bundle": "./scripts/build", "fmt:clj": "cljfmt fix --parallel=true src/ test/", - "fmt:clj:check": "cljfmt check --parallel=false src/ test/", - "lint:clj": "clj-kondo --parallel --lint src/", + "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", "test": "node --test", "watch:test": "node --test --watch", "watch": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch library" diff --git a/package.json b/package.json index 0a6d43e4f6..f38f80617d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "fmt": "./scripts/fmt" }, "devDependencies": { + "@github/copilot": "^1.0.2", "@types/node": "^20.12.7", "esbuild": "^0.25.9" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000000..bec7b49e31 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,370 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@github/copilot': + specifier: ^1.0.2 + version: 1.0.2 + '@types/node': + specifier: ^20.12.7 + version: 20.19.37 + esbuild: + specifier: ^0.25.9 + version: 0.25.12 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@github/copilot-darwin-arm64@1.0.2': + resolution: {integrity: sha512-dYoeaTidsphRXyMjvAgpjEbBV41ipICnXURrLFEiATcjC4IY6x2BqPOocrExBYW/Tz2VZvDw51iIZaf6GXrTmw==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@github/copilot-darwin-x64@1.0.2': + resolution: {integrity: sha512-8+Z9dYigEfXf0wHl9c2tgFn8Cr6v4RAY8xTgHMI9mZInjQyxVeBXCxbE2VgzUtDUD3a705Ka2d8ZOz05aYtGsg==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@github/copilot-linux-arm64@1.0.2': + resolution: {integrity: sha512-ik0Y5aTXOFRPLFrNjZJdtfzkozYqYeJjVXGBAH3Pp1nFZRu/pxJnrnQ1HrqO/LEgQVbJzAjQmWEfMbXdQIxE4Q==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@github/copilot-linux-x64@1.0.2': + resolution: {integrity: sha512-mHSPZjH4nU9rwbfwLxYJ7CQ90jK/Qu1v2CmvBCUPfmuGdVwrpGPHB5FrB+f+b0NEXjmemDWstk2zG53F7ppHfw==} + cpu: [x64] + os: [linux] + hasBin: true + + '@github/copilot-win32-arm64@1.0.2': + resolution: {integrity: sha512-tLW2CY/vg0fYLp8EuiFhWIHBVzbFCDDpohxT/F/XyMAdTVSZLnopCcxQHv2BOu0CVGrYjlf7YOIwPfAKYml1FA==} + cpu: [arm64] + os: [win32] + hasBin: true + + '@github/copilot-win32-x64@1.0.2': + resolution: {integrity: sha512-cFlc3xMkKKFRIYR00EEJ2XlYAemeh5EZHsGA8Ir2G0AH+DOevJbomdP1yyCC5gaK/7IyPkHX3sGie5sER2yPvQ==} + cpu: [x64] + os: [win32] + hasBin: true + + '@github/copilot@1.0.2': + resolution: {integrity: sha512-716SIZMYftldVcJay2uZOzsa9ROGGb2Mh2HnxbDxoisFsWNNgZlQXlV7A+PYoGsnAo2Zk/8e1i5SPTscGf2oww==} + hasBin: true + + '@types/node@20.19.37': + resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@github/copilot-darwin-arm64@1.0.2': + optional: true + + '@github/copilot-darwin-x64@1.0.2': + optional: true + + '@github/copilot-linux-arm64@1.0.2': + optional: true + + '@github/copilot-linux-x64@1.0.2': + optional: true + + '@github/copilot-win32-arm64@1.0.2': + optional: true + + '@github/copilot-win32-x64@1.0.2': + optional: true + + '@github/copilot@1.0.2': + optionalDependencies: + '@github/copilot-darwin-arm64': 1.0.2 + '@github/copilot-darwin-x64': 1.0.2 + '@github/copilot-linux-arm64': 1.0.2 + '@github/copilot-linux-x64': 1.0.2 + '@github/copilot-win32-arm64': 1.0.2 + '@github/copilot-win32-x64': 1.0.2 + + '@types/node@20.19.37': + dependencies: + undici-types: 6.21.0 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + undici-types@6.21.0: {} diff --git a/render-wasm/AGENTS.md b/render-wasm/AGENTS.md new file mode 100644 index 0000000000..378122985c --- /dev/null +++ b/render-wasm/AGENTS.md @@ -0,0 +1,61 @@ +# render-wasm – Agent Instructions + +This component compiles Rust to WebAssembly using Emscripten + Skia. It is consumed by the frontend as a canvas renderer. + +## Commands + +```bash +./build # Compile Rust → WASM (requires Emscripten environment) +./watch # Incremental rebuild on file change +./test # Run Rust unit tests (cargo test) +./lint # clippy -D warnings +cargo fmt --check +``` + +Run a single test: +```bash +cargo test my_test_name # by test function name +cargo test shapes:: # by module prefix +``` + +Build output lands in `../frontend/resources/public/js/` (consumed directly by the frontend dev server). + +## Build Environment + +The `_build_env` script sets required env vars (Emscripten paths, +`EMCC_CFLAGS`). `./build` sources it automatically. The WASM heap is +configured to 256 MB initial with geometric growth. + +## Architecture + +**Global state** — a single `unsafe static mut State` accessed +exclusively through `with_state!` / `with_state_mut!` macros. Never +access it directly. + +**Tile-based rendering** — only 512×512 tiles within the viewport +(plus a pre-render buffer) are drawn each frame. Tiles outside the +range are skipped. + +**Two-phase updates** — shape data is written via exported setter +functions (called from ClojureScript), then a single `render_frame()` +triggers the actual Skia draw calls. + +**Shape hierarchy** — shapes live in a flat pool indexed by UUID; +parent/child relationships are tracked separately. + +## Key Source Modules + +| Path | Role | +|------|------| +| `src/lib.rs` | WASM exports — all functions callable from JS | +| `src/state.rs` | Global `State` struct definition | +| `src/render/` | Tile rendering pipeline, Skia surface management | +| `src/shapes/` | Shape types and Skia draw logic per shape | +| `src/wasm/` | JS interop helpers (memory, string encoding) | + +## Frontend Integration + +The WASM module is loaded by `app.render-wasm.*` namespaces in the +frontend. ClojureScript calls exported Rust functions to push shape +data, then calls `render_frame`. Do not change export function +signatures without updating the ClojureScript bridge. diff --git a/run-ci.sh b/run-ci.sh deleted file mode 100755 index a57d425924..0000000000 --- a/run-ci.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash - -set -e - -echo "################ test common ################" -pushd common -pnpm install -pnpm run fmt:clj:check -pnpm run lint:clj -clojure -M:dev:test -pnpm run test -popd - -echo "################ test frontend ################" -pushd frontend -pnpm install -pnpm run fmt:clj:check -pnpm run fmt:js:check -pnpm run lint:scss -pnpm run lint:clj -pnpm run test -popd - -echo "################ test integration ################" -pushd frontend -pnpm install -pnpm run test:e2e -x --workers=4 -popd - -echo "################ test backend ################" -pushd backend -pnpm install -pnpm run fmt:clj:check -pnpm run lint:clj -clojure -M:dev:test --reporter kaocha.report/documentation -popd - -echo "################ test exporter ################" -pushd exporter -pnpm install -pnpm run fmt:clj:check -pnpm run lint:clj -popd - -echo "################ test render-wasm ################" -pushd render-wasm -cargo fmt --check -./lint --debug -./test -popd From ab90500ec824cd9a56ddf04780823915ace056cd Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 10:04:07 +0100 Subject: [PATCH 19/26] :bug: Fix download-image to properly handle network errors and non-2xx responses (#8554) The download-image function in app.media silently succeeded when the remote image URL was unreachable or returned an error status code, causing create-file-media-object-from-url to report success with no actual image stored. Add exception handling for connection refused, timeouts, and I/O errors around the HTTP request, and validate the HTTP status code in parse-and-validate before processing the response body. Fixes #8499 Signed-off-by: Andrey Antukh --- backend/src/app/media.clj | 36 ++++++- backend/test/backend_tests/rpc_media_test.clj | 102 +++++++++++++++++- 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index bbb3123e73..d54f19ab10 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -293,12 +293,17 @@ (defn download-image "Download an image from the provided URI and return the media input object" [{:keys [::http/client]} uri] - (letfn [(parse-and-validate [{:keys [headers] :as response}] + (letfn [(parse-and-validate [{:keys [status headers] :as response}] (let [size (some-> (get headers "content-length") d/parse-integer) mtype (get headers "content-type") format (cm/mtype->format mtype) max-size (cf/get :media-max-file-size default-max-file-size)] + (when-not (<= 200 status 299) + (ex/raise :type :validation + :code :unable-to-download-image + :hint (str/ffmt "unable to download image from '%': unexpected status code %" uri status))) + (when-not size (ex/raise :type :validation :code :unknown-size @@ -318,9 +323,32 @@ {:size size :mtype mtype :format format}))] - (let [{:keys [body] :as response} (http/req! client - {:method :get :uri uri} - {:response-type :input-stream}) + (let [{:keys [body] :as response} + (try + (http/req! client + {:method :get :uri uri} + {:response-type :input-stream}) + (catch java.net.ConnectException cause + (ex/raise :type :validation + :code :unable-to-download-image + :hint (str/ffmt "unable to download image from '%': connection refused or host unreachable" uri) + :cause cause)) + (catch java.net.http.HttpConnectTimeoutException cause + (ex/raise :type :validation + :code :unable-to-download-image + :hint (str/ffmt "unable to download image from '%': connection timeout" uri) + :cause cause)) + (catch java.net.http.HttpTimeoutException cause + (ex/raise :type :validation + :code :unable-to-download-image + :hint (str/ffmt "unable to download image from '%': request timeout" uri) + :cause cause)) + (catch java.io.IOException cause + (ex/raise :type :validation + :code :unable-to-download-image + :hint (str/ffmt "unable to download image from '%': I/O error" uri) + :cause cause))) + {:keys [size mtype]} (parse-and-validate response) path (tmp/tempfile :prefix "penpot.media.download.") written (io/write* path body :size size)] diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index d583565f39..79df6d38b4 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -9,11 +9,14 @@ [app.common.time :as ct] [app.common.uuid :as uuid] [app.db :as db] + [app.http.client :as http] + [app.media :as media] [app.rpc :as-alias rpc] [app.storage :as sto] [backend-tests.helpers :as th] [clojure.test :as t] - [datoteka.fs :as fs])) + [datoteka.fs :as fs] + [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -278,3 +281,100 @@ error-data (ex-data error)] (t/is (th/ex-info? error)) (t/is (= (:type error-data) :not-found))))) + + +(t/deftest download-image-connection-error + (t/testing "connection refused raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :throw (java.net.ConnectException. "Connection refused")}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://unreachable.invalid/image.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err))))))) + + (t/testing "connection timeout raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :throw (java.net.http.HttpConnectTimeoutException. "Connect timed out")}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://unreachable.invalid/image.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err))))))) + + (t/testing "request timeout raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :throw (java.net.http.HttpTimeoutException. "Request timed out")}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://unreachable.invalid/image.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err))))))) + + (t/testing "I/O error raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :throw (java.io.IOException. "Stream closed")}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://unreachable.invalid/image.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err)))))))) + + +(t/deftest download-image-status-code-error + (t/testing "404 status raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :return {:status 404 + :headers {"content-type" "text/html" + "content-length" "0"} + :body nil}}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://example.com/not-found.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err))))))) + + (t/testing "500 status raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :return {:status 500 + :headers {"content-type" "text/html" + "content-length" "0"} + :body nil}}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://example.com/server-error.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err))))))) + + (t/testing "302 status raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :return {:status 302 + :headers {"content-type" "text/html" + "content-length" "0"} + :body nil}}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://example.com/redirect.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err)))))))) From 3112b0d8cfcd4e4b97800bd09fe45375b6b44a70 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Tue, 10 Mar 2026 14:41:50 +0100 Subject: [PATCH 20/26] :bug: Fix grow options not verifying text-editor/v2 (#8571) --- .../main/ui/workspace/sidebar/options/menus/text.cljs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 3135a815bc..75c0344662 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -137,12 +137,13 @@ (fn [value] (on-blur) (let [uid (js/Symbol) - grow-type (keyword value) - content (when editor-instance - (content/dom->cljs (dwt/get-editor-root editor-instance)))] + grow-type (keyword value)] (st/emit! (dwu/start-undo-transaction uid)) - (when (some? content) - (st/emit! (dwt/v2-update-text-shape-content (first ids) content :finalize? true))) + (when (features/active-feature? @st/state "text-editor/v2") + (let [content (when editor-instance + (content/dom->cljs (dwt/get-editor-root editor-instance)))] + (when (some? content) + (st/emit! (dwt/v2-update-text-shape-content (first ids) content :finalize? true))))) (st/emit! (dwsh/update-shapes ids #(assoc % :grow-type grow-type))) (when (features/active-feature? @st/state "render-wasm/v1") From 9f66220caa8c16605828b445d642685e38a6fca2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 09:59:54 +0100 Subject: [PATCH 21/26] :bug: Fix flex layout container horizontalSizing/verticalSizing via plugin API (#8555) Setting horizontalSizing/verticalSizing on a FlexLayoutProxy was dispatching update-layout-child instead of update-layout, so the frame's auto-sizing (hug content) was never triggered even though the getter read back the value correctly. Also restricts accepted values to #{:fix :auto} (matching shape.cljs) since frames cannot use :fill, and fixes a copy-paste error that reported :horizontalPadding instead of :horizontalSizing in error messages. Signed-off-by: Andrey Antukh --- frontend/src/app/plugins/flex.cljs | 34 +++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index 9ae4525a5f..a1c7ef754c 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -265,7 +265,39 @@ (if (and (natural-child-ordering? plugin-id) (not (ctl/reverse? shape))) 0 (count (:shapes shape)))] - (st/emit! (dwsh/relocate-shapes #{child-id} id index))))))) + (st/emit! (dwsh/relocate-shapes #{child-id} id index))))) + + :horizontalSizing + {:this true + :get #(-> % u/proxy->shape :layout-item-h-sizing (d/nilv :fix) d/name) + :set + (fn [_ value] + (let [value (keyword value)] + (cond + (not (contains? ctl/item-h-sizing-types value)) + (u/display-not-valid :horizontalSizing value) + + (not (r/check-permission plugin-id "content:write")) + (u/display-not-valid :horizontalSizing "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))} + + :verticalSizing + {:this true + :get #(-> % u/proxy->shape :layout-item-v-sizing (d/nilv :fix) d/name) + :set + (fn [_ value] + (let [value (keyword value)] + (cond + (not (contains? ctl/item-v-sizing-types value)) + (u/display-not-valid :verticalSizing value) + + (not (r/check-permission plugin-id "content:write")) + (u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))})) (defn layout-child-proxy? [p] (obj/type-of? p "LayoutChildProxy")) From 98c1503bca584df684fe21ddc86672b7c835caa7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 15:05:08 +0100 Subject: [PATCH 22/26] :rewind: Backport serveral plugin types documentation --- plugins/libs/plugin-types/index.d.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index a3a67f4550..75861797bf 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -243,11 +243,17 @@ export interface Board extends ShapeBase { /** * The horizontal sizing behavior of the board. + * It can be one of the following values: + * - 'fix': The containers has its own intrinsic fixed size. + * - 'auto': The container fits the content. */ horizontalSizing?: 'auto' | 'fix'; /** * The vertical sizing behavior of the board. + * It can be one of the following values: + * - 'fix': The containers has its own intrinsic fixed size. + * - 'auto': The container fits the content. */ verticalSizing?: 'auto' | 'fix'; @@ -738,19 +744,19 @@ export interface CommonLayout { /** * The `horizontalSizing` property specifies the horizontal sizing behavior of the container. * It can be one of the following values: - * - 'fit-content': The container fits the content. - * - 'fill': The container fills the available space. - * - 'auto': The container size is determined automatically. + * - 'fix': The containers has its own intrinsic fixed size. + * - 'fill': The container fills the available space. Only can be set if it's inside another layout. + * - 'auto': The container fits the content. */ - horizontalSizing: 'fit-content' | 'fill' | 'auto'; + horizontalSizing: 'fix' | 'fill' | 'auto'; /** * The `verticalSizing` property specifies the vertical sizing behavior of the container. * It can be one of the following values: - * - 'fit-content': The container fits the content. - * - 'fill': The container fills the available space. - * - 'auto': The container size is determined automatically. + * - 'fix': The containers has its own intrinsic fixed size. + * - 'fill': The container fills the available space. Only can be set if it's inside another layout. + * - 'auto': The container fits the content. */ - verticalSizing: 'fit-content' | 'fill' | 'auto'; + verticalSizing: 'fix' | 'fill' | 'auto'; /** * The `remove` method removes the layout. From 31d8b35a2c7dd583aa5820eb308a475c75ca90f0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 18:50:33 +0100 Subject: [PATCH 23/26] :paperclip: Revert small changes related to browser pool on exporter --- exporter/src/app/browser.cljs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/exporter/src/app/browser.cljs b/exporter/src/app/browser.cljs index 0da27c2609..526ae77380 100644 --- a/exporter/src/app/browser.cljs +++ b/exporter/src/app/browser.cljs @@ -100,14 +100,12 @@ (def browser-pool-factory (letfn [(create [] - (-> (p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]} - browser (.launch pw/chromium opts) - id (swap! pool-browser-id inc)] - (l/info :origin "factory" :action "create" :browser-id id) - (unchecked-set browser "__id" id) - browser) - (p/catch (fn [cause] - (l/error :hint "Cannot launch the headless browser" :cause cause))))) + (p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]} + browser (.launch pw/chromium opts) + id (swap! pool-browser-id inc)] + (l/info :origin "factory" :action "create" :browser-id id) + (unchecked-set browser "__id" id) + browser)) (destroy [obj] (let [id (unchecked-get obj "__id")] From e855907b05869a335fead414abd2f2de4daeb19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 10 Mar 2026 18:42:28 +0100 Subject: [PATCH 24/26] :wrench: Disable search indexing of plugin docs for non-production envs --- .github/workflows/plugins-deploy-api-doc.yml | 17 +++++++++++++++++ .github/workflows/plugins-deploy-styles-doc.yml | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index aaa1339c9e..1842a61b16 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -104,6 +104,23 @@ jobs: run: | sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml + - name: Add noindex header and robots.txt files for non-production environments + if: ${{ steps.vars.outputs.gh_ref != 'main' }} + working-directory: plugins + shell: bash + run: | + ASSETS_DIR="dist/doc" + + cat > "${ASSETS_DIR}/_headers" << 'EOF' + /* + X-Robots-Tag: noindex, nofollow + EOF + + cat > "${ASSETS_DIR}/robots.txt" << 'EOF' + User-agent: * + Disallow: / + EOF + - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v3 with: diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 1e2b39e74d..f8e43899b8 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -102,6 +102,23 @@ jobs: run: | sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-styles-doc.toml + - name: Add noindex header and robots.txt files for non-production environments + if: ${{ steps.vars.outputs.gh_ref != 'main' }} + working-directory: plugins + shell: bash + run: | + ASSETS_DIR="dist/apps/example-styles" + + cat > "${ASSETS_DIR}/_headers" << 'EOF' + /* + X-Robots-Tag: noindex, nofollow + EOF + + cat > "${ASSETS_DIR}/robots.txt" << 'EOF' + User-agent: * + Disallow: / + EOF + - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v3 with: From 920e66fd2416fd42e87cb4373bb481da5f2663c8 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Wed, 4 Mar 2026 12:10:23 +0100 Subject: [PATCH 25/26] :tada: Add LTR/RTL cursor navigation --- render-wasm/src/state/text_editor.rs | 8 +++---- render-wasm/src/wasm/text_editor.rs | 36 ++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 34f2d12239..2766b476c6 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -204,10 +204,6 @@ impl TextEditorState { content: &TextContent, position: &TextPositionWithAffinity, ) { - fn is_word_char(c: char) -> bool { - c.is_alphanumeric() || c == '_' - } - self.is_pointer_selection_active = false; let paragraphs = content.paragraphs(); @@ -320,3 +316,7 @@ impl TextEditorState { !self.pending_events.is_empty() } } + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 54b360ac39..0886340851 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -7,7 +7,7 @@ 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}; -use skia_safe::Color; +use skia_safe::{textlayout::TextDirection, Color}; #[derive(PartialEq, ToJs)] #[repr(u8)] @@ -527,7 +527,25 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel let current = state.text_editor_state.selection.focus; - let new_cursor = match direction { + // Get the text direction of the span at the current cursor position + let span_text_direction = if current.paragraph < paragraphs.len() { + get_span_text_direction_at_offset(¶graphs[current.paragraph], current.offset) + } else { + TextDirection::LTR + }; + + // For horizontal navigation, swap Backward/Forward when in RTL text + let adjusted_direction = if span_text_direction == TextDirection::RTL { + match direction { + CursorDirection::Backward => CursorDirection::Forward, + CursorDirection::Forward => CursorDirection::Backward, + other => other, + } + } else { + direction + }; + + let new_cursor = match adjusted_direction { CursorDirection::Backward => move_cursor_backward(¤t, paragraphs), CursorDirection::Forward => move_cursor_forward(¤t, paragraphs), CursorDirection::LineBefore => { @@ -1136,6 +1154,20 @@ fn insert_text_with_newlines( Some(current_cursor) } +/// Get the text direction of the span at a given offset in a paragraph. +fn get_span_text_direction_at_offset( + para: &Paragraph, + char_offset: usize, +) -> skia_safe::textlayout::TextDirection { + if let Some((span_idx, _)) = find_span_at_offset(para, char_offset) { + if let Some(span) = para.children().get(span_idx) { + return span.text_direction; + } + } + // Fallback to paragraph's text direction + para.text_direction() +} + /// Insert text at a cursor position. Returns the new character offset after insertion. fn insert_text_at_cursor( text_content: &mut TextContent, From 7ec9261475131a79b255d8a96b85e5649391d569 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 11 Mar 2026 15:24:40 +0100 Subject: [PATCH 26/26] :sparkles: Add improvements to AGENTS.md (#8586) --- .github/workflows/tests.yml | 21 +- .gitignore | 22 +- AGENTS.md | 352 +++++-- backend/package.json | 6 +- common/package.json | 9 +- common/pnpm-lock.yaml | 10 + common/scripts/test | 3 +- common/src/app/common/encoding_impl.js | 134 ++- .../src/app/common/svg/path/arc_to_bezier.js | 72 +- common/src/app/common/svg/path/parser.js | 863 ++++++++++-------- common/src/app/common/uuid_impl.js | 154 ++-- common/src/app/common/weak/impl_weak_map.js | 7 +- common/tests.edn | 6 +- docker/devenv/Dockerfile | 1 + exporter/package.json | 5 +- frontend/package.json | 9 +- library/package.json | 5 +- scripts/check-fmt | 13 + scripts/lint | 10 - 19 files changed, 1038 insertions(+), 664 deletions(-) create mode 100755 scripts/check-fmt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9fa432e7d3..4ba57dde95 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,6 +34,8 @@ jobs: corepack enable; corepack install; pnpm install; + pnpm run check-fmt:clj + pnpm run check-fmt:js pnpm run lint:clj - name: Lint Frontend @@ -42,6 +44,9 @@ jobs: corepack enable; corepack install; pnpm install; + pnpm run check-fmt:js + pnpm run check-fmt:clj + pnpm run check-fmt:scss pnpm run lint:clj pnpm run lint:js pnpm run lint:scss @@ -52,7 +57,8 @@ jobs: corepack enable; corepack install; pnpm install; - pnpm run lint:clj + pnpm run check-fmt + pnpm run lint - name: Lint Exporter working-directory: ./exporter @@ -60,7 +66,8 @@ jobs: corepack enable; corepack install; pnpm install; - pnpm run lint:clj + pnpm run check-fmt + pnpm run lint - name: Lint Library working-directory: ./library @@ -68,7 +75,8 @@ jobs: corepack enable; corepack install; pnpm install; - pnpm run lint:clj + pnpm run check-fmt + pnpm run lint test-common: name: "Common Tests" @@ -79,12 +87,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Run tests on JVM - working-directory: ./common - run: | - clojure -M:dev:test - - - name: Run tests on NODE + - name: Run tests working-directory: ./common run: | ./scripts/test diff --git a/.gitignore b/.gitignore index 9958d90cb8..d0a13534b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,4 @@ .pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions -.pnpm-store *-init.clj *.css.json *.jar @@ -20,8 +13,6 @@ .nyc_output .rebel_readline_history .repl -.shadow-cljs -.pnpm-store/ /*.jpg /*.md /*.png @@ -36,6 +27,7 @@ /playground/ /backend/*.md !/backend/AGENTS.md +/backend/.shadow-cljs /backend/*.sql /backend/*.txt /backend/assets/ @@ -48,13 +40,13 @@ /backend/experiments /backend/scripts/_env.local /bundle* -/cd.md /clj-profiler/ /common/coverage /common/target -/deploy +/common/.shadow-cljs /docker/images/bundle* /exporter/target +/exporter/.shadow-cljs /frontend/.storybook/preview-body.html /frontend/.storybook/preview-head.html /frontend/playwright-report/ @@ -68,9 +60,9 @@ /frontend/storybook-static/ /frontend/target/ /frontend/test-results/ +/frontend/.shadow-cljs /other/ -/scripts/ -/telemetry/ +/nexus/ /tmp/ /vendor/**/target /vendor/svgclean/bundle*.js @@ -79,13 +71,11 @@ /library/*.zip /external /penpot-nitrate - -clj-profiler/ -node_modules /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ /render-wasm/target/ +/**/node_modules /**/.yarn/* /.pnpm-store diff --git a/AGENTS.md b/AGENTS.md index d126301300..9505d47698 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Penpot – Copilot Instructions +# Penpot – Instructions ## Architecture Overview @@ -18,7 +18,13 @@ The monorepo is managed with `pnpm` workspaces. The `manage.sh` orchestrates cross-component builds. `run-ci.sh` defines the CI pipeline. ---- +## Search Standards + +When searching code, always use `ripgrep` (rg) instead of grep if +available, as it respects `.gitignore` by default. + +If using grep, try to exclude node_modules and .shadow-cljs directories + ## Build, Test & Lint Commands @@ -28,27 +34,26 @@ Run `./scripts/setup` for setup all dependencies. ```bash -# Dev -pnpm run watch:app # Full dev build (WASM + CLJS + assets) - -# Production Build +# Build (Producution) ./scripts/build # Tests -pnpm run test # Build ClojureScript tests + run node target/tests/test.js -pnpm run watch:test # Watch + auto-rerun on change -pnpm run test:e2e # Playwright e2e tests -pnpm run test:e2e --grep "pattern" # Single e2e test by pattern +pnpm run test # Build ClojureScript tests + run node target/tests/test.js # Lint -pnpm run lint:js # format and linter check for JS -pnpm run lint:clj # format and linter check for CLJ -pnpm run lint:scss # prettier check for SCSS +pnpm run lint:js # Linter for JS/TS +pnpm run lint:clj # Linter for CLJ/CLJS/CLJC +pnpm run lint:scss # Linter for SCSS -# Code formatting -pnpm run fmt:clj # Format CLJ -pnpm run fmt:js # prettier for JS -pnpm run fmt:scss # prettier for SCSS +# Check Code Formart +pnpm run check-fmt:clj # Format CLJ/CLJS/CLJC +pnpm run check-fmt:js # Format JS/TS +pnpm run check-fmt:scss # Format SCSS + +# Code Format (Automatic Formating) +pnpm run fmt:clj # Format CLJ/CLJS/CLJC +pnpm run fmt:js # Format JS/TS +pnpm run fmt:scss # Format SCSS ``` To run a focused ClojureScript unit test: edit @@ -58,28 +63,63 @@ run build:test && node target/tests/test.js`. ### Backend (`cd backend`) -```bash -# Tests (Kaocha) -clojure -M:dev:test # Full suite -clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace +Run `pnpm install` for install all dependencies. -# Lint / Format -pnpm run lint:clj -pnpm run fmt:clj +```bash +# Run full test suite +pnpm run test + +# Run single namespace +pnpm run test --focus backend-tests.rpc-doc-test + +# Check Code Format +pnpm run check-fmt + +# Code Format (Automatic Formatting) +pnpm run fmt + +# Code Linter +pnpm run lint ``` -Test config is in `backend/tests.edn`; test namespaces match `.*-test$` under `test/`. +Test config is in `backend/tests.edn`; test namespaces match +`.*-test$` under `test/` directory. You should not touch this file, +just use it for reference. ### Common (`cd common`) +This contains code that should compile and run under different runtimes: JVM & JS so the commands are +separarated for each runtime. + ```bash -pnpm run test # Build + run node target/tests/test.js -pnpm run watch:test # Watch mode -pnpm run lint:clj -pnpm run fmt:clj +clojure -M:dev:test # Run full test suite under JVM +clojure -M:dev:test --focus backend-tests.my-ns-test # Run single namespace under JVM + +# Run full test suite under JS or JVM runtimes +pnpm run test:js +pnpm run test:jvm + +# Run single namespace (only on JVM) +pnpm run test:jvm --focus common-tests.my-ns-test + +# Lint +pnpm run lint:clj # Lint CLJ/CLJS/CLJC code + +# Check Format +pnpm run check-fmt:clj # Check CLJ/CLJS/CLJS code +pnpm run check-fmt:js # Check JS/TS code + +# Code Format (Automatic Formatting) +pnpm run fmt:clj # Check CLJ/CLJS/CLJS code +pnpm run fmt:js # Check JS/TS code ``` +To run a focused ClojureScript unit test: edit +`test/common_tests/runner.cljs` to narrow the test suite, then `pnpm +run build:test && node target/tests/test.js`. + + ### Render-WASM (`cd render-wasm`) ```bash @@ -93,6 +133,10 @@ cargo fmt --check ### Namespace Structure +The backend, frontend and exporter are developed using clojure and +clojurescript and code is organized in namespaces. This is a general +overview of the available namespaces. + **Backend:** - `app.rpc.commands.*` – RPC command implementations (`auth`, `files`, `teams`, etc.) - `app.http.*` – HTTP routes and middleware @@ -109,14 +153,26 @@ cargo fmt --check - `app.util.*` – Utilities (DOM, HTTP, i18n, keyboard shortcuts) **Common:** -- `app.common.types.*` – Shared data types for shapes, files, pages -- `app.common.schema` – Malli validation schemas -- `app.common.geom.*` – Geometry utilities +- `app.common.types.*` – Shared data types for shapes, files, pages using Malli schemas +- `app.common.schema` – Malli abstraction layer, exposes the most used functions from malli +- `app.common.geom.*` – Geometry and shape transformation helpers +- `app.common.data` – Generic helpers used around all application +- `app.common.math` – Generic math helpers used around all aplication +- `app.common.json` – Generic JSON encoding/decoding helpers - `app.common.data.macros` – Performance macros used everywhere + ### Backend RPC Commands -All API calls go through a single RPC endpoint: `POST /api/rpc/command/`. +The PRC methods are implement in a some kind of multimethod structure using +`app.util.serivices` namespace. All RPC methods are collected under `app.rpc` +namespace and exposed under `/api/rpc/command/`. The RPC method +accepts POST and GET requests indistinctly and uses `Accept` header for +negotiate the response encoding (which can be transit, the defaut or plain +json). It also accepts transit (defaut) or json as input, which should be +indicated using `Content-Type` header. + +This is an example: ```clojure (sv/defmethod ::my-command @@ -129,12 +185,18 @@ All API calls go through a single RPC endpoint: `POST /api/rpc/command/ my-component* -props]`, where props should be a map literal or symbol pointing to -javascript props objects. The javascript props object can be created -manually `#js {:data-foo "bar"}` or using `mf/spread-object` helper -macro. +Example for `mf/with-memo` macro: ---- +```clj +;; Using functions +(mf/use-effect + (mf/deps team-id) + (fn [] + (st/emit! (dd/initialize team-id)) + (fn [] + (st/emit! (dd/finalize team-id))))) -## Commit Guidelines +;; The same effect but using mf/with-effect +(mf/with-effect [team-id] + (st/emit! (dd/initialize team-id)) + (fn [] + (st/emit! (dd/finalize team-id)))) +``` + +Example for `mf/with-memo` macro: + +``` +;; Using functions +(mf/use-memo + (mf/deps projects team-id) + (fn [] + (->> (vals projects) + (filterv #(= team-id (:team-id %)))))) + +;; Using the macro +(mf/with-memo [projects team-id] + (->> (vals projects) + (filterv #(= team-id (:team-id %))))) +``` + +Prefer using the macros for it syntax simplicity. + + +4. Component Usage (Hiccup Syntax) + +When invoking a component within Hiccup, always use the [:> component* props] +pattern. + +Requirements for props: + +- Must be a map literal or a symbol pointing to a JavaScript props object. +- To create a JS props object, use the `#js` literal or the `mf/spread-object` helper macro. + +Examples: + +```clj +;; Using object literal (no need of #js because macro already interprets it) +[:> my-component* {:data-foo "bar"}] + +;; Using object literal (no need of #js because macro already interprets it) +(let [props #js {:data-foo "bar" + :className "myclass"}] + [:> my-component* props]) + +;; Using the spread helper +(let [props (mf/spread-object base-props {:extra "data"})] + [:> my-component* props]) +``` + +4. Checklist + +- [ ] Does the component name end with *? + + +## Commit Format Guidelines Format: ` ` @@ -263,3 +454,46 @@ applicable. | ⬇️ | `:arrow_down:` | Dependency downgrade | | 🔥 | `:fire:` | Remove files or code | | 🌐 | `:globe_with_meridians:` | Translations | + + +## SCSS Rules & Migration + +### General rules + +- Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss + variables and get the already defined properties from `_sizes.scss`. The SCSS + variables are allowed and still used, just prefer properties if they are + already defined. +- If a value isn't in the DS, use the `px2rem(n)` mixin: `@use "ds/_utils.scss" + as *; padding: px2rem(23);`. +- Do **not** create new SCSS variables for one-off values. +- Use physical directions with logical ones to support RTL/LTR naturally. + - ❌ `margin-left`, `padding-right`, `left`, `right`. + - ✅ `margin-inline-start`, `padding-inline-end`, `inset-inline-start`. +- Always use the `use-typography` mixin from `ds/typography.scss`. + - ✅ `@include t.use-typography("title-small");` +- Use `$br-*` for radius and `$b-*` for thickness from `ds/_borders.scss`. +- Use only tokens from `ds/colors.scss`. Do **NOT** use `design-tokens.scss` or + legacy color variables. +- Use mixins only those defined in`ds/mixins.scss`. Avoid legacy mixins like + `@include flexCenter;`. Write standard CSS (flex/grid) instead. + +### Syntax & Structure + +- Use the `@use` instead of `@import`. If you go to refactor existing SCSS file, + try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as + *;` (Use `as *` to expose variables directly). +- Avoid deep selector nesting or high-specificity (IDs). Flatten selectors: + - ❌ `.card { .title { ... } }` + - ✅ `.card-title { ... }` +- Leverage component-level CSS variables for state changes (hover/focus) instead + of rewriting properties. + +### Checklist + +- [ ] No references to `common/refactor/` +- [ ] All `@import` converted to `@use` (only if refactoring) +- [ ] Physical properties (left/right) using logical properties (inline-start/end). +- [ ] Typography implemented via `use-typography()` mixin. +- [ ] Hardcoded pixel values wrapped in `px2rem()`. +- [ ] Selectors are flat (no deep nesting). diff --git a/backend/package.json b/backend/package.json index 63bf06eddf..8ad7cd3c1d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,9 @@ "ws": "^8.17.0" }, "scripts": { - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", - "fmt:clj": "cljfmt fix --parallel=true src/ test/" + "lint": "clj-kondo --parallel --lint ../common/src src/", + "check-fmt": "cljfmt check --parallel=true src/ test/", + "fmt": "cljfmt fix --parallel=true src/ test/", + "test": "clojure -M:dev:test" } } diff --git a/common/package.json b/common/package.json index 09de4e95aa..ac874c2b45 100644 --- a/common/package.json +++ b/common/package.json @@ -13,6 +13,7 @@ "devDependencies": { "concurrently": "^9.1.2", "nodemon": "^3.1.10", + "prettier": "3.5.3", "source-map-support": "^0.5.21", "ws": "^8.18.2" }, @@ -20,11 +21,15 @@ "date-fns": "^4.1.0" }, "scripts": { - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel=true --lint src/", + "lint:clj": "clj-kondo --parallel=true --lint src/", + "check-fmt:clj": "cljfmt check --parallel=true src/ test/", + "check-fmt:js": "prettier -c src/**/*.js", "fmt:clj": "cljfmt fix --parallel=true src/ test/", + "fmt:js": "prettier -c src/**/*.js -w", "lint": "pnpm run lint:clj", "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"", "build:test": "clojure -M:dev:shadow-cljs compile test", - "test": "pnpm run build:test && node target/tests/test.js" + "test:js": "pnpm run build:test && node target/tests/test.js", + "test:jvm": "clojure -M:dev:test" } } diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 8536654155..7f63f16b3c 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: nodemon: specifier: ^3.1.10 version: 3.1.11 + prettier: + specifier: 3.5.3 + version: 3.5.3 source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -169,6 +172,11 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + prettier@3.5.3: + resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + engines: {node: '>=14'} + hasBin: true + pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -405,6 +413,8 @@ snapshots: picomatch@2.3.1: {} + prettier@3.5.3: {} + pstree.remy@1.1.8: {} readdirp@3.6.0: diff --git a/common/scripts/test b/common/scripts/test index 6402c5afd1..b064f2b8d2 100755 --- a/common/scripts/test +++ b/common/scripts/test @@ -4,4 +4,5 @@ set -ex corepack enable; corepack install; pnpm install; -pnpm run test; +pnpm run test:js; +pnpm run test:jvm; diff --git a/common/src/app/common/encoding_impl.js b/common/src/app/common/encoding_impl.js index 9af7d0fd57..a08f51170c 100644 --- a/common/src/app/common/encoding_impl.js +++ b/common/src/app/common/encoding_impl.js @@ -10,7 +10,7 @@ goog.require("cljs.core"); goog.provide("app.common.encoding_impl"); -goog.scope(function() { +goog.scope(function () { const core = cljs.core; const global = goog.global; const self = app.common.encoding_impl; @@ -28,8 +28,10 @@ goog.scope(function() { // Accept UUID hex format input = input.replace(/-/g, ""); - if ((input.length % 2) !== 0) { - throw new RangeError("Expected string to be an even number of characters") + if (input.length % 2 !== 0) { + throw new RangeError( + "Expected string to be an even number of characters", + ); } const view = new Uint8Array(input.length / 2); @@ -44,7 +46,11 @@ goog.scope(function() { function bufferToHex(source, isUuid) { if (source instanceof Uint8Array) { } else if (ArrayBuffer.isView(source)) { - source = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + source = new Uint8Array( + source.buffer, + source.byteOffset, + source.byteLength, + ); } else if (Array.isArray(source)) { source = Uint8Array.from(source); } @@ -56,22 +62,28 @@ goog.scope(function() { const spacer = isUuid ? "-" : ""; let i = 0; - return (hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]]); + return ( + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + ); } self.hexToBuffer = hexToBuffer; @@ -87,8 +99,10 @@ goog.scope(function() { // for base16 (hex), base32, or base64 encoding in a standards // compliant manner. - function getBaseCodec (ALPHABET) { - if (ALPHABET.length >= 255) { throw new TypeError("Alphabet too long"); } + function getBaseCodec(ALPHABET) { + if (ALPHABET.length >= 255) { + throw new TypeError("Alphabet too long"); + } let BASE_MAP = new Uint8Array(256); for (let j = 0; j < BASE_MAP.length; j++) { BASE_MAP[j] = 255; @@ -96,22 +110,32 @@ goog.scope(function() { for (let i = 0; i < ALPHABET.length; i++) { let x = ALPHABET.charAt(i); let xc = x.charCodeAt(0); - if (BASE_MAP[xc] !== 255) { throw new TypeError(x + " is ambiguous"); } + if (BASE_MAP[xc] !== 255) { + throw new TypeError(x + " is ambiguous"); + } BASE_MAP[xc] = i; } let BASE = ALPHABET.length; let LEADER = ALPHABET.charAt(0); let FACTOR = Math.log(BASE) / Math.log(256); // log(BASE) / log(256), rounded up let iFACTOR = Math.log(256) / Math.log(BASE); // log(256) / log(BASE), rounded up - function encode (source) { + function encode(source) { if (source instanceof Uint8Array) { } else if (ArrayBuffer.isView(source)) { - source = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + source = new Uint8Array( + source.buffer, + source.byteOffset, + source.byteLength, + ); } else if (Array.isArray(source)) { source = Uint8Array.from(source); } - if (!(source instanceof Uint8Array)) { throw new TypeError("Expected Uint8Array"); } - if (source.length === 0) { return ""; } + if (!(source instanceof Uint8Array)) { + throw new TypeError("Expected Uint8Array"); + } + if (source.length === 0) { + return ""; + } // Skip & count leading zeroes. let zeroes = 0; let length = 0; @@ -129,12 +153,18 @@ goog.scope(function() { let carry = source[pbegin]; // Apply "b58 = b58 * 256 + ch". let i = 0; - for (let it1 = size - 1; (carry !== 0 || i < length) && (it1 !== -1); it1--, i++) { + for ( + let it1 = size - 1; + (carry !== 0 || i < length) && it1 !== -1; + it1--, i++ + ) { carry += (256 * b58[it1]) >>> 0; - b58[it1] = (carry % BASE) >>> 0; + b58[it1] = carry % BASE >>> 0; carry = (carry / BASE) >>> 0; } - if (carry !== 0) { throw new Error("Non-zero carry"); } + if (carry !== 0) { + throw new Error("Non-zero carry"); + } length = i; pbegin++; } @@ -145,13 +175,19 @@ goog.scope(function() { } // Translate the result into a string. let str = LEADER.repeat(zeroes); - for (; it2 < size; ++it2) { str += ALPHABET.charAt(b58[it2]); } + for (; it2 < size; ++it2) { + str += ALPHABET.charAt(b58[it2]); + } return str; } - function decodeUnsafe (source) { - if (typeof source !== "string") { throw new TypeError("Expected String"); } - if (source.length === 0) { return new Uint8Array(); } + function decodeUnsafe(source) { + if (typeof source !== "string") { + throw new TypeError("Expected String"); + } + if (source.length === 0) { + return new Uint8Array(); + } let psz = 0; // Skip and count leading '1's. let zeroes = 0; @@ -161,21 +197,29 @@ goog.scope(function() { psz++; } // Allocate enough space in big-endian base256 representation. - let size = (((source.length - psz) * FACTOR) + 1) >>> 0; // log(58) / log(256), rounded up. + let size = ((source.length - psz) * FACTOR + 1) >>> 0; // log(58) / log(256), rounded up. let b256 = new Uint8Array(size); // Process the characters. while (source[psz]) { // Decode character let carry = BASE_MAP[source.charCodeAt(psz)]; // Invalid character - if (carry === 255) { return; } + if (carry === 255) { + return; + } let i = 0; - for (let it3 = size - 1; (carry !== 0 || i < length) && (it3 !== -1); it3--, i++) { + for ( + let it3 = size - 1; + (carry !== 0 || i < length) && it3 !== -1; + it3--, i++ + ) { carry += (BASE * b256[it3]) >>> 0; - b256[it3] = (carry % 256) >>> 0; + b256[it3] = carry % 256 >>> 0; carry = (carry / 256) >>> 0; } - if (carry !== 0) { throw new Error("Non-zero carry"); } + if (carry !== 0) { + throw new Error("Non-zero carry"); + } length = i; psz++; } @@ -192,20 +236,22 @@ goog.scope(function() { return vch; } - function decode (string) { + function decode(string) { let buffer = decodeUnsafe(string); - if (buffer) { return buffer; } + if (buffer) { + return buffer; + } throw new Error("Non-base" + BASE + " character"); } return { encode: encode, decodeUnsafe: decodeUnsafe, - decode: decode + decode: decode, }; } // MORE bases here: https://github.com/cryptocoinjs/base-x/tree/master - const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const BASE62 = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; self.bufferToBase62 = getBaseCodec(BASE62).encode; - }); diff --git a/common/src/app/common/svg/path/arc_to_bezier.js b/common/src/app/common/svg/path/arc_to_bezier.js index 39dc8d447f..b4220a7d95 100644 --- a/common/src/app/common/svg/path/arc_to_bezier.js +++ b/common/src/app/common/svg/path/arc_to_bezier.js @@ -14,7 +14,7 @@ goog.provide("app.common.svg.path.arc_to_bezier"); // https://raw.githubusercontent.com/fontello/svgpath/master/lib/a2c.js -goog.scope(function() { +goog.scope(function () { const self = app.common.svg.path.arc_to_bezier; var TAU = Math.PI * 2; @@ -27,20 +27,23 @@ goog.scope(function() { // we can use simplified math (without length normalization) // function unit_vector_angle(ux, uy, vx, vy) { - var sign = (ux * vy - uy * vx < 0) ? -1 : 1; - var dot = ux * vx + uy * vy; + var sign = ux * vy - uy * vx < 0 ? -1 : 1; + var dot = ux * vx + uy * vy; // Add this to work with arbitrary vectors: // dot /= Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy); // rounding errors, e.g. -1.0000000000000002 can screw up this - if (dot > 1.0) { dot = 1.0; } - if (dot < -1.0) { dot = -1.0; } + if (dot > 1.0) { + dot = 1.0; + } + if (dot < -1.0) { + dot = -1.0; + } return sign * Math.acos(dot); } - // Convert from endpoint to center parameterization, // see http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes // @@ -53,11 +56,11 @@ goog.scope(function() { // points. After that, rotate it to line up ellipse axes with coordinate // axes. // - var x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2; - var y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2; + var x1p = (cos_phi * (x1 - x2)) / 2 + (sin_phi * (y1 - y2)) / 2; + var y1p = (-sin_phi * (x1 - x2)) / 2 + (cos_phi * (y1 - y2)) / 2; - var rx_sq = rx * rx; - var ry_sq = ry * ry; + var rx_sq = rx * rx; + var ry_sq = ry * ry; var x1p_sq = x1p * x1p; var y1p_sq = y1p * y1p; @@ -66,33 +69,33 @@ goog.scope(function() { // Compute coordinates of the centre of this ellipse (cx', cy') // in the new coordinate system. // - var radicant = (rx_sq * ry_sq) - (rx_sq * y1p_sq) - (ry_sq * x1p_sq); + var radicant = rx_sq * ry_sq - rx_sq * y1p_sq - ry_sq * x1p_sq; if (radicant < 0) { // due to rounding errors it might be e.g. -1.3877787807814457e-17 radicant = 0; } - radicant /= (rx_sq * y1p_sq) + (ry_sq * x1p_sq); + radicant /= rx_sq * y1p_sq + ry_sq * x1p_sq; radicant = Math.sqrt(radicant) * (fa === fs ? -1 : 1); - var cxp = radicant * rx/ry * y1p; - var cyp = radicant * -ry/rx * x1p; + var cxp = ((radicant * rx) / ry) * y1p; + var cyp = ((radicant * -ry) / rx) * x1p; // Step 3. // // Transform back to get centre coordinates (cx, cy) in the original // coordinate system. // - var cx = cos_phi*cxp - sin_phi*cyp + (x1+x2)/2; - var cy = sin_phi*cxp + cos_phi*cyp + (y1+y2)/2; + var cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2; + var cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2; // Step 4. // // Compute angles (theta1, delta_theta). // - var v1x = (x1p - cxp) / rx; - var v1y = (y1p - cyp) / ry; + var v1x = (x1p - cxp) / rx; + var v1y = (y1p - cyp) / ry; var v2x = (-x1p - cxp) / rx; var v2y = (-y1p - cyp) / ry; @@ -106,7 +109,7 @@ goog.scope(function() { delta_theta += TAU; } - return [ cx, cy, theta1, delta_theta ]; + return [cx, cy, theta1, delta_theta]; } // @@ -114,24 +117,33 @@ goog.scope(function() { // see http://math.stackexchange.com/questions/873224 // function approximate_unit_arc(theta1, delta_theta) { - var alpha = 4/3 * Math.tan(delta_theta/4); + var alpha = (4 / 3) * Math.tan(delta_theta / 4); var x1 = Math.cos(theta1); var y1 = Math.sin(theta1); var x2 = Math.cos(theta1 + delta_theta); var y2 = Math.sin(theta1 + delta_theta); - return [ x1, y1, x1 - y1*alpha, y1 + x1*alpha, x2 + y2*alpha, y2 - x2*alpha, x2, y2 ]; + return [ + x1, + y1, + x1 - y1 * alpha, + y1 + x1 * alpha, + x2 + y2 * alpha, + y2 - x2 * alpha, + x2, + y2, + ]; } function calculate_beziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { - var sin_phi = Math.sin(phi * TAU / 360); - var cos_phi = Math.cos(phi * TAU / 360); + var sin_phi = Math.sin((phi * TAU) / 360); + var cos_phi = Math.cos((phi * TAU) / 360); // Make sure radii are valid // - var x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2; - var y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2; + var x1p = (cos_phi * (x1 - x2)) / 2 + (sin_phi * (y1 - y2)) / 2; + var y1p = (-sin_phi * (x1 - x2)) / 2 + (cos_phi * (y1 - y2)) / 2; // console.log("L", x1p, y1p) @@ -145,7 +157,6 @@ goog.scope(function() { return []; } - // Compensate out-of-range radii // rx = Math.abs(rx); @@ -157,25 +168,20 @@ goog.scope(function() { ry *= Math.sqrt(lambda); } - // Get center parameters (cx, cy, theta1, delta_theta) // var cc = get_arc_center(x1, y1, x2, y2, fa, fs, rx, ry, sin_phi, cos_phi); - var result = []; var theta1 = cc[2]; var delta_theta = cc[3]; - - // Split an arc to multiple segments, so each segment // will be less than τ/4 (= 90°) // var segments = Math.max(Math.ceil(Math.abs(delta_theta) / (TAU / 4)), 1); delta_theta /= segments; - for (var i = 0; i < segments; i++) { var item = approximate_unit_arc(theta1, delta_theta); result.push(item); @@ -195,8 +201,8 @@ goog.scope(function() { y *= ry; // rotate - var xp = cos_phi*x - sin_phi*y; - var yp = sin_phi*x + cos_phi*y; + var xp = cos_phi * x - sin_phi * y; + var yp = sin_phi * x + cos_phi * y; // translate curve[i + 0] = xp + cc[0]; diff --git a/common/src/app/common/svg/path/parser.js b/common/src/app/common/svg/path/parser.js index 5bbcddd3a0..f427874528 100644 --- a/common/src/app/common/svg/path/parser.js +++ b/common/src/app/common/svg/path/parser.js @@ -31,74 +31,81 @@ class Segment { toPersistentMap() { const fromArray = (data) => { return cljs.PersistentArrayMap.fromArray(data); - } + }; let command, params; - switch(this.command) { - case "M": - command = MOVE_TO; - params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); - break; + switch (this.command) { + case "M": + command = MOVE_TO; + params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); + break; - case "Z": - command = CLOSE_PATH; - params = cljs.PersistentArrayMap.EMPTY; - break; + case "Z": + command = CLOSE_PATH; + params = cljs.PersistentArrayMap.EMPTY; + break; - case "L": - command = LINE_TO; - params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); - break; + case "L": + command = LINE_TO; + params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); + break; - case "C": - command = CURVE_TO; - params = fromArray([K_C1X, this.params[0], - K_C1Y, this.params[1], - K_C2X, this.params[2], - K_C2Y, this.params[3], - K_X, this.params[4], - K_Y, this.params[5]]); - break; - default: - command = null - params = null; + case "C": + command = CURVE_TO; + params = fromArray([ + K_C1X, + this.params[0], + K_C1Y, + this.params[1], + K_C2X, + this.params[2], + K_C2Y, + this.params[3], + K_X, + this.params[4], + K_Y, + this.params[5], + ]); + break; + default: + command = null; + params = null; } if (command === null || params === null) { throw new Error("invalid segment"); } - return fromArray([K_COMMAND, command, - K_PARAMS, params]) + return fromArray([K_COMMAND, command, K_PARAMS, params]); } } function validCommand(c) { switch (c) { - case "Z": - case "M": - case "L": - case "C": - case "Q": - case "A": - case "H": - case "V": - case "S": - case "T": - case "z": - case "m": - case "l": - case "c": - case "q": - case "a": - case "h": - case "v": - case "s": - case "t": - return true; - default: - return false; + case "Z": + case "M": + case "L": + case "C": + case "Q": + case "A": + case "H": + case "V": + case "S": + case "T": + case "z": + case "m": + case "l": + case "c": + case "q": + case "a": + case "h": + case "v": + case "s": + case "t": + return true; + default: + return false; } } @@ -118,11 +125,11 @@ class Parser { next() { const done = !this.hasNext(); if (done) { - return {done: true}; + return { done: true }; } else { return { done: false, - value: this.parseSegment() + value: this.parseSegment(), }; } } @@ -130,8 +137,10 @@ class Parser { hasNext() { if (this._currentIndex === 0) { const command = this._peekSegmentCommand(); - return ((this._currentIndex < this._endIndex) && - (command === "M" || command === "m")); + return ( + this._currentIndex < this._endIndex && + (command === "M" || command === "m") + ); } else { return this._currentIndex < this._endIndex; } @@ -148,7 +157,10 @@ class Parser { } // Check for remaining coordinates in the current command. - if ((ch === "+" || ch === "-" || ch === "." || (ch >= "0" && ch <= "9")) && this._prevCommand !== "Z") { + if ( + (ch === "+" || ch === "-" || ch === "." || (ch >= "0" && ch <= "9")) && + this._prevCommand !== "Z" + ) { if (this._prevCommand === "M") { command = "L"; } else if (this._prevCommand === "m") { @@ -177,7 +189,12 @@ class Parser { } else if (cmd === "M" || cmd === "L" || cmd === "T") { params = [this._parseNumber(), this._parseNumber()]; } else if (cmd === "S" || cmd === "Q") { - params = [this._parseNumber(), this._parseNumber(), this._parseNumber(), this._parseNumber()]; + params = [ + this._parseNumber(), + this._parseNumber(), + this._parseNumber(), + this._parseNumber(), + ]; } else if (cmd === "C") { params = [ this._parseNumber(), @@ -185,7 +202,7 @@ class Parser { this._parseNumber(), this._parseNumber(), this._parseNumber(), - this._parseNumber() + this._parseNumber(), ]; } else if (cmd === "A") { params = [ @@ -195,7 +212,7 @@ class Parser { this._parseArcFlag(), this._parseArcFlag(), this._parseNumber(), - this._parseNumber() + this._parseNumber(), ]; } else if (cmd === "Z") { this._skipOptionalSpaces(); @@ -217,7 +234,10 @@ class Parser { _isCurrentSpace() { var ch = this._string[this._currentIndex]; - return ch <= " " && (ch === " " || ch === "\n" || ch === "\t" || ch === "\r" || ch === "\f"); + return ( + ch <= " " && + (ch === " " || ch === "\n" || ch === "\t" || ch === "\r" || ch === "\f") + ); } _skipOptionalSpaces() { @@ -228,14 +248,19 @@ class Parser { } _skipOptionalSpacesOrDelimiter() { - if (this._currentIndex < this._endIndex && - !this._isCurrentSpace() && - this._string[this._currentIndex] !== ",") { + if ( + this._currentIndex < this._endIndex && + !this._isCurrentSpace() && + this._string[this._currentIndex] !== "," + ) { return false; } if (this._skipOptionalSpaces()) { - if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === ",") { + if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "," + ) { this._currentIndex += 1; this._skipOptionalSpaces(); } @@ -258,16 +283,25 @@ class Parser { this._skipOptionalSpaces(); // Read the sign. - if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === "+") { + if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "+" + ) { this._currentIndex += 1; - } else if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === "-") { + } else if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "-" + ) { this._currentIndex += 1; sign = -1; } - if (this._currentIndex === this._endIndex || - ((this._string[this._currentIndex] < "0" || this._string[this._currentIndex] > "9") && - this._string[this._currentIndex] !== ".")) { + if ( + this._currentIndex === this._endIndex || + ((this._string[this._currentIndex] < "0" || + this._string[this._currentIndex] > "9") && + this._string[this._currentIndex] !== ".") + ) { // The first chacter of a number must be one of [0-9+-.]. return null; } @@ -275,9 +309,11 @@ class Parser { // Read the integer part, build right-to-left. var startIntPartIndex = this._currentIndex; - while (this._currentIndex < this._endIndex && - this._string[this._currentIndex] >= "0" && - this._string[this._currentIndex] <= "9") { + while ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] >= "0" && + this._string[this._currentIndex] <= "9" + ) { this._currentIndex += 1; // Advance to first non-digit. } @@ -293,19 +329,26 @@ class Parser { } // Read the decimals. - if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === ".") { + if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "." + ) { this._currentIndex += 1; // There must be a least one digit following the . - if (this._currentIndex >= this._endIndex || - this._string[this._currentIndex] < "0" || - this._string[this._currentIndex] > "9") { + if ( + this._currentIndex >= this._endIndex || + this._string[this._currentIndex] < "0" || + this._string[this._currentIndex] > "9" + ) { return null; } - while (this._currentIndex < this._endIndex && - this._string[this._currentIndex] >= "0" && - this._string[this._currentIndex] <= "9") { + while ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] >= "0" && + this._string[this._currentIndex] <= "9" + ) { frac *= 10; decimal += (this._string[this._currentIndex] - "0") / frac; this._currentIndex += 1; @@ -313,10 +356,14 @@ class Parser { } // Read the exponent part. - if (this._currentIndex !== startIndex && - this._currentIndex + 1 < this._endIndex && - (this._string[this._currentIndex] === "e" || this._string[this._currentIndex] === "E") && - (this._string[this._currentIndex + 1] !== "x" && this._string[this._currentIndex + 1] !== "m")) { + if ( + this._currentIndex !== startIndex && + this._currentIndex + 1 < this._endIndex && + (this._string[this._currentIndex] === "e" || + this._string[this._currentIndex] === "E") && + this._string[this._currentIndex + 1] !== "x" && + this._string[this._currentIndex + 1] !== "m" + ) { this._currentIndex += 1; // Read the sign of the exponent. @@ -328,17 +375,21 @@ class Parser { } // There must be an exponent. - if (this._currentIndex >= this._endIndex || - this._string[this._currentIndex] < "0" || - this._string[this._currentIndex] > "9") { + if ( + this._currentIndex >= this._endIndex || + this._string[this._currentIndex] < "0" || + this._string[this._currentIndex] > "9" + ) { return null; } - while (this._currentIndex < this._endIndex && - this._string[this._currentIndex] >= "0" && - this._string[this._currentIndex] <= "9") { + while ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] >= "0" && + this._string[this._currentIndex] <= "9" + ) { exponent *= 10; - exponent += (this._string[this._currentIndex] - "0"); + exponent += this._string[this._currentIndex] - "0"; this._currentIndex += 1; } } @@ -380,7 +431,7 @@ class Parser { this._skipOptionalSpacesOrDelimiter(); return flag; } -}; +} function absolutizePathData(pdata) { var currentX = null; @@ -389,212 +440,210 @@ function absolutizePathData(pdata) { var subpathX = null; var subpathY = null; - for (let i=0; i 1.0) ? 1.0 : (dot < -1.0) ? -1.0 : dot; + dot = dot > 1.0 ? 1.0 : dot < -1.0 ? -1.0 : dot; return sign * Math.acos(dot); } function getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi) { - let x1p = (cosPhi * ((x1 - x2) / 2)) + (sinPhi * ((y1 - y2) / 2)); - let y1p = (-sinPhi * ((x1 - x2) / 2)) + (cosPhi * ((y1 - y2) / 2)); + let x1p = cosPhi * ((x1 - x2) / 2) + sinPhi * ((y1 - y2) / 2); + let y1p = -sinPhi * ((x1 - x2) / 2) + cosPhi * ((y1 - y2) / 2); let rxSq = rx * rx; let rySq = ry * ry; @@ -602,9 +651,9 @@ function getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi) { let y1pSq = y1p * y1p; let radicant = rxSq * rySq - rxSq * y1pSq - rySq * x1pSq; - radicant = (radicant < 0) ? 0 : radicant; - radicant /= (rxSq * y1pSq + rySq * x1pSq); - radicant = (Math.sqrt(radicant) * ((fa === fs) ? -1 : 1)) + radicant = radicant < 0 ? 0 : radicant; + radicant /= rxSq * y1pSq + rySq * x1pSq; + radicant = Math.sqrt(radicant) * (fa === fs ? -1 : 1); let cxp = radicant * (rx / ry) * y1p; let cyp = radicant * (-ry / rx) * x1p; @@ -618,8 +667,8 @@ function getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi) { let theta1 = unitVectorAngle(1, 0, v1x, v1y); let dtheta = unitVectorAngle(v1x, v1y, v2x, v2y); - dtheta = (fs === 0 && dtheta > 0) ? dtheta - Math.PI * 2 : dtheta; - dtheta = (fs === 1 && dtheta < 0) ? dtheta + Math.PI * 2 : dtheta; + dtheta = fs === 0 && dtheta > 0 ? dtheta - Math.PI * 2 : dtheta; + dtheta = fs === 1 && dtheta < 0 ? dtheta + Math.PI * 2 : dtheta; return [cx, cy, theta1, dtheta]; } @@ -639,7 +688,7 @@ function approximateUnitArc(theta1, dtheta) { x2 + y2 * alpha, y2 - x2 * alpha, x2, - y2 + y2, ]; } @@ -674,7 +723,7 @@ function processCurve(curve, cx, cy, rx, ry, sinPhi, cosPhi) { export function arcToBeziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { const tau = Math.PI * 2; - const phiTau = phi * tau / 360; + const phiTau = (phi * tau) / 360; const sinPhi = Math.sin(phiTau); const cosPhi = Math.cos(phiTau); @@ -688,7 +737,7 @@ export function arcToBeziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { } if (rx === 0 || ry === 0) { - // one of the radii is zero + // one of the radii is zero return []; } @@ -696,8 +745,8 @@ export function arcToBeziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { ry = Math.abs(ry); let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry); - rx = (lambda > 1) ? rx * Math.sqrt(lambda) : rx; - ry = (lambda > 1) ? ry * Math.sqrt(lambda) : ry; + rx = lambda > 1 ? rx * Math.sqrt(lambda) : rx; + ry = lambda > 1 ? ry * Math.sqrt(lambda) : ry; const cc = getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi); const cx = cc[0]; @@ -736,175 +785,183 @@ function simplifyPathData(pdata) { var subpathX = null; var subpathY = null; - for (let i=0; i { - if (typeof global.crypto !== "undefined" && - typeof global.crypto.getRandomValues !== "undefined") { + if ( + typeof global.crypto !== "undefined" && + typeof global.crypto.getRandomValues !== "undefined" + ) { return (buf) => { global.crypto.getRandomValues(buf); return buf; @@ -30,7 +32,7 @@ goog.scope(function() { return (buf) => { const bytes = randomBytes(buf.length); - buf.set(bytes) + buf.set(bytes); return buf; }; } else { @@ -39,8 +41,10 @@ goog.scope(function() { return (buf) => { for (let i = 0, r; i < buf.length; i++) { - if ((i & 0x03) === 0) { r = Math.random() * 0x100000000; } - buf[i] = r >>> ((i & 0x03) << 3) & 0xff; + if ((i & 0x03) === 0) { + r = Math.random() * 0x100000000; + } + buf[i] = (r >>> ((i & 0x03) << 3)) & 0xff; } return buf; }; @@ -50,31 +54,38 @@ goog.scope(function() { function toHexString(buf) { const hexMap = encoding.hexMap; let i = 0; - return (hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]]); - }; + return ( + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + ); + } function getBigUint64(view, byteOffset, le) { const a = view.getUint32(byteOffset, le); const b = view.getUint32(byteOffset + 4, le); const leMask = Number(!!le); const beMask = Number(!le); - return ((BigInt(a * beMask + b * leMask) << 32n) | - (BigInt(a * leMask + b * beMask))); + return ( + (BigInt(a * beMask + b * leMask) << 32n) | BigInt(a * leMask + b * beMask) + ); } function setBigUint64(view, byteOffset, value, le) { @@ -83,8 +94,7 @@ goog.scope(function() { if (le) { view.setUint32(byteOffset + 4, hi, le); view.setUint32(byteOffset, lo, le); - } - else { + } else { view.setUint32(byteOffset, hi, le); view.setUint32(byteOffset + 4, lo, le); } @@ -104,17 +114,18 @@ goog.scope(function() { } self.shortID = (function () { - const buff = new ArrayBuffer(8); + const buff = new ArrayBuffer(8); const int8 = new Uint8Array(buff); - const view = new DataView(buff); + const view = new DataView(buff); const base = 0x0000_0000_0000_0000n; return function shortID(ts) { const tss = currentTimestamp(timeRef); - const msb = (base - | (nextLong() & 0xffff_ffff_0000_0000n) - | (tss & 0x0000_0000_ffff_ffffn)); + const msb = + base | + (nextLong() & 0xffff_ffff_0000_0000n) | + (tss & 0x0000_0000_ffff_ffffn); setBigUint64(view, 0, msb, false); return encoding.toBase62(int8); }; @@ -139,9 +150,9 @@ goog.scope(function() { const maxCs = 0x0000_0000_0000_3fffn; // 14 bits space let countCs = 0n; - let lastRd = 0n; - let lastCs = 0n; - let lastTs = 0n; + let lastRd = 0n; + let lastCs = 0n; + let lastTs = 0n; let baseMsb = 0x0000_0000_0000_8000n; let baseLsb = 0x8000_0000_0000_0000n; @@ -149,12 +160,9 @@ goog.scope(function() { lastCs = nextLong() & maxCs; const create = function create(ts, lastRd, lastCs) { - const msb = (baseMsb - | (lastRd & 0xffff_ffff_ffff_0fffn)); + const msb = baseMsb | (lastRd & 0xffff_ffff_ffff_0fffn); - const lsb = (baseLsb - | ((ts << 14n) & 0x3fff_ffff_ffff_c000n) - | lastCs); + const lsb = baseLsb | ((ts << 14n) & 0x3fff_ffff_ffff_c000n) | lastCs; setBigUint64(view, 0, msb, false); setBigUint64(view, 8, lsb, false); @@ -167,10 +175,10 @@ goog.scope(function() { let ts = currentTimestamp(timeRef); // Protect from clock regression - if ((ts - lastTs) < 0) { - lastRd = (lastRd - & 0x0000_0000_0000_0f00n - | (nextLong() & 0xffff_ffff_ffff_f0ffn)); + if (ts - lastTs < 0) { + lastRd = + (lastRd & 0x0000_0000_0000_0f00n) | + (nextLong() & 0xffff_ffff_ffff_f0ffn); countCs = 0n; continue; } @@ -209,63 +217,63 @@ goog.scope(function() { // Parse ........-....-....-####-............ int8[8] = (rest = parseInt(uuid.slice(19, 23), 16)) >>> 8; - int8[9] = rest & 0xff, - - // Parse ........-....-....-....-############ - // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) - int8[10] = ((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff; + (int8[9] = rest & 0xff), + // Parse ........-....-....-....-############ + // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) + (int8[10] = + ((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff); int8[11] = (rest / 0x100000000) & 0xff; int8[12] = (rest >>> 24) & 0xff; int8[13] = (rest >>> 16) & 0xff; int8[14] = (rest >>> 8) & 0xff; int8[15] = rest & 0xff; - } + }; const fromPair = (hi, lo) => { view.setBigInt64(0, hi); view.setBigInt64(8, lo); return encoding.bufferToHex(int8, true); - } + }; const getHi = (uuid) => { fillBytes(uuid); return view.getBigInt64(0); - } + }; const getLo = (uuid) => { fillBytes(uuid); return view.getBigInt64(8); - } + }; const getBytes = (uuid) => { fillBytes(uuid); return Int8Array.from(int8); - } + }; const getUnsignedParts = (uuid) => { fillBytes(uuid); const result = new Uint32Array(4); - result[0] = view.getUint32(0) + result[0] = view.getUint32(0); result[1] = view.getUint32(4); result[2] = view.getUint32(8); result[3] = view.getUint32(12); return result; - } + }; const fromUnsignedParts = (a, b, c, d) => { - view.setUint32(0, a) - view.setUint32(4, b) - view.setUint32(8, c) - view.setUint32(12, d) + view.setUint32(0, a); + view.setUint32(4, b); + view.setUint32(8, c); + view.setUint32(12, d); return encoding.bufferToHex(int8, true); - } + }; const fromArray = (u8data) => { int8.set(u8data); return encoding.bufferToHex(int8, true); - } + }; const setTag = (tag) => { tag = BigInt.asUintN(64, "" + tag); @@ -273,9 +281,9 @@ goog.scope(function() { throw new Error("illegal arguments: tag value should fit in 4bits"); } - lastRd = (lastRd - & 0xffff_ffff_ffff_f0ffn - | ((tag << 8) & 0x0000_0000_0000_0f00n)); + lastRd = + (lastRd & 0xffff_ffff_ffff_f0ffn) | + ((tag << 8) & 0x0000_0000_0000_0f00n); }; factory.create = create; @@ -290,9 +298,9 @@ goog.scope(function() { return factory; })(); - self.shortV8 = function(uuid) { + self.shortV8 = function (uuid) { const buff = encoding.hexToBuffer(uuid); - const short = new Uint8Array(buff, 4); + const short = new Uint8Array(buff, 4); return encoding.bufferToBase62(short); }; @@ -307,7 +315,7 @@ goog.scope(function() { return self.v8.fromPair(hi, lo); }; - self.fromBytes = function(data) { + self.fromBytes = function (data) { if (data instanceof Uint8Array) { return self.v8.fromArray(data); } else if (data instanceof Int8Array) { @@ -325,15 +333,15 @@ goog.scope(function() { return self.v8.getUnsignedParts(uuid); }; - self.fromUnsignedParts = function(a,b,c,d) { - return self.v8.fromUnsignedParts(a,b,c,d); + self.fromUnsignedParts = function (a, b, c, d) { + return self.v8.fromUnsignedParts(a, b, c, d); }; self.getHi = function (uuid) { return self.v8.getHi(uuid); - } + }; self.getLo = function (uuid) { return self.v8.getLo(uuid); - } + }; }); diff --git a/common/src/app/common/weak/impl_weak_map.js b/common/src/app/common/weak/impl_weak_map.js index 2379ea7e14..2c6fa8db53 100644 --- a/common/src/app/common/weak/impl_weak_map.js +++ b/common/src/app/common/weak/impl_weak_map.js @@ -67,8 +67,11 @@ export class WeakEqMap { } set(key, value) { - if (key === null || (typeof key !== 'object' && typeof key !== 'function')) { - throw new TypeError('WeakEqMap keys must be objects (like WeakMap).'); + if ( + key === null || + (typeof key !== "object" && typeof key !== "function") + ) { + throw new TypeError("WeakEqMap keys must be objects (like WeakMap)."); } const hash = this._hash(key); const bucket = this._getBucket(hash); diff --git a/common/tests.edn b/common/tests.edn index 9f487a7eaf..0a0582fed6 100644 --- a/common/tests.edn +++ b/common/tests.edn @@ -1,4 +1,4 @@ #kaocha/v1 - {:tests [{:id :unit - :test-paths ["test"]}] - :kaocha/reporter [kaocha.report/dots]} +{:tests [{:id :unit + :test-paths ["test"]}] + :kaocha/reporter [kaocha.report/dots]} diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index efa134d999..07fbab0bb4 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -18,6 +18,7 @@ RUN set -ex; \ curl \ bash \ git \ + ripgrep \ \ curl \ ca-certificates \ diff --git a/exporter/package.json b/exporter/package.json index 70b64bea7d..ba6570f3cc 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -34,7 +34,8 @@ "watch": "pnpm run watch:app", "build:app": "clojure -M:dev:shadow-cljs release main", "build": "pnpm run clear:shadow-cache && pnpm run build:app", - "fmt:clj": "cljfmt fix --parallel=true src/", - "lint:clj": "cljfmt check --parallel src/ && clj-kondo --parallel --lint src/" + "fmt": "cljfmt fix --parallel=true src/", + "check-fmt": "cljfmt check --parallel=true src/", + "lint": "clj-kondo --parallel --lint src/" } } diff --git a/frontend/package.json b/frontend/package.json index f1fb0b3feb..b2a1c7da1d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,12 +23,15 @@ "build:app:main": "clojure -M:dev:shadow-cljs release main worker", "build:app:worker": "clojure -M:dev:shadow-cljs release worker", "build:app": "pnpm run clear:shadow-cache && pnpm run build:app:main && pnpm run build:app:libs", + "check-fmt:clj": "cljfmt check --parallel=true src/ test/", + "check-fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js", + "check-fmt:scss": "prettier -c resources/styles -c src/**/*.scss", "fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w", "fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w", - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", - "lint:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js", - "lint:scss": "prettier -c resources/styles -c src/**/*.scss", + "lint:clj": "clj-kondo --parallel --lint ../common/src src/", + "lint:js": "exit 0", + "lint:scss": "exit 0", "build:test": "clojure -M:dev:shadow-cljs compile test", "test": "pnpm run build:test && node target/tests/test.js", "test:storybook": "vitest run --project=storybook", diff --git a/library/package.json b/library/package.json index c3f3d1c32a..f5dff418e8 100644 --- a/library/package.json +++ b/library/package.json @@ -26,8 +26,9 @@ "clear:shadow-cache": "rm -rf .shadow-cljs", "build": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs release library", "build:bundle": "./scripts/build", - "fmt:clj": "cljfmt fix --parallel=true src/ test/", - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", + "fmt": "cljfmt fix --parallel=true src/ test/", + "check-fmt": "cljfmt check --parallel=true src/ test/", + "lint": "clj-kondo --parallel --lint src/", "test": "node --test", "watch:test": "node --test --watch", "watch": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch library" diff --git a/scripts/check-fmt b/scripts/check-fmt new file mode 100755 index 0000000000..ce5c635630 --- /dev/null +++ b/scripts/check-fmt @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -ex + +cljfmt --parallel=true check \ + common/src/ \ + common/test/ \ + frontend/src/ \ + frontend/test/ \ + backend/src/ \ + backend/test/ \ + exporter/src/ \ + library/src; diff --git a/scripts/lint b/scripts/lint index d17e6d3c86..4ab59aed13 100755 --- a/scripts/lint +++ b/scripts/lint @@ -2,16 +2,6 @@ set -ex -cljfmt check --parallel=true \ - common/src/ \ - common/test/ \ - frontend/src/ \ - frontend/test/ \ - backend/src/ \ - backend/test/ \ - exporter/src/ \ - library/src; - clj-kondo --parallel=true --lint common/src; clj-kondo --parallel=true --lint frontend/src; clj-kondo --parallel=true --lint backend/src;