diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index 90ed0930a2..516789428b 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -55,6 +55,7 @@ "design-tokens/v1" "text-editor/v2-html-paste" "text-editor/v2" + "text-editor-wasm/v1" "render-wasm/v1" "variants/v1"}) @@ -78,6 +79,7 @@ "plugins/runtime" "text-editor/v2-html-paste" "text-editor/v2" + "text-editor-wasm/v1" "tokens/numeric-input" "render-wasm/v1"}) @@ -127,6 +129,7 @@ :feature-design-tokens "design-tokens/v1" :feature-text-editor-v2 "text-editor/v2" :feature-text-editor-v2-html-paste "text-editor/v2-html-paste" + :feature-text-editor-wasm "text-editor-wasm/v1" :feature-render-wasm "render-wasm/v1" :feature-variants "variants/v1" :feature-token-input "tokens/numeric-input" diff --git a/docs/technical-guide/getting-started/index.md b/docs/technical-guide/getting-started/index.md index 55fc229a67..dcbe7314f7 100644 --- a/docs/technical-guide/getting-started/index.md +++ b/docs/technical-guide/getting-started/index.md @@ -8,9 +8,7 @@ desc: Customize your Penpot instance today. Learn how to install with Elestio, D This guide explains how to get your own Penpot instance, running on a machine you control, to test it, use it by you or your team, or even customize and extend it any way you like. -If you need more context you can look at the post -about self-hosting in Penpot community. +For additional context, see the post How to self-host Penpot: A technical implementation guide on the Penpot blog. The experience stays the same, whether you use Penpot in the cloud diff --git a/docs/technical-guide/getting-started/recommended-settings.md b/docs/technical-guide/getting-started/recommended-settings.md index e6d1c9bfeb..4a36935b75 100644 --- a/docs/technical-guide/getting-started/recommended-settings.md +++ b/docs/technical-guide/getting-started/recommended-settings.md @@ -14,7 +14,7 @@ Keep in mind that database size doesn't grow strictly proportionally with user c # About Valkey / Redis requirements -"Valkey is mainly used for coordinating websocket notifications and, since Penpot 2.11, as a cache. Therefore, disk storage will not be necessary as it will use the instance's RAM. +Valkey is mainly used for coordinating websocket notifications and, since Penpot 2.11, as a cache. Therefore, disk storage will not be necessary as it will use the instance's RAM. To prevent the cache from hogging all the system's RAM usage, it is recommended to use two configuration parameters which, both in the docker-compose.yaml provided by Penpot and in the official Helm Chart, come with default parameters that should be sufficient for most deployments: diff --git a/frontend/playwright/data/render-wasm/get-file-fill-blend-blurs.json b/frontend/playwright/data/render-wasm/get-file-fill-blend-blurs.json new file mode 100644 index 0000000000..8359830e3c --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-fill-blend-blurs.json @@ -0,0 +1,147 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/objects-map", + "text-editor/v2", + "render-wasm/v1", + "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": "test_color_blending", + "~:revn": 78, + "~:modified-at": "~m1770820738388", + "~:vern": 0, + "~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0c", + "~: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": "~m1770741329904", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~ub15901d7-d46d-8056-8007-8d5e34fc1f0d" + ], + "~:pages-index": { + "~ub15901d7-d46d-8056-8007-8d5e34fc1f0d": { + "~:objects": { + "~#penpot/objects-map/v2": { + "~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297\",\"~udb80df91-a3a3-803b-8007-8e379b5fd50f\",\"~udb80df91-a3a3-803b-8007-8e38034ff7c8\",\"~udb80df91-a3a3-803b-8007-8e37a71c9d28\",\"~udb80df91-a3a3-803b-8007-8e384d8c53b9\",\"~udb80df91-a3a3-803b-8007-8e37c09b4084\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c4\",\"~u097859f1-ca3b-80ba-8007-8e8beb99a3f5\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c5\",\"~u097859f1-ca3b-80ba-8007-8e8bfca43303\",\"~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c6\"]]]", + "~u097859f1-ca3b-80ba-8007-8e8bfca43303": "[\"~#shape\",[\"^ \",\"~:y\",-637.0000057220459,\"~: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\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-637.000005722046]],[\"^<\",[\"^ \",\"~:x\",636.9999995231628,\"~:y\",-637.000005722046]],[\"^<\",[\"^ \",\"~:x\",636.9999995231628,\"~:y\",-337.00000858306885]],[\"^<\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-337.00000858306885]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u097859f1-ca3b-80ba-8007-8e8bfca43303\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",10,\"~:stroke-color\",\"#4bff00\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:outer\",\"^N\",10,\"^O\",\"#333fbd\",\"^P\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",336.9999895095825,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-637.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",336.9999895095825,\"~:y1\",-637.0000057220459,\"~:x2\",636.9999995231628,\"~:y2\",-337.00000858306885]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^11\",\"#ff0000\",\"^12\",1]],\"~:flip-x\",null,\"^W\",299.99999713897705,\"~:flip-y\",null]]", + "~udb80df91-a3a3-803b-8007-8e384d8c53b9": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~: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\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",1321.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",1321.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",751.0000142753124]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e37b7ddd15c\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"^@\",\"~udb80df91-a3a3-803b-8007-8e384d8c53b9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",20,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1]],\"~:x\",1021.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",1021.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",1321.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^W\",\"#ff0000\",\"^X\",1]],\"~:flip-x\",null,\"^Q\",300.0000162124634,\"~:flip-y\",null]]", + "~udb80df91-a3a3-803b-8007-8e379b5fd50f": "[\"~#shape\",[\"^ \",\"~:y\",82.00000368146124,\"~: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\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",986.7500224724797,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",986.7500224724797,\"~:y\",382.0000008204383]],[\"^<\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",382.0000008204383]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~udb80df91-a3a3-803b-8007-8e379b5fd50f\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",686.7500124588994,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",82.00000368146124,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",686.7500124588994,\"~:y1\",82.00000368146124,\"~:x2\",986.7500224724797,\"~:y2\",382.0000008204383]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^P\",\"#ff0000\",\"^Q\",1]],\"~:flip-x\",null,\"^J\",299.99999713897705,\"~:flip-y\",null]]", + "~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297": "[\"~#shape\",[\"^ \",\"~:y\",81.9999960520667,\"~: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\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",81.99999605206669]],[\"^<\",[\"^ \",\"~:x\",637.0000301018742,\"~:y\",81.99999605206669]],[\"^<\",[\"^ \",\"~:x\",637.0000301018742,\"~:y\",381.99999319104376]],[\"^<\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",381.99999319104376]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~u432cbb09-2ee7-80bf-8007-8d660b2f52ad\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",337.0000200882939,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",81.9999960520667,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",337.0000200882939,\"~:y1\",81.9999960520667,\"~:x2\",637.0000301018742,\"~:y2\",381.99999319104376]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^T\",\"#ff0000\",\"^U\",1]],\"~:flip-x\",null,\"^N\",299.99999713897705,\"~:flip-y\",null]]", + "~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7": "[\"~#shape\",[\"^ \",\"~:y\",-629.9999999999998,\"~: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\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1037,\"~:y\",-630]],[\"^<\",[\"^ \",\"~:x\",1337.0000100135803,\"~:y\",-630]],[\"^<\",[\"^ \",\"~:x\",1337.0000100135803,\"~:y\",-330.0000028610228]],[\"^<\",[\"^ \",\"~:x\",1037,\"~:y\",-330.0000028610228]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:center\",\"^N\",10,\"^O\",\"#4bff00\",\"^P\",1]],\"~:x\",1037,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1037,\"~:y\",-629.9999999999998,\"^8\",300.0000100135803,\"~:height\",299.999997138977,\"~:x1\",1037,\"~:y1\",-629.9999999999998,\"~:x2\",1337.0000100135803,\"~:y2\",-330.0000028610228]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^11\",\"#ff0000\",\"^12\",1]],\"~:flip-x\",null,\"^W\",299.999997138977,\"~:flip-y\",null]]", + "~u097859f1-ca3b-80ba-8007-8e8beb99a3f5": "[\"~#shape\",[\"^ \",\"~:y\",-626.0000057220459,\"~: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\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-626.000005722046]],[\"^<\",[\"^ \",\"~:x\",987.0000224113464,\"~:y\",-626.000005722046]],[\"^<\",[\"^ \",\"~:x\",987.0000224113464,\"~:y\",-326.00000858306885]],[\"^<\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-326.00000858306885]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u097859f1-ca3b-80ba-8007-8e8beb99a3f5\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"^M\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",687.0000123977661,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-626.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",687.0000123977661,\"~:y1\",-626.0000057220459,\"~:x2\",987.0000224113464,\"~:y2\",-326.00000858306885]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]", + "~udb80df91-a3a3-803b-8007-8e37a71c9d28": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~: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\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",637.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",637.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",751.0000142753124]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e37b7ddd15c\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"^@\",\"~udb80df91-a3a3-803b-8007-8e37a71c9d28\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",337.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",337.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",637.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^P\",\"#ff0000\",\"^Q\",1]],\"~:flip-x\",null,\"^J\",300.0000162124634,\"~:flip-y\",null]]", + "~u18522c44-655d-8050-8007-8e89f4bdc0c5": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~: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\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",637.000030040741,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",637.000030040741,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",12.999991416931152]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c5\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",337.00002002716064,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",337.00002002716064,\"~:y1\",-287.0000057220459,\"~:x2\",637.000030040741,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^10\",\"#ff0000\",\"^11\",1]],\"~:flip-x\",null,\"^V\",299.99999713897705,\"~:flip-y\",null]]", + "~udb80df91-a3a3-803b-8007-8e37c09b4084": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~: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\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",979.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",979.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",751.0000142753124]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~udb80df91-a3a3-803b-8007-8e37c09b4084\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",679.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",679.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",979.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^L\",\"#ff0000\",\"^M\",1]],\"~:flip-x\",null,\"^F\",300.0000162124634,\"~:flip-y\",null]]", + "~u18522c44-655d-8050-8007-8e89f4bdc0c4": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~: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\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",986.7500224113464,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",986.7500224113464,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",12.999991416931152]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c4\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",0.5],[\"^ \",\"^J\",\"^K\",\"^L\",\"^M\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",686.7500123977661,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",686.7500123977661,\"~:y1\",-287.0000057220459,\"~:x2\",986.7500224113464,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]", + "~udb80df91-a3a3-803b-8007-8e38034ff7c8": "[\"~#shape\",[\"^ \",\"~:y\",82.00000368146124,\"~: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\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",1336.5000148430852,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",1336.5000148430852,\"~:y\",382.0000008204383]],[\"^<\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",382.0000008204383]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~udb80df91-a3a3-803b-8007-8e38034ff7c8\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1]],\"~:x\",1036.5000048295049,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",82.00000368146124,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",1036.5000048295049,\"~:y1\",82.00000368146124,\"~:x2\",1336.5000148430852,\"~:y2\",382.0000008204383]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]", + "~u18522c44-655d-8050-8007-8e89f4bdc0c6": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~: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\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",1336.500014781952,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",1336.500014781952,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",12.999991416931152]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c6\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:outer\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",1036.5000047683716,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",1036.5000047683716,\"~:y1\",-287.0000057220459,\"~:x2\",1336.500014781952,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^10\",\"#ff0000\",\"^11\",1]],\"~:flip-x\",null,\"^V\",299.99999713897705,\"~:flip-y\",null]]" + } + }, + "~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0d", + "~:name": "Page 1" + } + }, + "~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0c", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/data/render-wasm/get-file-frame-strokes-opacity.json b/frontend/playwright/data/render-wasm/get-file-frame-strokes-opacity.json new file mode 100644 index 0000000000..36e807b1f1 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-frame-strokes-opacity.json @@ -0,0 +1,4853 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~ueba8fa2e-4140-8084-8005-448635d7a724", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 13", + "~:revn": 1, + "~:modified-at": "~m1770796980389", + "~:vern": 0, + "~:id": "~u3ef988d9-9ecf-8021-8007-8e3278ab57a3", + "~: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": "~u02e9633d-4ce7-80da-8007-70b9afc79817", + "~:created-at": "~m1770796973741", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~u3ef988d9-9ecf-8021-8007-8e3278ab57a4" + ], + "~:pages-index": { + "~u3ef988d9-9ecf-8021-8007-8e3278ab57a4": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~udc94224b-9b94-8052-8007-8e3279f43898", + "~udc94224b-9b94-8052-8007-8e3279f43899", + "~udc94224b-9b94-8052-8007-8e3279f4389a", + "~udc94224b-9b94-8052-8007-8e3279f4389b", + "~udc94224b-9b94-8052-8007-8e3279f4389c", + "~udc94224b-9b94-8052-8007-8e3279f4389d", + "~udc94224b-9b94-8052-8007-8e3279f4389e", + "~udc94224b-9b94-8052-8007-8e3279f4389f", + "~udc94224b-9b94-8052-8007-8e3279f438a0", + "~udc94224b-9b94-8052-8007-8e3279f438a1", + "~udc94224b-9b94-8052-8007-8e3279f438a2", + "~udc94224b-9b94-8052-8007-8e3279f438a3", + "~udc94224b-9b94-8052-8007-8e3279f438a4", + "~udc94224b-9b94-8052-8007-8e3279f438a5", + "~udc94224b-9b94-8052-8007-8e3279f438a6", + "~udc94224b-9b94-8052-8007-8e3279f438a7", + "~udc94224b-9b94-8052-8007-8e3279f438a8", + "~udc94224b-9b94-8052-8007-8e3279f438a9", + "~udc94224b-9b94-8052-8007-8e3279f438aa", + "~udc94224b-9b94-8052-8007-8e3279f438ab", + "~udc94224b-9b94-8052-8007-8e3279f438ac", + "~udc94224b-9b94-8052-8007-8e3279f438ad", + "~udc94224b-9b94-8052-8007-8e3279f438ae", + "~udc94224b-9b94-8052-8007-8e3279f438af", + "~udc94224b-9b94-8052-8007-8e3279f438b0", + "~udc94224b-9b94-8052-8007-8e3279f438b1", + "~udc94224b-9b94-8052-8007-8e3279f46c48", + "~udc94224b-9b94-8052-8007-8e3279f46c49", + "~udc94224b-9b94-8052-8007-8e3279f46c4a", + "~udc94224b-9b94-8052-8007-8e3279f46c4b", + "~udc94224b-9b94-8052-8007-8e3279f46c4c", + "~udc94224b-9b94-8052-8007-8e3279f46c4d", + "~udc94224b-9b94-8052-8007-8e3279f46c4e", + "~udc94224b-9b94-8052-8007-8e3279f46c4f", + "~udc94224b-9b94-8052-8007-8e3279f46c50", + "~udc94224b-9b94-8052-8007-8e3279f46c51", + "~udc94224b-9b94-8052-8007-8e3279f46c52", + "~udc94224b-9b94-8052-8007-8e3279f46c53", + "~udc94224b-9b94-8052-8007-8e3279f46c54", + "~udc94224b-9b94-8052-8007-8e3279f46c55", + "~udc94224b-9b94-8052-8007-8e3279f46c56", + "~udc94224b-9b94-8052-8007-8e3279f46c57", + "~udc94224b-9b94-8052-8007-8e3279f46c58", + "~udc94224b-9b94-8052-8007-8e3279f46c59", + "~udc94224b-9b94-8052-8007-8e3279f46c5a", + "~udc94224b-9b94-8052-8007-8e3279f46c5b" + ] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c4b": { + "~#shape": { + "~:y": 2233.99995642214, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 7176.00014339518, + "~:y": 2233.99995642214 + } + }, + { + "~#point": { + "~:x": 8312.00014339518, + "~:y": 2233.99995642214 + } + }, + { + "~#point": { + "~:x": 8312.00014339518, + "~:y": 3751.99995642214 + } + }, + { + "~#point": { + "~:x": 7176.00014339518, + "~:y": 3751.99995642214 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c4b", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 7176.00014339518, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 7176.00014339518, + "~:y": 2233.99995642214, + "~:width": 1136, + "~:height": 1518, + "~:x1": 7176.00014339518, + "~:y1": 2233.99995642214, + "~:x2": 8312.00014339518, + "~:y2": 3751.99995642214 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438ab": { + "~#shape": { + "~:y": 7577.99990389801, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2763.99995106668, + "~:y": 7577.99990389801 + } + }, + { + "~#point": { + "~:x": 3899.99995106668, + "~:y": 7577.99990389801 + } + }, + { + "~#point": { + "~:x": 3899.99995106668, + "~:y": 9095.99990389801 + } + }, + { + "~#point": { + "~:x": 2763.99995106668, + "~:y": 9095.99990389801 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438ab", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2763.99995106668, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 2763.99995106668, + "~:y": 7577.99990389801, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2763.99995106668, + "~:y1": 7577.99990389801, + "~:x2": 3899.99995106668, + "~:y2": 9095.99990389801 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c4a": { + "~#shape": { + "~:y": 602.999995249267, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 10026.000531694, + "~:y": 602.999995249267 + } + }, + { + "~#point": { + "~:x": 11162.000531694, + "~:y": 602.999995249267 + } + }, + { + "~#point": { + "~:x": 11162.000531694, + "~:y": 2120.99999524927 + } + }, + { + "~#point": { + "~:x": 10026.000531694, + "~:y": 2120.99999524927 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c4a", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 10026.000531694, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 10026.000531694, + "~:y": 602.999995249267, + "~:width": 1136, + "~:height": 1518, + "~:x1": 10026.000531694, + "~:y1": 602.999995249267, + "~:x2": 11162.000531694, + "~:y2": 2120.99999524927 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438aa": { + "~#shape": { + "~:y": 5946.99988168998, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5614.0002172952, + "~:y": 5946.99988168998 + } + }, + { + "~#point": { + "~:x": 6750.0002172952, + "~:y": 5946.99988168998 + } + }, + { + "~#point": { + "~:x": 6750.0002172952, + "~:y": 7464.99988168998 + } + }, + { + "~#point": { + "~:x": 5614.0002172952, + "~:y": 7464.99988168998 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438aa", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 5614.0002172952, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 5614.0002172952, + "~:y": 5946.99988168998, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5614.0002172952, + "~:y1": 5946.99988168998, + "~:x2": 6750.0002172952, + "~:y2": 7464.99988168998 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c49": { + "~#shape": { + "~:y": 597.999993155458, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 8595.99959200111, + "~:y": 597.999993155458 + } + }, + { + "~#point": { + "~:x": 9731.99959200111, + "~:y": 597.999993155458 + } + }, + { + "~#point": { + "~:x": 9731.99959200111, + "~:y": 2115.99999315546 + } + }, + { + "~#point": { + "~:x": 8595.99959200111, + "~:y": 2115.99999315546 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c49", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 8595.99959200111, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 8595.99959200111, + "~:y": 597.999993155458, + "~:width": 1136, + "~:height": 1518, + "~:x1": 8595.99959200111, + "~:y1": 597.999993155458, + "~:x2": 9731.99959200111, + "~:y2": 2115.99999315546 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438a9": { + "~#shape": { + "~:y": 5941.99991011375, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4183.00001002417, + "~:y": 5941.99991011375 + } + }, + { + "~#point": { + "~:x": 5319.00001002417, + "~:y": 5941.99991011375 + } + }, + { + "~#point": { + "~:x": 5319.00001002417, + "~:y": 7459.99991011375 + } + }, + { + "~#point": { + "~:x": 4183.00001002417, + "~:y": 7459.99991011375 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438a9", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 4183.00001002417, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 4183.00001002417, + "~:y": 5941.99991011375, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4183.00001002417, + "~:y1": 5941.99991011375, + "~:x2": 5319.00001002417, + "~:y2": 7459.99991011375 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c48": { + "~#shape": { + "~:y": 579.999984887378, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 7165.00012444247, + "~:y": 579.999984887378 + } + }, + { + "~#point": { + "~:x": 8301.00012444247, + "~:y": 579.999984887378 + } + }, + { + "~#point": { + "~:x": 8301.00012444247, + "~:y": 2097.99998488738 + } + }, + { + "~#point": { + "~:x": 7165.00012444247, + "~:y": 2097.99998488738 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c48", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 7165.00012444247, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 7165.00012444247, + "~:y": 579.999984887378, + "~:width": 1136, + "~:height": 1518, + "~:x1": 7165.00012444247, + "~:y1": 579.999984887378, + "~:x2": 8301.00012444247, + "~:y2": 2097.99998488738 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438a8": { + "~#shape": { + "~:y": 5923.99990184567, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2753.00005418428, + "~:y": 5923.99990184567 + } + }, + { + "~#point": { + "~:x": 3889.00005418428, + "~:y": 5923.99990184567 + } + }, + { + "~#point": { + "~:x": 3889.00005418428, + "~:y": 7441.99990184567 + } + }, + { + "~#point": { + "~:x": 2753.00005418428, + "~:y": 7441.99990184567 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438a8", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2753.00005418428, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 2753.00005418428, + "~:y": 5923.99990184567, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2753.00005418428, + "~:y1": 5923.99990184567, + "~:x2": 3889.00005418428, + "~:y2": 7441.99990184567 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c4f": { + "~#shape": { + "~:y": 4093.00017708734, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 8633.99957283692, + "~:y": 4093.00017708734 + } + }, + { + "~#point": { + "~:x": 9769.99957283693, + "~:y": 4093.00017708734 + } + }, + { + "~#point": { + "~:x": 9769.99957283693, + "~:y": 5611.00017708734 + } + }, + { + "~#point": { + "~:x": 8633.99957283692, + "~:y": 5611.00017708734 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c4f", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 8633.99957283692, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 8633.99957283692, + "~:y": 4093.00017708734, + "~:width": 1136, + "~:height": 1518, + "~:x1": 8633.99957283692, + "~:y1": 4093.00017708734, + "~:x2": 9769.99957283692, + "~:y2": 5611.00017708734 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438af": { + "~#shape": { + "~:y": 9437.0000024929, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4221.99999085999, + "~:y": 9437.0000024929 + } + }, + { + "~#point": { + "~:x": 5357.99999085999, + "~:y": 9437.0000024929 + } + }, + { + "~#point": { + "~:x": 5357.99999085999, + "~:y": 10955.0000024929 + } + }, + { + "~#point": { + "~:x": 4221.99999085999, + "~:y": 10955.0000024929 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438af", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 4221.99999085999, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 4221.99999085999, + "~:y": 9437.0000024929, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4221.99999085999, + "~:y1": 9437.0000024929, + "~:x2": 5357.99999085999, + "~:y2": 10955.0000024929 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c4e": { + "~#shape": { + "~:y": 4075.00007726653, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 7204.00016631344, + "~:y": 4075.00007726653 + } + }, + { + "~#point": { + "~:x": 8340.00016631344, + "~:y": 4075.00007726653 + } + }, + { + "~#point": { + "~:x": 8340.00016631344, + "~:y": 5593.00007726653 + } + }, + { + "~#point": { + "~:x": 7204.00016631344, + "~:y": 5593.00007726653 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c4e", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 7204.00016631344, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 7204.00016631344, + "~:y": 4075.00007726653, + "~:width": 1136, + "~:height": 1518, + "~:x1": 7204.00016631344, + "~:y1": 4075.00007726653, + "~:x2": 8340.00016631344, + "~:y2": 5593.00007726653 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438ae": { + "~#shape": { + "~:y": 9418.99990267208, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2791.99997398494, + "~:y": 9418.99990267208 + } + }, + { + "~#point": { + "~:x": 3927.99997398494, + "~:y": 9418.99990267208 + } + }, + { + "~#point": { + "~:x": 3927.99997398494, + "~:y": 10936.9999026721 + } + }, + { + "~#point": { + "~:x": 2791.99997398494, + "~:y": 10936.9999026721 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438ae", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2791.99997398494, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 2791.99997398494, + "~:y": 9418.99990267208, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2791.99997398494, + "~:y1": 9418.99990267208, + "~:x2": 3927.99997398494, + "~:y2": 10936.9999026721 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c4d": { + "~#shape": { + "~:y": 2256.99996678403, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 10037.0006116819, + "~:y": 2256.99996678403 + } + }, + { + "~#point": { + "~:x": 11173.0006116819, + "~:y": 2256.99996678403 + } + }, + { + "~#point": { + "~:x": 11173.0006116819, + "~:y": 3774.99996678403 + } + }, + { + "~#point": { + "~:x": 10037.0006116819, + "~:y": 3774.99996678403 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c4d", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 10037.0006116819, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 10037.0006116819, + "~:y": 2256.99996678403, + "~:width": 1136, + "~:height": 1518, + "~:x1": 10037.0006116819, + "~:y1": 2256.99996678403, + "~:x2": 11173.0006116819, + "~:y2": 3774.99996678403 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438ad": { + "~#shape": { + "~:y": 7600.9999142599, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5624.99980900182, + "~:y": 7600.9999142599 + } + }, + { + "~#point": { + "~:x": 6760.99980900182, + "~:y": 7600.9999142599 + } + }, + { + "~#point": { + "~:x": 6760.99980900182, + "~:y": 9118.9999142599 + } + }, + { + "~#point": { + "~:x": 5624.99980900182, + "~:y": 9118.9999142599 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438ad", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 5624.99980900182, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 5624.99980900182, + "~:y": 7600.9999142599, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5624.99980900182, + "~:y1": 7600.9999142599, + "~:x2": 6760.99980900182, + "~:y2": 9118.9999142599 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c4c": { + "~#shape": { + "~:y": 2251.99993417264, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 8607.00046544601, + "~:y": 2251.99993417264 + } + }, + { + "~#point": { + "~:x": 9743.00046544601, + "~:y": 2251.99993417264 + } + }, + { + "~#point": { + "~:x": 9743.00046544601, + "~:y": 3769.99993417264 + } + }, + { + "~#point": { + "~:x": 8607.00046544601, + "~:y": 3769.99993417264 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c4c", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 8607.00046544601, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 8607.00046544601, + "~:y": 2251.99993417264, + "~:width": 1136, + "~:height": 1518, + "~:x1": 8607.00046544601, + "~:y1": 2251.99993417264, + "~:x2": 9743.00046544601, + "~:y2": 3769.99993417264 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438ac": { + "~#shape": { + "~:y": 7596.00000371883, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4194.99990690657, + "~:y": 7596.00000371883 + } + }, + { + "~#point": { + "~:x": 5330.99990690657, + "~:y": 7596.00000371883 + } + }, + { + "~#point": { + "~:x": 5330.99990690657, + "~:y": 9114.00000371883 + } + }, + { + "~#point": { + "~:x": 4194.99990690657, + "~:y": 9114.00000371883 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438ac", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 4194.99990690657, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 4194.99990690657, + "~:y": 7596.00000371883, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4194.99990690657, + "~:y1": 7596.00000371883, + "~:x2": 5330.99990690657, + "~:y2": 9114.00000371883 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438a3": { + "~#shape": { + "~:y": 4037.99999319702, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2768.00003579942, + "~:y": 4037.99999319702 + } + }, + { + "~#point": { + "~:x": 3904.00003579942, + "~:y": 4037.99999319702 + } + }, + { + "~#point": { + "~:x": 3904.00003579942, + "~:y": 5555.99999319702 + } + }, + { + "~#point": { + "~:x": 2768.00003579942, + "~:y": 5555.99999319702 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438a3", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2768.00003579942, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 2768.00003579942, + "~:y": 4037.99999319702, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2768.00003579942, + "~:y1": 4037.99999319702, + "~:x2": 3904.00003579942, + "~:y2": 5555.99999319702 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438a2": { + "~#shape": { + "~:y": 2220.00000478483, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5601.00023702723, + "~:y": 2220.00000478483 + } + }, + { + "~#point": { + "~:x": 6737.00023702723, + "~:y": 2220.00000478483 + } + }, + { + "~#point": { + "~:x": 6737.00023702723, + "~:y": 3738.00000478483 + } + }, + { + "~#point": { + "~:x": 5601.00023702723, + "~:y": 3738.00000478483 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438a2", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 5601.00023702723, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 5601.00023702723, + "~:y": 2220.00000478483, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5601.00023702723, + "~:y1": 2220.00000478483, + "~:x2": 6737.00023702723, + "~:y2": 3738.00000478483 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438a1": { + "~#shape": { + "~:y": 2215.00009424376, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4171.00009079137, + "~:y": 2215.00009424376 + } + }, + { + "~#point": { + "~:x": 5307.00009079137, + "~:y": 2215.00009424376 + } + }, + { + "~#point": { + "~:x": 5307.00009079137, + "~:y": 3733.00009424376 + } + }, + { + "~#point": { + "~:x": 4171.00009079137, + "~:y": 3733.00009424376 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438a1", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 4171.00009079137, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 4171.00009079137, + "~:y": 2215.00009424376, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4171.00009079137, + "~:y1": 2215.00009424376, + "~:x2": 5307.00009079137, + "~:y2": 3733.00009424376 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438a0": { + "~#shape": { + "~:y": 2196.99999442295, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2740.00001288116, + "~:y": 2196.99999442295 + } + }, + { + "~#point": { + "~:x": 3876.00001288116, + "~:y": 2196.99999442295 + } + }, + { + "~#point": { + "~:x": 3876.00001288116, + "~:y": 3714.99999442295 + } + }, + { + "~#point": { + "~:x": 2740.00001288116, + "~:y": 3714.99999442295 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438a0", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2740.00001288116, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 2740.00001288116, + "~:y": 2196.99999442295, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2740.00001288116, + "~:y1": 2196.99999442295, + "~:x2": 3876.00001288116, + "~:y2": 3714.99999442295 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438a7": { + "~#shape": { + "~:y": 4035.00002246628, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 1552, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 835.999963926528, + "~:y": 4035.00002246628 + } + }, + { + "~#point": { + "~:x": 2387.99996392653, + "~:y": 4035.00002246628 + } + }, + { + "~#point": { + "~:x": 2387.99996392653, + "~:y": 5528.00002246628 + } + }, + { + "~#point": { + "~:x": 835.999963926528, + "~:y": 5528.00002246628 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438a7", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 835.999963926528, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 835.999963926528, + "~:y": 4035.00002246628, + "~:width": 1552, + "~:height": 1493, + "~:x1": 835.999963926528, + "~:y1": 4035.00002246628, + "~:x2": 2387.99996392653, + "~:y2": 5528.00002246628 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1493, + "~:flip-y": null + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438a6": { + "~#shape": { + "~:y": 2151.99990620884, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 1552, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 811.000017461204, + "~:y": 2151.99990620884 + } + }, + { + "~#point": { + "~:x": 2363.0000174612, + "~:y": 2151.99990620884 + } + }, + { + "~#point": { + "~:x": 2363.0000174612, + "~:y": 3644.99990620884 + } + }, + { + "~#point": { + "~:x": 811.000017461204, + "~:y": 3644.99990620884 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438a6", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 811.000017461204, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 811.000017461204, + "~:y": 2151.99990620884, + "~:width": 1552, + "~:height": 1493, + "~:x1": 811.000017461204, + "~:y1": 2151.99990620884, + "~:x2": 2363.0000174612, + "~:y2": 3644.99990620884 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1493, + "~:flip-y": null + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438a5": { + "~#shape": { + "~:y": 4061.00000355891, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5628.99989373455, + "~:y": 4061.00000355891 + } + }, + { + "~#point": { + "~:x": 6764.99989373455, + "~:y": 4061.00000355891 + } + }, + { + "~#point": { + "~:x": 6764.99989373455, + "~:y": 5579.00000355891 + } + }, + { + "~#point": { + "~:x": 5628.99989373455, + "~:y": 5579.00000355891 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438a5", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 5628.99989373455, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 5628.99989373455, + "~:y": 4061.00000355891, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5628.99989373455, + "~:y1": 4061.00000355891, + "~:x2": 6764.99989373455, + "~:y2": 5579.00000355891 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438a4": { + "~#shape": { + "~:y": 4056.00009301783, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4197.99993060416, + "~:y": 4056.00009301783 + } + }, + { + "~#point": { + "~:x": 5333.99993060416, + "~:y": 4056.00009301783 + } + }, + { + "~#point": { + "~:x": 5333.99993060416, + "~:y": 5574.00009301783 + } + }, + { + "~#point": { + "~:x": 4197.99993060416, + "~:y": 5574.00009301783 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438a4", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 4197.99993060416, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 4197.99993060416, + "~:y": 4056.00009301783, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4197.99993060416, + "~:y1": 4056.00009301783, + "~:x2": 5333.99993060416, + "~:y2": 5574.00009301783 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c5b": { + "~#shape": { + "~:y": 9478.99975296286, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 10089.000328645, + "~:y": 9478.99975296286 + } + }, + { + "~#point": { + "~:x": 11225.000328645, + "~:y": 9478.99975296286 + } + }, + { + "~#point": { + "~:x": 11225.000328645, + "~:y": 10996.9997529629 + } + }, + { + "~#point": { + "~:x": 10089.000328645, + "~:y": 10996.9997529629 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c5b", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 10089.000328645, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 10089.000328645, + "~:y": 9478.99975296286, + "~:width": 1136, + "~:height": 1518, + "~:x1": 10089.000328645, + "~:y1": 9478.99975296286, + "~:x2": 11225.000328645, + "~:y2": 10996.9997529629 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f4389b": { + "~#shape": { + "~:y": 11360.9999605895, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~:type": "root", + "~:key": "h8bbcy47mr", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "1o4afbmew95", + "~:font-size": "200", + "~:font-weight": "400", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "These three shapes should be visually equal (rect and frame with/without clipping)" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "1avwjyujtyp", + "~:font-size": "200", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "These three shapes should be visually equal (rect and frame with/without clipping)", + "~:width": 8675.73601102681, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 667.00003705989, + "~:y": 11360.9999605895 + } + }, + { + "~#point": { + "~:x": 9342.73604808669, + "~:y": 11360.9999605895 + } + }, + { + "~#point": { + "~:x": 9342.73604808669, + "~:y": 11756.3717851478 + } + }, + { + "~#point": { + "~:x": 667.00003705989, + "~:y": 11756.3717851478 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f4389b", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": 11610.490234375, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "200", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 6884.85986328125, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:x": 667.000061035156, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 258.98046875, + "~:text": "These three shapes should be visually equal (rect and frame with/without clipping)" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 667.000037059889, + "~:selrect": { + "~#rect": { + "~:x": 667.000037059889, + "~:y": 11360.9999605895, + "~:width": 8675.73601102681, + "~:height": 395.371824558355, + "~:x1": 667.000037059889, + "~:y1": 11360.9999605895, + "~:x2": 9342.7360480867, + "~:y2": 11756.3717851478 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 395.371824558355, + "~:flip-y": null + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c5a": { + "~#shape": { + "~:y": 9473.99984242178, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 8657.99963309276, + "~:y": 9473.99984242178 + } + }, + { + "~#point": { + "~:x": 9793.99963309276, + "~:y": 9473.99984242178 + } + }, + { + "~#point": { + "~:x": 9793.99963309276, + "~:y": 10991.9998424218 + } + }, + { + "~#point": { + "~:x": 8657.99963309276, + "~:y": 10991.9998424218 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c5a", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 8657.99963309276, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 8657.99963309276, + "~:y": 9473.99984242178, + "~:width": 1136, + "~:height": 1518, + "~:x1": 8657.99963309276, + "~:y1": 9473.99984242178, + "~:x2": 9793.99963309276, + "~:y2": 10991.9998424218 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f4389a": { + "~#shape": { + "~:y": 11976.0000077782, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 2928.51852662758, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 4411.00009977552, + "~:y": 11976.0000077782 + } + }, + { + "~#point": { + "~:x": 7339.5186264031, + "~:y": 11976.0000077782 + } + }, + { + "~#point": { + "~:x": 7339.5186264031, + "~:y": 14800.0845581234 + } + }, + { + "~#point": { + "~:x": 4411.00009977552, + "~:y": 14800.0845581234 + } + } + ], + "~:r2": 0, + "~:layout-item-h-sizing": "~:fix", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:layout-item-v-sizing": "~:fix", + "~:r3": 0, + "~:blur": { + "~:id": "~u5339c9ee-d6a8-80a6-8006-b710cd2ce7d6", + "~:type": "~:layer-blur", + "~:value": 20, + "~:hidden": false + }, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f4389a", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 100, + "~:stroke-color": "#ff0202", + "~:stroke-opacity": 1 + } + ], + "~:x": 4411.00009977552, + "~:proportion": 1, + "~:shadow": [ + { + "~:color": { + "~:color": "#010101", + "~:opacity": 1 + }, + "~:spread": 0, + "~:offset-y": 200, + "~:style": "~:drop-shadow", + "~:blur": 10, + "~:hidden": false, + "~:id": "~u59bee4d9-ff40-808f-8006-b6070426becf", + "~:offset-x": 200 + } + ], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 4411.00009977552, + "~:y": 11976.0000077782, + "~:width": 2928.51852662758, + "~:height": 2824.08455034524, + "~:x1": 4411.00009977552, + "~:y1": 11976.0000077782, + "~:x2": 7339.5186264031, + "~:y2": 14800.0845581234 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 2824.08455034524, + "~:flip-y": null + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c59": { + "~#shape": { + "~:y": 9455.99974260097, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 7228.00010449896, + "~:y": 9455.99974260097 + } + }, + { + "~#point": { + "~:x": 8364.00010449896, + "~:y": 9455.99974260097 + } + }, + { + "~#point": { + "~:x": 8364.00010449896, + "~:y": 10973.999742601 + } + }, + { + "~#point": { + "~:x": 7228.00010449896, + "~:y": 10973.999742601 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c59", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 7228.00010449896, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 7228.00010449896, + "~:y": 9455.99974260097, + "~:width": 1136, + "~:height": 1518, + "~:x1": 7228.00010449896, + "~:y1": 9455.99974260097, + "~:x2": 8364.00010449896, + "~:y2": 10973.999742601 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f43899": { + "~#shape": { + "~:y": 11975.9999310362, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 5.6843418860808e-14, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board without clipping", + "~:width": 2928.51855624499, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 8181.99983076588, + "~:y": 11975.999956165 + } + }, + { + "~#point": { + "~:x": 11110.5183870109, + "~:y": 11975.9999059074 + } + }, + { + "~#point": { + "~:x": 11110.5184363924, + "~:y": 14800.0843679262 + } + }, + { + "~#point": { + "~:x": 8181.99988014737, + "~:y": 14800.0844181838 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:layout-item-h-sizing": "~:fix", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:layout-item-v-sizing": "~:fix", + "~:r3": 0, + "~:blur": { + "~:id": "~u5339c9ee-d6a8-80a6-8006-b710cd2ce7d6", + "~:type": "~:layer-blur", + "~:value": 20, + "~:hidden": false + }, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f43899", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 100, + "~:stroke-color": "#ff0202", + "~:stroke-opacity": 1 + } + ], + "~:x": 8181.99985545664, + "~:proportion": 1, + "~:shadow": [ + { + "~:color": { + "~:color": "#010101", + "~:opacity": 1 + }, + "~:spread": 0, + "~:offset-y": 200, + "~:style": "~:drop-shadow", + "~:blur": 10, + "~:hidden": false, + "~:id": "~u59bee4d9-ff40-808f-8006-b6070426becf", + "~:offset-x": 200 + } + ], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 8181.99985545664, + "~:y": 11975.9999310362, + "~:width": 2928.51855624499, + "~:height": 2824.08446201884, + "~:x1": 8181.99985545664, + "~:y1": 11975.9999310362, + "~:x2": 11110.5184117016, + "~:y2": 14800.084393055 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 2824.08446201884, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c58": { + "~#shape": { + "~:y": 7638.00024247003, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 10061.0006719377, + "~:y": 7638.00024247003 + } + }, + { + "~#point": { + "~:x": 11197.0006719377, + "~:y": 7638.00024247003 + } + }, + { + "~#point": { + "~:x": 11197.0006719377, + "~:y": 9156.00024247003 + } + }, + { + "~#point": { + "~:x": 10061.0006719377, + "~:y": 9156.00024247003 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c58", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 10061.0006719377, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 10061.0006719377, + "~:y": 7638.00024247003, + "~:width": 1136, + "~:height": 1518, + "~:x1": 10061.0006719377, + "~:y1": 7638.00024247003, + "~:x2": 11197.0006719377, + "~:y2": 9156.00024247003 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f43898": { + "~#shape": { + "~:y": 11976.0000168284, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 5.6843418860808e-14, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board with clipping", + "~:width": 2928.51855624499, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 597, + "~:y": 11976.0000419572 + } + }, + { + "~#point": { + "~:x": 3525.51855624499, + "~:y": 11975.9999916996 + } + }, + { + "~#point": { + "~:x": 3525.51860562649, + "~:y": 14800.0844537184 + } + }, + { + "~#point": { + "~:x": 597.000049381491, + "~:y": 14800.084503976 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:layout-item-h-sizing": "~:fix", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:layout-item-v-sizing": "~:fix", + "~:r3": 0, + "~:blur": { + "~:id": "~u5339c9ee-d6a8-80a6-8006-b710cd2ce7d6", + "~:type": "~:layer-blur", + "~:value": 20, + "~:hidden": false + }, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f43898", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 100, + "~:stroke-color": "#ff0202", + "~:stroke-opacity": 1 + } + ], + "~:x": 597.000024690753, + "~:proportion": 1, + "~:shadow": [ + { + "~:color": { + "~:color": "#010101", + "~:opacity": 1 + }, + "~:spread": 0, + "~:offset-y": 200, + "~:style": "~:drop-shadow", + "~:blur": 10, + "~:hidden": false, + "~:id": "~u59bee4d9-ff40-808f-8006-b6070426becf", + "~:offset-x": 200 + } + ], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 597.000024690753, + "~:y": 11976.0000168284, + "~:width": 2928.51855624499, + "~:height": 2824.08446201884, + "~:x1": 597.000024690753, + "~:y1": 11976.0000168284, + "~:x2": 3525.51858093574, + "~:y2": 14800.0844788472 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 2824.08446201884, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f4389f": { + "~#shape": { + "~:y": 565.999972214917, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5590.00015703936, + "~:y": 565.999972214917 + } + }, + { + "~#point": { + "~:x": 6726.00015703936, + "~:y": 565.999972214917 + } + }, + { + "~#point": { + "~:x": 6726.00015703936, + "~:y": 2083.99997221492 + } + }, + { + "~#point": { + "~:x": 5590.00015703936, + "~:y": 2083.99997221492 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f4389f", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 5590.00015703936, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 5590.00015703936, + "~:y": 565.999972214917, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5590.00015703936, + "~:y1": 565.999972214917, + "~:x2": 6726.00015703936, + "~:y2": 2083.99997221492 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f4389e": { + "~#shape": { + "~:y": 561.000000638685, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4158.99994976834, + "~:y": 561.000000638685 + } + }, + { + "~#point": { + "~:x": 5294.99994976834, + "~:y": 561.000000638685 + } + }, + { + "~#point": { + "~:x": 5294.99994976834, + "~:y": 2079.00000063869 + } + }, + { + "~#point": { + "~:x": 4158.99994976834, + "~:y": 2079.00000063869 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f4389e", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 4158.99994976834, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 4158.99994976834, + "~:y": 561.000000638685, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4158.99994976834, + "~:y1": 561.000000638685, + "~:x2": 5294.99994976834, + "~:y2": 2079.00000063869 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f4389d": { + "~#shape": { + "~:y": 542.999992370606, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2728.99999392845, + "~:y": 542.999992370606 + } + }, + { + "~#point": { + "~:x": 3864.99999392845, + "~:y": 542.999992370606 + } + }, + { + "~#point": { + "~:x": 3864.99999392845, + "~:y": 2060.99999237061 + } + }, + { + "~#point": { + "~:x": 2728.99999392845, + "~:y": 2060.99999237061 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f4389d", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2728.99999392845, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 2728.99999392845, + "~:y": 542.999992370606, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2728.99999392845, + "~:y1": 542.999992370606, + "~:x2": 3864.99999392845, + "~:y2": 2060.99999237061 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f4389c": { + "~#shape": { + "~:y": 478, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 1552, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 811.999978669659, + "~:y": 478 + } + }, + { + "~#point": { + "~:x": 2363.99997866966, + "~:y": 478 + } + }, + { + "~#point": { + "~:x": 2363.99997866966, + "~:y": 1971 + } + }, + { + "~#point": { + "~:x": 811.999978669659, + "~:y": 1971 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f4389c", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 811.999978669659, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 811.999978669659, + "~:y": 478, + "~:width": 1552, + "~:height": 1493, + "~:x1": 811.999978669659, + "~:y1": 478, + "~:x2": 2363.99997866966, + "~:y2": 1971 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1493, + "~:flip-y": null + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c53": { + "~#shape": { + "~:y": 5960.99974177455, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 7189.00006262799, + "~:y": 5960.99974177455 + } + }, + { + "~#point": { + "~:x": 8325.00006262799, + "~:y": 5960.99974177455 + } + }, + { + "~#point": { + "~:x": 8325.00006262799, + "~:y": 7478.99974177455 + } + }, + { + "~#point": { + "~:x": 7189.00006262799, + "~:y": 7478.99974177455 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c53", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 7189.00006262799, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 7189.00006262799, + "~:y": 5960.99974177455, + "~:width": 1136, + "~:height": 1518, + "~:x1": 7189.00006262799, + "~:y1": 5960.99974177455, + "~:x2": 8325.00006262799, + "~:y2": 7478.99974177455 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c52": { + "~#shape": { + "~:y": 9458.99999972426, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 1552, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 694.999972370234, + "~:y": 9458.99999972426 + } + }, + { + "~#point": { + "~:x": 2246.99997237023, + "~:y": 9458.99999972426 + } + }, + { + "~#point": { + "~:x": 2246.99997237023, + "~:y": 10951.9999997243 + } + }, + { + "~#point": { + "~:x": 694.999972370234, + "~:y": 10951.9999997243 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c52", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 694.999972370234, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 694.999972370234, + "~:y": 9458.99999972426, + "~:width": 1552, + "~:height": 1493, + "~:x1": 694.999972370234, + "~:y1": 9458.99999972426, + "~:x2": 2246.99997237023, + "~:y2": 10951.9999997243 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1493, + "~:flip-y": null + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c51": { + "~#shape": { + "~:y": 7576.00000553714, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 1552, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 670.000025904909, + "~:y": 7576.00000553714 + } + }, + { + "~#point": { + "~:x": 2222.00002590491, + "~:y": 7576.00000553714 + } + }, + { + "~#point": { + "~:x": 2222.00002590491, + "~:y": 9069.00000553714 + } + }, + { + "~#point": { + "~:x": 670.000025904909, + "~:y": 9069.00000553714 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c51", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 670.000025904909, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 670.000025904909, + "~:y": 7576.00000553714, + "~:width": 1552, + "~:height": 1493, + "~:x1": 670.000025904909, + "~:y1": 7576.00000553714, + "~:x2": 2222.00002590491, + "~:y2": 9069.00000553714 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1493, + "~:flip-y": null + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438b1": { + "~#shape": { + "~:y": 5901.99997725798, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 1552, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 670.999987113365, + "~:y": 5901.99997725798 + } + }, + { + "~#point": { + "~:x": 2222.99998711336, + "~:y": 5901.99997725798 + } + }, + { + "~#point": { + "~:x": 2222.99998711336, + "~:y": 7394.99997725798 + } + }, + { + "~#point": { + "~:x": 670.999987113365, + "~:y": 7394.99997725798 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438b1", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 670.999987113365, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 670.999987113365, + "~:y": 5901.99997725798, + "~:width": 1552, + "~:height": 1493, + "~:x1": 670.999987113365, + "~:y1": 5901.99997725798, + "~:x2": 2222.99998711336, + "~:y2": 7394.99997725798 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1493, + "~:flip-y": null + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c50": { + "~#shape": { + "~:y": 4098.00008762842, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 10064.9997801079, + "~:y": 4098.00008762842 + } + }, + { + "~#point": { + "~:x": 11200.9997801079, + "~:y": 4098.00008762842 + } + }, + { + "~#point": { + "~:x": 11200.9997801079, + "~:y": 5616.00008762842 + } + }, + { + "~#point": { + "~:x": 10064.9997801079, + "~:y": 5616.00008762842 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c50", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 10064.9997801079, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 10064.9997801079, + "~:y": 4098.00008762842, + "~:width": 1136, + "~:height": 1518, + "~:x1": 10064.9997801079, + "~:y1": 4098.00008762842, + "~:x2": 11200.9997801079, + "~:y2": 5616.00008762842 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f438b0": { + "~#shape": { + "~:y": 9441.99991303398, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5652.99995399039, + "~:y": 9441.99991303398 + } + }, + { + "~#point": { + "~:x": 6788.99995399039, + "~:y": 9441.99991303398 + } + }, + { + "~#point": { + "~:x": 6788.99995399039, + "~:y": 10959.999913034 + } + }, + { + "~#point": { + "~:x": 5652.99995399039, + "~:y": 10959.999913034 + } + } + ], + "~:r2": 0, + "~:show-content": false, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f438b0", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 5652.99995399039, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 5652.99995399039, + "~:y": 9441.99991303398, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5652.99995399039, + "~:y1": 9441.99991303398, + "~:x2": 6788.99995399039, + "~:y2": 10959.999913034 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c57": { + "~#shape": { + "~:y": 7632.99984364771, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 8630.99954913934, + "~:y": 7632.99984364771 + } + }, + { + "~#point": { + "~:x": 9766.99954913934, + "~:y": 7632.99984364771 + } + }, + { + "~#point": { + "~:x": 9766.99954913934, + "~:y": 9150.99984364771 + } + }, + { + "~#point": { + "~:x": 8630.99954913934, + "~:y": 9150.99984364771 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c57", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 8630.99954913934, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 8630.99954913934, + "~:y": 7632.99984364771, + "~:width": 1136, + "~:height": 1518, + "~:x1": 8630.99954913934, + "~:y1": 7632.99984364771, + "~:x2": 9766.99954913934, + "~:y2": 9150.99984364771 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c56": { + "~#shape": { + "~:y": 7615.00023210814, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 7200.0000815807, + "~:y": 7615.00023210814 + } + }, + { + "~#point": { + "~:x": 8336.0000815807, + "~:y": 7615.00023210814 + } + }, + { + "~#point": { + "~:x": 8336.0000815807, + "~:y": 9133.00023210814 + } + }, + { + "~#point": { + "~:x": 7200.0000815807, + "~:y": 9133.00023210814 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c56", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 7200.0000815807, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 7200.0000815807, + "~:y": 7615.00023210814, + "~:width": 1136, + "~:height": 1518, + "~:x1": 7200.0000815807, + "~:y1": 7615.00023210814, + "~:x2": 8336.0000815807, + "~:y2": 9133.00023210814 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c55": { + "~#shape": { + "~:y": 5984.00020990012, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "20", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 10050.0005919498, + "~:y": 5984.00020990012 + } + }, + { + "~#point": { + "~:x": 11186.0005919498, + "~:y": 5984.00020990012 + } + }, + { + "~#point": { + "~:x": 11186.0005919498, + "~:y": 7502.00020990012 + } + }, + { + "~#point": { + "~:x": 10050.0005919498, + "~:y": 7502.00020990012 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c55", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.2 + } + ], + "~:x": 10050.0005919498, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 10050.0005919498, + "~:y": 5984.00020990012, + "~:width": 1136, + "~:height": 1518, + "~:x1": 10050.0005919498, + "~:y1": 5984.00020990012, + "~:x2": 11186.0005919498, + "~:y2": 7502.00020990012 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~udc94224b-9b94-8052-8007-8e3279f46c54": { + "~#shape": { + "~:y": 5978.99975004263, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "60", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 8618.99965225694, + "~:y": 5978.99975004263 + } + }, + { + "~#point": { + "~:x": 9754.99965225694, + "~:y": 5978.99975004263 + } + }, + { + "~#point": { + "~:x": 9754.99965225694, + "~:y": 7496.99975004263 + } + }, + { + "~#point": { + "~:x": 8618.99965225694, + "~:y": 7496.99975004263 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~udc94224b-9b94-8052-8007-8e3279f46c54", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 8618.99965225694, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 8618.99965225694, + "~:y": 5978.99975004263, + "~:width": 1136, + "~:height": 1518, + "~:x1": 8618.99965225694, + "~:y1": 5978.99975004263, + "~:x2": 9754.99965225694, + "~:y2": 7496.99975004263 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:id": "~u3ef988d9-9ecf-8021-8007-8e3278ab57a4", + "~:name": "Page 1" + } + }, + "~:id": "~u3ef988d9-9ecf-8021-8007-8e3278ab57a3", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/data/render-wasm/get-file-stroke-styles.json b/frontend/playwright/data/render-wasm/get-file-stroke-styles.json new file mode 100644 index 0000000000..3a2fdf0d7e --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-stroke-styles.json @@ -0,0 +1,2868 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~ueba8fa2e-4140-8084-8005-448635d7a724", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 4", + "~:revn": 17, + "~:modified-at": "~m1770721662110", + "~:vern": 0, + "~:id": "~ub888b894-3697-80d3-8006-51cc8a55c200", + "~: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": "~u02e9633d-4ce7-80da-8007-70b9afc79817", + "~:created-at": "~m1770721374141", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~u3ef988d9-9ecf-8021-8007-8d1214ef7fac" + ], + "~:pages-index": { + "~u3ef988d9-9ecf-8021-8007-8d1214ef7fac": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~u31bdb02f-8fc7-80ef-8007-8d121723d64a", + "~u31bdb02f-8fc7-80ef-8007-8d121723d64b", + "~u31bdb02f-8fc7-80ef-8007-8d121723d64c", + "~u31bdb02f-8fc7-80ef-8007-8d121723d64d", + "~u31bdb02f-8fc7-80ef-8007-8d121723d64e", + "~u31bdb02f-8fc7-80ef-8007-8d12172417fe", + "~u31bdb02f-8fc7-80ef-8007-8d12172417ff", + "~u31bdb02f-8fc7-80ef-8007-8d1217241800", + "~u31bdb02f-8fc7-80ef-8007-8d1217241801", + "~u31bdb02f-8fc7-80ef-8007-8d1217241807", + "~u31bdb02f-8fc7-80ef-8007-8d1217241808", + "~u31bdb02f-8fc7-80ef-8007-8d1217241809", + "~u31bdb02f-8fc7-80ef-8007-8d121724180a", + "~u31bdb02f-8fc7-80ef-8007-8d121724180b", + "~u31bdb02f-8fc7-80ef-8007-8d121724180c", + "~u31bdb02f-8fc7-80ef-8007-8d121724180d", + "~u31bdb02f-8fc7-80ef-8007-8d121724180e", + "~u31bdb02f-8fc7-80ef-8007-8d121724180f", + "~u31bdb02f-8fc7-80ef-8007-8d127b7d18a9", + "~u31bdb02f-8fc7-80ef-8007-8d12842a19c9", + "~u31bdb02f-8fc7-80ef-8007-8d12870a4bca", + "~u31bdb02f-8fc7-80ef-8007-8d12947c0f8d", + "~u31bdb02f-8fc7-80ef-8007-8d12947c0f8e", + "~u31bdb02f-8fc7-80ef-8007-8d12947c0f8f", + "~u31bdb02f-8fc7-80ef-8007-8d12991c06e5", + "~u31bdb02f-8fc7-80ef-8007-8d12991c628f", + "~u31bdb02f-8fc7-80ef-8007-8d12991c6290" + ] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d12991c6290": { + "~#shape": { + "~:y": 4625.99980021774, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 1142, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 9942.00018860513, + "~:y": 4625.99980021774 + } + }, + { + "~#point": { + "~:x": 11084.0001886051, + "~:y": 4625.99980021774 + } + }, + { + "~#point": { + "~:x": 11084.0001886051, + "~:y": 5834.99980021774 + } + }, + { + "~#point": { + "~:x": 9942.00018860513, + "~:y": 5834.99980021774 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d12991c6290", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:outer", + "~:stroke-style": "~:mixed", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 80 + } + ], + "~:x": 9942.00018860513, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 9942.00018860513, + "~:y": 4625.99980021774, + "~:width": 1142, + "~:height": 1209, + "~:x1": 9942.00018860513, + "~:y1": 4625.99980021774, + "~:x2": 11084.0001886051, + "~:y2": 5834.99980021774 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1209, + "~:flip-y": null + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d12172417fe": { + "~#shape": { + "~:y": 4660.00005326456, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4201.00000695116, + "~:y": 4660.00005326456 + } + }, + { + "~#point": { + "~:x": 5337.00000695116, + "~:y": 4660.00005326456 + } + }, + { + "~#point": { + "~:x": 5337.00000695116, + "~:y": 6178.00005326456 + } + }, + { + "~#point": { + "~:x": 4201.00000695116, + "~:y": 6178.00005326456 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d12172417fe", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dashed", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 4201.00000695116, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 4201.00000695116, + "~:y": 4660.00005326456, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4201.00000695116, + "~:y1": 4660.00005326456, + "~:x2": 5337.00000695116, + "~:y2": 6178.00005326456 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d12172417ff": { + "~#shape": { + "~:y": 1179.99994502052, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5718.99988541563, + "~:y": 1179.99994502052 + } + }, + { + "~#point": { + "~:x": 6854.99988541563, + "~:y": 1179.99994502052 + } + }, + { + "~#point": { + "~:x": 6854.99988541563, + "~:y": 2697.99994502052 + } + }, + { + "~#point": { + "~:x": 5718.99988541563, + "~:y": 2697.99994502052 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d12172417ff", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:mixed", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 5718.99988541563, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 5718.99988541563, + "~:y": 1179.99994502052, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5718.99988541563, + "~:y1": 1179.99994502052, + "~:x2": 6854.99988541563, + "~:y2": 2697.99994502052 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d1217241800": { + "~#shape": { + "~:y": 2833.99994707286, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5729.99990436834, + "~:y": 2833.99994707286 + } + }, + { + "~#point": { + "~:x": 6865.99990436834, + "~:y": 2833.99994707286 + } + }, + { + "~#point": { + "~:x": 6865.99990436834, + "~:y": 4351.99994707286 + } + }, + { + "~#point": { + "~:x": 5729.99990436834, + "~:y": 4351.99994707286 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d1217241800", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:mixed", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 5729.99990436834, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 5729.99990436834, + "~:y": 2833.99994707286, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5729.99990436834, + "~:y1": 2833.99994707286, + "~:x2": 6865.99990436834, + "~:y2": 4351.99994707286 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d1217241801": { + "~#shape": { + "~:y": 4674.99988481178, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5757.9999272866, + "~:y": 4674.99988481178 + } + }, + { + "~#point": { + "~:x": 6893.9999272866, + "~:y": 4674.99988481178 + } + }, + { + "~#point": { + "~:x": 6893.9999272866, + "~:y": 6192.99988481178 + } + }, + { + "~#point": { + "~:x": 5757.9999272866, + "~:y": 6192.99988481178 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d1217241801", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:mixed", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 5757.9999272866, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 5757.9999272866, + "~:y": 4674.99988481178, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5757.9999272866, + "~:y1": 4674.99988481178, + "~:x2": 6893.9999272866, + "~:y2": 6192.99988481178 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d1217241807": { + "~#shape": { + "~:y": 6611.0001641418, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2461.00003056651, + "~:y": 6611.0001641418 + } + }, + { + "~#point": { + "~:x": 3597.00003056651, + "~:y": 6611.0001641418 + } + }, + { + "~#point": { + "~:x": 3597.00003056651, + "~:y": 8129.0001641418 + } + }, + { + "~#point": { + "~:x": 2461.00003056651, + "~:y": 8129.0001641418 + } + } + ], + "~:r2": 100, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 100, + "~:r1": 100, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d1217241807", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dotted", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2461.00003056651, + "~:proportion": 1, + "~:r4": 100, + "~:selrect": { + "~#rect": { + "~:x": 2461.00003056651, + "~:y": 6611.0001641418, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2461.00003056651, + "~:y1": 6611.0001641418, + "~:x2": 3597.00003056651, + "~:y2": 8129.0001641418 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d12991c06e5": { + "~#shape": { + "~:y": 1229.9998859317, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 1142, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 9961.0002975198, + "~:y": 1229.9998859317 + } + }, + { + "~#point": { + "~:x": 11103.0002975198, + "~:y": 1229.9998859317 + } + }, + { + "~#point": { + "~:x": 11103.0002975198, + "~:y": 2438.9998859317 + } + }, + { + "~#point": { + "~:x": 9961.0002975198, + "~:y": 2438.9998859317 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d12991c06e5", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:mixed", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 80 + } + ], + "~:x": 9961.0002975198, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 9961.0002975198, + "~:y": 1229.9998859317, + "~:width": 1142, + "~:height": 1209, + "~:x1": 9961.0002975198, + "~:y1": 1229.9998859317, + "~:x2": 11103.0002975198, + "~:y2": 2438.9998859317 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1209, + "~:flip-y": null + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d121724180a": { + "~#shape": { + "~:y": 6567.00015990636, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4154.99999732382, + "~:y": 6567.00015990636 + } + }, + { + "~#point": { + "~:x": 5290.99999732382, + "~:y": 6567.00015990636 + } + }, + { + "~#point": { + "~:x": 5290.99999732382, + "~:y": 8085.00015990636 + } + }, + { + "~#point": { + "~:x": 4154.99999732382, + "~:y": 8085.00015990636 + } + } + ], + "~:r2": 100, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 100, + "~:r1": 100, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d121724180a", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dashed", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 4154.99999732382, + "~:proportion": 1, + "~:r4": 100, + "~:selrect": { + "~#rect": { + "~:x": 4154.99999732382, + "~:y": 6567.00015990636, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4154.99999732382, + "~:y1": 6567.00015990636, + "~:x2": 5290.99999732382, + "~:y2": 8085.00015990636 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d12870a4bca": { + "~#shape": { + "~:y": 4648.00004398575, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 1142, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 7195.99970797987, + "~:y": 4648.00004398575 + } + }, + { + "~#point": { + "~:x": 8337.99970797987, + "~:y": 4648.00004398575 + } + }, + { + "~#point": { + "~:x": 8337.99970797987, + "~:y": 5857.00004398575 + } + }, + { + "~#point": { + "~:x": 7195.99970797987, + "~:y": 5857.00004398575 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d12870a4bca", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:outer", + "~:stroke-style": "~:dotted", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 80 + } + ], + "~:x": 7195.99970797987, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 7195.99970797987, + "~:y": 4648.00004398575, + "~:width": 1142, + "~:height": 1209, + "~:x1": 7195.99970797987, + "~:y1": 4648.00004398575, + "~:x2": 8337.99970797987, + "~:y2": 5857.00004398575 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1209, + "~:flip-y": null + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d121723d64a": { + "~#shape": { + "~:y": 1208.99993460327, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2467.99999832288, + "~:y": 1208.99993460327 + } + }, + { + "~#point": { + "~:x": 3603.99999832288, + "~:y": 1208.99993460327 + } + }, + { + "~#point": { + "~:x": 3603.99999832288, + "~:y": 2726.99993460327 + } + }, + { + "~#point": { + "~:x": 2467.99999832288, + "~:y": 2726.99993460327 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d121723d64a", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dotted", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2467.99999832288, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 2467.99999832288, + "~:y": 1208.99993460327, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2467.99999832288, + "~:y1": 1208.99993460327, + "~:x2": 3603.99999832288, + "~:y2": 2726.99993460327 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d121724180b": { + "~#shape": { + "~:y": 8221.00028402901, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4166.00001627654, + "~:y": 8221.00028402901 + } + }, + { + "~#point": { + "~:x": 5302.00001627654, + "~:y": 8221.00028402901 + } + }, + { + "~#point": { + "~:x": 5302.00001627654, + "~:y": 9739.00028402901 + } + }, + { + "~#point": { + "~:x": 4166.00001627654, + "~:y": 9739.00028402901 + } + } + ], + "~:r2": 100, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 100, + "~:r1": 100, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d121724180b", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dashed", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 4166.00001627654, + "~:proportion": 1, + "~:r4": 100, + "~:selrect": { + "~#rect": { + "~:x": 4166.00001627654, + "~:y": 8221.00028402901, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4166.00001627654, + "~:y1": 8221.00028402901, + "~:x2": 5302.00001627654, + "~:y2": 9739.00028402901 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d121723d64b": { + "~#shape": { + "~:y": 2862.99993665561, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2392.00001727559, + "~:y": 2862.99993665561 + } + }, + { + "~#point": { + "~:x": 3528.00001727559, + "~:y": 2862.99993665561 + } + }, + { + "~#point": { + "~:x": 3528.00001727559, + "~:y": 4380.99993665561 + } + }, + { + "~#point": { + "~:x": 2392.00001727559, + "~:y": 4380.99993665561 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d121723d64b", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dotted", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2392.00001727559, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 2392.00001727559, + "~:y": 2862.99993665561, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2392.00001727559, + "~:y1": 2862.99993665561, + "~:x2": 3528.00001727559, + "~:y2": 4380.99993665561 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d1217241808": { + "~#shape": { + "~:y": 8265.00016619414, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2385.00004951922, + "~:y": 8265.00016619414 + } + }, + { + "~#point": { + "~:x": 3521.00004951922, + "~:y": 8265.00016619414 + } + }, + { + "~#point": { + "~:x": 3521.00004951922, + "~:y": 9783.00016619414 + } + }, + { + "~#point": { + "~:x": 2385.00004951922, + "~:y": 9783.00016619414 + } + } + ], + "~:r2": 100, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 100, + "~:r1": 100, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d1217241808", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dotted", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2385.00004951922, + "~:proportion": 1, + "~:r4": 100, + "~:selrect": { + "~#rect": { + "~:x": 2385.00004951922, + "~:y": 8265.00016619414, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2385.00004951922, + "~:y1": 8265.00016619414, + "~:x2": 3521.00004951922, + "~:y2": 9783.00016619414 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d12842a19c9": { + "~#shape": { + "~:y": 2954.99998345216, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 1142, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 7195.99973728343, + "~:y": 2954.99998345216 + } + }, + { + "~#point": { + "~:x": 8337.99973728343, + "~:y": 2954.99998345216 + } + }, + { + "~#point": { + "~:x": 8337.99973728343, + "~:y": 4163.99998345216 + } + }, + { + "~#point": { + "~:x": 7195.99973728343, + "~:y": 4163.99998345216 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d12842a19c9", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:center", + "~:stroke-style": "~:dotted", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 80 + } + ], + "~:x": 7195.99973728343, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 7195.99973728343, + "~:y": 2954.99998345216, + "~:width": 1142, + "~:height": 1209, + "~:x1": 7195.99973728343, + "~:y1": 2954.99998345216, + "~:x2": 8337.99973728343, + "~:y2": 4163.99998345216 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1209, + "~:flip-y": null + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d127b7d18a9": { + "~#shape": { + "~:y": 1251.99988555908, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 1142, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 7214.99981689453, + "~:y": 1251.99988555908 + } + }, + { + "~#point": { + "~:x": 8356.99981689453, + "~:y": 1251.99988555908 + } + }, + { + "~#point": { + "~:x": 8356.99981689453, + "~:y": 2460.99988555908 + } + }, + { + "~#point": { + "~:x": 7214.99981689453, + "~:y": 2460.99988555908 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d127b7d18a9", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:dotted", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 80 + } + ], + "~:x": 7214.99981689453, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 7214.99981689453, + "~:y": 1251.99988555908, + "~:width": 1142, + "~:height": 1209, + "~:x1": 7214.99981689453, + "~:y1": 1251.99988555908, + "~:x2": 8356.99981689453, + "~:y2": 2460.99988555908 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1209, + "~:flip-y": null + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d1217241809": { + "~#shape": { + "~:y": 10105.999676687, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2413.00007243748, + "~:y": 10105.999676687 + } + }, + { + "~#point": { + "~:x": 3549.00007243748, + "~:y": 10105.999676687 + } + }, + { + "~#point": { + "~:x": 3549.00007243748, + "~:y": 11623.999676687 + } + }, + { + "~#point": { + "~:x": 2413.00007243748, + "~:y": 11623.999676687 + } + } + ], + "~:r2": 100, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 100, + "~:r1": 100, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d1217241809", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dotted", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2413.00007243748, + "~:proportion": 1, + "~:r4": 100, + "~:selrect": { + "~#rect": { + "~:x": 2413.00007243748, + "~:y": 10105.999676687, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2413.00007243748, + "~:y1": 10105.999676687, + "~:x2": 3549.00007243748, + "~:y2": 11623.999676687 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d121724180e": { + "~#shape": { + "~:y": 8236.00017661139, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5723.00005868229, + "~:y": 8236.00017661139 + } + }, + { + "~#point": { + "~:x": 6859.00005868229, + "~:y": 8236.00017661139 + } + }, + { + "~#point": { + "~:x": 6859.00005868229, + "~:y": 9754.00017661139 + } + }, + { + "~#point": { + "~:x": 5723.00005868229, + "~:y": 9754.00017661139 + } + } + ], + "~:r2": 100, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 100, + "~:r1": 100, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d121724180e", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:mixed", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 5723.00005868229, + "~:proportion": 1, + "~:r4": 100, + "~:selrect": { + "~#rect": { + "~:x": 5723.00005868229, + "~:y": 8236.00017661139, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5723.00005868229, + "~:y1": 8236.00017661139, + "~:x2": 6859.00005868229, + "~:y2": 9754.00017661139 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d12947c0f8e": { + "~#shape": { + "~:y": 2932.99998295879, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 1142, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 8537.00069889493, + "~:y": 2932.99998295879 + } + }, + { + "~#point": { + "~:x": 9679.00069889493, + "~:y": 2932.99998295879 + } + }, + { + "~#point": { + "~:x": 9679.00069889493, + "~:y": 4141.99998295879 + } + }, + { + "~#point": { + "~:x": 8537.00069889493, + "~:y": 4141.99998295879 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d12947c0f8e", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:center", + "~:stroke-style": "~:dashed", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 80 + } + ], + "~:x": 8537.00069889493, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 8537.00069889493, + "~:y": 2932.99998295879, + "~:width": 1142, + "~:height": 1209, + "~:x1": 8537.00069889493, + "~:y1": 2932.99998295879, + "~:x2": 9679.00069889493, + "~:y2": 4141.99998295879 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1209, + "~:flip-y": null + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d121723d64e": { + "~#shape": { + "~:y": 2819.00005449049, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4172.9999840329, + "~:y": 2819.00005449049 + } + }, + { + "~#point": { + "~:x": 5308.9999840329, + "~:y": 2819.00005449049 + } + }, + { + "~#point": { + "~:x": 5308.9999840329, + "~:y": 4337.00005449049 + } + }, + { + "~#point": { + "~:x": 4172.9999840329, + "~:y": 4337.00005449049 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d121723d64e", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dashed", + "~:stroke-alignment": "~:center", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 4172.9999840329, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 4172.9999840329, + "~:y": 2819.00005449049, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4172.9999840329, + "~:y1": 2819.00005449049, + "~:x2": 5308.9999840329, + "~:y2": 4337.00005449049 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d121724180f": { + "~#shape": { + "~:y": 10077.0006026316, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5750.99995953023, + "~:y": 10077.0006026316 + } + }, + { + "~#point": { + "~:x": 6886.99995953023, + "~:y": 10077.0006026316 + } + }, + { + "~#point": { + "~:x": 6886.99995953023, + "~:y": 11595.0006026316 + } + }, + { + "~#point": { + "~:x": 5750.99995953023, + "~:y": 11595.0006026316 + } + } + ], + "~:r2": 100, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 100, + "~:r1": 100, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d121724180f", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:mixed", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 5750.99995953023, + "~:proportion": 1, + "~:r4": 100, + "~:selrect": { + "~#rect": { + "~:x": 5750.99995953023, + "~:y": 10077.0006026316, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5750.99995953023, + "~:y1": 10077.0006026316, + "~:x2": 6886.99995953023, + "~:y2": 11595.0006026316 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d12947c0f8f": { + "~#shape": { + "~:y": 4625.99979935176, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 1142, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 8537.00066959136, + "~:y": 4625.99979935176 + } + }, + { + "~#point": { + "~:x": 9679.00066959136, + "~:y": 4625.99979935176 + } + }, + { + "~#point": { + "~:x": 9679.00066959136, + "~:y": 5834.99979935176 + } + }, + { + "~#point": { + "~:x": 8537.00066959136, + "~:y": 5834.99979935176 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d12947c0f8f", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:outer", + "~:stroke-style": "~:dashed", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 80 + } + ], + "~:x": 8537.00066959136, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 8537.00066959136, + "~:y": 4625.99979935176, + "~:width": 1142, + "~:height": 1209, + "~:x1": 8537.00066959136, + "~:y1": 4625.99979935176, + "~:x2": 9679.00066959136, + "~:y2": 5834.99979935176 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1209, + "~:flip-y": null + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d12991c628f": { + "~#shape": { + "~:y": 2932.99998382477, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 1142, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 9942.0002179087, + "~:y": 2932.99998382477 + } + }, + { + "~#point": { + "~:x": 11084.0002179087, + "~:y": 2932.99998382477 + } + }, + { + "~#point": { + "~:x": 11084.0002179087, + "~:y": 4141.99998382477 + } + }, + { + "~#point": { + "~:x": 9942.0002179087, + "~:y": 4141.99998382477 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d12991c628f", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:center", + "~:stroke-style": "~:mixed", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 80 + } + ], + "~:x": 9942.0002179087, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 9942.0002179087, + "~:y": 2932.99998382477, + "~:width": 1142, + "~:height": 1209, + "~:x1": 9942.0002179087, + "~:y1": 2932.99998382477, + "~:x2": 11084.0002179087, + "~:y2": 4141.99998382477 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1209, + "~:flip-y": null + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d121724180c": { + "~#shape": { + "~:y": 10061.9997945218, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4194.00003919479, + "~:y": 10061.9997945218 + } + }, + { + "~#point": { + "~:x": 5330.00003919479, + "~:y": 10061.9997945218 + } + }, + { + "~#point": { + "~:x": 5330.00003919479, + "~:y": 11579.9997945218 + } + }, + { + "~#point": { + "~:x": 4194.00003919479, + "~:y": 11579.9997945218 + } + } + ], + "~:r2": 100, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 100, + "~:r1": 100, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d121724180c", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dashed", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 4194.00003919479, + "~:proportion": 1, + "~:r4": 100, + "~:selrect": { + "~#rect": { + "~:x": 4194.00003919479, + "~:y": 10061.9997945218, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4194.00003919479, + "~:y1": 10061.9997945218, + "~:x2": 5330.00003919479, + "~:y2": 11579.9997945218 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d121723d64c": { + "~#shape": { + "~:y": 4703.99993542969, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 2420.00004019385, + "~:y": 4703.99993542969 + } + }, + { + "~#point": { + "~:x": 3556.00004019385, + "~:y": 4703.99993542969 + } + }, + { + "~#point": { + "~:x": 3556.00004019385, + "~:y": 6221.99993542969 + } + }, + { + "~#point": { + "~:x": 2420.00004019385, + "~:y": 6221.99993542969 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d121723d64c", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dotted", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 2420.00004019385, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 2420.00004019385, + "~:y": 4703.99993542969, + "~:width": 1136, + "~:height": 1518, + "~:x1": 2420.00004019385, + "~:y1": 4703.99993542969, + "~:x2": 3556.00004019385, + "~:y2": 6221.99993542969 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d121724180d": { + "~#shape": { + "~:y": 6582.00017455905, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 5712.00003972957, + "~:y": 6582.00017455905 + } + }, + { + "~#point": { + "~:x": 6848.00003972957, + "~:y": 6582.00017455905 + } + }, + { + "~#point": { + "~:x": 6848.00003972957, + "~:y": 8100.00017455905 + } + }, + { + "~#point": { + "~:x": 5712.00003972957, + "~:y": 8100.00017455905 + } + } + ], + "~:r2": 100, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 100, + "~:r1": 100, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d121724180d", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:mixed", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 5712.00003972957, + "~:proportion": 1, + "~:r4": 100, + "~:selrect": { + "~#rect": { + "~:x": 5712.00003972957, + "~:y": 6582.00017455905, + "~:width": 1136, + "~:height": 1518, + "~:x1": 5712.00003972957, + "~:y1": 6582.00017455905, + "~:x2": 6848.00003972957, + "~:y2": 8100.00017455905 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d12947c0f8d": { + "~#shape": { + "~:y": 1230.00000713603, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 1142, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 8555.99980194352, + "~:y": 1230.00000713603 + } + }, + { + "~#point": { + "~:x": 9697.99980194352, + "~:y": 1230.00000713603 + } + }, + { + "~#point": { + "~:x": 9697.99980194352, + "~:y": 2439.00000713603 + } + }, + { + "~#point": { + "~:x": 8555.99980194352, + "~:y": 2439.00000713603 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d12947c0f8d", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:dashed", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 80 + } + ], + "~:x": 8555.99980194352, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 8555.99980194352, + "~:y": 1230.00000713603, + "~:width": 1142, + "~:height": 1209, + "~:x1": 8555.99980194352, + "~:y1": 1230.00000713603, + "~:x2": 9697.99980194352, + "~:y2": 2439.00000713603 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1209, + "~:flip-y": null + } + }, + "~u31bdb02f-8fc7-80ef-8007-8d121723d64d": { + "~#shape": { + "~:y": 1164.99993036784, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "100", + "~:width": 1136, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 4161.99996508019, + "~:y": 1164.99993036784 + } + }, + { + "~#point": { + "~:x": 5297.99996508019, + "~:y": 1164.99993036784 + } + }, + { + "~#point": { + "~:x": 5297.99996508019, + "~:y": 2682.99993036784 + } + }, + { + "~#point": { + "~:x": 4161.99996508019, + "~:y": 2682.99993036784 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u31bdb02f-8fc7-80ef-8007-8d121723d64d", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:dashed", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 80, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 4161.99996508019, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 4161.99996508019, + "~:y": 1164.99993036784, + "~:width": 1136, + "~:height": 1518, + "~:x1": 4161.99996508019, + "~:y1": 1164.99993036784, + "~:x2": 5297.99996508019, + "~:y2": 2682.99993036784 + } + }, + "~:fills": [ + { + "~:fill-color": "#b1b2b5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1518, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:id": "~u3ef988d9-9ecf-8021-8007-8d1214ef7fac", + "~:name": "Page 1" + } + }, + "~:id": "~ub888b894-3697-80d3-8006-51cc8a55c200", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } + } \ No newline at end of file diff --git a/frontend/playwright/ui/pages/WasmWorkspacePage.js b/frontend/playwright/ui/pages/WasmWorkspacePage.js index d594232c47..a29f741612 100644 --- a/frontend/playwright/ui/pages/WasmWorkspacePage.js +++ b/frontend/playwright/ui/pages/WasmWorkspacePage.js @@ -10,7 +10,7 @@ export const WASM_FLAGS = [ export class WasmWorkspacePage extends WorkspacePage { static async init(page) { await super.init(page); - await WorkspacePage.mockConfigFlags(page, WASM_FLAGS); + await WasmWorkspacePage.mockConfigFlags(page, WASM_FLAGS); await page.addInitScript(() => { document.addEventListener("penpot:wasm:loaded", () => { @@ -27,6 +27,14 @@ export class WasmWorkspacePage extends WorkspacePage { }); } + static async mockConfigFlags(page, flags) { + await super.mockConfigFlags(page, [...WASM_FLAGS, ...flags]); + } + + async mockConfigFlags(flags) { + return WasmWorkspacePage.mockConfigFlags(this.page, flags); + } + constructor(page) { super(page); this.canvas = page.getByTestId("canvas-wasm-shapes"); diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 1026bcc4a1..67eccff5b9 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -165,6 +165,7 @@ test("Updates canvas background", async ({ page }) => { }); await canvasBackgroundInput.fill("FABADA"); await workspace.page.keyboard.press("Enter"); + await workspace.waitForFirstRenderWithoutUI(); await expect(workspace.canvas).toHaveScreenshot(); }); @@ -196,7 +197,7 @@ test("Renders a file with blurs applied to any kind of shape", async ({ test("Renders a file with shadows applied to any kind of shape", async ({ page, -}) => { +}) => { const workspace = new WasmWorkspacePage(page); await workspace.setupEmptyFile(); await workspace.mockGetFile("render-wasm/get-file-shadows.json"); @@ -290,6 +291,24 @@ test("Renders a file with nested clipping frames", async ({ page }) => { await expect(workspace.canvas).toHaveScreenshot(); }); +test("Renders clipped frames with strokes correctly (no double painting)", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile( + "render-wasm/get-file-frame-strokes-opacity.json", + ); + + await workspace.goToWorkspace({ + id: "3144ac7c-a5cc-80e8-8007-8bbb29a4e56e", + pageId: "3144ac7c-a5cc-80e8-8007-8bbb29a510ac", + }); + await workspace.waitForFirstRenderWithoutUI(); + + await expect(workspace.canvas).toHaveScreenshot(); +}); + test("Renders a clipped frame with a large blur drop shadow", async ({ page, }) => { @@ -305,3 +324,35 @@ test("Renders a clipped frame with a large blur drop shadow", async ({ await expect(workspace.canvas).toHaveScreenshot(); }); + +test("Renders a file with solid, dotted, dashed and mixed stroke styles", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-stroke-styles.json"); + + await workspace.goToWorkspace({ + id: "b888b894-3697-80d3-8006-51cc8a55c200", + pageId: "b888b894-3697-80d3-8006-51cc8a55c210", + }); + await workspace.waitForFirstRenderWithoutUI(); + + await expect(workspace.canvas).toHaveScreenshot(); +}); + +test("Renders shapes with multiple fills and blur", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-fill-blend-blurs.json"); + + await workspace.goToWorkspace({ + id: "b15901d7-d46d-8056-8007-8d5e34fc1f0c", + pageId: "b15901d7-d46d-8056-8007-8d5e34fc1f0d", + }); + await workspace.waitForFirstRenderWithoutUI(); + + await expect(workspace.canvas).toHaveScreenshot(); +}); diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shadows-applied-to-any-kind-of-shape-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shadows-applied-to-any-kind-of-shape-1.png index af3bc2af39..3e3ed40915 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shadows-applied-to-any-kind-of-shape-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shadows-applied-to-any-kind-of-shape-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-dotted-dashed-and-mixed-stroke-styles-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-dotted-dashed-and-mixed-stroke-styles-1.png new file mode 100644 index 0000000000..f23422a736 Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-dotted-dashed-and-mixed-stroke-styles-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-clipped-frames-with-strokes-correctly-no-double-painting-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-clipped-frames-with-strokes-correctly-no-double-painting-1.png new file mode 100644 index 0000000000..e8d4a136b8 Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-clipped-frames-with-strokes-correctly-no-double-painting-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-with-multiple-fills-and-blur-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-with-multiple-fills-and-blur-1.png new file mode 100644 index 0000000000..353dfca842 Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-with-multiple-fills-and-blur-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Updates-canvas-background-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Updates-canvas-background-1.png index 01aa3ccd93..417af39655 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Updates-canvas-background-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Updates-canvas-background-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-emoji-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-emoji-1.png index f0cf9b0454..58f65a7339 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-emoji-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-emoji-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png index 86c2d90867..649a0751da 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png differ diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 518d7f0b60..34a5a57328 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -32,6 +32,7 @@ [app.main.features :as features] [app.main.fonts :as fonts] [app.main.router :as rt] + [app.render-wasm.api :as wasm.api] [app.util.text-editor :as ted] [app.util.text.content.styles :as styles] [app.util.timers :as ts] @@ -508,12 +509,12 @@ ptk/EffectEvent (effect [_ state _] (when (features/active-feature? state "text-editor/v2") - (let [instance (:workspace-editor state) - styles (some-> (editor.v2/getCurrentStyle instance) - (styles/get-styles-from-style-declaration :removed-mixed true) - ((comp update-node-fn migrate-node)) - (styles/attrs->styles))] - (editor.v2/applyStylesToSelection instance styles))))))) + (when-let [instance (:workspace-editor state)] + (let [styles (some-> (editor.v2/getCurrentStyle instance) + (styles/get-styles-from-style-declaration :removed-mixed true) + ((comp update-node-fn migrate-node)) + (styles/attrs->styles))] + (editor.v2/applyStylesToSelection instance styles)))))))) ;; --- RESIZE UTILS @@ -777,17 +778,30 @@ (rx/of (v2-update-text-editor-styles id attrs))) (when (features/active-feature? state "render-wasm/v1") - (rx/of (dwwt/resize-wasm-text-debounce id))))))) + (rx/concat + ;; Apply style to selected spans and sync content + (when (wasm.api/text-editor-is-active?) + (let [span-attrs (select-keys attrs txt/text-node-attrs)] + (when (not (empty? span-attrs)) + (let [result (wasm.api/apply-style-to-selection span-attrs)] + (when result + (rx/of (v2-update-text-shape-content + (:shape-id result) (:content result) + :update-name? true))))))) + ;; Resize (with delay for font-id changes) + (cond->> (rx/of (dwwt/resize-wasm-text id)) + (contains? attrs :font-id) + (rx/delay 200)))))))) ptk/EffectEvent (effect [_ state _] (when (features/active-feature? state "text-editor/v2") - (let [instance (:workspace-editor state) - attrs-to-override (some-> (editor.v2/getCurrentStyle instance) - (styles/get-styles-from-style-declaration)) - overriden-attrs (merge attrs-to-override attrs) - styles (styles/attrs->styles overriden-attrs)] - (editor.v2/applyStylesToSelection instance styles)))))) + (when-let [instance (:workspace-editor state)] + (let [attrs-to-override (some-> (editor.v2/getCurrentStyle instance) + (styles/get-styles-from-style-declaration)) + overriden-attrs (merge attrs-to-override attrs) + styles (styles/attrs->styles overriden-attrs)] + (editor.v2/applyStylesToSelection instance styles))))))) (defn update-all-attrs [ids attrs] diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 1e0331ccd8..bf75a6790e 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -409,7 +409,7 @@ modif-tree (dwm/build-modif-tree ids objects get-modifier)] (if (features/active-feature? state "render-wasm/v1") - (rx/of (dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})) + (rx/of (dwm/apply-wasm-modifiers modif-tree (assoc options :ignore-snap-pixel true))) (let [modif-tree (gm/set-objects-modifiers modif-tree objects)] (rx/of (dwm/apply-modifiers* objects modif-tree nil options))))))))) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index 936d30cccf..ef452cd663 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -352,11 +352,9 @@ max-height (max height selrect-height) valign (-> shape :content :vertical-align) y (:y selrect) - y (if (and valign (> height selrect-height)) - (case valign - "bottom" (- y (- height selrect-height)) - "center" (- y (/ (- height selrect-height) 2)) - y) + y (case valign + "bottom" (+ y (- selrect-height height)) + "center" (+ y (/ (- selrect-height height) 2)) y)] [(assoc selrect :y y :width max-width :height max-height) transform]) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss index feada220c0..b34628c932 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss @@ -29,6 +29,23 @@ color: transparent; + // Match Skia's text layout precision: prevent browser text-size + // adjustments and ensure consistent kerning across browsers. + text-size-adjust: none; + -webkit-text-size-adjust: none; + font-kerning: normal; + + &::selection, + *::selection { + color: transparent; + -webkit-text-fill-color: transparent; // WebKit/Safari + } + + &::-moz-selection, + *::-moz-selection { + color: transparent; + } + [data-itype="paragraph"] { line-height: inherit; user-select: text; diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index 3701bb505d..a58e1512ba 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -70,7 +70,7 @@ on-click (mf/use-fn - (mf/deps id) + (mf/deps id current-page-id) (fn [] ;; For the wasm renderer, apply a blur effect to the viewport canvas ;; when we navigate to a different page. diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index c336c49c75..f0a3bc3600 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -19,10 +19,14 @@ [app.main.data.workspace.media :as dwm] [app.main.data.workspace.path :as dwdp] [app.main.data.workspace.specialized-panel :as-alias dwsp] + [app.main.data.workspace.texts :as dwt] + [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.workspace.sidebar.assets.components :as wsac] [app.main.ui.workspace.viewport.viewport-ref :as uwvv] + [app.render-wasm.api :as wasm.api] + [app.render-wasm.wasm :as wasm.wasm] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] [app.util.dom.normalize-wheel :as nw] @@ -91,7 +95,17 @@ ::dwsp/interrupt) (when (and (not= edition id) (or text-editing? grid-editing?)) - (st/emit! (dw/clear-edition-mode))) + (st/emit! (dw/clear-edition-mode)) + ;; Sync and stop WASM text editor when exiting edit mode + (when (and text-editing? + (features/active-feature? @st/state "render-wasm/v1") + wasm.wasm/context-initialized?) + (when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)] + (st/emit! (dwt/v2-update-text-shape-content + shape-id content + :update-name? true + :finalize? true))) + (wasm.api/text-editor-stop))) (when (and (not text-editing?) (not blocked) @@ -184,6 +198,20 @@ (not drawing-tool)) (st/emit! (dw/select-shape (:id @hover) shift?))) + ;; If clicking on a text shape and wasm render is enabled, forward cursor position + (when (and hovering? + (not @space?) + edition ;; Only when already in edit mode + (not drawing-path?) + (not drawing-tool)) + (let [hover-shape @hover] + (when (and (= :text (:type hover-shape)) + (features/active-feature? @st/state "text-editor-wasm/v1") + wasm.wasm/context-initialized?) + (let [raw-pt (dom/get-client-position event)] + ;; FIXME + (wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt)))))) + (when (and @z? (not @space?) (not edition) @@ -223,8 +251,15 @@ (when (and (not drawing-path?) shape) (cond (and editable? (not= id edition) (not read-only?)) - (st/emit! (dw/select-shape id) - (dw/start-editing-selected)) + (do + (st/emit! (dw/select-shape id) + (dw/start-editing-selected)) + ;; If using wasm text-editor, notify WASM to start editing this shape + ;; and set cursor position from the double-click location + (when (and (= type :text) + (features/active-feature? @st/state "text-editor-wasm/v1") + wasm.wasm/context-initialized?) + (wasm.api/text-editor-start id))) (some? selected-shape) (do diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index 922e18057d..b615fc4aa7 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -164,7 +164,6 @@ ;; for the release of the z key (when-not ^boolean value (reset! z* false)))) - (hooks/use-stream kbd-zoom-s (fn [kevent] (dom/prevent-default kevent) @@ -316,7 +315,7 @@ (and (cfh/group-shape? objects %) (not (contains? child-parent? %))) (and (features/active-feature? @st/state "render-wasm/v1") - (cfh/text-shape? objects %) + (cfh/text-shape? (get objects %)) (not (wasm.api/intersect-position-in-shape % @last-point-ref))))))) remove-measure-xf diff --git a/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs b/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs index 4ba3d44fea..41894c8d21 100644 --- a/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs @@ -66,6 +66,14 @@ (gpt/divide zoom) (gpt/add box)))))) +(defn point->viewport-relative + "Convert client coordinates to viewport-relative coordinates. + Unlike point->viewport, this does NOT convert to canvas coordinates - + it just subtracts the viewport's bounding rect offset." + [pt] + (when (some? @viewport-brect) + (gpt/subtract pt @viewport-brect))) + (defn inside-viewport? [target] (dom/is-child? @viewport-ref target)) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index c86d80f998..3edbe19c21 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -54,6 +54,7 @@ [app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]] [app.main.ui.workspace.viewport.widgets :as widgets] [app.render-wasm.api :as wasm.api] + [app.render-wasm.text-editor-input :refer [text-editor-input]] [app.util.debug :as dbg] [app.util.text-editor :as ted] [beicon.v2.core :as rx] @@ -407,7 +408,14 @@ (when picking-color? [:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref - :canvas-ref canvas-ref}])] + :canvas-ref canvas-ref}]) + + ;; WASM text editor contenteditable (must be outside SVG to work) + (when (and show-text-editor? + (features/active-feature? @st/state "text-editor-wasm/v1")) + [:& text-editor-input {:shape editing-shape + :zoom zoom + :vbox vbox}])] [:canvas {:id "render" :data-testid "canvas-wasm-shapes" @@ -452,7 +460,10 @@ :height (max 0 (- (:height vbox) rule-area-size))}]]] [:g {:style {:pointer-events (if disable-events? "none" "auto")}} - (when show-text-editor? + ;; Text editor handling: + ;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM) + (when (and show-text-editor? + (not (features/active-feature? @st/state "text-editor-wasm/v1"))) (if (features/active-feature? @st/state "text-editor/v2") [:& editor-v2/text-editor {:shape editing-shape :canvas-ref canvas-ref diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 6e2373b519..46a32ef16e 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -39,6 +39,7 @@ [app.render-wasm.serializers :as sr] [app.render-wasm.serializers.color :as sr-clr] [app.render-wasm.svg-filters :as svg-filters] + [app.render-wasm.text-editor :as text-editor] [app.render-wasm.wasm :as wasm] [app.util.debug :as dbg] [app.util.dom :as dom] @@ -74,6 +75,18 @@ ;; Threshold below which we use synchronous processing (no chunking overhead) (def ^:const ASYNC_THRESHOLD 100) +;; Re-export public WebGL functions +(def capture-canvas-pixels webgl/capture-canvas-pixels) +(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels) +(def clear-canvas-pixels webgl/clear-canvas-pixels) + +;; Re-export public text editor functions +(def text-editor-start text-editor/text-editor-start) +(def text-editor-stop text-editor/text-editor-stop) +(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point) +(def text-editor-is-active? text-editor/text-editor-is-active?) +(def text-editor-sync-content text-editor/text-editor-sync-content) + (def dpr (if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0)) @@ -109,11 +122,36 @@ (mf/element object-svg #js {:shape shape}) (rds/renderToStaticMarkup))) +;; forward declare helpers so render can call them +(declare request-render) +(declare set-shape-vertical-align fonts-from-text-content) + ;; This should never be called from the outside. (defn- render [timestamp] (when (and wasm/context-initialized? (not @wasm/context-lost?)) (h/call wasm/internal-module "_render" timestamp) + + ;; Update text editor blink (so cursor toggles) using the same timestamp + (try + (when wasm/context-initialized? + (text-editor/text-editor-update-blink timestamp) + ;; Render text editor overlay on top of main canvas (only if feature enabled) + ;; Determine if text-editor-wasm feature is active without requiring + ;; app.main.features to avoid circular dependency: check runtime and + ;; persisted feature sets in the store state. + (let [runtime-features (get @st/state :features-runtime) + enabled-features (get @st/state :features)] + (when (or (contains? runtime-features "text-editor-wasm/v1") + (contains? enabled-features "text-editor-wasm/v1")) + (text-editor/text-editor-render-overlay))) + ;; Poll for editor events; if any event occurs, trigger a re-render + (let [ev (text-editor/text-editor-poll-event)] + (when (and ev (not= ev 0)) + (request-render "text-editor-event")))) + (catch :default e + (js/console.error "text-editor overlay/update failed:" e))) + (set! wasm/internal-frame-id nil) (ug/dispatch! (ug/event "penpot:wasm:render")))) @@ -187,25 +225,6 @@ (declare get-text-dimensions) -(defn update-text-rect! - [id] - (when wasm/context-initialized? - (let [dimensions (get-text-dimensions id) - page-id (:current-page-id @st/state)] - (mw/emit! - {:cmd :index/update-text-rect - :page-id page-id - :shape-id id - :dimensions dimensions})))) - - -(defn- ensure-text-content - "Guarantee that the shape always sends a valid text tree to WASM. When the - content is nil (freshly created text) we fall back to - tc/default-text-content so the renderer receives typography information." - [content] - (or content (tc/v2-default-text-content))) - (defn use-shape [id] (when wasm/context-initialized? @@ -216,6 +235,47 @@ (aget buffer 2) (aget buffer 3))))) +(defn set-shape-text-content + "This function sets shape text content and returns a stream that loads the needed fonts asynchronously" + [shape-id content] + + ;; Cache content for text editor sync + (text-editor/cache-shape-text-content! shape-id content) + + (h/call wasm/internal-module "_clear_shape_text") + + (set-shape-vertical-align (get content :vertical-align)) + + (let [fonts (f/get-content-fonts content) + fallback-fonts (fonts-from-text-content content true) + all-fonts (concat fonts fallback-fonts) + result (f/store-fonts all-fonts)] + (f/load-fallback-fonts-for-editor! fallback-fonts) + (h/call wasm/internal-module "_update_shape_text_layout") + result)) + +(defn apply-style-to-selection + "Apply style attrs to the currently selected text spans. + Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving." + [attrs] + (text-editor/apply-style-to-selection attrs use-shape set-shape-text-content)) + +(defn update-text-rect! + [id] + (when wasm/context-initialized? + (mw/emit! + {:cmd :index/update-text-rect + :page-id (:current-page-id @st/state) + :shape-id id + :dimensions (get-text-dimensions id)}))) + +(defn- ensure-text-content + "Guarantee that the shape always sends a valid text tree to WASM. When the + content is nil (freshly created text) we fall back to + tc/default-text-content so the renderer receives typography information." + [content] + (or content (tc/v2-default-text-content))) + (defn set-parent-id [id] (let [buffer (uuid/get-u32 id)] @@ -859,22 +919,6 @@ (if fallback-fonts-only? updated-fonts fallback-fonts)))))) -(defn set-shape-text-content - "This function sets shape text content and returns a stream that loads the needed fonts asynchronously" - [shape-id content] - - (h/call wasm/internal-module "_clear_shape_text") - - (set-shape-vertical-align (get content :vertical-align)) - - (let [fonts (f/get-content-fonts content) - fallback-fonts (fonts-from-text-content content true) - all-fonts (concat fonts fallback-fonts) - result (f/store-fonts all-fonts)] - (f/load-fallback-fonts-for-editor! fallback-fonts) - (f/update-text-layout shape-id) - result)) - (defn set-shape-grow-type [grow-type] (h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type))) @@ -1072,7 +1116,7 @@ (defn- set-objects-async "Asynchronously process shapes in chunks, yielding to the browser between chunks. Returns a promise that resolves when all shapes are processed. - + Renders a preview only periodically during loading to show progress, then does a full tile-based render at the end." [shapes render-callback] @@ -1557,33 +1601,41 @@ (persistent! result))) result - (->> result - (mapv - (fn [{:keys [paragraph span start-pos end-pos direction x y width height]}] - (let [content (:content shape) - element (-> content :children - (get 0) :children ;; paragraph-set - (get paragraph) :children ;; paragraph - (get span)) - text (subs (:text element) start-pos end-pos)] + (into [] + (keep + (fn [{:keys [paragraph span start-pos end-pos direction x y width height]}] + (let [content (:content shape) + element (-> content :children + (get 0) :children ;; paragraph-set + (get paragraph) :children ;; paragraph + (get span)) + element-text (:text element)] - (d/patch-object - txt/default-text-attrs - (d/without-nils - {:x x - :y (+ y height) - :width width - :height height - :direction (dr/translate-direction direction) - :font-family (get element :font-family) - :font-size (get element :font-size) - :font-weight (get element :font-weight) - :text-transform (get element :text-transform) - :text-decoration (get element :text-decoration) - :letter-spacing (get element :letter-spacing) - :font-style (get element :font-style) - :fills (d/nilv (get element :fills) [{:fill-color "#000000"}]) - :text text}))))))] + ;; Add comprehensive nil-safety checks + (when (and element + element-text + (>= start-pos 0) + (<= end-pos (count element-text)) + (<= start-pos end-pos)) + (let [text (subs element-text start-pos end-pos)] + (d/patch-object + txt/default-text-attrs + (d/without-nils + {:x x + :y (+ y height) + :width width + :height height + :direction (dr/translate-direction direction) + :font-family (get element :font-family) + :font-size (get element :font-size) + :font-weight (get element :font-weight) + :text-transform (get element :text-transform) + :text-decoration (get element :text-decoration) + :letter-spacing (get element :letter-spacing) + :font-style (get element :font-style) + :fills (get element :fills) + :text text}))))))) + result)] (mem/free) result))) @@ -1617,7 +1669,4 @@ (p/resolved false))))) (p/resolved false)))) -;; Re-export public WebGL functions -(def capture-canvas-pixels webgl/capture-canvas-pixels) -(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels) -(def clear-canvas-pixels webgl/clear-canvas-pixels) + diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index c520923f74..09a0adfac2 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -320,7 +320,7 @@ :style-name style :weight weight :emoji? emoji? - :fallbck? fallback? + :fallback? fallback? :asset-id asset-id})) (defn store-font diff --git a/frontend/src/app/render_wasm/api/shared.js b/frontend/src/app/render_wasm/api/shared.js index 172cb2a97d..e5456d91d2 100644 --- a/frontend/src/app/render_wasm/api/shared.js +++ b/frontend/src/app/render_wasm/api/shared.js @@ -240,3 +240,12 @@ export const RawGrowType = { "auto-height": 2, }; +export const CursorDirection = { + "backward": 0, + "forward": 1, + "line-before": 2, + "line-after": 3, + "line-start": 4, + "line-end": 5, +}; + diff --git a/frontend/src/app/render_wasm/mem.cljs b/frontend/src/app/render_wasm/mem.cljs index affccbc16c..4a9b7aa5e3 100644 --- a/frontend/src/app/render_wasm/mem.cljs +++ b/frontend/src/app/render_wasm/mem.cljs @@ -61,6 +61,29 @@ [] (h/call wasm/internal-module "_free_bytes")) +(defn read-string + "Read a UTF-8 string from WASM memory given a byte pointer/offset. + Uses Emscripten's UTF8ToString to decode the string." + [ptr] + (h/call wasm/internal-module "UTF8ToString" ptr)) + +(defn read-null-terminated-string + "Read a null-terminated UTF-8 string from WASM memory. + Manually reads bytes until null terminator and decodes using TextDecoder." + [ptr] + (when (and ptr (not (zero? ptr))) + (let [heap (get-heap-u8) + ;; Find the null terminator + end-idx (loop [idx ptr] + (if (zero? (aget heap idx)) + idx + (recur (inc idx)))) + ;; Extract the bytes (excluding null terminator) + bytes (.slice heap ptr end-idx) + ;; Decode using TextDecoder + decoder (js/TextDecoder. "utf-8")] + (.decode decoder bytes)))) + (defn slice "Returns a copy of a portion of a typed array into a new typed array object selected from start to end." diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs new file mode 100644 index 0000000000..882f24f890 --- /dev/null +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -0,0 +1,300 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.render-wasm.text-editor + "Text editor WASM bindings" + (:require + [app.common.uuid :as uuid] + [app.render-wasm.helpers :as h] + [app.render-wasm.mem :as mem] + [app.render-wasm.wasm :as wasm])) + +(defn text-editor-start + [id] + (when wasm/context-initialized? + (let [buffer (uuid/get-u32 id)] + (h/call wasm/internal-module "_text_editor_start" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3))))) + +(defn text-editor-set-cursor-from-point + [x y] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y))) + +(defn text-editor-update-blink + [timestamp-ms] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms))) + +(defn text-editor-render-overlay + [] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_render_overlay"))) + +(defn text-editor-poll-event + [] + (when wasm/context-initialized? + (let [res (h/call wasm/internal-module "_text_editor_poll_event")] + res))) + +(defn text-editor-insert-text + [text] + (when wasm/context-initialized? + (let [encoder (js/TextEncoder.) + buf (.encode encoder text) + heapu8 (mem/get-heap-u8) + size (mem/size buf) + offset (mem/alloc size)] + (mem/write-buffer offset heapu8 buf) + (h/call wasm/internal-module "_text_editor_insert_text") + (mem/free)))) + +(defn text-editor-delete-backward [] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_delete_backward"))) + +(defn text-editor-delete-forward [] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_delete_forward"))) + +(defn text-editor-insert-paragraph [] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_insert_paragraph"))) + +(defn text-editor-move-cursor + [direction extend-selection] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_move_cursor" direction (if extend-selection 1 0)))) + +(defn text-editor-select-all + [] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_select_all"))) + +(defn text-editor-stop + [] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_stop"))) + +(defn text-editor-is-active? + [] + (when wasm/context-initialized? + (not (zero? (h/call wasm/internal-module "_text_editor_is_active"))))) + +(defn text-editor-export-content + [] + (when wasm/context-initialized? + (let [ptr (h/call wasm/internal-module "_text_editor_export_content")] + (when (and ptr (not (zero? ptr))) + (let [json-str (mem/read-null-terminated-string ptr)] + (mem/free) + (js/JSON.parse json-str)))))) + +(defn text-editor-export-selection + "Export only the currently selected text as plain text from the WASM editor. Requires WASM support (_text_editor_export_selection)." + [] + (when wasm/context-initialized? + (let [ptr (h/call wasm/internal-module "_text_editor_export_selection")] + (when (and ptr (not (zero? ptr))) + (let [text (mem/read-null-terminated-string ptr)] + (mem/free) + text))))) + +(defn text-editor-get-active-shape-id + [] + (when wasm/context-initialized? + (try + (let [byte-offset (mem/alloc 16) + u32-offset (mem/->offset-32 byte-offset) + heap (mem/get-heap-u32)] + (h/call wasm/internal-module "_text_editor_get_active_shape_id" byte-offset) + (let [a (aget heap u32-offset) + b (aget heap (+ u32-offset 1)) + c (aget heap (+ u32-offset 2)) + d (aget heap (+ u32-offset 3)) + result (when (or (not= a 0) (not= b 0) (not= c 0) (not= d 0)) + (uuid/from-unsigned-parts a b c d))] + (mem/free) + result)) + (catch js/Error e + (js/console.error "[text-editor-get-active-shape-id] Error:" e) + nil)))) + +(defn text-editor-get-selection + [] + (when wasm/context-initialized? + (let [byte-offset (mem/alloc 16) + u32-offset (mem/->offset-32 byte-offset) + heap (mem/get-heap-u32) + active? (h/call wasm/internal-module "_text_editor_get_selection" byte-offset)] + (try + (when (= active? 1) + {:anchor-para (aget heap u32-offset) + :anchor-offset (aget heap (+ u32-offset 1)) + :focus-para (aget heap (+ u32-offset 2)) + :focus-offset (aget heap (+ u32-offset 3))}) + (finally + (mem/free)))))) + +(def ^:private shape-text-contents (atom {})) + +(defn- merge-exported-texts-into-content + "Merge exported span texts back into the existing content tree. + + The WASM editor may split or merge paragraphs (Enter / Backspace at + paragraph boundary), so the exported structure can differ from the + original. When extra paragraphs or spans appear we clone styling from + the nearest existing sibling; when fewer appear we truncate. + + exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]] + content existing Penpot content map (root -> paragraph-set -> …)" + [content exported-texts] + (let [para-set (first (get content :children)) + orig-paras (get para-set :children) + num-orig (count orig-paras) + last-orig-para (when (seq orig-paras) (last orig-paras)) + template-span (when last-orig-para + (-> last-orig-para :children last)) + new-paras + (mapv (fn [para-idx exported-span-texts] + (let [orig-para (if (< para-idx num-orig) + (nth orig-paras para-idx) + (dissoc last-orig-para :children)) + orig-spans (get orig-para :children) + num-orig-spans (count orig-spans) + last-orig-span (when (seq orig-spans) (last orig-spans))] + (assoc orig-para :children + (mapv (fn [span-idx new-text] + (let [orig-span (if (< span-idx num-orig-spans) + (nth orig-spans span-idx) + (or last-orig-span template-span))] + (assoc orig-span :text new-text))) + (range (count exported-span-texts)) + exported-span-texts)))) + (range (count exported-texts)) + exported-texts) + new-para-set (assoc para-set :children new-paras)] + (assoc content :children [new-para-set]))) + +(defn text-editor-sync-content + "Sync text content from the WASM text editor back to the frontend shape. + + Exports the current span texts from WASM, merges them into the shape's + cached content tree (preserving per-span styling), and returns the + shape-id and the fully merged content map ready for + v2-update-text-shape-content." + [] + (when (and wasm/context-initialized? (text-editor-is-active?)) + (let [shape-id (text-editor-get-active-shape-id) + new-texts (text-editor-export-content)] + (when (and shape-id new-texts) + (let [texts-clj (js->clj new-texts) + content (get @shape-text-contents shape-id)] + (when content + (let [merged (merge-exported-texts-into-content content texts-clj)] + (swap! shape-text-contents assoc shape-id merged) + {:shape-id shape-id + :content merged}))))))) + +(defn cache-shape-text-content! + [shape-id content] + (when (some? content) + (swap! shape-text-contents assoc shape-id content))) + +(defn get-cached-content + [shape-id] + (get @shape-text-contents shape-id)) + +(defn update-cached-content! + [shape-id content] + (swap! shape-text-contents assoc shape-id content)) + +(defn- normalize-selection + "Given anchor/focus para+offset, return {:start-para :start-offset :end-para :end-offset} + ordered so start <= end." + [{:keys [anchor-para anchor-offset focus-para focus-offset]}] + (if (or (< anchor-para focus-para) + (and (= anchor-para focus-para) (<= anchor-offset focus-offset))) + {:start-para anchor-para :start-offset anchor-offset + :end-para focus-para :end-offset focus-offset} + {:start-para focus-para :start-offset focus-offset + :end-para anchor-para :end-offset anchor-offset})) + +(defn- apply-attrs-to-paragraph + "Apply attrs to spans within [sel-start, sel-end) char range of a single paragraph. + Splits spans at boundaries as needed." + [para sel-start sel-end attrs] + (let [spans (:children para) + result (loop [spans spans + pos 0 + acc []] + (if (empty? spans) + acc + (let [span (first spans) + text (:text span) + span-len (count text) + span-end (+ pos span-len) + ol-start (max pos sel-start) + ol-end (min span-end sel-end) + has-overlap? (< ol-start ol-end)] + (if (not has-overlap?) + (recur (rest spans) span-end (conj acc span)) + (let [before (when (> ol-start pos) + (assoc span :text (subs text 0 (- ol-start pos)))) + selected (merge span attrs + {:text (subs text (- ol-start pos) (- ol-end pos))}) + after (when (< ol-end span-end) + (assoc span :text (subs text (- ol-end pos))))] + (recur (rest spans) span-end + (-> acc + (into (keep identity [before selected after])))))))))] + (assoc para :children result))) + +(defn- para-char-count + [para] + (apply + (map (fn [span] (count (:text span))) (:children para)))) + +(defn apply-style-to-selection + [attrs use-shape-fn set-shape-text-content-fn] + (when (and wasm/context-initialized? (text-editor-is-active?)) + (let [shape-id (text-editor-get-active-shape-id) + sel (text-editor-get-selection)] + (when (and shape-id sel) + (let [content (get @shape-text-contents shape-id)] + (when content + (let [{:keys [start-para start-offset end-para end-offset]} + (normalize-selection sel) + collapsed? (and (= start-para end-para) (= start-offset end-offset)) + para-set (first (:children content)) + paras (:children para-set) + new-paras + (when (not collapsed?) + (mapv (fn [idx para] + (cond + (or (< idx start-para) (> idx end-para)) + para + (= start-para end-para) + (apply-attrs-to-paragraph para start-offset end-offset attrs) + (= idx start-para) + (apply-attrs-to-paragraph para start-offset (para-char-count para) attrs) + (= idx end-para) + (apply-attrs-to-paragraph para 0 end-offset attrs) + :else + (apply-attrs-to-paragraph para 0 (para-char-count para) attrs))) + (range (count paras)) + paras)) + new-content (when new-paras + (assoc content :children + [(assoc para-set :children new-paras)]))] + (when new-content + (swap! shape-text-contents assoc shape-id new-content) + (use-shape-fn shape-id) + (set-shape-text-content-fn shape-id new-content) + {:shape-id shape-id + :content new-content})))))))) diff --git a/frontend/src/app/render_wasm/text_editor_input.cljs b/frontend/src/app/render_wasm/text_editor_input.cljs new file mode 100644 index 0000000000..f2979b935a --- /dev/null +++ b/frontend/src/app/render_wasm/text_editor_input.cljs @@ -0,0 +1,240 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.render-wasm.text-editor-input + "Contenteditable DOM element for WASM text editor input" + (:require + [app.common.geom.shapes :as gsh] + [app.main.data.workspace.texts :as dwt] + [app.main.store :as st] + [app.render-wasm.api :as wasm.api] + [app.render-wasm.text-editor :as text-editor] + [app.util.dom :as dom] + [app.util.object :as obj] + [cuerdas.core :as str] + [goog.events :as events] + [rumext.v2 :as mf]) + (:import goog.events.EventType)) + +(defn- sync-wasm-text-editor-content! + "Sync WASM text editor content back to the shape via the standard + commit pipeline. Called after every text-modifying input." + [& {:keys [finalize?]}] + (when-let [{:keys [shape-id content]} (text-editor/text-editor-sync-content)] + (st/emit! (dwt/v2-update-text-shape-content + shape-id content + :update-name? true + :finalize? finalize?)))) + +(mf/defc text-editor-input + "Contenteditable element positioned over the text shape to capture input events." + {::mf/wrap-props false} + [props] + (let [shape (obj/get props "shape") + zoom (obj/get props "zoom") + vbox (obj/get props "vbox") + + contenteditable-ref (mf/use-ref nil) + composing? (mf/use-state false) + + ;; Calculate screen position from shape bounds + shape-bounds (gsh/shape->rect shape) + screen-x (* (- (:x shape-bounds) (:x vbox)) zoom) + screen-y (* (- (:y shape-bounds) (:y vbox)) zoom) + screen-w (* (:width shape-bounds) zoom) + screen-h (* (:height shape-bounds) zoom)] + + ;; Focus contenteditable on mount + (mf/use-effect + (fn [] + (when-let [node (mf/ref-val contenteditable-ref)] + (.focus node)) + js/undefined)) + + ;; Animation loop for cursor blink + (mf/use-effect + (fn [] + (let [raf-id (atom nil) + animate (fn animate [] + (when (text-editor/text-editor-is-active?) + (wasm.api/request-render "cursor-blink") + (reset! raf-id (js/requestAnimationFrame animate))))] + (animate) + (fn [] + (when @raf-id + (js/cancelAnimationFrame @raf-id)))))) + + ;; Document-level keydown handler for control keys + (mf/use-effect + (fn [] + (let [on-doc-keydown + (fn [e] + (when (and (text-editor/text-editor-is-active?) + (not @composing?)) + (let [key (.-key e) + ctrl? (or (.-ctrlKey e) (.-metaKey e)) + shift? (.-shiftKey e)] + (cond + ;; Escape: finalize and stop + (= key "Escape") + (do + (dom/prevent-default e) + (sync-wasm-text-editor-content! :finalize? true) + (text-editor/text-editor-stop)) + + ;; Ctrl+A: select all (key is "a" or "A" depending on platform) + (and ctrl? (= (str/lower key) "a")) + (do + (dom/prevent-default e) + (text-editor/text-editor-select-all) + (wasm.api/request-render "text-select-all")) + + ;; Enter + (= key "Enter") + (do + (dom/prevent-default e) + (text-editor/text-editor-insert-paragraph) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-paragraph")) + + ;; Backspace + (= key "Backspace") + (do + (dom/prevent-default e) + (text-editor/text-editor-delete-backward) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-delete-backward")) + + ;; Delete + (= key "Delete") + (do + (dom/prevent-default e) + (text-editor/text-editor-delete-forward) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-delete-forward")) + + ;; Arrow keys + (= key "ArrowLeft") + (do + (dom/prevent-default e) + (text-editor/text-editor-move-cursor 0 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowRight") + (do + (dom/prevent-default e) + (text-editor/text-editor-move-cursor 1 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowUp") + (do + (dom/prevent-default e) + (text-editor/text-editor-move-cursor 2 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowDown") + (do + (dom/prevent-default e) + (text-editor/text-editor-move-cursor 3 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "Home") + (do + (dom/prevent-default e) + (text-editor/text-editor-move-cursor 4 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "End") + (do + (dom/prevent-default e) + (text-editor/text-editor-move-cursor 5 shift?) + (wasm.api/request-render "text-cursor-move")) + + ;; Let contenteditable handle text input via on-input + :else nil))))] + (events/listen js/document EventType.KEYDOWN on-doc-keydown true) + (fn [] + (events/unlisten js/document EventType.KEYDOWN on-doc-keydown true))))) + + ;; Composition and input events + (let [on-composition-start + (mf/use-fn + (fn [_event] + (reset! composing? true))) + + on-composition-end + (mf/use-fn + (fn [^js event] + (reset! composing? false) + (let [data (.-data event)] + (when data + (text-editor/text-editor-insert-text data) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-composition")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))) + + on-paste + (mf/use-fn + (fn [^js event] + (dom/prevent-default event) + (let [clipboard-data (.-clipboardData event) + text (.getData clipboard-data "text/plain")] + (when (and text (seq text)) + (text-editor/text-editor-insert-text text) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-paste")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))) + + on-copy + (mf/use-fn + (fn [^js event] + (when (text-editor/text-editor-is-active?) + (dom/prevent-default event) + (when (text-editor/text-editor-get-selection) + (let [text (text-editor/text-editor-export-selection)] + (.setData (.-clipboardData event) "text/plain" text)))))) + + on-input + (mf/use-fn + (fn [^js event] + (let [native-event (.-nativeEvent event) + input-type (.-inputType native-event) + data (.-data native-event)] + ;; Skip composition-related input events - composition-end handles those + (when (and (not @composing?) + (not= input-type "insertCompositionText")) + (when (and data (seq data)) + (text-editor/text-editor-insert-text data) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-input")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))))] + + [:div + {:ref contenteditable-ref + :contentEditable true + :suppressContentEditableWarning true + :on-composition-start on-composition-start + :on-composition-end on-composition-end + :on-input on-input + :on-paste on-paste + :on-copy on-copy + ;; FIXME on-click + ;; :on-click on-click + :id "text-editor-wasm-input" + ;; FIXME + :style {:position "absolute" + :left (str screen-x "px") + :top (str screen-y "px") + :width (str screen-w "px") + :height (str screen-h "px") + :opacity 0 + :overflow "hidden" + :white-space "pre" + :cursor "text" + :z-index 10}}]))) diff --git a/frontend/text-editor/README.md b/frontend/text-editor/README.md index 1932502880..9c8e328270 100644 --- a/frontend/text-editor/README.md +++ b/frontend/text-editor/README.md @@ -82,6 +82,26 @@ The `TextEditor` contains a series of references to DOM elements, one of them is `ChangeController` is called by the `TextEditor` instance everytime a change is performed on the content of the `contenteditable` element. +### Best practices + +#### Use `isType` functions + +Instead of handling elements by their properties like this: + +```javascript +if (element.tagName === "SPAN") { + ... +} +``` + +Use functions like `isParagraph`, `isTextSpan` or `isLineBreak`: + +```javascript +if (isTextSpan(element)) { + ... +} +``` + ### Events - `change`: This event is dispatched every time a change is made in the editor. All changes are debounced to prevent dispatching too many change events. This event is also dispatched when there are pending change events and the user blurs the textarea element. diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index e7f82739b1..8408dafa9b 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -326,9 +326,7 @@ export class TextEditor extends EventTarget { * @param {FocusEvent} e */ #onBlur = (e) => { - if (!this.isEmpty) { - this.#changeController.notifyImmediately(); - } + this.#changeController.notifyImmediately(); this.#selectionController.saveSelection(); this.dispatchEvent(new FocusEvent(e.type, e)); }; @@ -685,7 +683,7 @@ export function createRootFromString(string) { * Returns true if the passed object is a TextEditor * instance. * - * @param {TextEditor} instance + * @param {*} instance * @returns {boolean} */ export function isTextEditor(instance) { @@ -716,7 +714,7 @@ export function getRoot(instance) { if (isTextEditor(instance)) { return instance.root; } - throw new TypeError("Instance is not a TextEditor"); + return null; } /** @@ -756,7 +754,7 @@ export function getCurrentStyle(instance) { if (isTextEditor(instance)) { return instance.currentStyle; } - throw new TypeError("Instance is not a TextEditor"); + throw new TypeError('Instance is not a TextEditor'); } /** @@ -771,7 +769,7 @@ export function applyStylesToSelection(instance, styles) { if (isTextEditor(instance)) { return instance.applyStylesToSelection(styles); } - throw new TypeError("Instance is not a TextEditor"); + throw new TypeError('Instance is not a TextEditor'); } /** @@ -785,7 +783,7 @@ export function dispose(instance) { if (isTextEditor(instance)) { return instance.dispose(); } - throw new TypeError("Instance is not a TextEditor"); + throw new TypeError('Instance is not a TextEditor'); } export default TextEditor; diff --git a/frontend/text-editor/src/editor/content/dom/Style.js b/frontend/text-editor/src/editor/content/dom/Style.js index f8866550ed..aaf9e19227 100644 --- a/frontend/text-editor/src/editor/content/dom/Style.js +++ b/frontend/text-editor/src/editor/content/dom/Style.js @@ -336,20 +336,22 @@ export function getStyle(element, styleName, styleUnit) { * @returns {HTMLElement} */ export function setStylesFromObject(element, allowedStyles, styleObject) { - if (element.tagName === "SPAN") - for (const [styleName, styleUnit] of allowedStyles) { - if (!(styleName in styleObject)) { - continue; - } - let styleValue = styleObject[styleName]; - if (!styleValue) continue; - - if (styleName === "font-family") { - styleValue = sanitizeFontFamily(styleValue); - } - - setStyle(element, styleName, styleValue, styleUnit); + for (const [styleName, styleUnit] of allowedStyles) { + if (!(styleName in styleObject)) { + continue; } + + let styleValue = styleObject[styleName]; + if (!styleValue) { + continue; + } + + if (styleName === "font-family") { + styleValue = sanitizeFontFamily(styleValue); + } + + setStyle(element, styleName, styleValue, styleUnit); + } return element; } diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 6d4c11c136..88583a1116 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -1961,7 +1961,8 @@ export class SelectionController extends EventTarget { this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0); } // The styles are applied to the paragraph - else { + else + { const paragraph = this.startParagraph; setParagraphStyles(paragraph, newStyles); // Apply styles to child text spans. @@ -1969,11 +1970,9 @@ export class SelectionController extends EventTarget { setTextSpanStyles(textSpan, newStyles); } } - return this.#notifyStyleChange(); - - // If the startContainer and endContainer are different - // then we need to iterate through those nodes to apply - // the styles. + // If the startContainer and endContainer are different + // then we need to iterate through those nodes to apply + // the styles. } else if (startNode !== endNode) { const safeGuard = new SafeGuard("applyStylesTo"); safeGuard.start(); @@ -2022,12 +2021,12 @@ export class SelectionController extends EventTarget { } // We've reached the final node so we can return safely. - if (this.#textNodeIterator.currentNode === expectedEndNode) return; + if (this.#textNodeIterator.currentNode === expectedEndNode) + break; this.#textNodeIterator.nextNode(); } while (this.#textNodeIterator.currentNode); } - return this.#notifyStyleChange(); } diff --git a/render-wasm/docs/text_editor.md b/render-wasm/docs/text_editor.md new file mode 100644 index 0000000000..8b65fb1f22 --- /dev/null +++ b/render-wasm/docs/text_editor.md @@ -0,0 +1,217 @@ +# Text Editor Architecture + +## Overview (Simplified) + +```mermaid +flowchart TB + subgraph Browser["Browser / DOM"] + CE[contenteditable] + Events[DOM Events] + end + + subgraph CLJS["ClojureScript"] + InputHandler[text_editor_input.cljs] + Bindings[text_editor.cljs] + ContentCache[(content cache)] + end + + subgraph WASM["WASM Boundary"] + FFI["_text_editor_* functions"] + end + + subgraph Rust["Rust"] + subgraph StateModule["state/text_editor.rs"] + TES[TextEditorState] + Selection[TextSelection] + Cursor[TextCursor] + end + + subgraph WASMImpl["wasm/text_editor.rs"] + StateOps[start / stop] + CursorOps[cursor / selection] + EditOps[insert / delete] + ExportOps[export content] + end + + subgraph RenderMod["render/text_editor.rs"] + RenderOverlay[render_overlay] + end + + Shapes[(ShapesPool)] + end + + subgraph Skia["Skia"] + Canvas[Canvas] + Paragraph[Paragraph layout] + end + + %% Flow + CE --> Events + Events --> InputHandler + InputHandler --> Bindings + Bindings --> FFI + FFI --> StateOps & CursorOps & EditOps & ExportOps + + StateOps --> TES + CursorOps --> TES + EditOps --> TES + EditOps --> Shapes + ExportOps --> Shapes + TES --> Selection --> Cursor + + RenderOverlay --> TES + RenderOverlay --> Shapes + Shapes --> Paragraph + RenderOverlay --> Canvas + Paragraph --> Canvas + + ExportOps --> ContentCache + ContentCache --> InputHandler +``` + +--- + +## Detailed Architecture + +```mermaid +flowchart TB + subgraph Browser["Browser / DOM"] + CE[contenteditable element] + KeyEvents[keydown / keyup] + MouseEvents[mousedown / mousemove] + IME[compositionstart / end] + end + + subgraph CLJS["ClojureScript Layer"] + subgraph InputMod["text_editor_input.cljs"] + EventHandler[Event Handler] + BlinkLoop[RAF Blink Loop] + SyncFn[sync-content!] + end + + subgraph BindingsMod["text_editor.cljs"] + direction TB + StartStop[start / stop] + CursorFns[set-cursor / move] + SelectFns[select-all / extend] + EditFns[insert / delete] + ExportFns[export-content] + StyleFns[apply-style] + end + + ContentCache[(shape-text-contents
atom)] + end + + subgraph WASM["WASM Boundary"] + direction TB + FFI_State["_text_editor_start
_text_editor_stop
_text_editor_is_active"] + FFI_Cursor["_text_editor_set_cursor_from_point
_text_editor_move_cursor
_text_editor_select_all"] + FFI_Edit["_text_editor_insert_text
_text_editor_delete_backward
_text_editor_insert_paragraph"] + FFI_Query["_text_editor_export_content
_text_editor_get_selection
_text_editor_poll_event"] + FFI_Render["_text_editor_render_overlay
_text_editor_update_blink"] + end + + subgraph Rust["Rust Layer"] + subgraph StateMod["state/text_editor.rs"] + TES[TextEditorState] + Selection[TextSelection] + Cursor[TextCursor] + Events[EditorEvent queue] + end + + subgraph WASMMod["wasm/text_editor.rs"] + direction TB + WStateOps[State ops] + WCursorOps[Cursor ops] + WEditOps[Edit ops] + WQueryOps[Query ops] + end + + subgraph RenderMod["render/text_editor.rs"] + RenderOverlay[render_overlay] + RenderCursor[render_cursor] + RenderSelection[render_selection] + end + + Shapes[(ShapesPool
TextContent)] + end + + subgraph Skia["Skia"] + Canvas[Canvas] + SkParagraph[textlayout::Paragraph] + TextBoxes[get_rects_for_range] + end + + %% Browser to CLJS + CE --> KeyEvents & MouseEvents & IME + KeyEvents --> EventHandler + MouseEvents --> EventHandler + IME --> EventHandler + + %% CLJS internal + EventHandler --> StartStop & CursorFns & EditFns & SelectFns + BlinkLoop --> FFI_Render + SyncFn --> ExportFns + ExportFns --> ContentCache + ContentCache --> SyncFn + StyleFns --> ContentCache + + %% CLJS to WASM + StartStop --> FFI_State + CursorFns --> FFI_Cursor + SelectFns --> FFI_Cursor + EditFns --> FFI_Edit + ExportFns --> FFI_Query + + %% WASM to Rust impl + FFI_State --> WStateOps + FFI_Cursor --> WCursorOps + FFI_Edit --> WEditOps + FFI_Query --> WQueryOps + FFI_Render --> RenderOverlay + + %% Rust internal + WStateOps --> TES + WCursorOps --> TES + WEditOps --> TES + WEditOps --> Shapes + WQueryOps --> TES + WQueryOps --> Shapes + + TES --> Selection + Selection --> Cursor + TES --> Events + + %% Render flow + RenderOverlay --> RenderCursor & RenderSelection + RenderCursor --> TES + RenderSelection --> TES + RenderCursor --> Shapes + RenderSelection --> Shapes + + %% Skia + Shapes --> SkParagraph + SkParagraph --> TextBoxes + RenderCursor --> Canvas + RenderSelection --> Canvas +``` + +--- + +## Key Files + +| Layer | File | Purpose | +|-------|------|---------| +| DOM | - | contenteditable captures keyboard/IME input | +| CLJS | `text_editor_input.cljs` | Event handling, blink loop, content sync | +| CLJS | `text_editor.cljs` | WASM bindings, content cache, style application | +| Rust | `state/text_editor.rs` | TextEditorState, TextSelection, TextCursor | +| Rust | `wasm/text_editor.rs` | WASM exported functions | +| Rust | `render/text_editor.rs` | Cursor & selection overlay rendering | + +## Data Flow + +1. **Input**: DOM events → ClojureScript handler → WASM function → Rust state +2. **Edit**: Rust modifies TextContent in ShapesPool → triggers layout +3. **Sync**: Export content → merge with cached styles → update shape +4. **Render**: RAF loop → render_overlay → Skia draws cursor/selection diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index e8aa0640f7..a1de07e8fe 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -301,11 +301,7 @@ pub extern "C" fn set_view_end() { #[cfg(feature = "profile-macros")] { let total_time = performance::get_time() - unsafe { VIEW_INTERACTION_START }; - performance::console_log!( - "[PERF] view_interaction (zoom_changed={}): {}ms", - zoom_changed, - total_time - ); + performance::console_log!("[PERF] view_interaction: {}ms", total_time); } }); } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index b1968e7e99..1766af5a34 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -10,6 +10,7 @@ mod shadows; mod strokes; mod surfaces; pub mod text; +pub mod text_editor; mod ui; use skia_safe::{self as skia, Matrix, RRect, Rect}; @@ -22,7 +23,7 @@ pub use surfaces::{SurfaceId, Surfaces}; use crate::performance; use crate::shapes::{ - all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Type, + all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Stroke, Type, }; use crate::state::{ShapesPoolMutRef, ShapesPoolRef}; use crate::tiles::{self, PendingTiles, TileRect}; @@ -33,8 +34,9 @@ use crate::wapi; pub use fonts::*; pub use images::*; -// This is the extra are used for tile rendering. -const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2; +// This is the extra area used for tile rendering (tiles beyond viewport). +// Higher values pre-render more tiles, reducing empty squares during pan but using more memory. +const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; @@ -697,20 +699,17 @@ impl RenderState { canvas.translate(translation); }); - for fill in shape.fills().rev() { - fills::render(self, shape, fill, antialias, SurfaceId::Current); - } + fills::render(self, shape, &shape.fills, antialias, SurfaceId::Current); - for stroke in shape.visible_strokes().rev() { - strokes::render( - self, - shape, - stroke, - Some(SurfaceId::Current), - None, - antialias, - ); - } + // Pass strokes in natural order; stroke merging handles top-most ordering internally. + let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); + strokes::render( + self, + shape, + &visible_strokes, + Some(SurfaceId::Current), + antialias, + ); self.surfaces.apply_mut(SurfaceId::Current as u32, |s| { s.canvas().restore(); @@ -1014,33 +1013,35 @@ impl RenderState { { if let Some(fills_to_render) = self.nested_fills.last() { let fills_to_render = fills_to_render.clone(); - for fill in fills_to_render.iter() { - fills::render(self, shape, fill, antialias, fills_surface_id); - } + fills::render(self, shape, &fills_to_render, antialias, fills_surface_id); } } else { - for fill in shape.fills().rev() { - fills::render(self, shape, fill, antialias, fills_surface_id); - } + fills::render(self, shape, &shape.fills, antialias, fills_surface_id); } - for stroke in shape.visible_strokes().rev() { + // Skip stroke rendering for clipped frames - they are drawn in render_shape_exit + // over the children. Drawing twice would cause incorrect opacity blending. + let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content; + if !skip_strokes { + // Pass strokes in natural order; stroke merging handles top-most ordering internally. + let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); strokes::render( self, shape, - stroke, + &visible_strokes, Some(strokes_surface_id), - None, antialias, ); if !fast_mode { - shadows::render_stroke_inner_shadows( - self, - shape, - stroke, - antialias, - innershadows_surface_id, - ); + for stroke in &visible_strokes { + shadows::render_stroke_inner_shadows( + self, + shape, + stroke, + antialias, + innershadows_surface_id, + ); + } } } @@ -1240,8 +1241,6 @@ impl RenderState { if self.render_in_progress { if tree.len() != 0 { self.render_shape_tree_partial(base_object, tree, timestamp, true)?; - } else { - println!("Empty tree"); } self.flush_and_submit(); @@ -1264,8 +1263,6 @@ impl RenderState { ) -> Result<(), String> { if tree.len() != 0 { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; - } else { - println!("Empty tree"); } self.flush_and_submit(); @@ -1402,6 +1399,10 @@ impl RenderState { element_strokes.to_mut().clear_fills(); element_strokes.to_mut().clear_shadows(); element_strokes.to_mut().clip_content = false; + // Frame blur is applied at the save_layer level - avoid double blur on the stroke paint + if Self::frame_clip_layer_blur(element).is_some() { + element_strokes.to_mut().set_blur(None); + } self.render_shape( &element_strokes, clip_bounds, @@ -1551,6 +1552,11 @@ impl RenderState { plain_shape_mut.clear_shadows(); plain_shape_mut.blur = None; + // Shadow rendering uses a single render_shape call with no render_shape_exit, + // so strokes must be drawn here. Disable clip_content to avoid skip_strokes + // (which defers strokes to render_shape_exit for clipped frames). + plain_shape_mut.clip_content = false; + let Some(drop_filter) = transformed_shadow.get_drop_shadow_filter() else { return; }; @@ -1660,6 +1666,158 @@ impl RenderState { } } + /// Renders element drop shadows to DropShadows surface and composites to Current. + /// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur). + #[allow(clippy::too_many_arguments)] + fn render_element_drop_shadows_and_composite( + &mut self, + element: &Shape, + tree: ShapesPoolRef, + extrect: &mut Option, + clip_bounds: Option, + scale: f32, + translation: (f32, f32), + node_render_state: &NodeRenderState, + ) { + let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale)); + let inherited_layer_blur = match element.shape_type { + Type::Frame(_) | Type::Group(_) => element.blur, + _ => None, + }; + + for shadow in element.drop_shadows_visible() { + let paint = skia::Paint::default(); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + self.surfaces + .canvas(SurfaceId::DropShadows) + .save_layer(&layer_rec); + + self.render_drop_black_shadow( + element, + element_extrect, + shadow, + clip_bounds.clone(), + scale, + translation, + None, + ); + + if !matches!(element.shape_type, Type::Bool(_)) { + for shadow_shape_id in element.children.iter() { + let Some(shadow_shape) = tree.get(shadow_shape_id) else { + continue; + }; + if shadow_shape.hidden { + continue; + } + let nested_clip_bounds = + node_render_state.get_nested_shadow_clip_bounds(element, shadow); + + if !matches!(shadow_shape.shape_type, Type::Text(_)) { + self.render_drop_black_shadow( + shadow_shape, + &shadow_shape.extrect(tree, scale), + shadow, + nested_clip_bounds, + scale, + translation, + inherited_layer_blur, + ); + } else { + let paint = skia::Paint::default(); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + self.surfaces + .canvas(SurfaceId::DropShadows) + .save_layer(&layer_rec); + self.surfaces + .canvas(SurfaceId::DropShadows) + .scale((scale, scale)); + self.surfaces + .canvas(SurfaceId::DropShadows) + .translate(translation); + + let mut transformed_shadow: Cow = Cow::Borrowed(shadow); + transformed_shadow.to_mut().color = skia::Color::BLACK; + transformed_shadow.to_mut().blur = transformed_shadow.blur * scale; + transformed_shadow.to_mut().spread = transformed_shadow.spread * scale; + + let mut new_shadow_paint = skia::Paint::default(); + new_shadow_paint + .set_image_filter(transformed_shadow.get_drop_shadow_filter()); + new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver); + + self.with_nested_blurs_suppressed(|state| { + state.render_shape( + shadow_shape, + nested_clip_bounds, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + true, + None, + Some(vec![new_shadow_paint.clone()]), + ); + }); + self.surfaces.canvas(SurfaceId::DropShadows).restore(); + } + } + } + + let mut paint = skia::Paint::default(); + paint.set_color(shadow.color); + paint.set_blend_mode(skia::BlendMode::SrcIn); + self.surfaces + .canvas(SurfaceId::DropShadows) + .draw_paint(&paint); + + self.surfaces.canvas(SurfaceId::DropShadows).restore(); + } + + if let Some(clips) = clip_bounds.as_ref() { + let antialias = element.should_use_antialias(scale); + self.surfaces.canvas(SurfaceId::Current).save(); + for (bounds, corners, transform) in clips.iter() { + let mut total_matrix = Matrix::new_identity(); + total_matrix.pre_scale((scale, scale), None); + total_matrix.pre_translate((translation.0, translation.1)); + total_matrix.pre_concat(transform); + + self.surfaces + .canvas(SurfaceId::Current) + .concat(&total_matrix); + + if let Some(corners) = corners { + let rrect = RRect::new_rect_radii(*bounds, corners); + self.surfaces.canvas(SurfaceId::Current).clip_rrect( + rrect, + skia::ClipOp::Intersect, + antialias, + ); + } else { + self.surfaces.canvas(SurfaceId::Current).clip_rect( + *bounds, + skia::ClipOp::Intersect, + antialias, + ); + } + + self.surfaces + .canvas(SurfaceId::Current) + .concat(&total_matrix.invert().unwrap_or_default()); + } + self.surfaces + .draw_into(SurfaceId::DropShadows, SurfaceId::Current, None); + self.surfaces.canvas(SurfaceId::Current).restore(); + } else { + self.surfaces + .draw_into(SurfaceId::DropShadows, SurfaceId::Current, None); + } + self.surfaces + .canvas(SurfaceId::DropShadows) + .clear(skia::Color::TRANSPARENT); + } + pub fn render_shape_tree_partial_uncached( &mut self, tree: ShapesPoolRef, @@ -1742,6 +1900,33 @@ impl RenderState { // If a container was flattened, it doesn't affect children visually, so we skip // the expensive enter/exit operations and process children directly if !element.can_flatten() { + // Enter focus early so shadow_before_layer can run (it needs focus_mode.is_active()) + self.focus_mode.enter(&element.id); + + // For frames with layer blur, render shadow BEFORE the layer so it doesn't get + // the layer blur (which would make it more diffused than without clipping) + let shadow_before_layer = !node_render_state.is_root() + && self.focus_mode.is_active() + && !self.options.is_fast_mode() + && !matches!(element.shape_type, Type::Text(_)) + && Self::frame_clip_layer_blur(element).is_some() + && element.drop_shadows_visible().next().is_some(); + + if shadow_before_layer { + let translation = self + .surfaces + .get_render_context_translation(self.render_area, scale); + self.render_element_drop_shadows_and_composite( + element, + tree, + &mut extrect, + clip_bounds.clone(), + scale, + translation, + &node_render_state, + ); + } + self.render_shape_enter(element, mask); } @@ -1753,180 +1938,25 @@ impl RenderState { // Skip expensive drop shadow rendering in fast mode (during pan/zoom) let skip_shadows = self.options.is_fast_mode(); + // Skip shadow block when already rendered before the layer (frame_clip_layer_blur) + let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some(); + // For text shapes, render drop shadow using text rendering logic - if !skip_shadows && !matches!(element.shape_type, Type::Text(_)) { - // Shadow rendering technique: Two-pass approach for proper opacity handling - // - // The shadow rendering uses a two-pass technique to ensure that overlapping - // shadow areas maintain correct opacity without unwanted darkening: - // - // 1. First pass: Render shadow shape in pure black (alpha channel preserved) - // - This creates the shadow silhouette with proper alpha gradients - // - The black color acts as a mask for the final shadow color - // - // 2. Second pass: Apply actual shadow color using SrcIn blend mode - // - SrcIn preserves the alpha channel from the black shadow - // - Only the color channels are replaced, maintaining transparency - // - This prevents overlapping shadows from accumulating opacity - // - // This approach is essential for complex shapes with transparency where - // multiple shadow areas might overlap, ensuring visual consistency. - let inherited_layer_blur = match element.shape_type { - Type::Frame(_) | Type::Group(_) => element.blur, - _ => None, - }; - - for shadow in element.drop_shadows_visible() { - let paint = skia::Paint::default(); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - - self.surfaces - .canvas(SurfaceId::DropShadows) - .save_layer(&layer_rec); - - // First pass: Render shadow in black to establish alpha mask - let element_extrect = - extrect.get_or_insert_with(|| element.extrect(tree, scale)); - self.render_drop_black_shadow( - element, - element_extrect, - shadow, - clip_bounds.clone(), - scale, - translation, - None, - ); - - if !matches!(element.shape_type, Type::Bool(_)) { - // Nested shapes shadowing - apply black shadow to child shapes too - for shadow_shape_id in element.children.iter() { - let Some(shadow_shape) = tree.get(shadow_shape_id) else { - continue; - }; - if shadow_shape.hidden { - continue; - } - let clip_bounds = node_render_state - .get_nested_shadow_clip_bounds(element, shadow); - - if !matches!(shadow_shape.shape_type, Type::Text(_)) { - self.render_drop_black_shadow( - shadow_shape, - &shadow_shape.extrect(tree, scale), - shadow, - clip_bounds, - scale, - translation, - inherited_layer_blur, - ); - } else { - let paint = skia::Paint::default(); - let layer_rec = - skia::canvas::SaveLayerRec::default().paint(&paint); - - self.surfaces - .canvas(SurfaceId::DropShadows) - .save_layer(&layer_rec); - self.surfaces - .canvas(SurfaceId::DropShadows) - .scale((scale, scale)); - self.surfaces - .canvas(SurfaceId::DropShadows) - .translate(translation); - - let mut transformed_shadow: Cow = Cow::Borrowed(shadow); - - transformed_shadow.to_mut().color = skia::Color::BLACK; - transformed_shadow.to_mut().blur = - transformed_shadow.blur * scale; - transformed_shadow.to_mut().spread = - transformed_shadow.spread * scale; - - let mut new_shadow_paint = skia::Paint::default(); - new_shadow_paint.set_image_filter( - transformed_shadow.get_drop_shadow_filter(), - ); - new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver); - - self.with_nested_blurs_suppressed(|state| { - state.render_shape( - shadow_shape, - clip_bounds, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - true, - None, - Some(vec![new_shadow_paint.clone()]), - ); - }); - self.surfaces.canvas(SurfaceId::DropShadows).restore(); - } - } - } - - // Second pass: Apply actual shadow color using SrcIn blend mode - // This preserves the alpha channel from the black shadow while - // replacing only the color channels, preventing opacity accumulation - let mut paint = skia::Paint::default(); - paint.set_color(shadow.color); - paint.set_blend_mode(skia::BlendMode::SrcIn); - self.surfaces - .canvas(SurfaceId::DropShadows) - .draw_paint(&paint); - - self.surfaces.canvas(SurfaceId::DropShadows).restore(); - } + if !skip_shadows + && !shadows_already_rendered + && !matches!(element.shape_type, Type::Text(_)) + { + self.render_element_drop_shadows_and_composite( + element, + tree, + &mut extrect, + clip_bounds.clone(), + scale, + translation, + &node_render_state, + ); } - if let Some(clips) = clip_bounds.as_ref() { - let antialias = element.should_use_antialias(scale); - - self.surfaces.canvas(SurfaceId::Current).save(); - for (bounds, corners, transform) in clips.iter() { - let mut total_matrix = Matrix::new_identity(); - total_matrix.pre_scale((scale, scale), None); - total_matrix.pre_translate((translation.0, translation.1)); - total_matrix.pre_concat(transform); - - self.surfaces - .canvas(SurfaceId::Current) - .concat(&total_matrix); - - if let Some(corners) = corners { - let rrect = RRect::new_rect_radii(*bounds, corners); - self.surfaces.canvas(SurfaceId::Current).clip_rrect( - rrect, - skia::ClipOp::Intersect, - antialias, - ); - } else { - self.surfaces.canvas(SurfaceId::Current).clip_rect( - *bounds, - skia::ClipOp::Intersect, - antialias, - ); - } - - self.surfaces - .canvas(SurfaceId::Current) - .concat(&total_matrix.invert().unwrap_or_default()); - } - - self.surfaces - .draw_into(SurfaceId::DropShadows, SurfaceId::Current, None); - - self.surfaces.canvas(SurfaceId::Current).restore(); - } else { - self.surfaces - .draw_into(SurfaceId::DropShadows, SurfaceId::Current, None); - } - - self.surfaces - .canvas(SurfaceId::DropShadows) - .clear(skia::Color::TRANSPARENT); - self.render_shape( element, clip_bounds.clone(), @@ -2063,8 +2093,13 @@ impl RenderState { } } else { performance::begin_measure!("render_shape_tree::uncached"); + // Only allow stopping (yielding) if the current tile is NOT visible. + // This ensures all visible tiles render synchronously before showing, + // eliminating empty squares during zoom. Interest-area tiles can still yield. + let tile_is_visible = self.tile_viewbox.is_visible(¤t_tile); + let can_stop = allow_stop && !tile_is_visible; let (is_empty, early_return) = - self.render_shape_tree_partial_uncached(tree, timestamp, allow_stop)?; + self.render_shape_tree_partial_uncached(tree, timestamp, can_stop)?; if early_return { return Ok(()); @@ -2189,17 +2224,20 @@ impl RenderState { * Given a shape, check the indexes and update it's location in the tile set * returns the tiles that have changed in the process. */ - pub fn update_shape_tiles(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Vec { + pub fn update_shape_tiles( + &mut self, + shape: &Shape, + tree: ShapesPoolRef, + ) -> HashSet { let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree); - let old_tiles = self + // Collect old tiles to avoid borrow conflict with remove_shape_at + let old_tiles: Vec<_> = self .tiles .get_tiles_of(shape.id) - .map_or(Vec::new(), |tiles| tiles.iter().copied().collect()); + .map_or(Vec::new(), |t| t.iter().copied().collect()); - let new_tiles = (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))); - - let mut result = HashSet::::new(); + let mut result = HashSet::::with_capacity(old_tiles.len()); // First, remove the shape from all tiles where it was previously located for tile in old_tiles { @@ -2208,12 +2246,66 @@ impl RenderState { } // Then, add the shape to the new tiles - for tile in new_tiles { + for tile in (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))) { self.tiles.add_shape_at(tile, shape.id); result.insert(tile); } - result.iter().copied().collect() + result + } + + /* + * Incremental version of update_shape_tiles for pan/zoom operations. + * Updates the tile index and returns ONLY tiles that need cache invalidation. + * + * During pan operations, shapes don't move in world coordinates. The interest + * area (viewport) moves, which changes which tiles we track in the index, but + * tiles that were already cached don't need re-rendering just because the + * viewport moved. + * + * This function: + * 1. Updates the tile index (adds/removes shapes from tiles based on interest area) + * 2. Returns empty vec for cache invalidation (pan doesn't change tile content) + * + * Tile cache invalidation only happens when shapes actually move or change, + * which is handled by rebuild_touched_tiles, not during pan/zoom. + */ + pub fn update_shape_tiles_incremental( + &mut self, + shape: &Shape, + tree: ShapesPoolRef, + ) -> Vec { + let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree); + + let old_tiles: HashSet = self + .tiles + .get_tiles_of(shape.id) + .map_or(HashSet::new(), |tiles| tiles.iter().copied().collect()); + + let new_tiles: HashSet = (rsx..=rex) + .flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))) + .collect(); + + // Tiles where shape is being removed from index (left interest area) + let removed: Vec<_> = old_tiles.difference(&new_tiles).copied().collect(); + // Tiles where shape is being added to index (entered interest area) + let added: Vec<_> = new_tiles.difference(&old_tiles).copied().collect(); + + // Update the index: remove from old tiles + for tile in &removed { + self.tiles.remove_shape_at(*tile, shape.id); + } + + // Update the index: add to new tiles + for tile in &added { + self.tiles.add_shape_at(*tile, shape.id); + } + + // Don't invalidate cache for pan/zoom - the tile content hasn't changed, + // only the interest area moved. Tiles that were cached are still valid. + // New tiles that entered the interest area will be rendered fresh since + // they weren't in the cache anyway. + Vec::new() } /* @@ -2239,12 +2331,22 @@ impl RenderState { pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) { performance::begin_measure!("rebuild_tiles_shallow"); - let mut all_tiles = HashSet::::new(); + // Check if zoom changed - if so, we need full cache invalidation + // because tiles are rendered at specific zoom levels + let zoom_changed = self.zoom_changed(); + + let mut tiles_to_invalidate = HashSet::::new(); let mut nodes = vec![Uuid::nil()]; while let Some(shape_id) = nodes.pop() { if let Some(shape) = tree.get(&shape_id) { if shape_id != Uuid::nil() { - all_tiles.extend(self.update_shape_tiles(shape, tree)); + if zoom_changed { + // Zoom changed: use full update that tracks all affected tiles + tiles_to_invalidate.extend(self.update_shape_tiles(shape, tree)); + } else { + // Pan only: use incremental update that preserves valid cached tiles + self.update_shape_tiles_incremental(shape, tree); + } } else { // We only need to rebuild tiles from the first level. for child_id in shape.children_ids_iter(false) { @@ -2256,9 +2358,6 @@ impl RenderState { // Invalidate changed tiles - old content stays visible until new tiles render self.surfaces.remove_cached_tiles(self.background_color); - for tile in all_tiles { - self.remove_cached_tile(tile); - } performance::end_measure!("rebuild_tiles_shallow"); } @@ -2307,7 +2406,7 @@ impl RenderState { let mut all_tiles = HashSet::::new(); - let ids = self.touched_ids.clone(); + let ids = std::mem::take(&mut self.touched_ids); for shape_id in ids.iter() { if let Some(shape) = tree.get(shape_id) { @@ -2322,8 +2421,6 @@ impl RenderState { self.remove_cached_tile(tile); } - self.clean_touched(); - performance::end_measure!("rebuild_touched_tiles"); } @@ -2380,6 +2477,7 @@ impl RenderState { self.touched_ids.insert(uuid); } + #[allow(dead_code)] pub fn clean_touched(&mut self) { self.touched_ids.clear(); } diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index 1d8ad98084..0875fdd649 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -2,7 +2,7 @@ use skia_safe::{self as skia, Paint, RRect}; use super::{filters, RenderState, SurfaceId}; use crate::render::get_source_rect; -use crate::shapes::{Fill, Frame, ImageFill, Rect, Shape, Type}; +use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type}; fn draw_image_fill( render_state: &mut RenderState, @@ -92,6 +92,76 @@ fn draw_image_fill( * This SHOULD be the only public function in this module. */ pub fn render( + render_state: &mut RenderState, + shape: &Shape, + fills: &[Fill], + antialias: bool, + surface_id: SurfaceId, +) { + if fills.is_empty() { + return; + } + + // Image fills use draw_image_fill which needs render_state for GPU images + // and sampling options that get_fill_shader (used by merge_fills) lacks. + let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_))); + if has_image_fills { + for fill in fills.iter().rev() { + render_single_fill(render_state, shape, fill, antialias, surface_id); + } + return; + } + + let mut paint = merge_fills(fills, shape.selrect); + paint.set_anti_alias(antialias); + + if let Some(image_filter) = shape.image_filter(1.) { + let bounds = image_filter.compute_fast_bounds(shape.selrect); + if filters::render_with_filter_surface( + render_state, + bounds, + surface_id, + |state, temp_surface| { + let mut filtered_paint = paint.clone(); + filtered_paint.set_image_filter(image_filter.clone()); + draw_fill_to_surface(state, shape, temp_surface, &filtered_paint); + }, + ) { + return; + } else { + paint.set_image_filter(image_filter); + } + } + + draw_fill_to_surface(render_state, shape, surface_id, &paint); +} + +/// Draws a single paint (with a merged shader) to the appropriate surface +/// based on the shape type. +fn draw_fill_to_surface( + render_state: &mut RenderState, + shape: &Shape, + surface_id: SurfaceId, + paint: &Paint, +) { + match &shape.shape_type { + Type::Rect(_) | Type::Frame(_) => { + render_state.surfaces.draw_rect_to(surface_id, shape, paint); + } + Type::Circle => { + render_state + .surfaces + .draw_circle_to(surface_id, shape, paint); + } + Type::Path(_) | Type::Bool(_) => { + render_state.surfaces.draw_path_to(surface_id, shape, paint); + } + Type::Group(_) => {} + _ => unreachable!("This shape should not have fills"), + } +} + +fn render_single_fill( render_state: &mut RenderState, shape: &Shape, fill: &Fill, @@ -108,7 +178,14 @@ pub fn render( |state, temp_surface| { let mut filtered_paint = paint.clone(); filtered_paint.set_image_filter(image_filter.clone()); - draw_fill_to_surface(state, shape, fill, antialias, temp_surface, &filtered_paint); + draw_single_fill_to_surface( + state, + shape, + fill, + antialias, + temp_surface, + &filtered_paint, + ); }, ) { return; @@ -117,10 +194,10 @@ pub fn render( } } - draw_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint); + draw_single_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint); } -fn draw_fill_to_surface( +fn draw_single_fill_to_surface( render_state: &mut RenderState, shape: &Shape, fill: &Fill, @@ -153,8 +230,6 @@ fn draw_fill_to_surface( (_, Type::Group(_)) => { // Groups can have fills but they propagate them to their children } - (_, _) => { - unreachable!("This shape should not have fills") - } + _ => unreachable!("This shape should not have fills"), } } diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index 64a6d7533a..9a0862cbff 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -40,7 +40,7 @@ pub fn render_stroke_inner_shadows( if !shape.has_fills() { for shadow in shape.inner_shadows_visible() { let filter = shadow.get_inner_shadow_filter(); - strokes::render( + strokes::render_single( render_state, shape, stroke, diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 0d7797b8fb..ff61502d7c 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -1,7 +1,7 @@ use crate::math::{Matrix, Point, Rect}; use crate::shapes::{ - Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs, Type, + merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, Type, }; use skia_safe::{self as skia, ImageFilter, RRect}; @@ -9,32 +9,28 @@ use super::{filters, RenderState, SurfaceId}; use crate::render::filters::compose_filters; use crate::render::{get_dest_rect, get_source_rect}; -// FIXME: See if we can simplify these arguments #[allow(clippy::too_many_arguments)] fn draw_stroke_on_rect( canvas: &skia::Canvas, stroke: &Stroke, rect: &Rect, - selrect: &Rect, corners: &Option, - svg_attrs: Option<&SvgAttrs>, + paint: &skia::Paint, scale: f32, shadow: Option<&ImageFilter>, blur: Option<&ImageFilter>, antialias: bool, ) { - // Draw the different kind of strokes for a rect is straightforward, we just need apply a stroke to: - // - The same rect if it's a center stroke - // - A bigger rect if it's an outer stroke - // - A smaller rect if it's an outer stroke let stroke_rect = stroke.aligned_rect(rect, scale); - let mut paint = stroke.to_paint(selrect, svg_attrs, antialias); + let mut paint = paint.clone(); // Apply both blur and shadow filters if present, composing them if necessary. let filter = compose_filters(blur, shadow); paint.set_image_filter(filter); - match corners { + // By default just draw the rect. Only dotted inner/outer strokes need + // clipping to prevent the dotted pattern from appearing in wrong areas. + let draw_stroke = || match corners { Some(radii) => { let radii = stroke.outer_corners(radii); let rrect = RRect::new_rect_radii(stroke_rect, &radii); @@ -43,34 +39,58 @@ fn draw_stroke_on_rect( None => { canvas.draw_rect(stroke_rect, &paint); } + }; + + if let Some(clip_op) = stroke.clip_op() { + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + canvas.save_layer(&layer_rec); + match corners { + Some(radii) => { + let rrect = RRect::new_rect_radii(*rect, radii); + canvas.clip_rrect(rrect, clip_op, antialias); + } + None => { + canvas.clip_rect(*rect, clip_op, antialias); + } + } + draw_stroke(); + canvas.restore(); + } else { + draw_stroke(); } } -// FIXME: See if we can simplify these arguments #[allow(clippy::too_many_arguments)] fn draw_stroke_on_circle( canvas: &skia::Canvas, stroke: &Stroke, rect: &Rect, - selrect: &Rect, - svg_attrs: Option<&SvgAttrs>, + paint: &skia::Paint, scale: f32, shadow: Option<&ImageFilter>, blur: Option<&ImageFilter>, antialias: bool, ) { - // Draw the different kind of strokes for an oval is straightforward, we just need apply a stroke to: - // - The same oval if it's a center stroke - // - A bigger oval if it's an outer stroke - // - A smaller oval if it's an outer stroke let stroke_rect = stroke.aligned_rect(rect, scale); - let mut paint = stroke.to_paint(selrect, svg_attrs, antialias); + let mut paint = paint.clone(); // Apply both blur and shadow filters if present, composing them if necessary. let filter = compose_filters(blur, shadow); paint.set_image_filter(filter); - canvas.draw_oval(stroke_rect, &paint); + // By default just draw the circle. Only dotted inner/outer strokes need + // clipping to prevent the dotted pattern from appearing in wrong areas. + if let Some(clip_op) = stroke.clip_op() { + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + canvas.save_layer(&layer_rec); + let mut clip_path = skia::Path::new(); + clip_path.add_oval(rect, None); + canvas.clip_path(&clip_path, clip_op, antialias); + canvas.draw_oval(stroke_rect, &paint); + canvas.restore(); + } else { + canvas.draw_oval(stroke_rect, &paint); + } } fn draw_outer_stroke_path( @@ -122,15 +142,13 @@ fn draw_inner_stroke_path( } // For outer stroke we draw a center stroke (with double width) and use another path with blend mode clear to remove the inner stroke added -// FIXME: See if we can simplify these arguments #[allow(clippy::too_many_arguments)] -pub fn draw_stroke_on_path( +fn draw_stroke_on_path( canvas: &skia::Canvas, stroke: &Stroke, path: &Path, - selrect: &Rect, + paint: &skia::Paint, path_transform: Option<&Matrix>, - svg_attrs: Option<&SvgAttrs>, shadow: Option<&ImageFilter>, blur: Option<&ImageFilter>, antialias: bool, @@ -140,31 +158,28 @@ pub fn draw_stroke_on_path( let is_open = path.is_open(); - let mut paint: skia_safe::Handle<_> = - stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias); - + let mut draw_paint = paint.clone(); let filter = compose_filters(blur, shadow); - paint.set_image_filter(filter); + draw_paint.set_image_filter(filter); match stroke.render_kind(is_open) { StrokeKind::Inner => { - draw_inner_stroke_path(canvas, &skia_path, &paint, blur, antialias); + draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias); } StrokeKind::Center => { - canvas.draw_path(&skia_path, &paint); + canvas.draw_path(&skia_path, &draw_paint); } StrokeKind::Outer => { - draw_outer_stroke_path(canvas, &skia_path, &paint, blur, antialias); + draw_outer_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias); } } handle_stroke_caps( &mut skia_path, stroke, - selrect, canvas, is_open, - svg_attrs, + paint, blur, antialias, ); @@ -207,17 +222,15 @@ fn handle_stroke_cap( } } -// FIXME: See if we can simplify these arguments #[allow(clippy::too_many_arguments)] fn handle_stroke_caps( path: &mut skia::Path, stroke: &Stroke, - selrect: &Rect, canvas: &skia::Canvas, is_open: bool, - svg_attrs: Option<&SvgAttrs>, + paint: &skia::Paint, blur: Option<&ImageFilter>, - antialias: bool, + _antialias: bool, ) { let mut points = vec![Point::default(); path.count_points()]; path.get_points(&mut points); @@ -230,7 +243,7 @@ fn handle_stroke_caps( let first_point = points.first().unwrap(); let last_point = points.last().unwrap(); - let mut paint_stroke = stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias); + let mut paint_stroke = paint.clone(); if let Some(filter) = blur { paint_stroke.set_image_filter(filter.clone()); @@ -405,30 +418,25 @@ fn draw_image_stroke_in_container( match &shape.shape_type { shape_type @ (Type::Rect(_) | Type::Frame(_)) => { + let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias); draw_stroke_on_rect( canvas, stroke, container, - &outer_rect, &shape_type.corners(), - svg_attrs, + &paint, scale, None, None, antialias, ); } - Type::Circle => draw_stroke_on_circle( - canvas, - stroke, - container, - &outer_rect, - svg_attrs, - scale, - None, - None, - antialias, - ), + Type::Circle => { + let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias); + draw_stroke_on_circle( + canvas, stroke, container, &paint, scale, None, None, antialias, + ); + } shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(p) = shape_type.path() { @@ -446,21 +454,21 @@ fn draw_image_stroke_in_container( } } let is_open = p.is_open(); - let mut paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias); + let paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias); canvas.draw_path(&path, &paint); if stroke.render_kind(is_open) == StrokeKind::Outer { // Small extra inner stroke to overlap with the fill // and avoid unnecesary artifacts. - paint.set_stroke_width(1. / scale); - canvas.draw_path(&path, &paint); + let mut thin_paint = paint.clone(); + thin_paint.set_stroke_width(1. / scale); + canvas.draw_path(&path, &thin_paint); } handle_stroke_caps( &mut path, stroke, - &outer_rect, canvas, is_open, - svg_attrs, + &paint, shape.image_filter(1.).as_ref(), antialias, ); @@ -509,8 +517,230 @@ fn draw_image_stroke_in_container( canvas.restore(); } -#[allow(clippy::too_many_arguments)] +/// Renders all strokes for a shape. Merges strokes that share the same +/// geometry (kind, width, style, caps) into a single draw call to avoid +/// anti-aliasing edge bleed between them. pub fn render( + render_state: &mut RenderState, + shape: &Shape, + strokes: &[&Stroke], + surface_id: Option, + antialias: bool, +) { + if strokes.is_empty() { + return; + } + + let has_image_fills = strokes.iter().any(|s| matches!(s.fill, Fill::Image(_))); + let can_merge = !has_image_fills && strokes.len() > 1 && strokes_share_geometry(strokes); + + if !can_merge { + // When blur is active, render all strokes into a single offscreen surface + // and apply blur once to the composite. This prevents blur from making + // edges semi-transparent and revealing strokes underneath. + if let Some(image_filter) = shape.image_filter(1.) { + let mut content_bounds = shape.selrect; + let max_margin = strokes + .iter() + .map(|s| s.bounds_width(shape.is_open())) + .fold(0.0f32, f32::max); + if max_margin > 0.0 { + content_bounds.inset((-max_margin, -max_margin)); + } + let max_cap = strokes + .iter() + .map(|s| s.cap_bounds_margin()) + .fold(0.0f32, f32::max); + if max_cap > 0.0 { + content_bounds.inset((-max_cap, -max_cap)); + } + let bounds = image_filter.compute_fast_bounds(content_bounds); + let target = surface_id.unwrap_or(SurfaceId::Strokes); + if filters::render_with_filter_surface( + render_state, + bounds, + target, + |state, temp_surface| { + // Use save_layer with the blur filter so it applies once + // to the composite of all strokes, not per-stroke. + let canvas = state.surfaces.canvas(temp_surface); + let mut blur_paint = skia::Paint::default(); + blur_paint.set_image_filter(image_filter.clone()); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint); + canvas.save_layer(&layer_rec); + + for stroke in strokes.iter().rev() { + // bypass_filter=true prevents each stroke from creating + // its own filter surface. The blur on the paint inside + // draw functions is harmless — it composes with the + // layer's filter but the layer filter is the dominant one. + render_single_internal( + state, + shape, + stroke, + Some(temp_surface), + None, + antialias, + true, + true, + ); + } + + state.surfaces.canvas(temp_surface).restore(); + }, + ) { + return; + } + } + + // No blur or filter surface unavailable — draw strokes individually. + for stroke in strokes.iter().rev() { + render_single(render_state, shape, stroke, surface_id, None, antialias); + } + return; + } + + render_merged(render_state, shape, strokes, surface_id, antialias, false); +} + +fn strokes_share_geometry(strokes: &[&Stroke]) -> bool { + strokes.windows(2).all(|pair| { + pair[0].kind == pair[1].kind + && pair[0].width == pair[1].width + && pair[0].style == pair[1].style + && pair[0].cap_start == pair[1].cap_start + && pair[0].cap_end == pair[1].cap_end + }) +} + +fn render_merged( + render_state: &mut RenderState, + shape: &Shape, + strokes: &[&Stroke], + surface_id: Option, + antialias: bool, + bypass_filter: bool, +) { + let representative = *strokes + .last() + .expect("render_merged expects at least one stroke"); + + let blur_filter = if bypass_filter { + None + } else { + shape.image_filter(1.) + }; + + // Handle blur filter + if !bypass_filter { + if let Some(image_filter) = blur_filter.clone() { + let mut content_bounds = shape.selrect; + let stroke_margin = representative.bounds_width(shape.is_open()); + if stroke_margin > 0.0 { + content_bounds.inset((-stroke_margin, -stroke_margin)); + } + let cap_margin = representative.cap_bounds_margin(); + if cap_margin > 0.0 { + content_bounds.inset((-cap_margin, -cap_margin)); + } + let bounds = image_filter.compute_fast_bounds(content_bounds); + let target = surface_id.unwrap_or(SurfaceId::Strokes); + if filters::render_with_filter_surface( + render_state, + bounds, + target, + |state, temp_surface| { + let blur_filter = image_filter.clone(); + + state.surfaces.apply_mut(temp_surface as u32, |surface| { + let canvas = surface.canvas(); + let mut blur_paint = skia::Paint::default(); + blur_paint.set_image_filter(blur_filter.clone()); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint); + canvas.save_layer(&layer_rec); + }); + + render_merged(state, shape, strokes, Some(temp_surface), antialias, true); + + state.surfaces.apply_mut(temp_surface as u32, |surface| { + surface.canvas().restore(); + }); + }, + ) { + return; + } + } + } + + // `merge_fills` puts fills[0] on top (each new fill goes under the accumulated shader + // via SrcOver), matching the non-merged path where strokes[0] is drawn last (on top). + let fills: Vec = strokes.iter().map(|s| s.fill.clone()).collect(); + + let merged = merge_fills(&fills, shape.selrect); + let scale = render_state.get_scale(); + let target_surface = surface_id.unwrap_or(SurfaceId::Strokes); + let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); + let selrect = shape.selrect; + let svg_attrs = shape.svg_attrs.as_ref(); + let path_transform = shape.to_path_transform(); + + match &shape.shape_type { + shape_type @ (Type::Rect(_) | Type::Frame(_)) => { + let mut paint = representative.to_paint(&selrect, svg_attrs, antialias); + paint.set_shader(merged.shader()); + draw_stroke_on_rect( + canvas, + representative, + &selrect, + &shape_type.corners(), + &paint, + scale, + None, + blur_filter.as_ref(), + antialias, + ); + } + Type::Circle => { + let mut paint = representative.to_paint(&selrect, svg_attrs, antialias); + paint.set_shader(merged.shader()); + draw_stroke_on_circle( + canvas, + representative, + &selrect, + &paint, + scale, + None, + blur_filter.as_ref(), + antialias, + ); + } + Type::Text(_) => {} + shape_type @ (Type::Path(_) | Type::Bool(_)) => { + if let Some(path) = shape_type.path() { + let is_open = path.is_open(); + let mut paint = + representative.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); + paint.set_shader(merged.shader()); + draw_stroke_on_path( + canvas, + representative, + path, + &paint, + path_transform.as_ref(), + None, + blur_filter.as_ref(), + antialias, + ); + } + } + _ => unreachable!("This shape should not have strokes"), + } +} + +/// Renders a single stroke. Used by the shadow module which needs per-stroke +/// shadow filters. +#[allow(clippy::too_many_arguments)] +pub fn render_single( render_state: &mut RenderState, shape: &Shape, stroke: &Stroke, @@ -518,7 +748,7 @@ pub fn render( shadow: Option<&ImageFilter>, antialias: bool, ) { - render_internal( + render_single_internal( render_state, shape, stroke, @@ -526,34 +756,12 @@ pub fn render( shadow, antialias, false, + false, ); } -/// Internal function to render a stroke with support for offscreen blur rendering. -/// -/// # Parameters -/// - `render_state`: The rendering state containing surfaces and context. -/// - `shape`: The shape to render the stroke for. -/// - `stroke`: The stroke configuration (width, fill, style, etc.). -/// - `surface_id`: Optional target surface ID. Defaults to `SurfaceId::Strokes` if `None`. -/// - `shadow`: Optional shadow filter to apply to the stroke. -/// - `antialias`: Whether to use antialiasing for rendering. -/// - `bypass_filter`: -/// - If `false`, attempts to use offscreen filter surface for blur effects. -/// - If `true`, renders directly to the target surface (used for recursive calls to avoid infinite loops when rendering into the filter surface). -/// -/// # Behavior -/// When `bypass_filter` is `false` and the shape has a blur filter: -/// 1. Calculates bounds including stroke width and cap margins. -/// 2. Attempts to render into an offscreen filter surface at unscaled coordinates. -/// 3. If successful, composites the result back to the target surface and returns early. -/// 4. If the offscreen render fails or `bypass_filter` is `true`, renders directly to the target -/// surface using the appropriate drawing function for the shape type. -/// -/// The recursive call with `bypass_filter=true` ensures that when rendering into the filter -/// surface, we don't attempt to create another filter surface, avoiding infinite recursion. #[allow(clippy::too_many_arguments)] -fn render_internal( +fn render_single_internal( render_state: &mut RenderState, shape: &Shape, stroke: &Stroke, @@ -561,10 +769,10 @@ fn render_internal( shadow: Option<&ImageFilter>, antialias: bool, bypass_filter: bool, + skip_blur: bool, ) { if !bypass_filter { if let Some(image_filter) = shape.image_filter(1.) { - // We have to calculate the bounds considering the stroke and the cap margins. let mut content_bounds = shape.selrect; let stroke_margin = stroke.bounds_width(shape.is_open()); if stroke_margin > 0.0 { @@ -582,7 +790,7 @@ fn render_internal( bounds, target, |state, temp_surface| { - render_internal( + render_single_internal( state, shape, stroke, @@ -590,6 +798,7 @@ fn render_internal( shadow, antialias, true, + true, ); }, ) { @@ -605,6 +814,12 @@ fn render_internal( let path_transform = shape.to_path_transform(); let svg_attrs = shape.svg_attrs.as_ref(); + let blur = if skip_blur { + None + } else { + shape.image_filter(1.) + }; + if !matches!(shape.shape_type, Type::Text(_)) && shadow.is_none() && matches!(stroke.fill, Fill::Image(_)) @@ -622,42 +837,45 @@ fn render_internal( } else { match &shape.shape_type { shape_type @ (Type::Rect(_) | Type::Frame(_)) => { + let paint = stroke.to_paint(&selrect, svg_attrs, antialias); draw_stroke_on_rect( canvas, stroke, &selrect, - &selrect, &shape_type.corners(), - svg_attrs, + &paint, scale, shadow, - shape.image_filter(1.).as_ref(), + blur.as_ref(), + antialias, + ); + } + Type::Circle => { + let paint = stroke.to_paint(&selrect, svg_attrs, antialias); + draw_stroke_on_circle( + canvas, + stroke, + &selrect, + &paint, + scale, + shadow, + blur.as_ref(), antialias, ); } - Type::Circle => draw_stroke_on_circle( - canvas, - stroke, - &selrect, - &selrect, - svg_attrs, - scale, - shadow, - shape.image_filter(1.).as_ref(), - antialias, - ), Type::Text(_) => {} shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(path) = shape_type.path() { + let is_open = path.is_open(); + let paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); draw_stroke_on_path( canvas, stroke, path, - &selrect, + &paint, path_transform.as_ref(), - svg_attrs, shadow, - shape.image_filter(1.).as_ref(), + blur.as_ref(), antialias, ); } diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs new file mode 100644 index 0000000000..be37ce627d --- /dev/null +++ b/render-wasm/src/render/text_editor.rs @@ -0,0 +1,240 @@ +use crate::shapes::{Shape, TextContent, Type, VerticalAlign}; +use crate::state::{TextEditorState, TextSelection}; +use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; +use skia_safe::{BlendMode, Canvas, Matrix, Paint, Rect}; + +pub fn render_overlay( + canvas: &Canvas, + editor_state: &TextEditorState, + shape: &Shape, + transform: &Matrix, +) { + if !editor_state.is_active { + return; + } + + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + + canvas.save(); + canvas.concat(transform); + + if editor_state.selection.is_selection() { + render_selection(canvas, editor_state, text_content, shape); + } + + if editor_state.cursor_visible { + render_cursor(canvas, editor_state, text_content, shape); + } + + canvas.restore(); +} + +fn render_cursor( + canvas: &Canvas, + editor_state: &TextEditorState, + text_content: &TextContent, + shape: &Shape, +) { + let Some(rect) = calculate_cursor_rect(editor_state, text_content, shape) else { + return; + }; + + let mut paint = Paint::default(); + paint.set_color(editor_state.theme.cursor_color); + paint.set_anti_alias(true); + + canvas.draw_rect(rect, &paint); +} + +fn render_selection( + canvas: &Canvas, + editor_state: &TextEditorState, + text_content: &TextContent, + shape: &Shape, +) { + let selection = &editor_state.selection; + let rects = calculate_selection_rects(selection, text_content, shape); + + if rects.is_empty() { + return; + } + + let mut paint = Paint::default(); + paint.set_blend_mode(BlendMode::Multiply); + paint.set_color(editor_state.theme.selection_color); + paint.set_anti_alias(true); + for rect in rects { + canvas.draw_rect(rect, &paint); + } +} + +fn vertical_align_offset( + shape: &Shape, + layout_paragraphs: &[&skia_safe::textlayout::Paragraph], +) -> f32 { + let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); + match shape.vertical_align() { + VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0, + VerticalAlign::Bottom => shape.selrect().height() - total_height, + _ => 0.0, + } +} + +fn calculate_cursor_rect( + editor_state: &TextEditorState, + text_content: &TextContent, + shape: &Shape, +) -> Option { + let cursor = editor_state.selection.focus; + let paragraphs = text_content.paragraphs(); + if cursor.paragraph >= paragraphs.len() { + return None; + } + + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); + + if cursor.paragraph >= layout_paragraphs.len() { + return None; + } + + let selrect = shape.selrect(); + + let mut y_offset = vertical_align_offset(shape, &layout_paragraphs); + for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { + if idx == cursor.paragraph { + let char_pos = cursor.char_offset; + // For cursor, we get a zero-width range at the position + // We need to handle edge cases: + // - At start of paragraph: use position 0 + // - At end of paragraph: use last position + let para = ¶graphs[cursor.paragraph]; + let para_char_count: usize = para + .children() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + + let (cursor_x, cursor_height) = if para_char_count == 0 { + // Empty paragraph - use default height + (0.0, laid_out_para.height()) + } else if char_pos == 0 { + let rects = laid_out_para.get_rects_for_range( + 0..1, + RectHeightStyle::Max, + RectWidthStyle::Tight, + ); + if !rects.is_empty() { + (rects[0].rect.left(), rects[0].rect.height()) + } else { + (0.0, laid_out_para.height()) + } + } else if char_pos >= para_char_count { + let rects = laid_out_para.get_rects_for_range( + para_char_count.saturating_sub(1)..para_char_count, + RectHeightStyle::Max, + RectWidthStyle::Tight, + ); + if !rects.is_empty() { + (rects[0].rect.right(), rects[0].rect.height()) + } else { + (laid_out_para.longest_line(), laid_out_para.height()) + } + } else { + let rects = laid_out_para.get_rects_for_range( + char_pos..char_pos + 1, + RectHeightStyle::Max, + RectWidthStyle::Tight, + ); + if !rects.is_empty() { + (rects[0].rect.left(), rects[0].rect.height()) + } else { + // Fallback: use glyph position + let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); + (pos.position as f32, laid_out_para.height()) + } + }; + + return Some(Rect::from_xywh( + selrect.x() + cursor_x, + selrect.y() + y_offset, + editor_state.theme.cursor_width, + cursor_height, + )); + } + y_offset += laid_out_para.height(); + } + + None +} + +fn calculate_selection_rects( + selection: &TextSelection, + text_content: &TextContent, + shape: &Shape, +) -> Vec { + let mut rects = Vec::new(); + + let start = selection.start(); + let end = selection.end(); + + let paragraphs = text_content.paragraphs(); + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); + + let selrect = shape.selrect(); + let mut y_offset = vertical_align_offset(shape, &layout_paragraphs); + + for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() { + let para_height = laid_out_para.height(); + + // Check if this paragraph is in selection range + if para_idx < start.paragraph || para_idx > end.paragraph { + y_offset += para_height; + continue; + } + + // Calculate character range for this paragraph + let para = ¶graphs[para_idx]; + let para_char_count: usize = para + .children() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + + let range_start = if para_idx == start.paragraph { + start.char_offset + } else { + 0 + }; + + let range_end = if para_idx == end.paragraph { + end.char_offset + } else { + para_char_count + }; + + if range_start < range_end { + use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; + let text_boxes = laid_out_para.get_rects_for_range( + range_start..range_end, + RectHeightStyle::Max, + RectWidthStyle::Tight, + ); + + for text_box in text_boxes { + let r = text_box.rect; + rects.push(Rect::from_xywh( + selrect.x() + r.left(), + selrect.y() + y_offset + r.top(), + r.width(), + r.height(), + )); + } + } + + y_offset += para_height; + } + + rects +} diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 48c3bda1c7..2eed111226 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -620,6 +620,7 @@ impl Shape { (added, removed) } + #[allow(dead_code)] pub fn fills(&self) -> std::slice::Iter<'_, Fill> { self.fills.iter() } @@ -1119,6 +1120,28 @@ impl Shape { } } + /// Returns children in forward (non-reversed) order - useful for layout calculations + pub fn children_ids_iter_forward( + &self, + include_hidden: bool, + ) -> Box + '_> { + if include_hidden { + return Box::new(self.children.iter()); + } + + if let Type::Bool(_) = self.shape_type { + Box::new([].iter()) + } else if let Type::Group(group) = self.shape_type { + if group.masked { + Box::new(self.children.iter().skip(1)) + } else { + Box::new(self.children.iter()) + } + } else { + Box::new(self.children.iter()) + } + } + pub fn all_children( &self, shapes: ShapesPoolRef, diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 6a772527eb..443669c121 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -241,10 +241,14 @@ pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint { if let Some(shader) = shader { combined_shader = match combined_shader { + // Use SrcOver and treat the newly encountered fill as the source (top), + // overlaying it over the previously composed shader (destination/bottom). + // This avoids edge bleed from underlying fills when anti-aliasing causes + // fractional coverage at shape boundaries. Some(existing_shader) => Some(skia::shaders::blend( - skia::Blender::mode(skia::BlendMode::DstOver), - existing_shader, + skia::Blender::mode(skia::BlendMode::SrcOver), shader, + existing_shader, )), None => Some(shader), }; diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 1af06713a7..df2da87b3a 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -300,7 +300,20 @@ fn propagate_reflow( Type::Frame(Frame { layout: Some(_), .. }) => { - layout_reflows.insert(*id); + let mut skip_reflow = false; + if shape.is_layout_horizontal_fill() || shape.is_layout_vertical_fill() { + if let Some(parent_id) = shape.parent_id { + if parent_id != Uuid::nil() && !reflown.contains(&parent_id) { + // If this is a fill layout but the parent has not been reflown yet + // we wait for the next iteration for reflow + skip_reflow = true; + } + } + } + + if !skip_reflow { + layout_reflows.insert(*id); + } } Type::Group(Group { masked: true }) => { let children_ids = shape.children_ids(true); @@ -417,28 +430,26 @@ pub fn propagate_modifiers( } } } - - let mut layout_reflows_vec: Vec = layout_reflows.into_iter().collect(); - - // We sort the reflows so they are process first the ones that are more - // deep in the tree structure. This way we can be sure that the children layouts - // are already reflowed. + // We sort the reflows so they are processed deepest-first in the + // tree structure. This way we can be sure that the children layouts + // are already reflowed before their parents. + let mut layout_reflows_vec: Vec = + std::mem::take(&mut layout_reflows).into_iter().collect(); layout_reflows_vec.sort_unstable_by(|id_a, id_b| { let da = shapes.get_depth(id_a); let db = shapes.get_depth(id_b); db.cmp(&da) }); - let mut bounds_temp = bounds.clone(); - for id in layout_reflows_vec.iter() { + for id in &layout_reflows_vec { if reflown.contains(id) { continue; } - reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds_temp); + reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds); } - layout_reflows = HashSet::new(); } + #[allow(dead_code)] modifiers .iter() .map(|(key, val)| TransformEntry::from_input(*key, *val)) diff --git a/render-wasm/src/shapes/modifiers/flex_layout.rs b/render-wasm/src/shapes/modifiers/flex_layout.rs index 6377379306..72191a32a2 100644 --- a/render-wasm/src/shapes/modifiers/flex_layout.rs +++ b/render-wasm/src/shapes/modifiers/flex_layout.rs @@ -184,15 +184,18 @@ fn initialize_tracks( ) -> Vec { let mut tracks = Vec::::new(); let mut current_track = TrackData::default(); - let mut children = shape.children_ids(true); let mut first = true; - if flex_data.is_reverse() { - children.reverse(); - } + // When is_reverse() is true, we need forward order (children_ids_iter_forward). + // When is_reverse() is false, we need reversed order (children_ids_iter). + let children_iter: Box> = if flex_data.is_reverse() { + Box::new(shape.children_ids_iter_forward(true).copied()) + } else { + Box::new(shape.children_ids_iter(true).copied()) + }; - for child_id in children.iter() { - let Some(child) = shapes.get(child_id) else { + for child_id in children_iter { + let Some(child) = shapes.get(&child_id) else { continue; }; @@ -293,7 +296,7 @@ fn distribute_fill_main_space(layout_axis: &LayoutAxis, tracks: &mut [TrackData] track.main_size += delta; if (child.main_size - child.max_main_size).abs() < MIN_SIZE { - to_resize_children.remove(i); + to_resize_children.swap_remove(i); } } } @@ -330,7 +333,7 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat left_space -= delta; if (track.across_size - track.max_across_size).abs() < MIN_SIZE { - to_resize_tracks.remove(i); + to_resize_tracks.swap_remove(i); } } } diff --git a/render-wasm/src/shapes/modifiers/grid_layout.rs b/render-wasm/src/shapes/modifiers/grid_layout.rs index 93e7ac571e..7b2a314989 100644 --- a/render-wasm/src/shapes/modifiers/grid_layout.rs +++ b/render-wasm/src/shapes/modifiers/grid_layout.rs @@ -6,7 +6,7 @@ use crate::shapes::{ }; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use super::common::GetBounds; @@ -537,7 +537,7 @@ fn cell_bounds( pub fn create_cell_data<'a>( layout_bounds: &Bounds, - children: &[Uuid], + children: &HashSet, shapes: ShapesPoolRef<'a>, cells: &Vec, column_tracks: &[TrackData], @@ -614,7 +614,7 @@ pub fn grid_cell_data<'a>( let bounds = &mut HashMap::::new(); let layout_bounds = shape.bounds(); - let children = shape.children_ids(false); + let children: HashSet = shape.children_ids_iter(false).copied().collect(); let column_tracks = calculate_tracks( true, @@ -707,7 +707,7 @@ pub fn reflow_grid_layout( ) -> VecDeque { let mut result = VecDeque::new(); let layout_bounds = bounds.find(shape); - let children = shape.children_ids(true); + let children: HashSet = shape.children_ids_iter(true).copied().collect(); let column_tracks = calculate_tracks( true, diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index e45c011a14..599cd83f3d 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -119,6 +119,19 @@ impl Stroke { self.width *= value; } + /// Returns the clip operation for dotted inner/outer strokes. + /// Returns `None` when no clipping is needed (center or non-dotted). + pub fn clip_op(&self) -> Option { + if self.style != StrokeStyle::Dotted || self.kind == StrokeKind::Center { + return None; + } + match self.kind { + StrokeKind::Inner => Some(skia::ClipOp::Intersect), + StrokeKind::Outer => Some(skia::ClipOp::Difference), + StrokeKind::Center => None, + } + } + pub fn delta(&self) -> f32 { match self.kind { StrokeKind::Inner => 0., @@ -128,20 +141,28 @@ impl Stroke { } pub fn outer_rect(&self, rect: &Rect) -> Rect { - match self.kind { - StrokeKind::Inner => Rect::from_xywh( - rect.left + (self.width / 2.), - rect.top + (self.width / 2.), - rect.width() - self.width, - rect.height() - self.width, - ), - StrokeKind::Center => Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height()), - StrokeKind::Outer => Rect::from_xywh( - rect.left - (self.width / 2.), - rect.top - (self.width / 2.), - rect.width() + self.width, - rect.height() + self.width, - ), + match (self.kind, self.style) { + (StrokeKind::Inner, StrokeStyle::Dotted) | (StrokeKind::Outer, StrokeStyle::Dotted) => { + // Boundary so circles center on it and semicircles match after clipping + *rect + } + _ => match self.kind { + StrokeKind::Inner => Rect::from_xywh( + rect.left + (self.width / 2.), + rect.top + (self.width / 2.), + rect.width() - self.width, + rect.height() - self.width, + ), + StrokeKind::Center => { + Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height()) + } + StrokeKind::Outer => Rect::from_xywh( + rect.left - (self.width / 2.), + rect.top - (self.width / 2.), + rect.width() + self.width, + rect.height() + self.width, + ), + }, } } @@ -155,6 +176,11 @@ impl Stroke { } pub fn outer_corners(&self, corners: &Corners) -> Corners { + if matches!(self.style, StrokeStyle::Dotted | StrokeStyle::Dashed) { + // Path at boundary so no corner offset + return *corners; + } + let offset = match self.kind { StrokeKind::Center => 0.0, StrokeKind::Inner => -self.width / 2.0, diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 42cb6cd373..feaab039fb 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -116,6 +116,7 @@ impl TextContentSize { pub struct TextPositionWithAffinity { pub position_with_affinity: PositionWithAffinity, pub paragraph: i32, + #[allow(dead_code)] pub span: i32, pub offset: i32, } @@ -316,6 +317,10 @@ impl TextContent { &self.paragraphs } + pub fn paragraphs_mut(&mut self) -> &mut Vec { + &mut self.paragraphs + } + pub fn width(&self) -> f32 { self.size.width } @@ -428,8 +433,16 @@ impl TextContent { let end_y = offset_y + layout_paragraph.height(); // We only test against paragraphs that can contain the current y - // coordinate. - if point.y > start_y && point.y < end_y { + // coordinate. Use >= for start and handle zero-height paragraphs. + let paragraph_height = layout_paragraph.height(); + let matches = if paragraph_height > 0.0 { + point.y >= start_y && point.y < end_y + } else { + // For zero-height paragraphs (empty lines), match if we're at the start position + point.y >= start_y && point.y <= start_y + 1.0 + }; + + if matches { let position_with_affinity = layout_paragraph.get_glyph_position_at_coordinate(*point); if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) { @@ -438,18 +451,37 @@ impl TextContent { // in which span we are. let mut computed_position = 0; let mut span_offset = 0; - for span in paragraph.children() { - span_index += 1; - let length = span.text.len(); - let start_position = computed_position; - let end_position = computed_position + length; - let current_position = position_with_affinity.position as usize; - if start_position <= current_position && end_position >= current_position { - span_offset = position_with_affinity.position - start_position as i32; - break; + + // If paragraph has no spans, default to span 0, offset 0 + if paragraph.children().is_empty() { + span_index = 0; + span_offset = 0; + } else { + for span in paragraph.children() { + span_index += 1; + let length = span.text.chars().count(); + let start_position = computed_position; + let end_position = computed_position + length; + let current_position = position_with_affinity.position as usize; + + // Handle empty spans: if the span is empty and current position + // matches the start, this is the right span + if length == 0 && current_position == start_position { + span_offset = 0; + break; + } + + if start_position <= current_position + && end_position >= current_position + { + span_offset = + position_with_affinity.position - start_position as i32; + break; + } + computed_position += length; } - computed_position += length; } + return Some(TextPositionWithAffinity::new( position_with_affinity, paragraph_index, @@ -460,6 +492,26 @@ impl TextContent { } offset_y += layout_paragraph.height(); } + + // Handle completely empty text shapes: if there are no paragraphs or all paragraphs + // are empty, and the click is within the text shape bounds, return a default position + if (self.paragraphs().is_empty() || self.layout.paragraphs.is_empty()) + && self.bounds.contains(*point) + { + // Create a default position at the start of the text + use skia_safe::textlayout::Affinity; + let default_position = PositionWithAffinity { + position: 0, + affinity: Affinity::Downstream, + }; + return Some(TextPositionWithAffinity::new( + default_position, + 0, // paragraph 0 + 0, // span 0 + 0, // offset 0 + )); + } + None } @@ -838,6 +890,10 @@ impl Paragraph { &self.children } + pub fn children_mut(&mut self) -> &mut Vec { + &mut self.children + } + #[allow(dead_code)] fn add_span(&mut self, span: TextSpan) { self.children.push(span); @@ -847,6 +903,26 @@ impl Paragraph { self.line_height } + pub fn letter_spacing(&self) -> f32 { + self.letter_spacing + } + + pub fn text_align(&self) -> TextAlign { + self.text_align + } + + pub fn text_direction(&self) -> TextDirection { + self.text_direction + } + + pub fn text_decoration(&self) -> Option { + self.text_decoration + } + + pub fn text_transform(&self) -> Option { + self.text_transform + } + pub fn paragraph_to_style(&self) -> ParagraphStyle { let mut style = ParagraphStyle::default(); @@ -1228,14 +1304,21 @@ pub fn calculate_text_layout_data( let current_y = para_layout.y; let text_paragraph = text_paragraphs.get(paragraph_index); if let Some(text_para) = text_paragraph { - let mut span_ranges: Vec<(usize, usize, usize)> = vec![]; + let mut span_ranges: Vec<(usize, usize, usize, String, String)> = vec![]; let mut cur = 0; for (span_index, span) in text_para.children().iter().enumerate() { - let text: String = span.apply_text_transform(); - span_ranges.push((cur, cur + text.len(), span_index)); - cur += text.len(); + let transformed_text: String = span.apply_text_transform(); + let original_text = span.text.clone(); + let text = transformed_text.clone(); + let text_len = text.len(); + span_ranges.push((cur, cur + text_len, span_index, text, original_text)); + cur += text_len; } - for (start, end, span_index) in span_ranges { + for (start, end, span_index, transformed_text, original_text) in span_ranges { + // Skip empty spans to avoid invalid rect calculations + if start >= end { + continue; + } let rects = para_layout.paragraph.get_rects_for_range( start..end, RectHeightStyle::Tight, @@ -1245,22 +1328,43 @@ pub fn calculate_text_layout_data( let direction = textbox.direct; let mut rect = textbox.rect; let cy = rect.top + rect.height() / 2.0; - let start_pos = para_layout + + // Get byte positions from Skia's transformed text layout + let glyph_start = para_layout .paragraph .get_glyph_position_at_coordinate((rect.left + 0.1, cy)) .position as usize; - let end_pos = para_layout + let glyph_end = para_layout .paragraph .get_glyph_position_at_coordinate((rect.right - 0.1, cy)) .position as usize; - let start_pos = start_pos.saturating_sub(start); - let end_pos = end_pos.saturating_sub(start); + + // Convert to byte positions relative to this span + let byte_start = glyph_start.saturating_sub(start); + let byte_end = glyph_end.saturating_sub(start); + + // Convert byte positions to character positions in ORIGINAL text + // This handles multi-byte UTF-8 and text transform differences + let char_start = transformed_text + .char_indices() + .position(|(i, _)| i >= byte_start) + .unwrap_or(0); + let char_end = transformed_text + .char_indices() + .position(|(i, _)| i >= byte_end) + .unwrap_or_else(|| transformed_text.chars().count()); + + // Clamp to original text length for safety + let original_char_count = original_text.chars().count(); + let final_start = char_start.min(original_char_count); + let final_end = char_end.min(original_char_count); + rect.offset((x, current_y)); position_data.push(PositionData { paragraph: paragraph_index as u32, span: span_index as u32, - start_pos: start_pos as u32, - end_pos: end_pos as u32, + start_pos: final_start as u32, + end_pos: final_end as u32, x: rect.x(), y: rect.y(), width: rect.width(), diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 1664b3bb2d..d7474cc92f 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -1,9 +1,226 @@ #![allow(dead_code)] use crate::shapes::TextPositionWithAffinity; +use crate::uuid::Uuid; +use skia_safe::Color; -/// TODO: Now this is just a tuple with 2 i32 working -/// as indices (paragraph and span). +/// Cursor position within text content. +/// Uses character offsets for precise positioning. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub struct TextCursor { + pub paragraph: usize, + pub char_offset: usize, +} + +impl TextCursor { + pub fn new(paragraph: usize, char_offset: usize) -> Self { + Self { + paragraph, + char_offset, + } + } + + pub fn zero() -> Self { + Self { + paragraph: 0, + char_offset: 0, + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct TextSelection { + pub anchor: TextCursor, + pub focus: TextCursor, +} + +impl TextSelection { + pub fn new() -> Self { + Self::default() + } + + pub fn from_cursor(cursor: TextCursor) -> Self { + Self { + anchor: cursor, + focus: cursor, + } + } + + pub fn is_collapsed(&self) -> bool { + self.anchor == self.focus + } + + pub fn is_selection(&self) -> bool { + !self.is_collapsed() + } + + pub fn set_caret(&mut self, cursor: TextCursor) { + self.anchor = cursor; + self.focus = cursor; + } + + pub fn extend_to(&mut self, cursor: TextCursor) { + self.focus = cursor; + } + + pub fn collapse_to_focus(&mut self) { + self.anchor = self.focus; + } + + pub fn collapse_to_anchor(&mut self) { + self.focus = self.anchor; + } + + pub fn start(&self) -> TextCursor { + if self.anchor.paragraph < self.focus.paragraph { + self.anchor + } else if self.anchor.paragraph > self.focus.paragraph { + self.focus + } else if self.anchor.char_offset <= self.focus.char_offset { + self.anchor + } else { + self.focus + } + } + + pub fn end(&self) -> TextCursor { + if self.anchor.paragraph > self.focus.paragraph { + self.anchor + } else if self.anchor.paragraph < self.focus.paragraph { + self.focus + } else if self.anchor.char_offset >= self.focus.char_offset { + self.anchor + } else { + self.focus + } + } +} + +/// Events that the text editor can emit for frontend synchronization +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum EditorEvent { + None = 0, + ContentChanged = 1, + SelectionChanged = 2, + NeedsLayout = 3, +} + +/// FIXME: It should be better to get these constants from the frontend through the API. +const SELECTION_COLOR: Color = Color::from_argb(255, 0, 209, 184); +const CURSOR_WIDTH: f32 = 1.5; +const CURSOR_COLOR: Color = Color::BLACK; +const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0; + +pub struct TextEditorTheme { + pub selection_color: Color, + pub cursor_width: f32, + pub cursor_color: Color, +} + +pub struct TextEditorState { + pub theme: TextEditorTheme, + pub selection: TextSelection, + pub is_active: bool, + pub active_shape_id: Option, + pub cursor_visible: bool, + pub last_blink_time: f64, + pending_events: Vec, +} + +impl TextEditorState { + pub fn new() -> Self { + Self { + theme: TextEditorTheme { + selection_color: SELECTION_COLOR, + cursor_width: CURSOR_WIDTH, + cursor_color: CURSOR_COLOR, + }, + selection: TextSelection::new(), + is_active: false, + active_shape_id: None, + cursor_visible: true, + last_blink_time: 0.0, + pending_events: Vec::new(), + } + } + + pub fn start(&mut self, shape_id: Uuid) { + self.is_active = true; + self.active_shape_id = Some(shape_id); + self.cursor_visible = true; + self.last_blink_time = 0.0; + self.selection = TextSelection::new(); + self.pending_events.clear(); + } + + pub fn stop(&mut self) { + self.is_active = false; + self.active_shape_id = None; + self.cursor_visible = false; + self.pending_events.clear(); + } + + pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) { + let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize); + self.selection.set_caret(cursor); + self.reset_blink(); + self.push_event(EditorEvent::SelectionChanged); + } + + pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) { + let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize); + self.selection.extend_to(cursor); + self.reset_blink(); + self.push_event(EditorEvent::SelectionChanged); + } + + pub fn update_blink(&mut self, timestamp_ms: f64) { + if !self.is_active { + return; + } + + if self.last_blink_time == 0.0 { + self.last_blink_time = timestamp_ms; + self.cursor_visible = true; + return; + } + + let elapsed = timestamp_ms - self.last_blink_time; + if elapsed >= CURSOR_BLINK_INTERVAL_MS { + self.cursor_visible = !self.cursor_visible; + self.last_blink_time = timestamp_ms; + } + } + + pub fn reset_blink(&mut self) { + self.cursor_visible = true; + self.last_blink_time = 0.0; + } + + pub fn push_event(&mut self, event: EditorEvent) { + if self.pending_events.last() != Some(&event) { + self.pending_events.push(event); + } + } + + pub fn poll_event(&mut self) -> EditorEvent { + self.pending_events.pop().unwrap_or(EditorEvent::None) + } + + pub fn has_pending_events(&self) -> bool { + !self.pending_events.is_empty() + } + + pub fn set_caret_position_from( + &mut self, + text_position_with_affinity: TextPositionWithAffinity, + ) { + self.set_caret_from_position(text_position_with_affinity); + } +} + +/// TODO: Remove legacy code #[derive(Debug, PartialEq, Clone, Copy)] pub struct TextNodePosition { pub paragraph: i32, @@ -15,89 +232,7 @@ impl TextNodePosition { Self { paragraph, span } } - #[allow(dead_code)] pub fn is_invalid(&self) -> bool { self.paragraph < 0 || self.span < 0 } } - -pub struct TextPosition { - node: Option, - offset: i32, -} - -impl TextPosition { - pub fn new() -> Self { - Self { - node: None, - offset: -1, - } - } - - pub fn set(&mut self, node: Option, offset: i32) { - self.node = node; - self.offset = offset; - } -} - -pub struct TextSelection { - focus: TextPosition, - anchor: TextPosition, -} - -impl TextSelection { - pub fn new() -> Self { - Self { - focus: TextPosition::new(), - anchor: TextPosition::new(), - } - } - - #[allow(dead_code)] - pub fn is_caret(&self) -> bool { - self.focus.node == self.anchor.node && self.focus.offset == self.anchor.offset - } - - #[allow(dead_code)] - pub fn is_selection(&self) -> bool { - !self.is_caret() - } - - pub fn set_focus(&mut self, node: Option, offset: i32) { - self.focus.set(node, offset); - } - - pub fn set_anchor(&mut self, node: Option, offset: i32) { - self.anchor.set(node, offset); - } - - pub fn set(&mut self, node: Option, offset: i32) { - self.set_focus(node, offset); - self.set_anchor(node, offset); - } -} - -pub struct TextEditorState { - selection: TextSelection, -} - -impl TextEditorState { - pub fn new() -> Self { - Self { - selection: TextSelection::new(), - } - } - - pub fn set_caret_position_from( - &mut self, - text_position_with_affinity: TextPositionWithAffinity, - ) { - self.selection.set( - Some(TextNodePosition::new( - text_position_with_affinity.paragraph, - text_position_with_affinity.span, - )), - text_position_with_affinity.offset, - ); - } -} diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 7012a2e24b..02bd5c5eb5 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -209,16 +209,19 @@ impl PendingTiles { } } - pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) { - self.list.clear(); - - let columns = tile_viewbox.interest_rect.width(); - let rows = tile_viewbox.interest_rect.height(); - + // Generate tiles in spiral order from center + fn generate_spiral(rect: &TileRect) -> Vec { + let columns = rect.width(); + let rows = rect.height(); let total = columns * rows; - let mut cx = tile_viewbox.interest_rect.center_x(); - let mut cy = tile_viewbox.interest_rect.center_y(); + if total <= 0 { + return Vec::new(); + } + + let mut result = Vec::with_capacity(total as usize); + let mut cx = rect.center_x(); + let mut cy = rect.center_y(); let ratio = (columns as f32 / rows as f32).ceil() as i32; @@ -228,7 +231,7 @@ impl PendingTiles { let mut direction = 0; let mut current = 0; - self.list.push(Tile(cx, cy)); + result.push(Tile(cx, cy)); while current < total { match direction { 0 => cx += 1, @@ -238,7 +241,7 @@ impl PendingTiles { _ => unreachable!("Invalid direction"), } - self.list.push(Tile(cx, cy)); + result.push(Tile(cx, cy)); direction_current += 1; let direction_total = if direction % 2 == 0 { @@ -258,18 +261,44 @@ impl PendingTiles { } current += 1; } - self.list.reverse(); + result.reverse(); + result + } - // Create a new list where the cached tiles go first - let iter1 = self - .list - .iter() - .filter(|t| surfaces.has_cached_tile_surface(**t)); - let iter2 = self - .list - .iter() - .filter(|t| !surfaces.has_cached_tile_surface(**t)); - self.list = iter1.chain(iter2).copied().collect(); + pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) { + self.list.clear(); + + // Generate spiral for the interest area (viewport + margin) + let spiral = Self::generate_spiral(&tile_viewbox.interest_rect); + + // Partition tiles into 4 priority groups (highest priority = processed last due to pop()): + // 1. visible + cached (fastest - just blit from cache) + // 2. visible + uncached (user sees these, render next) + // 3. interest + cached (pre-rendered area, blit from cache) + // 4. interest + uncached (lowest priority - background pre-render) + let mut visible_cached = Vec::new(); + let mut visible_uncached = Vec::new(); + let mut interest_cached = Vec::new(); + let mut interest_uncached = Vec::new(); + + for tile in spiral { + let is_visible = tile_viewbox.visible_rect.contains(&tile); + let is_cached = surfaces.has_cached_tile_surface(tile); + + match (is_visible, is_cached) { + (true, true) => visible_cached.push(tile), + (true, false) => visible_uncached.push(tile), + (false, true) => interest_cached.push(tile), + (false, false) => interest_uncached.push(tile), + } + } + + // Build final list with lowest priority first (they get popped last) + // Order: interest_uncached, interest_cached, visible_uncached, visible_cached + self.list.extend(interest_uncached); + self.list.extend(interest_cached); + self.list.extend(visible_uncached); + self.list.extend(visible_cached); } pub fn pop(&mut self) -> Option { diff --git a/render-wasm/src/wasm.rs b/render-wasm/src/wasm.rs index 8dedf0a97f..3612a79984 100644 --- a/render-wasm/src/wasm.rs +++ b/render-wasm/src/wasm.rs @@ -9,3 +9,4 @@ pub mod shapes; pub mod strokes; pub mod svg_attrs; pub mod text; +pub mod text_editor; diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs new file mode 100644 index 0000000000..37758f5bb1 --- /dev/null +++ b/render-wasm/src/wasm/text_editor.rs @@ -0,0 +1,1341 @@ +use macros::ToJs; + +use crate::math::{Matrix, Point, Rect}; +use crate::mem; +use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign}; +use crate::state::{TextCursor, TextSelection}; +use crate::utils::uuid_from_u32_quartet; +use crate::utils::uuid_to_u32_quartet; +use crate::{with_state, with_state_mut, STATE}; + +#[derive(PartialEq, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum CursorDirection { + Backward = 0, + Forward = 1, + LineBefore = 2, + LineAfter = 3, + LineStart = 4, + LineEnd = 5, +} + +// ============================================================================ +// STATE MANAGEMENT +// ============================================================================ + +#[no_mangle] +pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool { + with_state_mut!(state, { + let shape_id = uuid_from_u32_quartet(a, b, c, d); + + let Some(shape) = state.shapes.get(&shape_id) else { + return false; + }; + + if !matches!(shape.shape_type, Type::Text(_)) { + return false; + } + + state.text_editor_state.start(shape_id); + true + }) +} + +#[no_mangle] +pub extern "C" fn text_editor_stop() { + with_state_mut!(state, { + state.text_editor_state.stop(); + }); +} + +#[no_mangle] +pub extern "C" fn text_editor_is_active() -> bool { + with_state!(state, { state.text_editor_state.is_active }) +} + +#[no_mangle] +pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) { + with_state!(state, { + if let Some(shape_id) = state.text_editor_state.active_shape_id { + let (a, b, c, d) = uuid_to_u32_quartet(&shape_id); + unsafe { + *buffer_ptr = a; + *buffer_ptr.add(1) = b; + *buffer_ptr.add(2) = c; + *buffer_ptr.add(3) = d; + } + } + }) +} + +#[no_mangle] +pub extern "C" fn text_editor_select_all() { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + + let paragraphs = text_content.paragraphs(); + if paragraphs.is_empty() { + return; + } + + let last_para_idx = paragraphs.len() - 1; + let last_para = ¶graphs[last_para_idx]; + let total_chars: usize = last_para + .children() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + + use crate::state::TextCursor; + state.text_editor_state.selection.anchor = TextCursor::new(0, 0); + state.text_editor_state.selection.focus = TextCursor::new(last_para_idx, total_chars); + state.text_editor_state.reset_blink(); + state + .text_editor_state + .push_event(crate::state::EditorEvent::SelectionChanged); + }); +} + +#[no_mangle] +pub extern "C" fn text_editor_poll_event() -> u8 { + with_state_mut!(state, { state.text_editor_state.poll_event() as u8 }) +} + +// ============================================================================ +// SELECTION MANAGEMENT +// ============================================================================ + +#[no_mangle] +pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + + let (shape_matrix, view_matrix, selrect, vertical_align) = { + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + ( + shape.get_concatenated_matrix(&state.shapes), + state.render_state.viewbox.get_matrix(), + shape.selrect(), + shape.vertical_align(), + ) + }; + + let Some(inv_view_matrix) = view_matrix.invert() else { + return; + }; + + let Some(inv_shape_matrix) = shape_matrix.invert() else { + return; + }; + + let mut matrix = Matrix::new_identity(); + matrix.post_concat(&inv_view_matrix); + matrix.post_concat(&inv_shape_matrix); + + let mapped_point = matrix.map_point(Point::new(x, y)); + + let Some(shape) = state.shapes.get_mut(&shape_id) else { + return; + }; + + let Type::Text(text_content) = &mut shape.shape_type else { + return; + }; + + if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() { + let bounds = text_content.bounds; + text_content.update_layout(bounds); + } + + // Calculate vertical alignment offset (same as in render/text_editor.rs) + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); + let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); + let vertical_offset = match vertical_align { + crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0, + crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height, + _ => 0.0, + }; + + // Adjust point: subtract selrect offset and vertical alignment + // The text layout expects coordinates where (0, 0) is the top-left of the text content + let adjusted_point = Point::new( + mapped_point.x - selrect.x(), + mapped_point.y - selrect.y() - vertical_offset, + ); + + if let Some(position) = text_content.get_caret_position_at(&adjusted_point) { + state.text_editor_state.set_caret_from_position(position); + } + }); +} + +#[no_mangle] +pub extern "C" fn text_editor_extend_selection_to_point(x: f32, y: f32) { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + + let (shape_matrix, view_matrix, selrect, vertical_align) = { + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + ( + shape.get_concatenated_matrix(&state.shapes), + state.render_state.viewbox.get_matrix(), + shape.selrect(), + shape.vertical_align(), + ) + }; + + let Some(inv_view_matrix) = view_matrix.invert() else { + return; + }; + + let Some(inv_shape_matrix) = shape_matrix.invert() else { + return; + }; + + let mut matrix = Matrix::new_identity(); + matrix.post_concat(&inv_view_matrix); + matrix.post_concat(&inv_shape_matrix); + + let mapped_point = matrix.map_point(Point::new(x, y)); + + let Some(shape) = state.shapes.get_mut(&shape_id) else { + return; + }; + + let Type::Text(text_content) = &mut shape.shape_type else { + return; + }; + + if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() { + let bounds = text_content.bounds; + text_content.update_layout(bounds); + } + + // Calculate vertical alignment offset (same as in render/text_editor.rs) + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); + let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); + let vertical_offset = match vertical_align { + crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0, + crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height, + _ => 0.0, + }; + + // Adjust point: subtract selrect offset and vertical alignment + let adjusted_point = Point::new( + mapped_point.x - selrect.x(), + mapped_point.y - selrect.y() - vertical_offset, + ); + + if let Some(position) = text_content.get_caret_position_at(&adjusted_point) { + state + .text_editor_state + .extend_selection_from_position(position); + } + }); +} + +// ============================================================================ +// TEXT OPERATIONS +// ============================================================================ + +#[no_mangle] +pub extern "C" fn text_editor_insert_text() { + let bytes = crate::mem::bytes(); + let text = match String::from_utf8(bytes) { + Ok(s) => s, + Err(_) => return, + }; + + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + + let Some(shape) = state.shapes.get_mut(&shape_id) else { + return; + }; + + let Type::Text(text_content) = &mut shape.shape_type else { + return; + }; + + let selection = state.text_editor_state.selection; + + if selection.is_selection() { + delete_selection_range(text_content, &selection); + let start = selection.start(); + state.text_editor_state.selection.set_caret(start); + } + + let cursor = state.text_editor_state.selection.focus; + + if let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) { + let new_cursor = TextCursor::new(cursor.paragraph, new_offset); + state.text_editor_state.selection.set_caret(new_cursor); + } + + text_content.layout.paragraphs.clear(); + text_content.layout.paragraph_builders.clear(); + + state.text_editor_state.reset_blink(); + state + .text_editor_state + .push_event(crate::state::EditorEvent::ContentChanged); + state + .text_editor_state + .push_event(crate::state::EditorEvent::NeedsLayout); + + state.render_state.mark_touched(shape_id); + }); + + crate::mem::free_bytes(); +} + +#[no_mangle] +pub extern "C" fn text_editor_delete_backward() { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + + let Some(shape) = state.shapes.get_mut(&shape_id) else { + return; + }; + + let Type::Text(text_content) = &mut shape.shape_type else { + return; + }; + + let selection = state.text_editor_state.selection; + + if selection.is_selection() { + delete_selection_range(text_content, &selection); + let start = selection.start(); + let clamped = clamp_cursor(start, text_content.paragraphs()); + state.text_editor_state.selection.set_caret(clamped); + } else { + let cursor = selection.focus; + if let Some(new_cursor) = delete_char_before(text_content, &cursor) { + state.text_editor_state.selection.set_caret(new_cursor); + } + } + + text_content.layout.paragraphs.clear(); + text_content.layout.paragraph_builders.clear(); + + state.text_editor_state.reset_blink(); + state + .text_editor_state + .push_event(crate::state::EditorEvent::ContentChanged); + state + .text_editor_state + .push_event(crate::state::EditorEvent::NeedsLayout); + + state.render_state.mark_touched(shape_id); + }); +} + +#[no_mangle] +pub extern "C" fn text_editor_delete_forward() { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + + let Some(shape) = state.shapes.get_mut(&shape_id) else { + return; + }; + + let Type::Text(text_content) = &mut shape.shape_type else { + return; + }; + + let selection = state.text_editor_state.selection; + + if selection.is_selection() { + delete_selection_range(text_content, &selection); + let start = selection.start(); + let clamped = clamp_cursor(start, text_content.paragraphs()); + state.text_editor_state.selection.set_caret(clamped); + } else { + let cursor = selection.focus; + delete_char_after(text_content, &cursor); + let clamped = clamp_cursor(cursor, text_content.paragraphs()); + state.text_editor_state.selection.set_caret(clamped); + } + + text_content.layout.paragraphs.clear(); + text_content.layout.paragraph_builders.clear(); + + state.text_editor_state.reset_blink(); + state + .text_editor_state + .push_event(crate::state::EditorEvent::ContentChanged); + state + .text_editor_state + .push_event(crate::state::EditorEvent::NeedsLayout); + + state.render_state.mark_touched(shape_id); + }); +} + +#[no_mangle] +pub extern "C" fn text_editor_insert_paragraph() { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + + let Some(shape) = state.shapes.get_mut(&shape_id) else { + return; + }; + + let Type::Text(text_content) = &mut shape.shape_type else { + return; + }; + + let selection = state.text_editor_state.selection; + + if selection.is_selection() { + delete_selection_range(text_content, &selection); + let start = selection.start(); + state.text_editor_state.selection.set_caret(start); + } + + let cursor = state.text_editor_state.selection.focus; + + if split_paragraph_at_cursor(text_content, &cursor) { + let new_cursor = TextCursor::new(cursor.paragraph + 1, 0); + state.text_editor_state.selection.set_caret(new_cursor); + } + + text_content.layout.paragraphs.clear(); + text_content.layout.paragraph_builders.clear(); + + state.text_editor_state.reset_blink(); + state + .text_editor_state + .push_event(crate::state::EditorEvent::ContentChanged); + state + .text_editor_state + .push_event(crate::state::EditorEvent::NeedsLayout); + + state.render_state.mark_touched(shape_id); + }); +} + +// ============================================================================ +// NAVIGATION +// ============================================================================ + +#[no_mangle] +pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_selection: bool) { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + + let paragraphs = text_content.paragraphs(); + if paragraphs.is_empty() { + return; + } + + let current = state.text_editor_state.selection.focus; + + let new_cursor = match direction { + CursorDirection::Backward => move_cursor_backward(¤t, paragraphs), + CursorDirection::Forward => move_cursor_forward(¤t, paragraphs), + CursorDirection::LineBefore => { + move_cursor_up(¤t, paragraphs, text_content, shape) + } + CursorDirection::LineAfter => { + move_cursor_down(¤t, paragraphs, text_content, shape) + } + CursorDirection::LineStart => move_cursor_line_start(¤t, paragraphs), + CursorDirection::LineEnd => move_cursor_line_end(¤t, paragraphs), + }; + + if extend_selection { + state.text_editor_state.selection.extend_to(new_cursor); + } else { + state.text_editor_state.selection.set_caret(new_cursor); + } + + state.text_editor_state.reset_blink(); + state + .text_editor_state + .push_event(crate::state::EditorEvent::SelectionChanged); + }); +} + +// ============================================================================ +// RENDERING & EXPORT +// ============================================================================ + +#[no_mangle] +pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { + with_state_mut!(state, { + if !state.text_editor_state.is_active || !state.text_editor_state.cursor_visible { + return std::ptr::null_mut(); + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return std::ptr::null_mut(); + }; + + let Some(shape) = state.shapes.get(&shape_id) else { + return std::ptr::null_mut(); + }; + + let Type::Text(text_content) = &shape.shape_type else { + return std::ptr::null_mut(); + }; + + let cursor = &state.text_editor_state.selection.focus; + + if let Some(rect) = get_cursor_rect(text_content, cursor, shape) { + let mut bytes = vec![0u8; 16]; + bytes[0..4].copy_from_slice(&rect.left().to_le_bytes()); + bytes[4..8].copy_from_slice(&rect.top().to_le_bytes()); + bytes[8..12].copy_from_slice(&rect.width().to_le_bytes()); + bytes[12..16].copy_from_slice(&rect.height().to_le_bytes()); + return mem::write_bytes(bytes); + } + + std::ptr::null_mut() + }) +} + +#[no_mangle] +pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return std::ptr::null_mut(); + } + + if state.text_editor_state.selection.is_collapsed() { + return std::ptr::null_mut(); + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return std::ptr::null_mut(); + }; + + let Some(shape) = state.shapes.get(&shape_id) else { + return std::ptr::null_mut(); + }; + + let Type::Text(text_content) = &shape.shape_type else { + return std::ptr::null_mut(); + }; + + let selection = &state.text_editor_state.selection; + let rects = get_selection_rects(text_content, selection, shape); + + if rects.is_empty() { + return std::ptr::null_mut(); + } + + let mut bytes = Vec::with_capacity(4 + rects.len() * 16); + bytes.extend_from_slice(&(rects.len() as u32).to_le_bytes()); + for rect in rects { + bytes.extend_from_slice(&rect.left().to_le_bytes()); + bytes.extend_from_slice(&rect.top().to_le_bytes()); + bytes.extend_from_slice(&rect.width().to_le_bytes()); + bytes.extend_from_slice(&rect.height().to_le_bytes()); + } + mem::write_bytes(bytes) + }) +} + +#[no_mangle] +pub extern "C" fn text_editor_update_blink(timestamp_ms: f64) { + with_state_mut!(state, { + state.text_editor_state.update_blink(timestamp_ms); + }); +} + +#[no_mangle] +pub extern "C" fn text_editor_render_overlay() { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + + if let Some(shape) = state.shapes.get(&shape_id) { + if let Type::Text(text_content) = &shape.shape_type { + if text_content.needs_update_layout() { + let selrect = shape.selrect(); + if let Some(shape) = state.shapes.get_mut(&shape_id) { + if let Type::Text(text_content) = &mut shape.shape_type { + text_content.update_layout(selrect); + } + } + } + } + } + + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + + let transform = shape.get_concatenated_matrix(&state.shapes); + + use crate::render::text_editor as te_render; + use crate::render::SurfaceId; + + let canvas = state.render_state.surfaces.canvas(SurfaceId::Target); + + canvas.save(); + let viewbox = state.render_state.viewbox; + let zoom = viewbox.zoom * state.render_state.options.dpr(); + canvas.scale((zoom, zoom)); + canvas.translate((-viewbox.area.left, -viewbox.area.top)); + + te_render::render_overlay(canvas, &state.text_editor_state, shape, &transform); + + canvas.restore(); + state.render_state.flush_and_submit(); + }); +} + +#[no_mangle] +pub extern "C" fn text_editor_export_content() -> *mut u8 { + with_state!(state, { + if !state.text_editor_state.is_active { + return std::ptr::null_mut(); + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return std::ptr::null_mut(); + }; + + let Some(shape) = state.shapes.get(&shape_id) else { + return std::ptr::null_mut(); + }; + + let Type::Text(text_content) = &shape.shape_type else { + return std::ptr::null_mut(); + }; + + let mut json_parts: Vec = Vec::new(); + for para in text_content.paragraphs() { + let mut span_parts: Vec = Vec::new(); + for span in para.children() { + let escaped_text = span + .text + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t"); + span_parts.push(format!("\"{}\"", escaped_text)); + } + json_parts.push(format!("[{}]", span_parts.join(","))); + } + let json = format!("[{}]", json_parts.join(",")); + + let mut bytes = json.into_bytes(); + bytes.push(0); + crate::mem::write_bytes(bytes) + }) +} + +#[no_mangle] +pub extern "C" fn text_editor_export_selection() -> *mut u8 { + use std::ptr; + with_state!(state, { + if !state.text_editor_state.is_active { + return ptr::null_mut(); + } + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return ptr::null_mut(); + }; + let Some(shape) = state.shapes.get(&shape_id) else { + return ptr::null_mut(); + }; + let Type::Text(text_content) = &shape.shape_type else { + return ptr::null_mut(); + }; + let selection = &state.text_editor_state.selection; + let start = selection.start(); + let end = selection.end(); + let paragraphs = text_content.paragraphs(); + let mut result = String::new(); + let end_paragraph = end.paragraph.min(paragraphs.len().saturating_sub(1)) + 1; + for (para_idx, _) in paragraphs + .iter() + .enumerate() + .take(end_paragraph) + .skip(start.paragraph) + { + let para = ¶graphs[para_idx]; + let mut para_text = String::new(); + let para_char_count: usize = para + .children() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + let range_start = if para_idx == start.paragraph { + start.char_offset + } else { + 0 + }; + let range_end = if para_idx == end.paragraph { + end.char_offset + } else { + para_char_count + }; + if range_start < range_end { + let mut char_pos = 0; + for span in para.children() { + let span_len = span.text.chars().count(); + let span_start = char_pos; + let span_end = char_pos + span_len; + let sel_start = range_start.max(span_start); + let sel_end = range_end.min(span_end); + if sel_start < sel_end { + let rel_start = sel_start - span_start; + let rel_end = sel_end - span_start; + let text: String = span + .text + .chars() + .skip(rel_start) + .take(rel_end - rel_start) + .collect(); + para_text.push_str(&text); + } + char_pos += span_len; + } + } + if !para_text.is_empty() { + if !result.is_empty() { + result.push('\n'); + } + result.push_str(¶_text); + } + } + let mut bytes = result.into_bytes(); + bytes.push(0); + crate::mem::write_bytes(bytes) + }) +} + +#[no_mangle] +pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 { + with_state!(state, { + if !state.text_editor_state.is_active { + return 0; + } + let sel = &state.text_editor_state.selection; + unsafe { + *buffer_ptr = sel.anchor.paragraph as u32; + *buffer_ptr.add(1) = sel.anchor.char_offset as u32; + *buffer_ptr.add(2) = sel.focus.paragraph as u32; + *buffer_ptr.add(3) = sel.focus.char_offset as u32; + } + 1 + }) +} + +// ============================================================================ +// HELPERS: Cursor & Selection +// ============================================================================ + +fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shape) -> Option { + let paragraphs = text_content.paragraphs(); + if cursor.paragraph >= paragraphs.len() { + return None; + } + + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); + + let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); + let valign_offset = match shape.vertical_align() { + VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0, + VerticalAlign::Bottom => shape.selrect().height() - total_height, + _ => 0.0, + }; + + let mut y_offset = valign_offset; + for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { + if idx == cursor.paragraph { + let char_pos = cursor.char_offset; + + use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; + let rects = laid_out_para.get_rects_for_range( + char_pos..char_pos, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ); + + let (x, height) = if !rects.is_empty() { + (rects[0].rect.left(), rects[0].rect.height()) + } else { + let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); + let height = laid_out_para.height(); + (pos.position as f32, height) + }; + + let cursor_width = 2.0; + let selrect = shape.selrect(); + let base_x = selrect.x(); + let base_y = selrect.y() + y_offset; + + return Some(Rect::from_xywh(base_x + x, base_y, cursor_width, height)); + } + y_offset += laid_out_para.height(); + } + + None +} + +/// Get selection rectangles for a given selection. +fn get_selection_rects( + text_content: &TextContent, + selection: &TextSelection, + shape: &Shape, +) -> Vec { + let mut rects = Vec::new(); + + let start = selection.start(); + let end = selection.end(); + + let paragraphs = text_content.paragraphs(); + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); + + let selrect = shape.selrect(); + + let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); + let valign_offset = match shape.vertical_align() { + VerticalAlign::Center => (selrect.height() - total_height) / 2.0, + VerticalAlign::Bottom => selrect.height() - total_height, + _ => 0.0, + }; + + let mut y_offset = valign_offset; + + for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() { + let para_height = laid_out_para.height(); + + if para_idx < start.paragraph || para_idx > end.paragraph { + y_offset += para_height; + continue; + } + + if para_idx >= paragraphs.len() { + y_offset += para_height; + continue; + } + + let para = ¶graphs[para_idx]; + let para_char_count: usize = para + .children() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + let range_start = if para_idx == start.paragraph { + start.char_offset + } else { + 0 + }; + + let range_end = if para_idx == end.paragraph { + end.char_offset + } else { + para_char_count + }; + + if range_start < range_end { + use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; + let text_boxes = laid_out_para.get_rects_for_range( + range_start..range_end, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ); + + for text_box in text_boxes { + let r = text_box.rect; + rects.push(Rect::from_xywh( + selrect.x() + r.left(), + selrect.y() + y_offset + r.top(), + r.width(), + r.height(), + )); + } + } + + y_offset += para_height; + } + + rects +} + +/// Get total character count in a paragraph. +fn paragraph_char_count(para: &Paragraph) -> usize { + para.children() + .iter() + .map(|span| span.text.chars().count()) + .sum() +} + +/// Clamp a cursor position to valid bounds within the text content. +fn clamp_cursor(cursor: TextCursor, paragraphs: &[Paragraph]) -> TextCursor { + if paragraphs.is_empty() { + return TextCursor::new(0, 0); + } + + let para_idx = cursor.paragraph.min(paragraphs.len() - 1); + let para_len = paragraph_char_count(¶graphs[para_idx]); + let char_offset = cursor.char_offset.min(para_len); + + TextCursor::new(para_idx, char_offset) +} + +/// Move cursor left by one character. +fn move_cursor_backward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor { + if cursor.char_offset > 0 { + TextCursor::new(cursor.paragraph, cursor.char_offset - 1) + } else if cursor.paragraph > 0 { + let prev_para = cursor.paragraph - 1; + let char_count = paragraph_char_count(¶graphs[prev_para]); + TextCursor::new(prev_para, char_count) + } else { + *cursor + } +} + +/// Move cursor right by one character. +fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor { + let para = ¶graphs[cursor.paragraph]; + let char_count = paragraph_char_count(para); + + if cursor.char_offset < char_count { + TextCursor::new(cursor.paragraph, cursor.char_offset + 1) + } else if cursor.paragraph < paragraphs.len() - 1 { + TextCursor::new(cursor.paragraph + 1, 0) + } else { + *cursor + } +} + +/// Move cursor up by one line. +fn move_cursor_up( + cursor: &TextCursor, + paragraphs: &[Paragraph], + _text_content: &TextContent, + _shape: &Shape, +) -> TextCursor { + // TODO: Implement proper line-based navigation using line metrics + if cursor.paragraph > 0 { + let prev_para = cursor.paragraph - 1; + let char_count = paragraph_char_count(¶graphs[prev_para]); + let new_offset = cursor.char_offset.min(char_count); + TextCursor::new(prev_para, new_offset) + } else { + TextCursor::new(cursor.paragraph, 0) + } +} + +/// Move cursor down by one line. +fn move_cursor_down( + cursor: &TextCursor, + paragraphs: &[Paragraph], + _text_content: &TextContent, + _shape: &Shape, +) -> TextCursor { + // TODO: Implement proper line-based navigation using line metrics + if cursor.paragraph < paragraphs.len() - 1 { + let next_para = cursor.paragraph + 1; + let char_count = paragraph_char_count(¶graphs[next_para]); + let new_offset = cursor.char_offset.min(char_count); + TextCursor::new(next_para, new_offset) + } else { + let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); + TextCursor::new(cursor.paragraph, char_count) + } +} + +/// Move cursor to start of current line. +fn move_cursor_line_start(cursor: &TextCursor, _paragraphs: &[Paragraph]) -> TextCursor { + // TODO: Implement proper line-start using line metrics + TextCursor::new(cursor.paragraph, 0) +} + +/// Move cursor to end of current line. +fn move_cursor_line_end(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor { + // TODO: Implement proper line-end using line metrics + let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); + TextCursor::new(cursor.paragraph, char_count) +} + +// ============================================================================ +// HELPERS: Text Modification +// ============================================================================ + +fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, usize)> { + let children = para.children(); + let mut accumulated = 0; + for (span_idx, span) in children.iter().enumerate() { + let span_len = span.text.chars().count(); + if char_offset <= accumulated + span_len { + return Some((span_idx, char_offset - accumulated)); + } + accumulated += span_len; + } + if !children.is_empty() { + let last_idx = children.len() - 1; + let last_len = children[last_idx].text.chars().count(); + return Some((last_idx, last_len)); + } + None +} + +/// Insert text at a cursor position. Returns the new character offset after insertion. +fn insert_text_at_cursor( + text_content: &mut TextContent, + cursor: &TextCursor, + text: &str, +) -> Option { + let paragraphs = text_content.paragraphs_mut(); + if cursor.paragraph >= paragraphs.len() { + return None; + } + + let para = &mut paragraphs[cursor.paragraph]; + + let children = para.children_mut(); + if children.is_empty() { + return None; + } + + if children.len() == 1 && children[0].text.is_empty() { + children[0].set_text(text.to_string()); + return Some(text.chars().count()); + } + + let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.char_offset)?; + + let children = para.children_mut(); + let span = &mut children[span_idx]; + let mut new_text = span.text.clone(); + + let byte_offset = new_text + .char_indices() + .nth(offset_in_span) + .map(|(i, _)| i) + .unwrap_or(new_text.len()); + + new_text.insert_str(byte_offset, text); + span.set_text(new_text); + + Some(cursor.char_offset + text.chars().count()) +} + +/// Delete a range of text specified by a selection. +fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) { + let start = selection.start(); + let end = selection.end(); + + let paragraphs = text_content.paragraphs_mut(); + if start.paragraph >= paragraphs.len() { + return; + } + + if start.paragraph == end.paragraph { + delete_range_in_paragraph( + &mut paragraphs[start.paragraph], + start.char_offset, + end.char_offset, + ); + } else { + let start_para_len = paragraph_char_count(¶graphs[start.paragraph]); + delete_range_in_paragraph( + &mut paragraphs[start.paragraph], + start.char_offset, + start_para_len, + ); + + delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.char_offset); + + if end.paragraph < paragraphs.len() { + let end_para_children: Vec<_> = + paragraphs[end.paragraph].children_mut().drain(..).collect(); + paragraphs[start.paragraph] + .children_mut() + .extend(end_para_children); + } + + if end.paragraph < paragraphs.len() { + paragraphs.drain((start.paragraph + 1)..=end.paragraph); + } + + let children = paragraphs[start.paragraph].children_mut(); + let has_content = children.iter().any(|span| !span.text.is_empty()); + if has_content { + children.retain(|span| !span.text.is_empty()); + } else if children.len() > 1 { + children.truncate(1); + } + } +} + +/// Delete a range of characters within a single paragraph. +fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offset: usize) { + if start_offset >= end_offset { + return; + } + + let mut accumulated = 0; + let mut delete_start_span = None; + let mut delete_end_span = None; + + for (idx, span) in para.children().iter().enumerate() { + let span_len = span.text.chars().count(); + let span_end = accumulated + span_len; + + if delete_start_span.is_none() && start_offset < span_end { + delete_start_span = Some((idx, start_offset - accumulated)); + } + if end_offset <= span_end { + delete_end_span = Some((idx, end_offset - accumulated)); + break; + } + accumulated += span_len; + } + + let Some((start_span_idx, start_in_span)) = delete_start_span else { + return; + }; + let Some((end_span_idx, end_in_span)) = delete_end_span else { + return; + }; + + let children = para.children_mut(); + + if start_span_idx == end_span_idx { + let span = &mut children[start_span_idx]; + let text = span.text.clone(); + let chars: Vec = text.chars().collect(); + + let start_clamped = start_in_span.min(chars.len()); + let end_clamped = end_in_span.min(chars.len()); + + let new_text: String = chars[..start_clamped] + .iter() + .chain(chars[end_clamped..].iter()) + .collect(); + span.set_text(new_text); + } else { + let start_span = &mut children[start_span_idx]; + let text = start_span.text.clone(); + let start_char_count = text.chars().count(); + let start_clamped = start_in_span.min(start_char_count); + let new_text: String = text.chars().take(start_clamped).collect(); + start_span.set_text(new_text); + + let end_span = &mut children[end_span_idx]; + let text = end_span.text.clone(); + let end_char_count = text.chars().count(); + let end_clamped = end_in_span.min(end_char_count); + let new_text: String = text.chars().skip(end_clamped).collect(); + end_span.set_text(new_text); + + if end_span_idx > start_span_idx + 1 { + children.drain((start_span_idx + 1)..end_span_idx); + } + } + + let has_content = children.iter().any(|span| !span.text.is_empty()); + if has_content { + children.retain(|span| !span.text.is_empty()); + } else if !children.is_empty() { + children.truncate(1); + } +} + +/// Delete the character before the cursor. Returns the new cursor position. +fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Option { + if cursor.char_offset > 0 { + let paragraphs = text_content.paragraphs_mut(); + let para = &mut paragraphs[cursor.paragraph]; + let delete_pos = cursor.char_offset - 1; + delete_range_in_paragraph(para, delete_pos, cursor.char_offset); + Some(TextCursor::new(cursor.paragraph, delete_pos)) + } else if cursor.paragraph > 0 { + let prev_para_idx = cursor.paragraph - 1; + let paragraphs = text_content.paragraphs_mut(); + let prev_para_len = paragraph_char_count(¶graphs[prev_para_idx]); + + let current_children: Vec<_> = paragraphs[cursor.paragraph] + .children_mut() + .drain(..) + .collect(); + paragraphs[prev_para_idx] + .children_mut() + .extend(current_children); + + paragraphs.remove(cursor.paragraph); + + Some(TextCursor::new(prev_para_idx, prev_para_len)) + } else { + None + } +} + +/// Delete the character after the cursor. +fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) { + let paragraphs = text_content.paragraphs_mut(); + if cursor.paragraph >= paragraphs.len() { + return; + } + + let para_len = paragraph_char_count(¶graphs[cursor.paragraph]); + + if cursor.char_offset < para_len { + let para = &mut paragraphs[cursor.paragraph]; + delete_range_in_paragraph(para, cursor.char_offset, cursor.char_offset + 1); + } else if cursor.paragraph < paragraphs.len() - 1 { + let next_para_idx = cursor.paragraph + 1; + let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect(); + paragraphs[cursor.paragraph] + .children_mut() + .extend(next_children); + + paragraphs.remove(next_para_idx); + } +} + +/// Split a paragraph at the cursor position. Returns true if split was successful. +fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor) -> bool { + let paragraphs = text_content.paragraphs_mut(); + if cursor.paragraph >= paragraphs.len() { + return false; + } + + let para = ¶graphs[cursor.paragraph]; + + let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.char_offset) else { + return false; + }; + + let mut new_para_children = Vec::new(); + let children = para.children(); + + let current_span = &children[span_idx]; + let span_text = current_span.text.clone(); + let chars: Vec = span_text.chars().collect(); + + if offset_in_span < chars.len() { + let after_text: String = chars[offset_in_span..].iter().collect(); + let mut new_span = current_span.clone(); + new_span.set_text(after_text); + new_para_children.push(new_span); + } + + for child in children.iter().skip(span_idx + 1) { + new_para_children.push(child.clone()); + } + + if new_para_children.is_empty() { + let mut empty_span = current_span.clone(); + empty_span.set_text(String::new()); + new_para_children.push(empty_span); + } + + let text_align = para.text_align(); + let text_direction = para.text_direction(); + let text_decoration = para.text_decoration(); + let text_transform = para.text_transform(); + let line_height = para.line_height(); + let letter_spacing = para.letter_spacing(); + + let para = &mut paragraphs[cursor.paragraph]; + let children = para.children_mut(); + + children.truncate(span_idx + 1); + + if !children.is_empty() { + let span = &mut children[span_idx]; + let text = span.text.clone(); + let new_text: String = text.chars().take(offset_in_span).collect(); + span.set_text(new_text); + } + + let new_para = crate::shapes::Paragraph::new( + text_align, + text_direction, + text_decoration, + text_transform, + line_height, + letter_spacing, + new_para_children, + ); + + paragraphs.insert(cursor.paragraph + 1, new_para); + + true +}