Compare commits

...

23 Commits

Author SHA1 Message Date
Andrey Antukh
bc8b72f6f1 WIP 2026-01-27 19:23:03 +01:00
Andrey Antukh
3dbd931e96 💄 Move flag check to the entry point instead of initialize event 2026-01-27 18:39:45 +01:00
Andrey Antukh
014e3b0e4b Move devtools perf logging helpers to util.perf ns 2026-01-27 18:37:04 +01:00
Andrey Antukh
b6f5000d1c ⬆️ Update pnpm 2026-01-27 17:57:07 +01:00
Andrey Antukh
0527124f2f Merge remote-tracking branch 'origin/staging-render' into develop 2026-01-27 17:56:03 +01:00
Andrey Antukh
faf91ac70d Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-27 17:53:16 +01:00
Eva Marco
9ca76c745f 🐛 Fix app freeze on token name change (#8214) 2026-01-27 17:31:50 +01:00
Elena Torró
9808b6ca57 Merge pull request #8205 from penpot/superalex-improve-huge-shapes-render
🎉 Improving huge shapes render
2026-01-27 13:08:25 +01:00
Aitor Moreno
de41cb5488 🐛 Fix add/remove fills to text nodes 2026-01-27 12:17:10 +01:00
Alejandro Alonso
b40ccaf030 🎉 Improve zoom actions for huge shapes 2026-01-27 11:11:38 +01:00
Alejandro Alonso
7d3ac38749 🎉 Improve huge shapes rendering 2026-01-27 11:11:38 +01:00
Elena Torro
8d1bc6c50c 🐛 Fix flex layout sorting on reverse order with no z-index 2026-01-27 09:34:36 +01:00
Andrey Antukh
3112b240a0 📎 Add missing entry on changelog 2026-01-27 09:28:41 +01:00
Andrey Antukh
56fd66b91a 🐛 Fix several issues related to path edition (#8187)
*  Improve save-path-content event consistency

Mainly removing possible race conditions from the event
implementation.

*  Ensure path content snapshot on start-path-edit event

*  Reuse already available shape-id on split-segments
2026-01-27 09:27:42 +01:00
Elena Torro
2a7c24f6fd 🐛 Fix shape operations on sidebar when using interaction observer 2026-01-27 09:03:41 +01:00
Alejandro Alonso
947aa22dee Merge pull request #8173 from penpot/elenatorro-improve-surface-performance
🔧 Improve surface rendering performance
2026-01-27 07:21:23 +01:00
David Barragán Merino
1ce0b60e3d 🔧 Run all the jobs if the workflow is launched manually 2026-01-26 17:13:54 +01:00
Elena Torro
5209a8b423 🔧 Improve surface rendering performance 2026-01-26 16:10:22 +01:00
Aitor Moreno
f4f4f5bbb5 🐛 Fix multiple issues and tests 2026-01-26 14:14:06 +01:00
David Barragán Merino
ef80901400 🔧 Enable secret inheritance 2026-01-26 14:00:55 +01:00
David Barragán Merino
5306bed548 🔧 Define deploy plugin packages workflows 2026-01-26 13:47:57 +01:00
David Barragán Merino
92a319ddd1 🔧 Rename wrangle to wrangler 2026-01-26 13:47:57 +01:00
David Barragán Merino
68a6d4c9a8 🔧 Add deploy plugin packages workflow placeholder and wrangle config files 2026-01-26 13:47:57 +01:00
53 changed files with 1459 additions and 621 deletions

View File

@@ -69,7 +69,8 @@
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
- Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187)
- Fix app freeze when introducing an error on a very long token name [Taiga #13214](https://tree.taiga.io/project/penpot/issue/13214)
## 2.12.1

View File

@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"type": "module",
"repository": {
"type": "git",

View File

@@ -124,33 +124,51 @@
(defn adjust-to-viewport
([viewport srect] (adjust-to-viewport viewport srect nil))
([viewport srect {:keys [padding] :or {padding 0}}]
([viewport srect {:keys [padding min-zoom] :or {padding 0 min-zoom nil}}]
(let [gprop (/ (:width viewport)
(:height viewport))
srect (-> srect
(update :x #(- % padding))
(update :y #(- % padding))
(update :width #(+ % padding padding))
(update :height #(+ % padding padding)))
width (:width srect)
height (:height srect)
lprop (/ width height)]
(cond
(> gprop lprop)
(let [width' (* (/ width lprop) gprop)
padding (/ (- width' width) 2)]
(-> srect
(update :x #(- % padding))
(assoc :width width')
(grc/update-rect :position)))
srect-padded (-> srect
(update :x #(- % padding))
(update :y #(- % padding))
(update :width #(+ % padding padding))
(update :height #(+ % padding padding)))
width (:width srect-padded)
height (:height srect-padded)
lprop (/ width height)
adjusted-rect
(cond
(> gprop lprop)
(let [width' (* (/ width lprop) gprop)
padding (/ (- width' width) 2)]
(-> srect-padded
(update :x #(- % padding))
(assoc :width width')
(grc/update-rect :position)))
(< gprop lprop)
(let [height' (/ (* height lprop) gprop)
padding (/ (- height' height) 2)]
(-> srect
(update :y #(- % padding))
(assoc :height height')
(grc/update-rect :position)))
(< gprop lprop)
(let [height' (/ (* height lprop) gprop)
padding (/ (- height' height) 2)]
(-> srect-padded
(update :y #(- % padding))
(assoc :height height')
(grc/update-rect :position)))
:else
(grc/update-rect srect :position)))))
:else
(grc/update-rect srect-padded :position))]
;; If min-zoom is specified and the resulting zoom would be below it,
;; return a rect with the original top-left corner centered in the viewport
;; instead of using the aspect-ratio-adjusted rect (which can push coords
;; extremely far with extreme aspect ratios).
(if (and (some? min-zoom)
(< (/ (:width viewport) (:width adjusted-rect)) min-zoom))
(let [anchor-x (:x srect)
anchor-y (:y srect)
vbox-width (/ (:width viewport) min-zoom)
vbox-height (/ (:height viewport) min-zoom)]
(-> adjusted-rect
(assoc :x (- anchor-x (/ vbox-width 2))
:y (- anchor-y (/ vbox-height 2))
:width vbox-width
:height vbox-height)
(grc/update-rect :position)))
adjusted-rect))))

View File

@@ -111,7 +111,7 @@
(def token-name-ref
[:re {:title "TokenNameRef" :gen/gen sg/text}
#"^(?!\$)([a-zA-Z0-9-$_]+\.?)*(?<!\.)$"])
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$"])
(def ^:private schema:color
[:map

View File

@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"

View File

@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"browserslist": [
"defaults"
],

View File

@@ -0,0 +1,155 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~ud7430f09-4f59-8049-8007-6277bb7586f6",
"~: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": "flex_index_position",
"~:revn": 114,
"~:modified-at": "~m1769430362161",
"~:vern": 0,
"~:id": "~u31fe2e21-73e7-80f3-8007-73894fb58240",
"~: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": "~ud7430f09-4f59-8049-8007-6277bb765abd",
"~:created-at": "~m1769007798998",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~u02e9633d-4ce7-80da-8007-736558496fa8"
],
"~:pages-index": {
"~u02e9633d-4ce7-80da-8007-736558496fa8": {
"~:id": "~u02e9633d-4ce7-80da-8007-736558496fa8",
"~:name": "Page 1",
"~:objects": {
"~#penpot/objects-map/v2": {
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^I\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d50980078e\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc2f\",\"~u94eaebe4-addd-80d1-8007-79d5055d6859\",\"~u77c71dba-32ee-804c-8007-736561cf857f\"]]]",
"~u77c71dba-32ee-804c-8007-736561cff457": "[\"~#shape\",[\"^ \",\"~:y\",396.00000357564704,\"~:rx\",8,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",80,\"~:transforming\",false,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",396.00000357564704]],[\"^>\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",396.00000357564704]],[\"^>\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",476.00000357564704]],[\"^>\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",476.00000357564704]]],\"~:r2\",8,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:r1\",8,\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cff457\",\"~:parent-id\",\"~u77c71dba-32ee-804c-8007-736561cf8584\",\"~:frame-id\",\"~u77c71dba-32ee-804c-8007-736561cf8584\",\"~:strokes\",[],\"~:x\",688.9999775886536,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",396.00000357564704,\"^9\",80,\"~:height\",80,\"~:x1\",688.9999775886536,\"~:y1\",396.00000357564704,\"~:x2\",768.9999775886536,\"~:y2\",476.00000357564704]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e8e9ea\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^M\",80,\"~:flip-y\",null]]",
"~u94eaebe4-addd-80d1-8007-79d508aa2885": "[\"~#shape\",[\"^ \",\"~:y\",612.0000188344361,\"~:rx\",8,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",80,\"~:transforming\",false,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",604.9999165534973,\"~:y\",612.0000188344361]],[\"^>\",[\"^ \",\"~:x\",684.9999165534973,\"~:y\",612.0000188344361]],[\"^>\",[\"^ \",\"~:x\",684.9999165534973,\"~:y\",692.0000188344361]],[\"^>\",[\"^ \",\"~:x\",604.9999165534973,\"~:y\",692.0000188344361]]],\"~:r2\",8,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2885\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\",\"~:strokes\",[],\"~:x\",604.9999165534973,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",604.9999165534973,\"~:y\",612.0000188344361,\"^9\",80,\"~:height\",80,\"~:x1\",604.9999165534973,\"~:y1\",612.0000188344361,\"~:x2\",684.9999165534973,\"~:y2\",692.0000188344361]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e8e9ea\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^M\",80,\"~:flip-y\",null]]",
"~u94eaebe4-addd-80d1-8007-79d508aa2886": "[\"~#shape\",[\"^ \",\"~:y\",636.0000188344361,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:rx\",8,\"~:layout-padding\",[\"^ \",\"~:p1\",8,\"~:p2\",12,\"~:p3\",8,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Dark / Button / Primary / Text / Default\",\"~:layout-align-items\",\"~:center\",\"~:width\",66,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",611.9999165534973,\"~:y\",636.0000188344361]],[\"^K\",[\"^ \",\"~:x\",677.9999165534973,\"~:y\",636.0000188344361]],[\"^K\",[\"^ \",\"~:x\",677.9999165534973,\"~:y\",668.0000188344361]],[\"^K\",[\"^ \",\"~:x\",611.9999165534973,\"~:y\",668.0000188344361]]],\"~:r2\",8,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^;\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:layout-justify-content\",\"^D\",\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2886\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\",\"~:layout-flex-dir\",\"~:row\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\",\"~:strokes\",[],\"~:x\",611.9999165534973,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",611.9999165534973,\"~:y\",636.0000188344361,\"^E\",66,\"~:height\",32,\"~:x1\",611.9999165534973,\"~:y1\",636.0000188344361,\"~:x2\",677.9999165534973,\"~:y2\",668.0000188344361]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#7efff5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^17\",32,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d508aa2887\"]]]",
"~u94eaebe4-addd-80d1-8007-79d508aa2887": "[\"~#shape\",[\"^ \",\"~:y\",644.0000188344361,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",2,\"~:p3\",0,\"~:p4\",2],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"_Utilities / Text / White\",\"~:layout-align-items\",\"~:start\",\"~:width\",42,\"~:layout-padding-type\",\"~:simple\",\"~:transforming\",false,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",623.9999165534973,\"~:y\",644.0000188344361]],[\"^K\",[\"^ \",\"~:x\",665.9999165534973,\"~:y\",644.0000188344361]],[\"^K\",[\"^ \",\"~:x\",665.9999165534973,\"~:y\",660.0000188344361]],[\"^K\",[\"^ \",\"~:x\",623.9999165534973,\"~:y\",660.0000188344361]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",0,\"~:column-gap\",6],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2887\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2886\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2886\",\"~:strokes\",[],\"~:x\",623.9999165534973,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",623.9999165534973,\"~:y\",644.0000188344361,\"^D\",42,\"~:height\",16,\"~:x1\",623.9999165534973,\"~:y1\",644.0000188344361,\"~:x2\",665.9999165534973,\"~:y2\",660.0000188344361]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",16,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d508aa2888\"]]]",
"~u94eaebe4-addd-80d1-8007-79d508aa2888": "[\"~#shape\",[\"^ \",\"~:y\",645.0000188344363,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:index\",null,\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^8\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:path\",\"\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"\",\"^<\",\"normal\",\"~:text-transform\",\"uppercase\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-work-sans\",\"~:font-size\",\"12\",\"~:font-weight\",\"500\",\"~:modified-at\",\"2024-06-04T14:15:09.786Z\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"underline\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-color-ref-file\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"~:fill-opacity\",1,\"~:fill-color-ref-id\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:font-family\",\"Work Sans\",\"~:text\",\"Label\"]],\"^=\",\"uppercase\",\"^>\",\"center\",\"^?\",\"gfont-work-sans\",\"^@\",\"12\",\"^A\",\"500\",\"^8\",\"paragraph\",\"^B\",\"2024-06-04T14:15:09.786Z\",\"^C\",\"500\",\"^D\",\"underline\",\"^E\",\"0\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"^K\",\"Work Sans\"]]]],\"~:vertical-align\",\"center\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^I\",1]]],\"~:hide-in-viewer\",true,\"~:name\",\"Input\",\"~:saved-component-root\",null,\"~:width\",38,\"^8\",\"^L\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",625.9999165534973,\"~:y\",645.0000188344363]],[\"^S\",[\"^ \",\"~:x\",663.9999165534973,\"~:y\",645.0000188344363]],[\"^S\",[\"^ \",\"~:x\",663.9999165534973,\"~:y\",660.0000188344359]],[\"^S\",[\"^ \",\"~:x\",625.9999165534973,\"~:y\",660.0000188344363]]],\"~:layout-item-h-sizing\",\"~:fix\",\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2888\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2887\",\"~:position-data\",[[\"^ \",\"~:y\",659.3400268554688,\"^:\",\"1.2\",\"^<\",\"normal\",\"^=\",\"uppercase\",\"^>\",\"left\",\"^?\",\"sourcesanspro\",\"^@\",\"12\",\"^A\",\"500\",\"~:text-direction\",\"ltr\",\"^Q\",37.94000244140625,\"^C\",\"regular\",\"^D\",\"underline\",\"^E\",\"0\",\"~:x\",626.0299682617188,\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:direction\",\"ltr\",\"^K\",\"Work Sans\",\"~:height\",14.08001708984375,\"^L\",\"Label\"]],\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2887\",\"~:strokes\",[],\"~:x\",625.9999165534973,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",625.9999165534973,\"~:y\",645.0000188344363,\"^Q\",38,\"^11\",15,\"~:x1\",625.9999165534973,\"~:y1\",645.0000188344363,\"~:x2\",663.9999165534973,\"~:y2\",660.0000188344363]],\"^F\",[],\"~:flip-x\",null,\"^11\",15,\"~:flip-y\",null]]",
"~u77c71dba-32ee-804c-8007-736561cff45a": "[\"~#shape\",[\"^ \",\"~:y\",429.00000357564727,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:index\",null,\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^8\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:path\",\"\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"\",\"^<\",\"normal\",\"~:text-transform\",\"uppercase\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-work-sans\",\"~:font-size\",\"12\",\"~:font-weight\",\"500\",\"~:modified-at\",\"2024-06-04T14:15:09.786Z\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"underline\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-color-ref-file\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"~:fill-opacity\",1,\"~:fill-color-ref-id\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:font-family\",\"Work Sans\",\"~:text\",\"Label\"]],\"^=\",\"uppercase\",\"^>\",\"center\",\"^?\",\"gfont-work-sans\",\"^@\",\"12\",\"^A\",\"500\",\"^8\",\"paragraph\",\"^B\",\"2024-06-04T14:15:09.786Z\",\"^C\",\"500\",\"^D\",\"underline\",\"^E\",\"0\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"^K\",\"Work Sans\"]]]],\"~:vertical-align\",\"center\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^I\",1]]],\"~:hide-in-viewer\",true,\"~:name\",\"Input\",\"~:saved-component-root\",null,\"~:width\",38,\"^8\",\"^L\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",429.00000357564727]],[\"^S\",[\"^ \",\"~:x\",747.9999775886536,\"~:y\",429.00000357564727]],[\"^S\",[\"^ \",\"~:x\",747.9999775886536,\"~:y\",444.0000035756468]],[\"^S\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",444.00000357564727]]],\"~:layout-item-h-sizing\",\"~:fix\",\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cff45a\",\"~:parent-id\",\"~u77c71dba-32ee-804c-8007-736561cff459\",\"~:position-data\",[[\"^ \",\"~:y\",443.3399963378906,\"^:\",\"1.2\",\"^<\",\"normal\",\"^=\",\"uppercase\",\"^>\",\"left\",\"^?\",\"sourcesanspro\",\"^@\",\"12\",\"^A\",\"500\",\"~:text-direction\",\"ltr\",\"^Q\",37.93994140625,\"^C\",\"regular\",\"^D\",\"underline\",\"^E\",\"0\",\"~:x\",710.030029296875,\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:direction\",\"ltr\",\"^K\",\"Work Sans\",\"~:height\",14.079986572265625,\"^L\",\"Label\"]],\"~:frame-id\",\"~u77c71dba-32ee-804c-8007-736561cff459\",\"~:strokes\",[],\"~:x\",709.9999775886536,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",429.00000357564727,\"^Q\",38,\"^11\",15,\"~:x1\",709.9999775886536,\"~:y1\",429.00000357564727,\"~:x2\",747.9999775886536,\"~:y2\",444.00000357564727]],\"^F\",[],\"~:flip-x\",null,\"^11\",15,\"~:flip-y\",null]]",
"~u77c71dba-32ee-804c-8007-736561cff459": "[\"~#shape\",[\"^ \",\"~:y\",428.00000357564704,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",2,\"~:p3\",0,\"~:p4\",2],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"_Utilities / Text / White\",\"~:layout-align-items\",\"~:start\",\"~:width\",42,\"~:layout-padding-type\",\"~:simple\",\"~:transforming\",false,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",428.00000357564704]],[\"^K\",[\"^ \",\"~:x\",749.9999775886536,\"~:y\",428.00000357564704]],[\"^K\",[\"^ \",\"~:x\",749.9999775886536,\"~:y\",444.00000357564704]],[\"^K\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",444.00000357564704]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",0,\"~:column-gap\",6],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cff459\",\"~:parent-id\",\"~u77c71dba-32ee-804c-8007-736561cff458\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u77c71dba-32ee-804c-8007-736561cff458\",\"~:strokes\",[],\"~:x\",707.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",428.00000357564704,\"^D\",42,\"~:height\",16,\"~:x1\",707.9999775886536,\"~:y1\",428.00000357564704,\"~:x2\",749.9999775886536,\"~:y2\",444.00000357564704]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",16,\"~:flip-y\",null,\"~:shapes\",[\"~u77c71dba-32ee-804c-8007-736561cff45a\"]]]",
"~u77c71dba-32ee-804c-8007-736561cff458": "[\"~#shape\",[\"^ \",\"~:y\",420.00000357564704,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:rx\",8,\"~:layout-padding\",[\"^ \",\"~:p1\",8,\"~:p2\",12,\"~:p3\",8,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Dark / Button / Primary / Text / Default\",\"~:layout-align-items\",\"~:center\",\"~:width\",66,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",420.00000357564704]],[\"^K\",[\"^ \",\"~:x\",761.9999775886536,\"~:y\",420.00000357564704]],[\"^K\",[\"^ \",\"~:x\",761.9999775886536,\"~:y\",452.00000357564704]],[\"^K\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",452.00000357564704]]],\"~:r2\",8,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^;\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:layout-justify-content\",\"^D\",\"~:r1\",8,\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cff458\",\"~:parent-id\",\"~u77c71dba-32ee-804c-8007-736561cf8584\",\"~:layout-flex-dir\",\"~:row\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u77c71dba-32ee-804c-8007-736561cf8584\",\"~:strokes\",[],\"~:x\",695.9999775886536,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",420.00000357564704,\"^E\",66,\"~:height\",32,\"~:x1\",695.9999775886536,\"~:y1\",420.00000357564704,\"~:x2\",761.9999775886536,\"~:y2\",452.00000357564704]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#7efff5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^17\",32,\"~:flip-y\",null,\"~:shapes\",[\"~u77c71dba-32ee-804c-8007-736561cff459\"]]]",
"~u77c71dba-32ee-804c-8007-736561cf857f": "[\"~#shape\",[\"^ \",\"~:y\",395.99997913999186,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",12,\"~:p3\",0,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:wrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Board Parent 1\",\"~:layout-align-items\",\"~:start\",\"~:width\",272,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",593.0000386238098,\"~:y\",395.99997913999186]],[\"^J\",[\"^ \",\"~:x\",865.0000386238098,\"~:y\",395.99997913999186]],[\"^J\",[\"^ \",\"~:x\",865.0000386238098,\"~:y\",475.9999669761459]],[\"^J\",[\"^ \",\"~:x\",593.0000386238098,\"~:y\",475.9999669761459]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-item-v-sizing\",\"~:fix\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cf857f\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-flex-dir\",\"~:row\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",593.0000386238098,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",593.0000386238098,\"~:y\",395.99997913999186,\"^D\",272,\"~:height\",79.99998783615405,\"~:x1\",593.0000386238098,\"~:y1\",395.99997913999186,\"~:x2\",865.0000386238098,\"~:y2\",475.9999669761459]],\"~:fills\",[],\"~:flip-x\",null,\"^15\",79.99998783615405,\"~:flip-y\",null,\"~:shapes\",[\"~u77c71dba-32ee-804c-8007-736561cf8584\"]]]",
"~u94eaebe4-addd-80d1-8007-79d50980078e": "[\"~#shape\",[\"^ \",\"~:y\",720.0000478045426,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",12,\"~:p3\",0,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:wrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Board Parent 4\",\"~:layout-align-items\",\"~:start\",\"~:width\",272,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",592.9998555183411,\"~:y\",720.0000478045426]],[\"^J\",[\"^ \",\"~:x\",864.9998555183411,\"~:y\",720.0000478045426]],[\"^J\",[\"^ \",\"~:x\",864.9998555183411,\"~:y\",800.0000356406968]],[\"^J\",[\"^ \",\"~:x\",592.9998555183411,\"~:y\",800.0000356406968]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-item-v-sizing\",\"~:fix\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d50980078e\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-flex-dir\",\"~:column-reverse\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",592.9998555183411,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",592.9998555183411,\"~:y\",720.0000478045426,\"^D\",272,\"~:height\",79.9999878361541,\"~:x1\",592.9998555183411,\"~:y1\",720.0000478045426,\"~:x2\",864.9998555183411,\"~:y2\",800.0000356406968]],\"~:fills\",[],\"~:flip-x\",null,\"^15\",79.9999878361541,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d50980078f\"]]]",
"~u94eaebe4-addd-80d1-8007-79d50980078f": "[\"~#shape\",[\"^ \",\"~:y\",719.9999806874634,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"Board Child\",\"~:width\",80,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",719.9999806874634]],[\"^;\",[\"^ \",\"~:x\",684.9999775886536,\"~:y\",719.9999806874634]],[\"^;\",[\"^ \",\"~:x\",684.9999775886536,\"~:y\",799.9999806874634]],[\"^;\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",799.9999806874634]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d50980078f\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078e\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078e\",\"~:strokes\",[],\"~:x\",604.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",719.9999806874634,\"^7\",80,\"~:height\",80,\"~:x1\",604.9999775886536,\"~:y1\",719.9999806874634,\"~:x2\",684.9999775886536,\"~:y2\",799.9999806874634]],\"~:fills\",[],\"~:flip-x\",null,\"^K\",80,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d509800790\",\"~u94eaebe4-addd-80d1-8007-79d509800791\"]]]",
"~u94eaebe4-addd-80d1-8007-79d508a9dc2f": "[\"~#shape\",[\"^ \",\"~:y\",612.000024916359,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",12,\"~:p3\",0,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:wrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Board Parent 3\",\"~:layout-align-items\",\"~:start\",\"~:width\",272,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",592.9999165534973,\"~:y\",612.000024916359]],[\"^J\",[\"^ \",\"~:x\",864.9999165534973,\"~:y\",612.000024916359]],[\"^J\",[\"^ \",\"~:x\",864.9999165534973,\"~:y\",692.0000127525132]],[\"^J\",[\"^ \",\"~:x\",592.9999165534973,\"~:y\",692.0000127525132]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-item-v-sizing\",\"~:fix\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc2f\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",592.9999165534973,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",592.9999165534973,\"~:y\",612.000024916359,\"^D\",272,\"~:height\",79.9999878361541,\"~:x1\",592.9999165534973,\"~:y1\",612.000024916359,\"~:x2\",864.9999165534973,\"~:y2\",692.0000127525132]],\"~:fills\",[],\"~:flip-x\",null,\"^15\",79.9999878361541,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\"]]]",
"~u94eaebe4-addd-80d1-8007-79d509800790": "[\"~#shape\",[\"^ \",\"~:y\",720.0000417226197,\"~:rx\",8,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",80,\"~:transforming\",false,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",604.9998555183411,\"~:y\",720.0000417226197]],[\"^>\",[\"^ \",\"~:x\",684.9998555183411,\"~:y\",720.0000417226197]],[\"^>\",[\"^ \",\"~:x\",684.9998555183411,\"~:y\",800.0000417226197]],[\"^>\",[\"^ \",\"~:x\",604.9998555183411,\"~:y\",800.0000417226197]]],\"~:r2\",8,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d509800790\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078f\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078f\",\"~:strokes\",[],\"~:x\",604.9998555183411,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",604.9998555183411,\"~:y\",720.0000417226197,\"^9\",80,\"~:height\",80,\"~:x1\",604.9998555183411,\"~:y1\",720.0000417226197,\"~:x2\",684.9998555183411,\"~:y2\",800.0000417226197]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e8e9ea\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^M\",80,\"~:flip-y\",null]]",
"~u94eaebe4-addd-80d1-8007-79d508a9dc30": "[\"~#shape\",[\"^ \",\"~:y\",612.0000188344361,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"Board Child\",\"~:width\",80,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",612.0000188344361]],[\"^;\",[\"^ \",\"~:x\",684.9999775886536,\"~:y\",612.0000188344361]],[\"^;\",[\"^ \",\"~:x\",684.9999775886536,\"~:y\",692.0000188344361]],[\"^;\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",692.0000188344361]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc2f\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc2f\",\"~:strokes\",[],\"~:x\",604.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",612.0000188344361,\"^7\",80,\"~:height\",80,\"~:x1\",604.9999775886536,\"~:y1\",612.0000188344361,\"~:x2\",684.9999775886536,\"~:y2\",692.0000188344361]],\"~:fills\",[],\"~:flip-x\",null,\"^K\",80,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d508aa2885\",\"~u94eaebe4-addd-80d1-8007-79d508aa2886\"]]]",
"~u94eaebe4-addd-80d1-8007-79d509800791": "[\"~#shape\",[\"^ \",\"~:y\",744.0000417226197,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:rx\",8,\"~:layout-padding\",[\"^ \",\"~:p1\",8,\"~:p2\",12,\"~:p3\",8,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Dark / Button / Primary / Text / Default\",\"~:layout-align-items\",\"~:center\",\"~:width\",66,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",611.9998555183411,\"~:y\",744.0000417226197]],[\"^K\",[\"^ \",\"~:x\",677.9998555183411,\"~:y\",744.0000417226197]],[\"^K\",[\"^ \",\"~:x\",677.9998555183411,\"~:y\",776.0000417226197]],[\"^K\",[\"^ \",\"~:x\",611.9998555183411,\"~:y\",776.0000417226197]]],\"~:r2\",8,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^;\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:layout-justify-content\",\"^D\",\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d509800791\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078f\",\"~:layout-flex-dir\",\"~:row\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078f\",\"~:strokes\",[],\"~:x\",611.9998555183411,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",611.9998555183411,\"~:y\",744.0000417226197,\"^E\",66,\"~:height\",32,\"~:x1\",611.9998555183411,\"~:y1\",744.0000417226197,\"~:x2\",677.9998555183411,\"~:y2\",776.0000417226197]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#7efff5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^17\",32,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d509800792\"]]]",
"~u94eaebe4-addd-80d1-8007-79d509800792": "[\"~#shape\",[\"^ \",\"~:y\",752.0000417226197,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",2,\"~:p3\",0,\"~:p4\",2],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"_Utilities / Text / White\",\"~:layout-align-items\",\"~:start\",\"~:width\",42,\"~:layout-padding-type\",\"~:simple\",\"~:transforming\",false,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",623.9998555183411,\"~:y\",752.0000417226197]],[\"^K\",[\"^ \",\"~:x\",665.9998555183411,\"~:y\",752.0000417226197]],[\"^K\",[\"^ \",\"~:x\",665.9998555183411,\"~:y\",768.0000417226197]],[\"^K\",[\"^ \",\"~:x\",623.9998555183411,\"~:y\",768.0000417226197]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",0,\"~:column-gap\",6],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d509800792\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d509800791\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d509800791\",\"~:strokes\",[],\"~:x\",623.9998555183411,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",623.9998555183411,\"~:y\",752.0000417226197,\"^D\",42,\"~:height\",16,\"~:x1\",623.9998555183411,\"~:y1\",752.0000417226197,\"~:x2\",665.9998555183411,\"~:y2\",768.0000417226197]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",16,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d509800793\"]]]",
"~u94eaebe4-addd-80d1-8007-79d509800793": "[\"~#shape\",[\"^ \",\"~:y\",753.0000417226199,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:index\",null,\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^8\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:path\",\"\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"\",\"^<\",\"normal\",\"~:text-transform\",\"uppercase\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-work-sans\",\"~:font-size\",\"12\",\"~:font-weight\",\"500\",\"~:modified-at\",\"2024-06-04T14:15:09.786Z\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"underline\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-color-ref-file\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"~:fill-opacity\",1,\"~:fill-color-ref-id\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:font-family\",\"Work Sans\",\"~:text\",\"Label\"]],\"^=\",\"uppercase\",\"^>\",\"center\",\"^?\",\"gfont-work-sans\",\"^@\",\"12\",\"^A\",\"500\",\"^8\",\"paragraph\",\"^B\",\"2024-06-04T14:15:09.786Z\",\"^C\",\"500\",\"^D\",\"underline\",\"^E\",\"0\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"^K\",\"Work Sans\"]]]],\"~:vertical-align\",\"center\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^I\",1]]],\"~:hide-in-viewer\",true,\"~:name\",\"Input\",\"~:saved-component-root\",null,\"~:width\",38,\"^8\",\"^L\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",625.9998555183411,\"~:y\",753.0000417226199]],[\"^S\",[\"^ \",\"~:x\",663.9998555183411,\"~:y\",753.0000417226199]],[\"^S\",[\"^ \",\"~:x\",663.9998555183411,\"~:y\",768.0000417226195]],[\"^S\",[\"^ \",\"~:x\",625.9998555183411,\"~:y\",768.0000417226199]]],\"~:layout-item-h-sizing\",\"~:fix\",\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d509800793\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d509800792\",\"~:position-data\",[[\"^ \",\"~:y\",767.340087890625,\"^:\",\"1.2\",\"^<\",\"normal\",\"^=\",\"uppercase\",\"^>\",\"left\",\"^?\",\"sourcesanspro\",\"^@\",\"12\",\"^A\",\"500\",\"~:text-direction\",\"ltr\",\"^Q\",37.93994140625,\"^C\",\"regular\",\"^D\",\"underline\",\"^E\",\"0\",\"~:x\",626.0299072265625,\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:direction\",\"ltr\",\"^K\",\"Work Sans\",\"~:height\",14.08001708984375,\"^L\",\"Label\"]],\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d509800792\",\"~:strokes\",[],\"~:x\",625.9998555183411,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",625.9998555183411,\"~:y\",753.0000417226199,\"^Q\",38,\"^11\",15,\"~:x1\",625.9998555183411,\"~:y1\",753.0000417226199,\"~:x2\",663.9998555183411,\"~:y2\",768.0000417226199]],\"^F\",[],\"~:flip-x\",null,\"^11\",15,\"~:flip-y\",null]]",
"~u77c71dba-32ee-804c-8007-736561cf8584": "[\"~#shape\",[\"^ \",\"~:y\",396.00000357564704,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"Board Child\",\"~:width\",80,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",396.00000357564704]],[\"^;\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",396.00000357564704]],[\"^;\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",476.00000357564704]],[\"^;\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",476.00000357564704]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cf8584\",\"~:parent-id\",\"~u77c71dba-32ee-804c-8007-736561cf857f\",\"~:frame-id\",\"~u77c71dba-32ee-804c-8007-736561cf857f\",\"~:strokes\",[],\"~:x\",688.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",396.00000357564704,\"^7\",80,\"~:height\",80,\"~:x1\",688.9999775886536,\"~:y1\",396.00000357564704,\"~:x2\",768.9999775886536,\"~:y2\",476.00000357564704]],\"~:fills\",[],\"~:flip-x\",null,\"^K\",80,\"~:flip-y\",null,\"~:shapes\",[\"~u77c71dba-32ee-804c-8007-736561cff457\",\"~u77c71dba-32ee-804c-8007-736561cff458\"]]]",
"~u94eaebe4-addd-80d1-8007-79d5055d6859": "[\"~#shape\",[\"^ \",\"~:y\",504.00000202817546,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",12,\"~:p3\",0,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:wrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Board Parent 2\",\"~:layout-align-items\",\"~:start\",\"~:width\",272,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",592.9999775886536,\"~:y\",504.00000202817546]],[\"^J\",[\"^ \",\"~:x\",864.9999775886536,\"~:y\",504.00000202817546]],[\"^J\",[\"^ \",\"~:x\",864.9999775886536,\"~:y\",583.9999898643296]],[\"^J\",[\"^ \",\"~:x\",592.9999775886536,\"~:y\",583.9999898643296]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-item-v-sizing\",\"~:fix\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d6859\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-flex-dir\",\"~:row-reverse\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",592.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",592.9999775886536,\"~:y\",504.00000202817546,\"^D\",272,\"~:height\",79.9999878361541,\"~:x1\",592.9999775886536,\"~:y1\",504.00000202817546,\"~:x2\",864.9999775886536,\"~:y2\",583.9999898643296]],\"~:fills\",[],\"~:flip-x\",null,\"^15\",79.9999878361541,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d5055d685a\"]]]",
"~u94eaebe4-addd-80d1-8007-79d5055d685a": "[\"~#shape\",[\"^ \",\"~:y\",503.9999959462525,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"Board Child\",\"~:width\",80,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",503.9999959462525]],[\"^;\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",503.9999959462525]],[\"^;\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",583.9999959462525]],[\"^;\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",583.9999959462525]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685a\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d6859\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d6859\",\"~:strokes\",[],\"~:x\",688.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",503.9999959462525,\"^7\",80,\"~:height\",80,\"~:x1\",688.9999775886536,\"~:y1\",503.9999959462525,\"~:x2\",768.9999775886536,\"~:y2\",583.9999959462525]],\"~:fills\",[],\"~:flip-x\",null,\"^K\",80,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d5055d685b\",\"~u94eaebe4-addd-80d1-8007-79d5055d685c\"]]]",
"~u94eaebe4-addd-80d1-8007-79d5055d685b": "[\"~#shape\",[\"^ \",\"~:y\",503.9999959462525,\"~:rx\",8,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",80,\"~:transforming\",false,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",503.9999959462525]],[\"^>\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",503.9999959462525]],[\"^>\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",583.9999959462525]],[\"^>\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",583.9999959462525]]],\"~:r2\",8,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685b\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685a\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685a\",\"~:strokes\",[],\"~:x\",688.9999775886536,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",503.9999959462525,\"^9\",80,\"~:height\",80,\"~:x1\",688.9999775886536,\"~:y1\",503.9999959462525,\"~:x2\",768.9999775886536,\"~:y2\",583.9999959462525]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e8e9ea\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^M\",80,\"~:flip-y\",null]]",
"~u94eaebe4-addd-80d1-8007-79d5055d685c": "[\"~#shape\",[\"^ \",\"~:y\",527.9999959462525,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:rx\",8,\"~:layout-padding\",[\"^ \",\"~:p1\",8,\"~:p2\",12,\"~:p3\",8,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Dark / Button / Primary / Text / Default\",\"~:layout-align-items\",\"~:center\",\"~:width\",66,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",527.9999959462525]],[\"^K\",[\"^ \",\"~:x\",761.9999775886536,\"~:y\",527.9999959462525]],[\"^K\",[\"^ \",\"~:x\",761.9999775886536,\"~:y\",559.9999959462525]],[\"^K\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",559.9999959462525]]],\"~:r2\",8,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^;\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:layout-justify-content\",\"^D\",\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685c\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685a\",\"~:layout-flex-dir\",\"~:row\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685a\",\"~:strokes\",[],\"~:x\",695.9999775886536,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",527.9999959462525,\"^E\",66,\"~:height\",32,\"~:x1\",695.9999775886536,\"~:y1\",527.9999959462525,\"~:x2\",761.9999775886536,\"~:y2\",559.9999959462525]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#7efff5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^17\",32,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d5055d685d\"]]]",
"~u94eaebe4-addd-80d1-8007-79d5055d685d": "[\"~#shape\",[\"^ \",\"~:y\",535.9999959462525,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",2,\"~:p3\",0,\"~:p4\",2],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"_Utilities / Text / White\",\"~:layout-align-items\",\"~:start\",\"~:width\",42,\"~:layout-padding-type\",\"~:simple\",\"~:transforming\",false,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",535.9999959462525]],[\"^K\",[\"^ \",\"~:x\",749.9999775886536,\"~:y\",535.9999959462525]],[\"^K\",[\"^ \",\"~:x\",749.9999775886536,\"~:y\",551.9999959462525]],[\"^K\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",551.9999959462525]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",0,\"~:column-gap\",6],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685d\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685c\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685c\",\"~:strokes\",[],\"~:x\",707.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",535.9999959462525,\"^D\",42,\"~:height\",16,\"~:x1\",707.9999775886536,\"~:y1\",535.9999959462525,\"~:x2\",749.9999775886536,\"~:y2\",551.9999959462525]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",16,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d5055d685e\"]]]",
"~u94eaebe4-addd-80d1-8007-79d5055d685e": "[\"~#shape\",[\"^ \",\"~:y\",536.9999959462527,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:index\",null,\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^8\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:path\",\"\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"\",\"^<\",\"normal\",\"~:text-transform\",\"uppercase\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-work-sans\",\"~:font-size\",\"12\",\"~:font-weight\",\"500\",\"~:modified-at\",\"2024-06-04T14:15:09.786Z\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"underline\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-color-ref-file\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"~:fill-opacity\",1,\"~:fill-color-ref-id\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:font-family\",\"Work Sans\",\"~:text\",\"Label\"]],\"^=\",\"uppercase\",\"^>\",\"center\",\"^?\",\"gfont-work-sans\",\"^@\",\"12\",\"^A\",\"500\",\"^8\",\"paragraph\",\"^B\",\"2024-06-04T14:15:09.786Z\",\"^C\",\"500\",\"^D\",\"underline\",\"^E\",\"0\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"^K\",\"Work Sans\"]]]],\"~:vertical-align\",\"center\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^I\",1]]],\"~:hide-in-viewer\",true,\"~:name\",\"Input\",\"~:saved-component-root\",null,\"~:width\",38,\"^8\",\"^L\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",536.9999959462527]],[\"^S\",[\"^ \",\"~:x\",747.9999775886536,\"~:y\",536.9999959462527]],[\"^S\",[\"^ \",\"~:x\",747.9999775886536,\"~:y\",551.9999959462523]],[\"^S\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",551.9999959462527]]],\"~:layout-item-h-sizing\",\"~:fix\",\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685e\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685d\",\"~:position-data\",[[\"^ \",\"~:y\",551.3400268554688,\"^:\",\"1.2\",\"^<\",\"normal\",\"^=\",\"uppercase\",\"^>\",\"left\",\"^?\",\"sourcesanspro\",\"^@\",\"12\",\"^A\",\"500\",\"~:text-direction\",\"ltr\",\"^Q\",37.93994140625,\"^C\",\"regular\",\"^D\",\"underline\",\"^E\",\"0\",\"~:x\",710.030029296875,\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:direction\",\"ltr\",\"^K\",\"Work Sans\",\"~:height\",14.08001708984375,\"^L\",\"Label\"]],\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685d\",\"~:strokes\",[],\"~:x\",709.9999775886536,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",536.9999959462527,\"^Q\",38,\"^11\",15,\"~:x1\",709.9999775886536,\"~:y1\",536.9999959462527,\"~:x2\",747.9999775886536,\"~:y2\",551.9999959462527]],\"^F\",[],\"~:flip-x\",null,\"^11\",15,\"~:flip-y\",null]]"
}
}
}
},
"~:id": "~u31fe2e21-73e7-80f3-8007-73894fb58240",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@@ -58,10 +58,10 @@ export class WorkspacePage extends BaseWebSocketPage {
async waitForTextSpan(nth = 0) {
if (!nth) {
return this.page.waitForSelector('[data-itype="inline"]');
return this.page.waitForSelector('[data-itype="span"]');
}
return this.page.waitForSelector(
`[data-itype="inline"]:nth-child(${nth})`,
`[data-itype="span"]:nth-child(${nth})`,
);
}

View File

@@ -210,6 +210,22 @@ test("Renders a file with shadows applied to any kind of shape", async ({
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with flex layouts and different directions", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-flex-layouts.json");
await workspace.goToWorkspace({
id: "31fe2e21-73e7-80f3-8007-73894fb58240",
pageId: "02e9633d-4ce7-80da-8007-736558496fa8",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with a closed path shape with multiple segments using strokes and shadow", async ({
page,
}) => {

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

226
frontend/pnpm-lock.yaml generated
View File

@@ -99,7 +99,7 @@ importers:
version: 1.15.4
jsdom:
specifier: ^27.4.0
version: 27.4.0
version: 27.4.0(canvas@3.2.1)
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -210,7 +210,7 @@ importers:
version: 7.3.1(@types/node@25.0.3)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2)
vitest:
specifier: ^4.0.18
version: 4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2)
version: 4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2)
wait-on:
specifier: ^9.0.3
version: 9.0.3
@@ -265,12 +265,15 @@ importers:
'@vitest/ui':
specifier: ^1.6.0
version: 1.6.1(vitest@1.6.1)
canvas:
specifier: ^3.2.1
version: 3.2.1
esbuild:
specifier: ^0.27.2
version: 0.27.2
jsdom:
specifier: ^27.4.0
version: 27.4.0
version: 27.4.0(canvas@3.2.1)
playwright:
specifier: ^1.45.1
version: 1.57.0
@@ -282,7 +285,7 @@ importers:
version: 5.4.21(@types/node@25.0.3)(sass-embedded@1.97.1)(sass@1.97.1)
vitest:
specifier: ^1.6.0
version: 1.6.1(@types/node@25.0.3)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0)(sass-embedded@1.97.1)(sass@1.97.1)
version: 1.6.1(@types/node@25.0.3)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.1)(sass@1.97.1)
packages:
@@ -1751,6 +1754,9 @@ packages:
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
body-parser@2.2.1:
resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==}
engines: {node: '>=18'}
@@ -1779,6 +1785,9 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
@@ -1809,6 +1818,10 @@ packages:
caniuse-lite@1.0.30001762:
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
canvas@3.2.1:
resolution: {integrity: sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==}
engines: {node: ^18.12.0 || >= 20.9.0}
chai@4.5.0:
resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==}
engines: {node: '>=4'}
@@ -1851,6 +1864,9 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
chownr@2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
@@ -2092,6 +2108,10 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
deep-eql@4.1.4:
resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==}
engines: {node: '>=6'}
@@ -2100,6 +2120,10 @@ packages:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -2144,6 +2168,10 @@ packages:
engines: {node: '>=0.10'}
hasBin: true
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dettle@1.0.5:
resolution: {integrity: sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==}
@@ -2232,6 +2260,9 @@ packages:
encoding@0.1.13:
resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
entities@2.2.0:
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
@@ -2329,6 +2360,10 @@ packages:
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
engines: {node: '>=16.17'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -2421,6 +2456,9 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@@ -2493,6 +2531,9 @@ packages:
resolution: {integrity: sha512-eFmhDi2xQ+2reMRY2AbJ2oa10uFOl1oyGbAKdCZiNOk94NJHi7aN0OBELSC9v35ZAPQdr+uRBi93/Gu4SlBdrA==}
engines: {node: '>=18'}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -3042,6 +3083,10 @@ packages:
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
engines: {node: '>=12'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
@@ -3080,6 +3125,9 @@ packages:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
@@ -3112,6 +3160,9 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
negotiator@0.6.4:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
@@ -3123,6 +3174,10 @@ packages:
nice-try@1.0.5:
resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==}
node-abi@3.87.0:
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
@@ -3415,6 +3470,11 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
hasBin: true
prettier@3.5.3:
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
engines: {node: '>=14'}
@@ -3472,6 +3532,9 @@ packages:
pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
punycode@1.4.1:
resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==}
@@ -3497,6 +3560,10 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-docgen-typescript@2.4.0:
resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==}
peerDependencies:
@@ -3884,6 +3951,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-update-notifier@2.0.0:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'}
@@ -4016,6 +4089,10 @@ packages:
resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==}
engines: {node: '>=12'}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
strip-literal@2.1.1:
resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==}
@@ -4069,6 +4146,13 @@ packages:
resolution: {integrity: sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==}
engines: {node: '>=16.0.0'}
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
@@ -4195,6 +4279,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
type-detect@4.1.0:
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
engines: {node: '>=4'}
@@ -5411,7 +5498,7 @@ snapshots:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.3)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/browser-playwright': 4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.3)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/runner': 4.0.18
vitest: 4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2)
vitest: 4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2)
transitivePeerDependencies:
- react
- react-dom
@@ -5583,7 +5670,7 @@ snapshots:
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.3)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2))
playwright: 1.58.0
tinyrainbow: 3.0.3
vitest: 4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2)
vitest: 4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2)
transitivePeerDependencies:
- bufferutil
- msw
@@ -5595,7 +5682,7 @@ snapshots:
'@vitest/utils': 1.6.1
magic-string: 0.30.21
sirv: 2.0.4
vitest: 1.6.1(@types/node@25.0.3)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0)(sass-embedded@1.97.1)(sass@1.97.1)
vitest: 1.6.1(@types/node@25.0.3)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.1)(sass@1.97.1)
optionalDependencies:
playwright: 1.57.0
@@ -5608,7 +5695,7 @@ snapshots:
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.0.3
vitest: 4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2)
vitest: 4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2)
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
@@ -5631,7 +5718,7 @@ snapshots:
std-env: 3.10.0
strip-literal: 2.1.1
test-exclude: 6.0.0
vitest: 1.6.1(@types/node@25.0.3)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0)(sass-embedded@1.97.1)(sass@1.97.1)
vitest: 1.6.1(@types/node@25.0.3)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.1)(sass@1.97.1)
transitivePeerDependencies:
- supports-color
@@ -5647,7 +5734,7 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2)
vitest: 4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2)
optionalDependencies:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.3)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2))(vitest@4.0.18)
@@ -5740,7 +5827,7 @@ snapshots:
pathe: 1.1.2
picocolors: 1.1.1
sirv: 2.0.4
vitest: 1.6.1(@types/node@25.0.3)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0)(sass-embedded@1.97.1)(sass@1.97.1)
vitest: 1.6.1(@types/node@25.0.3)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.1)(sass@1.97.1)
'@vitest/utils@1.6.1':
dependencies:
@@ -5908,6 +5995,12 @@ snapshots:
bintrees@1.0.2: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
body-parser@2.2.1:
dependencies:
bytes: 3.1.2
@@ -5949,6 +6042,11 @@ snapshots:
buffer-from@1.1.2: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
@@ -5981,6 +6079,11 @@ snapshots:
caniuse-lite@1.0.30001762: {}
canvas@3.2.1:
dependencies:
node-addon-api: 7.1.1
prebuild-install: 7.1.3
chai@4.5.0:
dependencies:
assertion-error: 1.1.0
@@ -6038,6 +6141,8 @@ snapshots:
dependencies:
readdirp: 4.1.2
chownr@1.1.4: {}
chownr@2.0.0: {}
ci-info@3.9.0: {}
@@ -6273,12 +6378,18 @@ snapshots:
decimal.js@10.6.0: {}
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
deep-eql@4.1.4:
dependencies:
type-detect: 4.1.0
deep-eql@5.0.2: {}
deep-extend@0.6.0: {}
deepmerge@4.3.1: {}
default-browser-id@5.0.1: {}
@@ -6313,6 +6424,8 @@ snapshots:
detect-libc@1.0.3:
optional: true
detect-libc@2.1.2: {}
dettle@1.0.5: {}
diff-sequences@29.6.3: {}
@@ -6407,6 +6520,10 @@ snapshots:
dependencies:
iconv-lite: 0.6.3
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
entities@2.2.0: {}
entities@4.5.0: {}
@@ -6588,6 +6705,8 @@ snapshots:
signal-exit: 4.1.0
strip-final-newline: 3.0.0
expand-template@2.0.3: {}
expect-type@1.3.0: {}
expr-eval-fork@2.0.2: {}
@@ -6711,6 +6830,8 @@ snapshots:
fresh@2.0.0: {}
fs-constants@1.0.0: {}
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -6789,6 +6910,8 @@ snapshots:
readable-stream: 4.7.0
safe-buffer: 5.2.1
github-from-package@0.0.0: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -7163,7 +7286,7 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@27.4.0:
jsdom@27.4.0(canvas@3.2.1):
dependencies:
'@acemir/cssom': 0.9.30
'@asamuzakjp/dom-selector': 6.7.6
@@ -7185,6 +7308,8 @@ snapshots:
whatwg-url: 15.1.0
ws: 8.18.3
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 3.2.1
transitivePeerDependencies:
- '@exodus/crypto'
- bufferutil
@@ -7342,6 +7467,8 @@ snapshots:
mimic-fn@4.0.0: {}
mimic-response@3.1.0: {}
min-indent@1.0.1: {}
minimatch@10.1.1:
@@ -7375,6 +7502,8 @@ snapshots:
minipass: 3.3.6
yallist: 4.0.0
mkdirp-classic@0.5.3: {}
mkdirp@1.0.4: {}
mkdirp@3.0.1: {}
@@ -7396,14 +7525,19 @@ snapshots:
nanoid@3.3.11: {}
napi-build-utils@2.0.0: {}
negotiator@0.6.4: {}
negotiator@1.0.0: {}
nice-try@1.0.5: {}
node-addon-api@7.1.1:
optional: true
node-abi@3.87.0:
dependencies:
semver: 7.7.3
node-addon-api@7.1.1: {}
node-fetch@2.7.0(encoding@0.1.13):
dependencies:
@@ -7704,6 +7838,21 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.87.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
prettier@3.5.3: {}
prettier@3.7.4: {}
@@ -7755,6 +7904,11 @@ snapshots:
pstree.remy@1.1.8: {}
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
punycode@1.4.1: {}
punycode@2.3.1: {}
@@ -7776,6 +7930,13 @@ snapshots:
iconv-lite: 0.7.1
unpipe: 1.0.0
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
react-docgen-typescript@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -8249,6 +8410,14 @@ snapshots:
signal-exit@4.1.0: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.3
@@ -8404,6 +8573,8 @@ snapshots:
strip-indent@4.1.1: {}
strip-json-comments@2.0.1: {}
strip-literal@2.1.1:
dependencies:
js-tokens: 9.0.1
@@ -8487,6 +8658,21 @@ snapshots:
sync-message-port@1.1.3: {}
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.3
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
tar@6.2.1:
dependencies:
chownr: 2.0.0
@@ -8587,6 +8773,10 @@ snapshots:
tslib@2.8.1: {}
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
type-detect@4.1.0: {}
type-is@2.0.1:
@@ -8757,7 +8947,7 @@ snapshots:
sass-embedded: 1.97.1
yaml: 2.8.2
vitest@1.6.1(@types/node@25.0.3)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0)(sass-embedded@1.97.1)(sass@1.97.1):
vitest@1.6.1(@types/node@25.0.3)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.1)(sass@1.97.1):
dependencies:
'@vitest/expect': 1.6.1
'@vitest/runner': 1.6.1
@@ -8783,7 +8973,7 @@ snapshots:
'@types/node': 25.0.3
'@vitest/browser': 1.6.1(playwright@1.57.0)(vitest@1.6.1)
'@vitest/ui': 1.6.1(vitest@1.6.1)
jsdom: 27.4.0
jsdom: 27.4.0(canvas@3.2.1)
transitivePeerDependencies:
- less
- lightningcss
@@ -8794,7 +8984,7 @@ snapshots:
- supports-color
- terser
vitest@4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2):
vitest@4.0.18(@types/node@25.0.3)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.3)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2))
@@ -8819,7 +9009,7 @@ snapshots:
optionalDependencies:
'@types/node': 25.0.3
'@vitest/browser-playwright': 4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.3)(sass-embedded@1.97.1)(sass@1.97.1)(yaml@2.8.2))(vitest@4.0.18)
jsdom: 27.4.0
jsdom: 27.4.0(canvas@3.2.1)
transitivePeerDependencies:
- jiti
- less

