diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9fa432e7d3..4ba57dde95 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,6 +34,8 @@ jobs: corepack enable; corepack install; pnpm install; + pnpm run check-fmt:clj + pnpm run check-fmt:js pnpm run lint:clj - name: Lint Frontend @@ -42,6 +44,9 @@ jobs: corepack enable; corepack install; pnpm install; + pnpm run check-fmt:js + pnpm run check-fmt:clj + pnpm run check-fmt:scss pnpm run lint:clj pnpm run lint:js pnpm run lint:scss @@ -52,7 +57,8 @@ jobs: corepack enable; corepack install; pnpm install; - pnpm run lint:clj + pnpm run check-fmt + pnpm run lint - name: Lint Exporter working-directory: ./exporter @@ -60,7 +66,8 @@ jobs: corepack enable; corepack install; pnpm install; - pnpm run lint:clj + pnpm run check-fmt + pnpm run lint - name: Lint Library working-directory: ./library @@ -68,7 +75,8 @@ jobs: corepack enable; corepack install; pnpm install; - pnpm run lint:clj + pnpm run check-fmt + pnpm run lint test-common: name: "Common Tests" @@ -79,12 +87,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Run tests on JVM - working-directory: ./common - run: | - clojure -M:dev:test - - - name: Run tests on NODE + - name: Run tests working-directory: ./common run: | ./scripts/test diff --git a/.gitignore b/.gitignore index 9958d90cb8..d0a13534b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,4 @@ .pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions -.pnpm-store *-init.clj *.css.json *.jar @@ -20,8 +13,6 @@ .nyc_output .rebel_readline_history .repl -.shadow-cljs -.pnpm-store/ /*.jpg /*.md /*.png @@ -36,6 +27,7 @@ /playground/ /backend/*.md !/backend/AGENTS.md +/backend/.shadow-cljs /backend/*.sql /backend/*.txt /backend/assets/ @@ -48,13 +40,13 @@ /backend/experiments /backend/scripts/_env.local /bundle* -/cd.md /clj-profiler/ /common/coverage /common/target -/deploy +/common/.shadow-cljs /docker/images/bundle* /exporter/target +/exporter/.shadow-cljs /frontend/.storybook/preview-body.html /frontend/.storybook/preview-head.html /frontend/playwright-report/ @@ -68,9 +60,9 @@ /frontend/storybook-static/ /frontend/target/ /frontend/test-results/ +/frontend/.shadow-cljs /other/ -/scripts/ -/telemetry/ +/nexus/ /tmp/ /vendor/**/target /vendor/svgclean/bundle*.js @@ -79,13 +71,11 @@ /library/*.zip /external /penpot-nitrate - -clj-profiler/ -node_modules /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ /render-wasm/target/ +/**/node_modules /**/.yarn/* /.pnpm-store diff --git a/AGENTS.md b/AGENTS.md index d126301300..9505d47698 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Penpot – Copilot Instructions +# Penpot – Instructions ## Architecture Overview @@ -18,7 +18,13 @@ The monorepo is managed with `pnpm` workspaces. The `manage.sh` orchestrates cross-component builds. `run-ci.sh` defines the CI pipeline. ---- +## Search Standards + +When searching code, always use `ripgrep` (rg) instead of grep if +available, as it respects `.gitignore` by default. + +If using grep, try to exclude node_modules and .shadow-cljs directories + ## Build, Test & Lint Commands @@ -28,27 +34,26 @@ Run `./scripts/setup` for setup all dependencies. ```bash -# Dev -pnpm run watch:app # Full dev build (WASM + CLJS + assets) - -# Production Build +# Build (Producution) ./scripts/build # Tests -pnpm run test # Build ClojureScript tests + run node target/tests/test.js -pnpm run watch:test # Watch + auto-rerun on change -pnpm run test:e2e # Playwright e2e tests -pnpm run test:e2e --grep "pattern" # Single e2e test by pattern +pnpm run test # Build ClojureScript tests + run node target/tests/test.js # Lint -pnpm run lint:js # format and linter check for JS -pnpm run lint:clj # format and linter check for CLJ -pnpm run lint:scss # prettier check for SCSS +pnpm run lint:js # Linter for JS/TS +pnpm run lint:clj # Linter for CLJ/CLJS/CLJC +pnpm run lint:scss # Linter for SCSS -# Code formatting -pnpm run fmt:clj # Format CLJ -pnpm run fmt:js # prettier for JS -pnpm run fmt:scss # prettier for SCSS +# Check Code Formart +pnpm run check-fmt:clj # Format CLJ/CLJS/CLJC +pnpm run check-fmt:js # Format JS/TS +pnpm run check-fmt:scss # Format SCSS + +# Code Format (Automatic Formating) +pnpm run fmt:clj # Format CLJ/CLJS/CLJC +pnpm run fmt:js # Format JS/TS +pnpm run fmt:scss # Format SCSS ``` To run a focused ClojureScript unit test: edit @@ -58,28 +63,63 @@ run build:test && node target/tests/test.js`. ### Backend (`cd backend`) -```bash -# Tests (Kaocha) -clojure -M:dev:test # Full suite -clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace +Run `pnpm install` for install all dependencies. -# Lint / Format -pnpm run lint:clj -pnpm run fmt:clj +```bash +# Run full test suite +pnpm run test + +# Run single namespace +pnpm run test --focus backend-tests.rpc-doc-test + +# Check Code Format +pnpm run check-fmt + +# Code Format (Automatic Formatting) +pnpm run fmt + +# Code Linter +pnpm run lint ``` -Test config is in `backend/tests.edn`; test namespaces match `.*-test$` under `test/`. +Test config is in `backend/tests.edn`; test namespaces match +`.*-test$` under `test/` directory. You should not touch this file, +just use it for reference. ### Common (`cd common`) +This contains code that should compile and run under different runtimes: JVM & JS so the commands are +separarated for each runtime. + ```bash -pnpm run test # Build + run node target/tests/test.js -pnpm run watch:test # Watch mode -pnpm run lint:clj -pnpm run fmt:clj +clojure -M:dev:test # Run full test suite under JVM +clojure -M:dev:test --focus backend-tests.my-ns-test # Run single namespace under JVM + +# Run full test suite under JS or JVM runtimes +pnpm run test:js +pnpm run test:jvm + +# Run single namespace (only on JVM) +pnpm run test:jvm --focus common-tests.my-ns-test + +# Lint +pnpm run lint:clj # Lint CLJ/CLJS/CLJC code + +# Check Format +pnpm run check-fmt:clj # Check CLJ/CLJS/CLJS code +pnpm run check-fmt:js # Check JS/TS code + +# Code Format (Automatic Formatting) +pnpm run fmt:clj # Check CLJ/CLJS/CLJS code +pnpm run fmt:js # Check JS/TS code ``` +To run a focused ClojureScript unit test: edit +`test/common_tests/runner.cljs` to narrow the test suite, then `pnpm +run build:test && node target/tests/test.js`. + + ### Render-WASM (`cd render-wasm`) ```bash @@ -93,6 +133,10 @@ cargo fmt --check ### Namespace Structure +The backend, frontend and exporter are developed using clojure and +clojurescript and code is organized in namespaces. This is a general +overview of the available namespaces. + **Backend:** - `app.rpc.commands.*` – RPC command implementations (`auth`, `files`, `teams`, etc.) - `app.http.*` – HTTP routes and middleware @@ -109,14 +153,26 @@ cargo fmt --check - `app.util.*` – Utilities (DOM, HTTP, i18n, keyboard shortcuts) **Common:** -- `app.common.types.*` – Shared data types for shapes, files, pages -- `app.common.schema` – Malli validation schemas -- `app.common.geom.*` – Geometry utilities +- `app.common.types.*` – Shared data types for shapes, files, pages using Malli schemas +- `app.common.schema` – Malli abstraction layer, exposes the most used functions from malli +- `app.common.geom.*` – Geometry and shape transformation helpers +- `app.common.data` – Generic helpers used around all application +- `app.common.math` – Generic math helpers used around all aplication +- `app.common.json` – Generic JSON encoding/decoding helpers - `app.common.data.macros` – Performance macros used everywhere + ### Backend RPC Commands -All API calls go through a single RPC endpoint: `POST /api/rpc/command/`. +The PRC methods are implement in a some kind of multimethod structure using +`app.util.serivices` namespace. All RPC methods are collected under `app.rpc` +namespace and exposed under `/api/rpc/command/`. The RPC method +accepts POST and GET requests indistinctly and uses `Accept` header for +negotiate the response encoding (which can be transit, the defaut or plain +json). It also accepts transit (defaut) or json as input, which should be +indicated using `Content-Type` header. + +This is an example: ```clojure (sv/defmethod ::my-command @@ -129,12 +185,18 @@ All API calls go through a single RPC endpoint: `POST /api/rpc/command/ my-component* -props]`, where props should be a map literal or symbol pointing to -javascript props objects. The javascript props object can be created -manually `#js {:data-foo "bar"}` or using `mf/spread-object` helper -macro. +Example for `mf/with-memo` macro: ---- +```clj +;; Using functions +(mf/use-effect + (mf/deps team-id) + (fn [] + (st/emit! (dd/initialize team-id)) + (fn [] + (st/emit! (dd/finalize team-id))))) -## Commit Guidelines +;; The same effect but using mf/with-effect +(mf/with-effect [team-id] + (st/emit! (dd/initialize team-id)) + (fn [] + (st/emit! (dd/finalize team-id)))) +``` + +Example for `mf/with-memo` macro: + +``` +;; Using functions +(mf/use-memo + (mf/deps projects team-id) + (fn [] + (->> (vals projects) + (filterv #(= team-id (:team-id %)))))) + +;; Using the macro +(mf/with-memo [projects team-id] + (->> (vals projects) + (filterv #(= team-id (:team-id %))))) +``` + +Prefer using the macros for it syntax simplicity. + + +4. Component Usage (Hiccup Syntax) + +When invoking a component within Hiccup, always use the [:> component* props] +pattern. + +Requirements for props: + +- Must be a map literal or a symbol pointing to a JavaScript props object. +- To create a JS props object, use the `#js` literal or the `mf/spread-object` helper macro. + +Examples: + +```clj +;; Using object literal (no need of #js because macro already interprets it) +[:> my-component* {:data-foo "bar"}] + +;; Using object literal (no need of #js because macro already interprets it) +(let [props #js {:data-foo "bar" + :className "myclass"}] + [:> my-component* props]) + +;; Using the spread helper +(let [props (mf/spread-object base-props {:extra "data"})] + [:> my-component* props]) +``` + +4. Checklist + +- [ ] Does the component name end with *? + + +## Commit Format Guidelines Format: ` ` @@ -263,3 +454,46 @@ applicable. | ⬇️ | `:arrow_down:` | Dependency downgrade | | 🔥 | `:fire:` | Remove files or code | | 🌐 | `:globe_with_meridians:` | Translations | + + +## SCSS Rules & Migration + +### General rules + +- Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss + variables and get the already defined properties from `_sizes.scss`. The SCSS + variables are allowed and still used, just prefer properties if they are + already defined. +- If a value isn't in the DS, use the `px2rem(n)` mixin: `@use "ds/_utils.scss" + as *; padding: px2rem(23);`. +- Do **not** create new SCSS variables for one-off values. +- Use physical directions with logical ones to support RTL/LTR naturally. + - ❌ `margin-left`, `padding-right`, `left`, `right`. + - ✅ `margin-inline-start`, `padding-inline-end`, `inset-inline-start`. +- Always use the `use-typography` mixin from `ds/typography.scss`. + - ✅ `@include t.use-typography("title-small");` +- Use `$br-*` for radius and `$b-*` for thickness from `ds/_borders.scss`. +- Use only tokens from `ds/colors.scss`. Do **NOT** use `design-tokens.scss` or + legacy color variables. +- Use mixins only those defined in`ds/mixins.scss`. Avoid legacy mixins like + `@include flexCenter;`. Write standard CSS (flex/grid) instead. + +### Syntax & Structure + +- Use the `@use` instead of `@import`. If you go to refactor existing SCSS file, + try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as + *;` (Use `as *` to expose variables directly). +- Avoid deep selector nesting or high-specificity (IDs). Flatten selectors: + - ❌ `.card { .title { ... } }` + - ✅ `.card-title { ... }` +- Leverage component-level CSS variables for state changes (hover/focus) instead + of rewriting properties. + +### Checklist + +- [ ] No references to `common/refactor/` +- [ ] All `@import` converted to `@use` (only if refactoring) +- [ ] Physical properties (left/right) using logical properties (inline-start/end). +- [ ] Typography implemented via `use-typography()` mixin. +- [ ] Hardcoded pixel values wrapped in `px2rem()`. +- [ ] Selectors are flat (no deep nesting). diff --git a/CHANGES.md b/CHANGES.md index b8676f6a1f..a569f69caf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -61,6 +61,7 @@ - Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513) - Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528) - Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) +- Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463) ## 2.13.3 diff --git a/backend/package.json b/backend/package.json index dbd18e2991..c8f354874f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,9 @@ "ws": "^8.17.0" }, "scripts": { - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", - "fmt:clj": "cljfmt fix --parallel=true src/ test/" + "lint": "clj-kondo --parallel --lint ../common/src src/", + "check-fmt": "cljfmt check --parallel=true src/ test/", + "fmt": "cljfmt fix --parallel=true src/ test/", + "test": "clojure -M:dev:test" } } diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index cf40a08a79..192030cbf8 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -138,6 +138,7 @@ c.deleted_at FROM snapshots1 AS c WHERE c.file_id = ? + ORDER BY c.created_at DESC ), snapshots3 AS ( (SELECT * FROM snapshots2 WHERE created_by = 'system' @@ -150,8 +151,7 @@ AND deleted_at IS NULL LIMIT 500) ) - SELECT * FROM snapshots3 - ORDER BY created_at DESC")) + SELECT * FROM snapshots3;")) (defn get-visible-snapshots "Return a list of snapshots fecheable from the API, it has a limited diff --git a/common/package.json b/common/package.json index cf268bfb18..300c768dcb 100644 --- a/common/package.json +++ b/common/package.json @@ -13,6 +13,7 @@ "devDependencies": { "concurrently": "^9.1.2", "nodemon": "^3.1.10", + "prettier": "3.5.3", "source-map-support": "^0.5.21", "ws": "^8.18.2" }, @@ -20,11 +21,15 @@ "date-fns": "^4.1.0" }, "scripts": { - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel=true --lint src/", + "lint:clj": "clj-kondo --parallel=true --lint src/", + "check-fmt:clj": "cljfmt check --parallel=true src/ test/", + "check-fmt:js": "prettier -c src/**/*.js", "fmt:clj": "cljfmt fix --parallel=true src/ test/", + "fmt:js": "prettier -c src/**/*.js -w", "lint": "pnpm run lint:clj", "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"", "build:test": "clojure -M:dev:shadow-cljs compile test", - "test": "pnpm run build:test && node target/tests/test.js" + "test:js": "pnpm run build:test && node target/tests/test.js", + "test:jvm": "clojure -M:dev:test" } } diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 8536654155..7f63f16b3c 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: nodemon: specifier: ^3.1.10 version: 3.1.11 + prettier: + specifier: 3.5.3 + version: 3.5.3 source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -169,6 +172,11 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + prettier@3.5.3: + resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + engines: {node: '>=14'} + hasBin: true + pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -405,6 +413,8 @@ snapshots: picomatch@2.3.1: {} + prettier@3.5.3: {} + pstree.remy@1.1.8: {} readdirp@3.6.0: diff --git a/common/scripts/test b/common/scripts/test index 6402c5afd1..b064f2b8d2 100755 --- a/common/scripts/test +++ b/common/scripts/test @@ -4,4 +4,5 @@ set -ex corepack enable; corepack install; pnpm install; -pnpm run test; +pnpm run test:js; +pnpm run test:jvm; diff --git a/common/src/app/common/encoding_impl.js b/common/src/app/common/encoding_impl.js index 9af7d0fd57..a08f51170c 100644 --- a/common/src/app/common/encoding_impl.js +++ b/common/src/app/common/encoding_impl.js @@ -10,7 +10,7 @@ goog.require("cljs.core"); goog.provide("app.common.encoding_impl"); -goog.scope(function() { +goog.scope(function () { const core = cljs.core; const global = goog.global; const self = app.common.encoding_impl; @@ -28,8 +28,10 @@ goog.scope(function() { // Accept UUID hex format input = input.replace(/-/g, ""); - if ((input.length % 2) !== 0) { - throw new RangeError("Expected string to be an even number of characters") + if (input.length % 2 !== 0) { + throw new RangeError( + "Expected string to be an even number of characters", + ); } const view = new Uint8Array(input.length / 2); @@ -44,7 +46,11 @@ goog.scope(function() { function bufferToHex(source, isUuid) { if (source instanceof Uint8Array) { } else if (ArrayBuffer.isView(source)) { - source = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + source = new Uint8Array( + source.buffer, + source.byteOffset, + source.byteLength, + ); } else if (Array.isArray(source)) { source = Uint8Array.from(source); } @@ -56,22 +62,28 @@ goog.scope(function() { const spacer = isUuid ? "-" : ""; let i = 0; - return (hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]]); + return ( + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + ); } self.hexToBuffer = hexToBuffer; @@ -87,8 +99,10 @@ goog.scope(function() { // for base16 (hex), base32, or base64 encoding in a standards // compliant manner. - function getBaseCodec (ALPHABET) { - if (ALPHABET.length >= 255) { throw new TypeError("Alphabet too long"); } + function getBaseCodec(ALPHABET) { + if (ALPHABET.length >= 255) { + throw new TypeError("Alphabet too long"); + } let BASE_MAP = new Uint8Array(256); for (let j = 0; j < BASE_MAP.length; j++) { BASE_MAP[j] = 255; @@ -96,22 +110,32 @@ goog.scope(function() { for (let i = 0; i < ALPHABET.length; i++) { let x = ALPHABET.charAt(i); let xc = x.charCodeAt(0); - if (BASE_MAP[xc] !== 255) { throw new TypeError(x + " is ambiguous"); } + if (BASE_MAP[xc] !== 255) { + throw new TypeError(x + " is ambiguous"); + } BASE_MAP[xc] = i; } let BASE = ALPHABET.length; let LEADER = ALPHABET.charAt(0); let FACTOR = Math.log(BASE) / Math.log(256); // log(BASE) / log(256), rounded up let iFACTOR = Math.log(256) / Math.log(BASE); // log(256) / log(BASE), rounded up - function encode (source) { + function encode(source) { if (source instanceof Uint8Array) { } else if (ArrayBuffer.isView(source)) { - source = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + source = new Uint8Array( + source.buffer, + source.byteOffset, + source.byteLength, + ); } else if (Array.isArray(source)) { source = Uint8Array.from(source); } - if (!(source instanceof Uint8Array)) { throw new TypeError("Expected Uint8Array"); } - if (source.length === 0) { return ""; } + if (!(source instanceof Uint8Array)) { + throw new TypeError("Expected Uint8Array"); + } + if (source.length === 0) { + return ""; + } // Skip & count leading zeroes. let zeroes = 0; let length = 0; @@ -129,12 +153,18 @@ goog.scope(function() { let carry = source[pbegin]; // Apply "b58 = b58 * 256 + ch". let i = 0; - for (let it1 = size - 1; (carry !== 0 || i < length) && (it1 !== -1); it1--, i++) { + for ( + let it1 = size - 1; + (carry !== 0 || i < length) && it1 !== -1; + it1--, i++ + ) { carry += (256 * b58[it1]) >>> 0; - b58[it1] = (carry % BASE) >>> 0; + b58[it1] = carry % BASE >>> 0; carry = (carry / BASE) >>> 0; } - if (carry !== 0) { throw new Error("Non-zero carry"); } + if (carry !== 0) { + throw new Error("Non-zero carry"); + } length = i; pbegin++; } @@ -145,13 +175,19 @@ goog.scope(function() { } // Translate the result into a string. let str = LEADER.repeat(zeroes); - for (; it2 < size; ++it2) { str += ALPHABET.charAt(b58[it2]); } + for (; it2 < size; ++it2) { + str += ALPHABET.charAt(b58[it2]); + } return str; } - function decodeUnsafe (source) { - if (typeof source !== "string") { throw new TypeError("Expected String"); } - if (source.length === 0) { return new Uint8Array(); } + function decodeUnsafe(source) { + if (typeof source !== "string") { + throw new TypeError("Expected String"); + } + if (source.length === 0) { + return new Uint8Array(); + } let psz = 0; // Skip and count leading '1's. let zeroes = 0; @@ -161,21 +197,29 @@ goog.scope(function() { psz++; } // Allocate enough space in big-endian base256 representation. - let size = (((source.length - psz) * FACTOR) + 1) >>> 0; // log(58) / log(256), rounded up. + let size = ((source.length - psz) * FACTOR + 1) >>> 0; // log(58) / log(256), rounded up. let b256 = new Uint8Array(size); // Process the characters. while (source[psz]) { // Decode character let carry = BASE_MAP[source.charCodeAt(psz)]; // Invalid character - if (carry === 255) { return; } + if (carry === 255) { + return; + } let i = 0; - for (let it3 = size - 1; (carry !== 0 || i < length) && (it3 !== -1); it3--, i++) { + for ( + let it3 = size - 1; + (carry !== 0 || i < length) && it3 !== -1; + it3--, i++ + ) { carry += (BASE * b256[it3]) >>> 0; - b256[it3] = (carry % 256) >>> 0; + b256[it3] = carry % 256 >>> 0; carry = (carry / 256) >>> 0; } - if (carry !== 0) { throw new Error("Non-zero carry"); } + if (carry !== 0) { + throw new Error("Non-zero carry"); + } length = i; psz++; } @@ -192,20 +236,22 @@ goog.scope(function() { return vch; } - function decode (string) { + function decode(string) { let buffer = decodeUnsafe(string); - if (buffer) { return buffer; } + if (buffer) { + return buffer; + } throw new Error("Non-base" + BASE + " character"); } return { encode: encode, decodeUnsafe: decodeUnsafe, - decode: decode + decode: decode, }; } // MORE bases here: https://github.com/cryptocoinjs/base-x/tree/master - const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const BASE62 = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; self.bufferToBase62 = getBaseCodec(BASE62).encode; - }); diff --git a/common/src/app/common/svg/path/arc_to_bezier.js b/common/src/app/common/svg/path/arc_to_bezier.js index 39dc8d447f..b4220a7d95 100644 --- a/common/src/app/common/svg/path/arc_to_bezier.js +++ b/common/src/app/common/svg/path/arc_to_bezier.js @@ -14,7 +14,7 @@ goog.provide("app.common.svg.path.arc_to_bezier"); // https://raw.githubusercontent.com/fontello/svgpath/master/lib/a2c.js -goog.scope(function() { +goog.scope(function () { const self = app.common.svg.path.arc_to_bezier; var TAU = Math.PI * 2; @@ -27,20 +27,23 @@ goog.scope(function() { // we can use simplified math (without length normalization) // function unit_vector_angle(ux, uy, vx, vy) { - var sign = (ux * vy - uy * vx < 0) ? -1 : 1; - var dot = ux * vx + uy * vy; + var sign = ux * vy - uy * vx < 0 ? -1 : 1; + var dot = ux * vx + uy * vy; // Add this to work with arbitrary vectors: // dot /= Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy); // rounding errors, e.g. -1.0000000000000002 can screw up this - if (dot > 1.0) { dot = 1.0; } - if (dot < -1.0) { dot = -1.0; } + if (dot > 1.0) { + dot = 1.0; + } + if (dot < -1.0) { + dot = -1.0; + } return sign * Math.acos(dot); } - // Convert from endpoint to center parameterization, // see http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes // @@ -53,11 +56,11 @@ goog.scope(function() { // points. After that, rotate it to line up ellipse axes with coordinate // axes. // - var x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2; - var y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2; + var x1p = (cos_phi * (x1 - x2)) / 2 + (sin_phi * (y1 - y2)) / 2; + var y1p = (-sin_phi * (x1 - x2)) / 2 + (cos_phi * (y1 - y2)) / 2; - var rx_sq = rx * rx; - var ry_sq = ry * ry; + var rx_sq = rx * rx; + var ry_sq = ry * ry; var x1p_sq = x1p * x1p; var y1p_sq = y1p * y1p; @@ -66,33 +69,33 @@ goog.scope(function() { // Compute coordinates of the centre of this ellipse (cx', cy') // in the new coordinate system. // - var radicant = (rx_sq * ry_sq) - (rx_sq * y1p_sq) - (ry_sq * x1p_sq); + var radicant = rx_sq * ry_sq - rx_sq * y1p_sq - ry_sq * x1p_sq; if (radicant < 0) { // due to rounding errors it might be e.g. -1.3877787807814457e-17 radicant = 0; } - radicant /= (rx_sq * y1p_sq) + (ry_sq * x1p_sq); + radicant /= rx_sq * y1p_sq + ry_sq * x1p_sq; radicant = Math.sqrt(radicant) * (fa === fs ? -1 : 1); - var cxp = radicant * rx/ry * y1p; - var cyp = radicant * -ry/rx * x1p; + var cxp = ((radicant * rx) / ry) * y1p; + var cyp = ((radicant * -ry) / rx) * x1p; // Step 3. // // Transform back to get centre coordinates (cx, cy) in the original // coordinate system. // - var cx = cos_phi*cxp - sin_phi*cyp + (x1+x2)/2; - var cy = sin_phi*cxp + cos_phi*cyp + (y1+y2)/2; + var cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2; + var cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2; // Step 4. // // Compute angles (theta1, delta_theta). // - var v1x = (x1p - cxp) / rx; - var v1y = (y1p - cyp) / ry; + var v1x = (x1p - cxp) / rx; + var v1y = (y1p - cyp) / ry; var v2x = (-x1p - cxp) / rx; var v2y = (-y1p - cyp) / ry; @@ -106,7 +109,7 @@ goog.scope(function() { delta_theta += TAU; } - return [ cx, cy, theta1, delta_theta ]; + return [cx, cy, theta1, delta_theta]; } // @@ -114,24 +117,33 @@ goog.scope(function() { // see http://math.stackexchange.com/questions/873224 // function approximate_unit_arc(theta1, delta_theta) { - var alpha = 4/3 * Math.tan(delta_theta/4); + var alpha = (4 / 3) * Math.tan(delta_theta / 4); var x1 = Math.cos(theta1); var y1 = Math.sin(theta1); var x2 = Math.cos(theta1 + delta_theta); var y2 = Math.sin(theta1 + delta_theta); - return [ x1, y1, x1 - y1*alpha, y1 + x1*alpha, x2 + y2*alpha, y2 - x2*alpha, x2, y2 ]; + return [ + x1, + y1, + x1 - y1 * alpha, + y1 + x1 * alpha, + x2 + y2 * alpha, + y2 - x2 * alpha, + x2, + y2, + ]; } function calculate_beziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { - var sin_phi = Math.sin(phi * TAU / 360); - var cos_phi = Math.cos(phi * TAU / 360); + var sin_phi = Math.sin((phi * TAU) / 360); + var cos_phi = Math.cos((phi * TAU) / 360); // Make sure radii are valid // - var x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2; - var y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2; + var x1p = (cos_phi * (x1 - x2)) / 2 + (sin_phi * (y1 - y2)) / 2; + var y1p = (-sin_phi * (x1 - x2)) / 2 + (cos_phi * (y1 - y2)) / 2; // console.log("L", x1p, y1p) @@ -145,7 +157,6 @@ goog.scope(function() { return []; } - // Compensate out-of-range radii // rx = Math.abs(rx); @@ -157,25 +168,20 @@ goog.scope(function() { ry *= Math.sqrt(lambda); } - // Get center parameters (cx, cy, theta1, delta_theta) // var cc = get_arc_center(x1, y1, x2, y2, fa, fs, rx, ry, sin_phi, cos_phi); - var result = []; var theta1 = cc[2]; var delta_theta = cc[3]; - - // Split an arc to multiple segments, so each segment // will be less than τ/4 (= 90°) // var segments = Math.max(Math.ceil(Math.abs(delta_theta) / (TAU / 4)), 1); delta_theta /= segments; - for (var i = 0; i < segments; i++) { var item = approximate_unit_arc(theta1, delta_theta); result.push(item); @@ -195,8 +201,8 @@ goog.scope(function() { y *= ry; // rotate - var xp = cos_phi*x - sin_phi*y; - var yp = sin_phi*x + cos_phi*y; + var xp = cos_phi * x - sin_phi * y; + var yp = sin_phi * x + cos_phi * y; // translate curve[i + 0] = xp + cc[0]; diff --git a/common/src/app/common/svg/path/parser.js b/common/src/app/common/svg/path/parser.js index 5bbcddd3a0..f427874528 100644 --- a/common/src/app/common/svg/path/parser.js +++ b/common/src/app/common/svg/path/parser.js @@ -31,74 +31,81 @@ class Segment { toPersistentMap() { const fromArray = (data) => { return cljs.PersistentArrayMap.fromArray(data); - } + }; let command, params; - switch(this.command) { - case "M": - command = MOVE_TO; - params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); - break; + switch (this.command) { + case "M": + command = MOVE_TO; + params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); + break; - case "Z": - command = CLOSE_PATH; - params = cljs.PersistentArrayMap.EMPTY; - break; + case "Z": + command = CLOSE_PATH; + params = cljs.PersistentArrayMap.EMPTY; + break; - case "L": - command = LINE_TO; - params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); - break; + case "L": + command = LINE_TO; + params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); + break; - case "C": - command = CURVE_TO; - params = fromArray([K_C1X, this.params[0], - K_C1Y, this.params[1], - K_C2X, this.params[2], - K_C2Y, this.params[3], - K_X, this.params[4], - K_Y, this.params[5]]); - break; - default: - command = null - params = null; + case "C": + command = CURVE_TO; + params = fromArray([ + K_C1X, + this.params[0], + K_C1Y, + this.params[1], + K_C2X, + this.params[2], + K_C2Y, + this.params[3], + K_X, + this.params[4], + K_Y, + this.params[5], + ]); + break; + default: + command = null; + params = null; } if (command === null || params === null) { throw new Error("invalid segment"); } - return fromArray([K_COMMAND, command, - K_PARAMS, params]) + return fromArray([K_COMMAND, command, K_PARAMS, params]); } } function validCommand(c) { switch (c) { - case "Z": - case "M": - case "L": - case "C": - case "Q": - case "A": - case "H": - case "V": - case "S": - case "T": - case "z": - case "m": - case "l": - case "c": - case "q": - case "a": - case "h": - case "v": - case "s": - case "t": - return true; - default: - return false; + case "Z": + case "M": + case "L": + case "C": + case "Q": + case "A": + case "H": + case "V": + case "S": + case "T": + case "z": + case "m": + case "l": + case "c": + case "q": + case "a": + case "h": + case "v": + case "s": + case "t": + return true; + default: + return false; } } @@ -118,11 +125,11 @@ class Parser { next() { const done = !this.hasNext(); if (done) { - return {done: true}; + return { done: true }; } else { return { done: false, - value: this.parseSegment() + value: this.parseSegment(), }; } } @@ -130,8 +137,10 @@ class Parser { hasNext() { if (this._currentIndex === 0) { const command = this._peekSegmentCommand(); - return ((this._currentIndex < this._endIndex) && - (command === "M" || command === "m")); + return ( + this._currentIndex < this._endIndex && + (command === "M" || command === "m") + ); } else { return this._currentIndex < this._endIndex; } @@ -148,7 +157,10 @@ class Parser { } // Check for remaining coordinates in the current command. - if ((ch === "+" || ch === "-" || ch === "." || (ch >= "0" && ch <= "9")) && this._prevCommand !== "Z") { + if ( + (ch === "+" || ch === "-" || ch === "." || (ch >= "0" && ch <= "9")) && + this._prevCommand !== "Z" + ) { if (this._prevCommand === "M") { command = "L"; } else if (this._prevCommand === "m") { @@ -177,7 +189,12 @@ class Parser { } else if (cmd === "M" || cmd === "L" || cmd === "T") { params = [this._parseNumber(), this._parseNumber()]; } else if (cmd === "S" || cmd === "Q") { - params = [this._parseNumber(), this._parseNumber(), this._parseNumber(), this._parseNumber()]; + params = [ + this._parseNumber(), + this._parseNumber(), + this._parseNumber(), + this._parseNumber(), + ]; } else if (cmd === "C") { params = [ this._parseNumber(), @@ -185,7 +202,7 @@ class Parser { this._parseNumber(), this._parseNumber(), this._parseNumber(), - this._parseNumber() + this._parseNumber(), ]; } else if (cmd === "A") { params = [ @@ -195,7 +212,7 @@ class Parser { this._parseArcFlag(), this._parseArcFlag(), this._parseNumber(), - this._parseNumber() + this._parseNumber(), ]; } else if (cmd === "Z") { this._skipOptionalSpaces(); @@ -217,7 +234,10 @@ class Parser { _isCurrentSpace() { var ch = this._string[this._currentIndex]; - return ch <= " " && (ch === " " || ch === "\n" || ch === "\t" || ch === "\r" || ch === "\f"); + return ( + ch <= " " && + (ch === " " || ch === "\n" || ch === "\t" || ch === "\r" || ch === "\f") + ); } _skipOptionalSpaces() { @@ -228,14 +248,19 @@ class Parser { } _skipOptionalSpacesOrDelimiter() { - if (this._currentIndex < this._endIndex && - !this._isCurrentSpace() && - this._string[this._currentIndex] !== ",") { + if ( + this._currentIndex < this._endIndex && + !this._isCurrentSpace() && + this._string[this._currentIndex] !== "," + ) { return false; } if (this._skipOptionalSpaces()) { - if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === ",") { + if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "," + ) { this._currentIndex += 1; this._skipOptionalSpaces(); } @@ -258,16 +283,25 @@ class Parser { this._skipOptionalSpaces(); // Read the sign. - if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === "+") { + if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "+" + ) { this._currentIndex += 1; - } else if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === "-") { + } else if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "-" + ) { this._currentIndex += 1; sign = -1; } - if (this._currentIndex === this._endIndex || - ((this._string[this._currentIndex] < "0" || this._string[this._currentIndex] > "9") && - this._string[this._currentIndex] !== ".")) { + if ( + this._currentIndex === this._endIndex || + ((this._string[this._currentIndex] < "0" || + this._string[this._currentIndex] > "9") && + this._string[this._currentIndex] !== ".") + ) { // The first chacter of a number must be one of [0-9+-.]. return null; } @@ -275,9 +309,11 @@ class Parser { // Read the integer part, build right-to-left. var startIntPartIndex = this._currentIndex; - while (this._currentIndex < this._endIndex && - this._string[this._currentIndex] >= "0" && - this._string[this._currentIndex] <= "9") { + while ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] >= "0" && + this._string[this._currentIndex] <= "9" + ) { this._currentIndex += 1; // Advance to first non-digit. } @@ -293,19 +329,26 @@ class Parser { } // Read the decimals. - if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === ".") { + if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "." + ) { this._currentIndex += 1; // There must be a least one digit following the . - if (this._currentIndex >= this._endIndex || - this._string[this._currentIndex] < "0" || - this._string[this._currentIndex] > "9") { + if ( + this._currentIndex >= this._endIndex || + this._string[this._currentIndex] < "0" || + this._string[this._currentIndex] > "9" + ) { return null; } - while (this._currentIndex < this._endIndex && - this._string[this._currentIndex] >= "0" && - this._string[this._currentIndex] <= "9") { + while ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] >= "0" && + this._string[this._currentIndex] <= "9" + ) { frac *= 10; decimal += (this._string[this._currentIndex] - "0") / frac; this._currentIndex += 1; @@ -313,10 +356,14 @@ class Parser { } // Read the exponent part. - if (this._currentIndex !== startIndex && - this._currentIndex + 1 < this._endIndex && - (this._string[this._currentIndex] === "e" || this._string[this._currentIndex] === "E") && - (this._string[this._currentIndex + 1] !== "x" && this._string[this._currentIndex + 1] !== "m")) { + if ( + this._currentIndex !== startIndex && + this._currentIndex + 1 < this._endIndex && + (this._string[this._currentIndex] === "e" || + this._string[this._currentIndex] === "E") && + this._string[this._currentIndex + 1] !== "x" && + this._string[this._currentIndex + 1] !== "m" + ) { this._currentIndex += 1; // Read the sign of the exponent. @@ -328,17 +375,21 @@ class Parser { } // There must be an exponent. - if (this._currentIndex >= this._endIndex || - this._string[this._currentIndex] < "0" || - this._string[this._currentIndex] > "9") { + if ( + this._currentIndex >= this._endIndex || + this._string[this._currentIndex] < "0" || + this._string[this._currentIndex] > "9" + ) { return null; } - while (this._currentIndex < this._endIndex && - this._string[this._currentIndex] >= "0" && - this._string[this._currentIndex] <= "9") { + while ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] >= "0" && + this._string[this._currentIndex] <= "9" + ) { exponent *= 10; - exponent += (this._string[this._currentIndex] - "0"); + exponent += this._string[this._currentIndex] - "0"; this._currentIndex += 1; } } @@ -380,7 +431,7 @@ class Parser { this._skipOptionalSpacesOrDelimiter(); return flag; } -}; +} function absolutizePathData(pdata) { var currentX = null; @@ -389,212 +440,210 @@ function absolutizePathData(pdata) { var subpathX = null; var subpathY = null; - for (let i=0; i 1.0) ? 1.0 : (dot < -1.0) ? -1.0 : dot; + dot = dot > 1.0 ? 1.0 : dot < -1.0 ? -1.0 : dot; return sign * Math.acos(dot); } function getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi) { - let x1p = (cosPhi * ((x1 - x2) / 2)) + (sinPhi * ((y1 - y2) / 2)); - let y1p = (-sinPhi * ((x1 - x2) / 2)) + (cosPhi * ((y1 - y2) / 2)); + let x1p = cosPhi * ((x1 - x2) / 2) + sinPhi * ((y1 - y2) / 2); + let y1p = -sinPhi * ((x1 - x2) / 2) + cosPhi * ((y1 - y2) / 2); let rxSq = rx * rx; let rySq = ry * ry; @@ -602,9 +651,9 @@ function getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi) { let y1pSq = y1p * y1p; let radicant = rxSq * rySq - rxSq * y1pSq - rySq * x1pSq; - radicant = (radicant < 0) ? 0 : radicant; - radicant /= (rxSq * y1pSq + rySq * x1pSq); - radicant = (Math.sqrt(radicant) * ((fa === fs) ? -1 : 1)) + radicant = radicant < 0 ? 0 : radicant; + radicant /= rxSq * y1pSq + rySq * x1pSq; + radicant = Math.sqrt(radicant) * (fa === fs ? -1 : 1); let cxp = radicant * (rx / ry) * y1p; let cyp = radicant * (-ry / rx) * x1p; @@ -618,8 +667,8 @@ function getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi) { let theta1 = unitVectorAngle(1, 0, v1x, v1y); let dtheta = unitVectorAngle(v1x, v1y, v2x, v2y); - dtheta = (fs === 0 && dtheta > 0) ? dtheta - Math.PI * 2 : dtheta; - dtheta = (fs === 1 && dtheta < 0) ? dtheta + Math.PI * 2 : dtheta; + dtheta = fs === 0 && dtheta > 0 ? dtheta - Math.PI * 2 : dtheta; + dtheta = fs === 1 && dtheta < 0 ? dtheta + Math.PI * 2 : dtheta; return [cx, cy, theta1, dtheta]; } @@ -639,7 +688,7 @@ function approximateUnitArc(theta1, dtheta) { x2 + y2 * alpha, y2 - x2 * alpha, x2, - y2 + y2, ]; } @@ -674,7 +723,7 @@ function processCurve(curve, cx, cy, rx, ry, sinPhi, cosPhi) { export function arcToBeziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { const tau = Math.PI * 2; - const phiTau = phi * tau / 360; + const phiTau = (phi * tau) / 360; const sinPhi = Math.sin(phiTau); const cosPhi = Math.cos(phiTau); @@ -688,7 +737,7 @@ export function arcToBeziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { } if (rx === 0 || ry === 0) { - // one of the radii is zero + // one of the radii is zero return []; } @@ -696,8 +745,8 @@ export function arcToBeziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { ry = Math.abs(ry); let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry); - rx = (lambda > 1) ? rx * Math.sqrt(lambda) : rx; - ry = (lambda > 1) ? ry * Math.sqrt(lambda) : ry; + rx = lambda > 1 ? rx * Math.sqrt(lambda) : rx; + ry = lambda > 1 ? ry * Math.sqrt(lambda) : ry; const cc = getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi); const cx = cc[0]; @@ -736,175 +785,183 @@ function simplifyPathData(pdata) { var subpathX = null; var subpathY = null; - for (let i=0; i { - if (typeof global.crypto !== "undefined" && - typeof global.crypto.getRandomValues !== "undefined") { + if ( + typeof global.crypto !== "undefined" && + typeof global.crypto.getRandomValues !== "undefined" + ) { return (buf) => { global.crypto.getRandomValues(buf); return buf; @@ -30,7 +32,7 @@ goog.scope(function() { return (buf) => { const bytes = randomBytes(buf.length); - buf.set(bytes) + buf.set(bytes); return buf; }; } else { @@ -39,8 +41,10 @@ goog.scope(function() { return (buf) => { for (let i = 0, r; i < buf.length; i++) { - if ((i & 0x03) === 0) { r = Math.random() * 0x100000000; } - buf[i] = r >>> ((i & 0x03) << 3) & 0xff; + if ((i & 0x03) === 0) { + r = Math.random() * 0x100000000; + } + buf[i] = (r >>> ((i & 0x03) << 3)) & 0xff; } return buf; }; @@ -50,31 +54,38 @@ goog.scope(function() { function toHexString(buf) { const hexMap = encoding.hexMap; let i = 0; - return (hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]]); - }; + return ( + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + ); + } function getBigUint64(view, byteOffset, le) { const a = view.getUint32(byteOffset, le); const b = view.getUint32(byteOffset + 4, le); const leMask = Number(!!le); const beMask = Number(!le); - return ((BigInt(a * beMask + b * leMask) << 32n) | - (BigInt(a * leMask + b * beMask))); + return ( + (BigInt(a * beMask + b * leMask) << 32n) | BigInt(a * leMask + b * beMask) + ); } function setBigUint64(view, byteOffset, value, le) { @@ -83,8 +94,7 @@ goog.scope(function() { if (le) { view.setUint32(byteOffset + 4, hi, le); view.setUint32(byteOffset, lo, le); - } - else { + } else { view.setUint32(byteOffset, hi, le); view.setUint32(byteOffset + 4, lo, le); } @@ -104,17 +114,18 @@ goog.scope(function() { } self.shortID = (function () { - const buff = new ArrayBuffer(8); + const buff = new ArrayBuffer(8); const int8 = new Uint8Array(buff); - const view = new DataView(buff); + const view = new DataView(buff); const base = 0x0000_0000_0000_0000n; return function shortID(ts) { const tss = currentTimestamp(timeRef); - const msb = (base - | (nextLong() & 0xffff_ffff_0000_0000n) - | (tss & 0x0000_0000_ffff_ffffn)); + const msb = + base | + (nextLong() & 0xffff_ffff_0000_0000n) | + (tss & 0x0000_0000_ffff_ffffn); setBigUint64(view, 0, msb, false); return encoding.toBase62(int8); }; @@ -139,9 +150,9 @@ goog.scope(function() { const maxCs = 0x0000_0000_0000_3fffn; // 14 bits space let countCs = 0n; - let lastRd = 0n; - let lastCs = 0n; - let lastTs = 0n; + let lastRd = 0n; + let lastCs = 0n; + let lastTs = 0n; let baseMsb = 0x0000_0000_0000_8000n; let baseLsb = 0x8000_0000_0000_0000n; @@ -149,12 +160,9 @@ goog.scope(function() { lastCs = nextLong() & maxCs; const create = function create(ts, lastRd, lastCs) { - const msb = (baseMsb - | (lastRd & 0xffff_ffff_ffff_0fffn)); + const msb = baseMsb | (lastRd & 0xffff_ffff_ffff_0fffn); - const lsb = (baseLsb - | ((ts << 14n) & 0x3fff_ffff_ffff_c000n) - | lastCs); + const lsb = baseLsb | ((ts << 14n) & 0x3fff_ffff_ffff_c000n) | lastCs; setBigUint64(view, 0, msb, false); setBigUint64(view, 8, lsb, false); @@ -167,10 +175,10 @@ goog.scope(function() { let ts = currentTimestamp(timeRef); // Protect from clock regression - if ((ts - lastTs) < 0) { - lastRd = (lastRd - & 0x0000_0000_0000_0f00n - | (nextLong() & 0xffff_ffff_ffff_f0ffn)); + if (ts - lastTs < 0) { + lastRd = + (lastRd & 0x0000_0000_0000_0f00n) | + (nextLong() & 0xffff_ffff_ffff_f0ffn); countCs = 0n; continue; } @@ -209,63 +217,63 @@ goog.scope(function() { // Parse ........-....-....-####-............ int8[8] = (rest = parseInt(uuid.slice(19, 23), 16)) >>> 8; - int8[9] = rest & 0xff, - - // Parse ........-....-....-....-############ - // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) - int8[10] = ((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff; + (int8[9] = rest & 0xff), + // Parse ........-....-....-....-############ + // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) + (int8[10] = + ((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff); int8[11] = (rest / 0x100000000) & 0xff; int8[12] = (rest >>> 24) & 0xff; int8[13] = (rest >>> 16) & 0xff; int8[14] = (rest >>> 8) & 0xff; int8[15] = rest & 0xff; - } + }; const fromPair = (hi, lo) => { view.setBigInt64(0, hi); view.setBigInt64(8, lo); return encoding.bufferToHex(int8, true); - } + }; const getHi = (uuid) => { fillBytes(uuid); return view.getBigInt64(0); - } + }; const getLo = (uuid) => { fillBytes(uuid); return view.getBigInt64(8); - } + }; const getBytes = (uuid) => { fillBytes(uuid); return Int8Array.from(int8); - } + }; const getUnsignedParts = (uuid) => { fillBytes(uuid); const result = new Uint32Array(4); - result[0] = view.getUint32(0) + result[0] = view.getUint32(0); result[1] = view.getUint32(4); result[2] = view.getUint32(8); result[3] = view.getUint32(12); return result; - } + }; const fromUnsignedParts = (a, b, c, d) => { - view.setUint32(0, a) - view.setUint32(4, b) - view.setUint32(8, c) - view.setUint32(12, d) + view.setUint32(0, a); + view.setUint32(4, b); + view.setUint32(8, c); + view.setUint32(12, d); return encoding.bufferToHex(int8, true); - } + }; const fromArray = (u8data) => { int8.set(u8data); return encoding.bufferToHex(int8, true); - } + }; const setTag = (tag) => { tag = BigInt.asUintN(64, "" + tag); @@ -273,9 +281,9 @@ goog.scope(function() { throw new Error("illegal arguments: tag value should fit in 4bits"); } - lastRd = (lastRd - & 0xffff_ffff_ffff_f0ffn - | ((tag << 8) & 0x0000_0000_0000_0f00n)); + lastRd = + (lastRd & 0xffff_ffff_ffff_f0ffn) | + ((tag << 8) & 0x0000_0000_0000_0f00n); }; factory.create = create; @@ -290,9 +298,9 @@ goog.scope(function() { return factory; })(); - self.shortV8 = function(uuid) { + self.shortV8 = function (uuid) { const buff = encoding.hexToBuffer(uuid); - const short = new Uint8Array(buff, 4); + const short = new Uint8Array(buff, 4); return encoding.bufferToBase62(short); }; @@ -307,7 +315,7 @@ goog.scope(function() { return self.v8.fromPair(hi, lo); }; - self.fromBytes = function(data) { + self.fromBytes = function (data) { if (data instanceof Uint8Array) { return self.v8.fromArray(data); } else if (data instanceof Int8Array) { @@ -325,15 +333,15 @@ goog.scope(function() { return self.v8.getUnsignedParts(uuid); }; - self.fromUnsignedParts = function(a,b,c,d) { - return self.v8.fromUnsignedParts(a,b,c,d); + self.fromUnsignedParts = function (a, b, c, d) { + return self.v8.fromUnsignedParts(a, b, c, d); }; self.getHi = function (uuid) { return self.v8.getHi(uuid); - } + }; self.getLo = function (uuid) { return self.v8.getLo(uuid); - } + }; }); diff --git a/common/src/app/common/weak/impl_weak_map.js b/common/src/app/common/weak/impl_weak_map.js index 2379ea7e14..2c6fa8db53 100644 --- a/common/src/app/common/weak/impl_weak_map.js +++ b/common/src/app/common/weak/impl_weak_map.js @@ -67,8 +67,11 @@ export class WeakEqMap { } set(key, value) { - if (key === null || (typeof key !== 'object' && typeof key !== 'function')) { - throw new TypeError('WeakEqMap keys must be objects (like WeakMap).'); + if ( + key === null || + (typeof key !== "object" && typeof key !== "function") + ) { + throw new TypeError("WeakEqMap keys must be objects (like WeakMap)."); } const hash = this._hash(key); const bucket = this._getBucket(hash); diff --git a/common/tests.edn b/common/tests.edn index 9f487a7eaf..0a0582fed6 100644 --- a/common/tests.edn +++ b/common/tests.edn @@ -1,4 +1,4 @@ #kaocha/v1 - {:tests [{:id :unit - :test-paths ["test"]}] - :kaocha/reporter [kaocha.report/dots]} +{:tests [{:id :unit + :test-paths ["test"]}] + :kaocha/reporter [kaocha.report/dots]} diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index efa134d999..07fbab0bb4 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -18,6 +18,7 @@ RUN set -ex; \ curl \ bash \ git \ + ripgrep \ \ curl \ ca-certificates \ diff --git a/exporter/package.json b/exporter/package.json index b023a6fab8..94e188896a 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -34,7 +34,8 @@ "watch": "pnpm run watch:app", "build:app": "clojure -M:dev:shadow-cljs release main", "build": "pnpm run clear:shadow-cache && pnpm run build:app", - "fmt:clj": "cljfmt fix --parallel=true src/", - "lint:clj": "cljfmt check --parallel src/ && clj-kondo --parallel --lint src/" + "fmt": "cljfmt fix --parallel=true src/", + "check-fmt": "cljfmt check --parallel=true src/", + "lint": "clj-kondo --parallel --lint src/" } } diff --git a/exporter/src/app/browser.cljs b/exporter/src/app/browser.cljs index 0da27c2609..526ae77380 100644 --- a/exporter/src/app/browser.cljs +++ b/exporter/src/app/browser.cljs @@ -100,14 +100,12 @@ (def browser-pool-factory (letfn [(create [] - (-> (p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]} - browser (.launch pw/chromium opts) - id (swap! pool-browser-id inc)] - (l/info :origin "factory" :action "create" :browser-id id) - (unchecked-set browser "__id" id) - browser) - (p/catch (fn [cause] - (l/error :hint "Cannot launch the headless browser" :cause cause))))) + (p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]} + browser (.launch pw/chromium opts) + id (swap! pool-browser-id inc)] + (l/info :origin "factory" :action "create" :browser-id id) + (unchecked-set browser "__id" id) + browser)) (destroy [obj] (let [id (unchecked-get obj "__id")] diff --git a/frontend/package.json b/frontend/package.json index 65642f9b78..03dfc498bc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,12 +23,15 @@ "build:app:main": "clojure -M:dev:shadow-cljs release main worker", "build:app:worker": "clojure -M:dev:shadow-cljs release worker", "build:app": "pnpm run clear:shadow-cache && pnpm run build:app:main && pnpm run build:app:libs", + "check-fmt:clj": "cljfmt check --parallel=true src/ test/", + "check-fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js", + "check-fmt:scss": "prettier -c resources/styles -c src/**/*.scss", "fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w", "fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w", - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", - "lint:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js", - "lint:scss": "prettier -c resources/styles -c src/**/*.scss", + "lint:clj": "clj-kondo --parallel --lint ../common/src src/", + "lint:js": "exit 0", + "lint:scss": "exit 0", "build:test": "clojure -M:dev:shadow-cljs compile test", "test": "pnpm run build:test && node target/tests/test.js", "test:storybook": "vitest run --project=storybook", diff --git a/frontend/playwright/data/render-wasm/get-file-blurs-affecting-other-elements.json b/frontend/playwright/data/render-wasm/get-file-blurs-affecting-other-elements.json new file mode 100644 index 0000000000..21aba9692b --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-blurs-affecting-other-elements.json @@ -0,0 +1,3583 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~ueba8fa2e-4140-8084-8005-448635d7a724", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Bad blur bad blur", + "~:revn": 127, + "~:modified-at": "~m1772523623921", + "~:vern": 0, + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc677169cd", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0004-clean-shadow-color", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node" + ] + }, + "~:version": 67, + "~:project-id": "~ueba8fa2e-4140-8084-8005-448635da32b4", + "~:created-at": "~m1772519242179", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~ua5508528-5928-8008-8007-a7de9feef61b" + ], + "~:pages-index": { + "~ua5508528-5928-8008-8007-a7de9feef61b": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~ua5508528-5928-8008-8007-a7e03d2ac912", + "~ua5508528-5928-8008-8007-a7e0e62b1820" + ] + } + }, + "~ua5508528-5928-8008-8007-a7e03d2ac912": { + "~#shape": { + "~:y": 470, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 233, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 408, + "~:y": 470 + } + }, + { + "~#point": { + "~:x": 641, + "~:y": 470 + } + }, + { + "~#point": { + "~:x": 641, + "~:y": 628 + } + }, + { + "~#point": { + "~:x": 408, + "~:y": 628 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ua5508528-5928-8008-8007-a7e03d2ac912", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 408, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 408, + "~:y": 470, + "~:width": 233, + "~:height": 158, + "~:x1": 408, + "~:y1": 470, + "~:x2": 641, + "~:y2": 628 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 158, + "~:flip-y": null + } + }, + "~ua5508528-5928-8008-8007-a7e0e62b1820": { + "~#shape": { + "~:y": 457, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 557, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 771, + "~:y": 457 + } + }, + { + "~#point": { + "~:x": 1328, + "~:y": 457 + } + }, + { + "~#point": { + "~:x": 1328, + "~:y": 781 + } + }, + { + "~#point": { + "~:x": 771, + "~:y": 781 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:blur": { + "~:id": "~ua5508528-5928-8008-8007-a7e0ef6b5783", + "~:type": "~:layer-blur", + "~:value": 4, + "~:hidden": false + }, + "~:r1": 0, + "~:id": "~ua5508528-5928-8008-8007-a7e0e62b1820", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 771, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 771, + "~:y": 457, + "~:width": 557, + "~:height": 324, + "~:x1": 771, + "~:y1": 457, + "~:x2": 1328, + "~:y2": 781 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 324, + "~:flip-y": null, + "~:shapes": [ + "~ua5508528-5928-8008-8007-a7e0e89a5a24" + ] + } + }, + "~ua5508528-5928-8008-8007-a7e0e89a5a24": { + "~#shape": { + "~:y": 496.000003814697, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": true, + "~:name": "Board", + "~:width": 212.000012099743, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 838.000027060509, + "~:y": 496.000003814697 + } + }, + { + "~#point": { + "~:x": 1050.00003916025, + "~:y": 496.000003814697 + } + }, + { + "~#point": { + "~:x": 1050.00003916025, + "~:y": 619.000005245209 + } + }, + { + "~#point": { + "~:x": 838.000027060509, + "~:y": 619.000005245209 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ua5508528-5928-8008-8007-a7e0e89a5a24", + "~:parent-id": "~ua5508528-5928-8008-8007-a7e0e62b1820", + "~:frame-id": "~ua5508528-5928-8008-8007-a7e0e62b1820", + "~:strokes": [], + "~:x": 838.000027060509, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 838.000027060509, + "~:y": 496.000003814697, + "~:width": 212.000012099743, + "~:height": 123.000001430511, + "~:x1": 838.000027060509, + "~:y1": 496.000003814697, + "~:x2": 1050.00003916025, + "~:y2": 619.000005245209 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 123.000001430511, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:id": "~ua5508528-5928-8008-8007-a7de9feef61b", + "~:name": "Page 1" + } + }, + "~:tokens-lib": { + "~#penpot/tokens-lib": { + "~:sets": { + "~#ordered-map": [ + [ + "S-Global", + { + "~#penpot/token-set": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781f1b3", + "~:name": "Global", + "~:description": "", + "~:modified-at": "~m1772519242247", + "~:tokens": { + "~#ordered-map": [ + [ + "COLOR-2", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc67817448", + "~:name": "COLOR-2", + "~:type": "~:color", + "~:value": "rgb(0, 239, 255)", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "SIZING-2", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc67817449", + "~:name": "SIZING-2", + "~:type": "~:sizing", + "~:value": "2", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "DIMENSIONS-1", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744a", + "~:name": "DIMENSIONS-1", + "~:type": "~:dimensions", + "~:value": "10", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "SIZING-0.5", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744f", + "~:name": "SIZING-0.5", + "~:type": "~:sizing", + "~:value": "0.5", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "ROTATION-60", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744b", + "~:name": "ROTATION-60", + "~:type": "~:rotation", + "~:value": "60", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "LETTER-SPACING-10", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744c", + "~:name": "LETTER-SPACING-10", + "~:type": "~:letter-spacing", + "~:value": "10", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "OPACITY-40", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744d", + "~:name": "OPACITY-40", + "~:type": "~:opacity", + "~:value": "40%", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "OPACITY-20", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc6781744e", + "~:name": "OPACITY-20", + "~:type": "~:opacity", + "~:value": "20%", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "SPACING-20", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc67817450", + "~:name": "SPACING-20", + "~:type": "~:spacing", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "BORDER-RADIUS-3", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc67817451", + "~:name": "BORDER-RADIUS-3", + "~:type": "~:border-radius", + "~:value": "30", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "SPACING-10", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc67817452", + "~:name": "SPACING-10", + "~:type": "~:spacing", + "~:value": "10", + "~:description": "", + "~:modified-at": "~m1772519242245" + } + } + ], + [ + "font-family-3", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186d5", + "~:name": "font-family-3", + "~:type": "~:font-family", + "~:value": [ + "Alexandria" + ], + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "FONT-SIZE-150", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186d6", + "~:name": "FONT-SIZE-150", + "~:type": "~:font-size", + "~:value": "150", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "NUMBER-16", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186d7", + "~:name": "NUMBER-16", + "~:type": "~:number", + "~:value": "16", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "OPACITY-60", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186d8", + "~:name": "OPACITY-60", + "~:type": "~:opacity", + "~:value": "0.6", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "NUMBER-4", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186d9", + "~:name": "NUMBER-4", + "~:type": "~:number", + "~:value": "4", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "font-family-2", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186da", + "~:name": "font-family-2", + "~:type": "~:font-family", + "~:value": [ + "Abel" + ], + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "SIZING-4", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186db", + "~:name": "SIZING-4", + "~:type": "~:sizing", + "~:value": "4", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "COLOR-1", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186dc", + "~:name": "COLOR-1", + "~:type": "~:color", + "~:value": "rgb(255, 0, 0)", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "BORDER-RADIUS-2", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186dd", + "~:name": "BORDER-RADIUS-2", + "~:type": "~:border-radius", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "BORDER-RADIUS-1", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186de", + "~:name": "BORDER-RADIUS-1", + "~:type": "~:border-radius", + "~:value": "10", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "NUMBER-8", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186df", + "~:name": "NUMBER-8", + "~:type": "~:number", + "~:value": "8", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "LETTER-SPACING-30", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e0", + "~:name": "LETTER-SPACING-30", + "~:type": "~:letter-spacing", + "~:value": "30", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "SPACING-5", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e1", + "~:name": "SPACING-5", + "~:type": "~:spacing", + "~:value": "5", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "font-family-1", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e2", + "~:name": "font-family-1", + "~:type": "~:font-family", + "~:value": [ + "ABeeZee" + ], + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "LETTER-SPACING-20", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e3", + "~:name": "LETTER-SPACING-20", + "~:type": "~:letter-spacing", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "FONT-SIZE-100", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e4", + "~:name": "FONT-SIZE-100", + "~:type": "~:font-size", + "~:value": "100", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "FONT-SIZE-40", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e5", + "~:name": "FONT-SIZE-40", + "~:type": "~:font-size", + "~:value": "40", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "ROTATION-30", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e6", + "~:name": "ROTATION-30", + "~:type": "~:rotation", + "~:value": "30", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "ROTATION-15", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e7", + "~:name": "ROTATION-15", + "~:type": "~:rotation", + "~:value": "15", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "COLOR-3", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e8", + "~:name": "COLOR-3", + "~:type": "~:color", + "~:value": "rgb(0, 255, 4)", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "DIMENSIONS-3", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186e9", + "~:name": "DIMENSIONS-3", + "~:type": "~:dimensions", + "~:value": "30", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ], + [ + "DIMENSIONS-2", + { + "~#penpot/token": { + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc678186ea", + "~:name": "DIMENSIONS-2", + "~:type": "~:dimensions", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1772519242246" + } + } + ] + ] + } + } + } + ] + ] + }, + "~:themes": { + "~#ordered-map": [ + [ + "", + { + "~#ordered-map": [ + [ + "__PENPOT__HIDDEN__TOKEN__THEME__", + { + "~#penpot/token-theme": { + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:name": "__PENPOT__HIDDEN__TOKEN__THEME__", + "~:group": "", + "~:description": "", + "~:is-source": false, + "~:external-id": "", + "~:modified-at": "~m1772519242248", + "~:sets": { + "~#set": [ + "Global" + ] + } + } + } + ] + ] + } + ] + ] + }, + "~:active-themes": { + "~#set": [ + "/__PENPOT__HIDDEN__TOKEN__THEME__" + ] + } + } + }, + "~:components": { + "~uade8229e-4891-80f7-8007-a6c641aa24c2": { + "~:path": "Modal / actions", + "~:deleted": true, + "~:main-instance-id": "~uade8229e-4891-80f7-8007-a6c641a6a32c", + "~:objects": { + "~uade8229e-4891-80f7-8007-a6c641a6a32f": { + "~#shape": { + "~:y": 1251.30205598607, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:layout-item-hsizing": "fill", + "~:layout-padding": { + "~:p1": 5.6843418860808e-14, + "~:p2": 0, + "~:p3": 5.6843418860808e-14, + "~:p4": 0 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "description", + "~:layout-align-items": "~:start", + "~:width": 465, + "~:layout-padding-type": "~:simple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1251.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1251.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1271.30205598607 + } + }, + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1271.30205598607 + } + } + ], + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:layout-justify-content": "~:start", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a32f", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:layout-flex-dir": "~:column", + "~:layout-align-content": "~:stretch", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:strokes": [], + "~:x": 931.380862910156, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 931.380862910156, + "~:y": 1251.30205598607, + "~:width": 465, + "~:height": 20.0000000000005, + "~:x1": 931.380862910156, + "~:y1": 1251.30205598607, + "~:x2": 1396.38086291016, + "~:y2": 1271.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 20.0000000000005, + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a339" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a32e": { + "~#shape": { + "~:y": 1287.30205598607, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:layout-item-hsizing": "auto", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "Board", + "~:layout-align-items": "~:start", + "~:width": 196, + "~:layout-padding-type": "~:simple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1200.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1327.30205598607 + } + }, + { + "~#point": { + "~:x": 1200.38086291016, + "~:y": 1327.30205598607 + } + } + ], + "~:r2": 0, + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:column-gap": 12, + "~:row-gap": 12 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:r3": 0, + "~:layout-justify-content": "~:end", + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:layout-flex-dir": "~:row-reverse", + "~:applied-tokens": { + "~:column-gap": "xx.alias.spacing.sm", + "~:row-gap": "xx.alias.spacing.sm" + }, + "~:layout-align-content": "~:stretch", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:strokes": [], + "~:x": 1200.38086291016, + "~:proportion": 1, + "~:r4": 0, + "~:layout-item-align-self": "~:end", + "~:selrect": { + "~#rect": { + "~:x": 1200.38086291016, + "~:y": 1287.30205598607, + "~:width": 196, + "~:height": 40, + "~:x1": 1200.38086291016, + "~:y1": 1287.30205598607, + "~:x2": 1396.38086291016, + "~:y2": 1327.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 40, + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a331", + "~uade8229e-4891-80f7-8007-a6c641a6a332" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a32d": { + "~#shape": { + "~:y": 1207.18039402734, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:layout-item-hsizing": "fill", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "content", + "~:layout-align-items": "~:start", + "~:width": 465, + "~:layout-padding-type": "~:simple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1207.18039402734 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1207.18039402734 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1327.30205598607 + } + }, + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1327.30205598607 + } + } + ], + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:column-gap": 16, + "~:row-gap": 16 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:layout-justify-content": "~:start", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32c", + "~:layout-flex-dir": "~:column", + "~:applied-tokens": { + "~:column-gap": "xx.alias.spacing.md", + "~:row-gap": "xx.alias.spacing.md" + }, + "~:layout-align-content": "~:stretch", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32c", + "~:strokes": [], + "~:x": 931.380862910156, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 931.380862910156, + "~:y": 1207.18039402734, + "~:width": 465, + "~:height": 120.121661958726, + "~:x1": 931.380862910156, + "~:y1": 1207.18039402734, + "~:x2": 1396.38086291016, + "~:y2": 1327.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 120.121661958726, + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~uade8229e-4891-80f7-8007-a6c641a6a32f", + "~uade8229e-4891-80f7-8007-a6c641a6a330" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a32c": { + "~#shape": { + "~:y": 1183.18039402734, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:rx": 20, + "~:layout-item-hsizing": "fix", + "~:layout-padding": { + "~:p2": 24, + "~:p4": 24, + "~:p3": 24, + "~:p1": 24 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "Modal / actions / Destructive", + "~:layout-align-items": "~:center", + "~:width": 513, + "~:layout-padding-type": "~:multiple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 907.380862910156, + "~:y": 1183.18039402734 + } + }, + { + "~#point": { + "~:x": 1420.38086291016, + "~:y": 1183.18039402734 + } + }, + { + "~#point": { + "~:x": 1420.38086291016, + "~:y": 1351.30205598607 + } + }, + { + "~#point": { + "~:x": 907.380862910156, + "~:y": 1351.30205598607 + } + } + ], + "~:r2": 8, + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:column-gap": 16, + "~:row-gap": 16 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:r3": 8, + "~:layout-justify-content": "~:start", + "~:r1": 8, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a32c", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:layout-flex-dir": "~:row", + "~:applied-tokens": { + "~:p2": "xx.alias.spacing.lg", + "~:p4": "xx.alias.spacing.lg", + "~:p3": "xx.alias.spacing.lg", + "~:stroke-color": "xx.alias.color.background.lowEmphasis", + "~:fill": "xx.alias.color.background.body", + "~:r2": "xx.alias.border.radius.md", + "~:p1": "xx.alias.spacing.lg", + "~:column-gap": "xx.alias.spacing.md", + "~:r3": "xx.alias.border.radius.md", + "~:r1": "xx.alias.border.radius.md", + "~:r4": "xx.alias.border.radius.md", + "~:row-gap": "xx.alias.spacing.md" + }, + "~:layout-align-content": "~:stretch", + "~:component-id": "~uade8229e-4891-80f7-8007-a6c641aa24c2", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1, + "~:stroke-color": "#dcdcdd", + "~:stroke-opacity": 1 + } + ], + "~:x": 907.380862910156, + "~:main-instance": true, + "~:proportion": 1, + "~:shadow": [ + { + "~:color": { + "~:opacity": 0.11, + "~:color": "#18141f" + }, + "~:spread": 0, + "~:offset-y": 16, + "~:style": "~:drop-shadow", + "~:blur": 36, + "~:hidden": false, + "~:id": "~u3fc22407-7a7d-80f6-8005-a2e2d45449f1", + "~:offset-x": 0 + }, + { + "~:color": { + "~:opacity": 0.06, + "~:color": "#18141f" + }, + "~:spread": 0, + "~:offset-y": 0, + "~:style": "~:drop-shadow", + "~:blur": 2, + "~:hidden": false, + "~:id": "~u3fc22407-7a7d-80f6-8005-a2e2aecba2f2", + "~:offset-x": 0 + } + ], + "~:r4": 8, + "~:selrect": { + "~#rect": { + "~:x": 907.380862910156, + "~:y": 1183.18039402734, + "~:width": 513, + "~:height": 168.121661958726, + "~:x1": 907.380862910156, + "~:y1": 1183.18039402734, + "~:x2": 1420.38086291016, + "~:y2": 1351.30205598607 + } + }, + "~:fills": [ + { + "~:fill-color": "#f3f3f4", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 20, + "~:height": 168.121661958726, + "~:component-file": "~ueffcbebc-b8c8-802f-8007-a7dc677169cd", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a32d" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a33c": { + "~#shape": { + "~:y": null, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAABmU6xErMGXRAMAAADnRqxELbWXRKQyrEQttZdEJSasRKzBl0QDAAAApRmsRCzOl0SlGaxEb+KXRCUmrETu7pdEAgAAAAAAAAAAAAAAAAAAAAAAAADv3qxEuKeYRAIAAAAAAAAAAAAAAAAAAAAAAAAAJSasRIJgmUQDAAAAphmsRAFtmUSmGaxERYGZRCUmrETEjZlEAwAAAKQyrERDmplE50asREOamURmU6xExI2ZRAIAAAAAAAAAAAAAAAAAAAAAAAAAMAytRPnUmEQCAAAAAAAAAAAAAAAAAAAAAAAAAPvErUTEjZlEAwAAAHrRrURDmplEveWtREOamUQ88q1ExI2ZRAMAAAC7/q1ERYGZRLv+rUQBbZlEPPKtRIJgmUQCAAAAAAAAAAAAAAAAAAAAAAAAAHE5rUS4p5hEAgAAAAAAAAAAAAAAAAAAAAAAAAA98q1E7u6XRAMAAAC8/q1Eb+KXRLz+rUQszpdEPfKtRKzBl0QDAAAAvuWtRC21l0R60a1ELbWXRPvErUSswZdEAgAAAAAAAAAAAAAAAAAAAAAAAAAwDK1Ed3qYRAIAAAAAAAAAAAAAAAAAAAAAAAAAZlOsRKzBl0QEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "~:name": "svg-path", + "~:width": null, + "~:type": "~:path", + "~:svg-attrs": { + + }, + "~:touched": { + "~#set": [ + "~:geometry-group", + "~:content-group", + "~:fill-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1376.89905291016, + "~:y": 1213.75941500671 + } + }, + { + "~#point": { + "~:x": 1391.86273791016, + "~:y": 1213.75941500671 + } + }, + { + "~#point": { + "~:x": 1391.86273791016, + "~:y": 1228.72300000671 + } + }, + { + "~#point": { + "~:x": 1376.89905291016, + "~:y": 1228.72300000671 + } + } + ], + "~:shape-ref": "~u5b5dd81f-49d7-8083-8005-9f14d5aadf2a", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:constraints-v": "~:scale", + "~:svg-transform": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + }, + "~:constraints-h": "~:scale", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a33c", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a33a", + "~:svg-viewbox": { + "~:y": 4.51819, + "~:y1": 4.51819, + "~:width": 14.963685, + "~:x": 4.51819, + "~:x1": 4.51819, + "~:y2": 19.481775, + "~:x2": 19.481875, + "~:height": 14.963585 + }, + "~:applied-tokens": { + "~:fill": "xx.alias.color.border.heavy" + }, + "~:svg-defs": { + + }, + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a33a", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-color": "#8b898f", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1 + } + ], + "~:x": null, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1376.89905291016, + "~:y": 1213.75941500671, + "~:width": 14.9636849999988, + "~:height": 14.9635850000004, + "~:x1": 1376.89905291016, + "~:y1": 1213.75941500671, + "~:x2": 1391.86273791016, + "~:y2": 1228.72300000671 + } + }, + "~:fills": [ + { + "~:fill-color": "#49454e", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a33b": { + "~#shape": { + "~:y": 1208.24122500671, + "~:layout-item-hsizing": "fill", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-height", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.3", + "~:path": "font-screen-lg / headline", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.3", + "~:path": "font-screen-lg / headline", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:font-size": "20", + "~:font-weight": "400", + "~:modified-at": "2025-01-24T18:57:33.017Z", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans", + "~:text": "Title" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:key": "daugc", + "~:font-size": "20", + "~:font-weight": "400", + "~:type": "paragraph", + "~:modified-at": "2025-01-24T18:57:33.017Z", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans" + } + ] + } + ], + "~:fills": [] + }, + "~:hide-in-viewer": false, + "~:name": "Title", + "~:width": 441, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1208.24122500671 + } + }, + { + "~#point": { + "~:x": 1372.38086291016, + "~:y": 1208.24122500671 + } + }, + { + "~#point": { + "~:x": 1372.38086291016, + "~:y": 1234.24122500671 + } + }, + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1234.24122500671 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a33b", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a330", + "~:applied-tokens": { + "~:fill": "xx.alias.color.text.emphasis" + }, + "~:position-data": [ + { + "~:y": 1234.26123046875, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "20", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 38.8800048828125, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:x": 931.300842285156, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "DM Sans", + "~:height": 26.0400390625, + "~:text": "Title" + } + ], + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a330", + "~:strokes": [], + "~:x": 931.380862910156, + "~:selrect": { + "~#rect": { + "~:x": 931.380862910156, + "~:y": 1208.24122500671, + "~:width": 441, + "~:height": 26, + "~:x1": 931.380862910156, + "~:y1": 1208.24122500671, + "~:x2": 1372.38086291016, + "~:y2": 1234.24122500671 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 26, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a33a": { + "~#shape": { + "~:y": 1209.24122500671, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "Icons / 24 / close", + "~:width": 24, + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:geometry-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1372.38086291016, + "~:y": 1209.24122500671 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1209.24122500671 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1233.24122500671 + } + }, + { + "~#point": { + "~:x": 1372.38086291016, + "~:y": 1233.24122500671 + } + } + ], + "~:shape-ref": "~u5b5dd81f-49d7-8083-8005-9f14d5aadf0e", + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a33a", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a330", + "~:applied-tokens": { + + }, + "~:component-id": "~u5b5dd81f-49d7-8083-8005-9f14d5abeaac", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a330", + "~:strokes": [], + "~:x": 1372.38086291016, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1372.38086291016, + "~:y": 1209.24122500671, + "~:width": 24, + "~:height": 24, + "~:x1": 1372.38086291016, + "~:y1": 1209.24122500671, + "~:x2": 1396.38086291016, + "~:y2": 1233.24122500671 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 24, + "~:component-file": "~u29d026bf-3f3f-8055-8006-518212e12739", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a33c" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a339": { + "~#shape": { + "~:y": 1251.30205598607, + "~:layout-item-hsizing": "fill", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-height", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.25", + "~:path": "font-screen-lg / hyperlink", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.25", + "~:path": "font-screen-lg / hyperlink", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:font-size": "16", + "~:font-weight": "400", + "~:modified-at": "2025-01-24T18:57:33.140Z", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:fills": [ + { + "~:fill-color": "#747279", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans", + "~:text": "Notification description text" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:key": "daugc", + "~:font-size": "16", + "~:font-weight": "400", + "~:type": "paragraph", + "~:modified-at": "2025-01-24T18:57:33.140Z", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:fills": [ + { + "~:fill-color": "#747279", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans" + } + ] + } + ], + "~:vertical-align": "center", + "~:fills": [] + }, + "~:hide-in-viewer": false, + "~:name": "Notification description text", + "~:width": 465, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1251.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1251.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1271.30205598607 + } + }, + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1271.30205598607 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a339", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32f", + "~:applied-tokens": { + "~:fill": "xx.alias.color.text.subtle" + }, + "~:position-data": [ + { + "~:y": 1271.72204589844, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "16", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 203.309997558594, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "-0.1599999964237213", + "~:x": 931.300842285156, + "~:fills": [ + { + "~:fill-color": "#747279", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "DM Sans", + "~:height": 20.840087890625, + "~:text": "Notification description text" + } + ], + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32f", + "~:strokes": [], + "~:x": 931.380862910156, + "~:selrect": { + "~#rect": { + "~:x": 931.380862910156, + "~:y": 1251.30205598607, + "~:width": 465, + "~:height": 20, + "~:x1": 931.380862910156, + "~:y1": 1251.30205598607, + "~:x2": 1396.38086291016, + "~:y2": 1271.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 20, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a338": { + "~#shape": { + "~:y": null, + "~:stroke-cap-start": "round", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAACGYaxEAP+iRAIAAAAAAAAAAAAAAAAAAAAAAAAA27atRAD/okQBAAAAAAAAAAAAAAAAAAAAAAAAAIbhrERVVKNEAgAAAAAAAAAAAAAAAAAAAAAAAACG4axEVdSjRAEAAAAAAAAAAAAAAAAAAAAAAAAA2zatRFVUo0QCAAAAAAAAAAAAAAAAAAAAAAAAANs2rURV1KNEAQAAAAAAAAAAAAAAAAAAAAAAAADbdqxEAP+iRAIAAAAAAAAAAAAAAAAAAAAAAAAAMIysRAD/o0QDAAAAMIysRJAWpERKn6xEqimkRNu2rESqKaREAgAAAAAAAAAAAAAAAAAAAAAAAACGYa1EqimkRAMAAAAWea1EqimkRDCMrUSQFqREMIytRAD/o0QCAAAAAAAAAAAAAAAAAAAAAAAAAIahrUQA/6JEAQAAAAAAAAAAAAAAAAAAAAAAAAAwzKxEAP+iRAIAAAAAAAAAAAAAAAAAAAAAAAAAMMysRAC/okQDAAAAMMysRDizokS91axEqqmiRIbhrESqqaJEAgAAAAAAAAAAAAAAAAAAAAAAAADbNq1EqqmiRAMAAACjQq1EqqmiRDBMrUQ4s6JEMEytRAC/okQCAAAAAAAAAAAAAAAAAAAAAAAAADBMrUQA/6JE" + }, + "~:name": "svg-path", + "~:width": null, + "~:type": "~:path", + "~:svg-attrs": { + "~:fill": "none", + "~:stroke-linejoin": "round" + }, + "~:touched": { + "~#set": [ + "~:geometry-group", + "~:content-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1379.04752957682, + "~:y": 1301.30205598607 + } + }, + { + "~#point": { + "~:x": 1389.71419624349, + "~:y": 1301.30205598607 + } + }, + { + "~#point": { + "~:x": 1389.71419624349, + "~:y": 1313.30205598607 + } + }, + { + "~#point": { + "~:x": 1379.04752957682, + "~:y": 1313.30205598607 + } + } + ], + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18bbd9cffcc", + "~:proportion-lock": false, + "~:stroke-cap-end": "round", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:constraints-v": "~:scale", + "~:svg-transform": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + }, + "~:constraints-h": "~:scale", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a338", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a335", + "~:svg-viewbox": { + "~:y": 3, + "~:y1": 3, + "~:width": 16, + "~:x": 4, + "~:x1": 4, + "~:y2": 21, + "~:x2": 20, + "~:height": 18 + }, + "~:applied-tokens": { + "~:stroke-color": "xx.alias.color.purpose.criticalLowEmphasis" + }, + "~:svg-defs": { + + }, + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a335", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1.5, + "~:stroke-cap-start": "~:round", + "~:stroke-cap-end": "~:round", + "~:stroke-color": "#ba5a56", + "~:stroke-opacity": 1 + } + ], + "~:x": null, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1379.04752957682, + "~:y": 1301.30205598607, + "~:width": 10.6666666666667, + "~:height": 12, + "~:x1": 1379.04752957682, + "~:y1": 1301.30205598607, + "~:x2": 1389.71419624349, + "~:y2": 1313.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a337": { + "~#shape": { + "~:y": null, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAAD/kK5ETdCiRAMAAACqiK5E+ceiRCh7rkT5x6JE03KuRE3QokQDAAAAfmquRKLYokR+aq5EJOaiRNNyrkR57qJEAgAAAAAAAAAAAAAAAAAAAAAAAAAF7q5EqmmjRAIAAAAAAAAAAAAAAAAAAAAAAAAA03KuRNzko0QDAAAAf2quRDHto0R/aq5Es/qjRNNyrkQHA6REAwAAACh7rkRcC6REqoiuRFwLpET/kK5EBwOkRAIAAAAAAAAAAAAAAAAAAAAAAAAAMAyvRNaHo0QCAAAAAAAAAAAAAAAAAAAAAAAAAGKHr0QHA6REAwAAALaPr0RcC6REOZ2vRFwLpESNpa9EBwOkRAMAAADira9Es/qjROKtr0Qx7aNEjaWvRNzko0QCAAAAAAAAAAAAAAAAAAAAAAAAAFwqr0SqaaNEAgAAAAAAAAAAAAAAAAAAAAAAAACOpa9Eee6iRAMAAADira9EJOaiROKtr0Si2KJEjqWvRE3QokQDAAAAOZ2vRPnHokS3j69E+ceiRGKHr0RN0KJEAgAAAAAAAAAAAAAAAAAAAAAAAAAwDK9Ef0ujRAIAAAAAAAAAAAAAAAAAAAAAAAAA/5CuRE3QokQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "~:name": "svg-path", + "~:width": null, + "~:type": "~:path", + "~:svg-attrs": { + + }, + "~:touched": { + "~#set": [ + "~:geometry-group", + "~:content-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1395.39298957682, + "~:y": 1302.31418265274 + } + }, + { + "~#point": { + "~:x": 1405.36877957682, + "~:y": 1302.31418265274 + } + }, + { + "~#point": { + "~:x": 1405.36877957682, + "~:y": 1312.28990598607 + } + }, + { + "~#point": { + "~:x": 1395.39298957682, + "~:y": 1312.28990598607 + } + } + ], + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18b7a1196dd", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:constraints-v": "~:scale", + "~:svg-transform": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + }, + "~:constraints-h": "~:scale", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a337", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a334", + "~:svg-viewbox": { + "~:y": 4.51819, + "~:y1": 4.51819, + "~:width": 14.963685, + "~:x": 4.51819, + "~:x1": 4.51819, + "~:y2": 19.481775, + "~:x2": 19.481875, + "~:height": 14.963585 + }, + "~:applied-tokens": { + "~:stroke-color": "xx.alias.color.purpose.criticalLowEmphasis" + }, + "~:svg-defs": { + + }, + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a334", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1, + "~:stroke-color": "#ba5a56", + "~:stroke-opacity": 1 + } + ], + "~:x": null, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1395.39298957682, + "~:y": 1302.31418265274, + "~:width": 9.97578999999928, + "~:height": 9.97572333333369, + "~:x1": 1395.39298957682, + "~:y1": 1302.31418265274, + "~:x2": 1405.36877957682, + "~:y2": 1312.28990598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a336": { + "~#shape": { + "~:y": 1295.30205598607, + "~:layout-item-hsizing": "fill", + "~:layout-padding": { + "~:p3": 24, + "~:p1": 24, + "~:p2": 12 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.5", + "~:path": "font-screen-lg / label", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.5", + "~:path": "font-screen-lg / label", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:font-size": "16", + "~:font-weight": "500", + "~:modified-at": "2025-01-24T19:39:31.048Z", + "~:font-variant-id": "500", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:fills": [ + { + "~:fill-color": "#ba5a56", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans", + "~:text": "Label" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:key": "daugc", + "~:font-size": "16", + "~:font-weight": "500", + "~:type": "paragraph", + "~:modified-at": "2025-01-24T19:39:31.048Z", + "~:font-variant-id": "500", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:fills": [ + { + "~:fill-color": "#ba5a56", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans" + } + ] + } + ], + "~:fills": [] + }, + "~:hide-in-viewer": false, + "~:name": "Label", + "~:width": 42, + "~:type": "~:text", + "~:touched": { + "~#set": [ + "~:geometry-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1326.38086291016, + "~:y": 1295.30205598607 + } + }, + { + "~#point": { + "~:x": 1368.38086291016, + "~:y": 1295.30205598607 + } + }, + { + "~#point": { + "~:x": 1368.38086291016, + "~:y": 1319.30205598607 + } + }, + { + "~#point": { + "~:x": 1326.38086291016, + "~:y": 1319.30205598607 + } + } + ], + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18b7a11d7ad", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a336", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:applied-tokens": { + "~:fill": "xx.alias.color.purpose.criticalLowEmphasis" + }, + "~:position-data": [ + { + "~:y": 1317.72204589844, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "16", + "~:font-weight": "500", + "~:text-direction": "ltr", + "~:width": 41.1199951171875, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:x": 1326.4208984375, + "~:fills": [ + { + "~:fill-color": "#ba5a56", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "DM Sans", + "~:height": 20.840087890625, + "~:text": "Label" + } + ], + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:strokes": [], + "~:x": 1326.38086291016, + "~:layout-item-margin": { + "~:m2": 8, + "~:m4": 8 + }, + "~:selrect": { + "~#rect": { + "~:x": 1326.38086291016, + "~:y": 1295.30205598607, + "~:width": 42, + "~:height": 24, + "~:x1": 1326.38086291016, + "~:y1": 1295.30205598607, + "~:x2": 1368.38086291016, + "~:y2": 1319.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 24, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a335": { + "~#shape": { + "~:y": 1299.30205598607, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "Icons / 16 / Trash", + "~:width": 16, + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:geometry-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1376.38086291016, + "~:y": 1299.30205598607 + } + }, + { + "~#point": { + "~:x": 1392.38086291016, + "~:y": 1299.30205598607 + } + }, + { + "~#point": { + "~:x": 1392.38086291016, + "~:y": 1315.30205598607 + } + }, + { + "~#point": { + "~:x": 1376.38086291016, + "~:y": 1315.30205598607 + } + } + ], + "~:r2": 0, + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18bbd9cffcb", + "~:show-content": true, + "~:proportion-lock": true, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:r3": 0, + "~:r1": 0, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a335", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:component-id": "~u829b5886-5b9d-80cc-8005-a17e67e9af2d", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:strokes": [], + "~:x": 1376.38086291016, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1376.38086291016, + "~:y": 1299.30205598607, + "~:width": 16, + "~:height": 16, + "~:x1": 1376.38086291016, + "~:y1": 1299.30205598607, + "~:x2": 1392.38086291016, + "~:y2": 1315.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 16, + "~:component-file": "~u29d026bf-3f3f-8055-8006-518212e12739", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a338" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a334": { + "~#shape": { + "~:y": 1299.30205598607, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "Icons / 24 / close", + "~:width": 16, + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:geometry-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1392.38086291016, + "~:y": 1299.30205598607 + } + }, + { + "~#point": { + "~:x": 1408.38086291016, + "~:y": 1299.30205598607 + } + }, + { + "~#point": { + "~:x": 1408.38086291016, + "~:y": 1315.30205598607 + } + }, + { + "~#point": { + "~:x": 1392.38086291016, + "~:y": 1315.30205598607 + } + } + ], + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18b7a1196dc", + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:hidden": true, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a334", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:applied-tokens": { + "~:width": "xx.alias.size.xxs", + "~:height": "xx.alias.size.xxs" + }, + "~:component-id": "~u5b5dd81f-49d7-8083-8005-9f14d5abeaac", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:strokes": [], + "~:x": 1392.38086291016, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1392.38086291016, + "~:y": 1299.30205598607, + "~:width": 16, + "~:height": 16, + "~:x1": 1392.38086291016, + "~:y1": 1299.30205598607, + "~:x2": 1408.38086291016, + "~:y2": 1315.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 16, + "~:component-file": "~u29d026bf-3f3f-8055-8006-518212e12739", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a337" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a333": { + "~#shape": { + "~:y": 1295.30205598607, + "~:layout-item-hsizing": "fill", + "~:layout-padding": { + "~:p3": 24, + "~:p1": 24, + "~:p2": 12 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.5", + "~:path": "font-screen-lg / label", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.5", + "~:path": "font-screen-lg / label", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:font-size": "16", + "~:font-weight": "500", + "~:modified-at": "2025-01-24T19:39:31.048Z", + "~:font-variant-id": "500", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:fills": [ + { + "~:fill-color": "#686fc8", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans", + "~:text": "Label" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-dm-sans", + "~:key": "daugc", + "~:font-size": "16", + "~:font-weight": "500", + "~:type": "paragraph", + "~:modified-at": "2025-01-24T19:39:31.048Z", + "~:font-variant-id": "500", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:fills": [ + { + "~:fill-color": "#686fc8", + "~:fill-opacity": 1 + } + ], + "~:font-family": "DM Sans" + } + ] + } + ], + "~:fills": [] + }, + "~:hide-in-viewer": false, + "~:name": "Label", + "~:width": 42, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 1224.38086291016, + "~:y": 1295.30205598607 + } + }, + { + "~#point": { + "~:x": 1266.38086291016, + "~:y": 1295.30205598607 + } + }, + { + "~#point": { + "~:x": 1266.38086291016, + "~:y": 1319.30205598607 + } + }, + { + "~#point": { + "~:x": 1224.38086291016, + "~:y": 1319.30205598607 + } + } + ], + "~:shape-ref": "~u453f99db-c307-8059-8005-af2b8edb901b", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a333", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a331", + "~:applied-tokens": { + "~:fill": "xx.alias.color.primary.brand" + }, + "~:position-data": [ + { + "~:y": 1317.72204589844, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "16", + "~:font-weight": "500", + "~:text-direction": "ltr", + "~:width": 41.1199951171875, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0.07999999821186066", + "~:x": 1224.4208984375, + "~:fills": [ + { + "~:fill-color": "#686fc8", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "DM Sans", + "~:height": 20.840087890625, + "~:text": "Label" + } + ], + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a331", + "~:strokes": [], + "~:x": 1224.38086291016, + "~:layout-item-margin": { + "~:m2": 8, + "~:m4": 8 + }, + "~:selrect": { + "~#rect": { + "~:x": 1224.38086291016, + "~:y": 1295.30205598607, + "~:width": 42, + "~:height": 24, + "~:x1": 1224.38086291016, + "~:y1": 1295.30205598607, + "~:x2": 1266.38086291016, + "~:y2": 1319.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 24, + "~:flip-y": null + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a332": { + "~#shape": { + "~:y": 1287.30205598607, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:rx": 20, + "~:layout-item-hsizing": "auto", + "~:layout-padding": { + "~:p2": 16, + "~:p4": 16, + "~:p3": 8, + "~:p1": 8 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "Button / Destructive / Label + Icon / Default", + "~:layout-align-items": "~:center", + "~:width": 94, + "~:layout-padding-type": "~:multiple", + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:geometry-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1302.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1327.30205598607 + } + }, + { + "~#point": { + "~:x": 1302.38086291016, + "~:y": 1327.30205598607 + } + } + ], + "~:r2": 8, + "~:shape-ref": "~u829b5886-5b9d-80cc-8005-a18b7a1196db", + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:r3": 8, + "~:layout-justify-content": "~:start", + "~:r1": 8, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a332", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~:layout-flex-dir": "~:row", + "~:applied-tokens": { + "~:p2": "xx.alias.spacing.md", + "~:p4": "xx.alias.spacing.md", + "~:p3": "xx.alias.spacing.xs", + "~:fill": "xx.alias.color.purpose.onCritical", + "~:r2": "xx.alias.border.radius.md", + "~:p1": "xx.alias.spacing.xs", + "~:r3": "xx.alias.border.radius.md", + "~:r1": "xx.alias.border.radius.md", + "~:r4": "xx.alias.border.radius.md" + }, + "~:layout-align-content": "~:stretch", + "~:component-id": "~u829b5886-5b9d-80cc-8005-a18c797f2def", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~:strokes": [], + "~:x": 1302.38086291016, + "~:proportion": 1, + "~:shadow": [ + { + "~:color": { + "~:opacity": 0.2, + "~:color": "#000000" + }, + "~:spread": 0, + "~:offset-y": 1, + "~:style": "~:drop-shadow", + "~:blur": 7, + "~:hidden": false, + "~:id": "~uad03169f-c56c-8064-8004-8e08166c6d2a", + "~:offset-x": 0 + } + ], + "~:r4": 8, + "~:selrect": { + "~#rect": { + "~:x": 1302.38086291016, + "~:y": 1287.30205598607, + "~:width": 94, + "~:height": 40, + "~:x1": 1302.38086291016, + "~:y1": 1287.30205598607, + "~:x2": 1396.38086291016, + "~:y2": 1327.30205598607 + } + }, + "~:fills": [ + { + "~:fill-color": "#ffc7bf", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 20, + "~:height": 40, + "~:component-file": "~u29d026bf-3f3f-8055-8006-518212e12739", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a334", + "~uade8229e-4891-80f7-8007-a6c641a6a335", + "~uade8229e-4891-80f7-8007-a6c641a6a336" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a331": { + "~#shape": { + "~:y": 1287.30205598607, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:rx": 20, + "~:layout-item-hsizing": "auto", + "~:layout-padding": { + "~:p2": 16, + "~:p4": 16, + "~:p3": 8, + "~:p1": 8 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "Button / Ghost / Label / Default", + "~:layout-align-items": "~:center", + "~:width": 90, + "~:layout-padding-type": "~:multiple", + "~:type": "~:frame", + "~:touched": { + "~#set": [] + }, + "~:points": [ + { + "~#point": { + "~:x": 1200.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1290.38086291016, + "~:y": 1287.30205598607 + } + }, + { + "~#point": { + "~:x": 1290.38086291016, + "~:y": 1327.30205598607 + } + }, + { + "~#point": { + "~:x": 1200.38086291016, + "~:y": 1327.30205598607 + } + } + ], + "~:r2": 8, + "~:shape-ref": "~u453f99db-c307-8059-8005-af2b8edb9019", + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:r3": 8, + "~:layout-justify-content": "~:start", + "~:r1": 8, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a331", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~:layout-flex-dir": "~:row", + "~:applied-tokens": { + "~:p2": "xx.alias.spacing.md", + "~:p4": "xx.alias.spacing.md", + "~:p3": "xx.alias.spacing.xs", + "~:stroke-color": "xx.alias.color.primary.brand", + "~:r2": "xx.alias.border.radius.md", + "~:p1": "xx.alias.spacing.xs", + "~:r3": "xx.alias.border.radius.md", + "~:r1": "xx.alias.border.radius.md", + "~:r4": "xx.alias.border.radius.md" + }, + "~:layout-align-content": "~:stretch", + "~:component-id": "~u453f99db-c307-8059-8005-af2baa65f975", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32e", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1, + "~:stroke-color": "#686fc8", + "~:stroke-opacity": 1 + } + ], + "~:x": 1200.38086291016, + "~:proportion": 1, + "~:shadow": [], + "~:r4": 8, + "~:selrect": { + "~#rect": { + "~:x": 1200.38086291016, + "~:y": 1287.30205598607, + "~:width": 90, + "~:height": 40, + "~:x1": 1200.38086291016, + "~:y1": 1287.30205598607, + "~:x2": 1290.38086291016, + "~:y2": 1327.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:ry": 20, + "~:height": 40, + "~:component-file": "~u29d026bf-3f3f-8055-8006-518212e12739", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a333" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a330": { + "~#shape": { + "~:y": 1207.18039402734, + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:layout-item-hsizing": "fill", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:layout-wrap-type": "~:nowrap", + "~:layout": "~:flex", + "~:hide-in-viewer": true, + "~:name": "header", + "~:layout-align-items": "~:center", + "~:width": 465, + "~:layout-padding-type": "~:simple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1207.18039402734 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1207.18039402734 + } + }, + { + "~#point": { + "~:x": 1396.38086291016, + "~:y": 1235.30205598607 + } + }, + { + "~#point": { + "~:x": 931.380862910156, + "~:y": 1235.30205598607 + } + } + ], + "~:show-content": true, + "~:proportion-lock": false, + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u829b5886-5b9d-80cc-8005-a18f9a6edd0f", + "~:layout-justify-content": "~:space-between", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a330", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:layout-flex-dir": "~:row", + "~:layout-align-content": "~:stretch", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a32d", + "~:strokes": [], + "~:x": 931.380862910156, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 931.380862910156, + "~:y": 1207.18039402734, + "~:width": 465, + "~:height": 28.1216619587253, + "~:x1": 931.380862910156, + "~:y1": 1207.18039402734, + "~:x2": 1396.38086291016, + "~:y2": 1235.30205598607 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 28.1216619587253, + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a33a", + "~uade8229e-4891-80f7-8007-a6c641a6a33b" + ] + } + } + }, + "~:name": "Destructive", + "~:modified-at": "~m1772446350100", + "~:main-instance-page": "~u73765590-e307-801e-8006-e4521313207b", + "~:id": "~uade8229e-4891-80f7-8007-a6c641aa24c2" + }, + "~uade8229e-4891-80f7-8007-a6c641a9b090": { + "~:path": "Modal / actions", + "~:deleted": true, + "~:main-instance-id": "~uade8229e-4891-80f7-8007-a6c641a6a31b", + "~:objects": { + "~uade8229e-4891-80f7-8007-a6c641a6a31b": { + "~#shape": { + "~:y": 352.999955487879, + "~:hide-fill-on-export": false, + "~:rx": 20, + "~:layout-item-hsizing": "fix", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "Modal / actions / Regular", + "~:width": 512.999938845635, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 981.00003269603, + "~:y": 352.999955487879 + } + }, + { + "~#point": { + "~:x": 1493.99997154166, + "~:y": 352.999955487879 + } + }, + { + "~#point": { + "~:x": 1493.99997154166, + "~:y": 520.999969122142 + } + }, + { + "~#point": { + "~:x": 981.00003269603, + "~:y": 520.999969122142 + } + } + ], + "~:r2": 0, + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u73765590-e307-801e-8006-e4521313207b", + "~:r3": 0, + "~:blur": { + "~:id": "~ua5508528-5928-8008-8007-a7de3e463903", + "~:type": "~:layer-blur", + "~:value": 4, + "~:hidden": true + }, + "~:r1": 0, + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a31b", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:applied-tokens": { + "~:row-gap": "xx.alias.spacing.md" + }, + "~:component-id": "~uade8229e-4891-80f7-8007-a6c641a9b090", + "~:layout-item-vsizing": "auto", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 981.00003269603, + "~:main-instance": true, + "~:proportion": 1, + "~:shadow": [], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 981.00003269603, + "~:y": 352.999955487879, + "~:width": 512.999938845635, + "~:height": 168.000013634263, + "~:x1": 981.00003269603, + "~:y1": 352.999955487879, + "~:x2": 1493.99997154166, + "~:y2": 520.999969122142 + } + }, + "~:fills": [ + { + "~:fill-color": "#f3f3f4", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 20, + "~:height": 168.000013634263, + "~:component-file": "~ueffcbebc-b8c8-802f-8007-a7dc677169cd", + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a31c" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a31c": { + "~#shape": { + "~:y": 376.999984733, + "~:hide-fill-on-export": false, + "~:layout-item-hsizing": "fill", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "content", + "~:width": 464.99994456768, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1005.00002988041, + "~:y": 376.999984733 + } + }, + { + "~#point": { + "~:x": 1469.99997444809, + "~:y": 376.999984733 + } + }, + { + "~#point": { + "~:x": 1469.99997444809, + "~:y": 496.999989763423 + } + }, + { + "~#point": { + "~:x": 1005.00002988041, + "~:y": 496.999989763423 + } + } + ], + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u73765590-e307-801e-8006-e4521313207b", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a31c", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a31b", + "~:applied-tokens": { + "~:column-gap": "xx.alias.spacing.md", + "~:row-gap": "xx.alias.spacing.md" + }, + "~:layout-item-vsizing": "auto", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a31b", + "~:strokes": [], + "~:x": 1005.00002988041, + "~:proportion": 1, + "~:grids": [], + "~:shadow": [], + "~:selrect": { + "~#rect": { + "~:x": 1005.00002988041, + "~:y": 376.999984733, + "~:width": 464.99994456768, + "~:height": 120.000005030423, + "~:x1": 1005.00002988041, + "~:y1": 376.999984733, + "~:x2": 1469.99997444809, + "~:y2": 496.999989763423 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 120.000005030423, + "~:flip-y": null, + "~:shapes": [ + "~uade8229e-4891-80f7-8007-a6c641a6a31f" + ] + } + }, + "~uade8229e-4891-80f7-8007-a6c641a6a31f": { + "~#shape": { + "~:y": 376.999995172412, + "~:hide-fill-on-export": false, + "~:layout-item-hsizing": "fill", + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "header", + "~:width": 464.99994456768, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1005.00002988041, + "~:y": 376.999995172412 + } + }, + { + "~#point": { + "~:x": 1469.99997444809, + "~:y": 376.999995172412 + } + }, + { + "~#point": { + "~:x": 1469.99997444809, + "~:y": 405.000011605939 + } + }, + { + "~#point": { + "~:x": 1005.00002988041, + "~:y": 405.000011605939 + } + } + ], + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u73765590-e307-801e-8006-e4521313207b", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a6a31f", + "~:parent-id": "~uade8229e-4891-80f7-8007-a6c641a6a31c", + "~:frame-id": "~uade8229e-4891-80f7-8007-a6c641a6a31c", + "~:strokes": [], + "~:x": 1005.00002988041, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1005.00002988041, + "~:y": 376.999995172412, + "~:width": 464.99994456768, + "~:height": 28.000016433527, + "~:x1": 1005.00002988041, + "~:y1": 376.999995172412, + "~:x2": 1469.99997444809, + "~:y2": 405.000011605939 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 28.000016433527, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:name": "Regular", + "~:modified-at": "~m1772519947685", + "~:main-instance-page": "~u73765590-e307-801e-8006-e4521313207b", + "~:id": "~uade8229e-4891-80f7-8007-a6c641a9b090" + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-a7dc677169cd", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } + } \ No newline at end of file diff --git a/frontend/playwright/data/render-wasm/get-file-huge-inner-strokes.json b/frontend/playwright/data/render-wasm/get-file-huge-inner-strokes.json new file mode 100644 index 0000000000..8c6b439eea --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-huge-inner-strokes.json @@ -0,0 +1,513 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u0b5bcbca-32ab-81eb-8005-a15fc4484678", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 6", + "~:revn": 1, + "~:modified-at": "~m1773140377840", + "~:vern": 0, + "~:id": "~ueffcbebc-b8c8-802f-8007-b11dd34fe190", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node" + ] + }, + "~:version": 67, + "~:project-id": "~u0b5bcbca-32ab-81eb-8005-a15fc448f334", + "~:created-at": "~m1773140371775", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~ueffcbebc-b8c8-802f-8007-b11dd34fe191" + ], + "~:pages-index": { + "~ueffcbebc-b8c8-802f-8007-b11dd34fe191": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~ub952fb5e-cae5-8054-8007-b11dd63f79f9", + "~ub952fb5e-cae5-8054-8007-b11dd63f79fa", + "~ub952fb5e-cae5-8054-8007-b11dd63f79fb" + ] + } + }, + "~ub952fb5e-cae5-8054-8007-b11dd63f79f9": { + "~#shape": { + "~:y": 660.000001521671, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 99.9999986310249, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 989, + "~:y": 660.000001521671 + } + }, + { + "~#point": { + "~:x": 1088.99999863103, + "~:y": 660.000001521671 + } + }, + { + "~#point": { + "~:x": 1088.99999863103, + "~:y": 760.000000795896 + } + }, + { + "~#point": { + "~:x": 989, + "~:y": 760.000000795896 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ub952fb5e-cae5-8054-8007-b11dd63f79f9", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 100 + } + ], + "~:x": 989, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 989, + "~:y": 660.000001521671, + "~:width": 99.9999986310249, + "~:height": 99.9999992742251, + "~:x1": 989, + "~:y1": 660.000001521671, + "~:x2": 1088.99999863103, + "~:y2": 760.000000795896 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 99.9999992742251, + "~:flip-y": null + } + }, + "~ub952fb5e-cae5-8054-8007-b11dd63f79fa": { + "~#shape": { + "~:y": 457.999994456768, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 299.99999499321, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1171.99998355202, + "~:y": 457.999994456768 + } + }, + { + "~#point": { + "~:x": 1471.99997854523, + "~:y": 457.999994456768 + } + }, + { + "~#point": { + "~:x": 1471.99997854523, + "~:y": 757.999989449978 + } + }, + { + "~#point": { + "~:x": 1171.99998355202, + "~:y": 757.999989449978 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~ub952fb5e-cae5-8054-8007-b11dd63f79fa", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 400 + } + ], + "~:x": 1171.99998355202, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1171.99998355202, + "~:y": 457.999994456768, + "~:width": 299.99999499321, + "~:height": 299.99999499321, + "~:x1": 1171.99998355202, + "~:y1": 457.999994456768, + "~:x2": 1471.99997854523, + "~:y2": 757.999989449978 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 299.99999499321, + "~:flip-y": null + } + }, + "~ub952fb5e-cae5-8054-8007-b11dd63f79fb": { + "~#shape": { + "~:y": 444, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 100, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 989, + "~:y": 444 + } + }, + { + "~#point": { + "~:x": 1089, + "~:y": 444 + } + }, + { + "~#point": { + "~:x": 1089, + "~:y": 544 + } + }, + { + "~#point": { + "~:x": 989, + "~:y": 544 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ub952fb5e-cae5-8054-8007-b11dd63f79fb", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 200 + } + ], + "~:x": 989, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 989, + "~:y": 444, + "~:width": 100, + "~:height": 100, + "~:x1": 989, + "~:y1": 444, + "~:x2": 1089, + "~:y2": 544 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 100, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-b11dd34fe191", + "~:name": "Page 1" + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-b11dd34fe190", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/data/render-wasm/get-file-strokes-and-not-100-percent-opacities.json b/frontend/playwright/data/render-wasm/get-file-strokes-and-not-100-percent-opacities.json new file mode 100644 index 0000000000..d5e9e7a363 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-strokes-and-not-100-percent-opacities.json @@ -0,0 +1,737 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u0b5bcbca-32ab-81eb-8005-a15fc4484678", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "holadios", + "~:revn": 54, + "~:modified-at": "~m1773136426990", + "~:vern": 0, + "~:id": "~ueffcbebc-b8c8-802f-8007-b0ebecd7ebf4", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0004-clean-shadow-color", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0016-copy-fills-from-position-data-to-text-node", + "0015-clean-shadow-color" + ] + }, + "~:version": 67, + "~:project-id": "~u0b5bcbca-32ab-81eb-8005-a15fc448f334", + "~:created-at": "~m1773127290716", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~u3e9e17c3-fc57-80ce-8007-101743996fe9" + ], + "~:pages-index": { + "~u3e9e17c3-fc57-80ce-8007-101743996fe9": { + "~:id": "~u3e9e17c3-fc57-80ce-8007-101743996fe9", + "~:name": "Page 1", + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~u3e9e17c3-fc57-80ce-8007-101743996fe9", + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~u7d004cdb-8305-806a-8007-b0f01ee65230", + "~u2ae0abdc-99ff-8009-8007-b0f7f123b5ea", + "~u2ae0abdc-99ff-8009-8007-b0f7f45177dc" + ] + } + }, + "~u7d004cdb-8305-806a-8007-b0f01ee65230": { + "~#shape": { + "~:y": -161.000001410182, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "6hv3a5x8wb", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "219sqyfv11", + "~:font-size": "250", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "HELLO WORLD" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "21rct71nkal", + "~:font-size": "250", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "HELLO WORLD", + "~:width": 1529.00000393592, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 109.999998223851, + "~:y": -161.000001410182 + } + }, + { + "~#point": { + "~:x": 1639.00000215977, + "~:y": -161.000001410182 + } + }, + { + "~#point": { + "~:x": 1639.00000215977, + "~:y": 138.999988970841 + } + }, + { + "~#point": { + "~:x": 109.999998223851, + "~:y": 138.999988970841 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u7d004cdb-8305-806a-8007-b0f01ee65230", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": 150.869995117188, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "250", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 1528.56005859375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:x": 110, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 323.739990234375, + "~:text": "HELLO WORLD" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:center", + "~:stroke-width": 50, + "~:stroke-color": "#43e50b", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 109.999998223851, + "~:selrect": { + "~#rect": { + "~:x": 109.999998223851, + "~:y": -161.000001410182, + "~:width": 1529.00000393592, + "~:height": 299.999990381022, + "~:x1": 109.999998223851, + "~:y1": -161.000001410182, + "~:x2": 1639.00000215977, + "~:y2": 138.999988970841 + } + }, + "~:flip-x": null, + "~:height": 299.999990381022, + "~:flip-y": null + } + }, + "~u2ae0abdc-99ff-8009-8007-b0f7f123b5ea": { + "~#shape": { + "~:y": -462.000004970439, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "6hv3a5x8wb", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "219sqyfv11", + "~:font-size": "250", + "~:font-weight": "400", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "HELLO WORLD" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "21rct71nkal", + "~:font-size": "250", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "HELLO WORLD", + "~:width": 1529.00000393592, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 92.9999982852505, + "~:y": -462.000004970439 + } + }, + { + "~#point": { + "~:x": 1622.00000222117, + "~:y": -462.000004970439 + } + }, + { + "~#point": { + "~:x": 1622.00000222117, + "~:y": -162.000040815452 + } + }, + { + "~#point": { + "~:x": 92.9999982852505, + "~:y": -162.000040815452 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u2ae0abdc-99ff-8009-8007-b0f7f123b5ea", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": -150.130004882813, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "250", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 1528.56005859375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:x": 93, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 323.739990234375, + "~:text": "HELLO WORLD" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 50, + "~:stroke-color": "#43e50b", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 92.9999982852505, + "~:selrect": { + "~#rect": { + "~:x": 92.9999982852505, + "~:y": -462.000004970439, + "~:width": 1529.00000393592, + "~:height": 299.999964154987, + "~:x1": 92.9999982852505, + "~:y1": -462.000004970439, + "~:x2": 1622.00000222117, + "~:y2": -162.000040815452 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 299.999964154987, + "~:flip-y": null + } + }, + "~u2ae0abdc-99ff-8009-8007-b0f7f45177dc": { + "~#shape": { + "~:y": 169.999996321908, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "6hv3a5x8wb", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "219sqyfv11", + "~:font-size": "250", + "~:font-weight": "400", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "HELLO WORLD" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "21rct71nkal", + "~:font-size": "250", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "HELLO WORLD", + "~:width": 1529.00000393592, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 92.9999984013972, + "~:y": 169.999996321908 + } + }, + { + "~#point": { + "~:x": 1622.00000233732, + "~:y": 169.999996321908 + } + }, + { + "~#point": { + "~:x": 1622.00000233732, + "~:y": 470.000003392238 + } + }, + { + "~#point": { + "~:x": 92.9999984013972, + "~:y": 470.000003392238 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u2ae0abdc-99ff-8009-8007-b0f7f45177dc", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": 481.869995117188, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "250", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 1528.56005859375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:x": 93, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 323.739990234375, + "~:text": "HELLO WORLD" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 50, + "~:stroke-color": "#43e50b", + "~:stroke-opacity": 0.6 + } + ], + "~:x": 92.9999984013973, + "~:selrect": { + "~#rect": { + "~:x": 92.9999984013973, + "~:y": 169.999996321908, + "~:width": 1529.00000393592, + "~:height": 300.00000707033, + "~:x1": 92.9999984013973, + "~:y1": 169.999996321908, + "~:x2": 1622.00000233732, + "~:y2": 470.000003392238 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 300.00000707033, + "~:flip-y": null + } + } + } + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-b0ebecd7ebf4", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/data/text-editor/update-file-11552.json b/frontend/playwright/data/text-editor/update-file-11552.json index e556b830cf..0967ef424b 100644 --- a/frontend/playwright/data/text-editor/update-file-11552.json +++ b/frontend/playwright/data/text-editor/update-file-11552.json @@ -1 +1 @@ -w +{} diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 242f0bf6d2..63e16a3966 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -455,4 +455,39 @@ test("Check inner stroke artifacts", async ({ maxDiffPixelRatio: 0, threshold: 0.1, }); +}); + +test("BUG 13551 - Blurs affecting other elements", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-blurs-affecting-other-elements.json"); + + await workspace.goToWorkspace({ + id: "effcbebc-b8c8-802f-8007-a7dc677169cd", + pageId: "a5508528-5928-8008-8007-a7de9feef61bd", + }); + await workspace.waitForFirstRenderWithoutUI(); + + // Stricter comparison: blur is very subtle + await expect(workspace.canvas).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0.1, + }); +}); + +test("BUG 13610 - Huge inner strokes", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-huge-inner-strokes.json"); + + await workspace.goToWorkspace({ + id: "effcbebc-b8c8-802f-8007-b11dd34fe190", + pageId: "effcbebc-b8c8-802f-8007-b11dd34fe191", + }); + await workspace.waitForFirstRenderWithoutUI(); + await expect(workspace.canvas).toHaveScreenshot(); }); \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13551---Blurs-affecting-other-elements-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13551---Blurs-affecting-other-elements-1.png new file mode 100644 index 0000000000..2836870086 Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13551---Blurs-affecting-other-elements-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13610---Huge-inner-strokes-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13610---Huge-inner-strokes-1.png new file mode 100644 index 0000000000..633bd8ad2d Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13610---Huge-inner-strokes-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js b/frontend/playwright/ui/render-wasm-specs/texts.spec.js index d837a64834..23a4701bbd 100644 --- a/frontend/playwright/ui/render-wasm-specs/texts.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/texts.spec.js @@ -587,3 +587,23 @@ test.skip("Updates text alignment edition - part 3", async ({ page }) => { await expect(workspace.canvas).toHaveScreenshot({ timeout: 10000 }); }); + + +test("Renders a file with group with strokes and not 100% opacities", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-strokes-and-not-100-percent-opacities.json"); + + await workspace.goToWorkspace({ + id: "effcbebc-b8c8-802f-8007-b0ebecd7ebf4", + pageId: "3e9e17c3-fc57-80ce-8007-101743996fe9", + }); + + await workspace.waitForFirstRenderWithoutUI(); + await expect(workspace.canvas).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0.01, + }); +}); \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png new file mode 100644 index 0000000000..989559cc02 Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png differ diff --git a/frontend/playwright/ui/specs/render-wasm.spec.js b/frontend/playwright/ui/specs/render-wasm.spec.js index 1c336bf6a8..c764df70b1 100644 --- a/frontend/playwright/ui/specs/render-wasm.spec.js +++ b/frontend/playwright/ui/specs/render-wasm.spec.js @@ -16,6 +16,29 @@ test.skip("BUG 10867 - Crash when loading comments", async ({ page }) => { ).toBeVisible(); }); +test("BUG 13541 - Shows error page when WebGL context is lost", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + await workspacePage.waitForFirstRender(); + + // Simulate a WebGL context loss by dispatching the event on the canvas + await workspacePage.canvas.evaluate((canvas) => { + const event = new Event("webglcontextlost", { cancelable: true }); + canvas.dispatchEvent(event); + }); + + await expect( + page.getByText("Oops! The canvas context was lost"), + ).toBeVisible(); + await expect( + page.getByText("WebGL has stopped working"), + ).toBeVisible(); + await expect(page.getByText("Reload page")).toBeVisible(); +}); + test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({ page, }) => { diff --git a/frontend/scripts/watch b/frontend/scripts/watch index 78de971cba..80d579b73f 100755 --- a/frontend/scripts/watch +++ b/frontend/scripts/watch @@ -4,8 +4,6 @@ TARGET=${1:-app}; set -ex -rm -rf node_modules; - corepack enable; corepack install; pnpm install; diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 222b86fdac..7fcf21d0de 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -127,6 +127,24 @@ (ex/print-throwable cause :prefix "Unexpected Error") (flash :cause cause :type :unhandled)))) +(defmethod ptk/handle-error :wasm-non-blocking + [error] + (when-let [cause (::instance error)] + (show-not-blocking-error cause))) + +(defmethod ptk/handle-error :wasm-critical + [error] + (when-let [cause (::instance error)] + (ex/print-throwable cause :prefix "WASM critical error")) + (st/emit! (rt/assign-exception error))) + +(defmethod ptk/handle-error :wasm-exception + [error] + (when-let [cause (::instance error)] + (let [prefix (or (:prefix error) "Exception")] + (ex/print-throwable cause :prefix prefix))) + (st/emit! (rt/assign-exception error))) + ;; We receive a explicit authentication error; If the uri is for ;; workspace, dashboard, viewer or settings, then assign the exception ;; for show the error page. Otherwise this explicitly clears all @@ -338,6 +356,17 @@ (str/starts-with? message "invalid props on component") (str/starts-with? message "Unexpected token ")))) + (handle-uncaught [cause] + (when cause + (set! last-exception cause) + (let [data (ex-data cause) + type (get data :type)] + (if (#{:wasm-critical :wasm-non-blocking :wasm-exception} type) + (on-error cause) + (when-not (is-ignorable-exception? cause) + (ex/print-throwable cause :prefix "Uncaught Exception") + (ts/schedule #(show-not-blocking-error cause))))))) + (on-unhandled-error [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "error")] diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss index f25fc4ccd9..56ecfe27f0 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss @@ -114,4 +114,5 @@ width: 100%; height: 100%; outline: $b-1 solid var(--tab-panel-outline-color); + overflow-y: auto; } diff --git a/frontend/src/app/main/ui/ds/product/milestone_group.cljs b/frontend/src/app/main/ui/ds/product/milestone_group.cljs index 2b8944d0fd..73452fc30b 100644 --- a/frontend/src/app/main/ui/ds/product/milestone_group.cljs +++ b/frontend/src/app/main/ui/ds/product/milestone_group.cljs @@ -39,7 +39,6 @@ (mf/spread-props props {:class [class class'] :data-testid "milestone"}) - open* (mf/use-state false) @@ -57,7 +56,13 @@ (dom/get-data "index") (d/parse-integer))] (when (fn? on-menu-click) - (on-menu-click index event)))))] + (on-menu-click index event))))) + + snapshots + (mf/with-memo [snapshots] + (map-indexed (fn [index date] + (d/vec2 date index)) + snapshots))] [:> :div props [:> text* {:as "span" :typography t/body-small :class (stl/css :name)} label] @@ -76,14 +81,14 @@ :icon-arrow-toggled open?)}]] (when ^boolean open? - (for [[idx d] (d/enumerate snapshots)] - [:div {:key (dm/str "entry-" idx) + (for [[date index] snapshots] + [:div {:key (dm/str "entry-" index) :class (stl/css :version-entry)} - [:> date* {:date d :class (stl/css :date) :typography t/body-small}] + [:> date* {:date date :class (stl/css :date) :typography t/body-small}] [:> icon-button* {:class (stl/css :entry-button) :variant "ghost" :icon i/menu :aria-label (tr "workspace.versions.version-menu") - :data-index idx + :data-index index :on-click on-menu-click}]]))]])) diff --git a/frontend/src/app/main/ui/ds/utilities/date.cljs b/frontend/src/app/main/ui/ds/utilities/date.cljs index f24cd11ac6..eadeeb187a 100644 --- a/frontend/src/app/main/ui/ds/utilities/date.cljs +++ b/frontend/src/app/main/ui/ds/utilities/date.cljs @@ -6,10 +6,8 @@ (ns app.main.ui.ds.utilities.date (:require-macros - [app.common.data.macros :as dm] [app.main.style :as stl]) (:require - [app.common.data :as d] [app.common.time :as ct] [app.main.ui.ds.foundations.typography :as t] [app.main.ui.ds.foundations.typography.text :refer [text*]] @@ -30,15 +28,10 @@ (mf/defc date* {::mf/schema schema:date} [{:keys [class date selected typography] :rest props}] - (let [class (d/append-class class (stl/css-case :date true :is-selected selected)) - date (cond-> date (not (ct/inst? date)) ct/inst) + (let [date (cond-> date (not (ct/inst? date)) ct/inst) typography (or typography t/body-medium)] [:> text* {:as "time" :typography typography - :class class + :class [class (stl/css-case :date true :is-selected selected)] :date-time (ct/format-inst date :iso)} - (dm/str - (ct/format-inst date :localized-date) - " . " - (ct/format-inst date :localized-time) - "h")])) + (ct/format-inst date :localized-date-time)])) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index a9f69a2c8e..a32b0aca7f 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -477,8 +477,12 @@ :service-unavailable [:> service-unavailable*] - :webgl-context-lost - [:> webgl-context-lost*] + :wasm-exception + (case (get data :exception-type) + :webgl-context-lost + [:> webgl-context-lost*] + + [:> internal-error* props]) [:> internal-error* props]))) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs new file mode 100644 index 0000000000..9fd4a23e99 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -0,0 +1,343 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.workspace.shapes.text.v3-editor + "Contenteditable DOM element for WASM text editor input" + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.main.data.helpers :as dsh] + [app.main.data.workspace.texts :as dwt] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.css-cursors :as cur] + [app.render-wasm.api :as wasm.api] + [app.render-wasm.text-editor :as text-editor] + [app.util.dom :as dom] + [app.util.object :as obj] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(def caret-blink-interval-ms 250) + +(defn- sync-wasm-text-editor-content! + "Sync WASM text editor content back to the shape via the standard + commit pipeline. Called after every text-modifying input." + [& {:keys [finalize?]}] + (when-let [{:keys [shape-id content]} + (text-editor/text-editor-sync-content)] + (st/emit! (dwt/v2-update-text-shape-content + shape-id content + :update-name? true + :finalize? finalize?)))) + +(defn- font-family-from-font-id [font-id] + (if (str/includes? font-id "gfont-noto-sans") + (let [lang (str/replace font-id #"gfont\-noto\-sans\-" "")] + (if (>= (count lang) 3) (str/capital lang) (str/upper lang))) + "Noto Color Emoji")) + +(mf/defc text-editor + "Contenteditable element positioned over the text shape to capture input events." + {::mf/wrap-props false} + [props] + (let [shape (obj/get props "shape") + shape-id (dm/get-prop shape :id) + + clip-id (dm/str "text-edition-clip" shape-id) + + contenteditable-ref (mf/use-ref nil) + composing? (mf/use-state false) + + fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false) + fallback-families (map (fn [font] + (font-family-from-font-id (:font-id font))) fallback-fonts) + + [{:keys [x y width height]} transform] + (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id) + selrect-transform (mf/deref refs/workspace-selrect) + [selrect transform] (dsh/get-selrect selrect-transform shape) + selrect-height (:height selrect) + selrect-width (:width selrect) + max-width (max width selrect-width) + max-height (max height selrect-height) + valign (-> shape :content :vertical-align) + y (:y selrect) + y (case valign + "bottom" (+ y (- selrect-height height)) + "center" (+ y (/ (- selrect-height height) 2)) + y)] + [(assoc selrect :y y :width max-width :height max-height) transform]) + + on-composition-start + (mf/use-fn + (fn [_event] + (reset! composing? true))) + + on-composition-end + (mf/use-fn + (fn [^js event] + (reset! composing? false) + (let [data (.-data event)] + (when data + (text-editor/text-editor-insert-text data) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-composition")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))) + + on-paste + (mf/use-fn + (fn [^js event] + (dom/prevent-default event) + (let [clipboard-data (.-clipboardData event) + text (.getData clipboard-data "text/plain")] + (when (and text (seq text)) + (text-editor/text-editor-insert-text text) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-paste")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))) + + on-copy + (mf/use-fn + (fn [^js event] + (when (text-editor/text-editor-is-active?) + (dom/prevent-default event) + (when (text-editor/text-editor-get-selection) + (let [text (text-editor/text-editor-export-selection)] + (.setData (.-clipboardData event) "text/plain" text)))))) + + on-cut + (mf/use-fn + (fn [^js event] + (when (text-editor/text-editor-is-active?) + (dom/prevent-default event) + (when (text-editor/text-editor-get-selection) + (let [text (text-editor/text-editor-export-selection)] + (.setData (.-clipboardData event) "text/plain" (or text "")) + (when (and text (seq text)) + (text-editor/text-editor-delete-backward) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-cut")))) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))) + + on-key-down + (mf/use-fn + (fn [^js event] + (when (and (text-editor/text-editor-is-active?) + (not @composing?)) + (let [key (.-key event) + ctrl? (or (.-ctrlKey event) (.-metaKey event)) + shift? (.-shiftKey event)] + + (cond + ;; Escape: finalize and stop + (= key "Escape") + (do + (dom/prevent-default event) + (when-let [node (mf/ref-val contenteditable-ref)] + (.blur node))) + + ;; Ctrl+A: select all (key is "a" or "A" depending on platform) + (and ctrl? (= (str/lower key) "a")) + (do + (dom/prevent-default event) + (text-editor/text-editor-select-all) + (wasm.api/request-render "text-select-all")) + + ;; Enter + (= key "Enter") + (do + (dom/prevent-default event) + (text-editor/text-editor-insert-paragraph) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-paragraph")) + + ;; Backspace + (= key "Backspace") + (do + (dom/prevent-default event) + (text-editor/text-editor-delete-backward) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-delete-backward")) + + ;; Delete + (= key "Delete") + (do + (dom/prevent-default event) + (text-editor/text-editor-delete-forward) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-delete-forward")) + + ;; Arrow keys + (= key "ArrowLeft") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 0 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowRight") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 1 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowUp") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 2 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "ArrowDown") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 3 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "Home") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 4 shift?) + (wasm.api/request-render "text-cursor-move")) + + (= key "End") + (do + (dom/prevent-default event) + (text-editor/text-editor-move-cursor 5 shift?) + (wasm.api/request-render "text-cursor-move")) + + ;; Let contenteditable handle text input via on-input + :else nil))))) + + on-input + (mf/use-fn + (fn [^js event] + (let [native-event (.-nativeEvent event) + input-type (.-inputType native-event) + data (.-data native-event)] + ;; Skip composition-related input events - composition-end handles those + (when (and (not @composing?) + (not= input-type "insertCompositionText")) + (when (and data (seq data)) + (text-editor/text-editor-insert-text data) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-input")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) "")))))) + + on-pointer-down + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-pointer-down off-pt)))) + + on-pointer-move + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-pointer-move off-pt)))) + + on-pointer-up + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-pointer-up off-pt)))) + + on-click + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-set-cursor-from-offset off-pt)))) + + on-double-click + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-select-word-boundary off-pt)))) + + on-focus + (mf/use-fn + (fn [^js _event] + (wasm.api/text-editor-start shape-id))) + + on-blur + (mf/use-fn + (fn [^js _event] + (sync-wasm-text-editor-content! {:finalize? true}) + (wasm.api/text-editor-stop))) + + style #js {:pointerEvents "all" + "--editor-container-width" (dm/str width "px") + "--editor-container-height" (dm/str height "px") + "--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}] + + ;; Focus contenteditable on mount + (mf/use-effect + (mf/deps contenteditable-ref) + (fn [] + (when-let [node (mf/ref-val contenteditable-ref)] + (.focus node)) + ;; Explicitly call on-blur here instead of relying on browser blur events, + ;; because in Firefox blur is not reliably fired when leaving the text editor + ;; by clicking elsewhere. The component does unmount when the shape is + ;; deselected, so we can safely call the blur handler here to finalize the editor. + on-blur)) + + (mf/use-effect + (fn [] + (let [timeout-id (atom nil) + schedule-blink (fn schedule-blink [] + (when (text-editor/text-editor-is-active?) + (wasm.api/request-render "cursor-blink")) + (reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))] + (schedule-blink) + (fn [] + (when @timeout-id + (js/clearTimeout @timeout-id)))))) + + ;; Composition and input events + [:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id) + :transform (dm/str transform) + :data-testid "text-editor"} + [:defs + [:clipPath {:id clip-id} + [:rect {:x x :y y :width width :height height}]]] + + [:foreignObject {:x x :y y :width width :height height} + [:div {:on-click on-click + :on-double-click on-double-click + :on-pointer-down on-pointer-down + :on-pointer-move on-pointer-move + :on-pointer-up on-pointer-up + :class (stl/css :text-editor) + :style style} + [:div + {:ref contenteditable-ref + :contentEditable true + :suppressContentEditableWarning true + :on-composition-start on-composition-start + :on-composition-end on-composition-end + :on-key-down on-key-down + :on-input on-input + :on-paste on-paste + :on-copy on-copy + :on-cut on-cut + :on-focus on-focus + :on-blur on-blur + ;; FIXME on-click + ;; :on-click on-click + :id "text-editor-wasm-input" + :class (dm/str (cur/get-dynamic "text" (:rotation shape)) + " " + (stl/css :text-editor-container)) + :data-testid "text-editor-container"}]]]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss new file mode 100644 index 0000000000..8539a7ca29 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss @@ -0,0 +1,13 @@ +.text-editor { + height: 100%; +} + +.text-editor-container { + width: 100%; + height: 100%; + position: absolute; + + opacity: 0; + overflow: hidden; + white-space: pre; +} diff --git a/frontend/src/app/main/ui/workspace/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar.scss index acceaf4f7d..3c5360b4f4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/sidebar.scss @@ -159,3 +159,7 @@ overflow: hidden; height: calc(100vh - deprecated.$s-88); } + +.history-tab { + overflow-y: auto; +} diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index 041cb6f53a..50cd0acec5 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -19,13 +19,11 @@ [app.main.data.workspace.media :as dwm] [app.main.data.workspace.path :as dwdp] [app.main.data.workspace.specialized-panel :as-alias dwsp] - [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.workspace.sidebar.assets.components :as wsac] [app.main.ui.workspace.viewport.viewport-ref :as uwvv] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm.wasm] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] [app.util.dom.normalize-wheel :as nw] @@ -74,7 +72,6 @@ shift? (kbd/shift? native-event) alt? (kbd/alt? native-event) mod? (kbd/mod? native-event) - off-pt (dom/get-offset-position native-event) left-click? (and (not panning) (dom/left-mouse? event)) middle-click? (and (not panning) (dom/middle-mouse? event))] @@ -94,23 +91,8 @@ (st/emit! (mse/->MouseEvent :down ctrl? shift? alt? meta?) ::dwsp/interrupt) - (when (wasm.api/text-editor-is-active?) - (wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt))) - (when (and (not= edition id) (or text-editing? grid-editing?)) - (st/emit! (dw/clear-edition-mode)) - ;; FIXME: I think this is not completely correct because this - ;; is going to happen even when clicking or selecting text. - ;; Sync and stop WASM text editor when exiting edit mode - #_(when (and text-editing? - (features/active-feature? @st/state "render-wasm/v1") - wasm.wasm/context-initialized?) - (when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :finalize? true))) - (wasm.api/text-editor-stop))) + (st/emit! (dw/clear-edition-mode))) (when (and (not text-editing?) (not blocked) @@ -192,8 +174,6 @@ alt? (kbd/alt? event) meta? (kbd/meta? event) hovering? (some? @hover) - native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event) raw-pt (dom/get-client-position event) pt (uwvv/point->viewport raw-pt)] (st/emit! (mse/->MouseEvent :click ctrl? shift? alt? meta?)) @@ -207,20 +187,6 @@ (not drawing-tool)) (st/emit! (dw/select-shape (:id @hover) shift?))) - ;; FIXME: Maybe we can move into a function of the kind - ;; "text-editor-on-click" - ;; If clicking on a text shape and wasm render is enabled, forward cursor position - (when (and hovering? - (not @space?) - edition ;; Only when already in edit mode - (not drawing-path?) - (not drawing-tool)) - (let [hover-shape @hover] - (when (and (= :text (:type hover-shape)) - (features/active-feature? @st/state "text-editor-wasm/v1") - wasm.wasm/context-initialized?) - (wasm.api/text-editor-set-cursor-from-point (.-x off-pt) (.-y off-pt))))) - (when (and @z? (not @space?) (not edition) @@ -262,19 +228,7 @@ (and editable? (not= id edition) (not read-only?)) (do (st/emit! (dw/select-shape id) - (dw/start-editing-selected)) - ;; If using wasm text-editor, notify WASM to start editing this shape - ;; and set cursor position from the double-click location - (when (and (= type :text) - (features/active-feature? @st/state "text-editor-wasm/v1") - wasm.wasm/context-initialized?) - (wasm.api/text-editor-start id))) - - (and editable? (= id edition) (not read-only?) - (= type :text) - (features/active-feature? @st/state "text-editor-wasm/v1") - wasm.wasm/context-initialized?) - (wasm.api/text-editor-select-all) + (dw/start-editing-selected))) (some? selected-shape) (do diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index b120658a5b..52255eac22 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -30,6 +30,7 @@ [app.main.ui.workspace.shapes.text.editor :as editor-v1] [app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline]] [app.main.ui.workspace.shapes.text.v2-editor :as editor-v2] + [app.main.ui.workspace.shapes.text.v3-editor :as editor-v3] [app.main.ui.workspace.top-toolbar :refer [top-toolbar*]] [app.main.ui.workspace.viewport.actions :as actions] [app.main.ui.workspace.viewport.comments :as comments] @@ -54,7 +55,6 @@ [app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]] [app.main.ui.workspace.viewport.widgets :as widgets] [app.render-wasm.api :as wasm.api] - [app.render-wasm.text-editor-input :refer [text-editor-input]] [app.util.debug :as dbg] [app.util.text-editor :as ted] [beicon.v2.core :as rx] @@ -417,14 +417,7 @@ (when picking-color? [:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref - :canvas-ref canvas-ref}]) - - ;; WASM text editor contenteditable (must be outside SVG to work) - (when (and show-text-editor? - (features/active-feature? @st/state "text-editor-wasm/v1")) - [:& text-editor-input {:shape editing-shape - :zoom zoom - :vbox vbox}])] + :canvas-ref canvas-ref}])] [:canvas {:id "render" :data-testid "canvas-wasm-shapes" @@ -471,14 +464,20 @@ [:g {:style {:pointer-events (if disable-events? "none" "auto")}} ;; Text editor handling: ;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM) - (when (and show-text-editor? - (not (features/active-feature? @st/state "text-editor-wasm/v1"))) - (if (features/active-feature? @st/state "text-editor/v2") + (when show-text-editor? + (cond + (features/active-feature? @st/state "text-editor-wasm/v1") + [:& editor-v3/text-editor {:shape editing-shape + :canvas-ref canvas-ref + :ref text-editor-ref}] + + (features/active-feature? @st/state "text-editor/v2") [:& editor-v2/text-editor {:shape editing-shape :canvas-ref canvas-ref :ref text-editor-ref}] - [:& editor-v1/text-editor-svg {:shape editing-shape - :ref text-editor-ref}])) + + :else [:& editor-v1/text-editor-svg {:shape editing-shape + :ref text-editor-ref}])) (when show-frame-outline? (let [outlined-frame-id diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index e3f55b0d37..82fb16ebaa 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -86,12 +86,14 @@ ;; Re-export public text editor functions (def text-editor-start text-editor/text-editor-start) (def text-editor-stop text-editor/text-editor-stop) +(def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset) (def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point) (def text-editor-pointer-down text-editor/text-editor-pointer-down) (def text-editor-pointer-move text-editor/text-editor-pointer-move) (def text-editor-pointer-up text-editor/text-editor-pointer-up) (def text-editor-is-active? text-editor/text-editor-is-active?) (def text-editor-select-all text-editor/text-editor-select-all) +(def text-editor-select-word-boundary text-editor/text-editor-select-word-boundary) (def text-editor-sync-content text-editor/text-editor-sync-content) (def dpr @@ -1419,7 +1421,9 @@ (dom/prevent-default event) (reset! wasm/context-lost? true) (log/warn :hint "WebGL context lost") - (ex/raise :type :webgl-context-lost + (ex/raise :type :wasm-exception + :exception-type :webgl-context-lost + :prefix "WebGL context lost" :hint "WebGL context lost")) (defn init-canvas-context diff --git a/frontend/src/app/render_wasm/helpers.cljc b/frontend/src/app/render_wasm/helpers.cljc index 5cb9b5f5ac..5b973cd837 100644 --- a/frontend/src/app/render_wasm/helpers.cljc +++ b/frontend/src/app/render_wasm/helpers.cljc @@ -7,11 +7,30 @@ (ns app.render-wasm.helpers #?(:cljs (:require-macros [app.render-wasm.helpers]))) +(def ^:export error-code + "WASM error code constants (must match render-wasm/src/error.rs and mem.rs)." + {0x01 :wasm-non-blocking 0x02 :wasm-critical}) + (defmacro call - "A helper for easy call wasm defined function in a module." + "A helper for calling a wasm function. + Catches any exception thrown by the WASM function, reads the error code from + WASM when available, and routes it based on the error type: + - :wasm-non-blocking: call app.main.errors/on-error (eventually, shows a toast and logs the error) + - :wasm-critical or unknown: throws an exception to be handled by the global error handler (eventually, shows the internal error page)" [module name & params] - (let [fn-sym (with-meta (gensym "fn-") {:tag 'function})] + (let [fn-sym (with-meta (gensym "fn-") {:tag 'function}) + e-sym (gensym "e") + code-sym (gensym "code")] `(let [~fn-sym (cljs.core/unchecked-get ~module ~name)] - ;; DEBUG - ;; (println "##" ~name) - (~fn-sym ~@params)))) + (try + (~fn-sym ~@params) + (catch :default ~e-sym + (let [read-code# (cljs.core/unchecked-get ~module "_read_error_code") + ~code-sym (when read-code# (read-code#)) + type# (or (get app.render-wasm.helpers/error-code ~code-sym) :wasm-critical) + ex# (ex-info (str "WASM error (type: " type# ")") + {:fn ~name :type type# :message (.-message ~e-sym) :error-code ~code-sym} + ~e-sym)] + (if (= type# :wasm-non-blocking) + (@~'app.main.store/on-error ex#) + (throw ex#)))))))) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 21bcca45d2..94ad28b690 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -16,29 +16,37 @@ [id] (when wasm/context-initialized? (let [buffer (uuid/get-u32 id)] - (h/call wasm/internal-module "_text_editor_start" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3))))) + (when-not (h/call wasm/internal-module "_text_editor_start" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3)) + (throw (js/Error. "TextEditor initialization failed")))))) + +(defn text-editor-set-cursor-from-offset + "Sets caret position from shape relative coordinates" + [{:keys [x y]}] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_set_cursor_from_offset" x y))) (defn text-editor-set-cursor-from-point - [x y] + "Sets caret position from screen (canvas) coordinates" + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y))) (defn text-editor-pointer-down - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_pointer_down" x y))) (defn text-editor-pointer-move - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_pointer_move" x y))) (defn text-editor-pointer-up - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_pointer_up" x y))) @@ -92,10 +100,16 @@ (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_select_all"))) +(defn text-editor-select-word-boundary + [{:keys [x y]}] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_select_word_boundary" x y))) + (defn text-editor-stop [] (when wasm/context-initialized? - (h/call wasm/internal-module "_text_editor_stop"))) + (when-not (h/call wasm/internal-module "_text_editor_stop") + (throw (js/Error. "TextEditor finalization failed"))))) (defn text-editor-is-active? ([id] @@ -160,6 +174,7 @@ (finally (mem/free)))))) +;; This is used as a intermediate cache between Clojure global state and WASM state. (def ^:private shape-text-contents (atom {})) (defn- merge-exported-texts-into-content diff --git a/frontend/src/app/render_wasm/text_editor_input.cljs b/frontend/src/app/render_wasm/text_editor_input.cljs deleted file mode 100644 index 7eced4ab16..0000000000 --- a/frontend/src/app/render_wasm/text_editor_input.cljs +++ /dev/null @@ -1,241 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) KALEIDOS INC - -(ns app.render-wasm.text-editor-input - "Contenteditable DOM element for WASM text editor input" - (:require - [app.common.geom.shapes :as gsh] - [app.main.data.workspace.texts :as dwt] - [app.main.store :as st] - [app.render-wasm.api :as wasm.api] - [app.render-wasm.text-editor :as text-editor] - [app.util.dom :as dom] - [app.util.object :as obj] - [cuerdas.core :as str] - [goog.events :as events] - [rumext.v2 :as mf]) - (:import goog.events.EventType)) - -(def caret-blink-interval-ms 250) - -(defn- sync-wasm-text-editor-content! - "Sync WASM text editor content back to the shape via the standard - commit pipeline. Called after every text-modifying input." - [& {:keys [finalize?]}] - (when-let [{:keys [shape-id content]} (text-editor/text-editor-sync-content)] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :finalize? finalize?)))) - -(mf/defc text-editor-input - "Contenteditable element positioned over the text shape to capture input events." - {::mf/wrap-props false} - [props] - (let [shape (obj/get props "shape") - zoom (obj/get props "zoom") - vbox (obj/get props "vbox") - - contenteditable-ref (mf/use-ref nil) - composing? (mf/use-state false) - - ;; Calculate screen position from shape bounds - shape-bounds (gsh/shape->rect shape) - screen-x (* (- (:x shape-bounds) (:x vbox)) zoom) - screen-y (* (- (:y shape-bounds) (:y vbox)) zoom) - screen-w (* (:width shape-bounds) zoom) - screen-h (* (:height shape-bounds) zoom)] - - ;; Focus contenteditable on mount - (mf/use-effect - (fn [] - (when-let [node (mf/ref-val contenteditable-ref)] - (.focus node)) - js/undefined)) - - (mf/use-effect - (fn [] - (let [timeout-id (atom nil) - schedule-blink (fn schedule-blink [] - (when (text-editor/text-editor-is-active?) - (wasm.api/request-render "cursor-blink")) - (reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))] - (schedule-blink) - (fn [] - (when @timeout-id - (js/clearTimeout @timeout-id)))))) - - ;; Document-level keydown handler for control keys - (mf/use-effect - (fn [] - (let [on-doc-keydown - (fn [e] - (when (and (text-editor/text-editor-is-active?) - (not @composing?)) - (let [key (.-key e) - ctrl? (or (.-ctrlKey e) (.-metaKey e)) - shift? (.-shiftKey e)] - (cond - ;; Escape: finalize and stop - (= key "Escape") - (do - (dom/prevent-default e) - (sync-wasm-text-editor-content! :finalize? true) - (text-editor/text-editor-stop)) - - ;; Ctrl+A: select all (key is "a" or "A" depending on platform) - (and ctrl? (= (str/lower key) "a")) - (do - (dom/prevent-default e) - (text-editor/text-editor-select-all) - (wasm.api/request-render "text-select-all")) - - ;; Enter - (= key "Enter") - (do - (dom/prevent-default e) - (text-editor/text-editor-insert-paragraph) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-paragraph")) - - ;; Backspace - (= key "Backspace") - (do - (dom/prevent-default e) - (text-editor/text-editor-delete-backward) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-delete-backward")) - - ;; Delete - (= key "Delete") - (do - (dom/prevent-default e) - (text-editor/text-editor-delete-forward) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-delete-forward")) - - ;; Arrow keys - (= key "ArrowLeft") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 0 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "ArrowRight") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 1 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "ArrowUp") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 2 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "ArrowDown") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 3 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "Home") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 4 shift?) - (wasm.api/request-render "text-cursor-move")) - - (= key "End") - (do - (dom/prevent-default e) - (text-editor/text-editor-move-cursor 5 shift?) - (wasm.api/request-render "text-cursor-move")) - - ;; Let contenteditable handle text input via on-input - :else nil))))] - (events/listen js/document EventType.KEYDOWN on-doc-keydown true) - (fn [] - (events/unlisten js/document EventType.KEYDOWN on-doc-keydown true))))) - - ;; Composition and input events - (let [on-composition-start - (mf/use-fn - (fn [_event] - (reset! composing? true))) - - on-composition-end - (mf/use-fn - (fn [^js event] - (reset! composing? false) - (let [data (.-data event)] - (when data - (text-editor/text-editor-insert-text data) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-composition")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))) - - on-paste - (mf/use-fn - (fn [^js event] - (dom/prevent-default event) - (let [clipboard-data (.-clipboardData event) - text (.getData clipboard-data "text/plain")] - (when (and text (seq text)) - (text-editor/text-editor-insert-text text) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-paste")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))) - - on-copy - (mf/use-fn - (fn [^js event] - (when (text-editor/text-editor-is-active?) - (dom/prevent-default event) - (when (text-editor/text-editor-get-selection) - (let [text (text-editor/text-editor-export-selection)] - (.setData (.-clipboardData event) "text/plain" text)))))) - - on-input - (mf/use-fn - (fn [^js event] - (let [native-event (.-nativeEvent event) - input-type (.-inputType native-event) - data (.-data native-event)] - ;; Skip composition-related input events - composition-end handles those - (when (and (not @composing?) - (not= input-type "insertCompositionText")) - (when (and data (seq data)) - (text-editor/text-editor-insert-text data) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-input")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))))] - - [:div - {:ref contenteditable-ref - :contentEditable true - :suppressContentEditableWarning true - :on-composition-start on-composition-start - :on-composition-end on-composition-end - :on-input on-input - :on-paste on-paste - :on-copy on-copy - ;; FIXME on-click - ;; :on-click on-click - :id "text-editor-wasm-input" - ;; FIXME - :style {:position "absolute" - :left (str screen-x "px") - :top (str screen-y "px") - :width (str screen-w "px") - :height (str screen-h "px") - :opacity 0 - :overflow "hidden" - :white-space "pre" - :cursor "text" - :z-index 10}}]))) diff --git a/library/package.json b/library/package.json index f1b24fcb49..aa279286ad 100644 --- a/library/package.json +++ b/library/package.json @@ -26,8 +26,9 @@ "clear:shadow-cache": "rm -rf .shadow-cljs", "build": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs release library", "build:bundle": "./scripts/build", - "fmt:clj": "cljfmt fix --parallel=true src/ test/", - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", + "fmt": "cljfmt fix --parallel=true src/ test/", + "check-fmt": "cljfmt check --parallel=true src/ test/", + "lint": "clj-kondo --parallel --lint src/", "test": "node --test", "watch:test": "node --test --watch", "watch": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch library" diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock index e5c289d99e..5d749143fd 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -297,6 +297,8 @@ name = "macros" version = "0.1.0" dependencies = [ "heck", + "proc-macro2", + "quote", "syn", ] @@ -426,6 +428,7 @@ dependencies = [ "indexmap", "macros", "skia-safe", + "thiserror", "uuid", ] @@ -579,6 +582,26 @@ dependencies = [ "xattr", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "1.0.3+spec-1.1.0" diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index ca37fe4104..a26e798e13 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -32,6 +32,7 @@ skia-safe = { version = "0.93.1", default-features = false, features = [ "binary-cache", "webp", ] } +thiserror = "2.0.18" uuid = { version = "1.11.0", features = ["v4", "js"] } [profile.release] diff --git a/render-wasm/macros/Cargo.lock b/render-wasm/macros/Cargo.lock index 8b4fd748ae..8612f3a90b 100644 --- a/render-wasm/macros/Cargo.lock +++ b/render-wasm/macros/Cargo.lock @@ -13,6 +13,8 @@ name = "macros" version = "0.1.0" dependencies = [ "heck", + "proc-macro2", + "quote", "syn", ] diff --git a/render-wasm/macros/Cargo.toml b/render-wasm/macros/Cargo.toml index 6c2abd7509..f3738381b8 100644 --- a/render-wasm/macros/Cargo.toml +++ b/render-wasm/macros/Cargo.toml @@ -1,11 +1,13 @@ [package] name = "macros" version = "0.1.0" -edition = "2024" +edition = "2021" [lib] proc-macro = true [dependencies] heck = "0.5.0" +proc-macro2 = "1.0" +quote = "1.0" syn = "2.0.106" diff --git a/render-wasm/macros/src/lib.rs b/render-wasm/macros/src/lib.rs index a0eec23ca6..2d3536d3d1 100644 --- a/render-wasm/macros/src/lib.rs +++ b/render-wasm/macros/src/lib.rs @@ -6,9 +6,109 @@ use std::sync; use heck::{ToKebabCase, ToPascalCase}; use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, Block, GenericArgument, ItemFn, ReturnType, Type}; type Result = std::result::Result; +/// Attribute macro for WASM-exported functions. The function **must** return +/// `std::result::Result` where T is a C ABI type and E implements +/// `std::error::Error` and `Into`. The macro: +/// - Clears the error code at entry. +/// - Runs the body in `std::panic::catch_unwind`. +/// - Unwraps the Result: `Ok(x)` → return x; `Err(e)` → set error code in memory and panic +/// (so ClojureScript can catch the exception and read the code via `read_error_code`). +/// - On panic from the body: sets critical error code (0x02) and resumes unwind. +#[proc_macro_attribute] +pub fn wasm_error(_attr: TokenStream, item: TokenStream) -> TokenStream { + let mut input = parse_macro_input!(item as ItemFn); + let body = (*input.block).clone(); + + let (attrs, boxed_ty) = match &input.sig.output { + ReturnType::Type(attrs, boxed_ty) => (attrs, boxed_ty), + ReturnType::Default => { + return quote! { + compile_error!( + "#[wasm_error] requires the function to return std::result::Result where E: std::error::Error + Into" + ); + } + .into(); + } + }; + + let (inner_ty, error_ty) = match crate_error_result_inner_type(boxed_ty) { + Some(t) => (t, quote!(crate::error::Error)), + None => { + return quote! { + compile_error!( + "#[wasm_error] requires the function to return crate::error::Result. T must be a C ABI type (u32, u8, bool, (), etc.)" + ); + } + .into(); + } + }; + + let block: Block = syn::parse2(quote! { + { + crate::mem::clear_error_code(); + let __wasm_err_result = std::panic::catch_unwind(|| -> std::result::Result<#inner_ty, #error_ty> { + #body + }); + match __wasm_err_result { + Ok(__inner) => match __inner { + Ok(__val) => __val, + Err(__e) => { + let _: &dyn std::error::Error = &__e; + let __msg = __e.to_string(); + crate::mem::set_error_code(__e.into()); + panic!("WASM error: {}",__msg); + } + }, + Err(__payload) => { + crate::mem::set_error_code(0x02); // critical, same as Error::Critical + std::panic::resume_unwind(__payload); + } + } + } + }) + .expect("block parse"); + + input.sig.output = ReturnType::Type(attrs.clone(), Box::new(inner_ty.clone())); + input.block = Box::new(block); + quote! { #input }.into() +} + +/// If the type is crate::error::Result or a single-segment Result (e.g. with +/// `use crate::error::Result`), returns Some(T). Otherwise None. +fn crate_error_result_inner_type(ty: &Type) -> Option<&Type> { + let path = match ty { + Type::Path(tp) => &tp.path, + _ => return None, + }; + let segs: Vec<_> = path.segments.iter().collect(); + let last = path.segments.last()?; + if last.ident != "Result" { + return None; + } + let args = match &last.arguments { + syn::PathArguments::AngleBracketed(a) => &a.args, + _ => return None, + }; + if args.len() != 1 { + return None; + } + // Accept crate::error::Result or bare Result (from use) + let ok = segs.len() == 1 + || (segs.len() == 3 && segs[0].ident == "crate" && segs[1].ident == "error"); + if !ok { + return None; + } + match &args[0] { + GenericArgument::Type(t) => Some(t), + _ => None, + } +} + #[proc_macro_derive(ToJs)] pub fn derive_to_cljs(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as syn::DeriveInput); diff --git a/render-wasm/src/error.rs b/render-wasm/src/error.rs new file mode 100644 index 0000000000..413a81afff --- /dev/null +++ b/render-wasm/src/error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +pub const RECOVERABLE_ERROR: u8 = 0x01; +pub const CRITICAL_ERROR: u8 = 0x02; + +// This is not really dead code, #[wasm_error] macro replaces this by something else. +#[allow(dead_code)] +pub type Result = std::result::Result; + +#[derive(Error, Debug)] +pub enum Error { + #[error("[Recoverable] {0}")] + RecoverableError(String), + #[error("[Critical] {0}")] + CriticalError(String), +} + +impl From for u8 { + fn from(error: Error) -> Self { + match error { + Error::RecoverableError(_) => RECOVERABLE_ERROR, + Error::CriticalError(_) => CRITICAL_ERROR, + } + } +} diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index c435e03ded..6e519cc249 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -1,5 +1,6 @@ #[cfg(target_arch = "wasm32")] mod emscripten; +mod error; mod math; mod mem; mod options; @@ -14,12 +15,16 @@ mod view; mod wapi; mod wasm; +use std::collections::HashMap; + +#[allow(unused_imports)] +use crate::error::{Error, Result}; +use macros::wasm_error; use math::{Bounds, Matrix}; use mem::SerializableResult; use shapes::{StructureEntry, StructureEntryType, TransformEntry}; use skia_safe as skia; use state::State; -use std::collections::HashMap; use utils::uuid_from_u32_quartet; use uuid::Uuid; @@ -95,22 +100,27 @@ macro_rules! with_state_mut_current_shape { } #[no_mangle] -pub extern "C" fn init(width: i32, height: i32) { +#[wasm_error] +pub extern "C" fn init(width: i32, height: i32) -> Result<()> { let state_box = Box::new(State::new(width, height)); unsafe { STATE = Some(state_box); } + Ok(()) } #[no_mangle] -pub extern "C" fn set_browser(browser: u8) { +#[wasm_error] +pub extern "C" fn set_browser(browser: u8) -> Result<()> { with_state_mut!(state, { state.set_browser(browser); }); + Ok(()) } #[no_mangle] -pub extern "C" fn clean_up() { +#[wasm_error] +pub extern "C" fn clean_up() -> Result<()> { with_state_mut!(state, { // Cancel the current animation frame if it exists so // it won't try to render without context @@ -118,49 +128,60 @@ pub extern "C" fn clean_up() { render_state.cancel_animation_frame(); }); unsafe { STATE = None } - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] -pub extern "C" fn set_render_options(debug: u32, dpr: f32) { +#[wasm_error] +pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> { with_state_mut!(state, { let render_state = state.render_state_mut(); render_state.set_debug_flags(debug); render_state.set_dpr(dpr); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_canvas_background(raw_color: u32) { +#[wasm_error] +pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { with_state_mut!(state, { let color = skia::Color::new(raw_color); state.set_background_color(color); state.rebuild_tiles_shallow(); }); + + Ok(()) } #[no_mangle] -pub extern "C" fn render(_: i32) { +#[wasm_error] +pub extern "C" fn render(_: i32) -> Result<()> { with_state_mut!(state, { state.rebuild_touched_tiles(); state .start_render_loop(performance::get_time()) .expect("Error rendering"); }); + Ok(()) } #[no_mangle] -pub extern "C" fn render_sync() { +#[wasm_error] +pub extern "C" fn render_sync() -> Result<()> { with_state_mut!(state, { state.rebuild_tiles(); state .render_sync(performance::get_time()) .expect("Error rendering"); }); + Ok(()) } #[no_mangle] -pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) { +#[wasm_error] +pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { with_state_mut!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.use_shape(id); @@ -179,34 +200,42 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) { state.rebuild_tiles_from(Some(&id)); state .render_sync_shape(&id, performance::get_time()) - .expect("Error rendering"); + .map_err(|e| Error::RecoverableError(e.to_string()))?; }); + Ok(()) } #[no_mangle] -pub extern "C" fn render_from_cache(_: i32) { +#[wasm_error] +pub extern "C" fn render_from_cache(_: i32) -> Result<()> { with_state_mut!(state, { state.render_state.cancel_animation_frame(); state.render_from_cache(); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_preview_mode(enabled: bool) { +#[wasm_error] +pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> { with_state_mut!(state, { state.render_state.set_preview_mode(enabled); }); + Ok(()) } #[no_mangle] -pub extern "C" fn render_preview() { +#[wasm_error] +pub extern "C" fn render_preview() -> Result<()> { with_state_mut!(state, { state.render_preview(performance::get_time()); }); + Ok(()) } #[no_mangle] -pub extern "C" fn process_animation_frame(timestamp: i32) { +#[wasm_error] +pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> { let result = std::panic::catch_unwind(|| { with_state_mut!(state, { state @@ -225,37 +254,45 @@ pub extern "C" fn process_animation_frame(timestamp: i32) { std::panic::resume_unwind(err); } } + Ok(()) } #[no_mangle] -pub extern "C" fn reset_canvas() { +#[wasm_error] +pub extern "C" fn reset_canvas() -> Result<()> { with_state_mut!(state, { state.render_state_mut().reset_canvas(); }); + Ok(()) } #[no_mangle] -pub extern "C" fn resize_viewbox(width: i32, height: i32) { +#[wasm_error] +pub extern "C" fn resize_viewbox(width: i32, height: i32) -> Result<()> { with_state_mut!(state, { state.resize(width, height); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) { +#[wasm_error] +pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) -> Result<()> { with_state_mut!(state, { performance::begin_measure!("set_view"); let render_state = state.render_state_mut(); render_state.set_view(zoom, x, y); performance::end_measure!("set_view"); }); + Ok(()) } #[cfg(feature = "profile-macros")] static mut VIEW_INTERACTION_START: i32 = 0; #[no_mangle] -pub extern "C" fn set_view_start() { +#[wasm_error] +pub extern "C" fn set_view_start() -> Result<()> { with_state_mut!(state, { #[cfg(feature = "profile-macros")] unsafe { @@ -265,10 +302,12 @@ pub extern "C" fn set_view_start() { state.render_state.options.set_fast_mode(true); performance::end_measure!("set_view_start"); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_view_end() { +#[wasm_error] +pub extern "C" fn set_view_end() -> Result<()> { with_state_mut!(state, { let _end_start = performance::begin_timed_log!("set_view_end"); performance::begin_measure!("set_view_end"); @@ -304,17 +343,21 @@ pub extern "C" fn set_view_end() { performance::console_log!("[PERF] view_interaction: {}ms", total_time); } }); + Ok(()) } #[no_mangle] -pub extern "C" fn clear_focus_mode() { +#[wasm_error] +pub extern "C" fn clear_focus_mode() -> Result<()> { with_state_mut!(state, { state.clear_focus_mode(); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_focus_mode() { +#[wasm_error] +pub extern "C" fn set_focus_mode() -> Result<()> { let bytes = mem::bytes(); let entries: Vec = bytes @@ -325,83 +368,111 @@ pub extern "C" fn set_focus_mode() { with_state_mut!(state, { state.set_focus_mode(entries); }); + Ok(()) } #[no_mangle] -pub extern "C" fn init_shapes_pool(capacity: usize) { +#[wasm_error] +pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> { with_state_mut!(state, { state.init_shapes_pool(capacity); }); + Ok(()) } #[no_mangle] -pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) { +#[wasm_error] +pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { with_state_mut!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.use_shape(id); }); + Ok(()) } #[no_mangle] -pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) { +#[wasm_error] +pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { with_state_mut!(state, { let shape_id = uuid_from_u32_quartet(a, b, c, d); state.touch_shape(shape_id); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) { +#[wasm_error] +pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) -> Result<()> { with_state_mut!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.set_parent_for_current_shape(id); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_masked_group(masked: bool) { +#[wasm_error] +pub extern "C" fn set_shape_masked_group(masked: bool) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_masked(masked); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_selrect(left: f32, top: f32, right: f32, bottom: f32) { +#[wasm_error] +pub extern "C" fn set_shape_selrect(left: f32, top: f32, right: f32, bottom: f32) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_selrect(left, top, right, bottom); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_clip_content(clip_content: bool) { +#[wasm_error] +pub extern "C" fn set_shape_clip_content(clip_content: bool) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_clip(clip_content); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_rotation(rotation: f32) { +#[wasm_error] +pub extern "C" fn set_shape_rotation(rotation: f32) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_rotation(rotation); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_transform(a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) { +#[wasm_error] +pub extern "C" fn set_shape_transform( + a: f32, + b: f32, + c: f32, + d: f32, + e: f32, + f: f32, +) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_transform(a, b, c, d, e, f); }); + Ok(()) } #[no_mangle] -pub extern "C" fn add_shape_child(a: u32, b: u32, c: u32, d: u32) { +#[wasm_error] +pub extern "C" fn add_shape_child(a: u32, b: u32, c: u32, d: u32) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { let id = uuid_from_u32_quartet(a, b, c, d); shape.add_child(id); }); + Ok(()) } -fn set_children_set(entries: Vec) { +fn set_children_set(entries: Vec) -> Result<()> { let mut deleted = Vec::new(); let mut parent_id = None; @@ -420,7 +491,9 @@ fn set_children_set(entries: Vec) { with_state_mut!(state, { let Some(parent_id) = parent_id else { - return; + return Err(Error::RecoverableError( + "set_children_set: Parent ID not found".to_string(), + )); }; for id in deleted { @@ -428,21 +501,27 @@ fn set_children_set(entries: Vec) { state.touch_shape(id); } }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_children_0() { +#[wasm_error] +pub extern "C" fn set_children_0() -> Result<()> { let entries = vec![]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] -pub extern "C" fn set_children_1(a1: u32, b1: u32, c1: u32, d1: u32) { +#[wasm_error] +pub extern "C" fn set_children_1(a1: u32, b1: u32, c1: u32, d1: u32) -> Result<()> { let entries = vec![uuid_from_u32_quartet(a1, b1, c1, d1)]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] +#[wasm_error] pub extern "C" fn set_children_2( a1: u32, b1: u32, @@ -452,15 +531,17 @@ pub extern "C" fn set_children_2( b2: u32, c2: u32, d2: u32, -) { +) -> Result<()> { let entries = vec![ uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a2, b2, c2, d2), ]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] +#[wasm_error] pub extern "C" fn set_children_3( a1: u32, b1: u32, @@ -474,16 +555,18 @@ pub extern "C" fn set_children_3( b3: u32, c3: u32, d3: u32, -) { +) -> Result<()> { let entries = vec![ uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a2, b2, c2, d2), uuid_from_u32_quartet(a3, b3, c3, d3), ]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] +#[wasm_error] pub extern "C" fn set_children_4( a1: u32, b1: u32, @@ -501,17 +584,19 @@ pub extern "C" fn set_children_4( b4: u32, c4: u32, d4: u32, -) { +) -> Result<()> { let entries = vec![ uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a2, b2, c2, d2), uuid_from_u32_quartet(a3, b3, c3, d3), uuid_from_u32_quartet(a4, b4, c4, d4), ]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] +#[wasm_error] pub extern "C" fn set_children_5( a1: u32, b1: u32, @@ -533,7 +618,7 @@ pub extern "C" fn set_children_5( b5: u32, c5: u32, d5: u32, -) { +) -> Result<()> { let entries = vec![ uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a2, b2, c2, d2), @@ -541,11 +626,13 @@ pub extern "C" fn set_children_5( uuid_from_u32_quartet(a4, b4, c4, d4), uuid_from_u32_quartet(a5, b5, c5, d5), ]; - set_children_set(entries); + set_children_set(entries)?; + Ok(()) } #[no_mangle] -pub extern "C" fn set_children() { +#[wasm_error] +pub extern "C" fn set_children() -> Result<()> { let bytes = mem::bytes_or_empty(); let entries: Vec = bytes @@ -553,58 +640,76 @@ pub extern "C" fn set_children() { .map(|data| Uuid::try_from(data).unwrap()) .collect(); - set_children_set(entries); + set_children_set(entries)?; if !bytes.is_empty() { - mem::free_bytes(); + mem::free_bytes()?; } + + Ok(()) } #[no_mangle] -pub extern "C" fn is_image_cached(a: u32, b: u32, c: u32, d: u32, is_thumbnail: bool) -> bool { +#[wasm_error] +pub extern "C" fn is_image_cached( + a: u32, + b: u32, + c: u32, + d: u32, + is_thumbnail: bool, +) -> Result { with_state_mut!(state, { let id = uuid_from_u32_quartet(a, b, c, d); - state.render_state().has_image(&id, is_thumbnail) + let result = state.render_state().has_image(&id, is_thumbnail); + Ok(result) }) } #[no_mangle] -pub extern "C" fn set_shape_svg_raw_content() { +#[wasm_error] +pub extern "C" fn set_shape_svg_raw_content() -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { let bytes = mem::bytes(); let svg_raw_content = String::from_utf8(bytes) - .unwrap() + .map_err(|e| Error::RecoverableError(e.to_string()))? .trim_end_matches('\0') .to_string(); - shape - .set_svg_raw_content(svg_raw_content) - .expect("Failed to set svg raw content"); + shape.set_svg_raw_content(svg_raw_content); }); + + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_opacity(opacity: f32) { +#[wasm_error] +pub extern "C" fn set_shape_opacity(opacity: f32) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_opacity(opacity); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_hidden(hidden: bool) { +#[wasm_error] +pub extern "C" fn set_shape_hidden(hidden: bool) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_hidden(hidden); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_shape_corners(r1: f32, r2: f32, r3: f32, r4: f32) { +#[wasm_error] +pub extern "C" fn set_shape_corners(r1: f32, r2: f32, r3: f32, r4: f32) -> Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { shape.set_corners((r1, r2, r3, r4)); }); + Ok(()) } #[no_mangle] -pub extern "C" fn get_selection_rect() -> *mut u8 { +#[wasm_error] +pub extern "C" fn get_selection_rect() -> Result<*mut u8> { let bytes = mem::bytes(); let entries: Vec = bytes @@ -619,40 +724,41 @@ pub extern "C" fn get_selection_rect() -> *mut u8 { }) .collect(); - with_state_mut!(state, { + let result_bound = with_state_mut!(state, { let bbs: Vec<_> = entries .iter() .flat_map(|id| state.shapes.get(id).map(|b| b.bounds())) .collect(); - let result_bound = if bbs.len() == 1 { + if bbs.len() == 1 { bbs[0] } else { Bounds::join_bounds(&bbs) - }; + } + }); - let width = result_bound.width(); - let height = result_bound.height(); - let center = result_bound.center(); - let transform = result_bound.transform_matrix().unwrap_or(Matrix::default()); + let width = result_bound.width(); + let height = result_bound.height(); + let center = result_bound.center(); + let transform = result_bound.transform_matrix().unwrap_or(Matrix::default()); - let mut bytes = vec![0; 40]; - bytes[0..4].clone_from_slice(&width.to_le_bytes()); - bytes[4..8].clone_from_slice(&height.to_le_bytes()); - bytes[8..12].clone_from_slice(¢er.x.to_le_bytes()); - bytes[12..16].clone_from_slice(¢er.y.to_le_bytes()); - bytes[16..20].clone_from_slice(&transform[0].to_le_bytes()); - bytes[20..24].clone_from_slice(&transform[3].to_le_bytes()); - bytes[24..28].clone_from_slice(&transform[1].to_le_bytes()); - bytes[28..32].clone_from_slice(&transform[4].to_le_bytes()); - bytes[32..36].clone_from_slice(&transform[2].to_le_bytes()); - bytes[36..40].clone_from_slice(&transform[5].to_le_bytes()); - mem::write_bytes(bytes) - }) + let mut bytes = vec![0; 40]; + bytes[0..4].clone_from_slice(&width.to_le_bytes()); + bytes[4..8].clone_from_slice(&height.to_le_bytes()); + bytes[8..12].clone_from_slice(¢er.x.to_le_bytes()); + bytes[12..16].clone_from_slice(¢er.y.to_le_bytes()); + bytes[16..20].clone_from_slice(&transform[0].to_le_bytes()); + bytes[20..24].clone_from_slice(&transform[3].to_le_bytes()); + bytes[24..28].clone_from_slice(&transform[1].to_le_bytes()); + bytes[28..32].clone_from_slice(&transform[4].to_le_bytes()); + bytes[32..36].clone_from_slice(&transform[2].to_le_bytes()); + bytes[36..40].clone_from_slice(&transform[5].to_le_bytes()); + Ok(mem::write_bytes(bytes)) } #[no_mangle] -pub extern "C" fn set_structure_modifiers() { +#[wasm_error] +pub extern "C" fn set_structure_modifiers() -> Result<()> { let bytes = mem::bytes(); let entries: Vec<_> = bytes @@ -690,18 +796,22 @@ pub extern "C" fn set_structure_modifiers() { } }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] -pub extern "C" fn clean_modifiers() { +#[wasm_error] +pub extern "C" fn clean_modifiers() -> Result<()> { with_state_mut!(state, { state.shapes.clean_all(); }); + Ok(()) } #[no_mangle] -pub extern "C" fn set_modifiers() { +#[wasm_error] +pub extern "C" fn set_modifiers() -> Result<()> { let bytes = mem::bytes(); let entries: Vec<_> = bytes @@ -720,26 +830,31 @@ pub extern "C" fn set_modifiers() { state.set_modifiers(modifiers); state.rebuild_modifier_tiles(ids); }); + Ok(()) } #[no_mangle] -pub extern "C" fn start_temp_objects() { +#[wasm_error] +pub extern "C" fn start_temp_objects() -> Result<()> { unsafe { #[allow(static_mut_refs)] let mut state = STATE.take().expect("Got an invalid state pointer"); state = Box::new(state.start_temp_objects()); STATE = Some(state); } + Ok(()) } #[no_mangle] -pub extern "C" fn end_temp_objects() { +#[wasm_error] +pub extern "C" fn end_temp_objects() -> Result<()> { unsafe { #[allow(static_mut_refs)] let mut state = STATE.take().expect("Got an invalid state pointer"); state = Box::new(state.end_temp_objects()); STATE = Some(state); } + Ok(()) } fn main() { diff --git a/render-wasm/src/mem.rs b/render-wasm/src/mem.rs index 54cf4aa9d1..d03cb4fdc1 100644 --- a/render-wasm/src/mem.rs +++ b/render-wasm/src/mem.rs @@ -1,29 +1,29 @@ -use std::alloc::{alloc, Layout}; -use std::ptr; use std::sync::Mutex; -const LAYOUT_ALIGN: usize = 4; +use crate::error::{Error, Result, CRITICAL_ERROR}; -static BUFFERU8: Mutex>> = Mutex::new(None); +pub const LAYOUT_ALIGN: usize = 4; + +pub static BUFFERU8: Mutex>> = Mutex::new(None); +pub static BUFFER_ERROR: Mutex = Mutex::new(0x00); + +pub fn clear_error_code() { + let mut guard = BUFFER_ERROR.lock().unwrap(); + *guard = 0x00; +} + +/// Sets the error buffer from a byte. Used by #[wasm_error] when E: Into. +pub fn set_error_code(code: u8) { + let mut guard = BUFFER_ERROR.lock().unwrap(); + *guard = code; +} #[no_mangle] -pub extern "C" fn alloc_bytes(len: usize) -> *mut u8 { - let mut guard = BUFFERU8.lock().unwrap(); - - if guard.is_some() { - panic!("Bytes already allocated"); - } - - unsafe { - let layout = Layout::from_size_align_unchecked(len, LAYOUT_ALIGN); - let ptr = alloc(layout); - if ptr.is_null() { - panic!("Allocation failed"); - } - // TODO: Maybe this could be removed. - ptr::write_bytes(ptr, 0, len); - *guard = Some(Vec::from_raw_parts(ptr, len, len)); - ptr +pub extern "C" fn read_error_code() -> u8 { + if let Ok(guard) = BUFFER_ERROR.lock() { + *guard + } else { + CRITICAL_ERROR } } @@ -40,13 +40,6 @@ pub fn write_bytes(mut bytes: Vec) -> *mut u8 { ptr } -#[no_mangle] -pub extern "C" fn free_bytes() { - let mut guard = BUFFERU8.lock().unwrap(); - *guard = None; - std::mem::drop(guard); -} - pub fn bytes() -> Vec { let mut guard = BUFFERU8.lock().unwrap(); guard.take().expect("Buffer is not initialized") @@ -57,6 +50,15 @@ pub fn bytes_or_empty() -> Vec { guard.take().unwrap_or_default() } +pub fn free_bytes() -> Result<()> { + let mut guard = BUFFERU8 + .lock() + .map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?; + *guard = None; + std::mem::drop(guard); + Ok(()) +} + pub trait SerializableResult: From + Into { type BytesType; fn clone_to_slice(&self, slice: &mut [u8]); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1493b9851e..ce5781ca63 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -53,6 +53,8 @@ pub struct NodeRenderState { visited_mask: bool, // This bool indicates that we're drawing the mask shape. mask: bool, + // True when this container was flattened (enter/exit skipped). + flattened: bool, } /// Get simplified children of a container, flattening nested flattened containers @@ -745,16 +747,17 @@ impl RenderState { s.canvas().concat(transform); }); + // Hard clip edge (antialias = false) to avoid alpha seam when clipping + // semi-transparent content larger than the frame. if let Some(corners) = corners { let rrect = RRect::new_rect_radii(*bounds, corners); self.surfaces.apply_mut(surface_ids, |s| { - s.canvas() - .clip_rrect(rrect, skia::ClipOp::Intersect, antialias); + s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false); }); } else { self.surfaces.apply_mut(surface_ids, |s| { s.canvas() - .clip_rect(*bounds, skia::ClipOp::Intersect, antialias); + .clip_rect(*bounds, skia::ClipOp::Intersect, false); }); } @@ -865,7 +868,7 @@ impl RenderState { let text_stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); - let mut stroke_paragraphs_list = shape + let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape .visible_strokes() .rev() .map(|stroke| { @@ -877,7 +880,7 @@ impl RenderState { None, ) }) - .collect::>(); + .unzip(); if fast_mode { // Fast path: render fills and strokes only (skip shadows/blur). text::render( @@ -889,9 +892,13 @@ impl RenderState { None, None, text_fill_inset, + None, ); - for stroke_paragraphs in stroke_paragraphs_list.iter_mut() { + for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list + .iter_mut() + .zip(stroke_opacities.iter()) + { text::render_with_bounds_outset( Some(self), None, @@ -902,6 +909,7 @@ impl RenderState { None, text_stroke_blur_outset, None, + *layer_opacity, ); } } else { @@ -915,7 +923,10 @@ impl RenderState { let blur_filter = shape.image_filter(1.); let mut paragraphs_with_shadows = text_content.paragraph_builder_group_from_text(Some(true)); - let mut stroke_paragraphs_with_shadows_list = shape + let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( + Vec<_>, + Vec<_>, + ) = shape .visible_strokes() .rev() .map(|stroke| { @@ -927,7 +938,7 @@ impl RenderState { Some(true), ) }) - .collect::>(); + .unzip(); if let Some(parent_shadows) = parent_shadows { if !shape.has_visible_strokes() { @@ -941,6 +952,7 @@ impl RenderState { Some(&shadow), blur_filter.as_ref(), None, + None, ); } } else { @@ -967,6 +979,7 @@ impl RenderState { Some(shadow), blur_filter.as_ref(), None, + None, ); } } @@ -981,6 +994,7 @@ impl RenderState { None, blur_filter.as_ref(), text_fill_inset, + None, ); // 3. Stroke drop shadows @@ -995,7 +1009,10 @@ impl RenderState { ); // 4. Stroke fills - for stroke_paragraphs in stroke_paragraphs_list.iter_mut() { + for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list + .iter_mut() + .zip(stroke_opacities.iter()) + { text::render_with_bounds_outset( Some(self), None, @@ -1006,6 +1023,7 @@ impl RenderState { blur_filter.as_ref(), text_stroke_blur_outset, None, + *layer_opacity, ); } @@ -1032,6 +1050,7 @@ impl RenderState { Some(shadow), blur_filter.as_ref(), None, + None, ); } } @@ -1462,6 +1481,7 @@ impl RenderState { clip_bounds: None, visited_mask: true, mask: false, + flattened: false, }); if let Some(&mask_id) = element.mask_id() { self.pending_nodes.push(NodeRenderState { @@ -1470,6 +1490,7 @@ impl RenderState { clip_bounds: None, visited_mask: false, mask: true, + flattened: false, }); } } @@ -1999,8 +2020,7 @@ impl RenderState { } if visited_children { - // Skip render_shape_exit for flattened containers - if !element.can_flatten() { + if !node_render_state.flattened { self.render_shape_exit(element, visited_mask, clip_bounds); } continue; @@ -2149,6 +2169,7 @@ impl RenderState { clip_bounds: clip_bounds.clone(), visited_mask: false, mask, + flattened: can_flatten, }); if element.is_recursive() { @@ -2175,13 +2196,13 @@ impl RenderState { ids.reverse(); } // Sort by z_index descending (higher z renders on top). - // When z_index is equal, absolute children go behind - // non-absolute children (false < true). + // When z_index is equal, absolute children go above + // non-absolute children ids.sort_by_key(|id| { let s = tree.get(id); let z = s.map(|s| s.z_index()).unwrap_or(0); let abs = s.map(|s| s.is_absolute()).unwrap_or(false); - (std::cmp::Reverse(z), abs) + (std::cmp::Reverse(z), !abs) }); ids } else { @@ -2195,6 +2216,7 @@ impl RenderState { clip_bounds: children_clip_bounds.clone(), visited_mask: false, mask: false, + flattened: false, }); } } @@ -2309,6 +2331,7 @@ impl RenderState { clip_bounds: None, visited_mask: false, mask: false, + flattened: false, } })); } diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index b5077f0688..ea43322b70 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -155,6 +155,7 @@ pub fn render_text_shadows( None, blur_filter.as_ref(), None, + None, ); for stroke_paragraphs in stroke_paragraphs_group.iter_mut() { @@ -167,6 +168,7 @@ pub fn render_text_shadows( None, blur_filter.as_ref(), None, + None, ); } diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 8028316e03..319b921ac5 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -41,8 +41,8 @@ fn draw_stroke_on_rect( } }; - // By default just draw the rect. Only dotted inner/outer strokes need - // clipping to prevent the dotted pattern from appearing in wrong areas. + // Dotted inner/outer strokes need clipping to prevent the dotted + // pattern from appearing in wrong areas. if let Some(clip_op) = stroke.clip_op() { // Use a neutral layer (no extra paint) so opacity and filters // come solely from the stroke paint. This avoids applying @@ -60,6 +60,35 @@ fn draw_stroke_on_rect( } draw_stroke(); canvas.restore(); + } else if stroke.kind == StrokeKind::Inner + && (stroke.width >= rect.width() || stroke.width >= rect.height()) + { + // When the inner stroke width exceeds a shape dimension, the inset + // rect goes negative and the stroke overflows outside the shape. + // Fall back to the same approach as the SVG renderer: draw with + // doubled width centered on the original shape and clip to it. + canvas.save(); + match corners { + Some(radii) => { + let rrect = RRect::new_rect_radii(*rect, radii); + canvas.clip_rrect(rrect, skia::ClipOp::Intersect, antialias); + } + None => { + canvas.clip_rect(*rect, skia::ClipOp::Intersect, antialias); + } + } + let mut inner_paint = paint.clone(); + inner_paint.set_stroke_width(stroke.width * 2.0); + match corners { + Some(radii) => { + let rrect = RRect::new_rect_radii(*rect, radii); + canvas.draw_rrect(rrect, &inner_paint); + } + None => { + canvas.draw_rect(*rect, &inner_paint); + } + } + canvas.restore(); } else { draw_stroke(); } @@ -83,8 +112,8 @@ fn draw_stroke_on_circle( let filter = compose_filters(blur, shadow); paint.set_image_filter(filter); - // By default just draw the circle. Only dotted inner/outer strokes need - // clipping to prevent the dotted pattern from appearing in wrong areas. + // Dotted inner/outer strokes need clipping to prevent the dotted + // pattern from appearing in wrong areas. if let Some(clip_op) = stroke.clip_op() { // Use a neutral layer (no extra paint) so opacity and filters // come solely from the stroke paint. This avoids applying @@ -99,6 +128,24 @@ fn draw_stroke_on_circle( canvas.clip_path(&clip_path, clip_op, antialias); canvas.draw_oval(stroke_rect, &paint); canvas.restore(); + } else if stroke.kind == StrokeKind::Inner + && (stroke.width >= rect.width() || stroke.width >= rect.height()) + { + // When the inner stroke width exceeds a shape dimension, the inset + // rect goes negative and the stroke overflows outside the shape. + // Fall back to the same approach as the SVG renderer: draw with + // doubled width centered on the original shape and clip to it. + canvas.save(); + let clip_path = { + let mut pb = skia::PathBuilder::new(); + pb.add_oval(rect, None, None); + pb.detach() + }; + canvas.clip_path(&clip_path, skia::ClipOp::Intersect, antialias); + let mut inner_paint = paint.clone(); + inner_paint.set_stroke_width(stroke.width * 2.0); + canvas.draw_oval(*rect, &inner_paint); + canvas.restore(); } else { canvas.draw_oval(stroke_rect, &paint); } @@ -164,16 +211,25 @@ fn draw_stroke_on_path( blur: Option<&ImageFilter>, antialias: bool, ) { - let skia_path = path - .to_skia_path() - .make_transform(path_transform.unwrap_or(&Matrix::default())); - let is_open = path.is_open(); let mut draw_paint = paint.clone(); let filter = compose_filters(blur, shadow); draw_paint.set_image_filter(filter); + // Move path_transform from the path geometry to the canvas so the + // stroke width is not distorted by non-uniform shape scaling. + // The path coordinates are already in world space, so we draw the + // raw path on a canvas where the shape transform has been undone: + // canvas * path_transform = View × parents (no shape scale/rotation) + // This matches the SVG renderer, which bakes the transform into path + // coordinates and never sets a transform attribute on the element. + let save_count = canvas.save(); + if let Some(pt) = path_transform { + canvas.concat(pt); + } + let skia_path = path.to_skia_path(); + match stroke.render_kind(is_open) { StrokeKind::Inner => { draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias); @@ -187,6 +243,8 @@ fn draw_stroke_on_path( } handle_stroke_caps(&skia_path, stroke, canvas, is_open, paint, blur, antialias); + + canvas.restore_to_count(save_count); } fn handle_stroke_cap( diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 42c35b450f..832503505d 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -20,11 +20,12 @@ pub fn stroke_paragraph_builder_group_from_text( bounds: &Rect, count_inner_strokes: usize, use_shadow: Option, -) -> Vec { +) -> (Vec, Option) { let fallback_fonts = get_fallback_fonts(); let fonts = get_font_collection(); let mut paragraph_group = Vec::new(); let remove_stroke_alpha = use_shadow.unwrap_or(false) && !stroke.is_transparent(); + let mut group_layer_opacity: Option = None; for paragraph in text_content.paragraphs() { let mut stroke_paragraphs_map: std::collections::HashMap = @@ -32,7 +33,7 @@ pub fn stroke_paragraph_builder_group_from_text( for span in paragraph.children().iter() { let text_paint: skia_safe::Handle<_> = merge_fills(span.fills(), *bounds); - let stroke_paints = get_text_stroke_paints( + let (stroke_paints, stroke_layer_opacity) = get_text_stroke_paints( stroke, bounds, &text_paint, @@ -40,6 +41,10 @@ pub fn stroke_paragraph_builder_group_from_text( remove_stroke_alpha, ); + if group_layer_opacity.is_none() { + group_layer_opacity = stroke_layer_opacity; + } + let text: String = span.apply_text_transform(); for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() { @@ -67,7 +72,7 @@ pub fn stroke_paragraph_builder_group_from_text( paragraph_group.push(stroke_paragraphs); } - paragraph_group + (paragraph_group, group_layer_opacity) } fn get_text_stroke_paints( @@ -76,8 +81,25 @@ fn get_text_stroke_paints( text_paint: &Paint, count_inner_strokes: usize, remove_stroke_alpha: bool, -) -> Vec { +) -> (Vec, Option) { let mut paints = Vec::new(); + let mut layer_opacity: Option = None; + + let stroke_opacity = stroke.fill.opacity(); + let needs_opacity_layer = stroke_opacity < 1.0 && !remove_stroke_alpha; + + let fill_for_paint = |paint: &mut Paint| { + if needs_opacity_layer { + let opaque_fill = stroke.fill.with_full_opacity(); + set_paint_fill(paint, &opaque_fill, bounds, remove_stroke_alpha); + } else { + set_paint_fill(paint, &stroke.fill, bounds, remove_stroke_alpha); + } + }; + + if needs_opacity_layer { + layer_opacity = Some(stroke_opacity); + } match stroke.kind { StrokeKind::Inner => { @@ -99,7 +121,7 @@ fn get_text_stroke_paints( paint.set_blend_mode(skia::BlendMode::SrcIn); paint.set_anti_alias(true); paint.set_stroke_width(stroke.width * 2.0); - set_paint_fill(&mut paint, &stroke.fill, bounds, remove_stroke_alpha); + fill_for_paint(&mut paint); paints.push(paint); } else { let mut paint = skia::Paint::default(); @@ -108,7 +130,12 @@ fn get_text_stroke_paints( paint.set_alpha(255); } else { paint = text_paint.clone(); - set_paint_fill(&mut paint, &stroke.fill, bounds, false); + if needs_opacity_layer { + let opaque_fill = stroke.fill.with_full_opacity(); + set_paint_fill(&mut paint, &opaque_fill, bounds, false); + } else { + set_paint_fill(&mut paint, &stroke.fill, bounds, false); + } } paint.set_style(skia::PaintStyle::Fill); @@ -132,7 +159,7 @@ fn get_text_stroke_paints( paint.set_style(skia::PaintStyle::Stroke); paint.set_anti_alias(true); paint.set_stroke_width(stroke.width); - set_paint_fill(&mut paint, &stroke.fill, bounds, remove_stroke_alpha); + fill_for_paint(&mut paint); paints.push(paint); } StrokeKind::Outer => { @@ -141,7 +168,7 @@ fn get_text_stroke_paints( paint.set_blend_mode(skia::BlendMode::DstOver); paint.set_anti_alias(true); paint.set_stroke_width(stroke.width * 2.0); - set_paint_fill(&mut paint, &stroke.fill, bounds, remove_stroke_alpha); + fill_for_paint(&mut paint); paints.push(paint); let mut paint = skia::Paint::default(); @@ -153,7 +180,7 @@ fn get_text_stroke_paints( } } - paints + (paints, layer_opacity) } #[allow(clippy::too_many_arguments)] @@ -167,6 +194,7 @@ pub fn render_with_bounds_outset( blur: Option<&ImageFilter>, stroke_bounds_outset: f32, fill_inset: Option, + layer_opacity: Option, ) { if let Some(render_state) = render_state { let target_surface = surface_id.unwrap_or(SurfaceId::Fills); @@ -195,6 +223,7 @@ pub fn render_with_bounds_outset( shadow, Some(&blur_filter_clone), fill_inset, + layer_opacity, ); }, ) { @@ -204,12 +233,28 @@ pub fn render_with_bounds_outset( } let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); - render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset); + render_text_on_canvas( + canvas, + shape, + paragraph_builders, + shadow, + blur, + fill_inset, + layer_opacity, + ); return; } if let Some(canvas) = canvas { - render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset); + render_text_on_canvas( + canvas, + shape, + paragraph_builders, + shadow, + blur, + fill_inset, + layer_opacity, + ); } } @@ -223,6 +268,7 @@ pub fn render( shadow: Option<&Paint>, blur: Option<&ImageFilter>, fill_inset: Option, + layer_opacity: Option, ) { render_with_bounds_outset( render_state, @@ -234,6 +280,7 @@ pub fn render( blur, 0.0, fill_inset, + layer_opacity, ); } @@ -244,6 +291,7 @@ fn render_text_on_canvas( shadow: Option<&Paint>, blur: Option<&ImageFilter>, fill_inset: Option, + layer_opacity: Option, ) { if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); @@ -255,7 +303,7 @@ fn render_text_on_canvas( if let Some(shadow_paint) = shadow { let layer_rec = SaveLayerRec::default().paint(shadow_paint); canvas.save_layer(&layer_rec); - draw_text(canvas, shape, paragraph_builders); + draw_text(canvas, shape, paragraph_builders, layer_opacity); canvas.restore(); } else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) { if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) { @@ -263,13 +311,13 @@ fn render_text_on_canvas( layer_paint.set_image_filter(erode); let layer_rec = SaveLayerRec::default().paint(&layer_paint); canvas.save_layer(&layer_rec); - draw_text(canvas, shape, paragraph_builders); + draw_text(canvas, shape, paragraph_builders, layer_opacity); canvas.restore(); } else { - draw_text(canvas, shape, paragraph_builders); + draw_text(canvas, shape, paragraph_builders, layer_opacity); } } else { - draw_text(canvas, shape, paragraph_builders); + draw_text(canvas, shape, paragraph_builders, layer_opacity); } if blur.is_some() { @@ -283,13 +331,20 @@ fn draw_text( canvas: &Canvas, shape: &Shape, paragraph_builder_groups: &mut [Vec], + layer_opacity: Option, ) { let text_content = shape.get_text_content(); let layout_info = calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true); - let layer_rec = SaveLayerRec::default(); - canvas.save_layer(&layer_rec); + if let Some(opacity) = layer_opacity { + let mut opacity_paint = Paint::default(); + opacity_paint.set_alpha_f(opacity); + let layer_rec = SaveLayerRec::default().paint(&opacity_paint); + canvas.save_layer(&layer_rec); + } else { + canvas.save_layer(&SaveLayerRec::default()); + } for para in &layout_info.paragraphs { para.paragraph.paint(canvas, (para.x, para.y)); diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 2cf7eeab0c..9c1cdaf526 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -66,7 +66,7 @@ fn render_selection( } let mut paint = Paint::default(); - paint.set_blend_mode(BlendMode::Multiply); + paint.set_blend_mode(BlendMode::default()); paint.set_color(editor_state.theme.selection_color); paint.set_anti_alias(true); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 8e7e1e7c99..ef12164896 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -705,9 +705,8 @@ impl Shape { self.invalidate_extrect(); } - pub fn set_svg_raw_content(&mut self, content: String) -> Result<(), String> { + pub fn set_svg_raw_content(&mut self, content: String) { self.shape_type = Type::SVGRaw(SVGRaw::from_content(content)); - Ok(()) } pub fn set_blend_mode(&mut self, mode: BlendMode) { diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index cf8a930894..5b61b3ee2a 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -140,6 +140,38 @@ pub enum Fill { } impl Fill { + pub fn opacity(&self) -> f32 { + match self { + Fill::Solid(SolidColor(color)) => color.a() as f32 / 255.0, + Fill::LinearGradient(g) => g.opacity as f32 / 255.0, + Fill::RadialGradient(g) => g.opacity as f32 / 255.0, + Fill::Image(i) => i.opacity as f32 / 255.0, + } + } + + pub fn with_full_opacity(&self) -> Fill { + match self { + Fill::Solid(SolidColor(color)) => Fill::Solid(SolidColor(skia::Color::from_argb( + 255, + color.r(), + color.g(), + color.b(), + ))), + Fill::LinearGradient(g) => Fill::LinearGradient(Gradient { + opacity: 255, + ..g.clone() + }), + Fill::RadialGradient(g) => Fill::RadialGradient(Gradient { + opacity: 255, + ..g.clone() + }), + Fill::Image(i) => Fill::Image(ImageFill { + opacity: 255, + ..i.clone() + }), + } + } + pub fn to_paint(&self, rect: &Rect, anti_alias: bool) -> skia::Paint { match self { Self::Solid(SolidColor(color)) => { diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index b18a7b4aab..298b44cc55 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -161,6 +161,13 @@ impl TextPositionWithAffinity { offset, } } + + pub fn reset(&mut self) { + self.position_with_affinity.position = 0; + self.position_with_affinity.affinity = Affinity::Downstream; + self.paragraph = 0; + self.offset = 0; + } } #[derive(Debug)] @@ -569,6 +576,7 @@ impl TextContent { for paragraph in self.paragraphs() { let paragraph_style = paragraph.paragraph_to_style(); let mut builder = ParagraphBuilder::new(¶graph_style, fonts); + let mut has_text = false; for span in paragraph.children() { let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent(); let text_style = span.to_style( @@ -578,9 +586,15 @@ impl TextContent { paragraph.line_height(), ); let text: String = span.apply_text_transform(); + if !text.is_empty() { + has_text = true; + } builder.push_style(&text_style); builder.add_text(&text); } + if !has_text { + builder.add_text(" "); + } paragraph_group.push(vec![builder]); } diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index dc0f4152d4..2766b476c6 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -33,6 +33,11 @@ impl TextSelection { !self.is_collapsed() } + pub fn reset(&mut self) { + self.anchor.reset(); + self.focus.reset(); + } + pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) { self.anchor = cursor; self.focus = cursor; @@ -86,7 +91,7 @@ pub enum TextEditorEvent { } /// FIXME: It should be better to get these constants from the frontend through the API. -const SELECTION_COLOR: Color = Color::from_argb(255, 0, 209, 184); +const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184); const CURSOR_WIDTH: f32 = 1.5; const CURSOR_COLOR: Color = Color::BLACK; const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0; @@ -133,7 +138,7 @@ impl TextEditorState { self.active_shape_id = Some(shape_id); self.cursor_visible = true; self.last_blink_time = 0.0; - self.selection = TextSelection::new(); + self.selection.reset(); self.is_pointer_selection_active = false; self.pending_events.clear(); } @@ -142,9 +147,10 @@ impl TextEditorState { self.is_active = false; self.active_shape_id = None; self.cursor_visible = false; + self.last_blink_time = 0.0; + self.selection.reset(); self.is_pointer_selection_active = false; self.pending_events.clear(); - self.reset_blink(); } pub fn start_pointer_selection(&mut self) -> bool { @@ -193,15 +199,83 @@ impl TextEditorState { true } + pub fn select_word_boundary( + &mut self, + content: &TextContent, + position: &TextPositionWithAffinity, + ) { + self.is_pointer_selection_active = false; + + let paragraphs = content.paragraphs(); + if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { + return; + } + + let paragraph = ¶graphs[position.paragraph]; + let paragraph_text: String = paragraph + .children() + .iter() + .map(|span| span.text.as_str()) + .collect(); + + let chars: Vec = paragraph_text.chars().collect(); + if chars.is_empty() { + self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + 0, + )); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + return; + } + + let mut offset = position.offset.min(chars.len()); + + if offset == chars.len() { + offset = offset.saturating_sub(1); + } else if !is_word_char(chars[offset]) && offset > 0 && is_word_char(chars[offset - 1]) { + offset -= 1; + } + + if !is_word_char(chars[offset]) { + self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + position.offset.min(chars.len()), + )); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + return; + } + + let mut start = offset; + while start > 0 && is_word_char(chars[start - 1]) { + start -= 1; + } + + let mut end = offset + 1; + while end < chars.len() && is_word_char(chars[end]) { + end += 1; + } + + self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + start, + )); + self.extend_selection_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + end, + )); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + } + pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.set_caret(*position); - self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.extend_to(*position); - self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } @@ -242,3 +316,7 @@ impl TextEditorState { !self.pending_events.is_empty() } } + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} diff --git a/render-wasm/src/wasm.rs b/render-wasm/src/wasm.rs index 47578e5b9c..c33b614cd3 100644 --- a/render-wasm/src/wasm.rs +++ b/render-wasm/src/wasm.rs @@ -3,6 +3,7 @@ pub mod blurs; pub mod fills; pub mod fonts; pub mod layouts; +pub mod mem; pub mod paths; pub mod shadows; pub mod shapes; diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index 86f0e57e8e..8513856455 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -1,4 +1,4 @@ -use macros::ToJs; +use macros::{wasm_error, ToJs}; use crate::mem; use crate::shapes; @@ -67,7 +67,8 @@ pub fn parse_fills_from_bytes(buffer: &[u8], num_fills: usize) -> Vec Result<()> { with_current_shape_mut!(state, |shape: &mut Shape| { let bytes = mem::bytes(); // The first byte contains the actual number of fills @@ -75,8 +76,9 @@ pub extern "C" fn set_shape_fills() { // Skip the first 4 bytes (header with fill count) and parse only the actual fills let fills = parse_fills_from_bytes(&bytes[4..], num_fills); shape.set_fills(fills); - mem::free_bytes(); + mem::free_bytes()?; }); + Ok(()) } #[no_mangle] diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index 9c7a5d312b..f0e5b36526 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -1,5 +1,7 @@ use crate::mem; +use macros::wasm_error; // use crate::mem::SerializableResult; +use crate::error::Error; use crate::uuid::Uuid; use crate::with_state_mut; use crate::STATE; @@ -65,7 +67,8 @@ impl TryFrom> for ShapeImageIds { } #[no_mangle] -pub extern "C" fn store_image() { +#[wasm_error] +pub extern "C" fn store_image() -> crate::error::Result<()> { let bytes = mem::bytes(); let ids = ShapeImageIds::try_from(bytes[0..IMAGE_IDS_SIZE].to_vec()).unwrap(); @@ -87,7 +90,8 @@ pub extern "C" fn store_image() { state.touch_shape(ids.shape_id); }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } /// Stores an image from an existing WebGL texture, avoiding re-decoding @@ -99,13 +103,17 @@ pub extern "C" fn store_image() { /// - bytes 40-43: width (i32) /// - bytes 44-47: height (i32) #[no_mangle] -pub extern "C" fn store_image_from_texture() { +#[wasm_error] +pub extern "C" fn store_image_from_texture() -> crate::error::Result<()> { let bytes = mem::bytes(); if bytes.len() < 48 { + // FIXME: Review if this should be an critical or a recoverable error. eprintln!("store_image_from_texture: insufficient data"); - mem::free_bytes(); - return; + mem::free_bytes()?; + return Err(Error::RecoverableError( + "store_image_from_texture: insufficient data".to_string(), + )); } let ids = ShapeImageIds::try_from(bytes[0..IMAGE_IDS_SIZE].to_vec()).unwrap(); @@ -139,5 +147,6 @@ pub extern "C" fn store_image_from_texture() { state.touch_shape(ids.shape_id); }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index b4a604e0e5..f4723e20c3 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -1,4 +1,4 @@ -use macros::ToJs; +use macros::{wasm_error, ToJs}; use crate::mem; use crate::shapes::{FontFamily, FontStyle}; @@ -30,6 +30,7 @@ impl From for FontStyle { } #[no_mangle] +#[wasm_error] pub extern "C" fn store_font( a: u32, b: u32, @@ -39,7 +40,7 @@ pub extern "C" fn store_font( style: u8, is_emoji: bool, is_fallback: bool, -) { +) -> Result<()> { with_state_mut!(state, { let id = uuid_from_u32_quartet(a, b, c, d); let font_bytes = mem::bytes(); @@ -52,8 +53,9 @@ pub extern "C" fn store_font( .fonts_mut() .add(family, &font_bytes, is_emoji, is_fallback); - mem::free_bytes(); + mem::free_bytes()?; }); + Ok(()) } #[no_mangle] diff --git a/render-wasm/src/wasm/layouts/grid.rs b/render-wasm/src/wasm/layouts/grid.rs index 096468e514..d1a0476814 100644 --- a/render-wasm/src/wasm/layouts/grid.rs +++ b/render-wasm/src/wasm/layouts/grid.rs @@ -1,4 +1,4 @@ -use macros::ToJs; +use macros::{wasm_error, ToJs}; use crate::mem; use crate::shapes::{GridCell, GridDirection, GridTrack, GridTrackType}; @@ -7,6 +7,9 @@ use crate::{uuid_from_u32_quartet, with_current_shape_mut, with_state, with_stat use super::align; +#[allow(unused_imports)] +use crate::error::Result; + #[derive(Debug)] #[repr(C, align(1))] struct RawGridCell { @@ -168,7 +171,8 @@ pub extern "C" fn set_grid_layout_data( } #[no_mangle] -pub extern "C" fn set_grid_columns() { +#[wasm_error] +pub extern "C" fn set_grid_columns() -> Result<()> { let bytes = mem::bytes(); let entries: Vec = bytes @@ -181,11 +185,13 @@ pub extern "C" fn set_grid_columns() { shape.set_grid_columns(entries); }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] -pub extern "C" fn set_grid_rows() { +#[wasm_error] +pub extern "C" fn set_grid_rows() -> Result<()> { let bytes = mem::bytes(); let entries: Vec = bytes @@ -198,11 +204,13 @@ pub extern "C" fn set_grid_rows() { shape.set_grid_rows(entries); }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] -pub extern "C" fn set_grid_cells() { +#[wasm_error] +pub extern "C" fn set_grid_cells() -> Result<()> { let bytes = mem::bytes(); let cells: Vec = bytes @@ -215,7 +223,8 @@ pub extern "C" fn set_grid_cells() { shape.set_grid_cells(cells.into_iter().map(|raw| raw.into()).collect()); }); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] diff --git a/render-wasm/src/wasm/mem.rs b/render-wasm/src/wasm/mem.rs new file mode 100644 index 0000000000..8f6b7508ef --- /dev/null +++ b/render-wasm/src/wasm/mem.rs @@ -0,0 +1,38 @@ +use std::alloc::{alloc, Layout}; +use std::ptr; + +#[allow(unused_imports)] +use crate::error::{Error, Result}; +use crate::mem::{BUFFERU8, LAYOUT_ALIGN}; +use macros::wasm_error; + +#[no_mangle] +#[wasm_error] +pub extern "C" fn alloc_bytes(len: usize) -> Result<*mut u8> { + let mut guard = BUFFERU8 + .lock() + .map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?; + + if guard.is_some() { + return Err(Error::CriticalError("Bytes already allocated".to_string())); + } + + unsafe { + let layout = Layout::from_size_align_unchecked(len, LAYOUT_ALIGN); + let ptr = alloc(layout); + if ptr.is_null() { + return Err(Error::CriticalError("Allocation failed".to_string())); + } + // TODO: Maybe this could be removed. + ptr::write_bytes(ptr, 0, len); + *guard = Some(Vec::from_raw_parts(ptr, len, len)); + Ok(ptr) + } +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn free_bytes() -> Result<()> { + crate::mem::free_bytes()?; + Ok(()) +} diff --git a/render-wasm/src/wasm/paths.rs b/render-wasm/src/wasm/paths.rs index 815c9d2804..0748111533 100644 --- a/render-wasm/src/wasm/paths.rs +++ b/render-wasm/src/wasm/paths.rs @@ -1,5 +1,5 @@ #![allow(unused_mut, unused_variables)] -use macros::ToJs; +use macros::{wasm_error, ToJs}; use mem::SerializableResult; use std::mem::size_of; use std::sync::{Mutex, OnceLock}; @@ -161,12 +161,14 @@ pub extern "C" fn start_shape_path_buffer() { } #[no_mangle] -pub extern "C" fn set_shape_path_chunk_buffer() { +#[wasm_error] +pub extern "C" fn set_shape_path_chunk_buffer() -> Result<()> { let bytes = mem::bytes(); let buffer = get_path_upload_buffer(); let mut buffer = buffer.lock().unwrap(); buffer.extend_from_slice(&bytes); - mem::free_bytes(); + mem::free_bytes()?; + Ok(()) } #[no_mangle] diff --git a/render-wasm/src/wasm/paths/bools.rs b/render-wasm/src/wasm/paths/bools.rs index 36bd0e4440..c19791dc95 100644 --- a/render-wasm/src/wasm/paths/bools.rs +++ b/render-wasm/src/wasm/paths/bools.rs @@ -1,4 +1,4 @@ -use macros::ToJs; +use macros::{wasm_error, ToJs}; use super::RawSegmentData; use crate::math; @@ -8,6 +8,9 @@ use crate::{mem, SerializableResult}; use crate::{with_current_shape_mut, with_state, STATE}; use std::mem::size_of; +#[allow(unused_imports)] +use crate::error::{Error, Result}; + #[derive(Debug, Clone, Copy, PartialEq, ToJs)] #[repr(u8)] #[allow(dead_code)] @@ -43,15 +46,19 @@ pub extern "C" fn set_shape_bool_type(raw_bool_type: u8) { } #[no_mangle] -pub extern "C" fn calculate_bool(raw_bool_type: u8) -> *mut u8 { +#[wasm_error] +pub extern "C" fn calculate_bool(raw_bool_type: u8) -> Result<*mut u8> { let bytes = mem::bytes_or_empty(); let entries: Vec = bytes .chunks(size_of::<::BytesType>()) - .map(|data| Uuid::try_from(data).unwrap()) - .collect(); + .map(|data| { + // FIXME: Review if this should be an critical or a recoverable error. + Uuid::try_from(data).map_err(|_| Error::RecoverableError("Invalid UUID".to_string())) + }) + .collect::>>()?; - mem::free_bytes(); + mem::free_bytes()?; let bool_type = RawBoolType::from(raw_bool_type).into(); let result; @@ -64,5 +71,5 @@ pub extern "C" fn calculate_bool(raw_bool_type: u8) -> *mut u8 { .map(RawSegmentData::from_segment) .collect(); }); - mem::write_vec(result) + Ok(mem::write_vec(result)) } diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index ab4b14541e..737925b9b4 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -1,4 +1,4 @@ -use macros::ToJs; +use macros::{wasm_error, ToJs}; use super::{fills::RawFillData, fonts::RawFontStyle}; @@ -9,6 +9,8 @@ use crate::shapes::{ use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_mut, STATE}; +use crate::error::Error; + const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::(); const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::(); @@ -285,16 +287,22 @@ pub extern "C" fn clear_shape_text() { } #[no_mangle] -pub extern "C" fn set_shape_text_content() { +#[wasm_error] +pub extern "C" fn set_shape_text_content() -> crate::error::Result<()> { let bytes = mem::bytes(); with_current_shape_mut!(state, |shape: &mut Shape| { let raw_text_data = RawParagraph::try_from(&bytes).unwrap(); - if shape.add_paragraph(raw_text_data.into()).is_err() { - println!("Error with set_shape_text_content on {:?}", shape.id); - } + shape.add_paragraph(raw_text_data.into()).map_err(|_| { + Error::RecoverableError(format!( + "Error with set_shape_text_content on {:?}", + shape.id + )) + })?; }); - mem::free_bytes(); + + mem::free_bytes()?; + Ok(()) } #[no_mangle] diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index c7285345e5..0886340851 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1,3 +1,5 @@ +use macros::{wasm_error, ToJs}; + use crate::math::{Matrix, Point, Rect}; use crate::mem; use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; @@ -5,7 +7,7 @@ use crate::state::TextSelection; use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; use crate::{with_state, with_state_mut, STATE}; -use macros::ToJs; +use skia_safe::{textlayout::TextDirection, Color}; #[derive(PartialEq, ToJs)] #[repr(u8)] @@ -23,6 +25,21 @@ pub enum CursorDirection { // STATE MANAGEMENT // ============================================================================ +#[no_mangle] +pub extern "C" fn text_editor_apply_theme( + selection_color: u32, + cursor_width: f32, + cursor_color: u32, +) { + with_state_mut!(state, { + // NOTE: In the future could be interesting to fill al this data from + // a structure pointer. + state.text_editor_state.theme.selection_color = Color::new(selection_color); + state.text_editor_state.theme.cursor_width = cursor_width; + state.text_editor_state.theme.cursor_color = Color::new(cursor_color); + }) +} + #[no_mangle] pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool { with_state_mut!(state, { @@ -42,10 +59,14 @@ pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool { } #[no_mangle] -pub extern "C" fn text_editor_stop() { +pub extern "C" fn text_editor_stop() -> bool { with_state_mut!(state, { + if !state.text_editor_state.is_active { + return false; + } state.text_editor_state.stop(); - }); + true + }) } #[no_mangle] @@ -101,6 +122,34 @@ pub extern "C" fn text_editor_select_all() -> bool { }) } +#[no_mangle] +pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + + let point = Point::new(x, y); + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + state + .text_editor_state + .select_word_boundary(text_content, &position); + } + }) +} + #[no_mangle] pub extern "C" fn text_editor_poll_event() -> u8 { with_state_mut!(state, { state.text_editor_state.poll_event() as u8 }) @@ -126,12 +175,8 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { return; }; let point = Point::new(x, y); - let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); - let shape_matrix = shape.get_matrix(); state.text_editor_state.start_pointer_selection(); - if let Some(position) = - text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) - { + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state.text_editor_state.set_caret_from_position(&position); } }); @@ -143,7 +188,6 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { if !state.text_editor_state.is_active { return; } - let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); let point = Point::new(x, y); let Some(shape_id) = state.text_editor_state.active_shape_id else { return; @@ -151,11 +195,6 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { let Some(shape) = state.shapes.get(&shape_id) else { return; }; - let shape_matrix = shape.get_matrix(); - let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix) - else { - return; - }; if !state.text_editor_state.is_pointer_selection_active { return; } @@ -163,9 +202,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { return; }; - if let Some(position) = - text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) - { + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state .text_editor_state .extend_selection_from_position(&position); @@ -179,7 +216,6 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { if !state.text_editor_state.is_active { return; } - let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); let point = Point::new(x, y); let Some(shape_id) = state.text_editor_state.active_shape_id else { return; @@ -187,20 +223,13 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { let Some(shape) = state.shapes.get(&shape_id) else { return; }; - let shape_matrix = shape.get_matrix(); - let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix) - else { - return; - }; if !state.text_editor_state.is_pointer_selection_active { return; } let Type::Text(text_content) = &shape.shape_type else { return; }; - if let Some(position) = - text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) - { + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state .text_editor_state .extend_selection_from_position(&position); @@ -209,6 +238,29 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { }); } +#[no_mangle] +pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + + let point = Point::new(x, y); + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + state.text_editor_state.set_caret_from_position(&position); + } + }); +} + #[no_mangle] pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { with_state_mut!(state, { @@ -240,29 +292,31 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { // TEXT OPERATIONS // ============================================================================ +// FIXME: Review if all the return Ok(()) should be Err instead. #[no_mangle] -pub extern "C" fn text_editor_insert_text() { +#[wasm_error] +pub extern "C" fn text_editor_insert_text() -> Result<()> { let bytes = crate::mem::bytes(); let text = match String::from_utf8(bytes) { - Ok(s) => s, - Err(_) => return, + Ok(text) => text, + Err(_) => return Ok(()), }; with_state_mut!(state, { if !state.text_editor_state.is_active { - return; + return Ok(()); } let Some(shape_id) = state.text_editor_state.active_shape_id else { - return; + return Ok(()); }; let Some(shape) = state.shapes.get_mut(&shape_id) else { - return; + return Ok(()); }; let Type::Text(text_content) = &mut shape.shape_type else { - return; + return Ok(()); }; let selection = state.text_editor_state.selection; @@ -275,9 +329,7 @@ pub extern "C" fn text_editor_insert_text() { let cursor = state.text_editor_state.selection.focus; - if let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) { - let new_cursor = - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, new_offset); + if let Some(new_cursor) = insert_text_with_newlines(text_content, &cursor, &text) { state.text_editor_state.selection.set_caret(new_cursor); } @@ -295,7 +347,8 @@ pub extern "C" fn text_editor_insert_text() { state.render_state.mark_touched(shape_id); }); - crate::mem::free_bytes(); + crate::mem::free_bytes()?; + Ok(()) } #[no_mangle] @@ -474,7 +527,25 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel let current = state.text_editor_state.selection.focus; - let new_cursor = match direction { + // Get the text direction of the span at the current cursor position + let span_text_direction = if current.paragraph < paragraphs.len() { + get_span_text_direction_at_offset(¶graphs[current.paragraph], current.offset) + } else { + TextDirection::LTR + }; + + // For horizontal navigation, swap Backward/Forward when in RTL text + let adjusted_direction = if span_text_direction == TextDirection::RTL { + match direction { + CursorDirection::Backward => CursorDirection::Forward, + CursorDirection::Forward => CursorDirection::Backward, + other => other, + } + } else { + direction + }; + + let new_cursor = match adjusted_direction { CursorDirection::Backward => move_cursor_backward(¤t, paragraphs), CursorDirection::Forward => move_cursor_forward(¤t, paragraphs), CursorDirection::LineBefore => { @@ -744,12 +815,10 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 { char_pos += span_len; } } - if !para_text.is_empty() { - if !result.is_empty() { - result.push('\n'); - } - result.push_str(¶_text); + if para_idx > start.paragraph { + result.push('\n'); } + result.push_str(¶_text); } let mut bytes = result.into_bytes(); bytes.push(0); @@ -1046,6 +1115,59 @@ fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, u None } +/// Insert text at a cursor position, splitting on newlines into multiple paragraphs. +/// Returns the final cursor position after insertion. +fn insert_text_with_newlines( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, + text: &str, +) -> Option { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + let lines: Vec<&str> = normalized.split('\n').collect(); + if lines.is_empty() { + return None; + } + + let mut current_cursor = *cursor; + + if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, lines[0]) { + current_cursor = + TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset); + } else { + return None; + } + + for line in lines.iter().skip(1) { + if !split_paragraph_at_cursor(text_content, ¤t_cursor) { + break; + } + current_cursor = + TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); + if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, line) { + current_cursor = TextPositionWithAffinity::new_without_affinity( + current_cursor.paragraph, + new_offset, + ); + } + } + + Some(current_cursor) +} + +/// Get the text direction of the span at a given offset in a paragraph. +fn get_span_text_direction_at_offset( + para: &Paragraph, + char_offset: usize, +) -> skia_safe::textlayout::TextDirection { + if let Some((span_idx, _)) = find_span_at_offset(para, char_offset) { + if let Some(span) = para.children().get(span_idx) { + return span.text_direction; + } + } + // Fallback to paragraph's text direction + para.text_direction() +} + /// Insert text at a cursor position. Returns the new character offset after insertion. fn insert_text_at_cursor( text_content: &mut TextContent, diff --git a/scripts/check-fmt b/scripts/check-fmt new file mode 100755 index 0000000000..ce5c635630 --- /dev/null +++ b/scripts/check-fmt @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -ex + +cljfmt --parallel=true check \ + common/src/ \ + common/test/ \ + frontend/src/ \ + frontend/test/ \ + backend/src/ \ + backend/test/ \ + exporter/src/ \ + library/src; diff --git a/scripts/lint b/scripts/lint index d17e6d3c86..4ab59aed13 100755 --- a/scripts/lint +++ b/scripts/lint @@ -2,16 +2,6 @@ set -ex -cljfmt check --parallel=true \ - common/src/ \ - common/test/ \ - frontend/src/ \ - frontend/test/ \ - backend/src/ \ - backend/test/ \ - exporter/src/ \ - library/src; - clj-kondo --parallel=true --lint common/src; clj-kondo --parallel=true --lint frontend/src; clj-kondo --parallel=true --lint backend/src;