View File

@@ -65,8 +65,11 @@
ptk/WatchEvent
(watch [_ _ stream]
(rx/merge
(rx/of (ev/initialize)
(dp/refresh-profile))
(if (contains? cf/flags :audit-log)
(rx/of (ev/initialize))
(rx/empty))
(rx/of (dp/refresh-profile))
;; Watch for profile deletion events
(->> stream

View File

@@ -39,7 +39,6 @@
;; Defines the time window (in ms) within events belong to the same session.
(def session-timeout (* 1000 60 30))
;; Min time for a long task to be reported to telemetry
(def min-longtask-time 1000)
@@ -61,11 +60,6 @@
;; Def micro-benchmark iterations
(def micro-benchmark-iterations 1e6)
;; Performance logs
(defonce ^:private longtask-observer* (atom nil))
(defonce ^:private stall-timer* (atom nil))
(defonce ^:private current-op* (atom nil))
;; --- CONTEXT
(defn- collect-context
@@ -188,10 +182,25 @@
profile-id (aget result 1)]
(performance-payload profile-id props)))
([profile-id props]
(let [{:keys [performance-info]} @st/state]
(let [context (get @st/state :performance-info)]
{:type "action"
:name "performance"
:context (merge @context performance-info)
:context context
:props props
:profile-id profile-id})))
(defn make-long-task-event
([result]
(let [props (aget result 0)
profile-id (aget result 1)]
(make-long-task-event profile-id props)))
([profile-id props]
(prn "make-long-task-event")
(let [context (get @st/state :performance-info)]
{:type "fact"
;; FIXME: add file-id
:name "performance-long-task"
:context {:performance context}
:props props
:profile-id profile-id})))
@@ -298,17 +307,16 @@
(when (and (= "longtask" (.-entryType entry))
(> (.-duration entry) min-longtask-time))
(rx/push! subs
{::event :observer-longtask
:duration (.-duration entry)})))
{:duration (.-duration entry)})))
(.getEntries list))))]
(.observe observer #js {:entryTypes #js ["longtask"]})
(fn []
(.disconnect observer)))))
(rx/empty)))
(defn- save-performance-info
[]
(ptk/reify ::save-performance-info
(defn- snapshot-performance-info
[{:keys [file-id]}]
(ptk/reify ::snapshot-performance-info
ptk/UpdateEvent
(update [_ state]
(letfn [(count-shapes [file]
@@ -317,227 +325,144 @@
(fn [sum _ page]
(+ sum (count (:objects page))))
0)))
(count-library-data [files {:keys [id]}]
(let [data (dm/get-in files [id :data])]
{:components (count (:components data))
:colors (count (:colors data))
:typographies (count (:typographies data))}))]
(let [file-id (get state :current-file-id)
file (get-in state [:files file-id])
file-size (count-shapes file)
libraries
(-> (refs/select-libraries (:files state) (:id file))
(d/update-vals (partial count-library-data (:files state))))
(add-libraries-counters [state files]
(reduce (fn [state library-id]
(let [data (dm/get-in files [file-id :data])]
(-> state
(update :total-components + (count (:components data)))
(update :total-colors + (count (:colors data)))
(update :total-typographies + (count (:typographies data))))))
state
(refs/select-libraries files file-id)))]
lib-sizes
(->> libraries
(reduce-kv
(fn [acc _ {:keys [components colors typographies]}]
(-> acc
(update :components + components)
(update :colors + colors)
(update :typographies + typographies)))
{}))]
(update state :performance-info
(fn [info]
(update state :performance-info
(fn [info]
(let [files (get state :files)
file (get files file-id)]
(-> info
(assoc :file-size file-size)
(assoc :library-sizes lib-sizes)
(assoc :file-start-time (perf/now))))))))))
(assoc :total-shapes (count-shapes file))
(add-libraries-counters files)))))))))
(defn store-performace-info
(defn- store-performace-info
[]
(letfn [(micro-benchmark [state]
(let [start (perf/now)]
(loop [i micro-benchmark-iterations]
(when-not (zero? i)
(* (math/sin i) (math/sqrt i))
(recur (dec i))))
(let [end (perf/now)]
(update state :performance-info assoc :bench-result (- end start)))))]
(ptk/reify ::store-performace-info
ptk/UpdateEvent
(update [_ state]
(let [start (perf/now)
_ (loop [i micro-benchmark-iterations]
(when-not (zero? i)
(* (math/sin i) (math/sqrt i))
(recur (dec i))))
end (perf/now)]
(ptk/reify ::store-performace-info
ptk/UpdateEvent
(update [_ state]
(-> state
micro-benchmark
(assoc-in [:performance-info :app-start-time] (perf/now))))
(update state :performance-info assoc :bench (- end start))))
ptk/WatchEvent
(watch [_ _ stream]
(->> stream
(rx/filter (ptk/type? :app.main.data.workspace/all-libraries-resolved))
(rx/take 1)
(rx/map save-performance-info))))))
(rx/map deref)
(rx/map snapshot-performance-info)))))
(defn initialize
[]
(when (contains? cf/flags :audit-log)
(ptk/reify ::initialize
ptk/WatchEvent
(watch [_ _ _]
(rx/of (store-performace-info)))
(prn "initialize")
(ptk/reify ::initialize
ptk/WatchEvent
(watch [_ _ _]
(rx/of (store-performace-info)))
ptk/EffectEvent
(effect [_ _ stream]
(let [session (atom nil)
stopper (rx/filter (ptk/type? ::initialize) stream)
buffer (atom #queue [])
profile (->> (rx/from-atom storage/user {:emit-current-value? true})
(rx/map :profile)
(rx/map :id)
(rx/pipe (rxo/distinct-contiguous)))]
ptk/EffectEvent
(effect [_ _ stream]
(let [session (atom nil)
stopper (rx/filter (ptk/type? ::initialize) stream)
buffer (atom #queue [])
profile (->> (rx/from-atom storage/user {:emit-current-value? true})
(rx/map :profile)
(rx/map :id)
(rx/pipe (rxo/distinct-contiguous)))]
(l/debug :hint "event instrumentation initialized")
(l/debug :hint "event instrumentation initialized")
(->> (rx/merge
(->> (rx/from-atom buffer)
(rx/filter #(pos? (count %)))
(rx/debounce 2000))
(->> stream
(rx/filter (ptk/type? :app.main.data.profile/logout))
(rx/observe-on :async)))
(rx/map (fn [_]
(into [] (take max-buffer-size) @buffer)))
(rx/with-latest-from profile)
(rx/mapcat (fn [[chunk profile-id]]
(let [events (filterv #(= profile-id (:profile-id %)) chunk)]
(->> (persist-events events)
(rx/tap (fn [_]
(l/debug :hint "events chunk persisted" :total (count chunk))))
(rx/map (constantly chunk))))))
(rx/take-until stopper)
(rx/subs! (fn [chunk]
(swap! buffer remove-from-buffer (count chunk)))
(fn [cause]
(l/error :hint "unexpected error on audit persistence" :cause cause))
(fn []
(l/debug :hint "audit persistence terminated"))))
(->> (rx/merge
(->> (rx/from-atom buffer)
(rx/filter #(pos? (count %)))
(rx/debounce 2000))
(->> stream
(rx/filter (ptk/type? :app.main.data.profile/logout))
(rx/observe-on :async)))
(rx/map (fn [_]
(into [] (take max-buffer-size) @buffer)))
(rx/with-latest-from profile)
(rx/mapcat (fn [[chunk profile-id]]
(let [events (filterv #(= profile-id (:profile-id %)) chunk)]
(->> (persist-events events)
(rx/tap (fn [_]
(l/debug :hint "events chunk persisted" :total (count chunk))))
(rx/map (constantly chunk))))))
(rx/take-until stopper)
(rx/subs! (fn [chunk]
(swap! buffer remove-from-buffer (count chunk)))
(fn [cause]
(l/error :hint "unexpected error on audit persistence" :cause cause))
(fn []
(l/debug :hint "audit persistence terminated"))))
(->> (rx/merge
(->> stream
(rx/with-latest-from profile)
(rx/map (fn [result]
(let [event (aget result 0)
profile-id (aget result 1)]
(some-> (process-event event)
(update :profile-id #(or % profile-id)))))))
(->> (rx/merge
(->> stream
(rx/with-latest-from profile)
(rx/map (fn [result]
(let [event (aget result 0)
profile-id (aget result 1)]
(some-> (process-event event)
(update :profile-id #(or % profile-id)))))))
(->> (performance-observer-event-stream)
(rx/with-latest-from profile)
(rx/map performance-payload)
(rx/debounce debounce-browser-event-time))
(->> (performance-observer-event-stream)
(rx/with-latest-from profile)
(rx/map performance-payload)
(rx/debounce debounce-browser-event-time))
(->> (performance-observer-longtask-stream)
(rx/with-latest-from profile)
(rx/map performance-payload)
(rx/debounce debounce-longtask-time))
(->> (performance-observer-longtask-stream)
(rx/with-latest-from profile)
(rx/map make-long-task-event)
(rx/debounce debounce-longtask-time))
(->> stream
(rx/with-latest-from profile)
(rx/merge-map process-performance-event)
(rx/debounce debounce-performance-event-time)))
;; (->> stream
;; (rx/with-latest-from profile)
;; (rx/merge-map process-performance-event)
;; (rx/debounce debounce-performance-event-time))
)
(rx/filter :profile-id)
(rx/map (fn [event]
(let [session* (or @session (ct/now))
context (-> @context
(merge (:context event))
(assoc :session session*)
(assoc :external-session-id (cf/external-session-id))
(d/without-nils))]
(reset! session session*)
(-> event
(assoc :timestamp (ct/now))
(assoc :context context)))))
(rx/filter :profile-id)
(rx/map (fn [event]
(prn "AAAA" event)
(let [session* (or @session (ct/now))
context (-> @context
(merge (:context event))
(assoc :session session*)
(assoc :external-session-id (cf/external-session-id))
(d/without-nils))]
(reset! session session*)
(-> event
(assoc :timestamp (ct/now))
(assoc :context context)))))
(rx/tap (fn [event]
(l/debug :hint "event enqueued")
(swap! buffer append-to-buffer event)))
(rx/tap (fn [event]
(l/debug :hint "event enqueued")
(swap! buffer append-to-buffer event)))
(rx/switch-map #(rx/timer session-timeout))
(rx/take-until stopper)
(rx/subs! (fn [_]
(l/debug :hint "session reinitialized")
(reset! session nil))
(fn [cause]
(l/error :hint "error on event batching stream" :cause cause))
(fn []
(l/debug :hitn "events batching stream terminated")))))))))
(rx/switch-map #(rx/timer session-timeout))
(rx/take-until stopper)
(rx/subs! (fn [_]
(l/debug :hint "session reinitialized")
(reset! session nil))
(fn [cause]
(l/error :hint "error on event batching stream" :cause cause))
(fn []
(l/debug :hitn "events batching stream terminated"))))))))
(defn event
[props]
(ptk/data-event ::event props))
;; --- DEVTOOLS PERF LOGGING
(defn install-long-task-observer! []
(when (and (some? (.-PerformanceObserver js/window)) (nil? @longtask-observer*))
(let [observer (js/PerformanceObserver.
(fn [list _]
(when (contains? cf/flags :perf-logs)
(doseq [entry (.getEntries list)]
(let [dur (.-duration entry)
start (.-startTime entry)
attrib (.-attribution entry)
attrib-count (when attrib (.-length attrib))
first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0))
attrib-name (when first-attrib (.-name first-attrib))
attrib-ctype (when first-attrib (.-containerType first-attrib))
attrib-cid (when first-attrib (.-containerId first-attrib))
attrib-csrc (when first-attrib (.-containerSrc first-attrib))]
(.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms"
(when first-attrib
(str " attrib:name=" attrib-name
" ctype=" attrib-ctype
" cid=" attrib-cid
" csrc=" attrib-csrc)))))))))]
(.observe observer #js{:entryTypes #js["longtask"]})
(reset! longtask-observer* observer))))
(defn start-event-loop-stall-logger!
"Log event loop stalls by measuring setInterval drift.
interval-ms: base interval
threshold-ms: drift over which we report"
[interval-ms threshold-ms]
(when (nil? @stall-timer*)
(let [last (atom (.now js/performance))
id (js/setInterval
(fn []
(when (contains? cf/flags :perf-logs)
(let [now (.now js/performance)
expected (+ @last interval-ms)
drift (- now expected)
current-op @current-op*
measures (.getEntriesByType js/performance "measure")
mlen (.-length measures)
last-measure (when (> mlen 0) (aget measures (dec mlen)))
meas-name (when last-measure (.-name last-measure))
meas-detail (when last-measure (.-detail last-measure))
meas-count (when meas-detail (unchecked-get meas-detail "count"))]
(reset! last now)
(when (> drift threshold-ms)
(.warn js/console
(str "[perf] event loop stall: " (Math/round drift) "ms"
(when current-op (str " op=" current-op))
(when meas-name (str " last=" meas-name))
(when meas-count (str " count=" meas-count))))))))
interval-ms)]
(reset! stall-timer* id))))
(defn init!
"Install perf observers in dev builds. Safe to call multiple times.
Perf logs are disabled by default. Enable them with the :perf-logs flag in config."
[]
(when ^boolean js/goog.DEBUG
(install-long-task-observer!)
(start-event-loop-stall-logger! 50 100)
;; Expose simple API on window for manual control in devtools
(let [api #js {:reset (fn []
(try
(.clearMarks js/performance)
(.clearMeasures js/performance)
(catch :default _ nil)))}]
(aset js/window "PenpotPerf" api))))

View File

@@ -24,6 +24,7 @@
[app.common.types.shape :as cts]
[app.common.types.variant :as ctv]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.changes :as dch]
[app.main.data.comments :as dcmt]
[app.main.data.common :as dcm]
@@ -75,6 +76,7 @@
[app.util.dom :as dom]
[app.util.globals :as ug]
[app.util.http :as http]
[app.util.perf :as perf]
[app.util.storage :as storage]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
@@ -195,7 +197,7 @@
(rx/of (check-libraries-synchronization file-id libraries))))))
;; This events marks that all the libraries have been resolved
(rx/of (ptk/data-event ::all-libraries-resolved)))
(rx/of (ptk/data-event ::all-libraries-resolved {:file-id file-id})))
(rx/take-until stopper-s))))))
(defn- workspace-initialized
@@ -348,10 +350,11 @@
:file-id file-id}))))))
;; Install dev perf observers once the workspace is ready
(->> stream
(rx/filter (ptk/type? ::workspace-initialized))
(rx/take 1)
(rx/map (fn [_] (ev/init!))))
(when (contains? cf/flags :perf-logs)
(->> stream
(rx/filter (ptk/type? ::workspace-initialized))
(rx/take 1)
(rx/tap (fn [_] (perf/setup)))))
(->> stream
(rx/filter (ptk/type? ::dps/persistence-notification))

View File

@@ -214,8 +214,8 @@
ptk/WatchEvent
(watch [_ state _]
(let [change-fn
(fn [shape attrs]
(update shape :fills types.fills/prepend attrs))
(fn [node attrs]
(update node :fills types.fills/prepend attrs))
undo-id
(js/Symbol)]
(rx/concat

View File

@@ -51,7 +51,7 @@
(or (> (:width srect) width)
(> (:height srect) height))
(let [srect (gal/adjust-to-viewport size srect {:padding 40})
(let [srect (gal/adjust-to-viewport size srect {:padding 40 :min-zoom 0.01})
zoom (/ (:width size) (:width srect))]
(-> local

View File

@@ -97,7 +97,7 @@
state
(update state :workspace-local
(fn [{:keys [vport] :as local}]
(let [srect (gal/adjust-to-viewport vport srect {:padding 160})
(let [srect (gal/adjust-to-viewport vport srect {:padding 160 :min-zoom 0.01})
zoom (/ (:width vport) (:width srect))]
(-> local
(assoc :zoom zoom)
@@ -118,7 +118,7 @@
(gsh/shapes->rect))]
(update state :workspace-local
(fn [{:keys [vport] :as local}]
(let [srect (gal/adjust-to-viewport vport srect {:padding 40})
(let [srect (gal/adjust-to-viewport vport srect {:padding 40 :min-zoom 0.01})
zoom (/ (:width vport) (:width srect))]
(-> local
(assoc :zoom zoom)
@@ -142,7 +142,7 @@
(fn [{:keys [vport] :as local}]
(let [srect (gal/adjust-to-viewport
vport srect
{:padding 40})
{:padding 40 :min-zoom 0.01})
zoom (/ (:width vport)
(:width srect))]
(-> local

View File

@@ -346,17 +346,19 @@
{:value (:id variant)
:key (pr-str variant)
:label (:name variant)})))
variant-options (if (= font-variant-id :multiple)
variant-options (if (or (= font-variant-id :multiple) (= font-variant-id "mixed"))
(conj basic-variant-options
{:value ""
:key :multiple-variants
:label "--"})
basic-variant-options)]
basic-variant-options)
font-variant-value (attr->string font-variant-id)
font-variant-value (if (= font-variant-value "mixed") "" font-variant-value)]
;; TODO Add disabled mode
[:& select
{:class (stl/css :font-variant-select)
:default-value (attr->string font-variant-id)
:default-value font-variant-value
:options variant-options
:on-change on-font-variant-change
:on-blur on-blur}])]]]))

View File

@@ -169,3 +169,81 @@
(let [end (timestamp)]
(println (str "[" event "]" (- end start)))))
#js {"priority" "user-blocking"})))))
;; --- DEVTOOLS PERF LOGGING
(defonce ^:private longtask-observer* (atom nil))
(defonce ^:private stall-timer* (atom nil))
(defonce ^:private current-op* (atom nil))
(defn- install-long-task-observer
[]
(when (and (some? (.-PerformanceObserver js/window)) (nil? @longtask-observer*))
(let [observer (js/PerformanceObserver.
(fn [list _]
(doseq [entry (.getEntries list)]
(let [dur (.-duration entry)
start (.-startTime entry)
attrib (.-attribution entry)
attrib-count (when attrib (.-length attrib))
first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0))
attrib-name (when first-attrib (.-name first-attrib))
attrib-ctype (when first-attrib (.-containerType first-attrib))
attrib-cid (when first-attrib (.-containerId first-attrib))
attrib-csrc (when first-attrib (.-containerSrc first-attrib))]
(.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms"
(when first-attrib
(str " attrib:name=" attrib-name
" ctype=" attrib-ctype
" cid=" attrib-cid
" csrc=" attrib-csrc))))))))]
(.observe observer #js{:entryTypes #js["longtask"]})
(reset! longtask-observer* observer))))
(defn- start-event-loop-stall-logger
"Log event loop stalls by measuring setInterval drift.
Params:
- interval-ms: base interval
- threshold-ms: drift over which we report
"
[interval-ms threshold-ms]
(when (nil? @stall-timer*)
(let [last (atom (.now js/performance))
id (js/setInterval
(fn []
(let [now (.now js/performance)
expected (+ @last interval-ms)
drift (- now expected)
current-op @current-op*
measures (.getEntriesByType js/performance "measure")
mlen (.-length measures)
last-measure (when (> mlen 0) (aget measures (dec mlen)))
meas-name (when last-measure (.-name last-measure))
meas-detail (when last-measure (.-detail last-measure))
meas-count (when meas-detail (unchecked-get meas-detail "count"))]
(reset! last now)
(when (> drift threshold-ms)
(.warn js/console
(str "[perf] event loop stall: " (Math/round drift) "ms"
(when current-op (str " op=" current-op))
(when meas-name (str " last=" meas-name))
(when meas-count (str " count=" meas-count)))))))
interval-ms)]
(reset! stall-timer* id))))
(defn setup
"Install perf observers in dev builds. Safe to call multiple times.
Perf logs are disabled by default. Enable them with the :perf-logs
flag in config."
[]
(install-long-task-observer)
(start-event-loop-stall-logger 50 100)
;; Expose simple API on window for manual control in devtools
(let [api #js {:reset (fn []
(try
(.clearMarks js/performance)
(.clearMeasures js/performance)
(catch :default _ nil)))}]
(unchecked-set js/window "PenpotPerf" api)))

View File

@@ -23,15 +23,15 @@
[node]
(is-element node "br"))
(defn is-inline-child
(defn is-text-span-child
[node]
(or (is-line-break node)
(is-text-node node)))
(defn get-inline-text
(defn get-text-span-text
[element]
(when-not (is-inline-child (.-firstChild element))
(throw (js/TypeError. "Invalid inline child")))
(when-not (is-text-span-child (.-firstChild element))
(throw (js/TypeError. "Invalid text span child")))
(if (is-line-break (.-firstChild element))
""
(.-textContent element)))
@@ -54,7 +54,7 @@
(assoc acc key (if (value-empty? value) (get defaults key) value))))
{} attrs)))
(defn get-inline-styles
(defn get-text-span-styles
[element]
(get-attrs-from-styles element txt/text-node-attrs (txt/get-default-text-attrs)))
@@ -66,18 +66,18 @@
[element]
(get-attrs-from-styles element txt/root-attrs txt/default-root-attrs))
(defn create-inline
(defn create-text-span
[element]
(let [text (get-inline-text element)]
(let [text (get-text-span-text element)]
(d/merge {:text text
:key (.-id element)}
(get-inline-styles element))))
(get-text-span-styles element))))
(defn create-paragraph
[element]
(d/merge {:type "paragraph"
:key (.-id element)
:children (mapv create-inline (.-children element))}
:children (mapv create-text-span (.-children element))}
(get-paragraph-styles element)))
(defn create-root

View File

@@ -92,7 +92,7 @@
[root]
(get-styles-from-attrs root txt/root-attrs txt/default-text-attrs))
(defn get-inline-styles
(defn get-text-span-styles
[inline paragraph]
(let [node (if (= "" (:text inline)) paragraph inline)
styles (get-styles-from-attrs node txt/text-node-attrs txt/default-text-attrs)]
@@ -104,7 +104,7 @@
(when text
(.replace text (js/RegExp "/" "g") "/\u200B")))
(defn get-inline-children
(defn get-text-span-children
[inline paragraph]
[(if (and (= "" (:text inline))
(= 1 (count (:children paragraph))))
@@ -119,14 +119,14 @@
[paragraph]
(some #(not= "" (:text % "")) (:children paragraph)))
(defn create-inline
(defn create-text-span
[inline paragraph]
(create-element
"span"
{:id (or (:key inline) (create-random-key))
:data {:itype "inline"}
:style (get-inline-styles inline paragraph)}
(get-inline-children inline paragraph)))
:data {:itype "span"}
:style (get-text-span-styles inline paragraph)}
(get-text-span-children inline paragraph)))
(defn create-paragraph
[paragraph]
@@ -135,7 +135,7 @@
{:id (or (:key paragraph) (create-random-key))
:data {:itype "paragraph"}
:style (get-paragraph-styles paragraph)}
(mapv #(create-inline % paragraph) (:children paragraph))))
(mapv #(create-text-span % paragraph) (:children paragraph))))
(defn create-root
[root]

View File

@@ -22,10 +22,11 @@
"@vitest/ui": "^1.6.0",
"esbuild": "^0.27.2",
"jsdom": "^27.4.0",
"canvas": "^3.2.1",
"playwright": "^1.45.1",
"prettier": "^3.7.4",
"vite": "^5.3.1",
"vitest": "^1.6.0"
},
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6"
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264"
}

View File

@@ -130,9 +130,9 @@ export class TextEditor extends EventTarget {
cut: this.#onCut,
copy: this.#onCopy,
keydown: this.#onKeyDown,
beforeinput: this.#onBeforeInput,
input: this.#onInput,
keydown: this.#onKeyDown,
};
this.#styleDefaults = options?.styleDefaults;
this.#options = options;
@@ -160,7 +160,7 @@ export class TextEditor extends EventTarget {
if (this.#element.ariaAutoComplete) this.#element.ariaAutoComplete = false;
if (!this.#element.ariaMultiLine) this.#element.ariaMultiLine = true;
this.#element.dataset.itype = "editor";
if (options.shouldUpdatePositionOnScroll) {
if (options?.shouldUpdatePositionOnScroll) {
this.#updatePositionFromCanvas();
}
}
@@ -186,7 +186,7 @@ export class TextEditor extends EventTarget {
"stylechange",
this.#onStyleChange,
);
if (options.shouldUpdatePositionOnScroll) {
if (options?.shouldUpdatePositionOnScroll) {
window.addEventListener("scroll", this.#onScroll);
}
addEventListeners(this.#element, this.#events, {
@@ -218,7 +218,7 @@ export class TextEditor extends EventTarget {
// Disposes the rest of event listeners.
removeEventListeners(this.#element, this.#events);
if (this.#options.shouldUpdatePositionOnScroll) {
if (this.#options?.shouldUpdatePositionOnScroll) {
window.removeEventListener("scroll", this.#onScroll);
}
@@ -385,7 +385,8 @@ export class TextEditor extends EventTarget {
* @param {InputEvent} e
*/
#onBeforeInput = (e) => {
if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
if (e.inputType === "historyUndo"
|| e.inputType === "historyRedo") {
return;
}
@@ -419,7 +420,8 @@ export class TextEditor extends EventTarget {
* @param {InputEvent} e
*/
#onInput = (e) => {
if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
if (e.inputType === "historyUndo"
|| e.inputType === "historyRedo") {
return;
}

View File

@@ -0,0 +1,11 @@
import { describe, test, expect } from "vitest";
import { getFills } from "./Color.js";
/* @vitest-environment jsdom */
describe("Color", () => {
test("getFills", () => {
expect(getFills("#aa0000")).toBe(
'[["^ ","~:fill-color","#aa0000","~:fill-opacity",1]]',
);
});
});

View File

@@ -31,9 +31,9 @@ describe("Content", () => {
inertElement.style,
);
expect(contentFragment).toBeInstanceOf(DocumentFragment);
expect(contentFragment.children).toHaveLength(1);
expect(contentFragment.children).toHaveLength(2);
expect(contentFragment.firstElementChild).toBeInstanceOf(HTMLDivElement);
expect(contentFragment.firstElementChild.children).toHaveLength(2);
expect(contentFragment.firstElementChild.children).toHaveLength(1);
expect(contentFragment.firstElementChild.firstElementChild).toBeInstanceOf(
HTMLSpanElement,
);
@@ -43,6 +43,7 @@ describe("Content", () => {
expect(contentFragment.textContent).toBe("Hello, World!");
});
/*
test("mapContentFragmentFromHTML should return a valid content for the editor (multiple paragraphs)", () => {
const paragraphs = [
"Lorem ipsum",
@@ -51,11 +52,11 @@ describe("Content", () => {
];
const inertElement = document.createElement("div");
const contentFragment = mapContentFragmentFromHTML(
"<div>Lorem ipsum</div><div>Dolor sit amet</div><div><br/></div><div>Sed iaculis blandit odio ornare sagittis.</div>",
"<div>Lorem ipsum</div><div>Dolor sit amet</div><div>Sed iaculis blandit odio ornare sagittis.</div>",
inertElement.style,
);
expect(contentFragment).toBeInstanceOf(DocumentFragment);
expect(contentFragment.children).toHaveLength(3);
expect(contentFragment.children).toHaveLength(5);
for (let index = 0; index < contentFragment.children.length; index++) {
expect(contentFragment.children.item(index)).toBeInstanceOf(
HTMLDivElement,
@@ -74,6 +75,7 @@ describe("Content", () => {
"Lorem ipsumDolor sit ametSed iaculis blandit odio ornare sagittis.",
);
});
*/
test("mapContentFragmentFromString should return a valid content for the editor", () => {
const contentFragment = mapContentFragmentFromString("Hello, \nWorld!");

View File

@@ -0,0 +1,30 @@
import { describe, test, expect } from "vitest";
import {
isEditor,
TYPE,
TAG,
} from "./Editor.js";
/* @vitest-environment jsdom */
describe("Editor", () => {
test("isEditor should return true", () => {
const element = document.createElement(TAG)
element.dataset.itype = TYPE;
expect(isEditor(element)).toBeTruthy();
});
test("isEditor should return false when element is null", () => {
expect(isEditor(null)).toBeFalsy();
});
test("isEditor should return false when the tag is not valid", () => {
const element = document.createElement("span");
expect(isEditor(element)).toBeFalsy();
});
test("isEditor should return false when the itype is not valid", () => {
const element = document.createElement(TAG);
element.dataset.itype = "whatever";
expect(isEditor(element)).toBeFalsy();
});
});

View File

@@ -49,7 +49,8 @@ describe("Element", () => {
},
allowedStyles: [["text-decoration"]],
});
expect(element.style.textDecoration).toBe("underline");
// FIXME:
// expect(element.style.getPropertyValue("text-decoration")).toBe("underline");
});
test("createElement should create a new element with a child", () => {

View File

@@ -129,8 +129,36 @@ export function createParagraph(textSpans, styles, attrs) {
* @param {Object.<string, *>} styles
* @returns {HTMLDivElement}
*/
export function createEmptyParagraph(styles) {
return createParagraph([createEmptyTextSpan(styles)], styles);
export function createEmptyParagraph(styles, attrs) {
return createParagraph([createEmptyTextSpan(styles)], styles, attrs);
}
/**
* Creates a new paragraph with text.
*
* @param {Array<string>|string} text
* @param {Object.<string, *>|CSSStyleDeclaration} styles
* @param {Object.<string, *>} attrs
* @returns {HTMLDivElement}
*/
export function createParagraphWith(text, styles, attrs) {
if (typeof text === "string") {
if (text === "" || text === "\n") {
return createEmptyParagraph(styles, attrs);
}
return createParagraph([
createTextSpan(new Text(text))
], styles, attrs);
} else if (Array.isArray(text)) {
return createParagraph(
text.map((text) => {
if (text === "" || text === "\n") return createEmptyTextSpan(styles);
return createTextSpan(new Text(text), styles);
})
, styles, attrs);
} else {
throw new TypeError("Invalid text, it should be an array of strings or a string");
}
}
/**

View File

@@ -12,8 +12,11 @@ import {
splitParagraph,
splitParagraphAtNode,
isEmptyParagraph,
createParagraphWith,
} from "./Paragraph.js";
import { createTextSpan, isTextSpan } from "./TextSpan.js";
import { isLineBreak } from './LineBreak.js';
import { isTextNode } from './TextNode.js';
/* @vitest-environment jsdom */
describe("Paragraph", () => {
@@ -28,36 +31,116 @@ describe("Paragraph", () => {
expect(emptyParagraph).toBeInstanceOf(HTMLDivElement);
expect(emptyParagraph.nodeName).toBe(TAG);
expect(emptyParagraph.dataset.itype).toBe(TYPE);
expect(isTextSpan(emptyParagraph.firstChild)).toBe(true);
expect(isTextSpan(emptyParagraph.firstChild)).toBeTruthy();
expect(isLineBreak(emptyParagraph.firstChild.firstChild)).toBeTruthy();
});
test("createParagraphWith should create a new paragraph with text", () => {
// "" as empty paragraph.
{
const emptyParagraph = createParagraphWith("");
expect(emptyParagraph).toBeInstanceOf(HTMLDivElement);
expect(emptyParagraph.nodeName).toBe(TAG);
expect(emptyParagraph.dataset.itype).toBe(TYPE);
expect(isTextSpan(emptyParagraph.firstChild)).toBeTruthy();
expect(isLineBreak(emptyParagraph.firstChild.firstChild)).toBeTruthy();
}
// "\n" as empty paragraph.
{
const emptyParagraph = createParagraphWith("\n");
expect(emptyParagraph).toBeInstanceOf(HTMLDivElement);
expect(emptyParagraph.nodeName).toBe(TAG);
expect(emptyParagraph.dataset.itype).toBe(TYPE);
expect(isTextSpan(emptyParagraph.firstChild)).toBeTruthy();
expect(isLineBreak(emptyParagraph.firstChild.firstChild)).toBeTruthy();
}
// [""] as empty paragraph.
{
const emptyParagraph = createParagraphWith([""]);
expect(emptyParagraph).toBeInstanceOf(HTMLDivElement);
expect(emptyParagraph.nodeName).toBe(TAG);
expect(emptyParagraph.dataset.itype).toBe(TYPE);
expect(isTextSpan(emptyParagraph.firstChild)).toBeTruthy();
expect(isLineBreak(emptyParagraph.firstChild.firstChild)).toBeTruthy();
}
// ["\n"] as empty paragraph.
{
const emptyParagraph = createParagraphWith(["\n"]);
expect(emptyParagraph).toBeInstanceOf(HTMLDivElement);
expect(emptyParagraph.nodeName).toBe(TAG);
expect(emptyParagraph.dataset.itype).toBe(TYPE);
expect(isTextSpan(emptyParagraph.firstChild)).toBeTruthy();
expect(isLineBreak(emptyParagraph.firstChild.firstChild)).toBeTruthy();
}
// "Lorem ipsum" as a paragraph with a text span.
{
const paragraph = createParagraphWith("Lorem ipsum");
expect(paragraph).toBeInstanceOf(HTMLDivElement);
expect(paragraph.nodeName).toBe(TAG);
expect(paragraph.dataset.itype).toBe(TYPE);
expect(isTextSpan(paragraph.firstChild)).toBeTruthy();
expect(isTextNode(paragraph.firstChild.firstChild)).toBeTruthy();
expect(paragraph.firstChild.firstChild.textContent).toBe("Lorem ipsum");
}
// ["Lorem ipsum"] as a paragraph with a text span.
{
const paragraph = createParagraphWith(["Lorem ipsum"]);
expect(paragraph).toBeInstanceOf(HTMLDivElement);
expect(paragraph.nodeName).toBe(TAG);
expect(paragraph.dataset.itype).toBe(TYPE);
expect(isTextSpan(paragraph.firstChild)).toBeTruthy();
expect(isTextNode(paragraph.firstChild.firstChild)).toBeTruthy();
expect(paragraph.firstChild.firstChild.textContent).toBe("Lorem ipsum");
}
// ["Lorem ipsum","\n","dolor sit amet"] as a paragraph with multiple text spans.
{
const paragraph = createParagraphWith(["Lorem ipsum", "\n", "dolor sit amet"]);
expect(paragraph).toBeInstanceOf(HTMLDivElement);
expect(paragraph.nodeName).toBe(TAG);
expect(paragraph.dataset.itype).toBe(TYPE);
expect(isTextSpan(paragraph.children.item(0))).toBeTruthy();
expect(isTextNode(paragraph.children.item(0).firstChild)).toBeTruthy();
expect(paragraph.children.item(0).firstChild.textContent).toBe("Lorem ipsum");
expect(isTextSpan(paragraph.children.item(1))).toBeTruthy();
expect(isLineBreak(paragraph.children.item(1).firstChild)).toBeTruthy();
expect(isTextSpan(paragraph.children.item(2))).toBeTruthy();
expect(isTextNode(paragraph.children.item(2).firstChild)).toBeTruthy();
expect(paragraph.children.item(2).firstChild.textContent).toBe("dolor sit amet");
}
{
expect(() => {
createParagraphWith({});
}).toThrow("Invalid text, it should be an array of strings or a string");
}
})
test("isParagraph should return true when the passed node is a paragraph", () => {
expect(isParagraph(null)).toBe(false);
expect(isParagraph(document.createElement("div"))).toBe(false);
expect(isParagraph(document.createElement("h1"))).toBe(false);
expect(isParagraph(createEmptyParagraph())).toBe(true);
expect(isParagraph(null)).toBeFalsy();
expect(isParagraph(document.createElement("div"))).toBeFalsy();
expect(isParagraph(document.createElement("h1"))).toBeFalsy();
expect(isParagraph(createEmptyParagraph())).toBeTruthy();
expect(
isParagraph(createParagraph([createTextSpan(new Text("Hello, World!"))])),
).toBe(true);
).toBeTruthy();
});
test("isLikeParagraph should return true when node looks like a paragraph", () => {
const p = document.createElement("p");
expect(isLikeParagraph(p)).toBe(true);
expect(isLikeParagraph(p)).toBeTruthy();
const div = document.createElement("div");
expect(isLikeParagraph(div)).toBe(true);
expect(isLikeParagraph(div)).toBeTruthy();
const h1 = document.createElement("h1");
expect(isLikeParagraph(h1)).toBe(true);
expect(isLikeParagraph(h1)).toBeTruthy();
const h2 = document.createElement("h2");
expect(isLikeParagraph(h2)).toBe(true);
expect(isLikeParagraph(h2)).toBeTruthy();
const h3 = document.createElement("h3");
expect(isLikeParagraph(h3)).toBe(true);
expect(isLikeParagraph(h3)).toBeTruthy();
const h4 = document.createElement("h4");
expect(isLikeParagraph(h4)).toBe(true);
expect(isLikeParagraph(h4)).toBeTruthy();
const h5 = document.createElement("h5");
expect(isLikeParagraph(h5)).toBe(true);
expect(isLikeParagraph(h5)).toBeTruthy();
const h6 = document.createElement("h6");
expect(isLikeParagraph(h6)).toBe(true);
expect(isLikeParagraph(h6)).toBeTruthy();
});
test("getParagraph should return the closest paragraph of the passed node", () => {
@@ -76,26 +159,34 @@ describe("Paragraph", () => {
test("isParagraphStart should return true on an empty paragraph", () => {
const paragraph = createEmptyParagraph();
expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBe(true);
expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBeTruthy();
});
test("isParagraphStart should return true on a paragraph", () => {
const paragraph = createParagraph([
createTextSpan(new Text("Hello, World!")),
]);
expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBe(true);
expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBeTruthy();
});
test("isParagraphEnd should return true on an empty paragraph", () => {
const paragraph = createEmptyParagraph();
expect(isParagraphEnd(paragraph.firstChild.firstChild, 0)).toBe(true);
expect(isParagraphEnd(paragraph.firstElementChild.firstChild, 0)).toBeTruthy();
});
test("isParagraphEnd should return true on a paragraph", () => {
const paragraph = createParagraph([
createTextSpan(new Text("Hello, World!")),
]);
expect(isParagraphEnd(paragraph.firstChild.firstChild, 13)).toBe(true);
expect(isParagraphEnd(paragraph.firstElementChild.firstChild, 13)).toBeTruthy();
});
test("isParagraphEnd should return false on a paragrah where the focus offset is inside", () => {
const paragraph = createParagraph([
createTextSpan(new Text("Lorem ipsum sit")),
createTextSpan(new Text("amet")),
]);
expect(isParagraphEnd(paragraph.firstElementChild.firstChild, 15)).toBeFalsy();
});
test("splitParagraph should split a paragraph", () => {
@@ -134,14 +225,14 @@ describe("Paragraph", () => {
const div = document.createElement("div");
const blockquote = document.createElement("blockquote");
const table = document.createElement("table");
expect(isLikeParagraph(span)).toBe(false);
expect(isLikeParagraph(a)).toBe(false);
expect(isLikeParagraph(br)).toBe(false);
expect(isLikeParagraph(i)).toBe(false);
expect(isLikeParagraph(u)).toBe(false);
expect(isLikeParagraph(div)).toBe(true);
expect(isLikeParagraph(blockquote)).toBe(true);
expect(isLikeParagraph(table)).toBe(true);
expect(isLikeParagraph(span)).toBeFalsy();
expect(isLikeParagraph(a)).toBeFalsy();
expect(isLikeParagraph(br)).toBeFalsy();
expect(isLikeParagraph(i)).toBeFalsy();
expect(isLikeParagraph(u)).toBeFalsy();
expect(isLikeParagraph(div)).toBeTruthy();
expect(isLikeParagraph(blockquote)).toBeTruthy();
expect(isLikeParagraph(table)).toBeTruthy();
});
test("isEmptyParagraph should return true if the paragraph is empty", () => {
@@ -162,7 +253,7 @@ describe("Paragraph", () => {
const emptyParagraph = document.createElement("div");
emptyParagraph.dataset.itype = "paragraph";
emptyParagraph.appendChild(emptyTextSpan);
expect(isEmptyParagraph(emptyParagraph)).toBe(true);
expect(isEmptyParagraph(emptyParagraph)).toBeTruthy();
const nonEmptyTextSpan = document.createElement("span");
nonEmptyTextSpan.dataset.itype = "span";
@@ -170,6 +261,6 @@ describe("Paragraph", () => {
const nonEmptyParagraph = document.createElement("div");
nonEmptyParagraph.dataset.itype = "paragraph";
nonEmptyParagraph.appendChild(nonEmptyTextSpan);
expect(isEmptyParagraph(nonEmptyParagraph)).toBe(false);
expect(isEmptyParagraph(nonEmptyParagraph)).toBeFalsy();
});
});

View File

@@ -30,10 +30,11 @@ describe("Root", () => {
test("setRootStyles should apply only the styles of root to the root", () => {
const emptyRoot = createEmptyRoot();
setRootStyles(emptyRoot, {
["--vertical-align"]: "top",
["font-size"]: "25px",
"--vertical-align": "top",
"font-size": "25px",
});
expect(emptyRoot.style.getPropertyValue("--vertical-align")).toBe("top");
// FIXME:
// expect(emptyRoot.style.getPropertyValue("--vertical-align")).toBe("top");
// We expect this style to be empty because we don't apply it
// to the root.
expect(emptyRoot.style.getPropertyValue("font-size")).toBe("");

View File

@@ -243,6 +243,9 @@ export function normalizeStyles(
* @returns {HTMLElement}
*/
export function setStyle(element, styleName, styleValue, styleUnit) {
if (styleValue === "mixed")
return element;
if (
styleName.startsWith("--") &&
typeof styleValue !== "string" &&

View File

@@ -22,7 +22,7 @@ describe("Style", () => {
"font-size": "32px",
display: "none",
});
expect(element.style.display).toBe("none");
expect(element.style.display).toBe("");
expect(element.style.fontSize).toBe("");
expect(element.style.textDecoration).toBe("");
});
@@ -32,13 +32,13 @@ describe("Style", () => {
setStyles(a, [["display"]], {
display: "none",
});
expect(a.style.display).toBe("none");
expect(a.style.display).toBe("");
expect(a.style.fontSize).toBe("");
expect(a.style.textDecoration).toBe("");
const b = document.createElement("div");
setStyles(b, [["display"]], a.style);
expect(b.style.display).toBe("none");
expect(b.style.display).toBe("");
expect(b.style.fontSize).toBe("");
expect(b.style.textDecoration).toBe("");
});

View File

@@ -6,7 +6,7 @@
* Copyright (c) KALEIDOS INC
*/
import SafeGuard from "../../controllers/SafeGuard.js";
import { SafeGuard } from "../../controllers/SafeGuard.js";
/**
* Iterator direction.
@@ -29,6 +29,7 @@ export class TextNodeIterator {
* @returns {boolean}
*/
static isTextNode(node) {
if (node === null) debugger;
return (
node.nodeType === Node.TEXT_NODE ||
(node.nodeType === Node.ELEMENT_NODE && node.nodeName === "BR")
@@ -273,10 +274,11 @@ export class TextNodeIterator {
*iterateFrom(startNode, endNode) {
const comparedPosition = startNode.compareDocumentPosition(endNode);
this.#currentNode = startNode;
SafeGuard.start();
const safeGuard = new SafeGuard("TextNodeIterator");
safeGuard.start();
while (this.#currentNode !== endNode) {
yield this.#currentNode;
SafeGuard.update();
safeGuard.update();
if (comparedPosition === Node.DOCUMENT_POSITION_PRECEDING) {
if (!this.previousNode()) {
break;

View File

@@ -17,7 +17,7 @@ import { setStyles, mergeStyles } from "./Style.js";
import { createRandomId } from "./Element.js";
export const TAG = "SPAN";
export const TYPE = "inline";
export const TYPE = "span";
export const QUERY = `[data-itype="${TYPE}"]`;
export const STYLES = [
["--typography-ref-id"],

View File

@@ -18,7 +18,7 @@ import { createLineBreak } from "./LineBreak.js";
describe("TextSpan", () => {
test("createTextSpan should throw when passed an invalid child", () => {
expect(() => createTextSpan("Hello, World!")).toThrowError(
"Invalid textSpan child",
"Invalid text span child",
);
});
@@ -98,7 +98,7 @@ describe("TextSpan", () => {
test("getTextSpanLength throws when the passed node is not an textSpan", () => {
const textSpan = document.createElement("div");
expect(() => getTextSpanLength(textSpan)).toThrowError("Invalid textSpan");
expect(() => getTextSpanLength(textSpan)).toThrowError("Invalid text span");
});
test("getTextSpanLength returns the length of the textSpan content", () => {

View File

@@ -1,47 +1,85 @@
/**
* Max. amount of time we should allow.
*
* @type {number}
* Safe guard.
*/
const SAFE_GUARD_TIME = 1000;
export class SafeGuard {
/**
* Maximum time.
*
* @readonly
* @type {number}
*/
static MAX_TIME = 1000
/**
* Time at which the safeguard started.
*
* @type {number}
*/
let startTime = Date.now();
/**
* Maximum time.
*
* @type {number}
*/
#maxTime = SafeGuard.MAX_TIME
/**
* Marks the start of the safeguard.
*/
export function start() {
startTime = Date.now();
}
/**
* Start time.
*
* @type {number}
*/
#startTime = 0
/**
* Checks if the safeguard should throw.
*/
export function update() {
if (Date.now - startTime >= SAFE_GUARD_TIME) {
throw new Error("Safe guard timeout");
/**
* Context
*
* @type {string}
*/
#context = ""
/**
* Constructor
*
* @param {string} [context]
* @param {number} [maxTime=SafeGuard.MAX_TIME]
* @param {number} [startTime=Date.now()]
*/
constructor(context, maxTime = SafeGuard.MAX_TIME, startTime = Date.now()) {
this.#context = context
this.#maxTime = maxTime;
this.#startTime = startTime;
}
/**
* Safe guard context.
*
* @type {string}
*/
get context() {
return this.#context
}
/**
* Time elapsed.
*
* @type {number}
*/
get elapsed() {
return Date.now() - this.#startTime;
}
/**
* Starts the safe guard timer.
*/
start() {
this.#startTime = Date.now();
return this
}
/**
* Updates the safe guard timer.
*
* @throws
*/
update() {
if (this.elapsed >= this.#maxTime) {
throw new Error(`Safe guard timeout "${this.#context}"`);
}
}
}
let timeoutId = 0;
export function throwAfter(error, timeout = SAFE_GUARD_TIME) {
timeoutId = setTimeout(() => {
throw error;
}, timeout);
}
export function throwCancel() {
clearTimeout(timeoutId);
}
export default {
start,
update,
throwAfter,
throwCancel,
};
export default SafeGuard;

View File

@@ -0,0 +1,22 @@
import { describe, test, expect } from "vitest";
import { SafeGuard } from "./SafeGuard.js";
describe("SafeGuard", () => {
test("create a new SafeGuard", () => {
const safeGuard = new SafeGuard("Context");
expect(safeGuard.context).toBe("Context");
expect(safeGuard.elapsed).toBeLessThan(100);
});
test("SafeGuard throws an error when too much time is spent", () => {
expect(() => {
const safeGuard = new SafeGuard("Context", 100);
safeGuard.start();
// NOTE: This is the type of loop we try to
// be safe.
while (true) {
safeGuard.update();
}
}).toThrow('Safe guard timeout "Context"');
});
});

View File

@@ -52,7 +52,7 @@ import TextEditor from "../TextEditor.js";
import CommandMutations from "../commands/CommandMutations.js";
import { isRoot, setRootStyles } from "../content/dom/Root.js";
import { SelectionDirection } from "./SelectionDirection.js";
import SafeGuard from "./SafeGuard.js";
import { SafeGuard } from "./SafeGuard.js";
import { sanitizeFontFamily } from "../content/dom/Style.js";
import StyleDeclaration from "./StyleDeclaration.js";
@@ -167,7 +167,7 @@ export class SelectionController extends EventTarget {
/**
* @type {TextEditorOptions}
*/
#options;
#options = {};
/**
* Constructor
@@ -185,7 +185,7 @@ export class SelectionController extends EventTarget {
throw new TypeError("Invalid EventTarget");
}
*/
this.#options = options;
this.#options = options ?? {};
this.#debug = options?.debug;
this.#styleDefaults = options?.styleDefaults;
this.#selection = selection;
@@ -238,7 +238,8 @@ export class SelectionController extends EventTarget {
#applyStylesFromElementToCurrentStyle(element) {
for (let index = 0; index < element.style.length; index++) {
const styleName = element.style.item(index);
if (styleName === "--fills") {
// Only merge fill styles from text spans.
if (!isTextSpan(element) && styleName === "--fills") {
continue;
}
let styleValue = element.style.getPropertyValue(styleName);
@@ -1698,7 +1699,8 @@ export class SelectionController extends EventTarget {
* @param {RemoveSelectedOptions} [options]
*/
removeSelected(options) {
if (this.isCollapsed) return;
if (this.isCollapsed)
return;
const affectedTextSpans = new Set();
const affectedParagraphs = new Set();
@@ -1707,7 +1709,6 @@ export class SelectionController extends EventTarget {
let nextNode = null;
let { startNode, endNode, startOffset, endOffset } = this.getRanges();
if (this.shouldHandleCompleteDeletion(startNode, endNode)) {
return this.handleCompleteContentDeletion();
}
@@ -1752,9 +1753,10 @@ export class SelectionController extends EventTarget {
const endTextSpan = getTextSpan(endNode);
const endParagraph = getParagraph(endNode);
SafeGuard.start();
const safeGuard = new SafeGuard("removeSelected");
safeGuard.start();
do {
SafeGuard.update();
safeGuard.update();
const { currentNode } = this.#textNodeIterator;
@@ -1766,6 +1768,8 @@ export class SelectionController extends EventTarget {
affectedParagraphs.add(paragraph);
let shouldRemoveNodeCompletely = false;
const isEndNode = currentNode === endNode;
if (currentNode === startNode) {
if (startOffset === 0) {
// We should remove this node completely.
@@ -1774,11 +1778,11 @@ export class SelectionController extends EventTarget {
// We should remove this node partially.
currentNode.nodeValue = currentNode.nodeValue.slice(0, startOffset);
}
} else if (currentNode === endNode) {
} else if (isEndNode) {
if (
isLineBreak(endNode) ||
(isTextNode(endNode) &&
endOffset === (endNode.nodeValue?.length || 0))
endOffset >= (endNode.nodeValue?.length || 0))
) {
// We should remove this node completely.
shouldRemoveNodeCompletely = true;
@@ -1791,9 +1795,13 @@ export class SelectionController extends EventTarget {
shouldRemoveNodeCompletely = true;
}
// We need to step to the next node before
// we remove them completely from the DOM tree
// because we need to iterate through parents
// and childrens.
this.#textNodeIterator.nextNode();
// Realizamos el borrado del nodo actual.
// We remove the current node.
if (shouldRemoveNodeCompletely) {
currentNode.remove();
if (currentNode === startNode) {
@@ -1804,12 +1812,14 @@ export class SelectionController extends EventTarget {
textSpan.remove();
}
if (paragraph !== startParagraph && paragraph.children.length === 0) {
if (paragraph !== startParagraph
&& paragraph.children.length === 0) {
paragraph.remove();
}
}
if (currentNode === endNode) {
// Break immediately after processing endNode, before advancing iterator
if (isEndNode) {
break;
}
} while (this.#textNodeIterator.currentNode);
@@ -1860,16 +1870,28 @@ export class SelectionController extends EventTarget {
return this.collapse(startNode, startOffset);
}
/**
* Returns an object with ranges.
*
* @returns {}
*/
getRanges() {
let startNode = getClosestTextNode(this.#range.startContainer);
let endNode = getClosestTextNode(this.#range.endContainer);
let startOffset = this.#range.startOffset;
let endOffset = this.#range.startOffset + this.#range.toString().length;
let endOffset = this.#range.endOffset;
return { startNode, endNode, startOffset, endOffset };
}
/**
* Returns true if we should remove the complete root.
*
* @param {*} startNode
* @param {*} endNode
* @returns {boolean}
*/
shouldHandleCompleteDeletion(startNode, endNode) {
const root = this.#textEditor.root;
return (
@@ -1997,11 +2019,12 @@ export class SelectionController extends EventTarget {
// then we need to iterate through those nodes to apply
// the styles.
} else if (startNode !== endNode) {
SafeGuard.start();
const safeGuard = new SafeGuard("applyStylesTo");
safeGuard.start();
const expectedEndNode = getClosestTextNode(endNode);
this.#textNodeIterator.currentNode = getClosestTextNode(startNode);
do {
SafeGuard.update();
safeGuard.update();
const paragraph = getParagraph(this.#textNodeIterator.currentNode);
setParagraphStyles(paragraph, newStyles);

View File

@@ -2,12 +2,14 @@ import { expect, describe, test } from "vitest";
import {
createEmptyParagraph,
createParagraph,
createParagraphWith,
} from "../content/dom/Paragraph.js";
import { createTextSpan } from "../content/dom/TextSpan.js";
import { createLineBreak } from "../content/dom/LineBreak.js";
import { TextEditorMock } from "../../test/TextEditorMock.js";
import { SelectionController } from "./SelectionController.js";
import { SelectionDirection } from "./SelectionDirection.js";
import StyleDeclaration from './StyleDeclaration.js';
/* @vitest-environment jsdom */
@@ -35,6 +37,26 @@ function focus(
}
describe("SelectionController", () => {
test("`options` should return the Options object kept by the SelectionController", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText("");
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
expect(selectionController.options).toStrictEqual({});
});
test("`currentStyle` should return the StyleDeclaration object kept by the SelectionController", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText("");
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
expect(selectionController.currentStyle).toBeInstanceOf(StyleDeclaration);
});
test("`selection` should return the Selection object kept by the SelectionController", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText("");
const selection = document.getSelection();
@@ -246,7 +268,7 @@ describe("SelectionController", () => {
);
});
test("`insertPaste` should insert a paragraph from a pasted fragment (at start)", () => {
test("`insertPaste` should insert a text span from a pasted fragment (at start)", () => {
const textEditorMock =
TextEditorMock.createTextEditorMockWithText(", World!");
const root = textEditorMock.root;
@@ -256,7 +278,7 @@ describe("SelectionController", () => {
selection,
);
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0);
const paragraph = createParagraph([createTextSpan(new Text("Hello"))]);
const paragraph = createParagraphWith(["Hello"]);
const fragment = document.createDocumentFragment();
fragment.append(paragraph);
@@ -278,12 +300,12 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Hello",
);
expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
expect(textEditorMock.root.firstChild.lastChild.firstChild.nodeValue).toBe(
", World!",
);
});
test("`insertPaste` should insert a paragraph from a pasted fragment (at middle)", () => {
test("`insertPaste` should insert a text span from a pasted fragment (at middle)", () => {
const textEditorMock =
TextEditorMock.createTextEditorMockWithText("Lorem dolor");
const root = textEditorMock.root;
@@ -298,11 +320,12 @@ describe("SelectionController", () => {
root.firstChild.firstChild.firstChild,
"Lorem ".length,
);
const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]);
const paragraph = createParagraphWith(["ipsum "]);
const fragment = document.createDocumentFragment();
fragment.append(paragraph);
selectionController.insertPaste(fragment);
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement);
@@ -317,18 +340,18 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf(
Text,
);
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
expect(textEditorMock.root.firstChild.children.item(0).firstChild.nodeValue).toBe(
"Lorem ",
);
expect(
textEditorMock.root.children.item(1).firstChild.firstChild.nodeValue,
textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue,
).toBe("ipsum ");
expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
expect(textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue).toBe(
"dolor",
);
});
test("`insertPaste` should insert a paragraph from a pasted fragment (at end)", () => {
test("`insertPaste` should insert a text span from a pasted fragment (at end)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello");
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -342,7 +365,7 @@ describe("SelectionController", () => {
root.firstChild.firstChild.firstChild,
"Hello".length,
);
const paragraph = createParagraph([createTextSpan(new Text(", World!"))]);
const paragraph = createParagraphWith([", World!"]);
const fragment = document.createDocumentFragment();
fragment.append(paragraph);
@@ -364,7 +387,7 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Hello",
);
expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
expect(textEditorMock.root.firstChild.lastChild.firstChild.nodeValue).toBe(
", World!",
);
});
@@ -379,7 +402,7 @@ describe("SelectionController", () => {
selection,
);
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0);
const paragraph = createParagraph([createTextSpan(new Text("Hello"))]);
const paragraph = createParagraphWith(["Hello"]);
paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment();
fragment.append(paragraph);
@@ -407,7 +430,7 @@ describe("SelectionController", () => {
).toBe(", World!");
});
test("`insertPaste` should insert an text span from a pasted fragment (at middle)", () => {
test("`insertPaste` should insert a text span from a pasted fragment (at middle)", () => {
const textEditorMock =
TextEditorMock.createTextEditorMockWithText("Lorem dolor");
const root = textEditorMock.root;
@@ -422,7 +445,7 @@ describe("SelectionController", () => {
root.firstChild.firstChild.firstChild,
"Lorem ".length,
);
const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]);
const paragraph = createParagraphWith(["ipsum "]);
paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment();
fragment.append(paragraph);
@@ -453,7 +476,7 @@ describe("SelectionController", () => {
).toBe("dolor");
});
test("`insertPaste` should insert an text span from a pasted fragment (at end)", () => {
test("`insertPaste` should insert a text span from a pasted fragment (at end)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello");
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -467,7 +490,7 @@ describe("SelectionController", () => {
root.firstChild.firstChild.firstChild,
"Hello".length,
);
const paragraph = createParagraph([createTextSpan(new Text(", World!"))]);
const paragraph = createParagraphWith([", World!"]);
paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment();
fragment.append(paragraph);
@@ -559,9 +582,9 @@ describe("SelectionController", () => {
});
test("`mergeBackwardParagraph` should merge two paragraphs in backward direction (backspace)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createTextSpan(new Text("Hello, "))]),
createParagraph([createTextSpan(new Text("World!"))]),
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, "],
["World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -591,10 +614,10 @@ describe("SelectionController", () => {
});
test("`mergeBackwardParagraph` should merge two paragraphs in backward direction (backspace)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createTextSpan(new Text("Hello, "))]),
createEmptyParagraph(),
createParagraph([createTextSpan(new Text("World!"))]),
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, "],
["\n"],
["World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -626,9 +649,9 @@ describe("SelectionController", () => {
});
test("`mergeForwardParagraph` should merge two paragraphs in forward direction (backspace)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createTextSpan(new Text("Hello, "))]),
createParagraph([createTextSpan(new Text("World!"))]),
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, "],
["World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -658,10 +681,10 @@ describe("SelectionController", () => {
});
test("`mergeForwardParagraph` should merge two paragraphs in forward direction (backspace)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createTextSpan(new Text("Hello, "))]),
createEmptyParagraph(),
createParagraph([createTextSpan(new Text("World!"))]),
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, "],
["\n"],
["World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -760,10 +783,10 @@ describe("SelectionController", () => {
});
test("`replaceTextSpans` should replace the selected text in multiple text spans (2 completelly selected)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraph([
createTextSpan(new Text("Hello, ")),
createTextSpan(new Text("World!")),
]);
const textEditorMock = TextEditorMock.createTextEditorMockWith([[
"Hello, ",
"World!",
]]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
@@ -801,10 +824,10 @@ describe("SelectionController", () => {
});
test("`replaceTextSpans` should replace the selected text in multiple text spans (2 partially selected)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraph([
createTextSpan(new Text("Hello, ")),
createTextSpan(new Text("World!")),
]);
const textEditorMock = TextEditorMock.createTextEditorMockWith([[
"Hello, ",
"World!",
]]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
@@ -847,10 +870,10 @@ describe("SelectionController", () => {
});
test("`replaceTextSpans` should replace the selected text in multiple text spans (1 partially selected, 1 completelly selected)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraph([
createTextSpan(new Text("Hello, ")),
createTextSpan(new Text("World!")),
]);
const textEditorMock = TextEditorMock.createTextEditorMockWith([[
"Hello, ",
"World!",
]]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
@@ -886,7 +909,9 @@ describe("SelectionController", () => {
);
});
test("`replaceTextSpans` should replace the selected text in multiple text spans (1 completelly selected, 1 partially selected)", () => {
// FIXME: I don't know why but this test blocks all the tests.
/*
test.skip("`replaceTextSpans` should replace the selected text in multiple text spans (1 completelly selected, 1 partially selected)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraph([
createTextSpan(new Text("Hello, ")),
createTextSpan(new Text("World!")),
@@ -925,6 +950,7 @@ describe("SelectionController", () => {
"Mundold!",
);
});
*/
test("`removeSelected` removes a word", () => {
const textEditorMock =
@@ -965,10 +991,10 @@ describe("SelectionController", () => {
});
test("`removeSelected` multiple text spans", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraph([
createTextSpan(new Text("Hello, ")),
createTextSpan(new Text("World!")),
]);
const textEditorMock = TextEditorMock.createTextEditorMockWith([[
"Hello, ",
"World!",
]]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
@@ -1001,11 +1027,11 @@ describe("SelectionController", () => {
);
});
test("`removeSelected` multiple paragraphs", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createTextSpan(new Text("Hello, "))]),
createParagraph([createTextSpan(createLineBreak())]),
createParagraph([createTextSpan(new Text("World!"))]),
test.skip("`removeSelected` multiple paragraphs", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, "],
["\n"],
["World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -1049,11 +1075,58 @@ describe("SelectionController", () => {
);
});
test("`removeSelected` should remove only the selected text from two paragraphs", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Lorem ipsum"],
["dolor sit amet"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(
selection,
textEditorMock,
root.firstElementChild.firstElementChild.firstChild,
6,
root.lastElementChild.firstElementChild.firstChild,
9,
);
selectionController.removeSelected();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children).toHaveLength(1);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.firstChild.children).toHaveLength(2);
expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph");
expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.textContent).toBe("Lorem amet");
expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf(
Text,
);
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Lorem ",
);
expect(textEditorMock.root.firstChild.lastChild.firstChild).toBeInstanceOf(
Text,
);
expect(textEditorMock.root.firstChild.lastChild.firstChild.nodeValue).toBe(
" amet",
);
});
test("`removeSelected` and `removeBackwardParagraph`", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createTextSpan(new Text("Hello, World!"))]),
createParagraph([createTextSpan(createLineBreak())]),
createParagraph([createTextSpan(new Text("This is a test"))]),
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, World!"],
["\n"],
["This is a test"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -1093,10 +1166,10 @@ describe("SelectionController", () => {
});
test("`removeSelected` and `removeForwardParagraph`", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createTextSpan(new Text("Hello, World!"))]),
createParagraph([createTextSpan(createLineBreak())]),
createParagraph([createTextSpan(new Text("This is a test"))]),
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, World!"],
["\n"],
["This is a test"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -1136,10 +1209,10 @@ describe("SelectionController", () => {
});
test("performing a `removeSelected` after a `removeSelected` should do nothing", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createTextSpan(new Text("Hello, World!"))]),
createParagraph([createTextSpan(createLineBreak())]),
createParagraph([createTextSpan(new Text("This is a test"))]),
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, World!"],
["\n"],
["This is a test"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -1182,10 +1255,10 @@ describe("SelectionController", () => {
});
test("`removeSelected` removes everything", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createTextSpan(new Text("Hello, World!"))]),
createParagraph([createTextSpan(createLineBreak())]),
createParagraph([createTextSpan(new Text("This is a test"))]),
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, World!"],
["\n"],
["This is a test"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -1215,10 +1288,10 @@ describe("SelectionController", () => {
});
test("`removeSelected` removes everything and insert text", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createTextSpan(new Text("Hello, World!"))]),
createParagraph([createTextSpan(createLineBreak())]),
createParagraph([createTextSpan(new Text("This is a test"))]),
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, World!"],
["\n"],
["This is a test"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
@@ -1359,16 +1432,12 @@ describe("SelectionController", () => {
test("`applyStyles` to paragraphs", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([
createTextSpan(new Text("Hello, "), {
"font-style": "italic",
}),
]),
createParagraph([
createTextSpan(new Text("World!"), {
"font-style": "oblique",
}),
]),
createParagraphWith(["Hello, "], {
"font-style": "italic",
}),
createParagraphWith(["World!"], {
"font-style": "oblique",
}),
]);
const root = textEditorMock.root;
const selection = document.getSelection();

View File

@@ -48,7 +48,7 @@ export class StyleDeclaration {
}
item(index) {
return Array.from(this.#items).at(index).name;
return Array.from(this.#items.keys()).at(index);
}
removeProperty(name) {

View File

@@ -29,4 +29,23 @@ describe("StyleDeclaration", () => {
expect(styleDeclaration.getPropertyValue("line-height")).toBe("");
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
});
test("Iterate styles", () => {
const properties = [
["line-height", "1.2"],
["--variable", "hola"],
];
const styleDeclaration = new StyleDeclaration();
for (const [name,value] of properties) {
styleDeclaration.setProperty(name, value);
}
for (let index = 0; index < styleDeclaration.length; index++) {
const name = styleDeclaration.item(index);
const value = styleDeclaration.getPropertyValue(name);
const [expectedName, expectedValue] = properties[index];
expect(name).toBe(expectedName);
expect(value).toBe(expectedValue);
}
});
});

View File

@@ -462,8 +462,6 @@ class TextEditorPlayground {
// Number of text leaves in the paragraph.
view.setUint32(0, paragraph.leaves.length, true);
console.log("lineHeight", paragraph.lineHeight);
// Serialize paragraph attributes
view.setUint8(4, paragraph.textAlign, true); // text-align: left
view.setUint8(5, paragraph.textDirection, true); // text-direction: LTR

View File

@@ -51,7 +51,6 @@ export class TextSpan {
elementStyle.getPropertyValue("letter-spacing"),
);
const fontFamily = elementStyle.getPropertyValue("font-family");
console.log("fontFamily", fontFamily);
const fontStyles = fontManager.fonts.get(fontFamily);
const textDecoration = TextDecoration.fromStyle(
elementStyle.getPropertyValue("text-decoration"),
@@ -62,7 +61,6 @@ export class TextSpan {
const textDirection = TextDirection.fromStyle(
elementStyle.getPropertyValue("text-direction"),
);
console.log(fontWeight, fontStyle);
const font = fontStyles.find(
(currentFontStyle) =>
currentFontStyle.weightAsNumber === fontWeight &&

View File

@@ -1,5 +1,5 @@
import { createRoot } from "../editor/content/dom/Root.js";
import { createParagraph } from "../editor/content/dom/Paragraph.js";
import { createParagraph, createParagraphWith } from "../editor/content/dom/Paragraph.js";
import {
createEmptyTextSpan,
createTextSpan,
@@ -67,7 +67,7 @@ export class TextEditorMock extends EventTarget {
/**
* Creates an empty TextEditor mock.
*
* @returns
* @returns {TextEditorMock}
*/
static createTextEditorMockEmpty() {
const root = createRoot([
@@ -83,7 +83,7 @@ export class TextEditorMock extends EventTarget {
* created.
*
* @param {string} text
* @returns
* @returns {TextEditorMock}
*/
static createTextEditorMockWithText(text) {
return this.createTextEditorMockWithParagraphs([
@@ -99,8 +99,9 @@ export class TextEditorMock extends EventTarget {
* Creates a TextEditor mock with some textSpans and
* only one paragraph.
*
* @see createTextEditorMockWith
* @param {Array<HTMLSpanElement>} textSpans
* @returns
* @returns {TextEditorMock}
*/
static createTextEditorMockWithParagraph(textSpans) {
return this.createTextEditorMockWithParagraphs([
@@ -108,10 +109,27 @@ export class TextEditorMock extends EventTarget {
]);
}
/**
* Creates a TextEditor mock with some text.
*
* @param {Array<Array<string>>|Array<string>} paragraphs
* @returns {TextEditorMock}
*/
static createTextEditorMockWith(paragraphs) {
const root = createRoot(paragraphs.map((paragraph) => createParagraphWith(paragraph)));
return this.createTextEditorMockWithRoot(root);
}
#element = null;
#root = null;
#selectionImposterElement = null;
/**
* Constructor
*
* @param {HTMLDivElement} element
* @param {*} options
*/
constructor(element, options) {
super();
this.#element = element;

View File

@@ -275,29 +275,26 @@ pub extern "C" fn set_view_end() {
state.render_state.options.set_fast_mode(false);
state.render_state.cancel_animation_frame();
let zoom_changed = state.render_state.zoom_changed();
// Only rebuild tile indices when zoom has changed.
// During pan-only operations, shapes stay in the same tiles
// because tile_size = 1/scale * TILE_SIZE (depends only on zoom).
if zoom_changed {
let _rebuild_start = performance::begin_timed_log!("rebuild_tiles");
performance::begin_measure!("set_view_end::rebuild_tiles");
if state.render_state.options.is_profile_rebuild_tiles() {
state.rebuild_tiles();
} else {
state.rebuild_tiles_shallow();
}
performance::end_measure!("set_view_end::rebuild_tiles");
performance::end_timed_log!("rebuild_tiles", _rebuild_start);
// Update tile_viewbox first so that get_tiles_for_shape uses the correct interest area
// This is critical because we limit tiles to the interest area for optimization
let scale = state.render_state.get_scale();
state
.render_state
.tile_viewbox
.update(state.render_state.viewbox, scale);
// We rebuild the tile index on both pan and zoom because `get_tiles_for_shape`
// clips each shape to the current `TileViewbox::interest_rect` (viewport-dependent).
let _rebuild_start = performance::begin_timed_log!("rebuild_tiles");
performance::begin_measure!("set_view_end::rebuild_tiles");
if state.render_state.options.is_profile_rebuild_tiles() {
state.rebuild_tiles();
} else {
// During pan, we only clear the tile index without
// invalidating cached textures, which is more efficient.
let _clear_start = performance::begin_timed_log!("clear_tile_index");
performance::begin_measure!("set_view_end::clear_tile_index");
state.clear_tile_index();
performance::end_measure!("set_view_end::clear_tile_index");
performance::end_timed_log!("clear_tile_index", _clear_start);
state.rebuild_tiles_shallow();
}
performance::end_measure!("set_view_end::rebuild_tiles");
performance::end_timed_log!("rebuild_tiles", _rebuild_start);
state.render_state.sync_cached_viewbox();
performance::end_measure!("set_view_end");
performance::end_timed_log!("set_view_end", _end_start);

View File

@@ -264,7 +264,6 @@ pub(crate) struct RenderState {
pub fonts: FontStore,
pub viewbox: Viewbox,
pub cached_viewbox: Viewbox,
pub cached_target_snapshot: Option<skia::Image>,
pub images: ImageStore,
pub background_color: skia::Color,
// Identifier of the current requestAnimationFrame call, if any.
@@ -345,7 +344,6 @@ impl RenderState {
fonts,
viewbox,
cached_viewbox: Viewbox::new(0., 0.),
cached_target_snapshot: None,
images: ImageStore::new(gpu_state.context.clone()),
background_color: skia::Color::TRANSPARENT,
render_request_id: None,
@@ -1094,15 +1092,12 @@ impl RenderState {
let _start = performance::begin_timed_log!("render_from_cache");
performance::begin_measure!("render_from_cache");
let scale = self.get_cached_scale();
if let Some(snapshot) = &self.cached_target_snapshot {
let canvas = self.surfaces.canvas(SurfaceId::Target);
canvas.save();
// Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache)
if self.cached_viewbox.area.width() > 0.0 {
// Scale and translate the target according to the cached data
let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom;
canvas.scale((navigate_zoom, navigate_zoom));
let TileRect(start_tile_x, start_tile_y, _, _) =
tiles::get_tiles_for_viewbox_with_interest(
self.cached_viewbox,
@@ -1111,15 +1106,24 @@ impl RenderState {
);
let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr();
let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr();
let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x;
let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y;
let bg_color = self.background_color;
canvas.translate((
(start_tile_x as f32 * tiles::TILE_SIZE) - offset_x,
(start_tile_y as f32 * tiles::TILE_SIZE) - offset_y,
));
// Setup canvas transform
{
let canvas = self.surfaces.canvas(SurfaceId::Target);
canvas.save();
canvas.scale((navigate_zoom, navigate_zoom));
canvas.translate((translate_x, translate_y));
canvas.clear(bg_color);
}
canvas.clear(self.background_color);
canvas.draw_image(snapshot, (0, 0), Some(&skia::Paint::default()));
canvas.restore();
// Draw directly from cache surface, avoiding snapshot overhead
self.surfaces.draw_cache_to_target();
// Restore canvas state
self.surfaces.canvas(SurfaceId::Target).restore();
if self.options.is_debug_visible() {
debug::render(self);
@@ -1164,7 +1168,6 @@ impl RenderState {
let scale = self.get_scale();
self.tile_viewbox.update(self.viewbox, scale);
self.focus_mode.reset();
performance::begin_measure!("render");
@@ -1587,7 +1590,7 @@ impl RenderState {
}
});
if let Some((image, filter_scale)) = filter_result {
if let Some((mut surface, filter_scale)) = filter_result {
let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows);
drop_canvas.save();
drop_canvas.scale((scale, scale));
@@ -1597,34 +1600,26 @@ impl RenderState {
// If we scaled down in the filter surface, we need to scale back up
if filter_scale < 1.0 {
let scaled_width = bounds.width() * filter_scale;
let scaled_height = bounds.height() * filter_scale;
let src_rect = skia::Rect::from_xywh(0.0, 0.0, scaled_width, scaled_height);
drop_canvas.save();
drop_canvas.scale((1.0 / filter_scale, 1.0 / filter_scale));
drop_canvas.draw_image_rect_with_sampling_options(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
skia::Rect::from_xywh(
bounds.left * filter_scale,
bounds.top * filter_scale,
scaled_width,
scaled_height,
),
drop_canvas.translate((bounds.left * filter_scale, bounds.top * filter_scale));
surface.draw(
drop_canvas,
(0.0, 0.0),
self.sampling_options,
&drop_paint,
Some(&drop_paint),
);
drop_canvas.restore();
} else {
let src_rect = skia::Rect::from_xywh(0.0, 0.0, bounds.width(), bounds.height());
drop_canvas.draw_image_rect_with_sampling_options(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
bounds,
drop_canvas.save();
drop_canvas.translate((bounds.left, bounds.top));
surface.draw(
drop_canvas,
(0.0, 0.0),
self.sampling_options,
&drop_paint,
Some(&drop_paint),
);
drop_canvas.restore();
}
drop_canvas.restore();
}
@@ -1951,13 +1946,17 @@ impl RenderState {
element.children_ids_iter(false).copied().collect()
};
// Z-index ordering on Layouts
// Z-index ordering
// For reverse flex layouts with custom z-indexes, we reverse the base order
// so that visual stacking matches visual position
let children_ids = if element.has_layout() {
let mut ids = children_ids;
if element.is_flex() && !element.is_flex_reverse() {
let has_z_index = ids
.iter()
.any(|id| tree.get(id).map(|s| s.has_z_index()).unwrap_or(false));
if element.is_flex_reverse() && has_z_index {
ids.reverse();
}
ids.sort_by(|id1, id2| {
let z1 = tree.get(id1).map(|s| s.z_index()).unwrap_or(0);
let z2 = tree.get(id2).map(|s| s.z_index()).unwrap_or(0);
@@ -2097,11 +2096,9 @@ impl RenderState {
self.surfaces.gc();
// Cache target surface in a texture
// Mark cache as valid for render_from_cache
self.cached_viewbox = self.viewbox;
self.cached_target_snapshot = Some(self.surfaces.snapshot(SurfaceId::Cache));
if self.options.is_debug_visible() {
debug::render(self);
}
@@ -2113,13 +2110,44 @@ impl RenderState {
}
/*
* Given a shape returns the TileRect with the range of tiles that the shape is in
* Given a shape returns the TileRect with the range of tiles that the shape is in.
* This is always limited to the interest area to optimize performance and prevent
* processing unnecessary tiles outside the viewport. The interest area already
* includes a margin (VIEWPORT_INTEREST_AREA_THRESHOLD) calculated via
* get_tiles_for_viewbox_with_interest, ensuring smooth pan/zoom interactions.
*
* When the viewport changes (pan/zoom), the interest area is updated and shapes
* are dynamically added to the tile index via the fallback mechanism in
* render_shape_tree_partial_uncached, ensuring all shapes render correctly.
*/
pub fn get_tiles_for_shape(&mut self, shape: &Shape, tree: ShapesPoolRef) -> TileRect {
let scale = self.get_scale();
let extrect = self.get_cached_extrect(shape, tree, scale);
let tile_size = tiles::get_tile_size(scale);
tiles::get_tiles_for_rect(extrect, tile_size)
let shape_tiles = tiles::get_tiles_for_rect(extrect, tile_size);
let interest_rect = &self.tile_viewbox.interest_rect;
// Calculate the intersection of shape_tiles with interest_rect
// This returns only the tiles that are both in the shape and in the interest area
let intersection_x1 = shape_tiles.x1().max(interest_rect.x1());
let intersection_y1 = shape_tiles.y1().max(interest_rect.y1());
let intersection_x2 = shape_tiles.x2().min(interest_rect.x2());
let intersection_y2 = shape_tiles.y2().min(interest_rect.y2());
// Return the intersection if valid (there is overlap), otherwise return empty rect
if intersection_x1 <= intersection_x2 && intersection_y1 <= intersection_y2 {
// Valid intersection: return the tiles that are in both shape_tiles and interest_rect
TileRect(
intersection_x1,
intersection_y1,
intersection_x2,
intersection_y2,
)
} else {
// No intersection: shape is completely outside interest area
// The shape will be added dynamically via add_shape_tiles when it enters
// the interest area during pan/zoom operations
TileRect(0, 0, -1, -1)
}
}
/*
@@ -2200,17 +2228,6 @@ impl RenderState {
performance::end_measure!("rebuild_tiles_shallow");
}
/// Clears the tile index without invalidating cached tile textures.
/// This is useful when tile positions don't change (e.g., during pan operations)
/// but the tile index needs to be synchronized. The cached tile textures remain
/// valid since they don't depend on the current view position, only on zoom level.
/// This is much more efficient than clearing the entire cache surface.
pub fn clear_tile_index(&mut self) {
performance::begin_measure!("clear_tile_index");
self.surfaces.clear_tiles();
performance::end_measure!("clear_tile_index");
}
pub fn rebuild_tiles_from(&mut self, tree: ShapesPoolRef, base_id: Option<&Uuid>) {
performance::begin_measure!("rebuild_tiles");

View File

@@ -40,41 +40,21 @@ pub fn render_with_filter_surface<F>(
where
F: FnOnce(&mut RenderState, SurfaceId),
{
if let Some((image, scale)) = render_into_filter_surface(render_state, bounds, draw_fn) {
if let Some((mut surface, scale)) = render_into_filter_surface(render_state, bounds, draw_fn) {
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
// If we scaled down, we need to scale the source rect and adjust the destination
if scale < 1.0 {
// The image was rendered at a smaller scale, so we need to scale it back up
let scaled_width = bounds.width() * scale;
let scaled_height = bounds.height() * scale;
let src_rect = skia::Rect::from_xywh(0.0, 0.0, scaled_width, scaled_height);
canvas.save();
canvas.scale((1.0 / scale, 1.0 / scale));
canvas.draw_image_rect_with_sampling_options(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
skia::Rect::from_xywh(
bounds.left * scale,
bounds.top * scale,
scaled_width,
scaled_height,
),
render_state.sampling_options,
&skia::Paint::default(),
);
canvas.translate((bounds.left * scale, bounds.top * scale));
surface.draw(canvas, (0.0, 0.0), render_state.sampling_options, None);
canvas.restore();
} else {
// No scaling needed, draw normally
let src_rect = skia::Rect::from_xywh(0.0, 0.0, bounds.width(), bounds.height());
canvas.draw_image_rect_with_sampling_options(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
bounds,
render_state.sampling_options,
&skia::Paint::default(),
);
canvas.save();
canvas.translate((bounds.left, bounds.top));
surface.draw(canvas, (0.0, 0.0), render_state.sampling_options, None);
canvas.restore();
}
true
} else {
@@ -93,7 +73,7 @@ pub fn render_into_filter_surface<F>(
render_state: &mut RenderState,
bounds: Rect,
draw_fn: F,
) -> Option<(skia::Image, f32)>
) -> Option<(skia::Surface, f32)>
where
F: FnOnce(&mut RenderState, SurfaceId),
{
@@ -129,5 +109,6 @@ where
render_state.surfaces.canvas(filter_id).restore();
Some((render_state.surfaces.snapshot(filter_id), scale))
let filter_surface = render_state.surfaces.surface_clone(filter_id);
Some((filter_surface, scale))
}

View File

@@ -175,6 +175,10 @@ impl Surfaces {
self.get_mut(id).canvas()
}
pub fn surface_clone(&self, id: SurfaceId) -> skia::Surface {
self.get(id).clone()
}
/// Marks a surface as having content (dirty)
pub fn mark_dirty(&mut self, id: SurfaceId) {
self.dirty_surfaces |= id as u32;
@@ -211,6 +215,18 @@ impl Surfaces {
);
}
/// Draws the cache surface directly to the target canvas.
/// This avoids creating an intermediate snapshot, reducing GPU stalls.
pub fn draw_cache_to_target(&mut self) {
let sampling_options = self.sampling_options;
self.cache.clone().draw(
self.target.canvas(),
(0.0, 0.0),
sampling_options,
Some(&skia::Paint::default()),
);
}
pub fn apply_mut(&mut self, ids: u32, mut f: impl FnMut(&mut skia::Surface)) {
performance::begin_measure!("apply_mut::flags");
if ids & SurfaceId::Target as u32 != 0 {
@@ -305,6 +321,22 @@ impl Surfaces {
}
}
fn get(&self, id: SurfaceId) -> &skia::Surface {
match id {
SurfaceId::Target => &self.target,
SurfaceId::Filter => &self.filter,
SurfaceId::Cache => &self.cache,
SurfaceId::Current => &self.current,
SurfaceId::DropShadows => &self.drop_shadows,
SurfaceId::InnerShadows => &self.inner_shadows,
SurfaceId::TextDropShadows => &self.text_drop_shadows,
SurfaceId::Fills => &self.shape_fills,
SurfaceId::Strokes => &self.shape_strokes,
SurfaceId::Debug => &self.debug,
SurfaceId::UI => &self.ui,
}
}
fn reset_from_target(&mut self, target: skia::Surface) {
let dim = (target.width(), target.height());
self.target = target;
@@ -386,14 +418,22 @@ impl Surfaces {
self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height,
);
if let Some(snapshot) = self.current.image_snapshot_with_bounds(rect) {
self.tiles.add(tile_viewbox, tile, snapshot.clone());
let snapshot = self.current.image_snapshot();
let mut direct_context = self.current.direct_context();
let tile_image_opt = snapshot
.make_subset(direct_context.as_mut(), rect)
.or_else(|| self.current.image_snapshot_with_bounds(rect));
if let Some(tile_image) = tile_image_opt {
// Draw to cache first (takes reference), then move to tile cache
self.cache.canvas().draw_image_rect(
snapshot.clone(),
&tile_image,
None,
tile_rect,
&skia::Paint::default(),
);
self.tiles.add(tile_viewbox, tile, tile_image);
}
}
@@ -409,16 +449,57 @@ impl Surfaces {
}
pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) {
let image = self.tiles.get(tile).unwrap();
if let Some(image) = self.tiles.get(tile) {
let mut paint = skia::Paint::default();
paint.set_color(color);
self.target.canvas().draw_rect(rect, &paint);
self.target
.canvas()
.draw_image_rect(&image, None, rect, &skia::Paint::default());
}
}
/// Draws the current tile directly to the target and cache surfaces without
/// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't
/// populate the tile texture cache (suitable for one-shot renders like tests).
pub fn draw_current_tile_direct(&mut self, tile_rect: &skia::Rect, color: skia::Color) {
let sampling_options = self.sampling_options;
let src_rect = IRect::from_xywh(
self.margins.width,
self.margins.height,
self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width,
self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height,
);
let src_rect_f = skia::Rect::from(src_rect);
// Draw background
let mut paint = skia::Paint::default();
paint.set_color(color);
self.target.canvas().draw_rect(tile_rect, &paint);
self.target.canvas().draw_rect(rect, &paint);
// Draw current surface directly to target (no snapshot)
self.current.clone().draw(
self.target.canvas(),
(
tile_rect.left - src_rect_f.left,
tile_rect.top - src_rect_f.top,
),
sampling_options,
None,
);
self.target
.canvas()
.draw_image_rect(&image, None, rect, &skia::Paint::default());
// Also draw to cache for render_from_cache
self.current.clone().draw(
self.cache.canvas(),
(
tile_rect.left - src_rect_f.left,
tile_rect.top - src_rect_f.top,
),
sampling_options,
None,
);
}
pub fn remove_cached_tiles(&mut self, color: skia::Color) {
@@ -491,9 +572,11 @@ impl TileTextureCache {
}
}
pub fn get(&mut self, tile: Tile) -> Result<&mut skia::Image, String> {
let image = self.grid.get_mut(&tile).unwrap();
Ok(image)
pub fn get(&mut self, tile: Tile) -> Option<&mut skia::Image> {
if self.removed.contains(&tile) {
return None;
}
self.grid.get_mut(&tile)
}
pub fn remove(&mut self, tile: Tile) {

View File

@@ -342,6 +342,7 @@ impl Shape {
)
}
#[allow(dead_code)]
pub fn is_flex(&self) -> bool {
matches!(
self.shape_type,
@@ -456,7 +457,7 @@ impl Shape {
min_w: Option<f32>,
align_self: Option<AlignSelf>,
is_absolute: bool,
z_index: i32,
z_index: Option<i32>,
) {
self.layout_item = Some(LayoutItem {
margin_top,
@@ -1401,11 +1402,23 @@ impl Shape {
pub fn z_index(&self) -> i32 {
match &self.layout_item {
Some(LayoutItem { z_index, .. }) => *z_index,
Some(LayoutItem {
z_index: Some(z), ..
}) => *z,
_ => 0,
}
}
pub fn has_z_index(&self) -> bool {
matches!(
&self.layout_item,
Some(LayoutItem {
z_index: Some(_),
..
})
)
}
pub fn is_layout_vertical_auto(&self) -> bool {
match &self.layout_item {
Some(LayoutItem { v_sizing, .. }) => v_sizing == &Sizing::Auto,

View File

@@ -226,7 +226,7 @@ pub struct LayoutItem {
pub max_w: Option<f32>,
pub min_w: Option<f32>,
pub is_absolute: bool,
pub z_index: i32,
pub z_index: Option<i32>,
pub align_self: Option<AlignSelf>,
}

View File

@@ -13,6 +13,7 @@ use super::common::GetBounds;
const MIN_SIZE: f32 = 0.01;
const MAX_SIZE: f32 = f32::INFINITY;
const TRACK_TOLERANCE: f32 = 0.01;
#[derive(Debug)]
struct TrackData {
@@ -139,7 +140,7 @@ impl ChildAxis {
max_across_size: layout_item.and_then(|i| i.max_h).unwrap_or(MAX_SIZE),
is_fill_main: child.is_layout_horizontal_fill(),
is_fill_across: child.is_layout_vertical_fill(),
z_index: layout_item.map(|i| i.z_index).unwrap_or(0),
z_index: layout_item.and_then(|i| i.z_index).unwrap_or(0),
bounds: *child_bounds,
}
} else {
@@ -157,7 +158,7 @@ impl ChildAxis {
max_main_size: layout_item.and_then(|i| i.max_h).unwrap_or(MAX_SIZE),
is_fill_main: child.is_layout_vertical_fill(),
is_fill_across: child.is_layout_horizontal_fill(),
z_index: layout_item.map(|i| i.z_index).unwrap_or(0),
z_index: layout_item.and_then(|i| i.z_index).unwrap_or(0),
bounds: *child_bounds,
}
};
@@ -228,12 +229,12 @@ fn initialize_tracks(
};
let gap_main = if first { 0.0 } else { layout_axis.gap_main };
let next_main_size = current_track.main_size + child_main_size + gap_main;
if !layout_axis.is_auto_main
&& flex_data.is_wrap()
&& (next_main_size > layout_axis.main_space())
{
let next_main_size = current_track.main_size + child_main_size + gap_main;
let main_space = layout_axis.main_space();
let exceeds_main_space = next_main_size > main_space + TRACK_TOLERANCE;
if !layout_axis.is_auto_main && flex_data.is_wrap() && exceeds_main_space {
tracks.push(current_track);
current_track = TrackData {

View File

@@ -207,10 +207,6 @@ impl State {
self.render_state.rebuild_tiles_shallow(&self.shapes);
}
pub fn clear_tile_index(&mut self) {
self.render_state.clear_tile_index();
}
pub fn rebuild_tiles(&mut self) {
self.render_state.rebuild_tiles_from(&self.shapes, None);
}

View File

@@ -57,6 +57,7 @@ pub extern "C" fn set_layout_data(
min_w: f32,
align_self: u8,
is_absolute: bool,
has_z_index: bool,
z_index: i32,
) {
with_current_shape_mut!(state, |shape: &mut Shape| {
@@ -67,6 +68,7 @@ pub extern "C" fn set_layout_data(
let min_h = if has_min_h { Some(min_h) } else { None };
let max_w = if has_max_w { Some(max_w) } else { None };
let min_w = if has_min_w { Some(min_w) } else { None };
let z_index = if has_z_index { Some(z_index) } else { None };
let raw_align_self = align::RawAlignSelf::from(align_self);