Compare commits

..

1 Commits

Author SHA1 Message Date
alonso.torres
5e54c191cc WIP 2026-01-22 16:47:30 +01:00
120 changed files with 1380 additions and 3364 deletions

View File

@@ -33,7 +33,7 @@ jobs:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
🐳 *[PENPOT] Docker image available: ${{ github.ref_name }}*
🐳 *[PENPOT] Docker image available: {{ github.ref_name }}*
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@@ -1,113 +0,0 @@
name: Plugins/api-doc deployer
on:
push:
branches:
- develop
- staging
- main
paths:
- "plugins/libs/plugin-types/index.d.ts"
- "plugins/libs/plugin-types/REAME.md"
- "plugins/tools/typedoc.css"
- "plugins/CHANGELOG.md"
- "plugins/wrangler-penpot-plugins-api-doc.toml"
workflow_dispatch:
inputs:
gh_ref:
description: 'Name of the branch'
type: choice
required: true
default: 'develop'
options:
- develop
- staging
- main
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Extract some useful variables
id: vars
run: |
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ steps.vars.outputs.gh_ref }}
# START: Setup Node and PNPM enabling cache
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Enable PNPM
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
- name: Get pnpm store path
id: pnpm-store
working-directory: ./plugins
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
# END: Setup Node and PNPM enabling cache
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
pnpm install --no-frozen-lockfile;
pnpm add -D -w wrangler@latest;
- name: Build docs
working-directory: plugins
shell: bash
run: pnpm run build:doc
- name: Select Worker name
run: |
REF="${{ steps.vars.outputs.gh_ref }}"
case "$REF" in
main) echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV ;;
staging) echo "WORKER_NAME=penpot-plugins-api-doc-pre" >> $GITHUB_ENV ;;
develop) echo "WORKER_NAME=penpot-plugins-api-doc-hourly" >> $GITHUB_ENV ;;
*) echo "Unsupported branch ${REF}" && exit 1 ;;
esac
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
workingDirectory: plugins
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --config wrangler-penpot-plugins-api-doc.toml --name ${{ env.WORKER_NAME }}
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
❌ 🧩📚 *[PENPOT PLUGINS] Error deploying API documentation.*
📄 Triggered from ref: `${{ inputs.gh_ref }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@@ -1,127 +0,0 @@
name: Plugins/package deployer
on:
# Deploy package from manual action
workflow_dispatch:
inputs:
gh_ref:
description: 'Name of the branch'
type: choice
required: true
default: 'develop'
options:
- develop
- staging
- main
plugin_name:
description: 'Pluging name (like plugins/apps/<plugin_name>-plugin)'
type: string
required: true
workflow_call:
inputs:
gh_ref:
description: 'Name of the branch'
type: string
required: true
default: 'develop'
plugin_name:
description: 'Publig name (from plugins/apps/<plugin_name>-plugin)'
type: string
required: true
permissions:
contents: read
jobs:
deploy:
runs-on: penpot-runner-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.gh_ref }}
# START: Setup Node and PNPM enabling cache
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Enable PNPM
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
- name: Get pnpm store path
id: pnpm-store
working-directory: ./plugins
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
# END: Setup Node and PNPM enabling cache
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
pnpm install --no-frozen-lockfile;
pnpm add -D -w wrangler@latest;
- name: "Build package for ${{ inputs.plugin_name }}-plugin"
working-directory: plugins
shell: bash
run: npx nx build ${{ inputs.plugin_name }}-plugin
- name: Select Worker name
run: |
REF="${{ inputs.gh_ref }}"
case "$REF" in
main)
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pro" >> $GITHUB_ENV
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.app" >> $GITHUB_ENV ;;
staging)
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pre" >> $GITHUB_ENV
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.dev" >> $GITHUB_ENV ;;
develop)
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-hourly" >> $GITHUB_ENV
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
*) echo "Unsupported branch ${REF}" && exit 1 ;;
esac
- name: Set the custom url
working-directory: plugins
shell: bash
run: |
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" apps/${{ inputs.plugin_name }}-plugin/wrangler.toml
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
workingDirectory: plugins
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --config apps/${{ inputs.plugin_name }}-plugin/wrangler.toml --name ${{ env.WORKER_NAME }}
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
❌ 🧩📦 *[PENPOT PLUGINS] Error deploying ${{ env.WORKER_NAME }}.*
📄 Triggered from ref: `${{ inputs.gh_ref }}`
Plugin name: `${{ inputs.plugin_name }}-plugin`
Cloudflare worker name: `${{ env.WORKER_NAME }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@@ -1,143 +0,0 @@
name: Plugins/packages deployer
on:
push:
branches:
- develop
- staging
- main
paths:
- 'plugins/apps/*-plugin/**'
- 'libs/plugins-styles/**'
workflow_dispatch:
inputs:
gh_ref:
description: 'Name of the branch'
type: choice
required: true
default: 'develop'
options:
- develop
- staging
- main
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
colors_to_tokens: ${{ steps.filter.outputs.colors_to_tokens }}
create_palette: ${{ steps.filter.outputs.create_palette }}
lorem_ipsum: ${{ steps.filter.outputs.lorem_ipsum }}
rename_layers: ${{ steps.filter.outputs.rename_layers }}
contrast: ${{ steps.filter.outputs.contrast }}
icons: ${{ steps.filter.outputs.icons }}
poc_state: ${{ steps.filter.outputs.poc_state }}
table: ${{ steps.filter.outputs.table }}
# [For new plugins]
# Add more outputs here
steps:
- uses: actions/checkout@v4
- id: filter
uses: dorny/paths-filter@v3
with:
filters: |
colors_to_tokens:
- 'plugins/apps/colors-to-tokens-plugin/**'
- 'libs/plugins-styles/**'
contrast:
- 'plugins/apps/contrast-plugin/**'
- 'libs/plugins-styles/**'
create_palette:
- 'plugins/apps/create-palette-plugin/**'
- 'libs/plugins-styles/**'
icons:
- 'plugins/apps/icons-plugin/**'
- 'libs/plugins-styles/**'
lorem_ipsum:
- 'plugins/apps/lorem-ipsum-plugin/**'
- 'libs/plugins-styles/**'
rename_layers:
- 'plugins/apps/rename-layers-plugin/**'
- 'libs/plugins-styles/**'
table:
- 'plugins/apps/table-plugin/**'
- 'libs/plugins-styles/**'
# [For new plugins]
# Add more plugin filters here
# another_plugin:
# - 'plugins/apps/another-plugin/**'
# - 'libs/plugins-styles/**'
colors-to-tokens-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.colors_to_tokens == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: colors-to-tokens
contrast-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.contrast == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: contrast
create-palette-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.create_palette == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: create-palette
icons-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.icons == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: icons
lorem-ipsum-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.lorem_ipsum == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: lorem-ipsum
rename-layers-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.rename_layers == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: rename-layers
table-plugin:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.table == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: table
# [For new plugins]
# Add more jobs for other plugins below, following the same pattern
# another-plugin:
# needs: detect-changes
# if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.another_plugin == 'true'
# uses: ./.github/workflows/plugins-deploy-package.yml
# secrets: inherit
# with:
# gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
# plugin_name: another

View File

@@ -21,7 +21,7 @@ concurrency:
jobs:
lint:
name: "Linter"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
@@ -34,7 +34,7 @@ jobs:
test-common:
name: "Common Tests"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
@@ -53,8 +53,7 @@ jobs:
test-plugins:
name: Plugins Runtime Linter & Tests
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -99,7 +98,7 @@ jobs:
test-frontend:
name: "Frontend Tests"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
@@ -120,7 +119,7 @@ jobs:
test-render-wasm:
name: "Render WASM Tests"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
@@ -144,7 +143,7 @@ jobs:
test-backend:
name: "Backend Tests"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
services:
@@ -183,7 +182,7 @@ jobs:
test-library:
name: "Library Tests"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
@@ -197,7 +196,7 @@ jobs:
build-integration:
name: "Build Integration Bundle"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
@@ -218,7 +217,7 @@ jobs:
test-integration-1:
name: "Integration Tests 1/4"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
@@ -248,7 +247,7 @@ jobs:
test-integration-2:
name: "Integration Tests 2/4"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
@@ -278,7 +277,7 @@ jobs:
test-integration-3:
name: "Integration Tests 3/4"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
@@ -308,7 +307,7 @@ jobs:
test-integration-4:
name: "Integration Tests 4/4"
runs-on: penpot-runner-02
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration

View File

@@ -30,15 +30,6 @@
- Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959)
- Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865)
- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835)
- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
- Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167)
- Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171)
- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133)
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
- Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187)
- Fix app freeze when introducing an error on a very long token name [Taiga #13214](https://tree.taiga.io/project/penpot/issue/13214)
## 2.12.1

View File

@@ -124,6 +124,8 @@
(throw (IllegalArgumentException. "invalid email body provided")))
(doseq [[name content] attachments]
(prn "attachment" name)
(let [attachment-part (MimeBodyPart.)]
(.setFileName attachment-part ^String name)
(.setContent attachment-part ^String content (str "text/plain; charset=" charset))

View File

@@ -27,17 +27,7 @@
[app.rpc.helpers :as rph]
[app.rpc.quotes :as quotes]
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.util.services :as sv]
[datoteka.io :as io])
(:import
java.io.InputStream
java.io.OutputStream
java.io.SequenceInputStream
java.util.Collections))
(set! *warn-on-reflection* true)
[app.util.services :as sv]))
(def valid-weight #{100 200 300 400 500 600 700 800 900 950})
(def valid-style #{"normal" "italic"})
@@ -115,7 +105,7 @@
(defn create-font-variant
[{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}]
(letfn [(generate-missing [data]
(letfn [(generate-missing! [data]
(let [data (media/run {:cmd :generate-fonts :input data})]
(when (and (not (contains? data "font/otf"))
(not (contains? data "font/ttf"))
@@ -126,26 +116,8 @@
:hint "invalid font upload, unable to generate missing font assets"))
data))
(process-chunks [chunks]
(let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "")
streams (map io/input-stream chunks)
streams (Collections/enumeration streams)]
(with-open [^OutputStream output (io/output-stream tmp)
^InputStream input (SequenceInputStream. streams)]
(io/copy input output))
tmp))
(join-chunks [data]
(reduce-kv (fn [data mtype content]
(if (vector? content)
(assoc data mtype (process-chunks content))
data))
data
data))
(prepare-font [data mtype]
(when-let [resource (get data mtype)]
(let [hash (sto/calculate-hash resource)
content (-> (sto/content resource)
(sto/wrap-with-hash hash))]
@@ -184,8 +156,7 @@
:otf-file-id (:id otf)
:ttf-file-id (:id ttf)}))]
(let [data (join-chunks data)
data (generate-missing data)
(let [data (generate-missing! data)
assets (persist-fonts-files! data)
result (insert-font-variant! assets)]
(vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys))))))

View File

@@ -134,8 +134,6 @@
:subscriptions
:subscriptions-old
:inspect-styles
;; Enable performance logs in devconsole (disabled by default)
:perf-logs
;; Security layer middleware that filters request by fetch
;; metadata headers

View File

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

View File

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

View File

@@ -36,7 +36,7 @@
{:path path
:mtype (mime/get type)
:name name
:filename (str/concat (str/slug name) (mime/get-extension type))
:filename (str/concat name (mime/get-extension type))
:id task-id}))
(defn create-zip

View File

@@ -58,7 +58,8 @@
:share-id share-id
:object-id (mapv :id objects)
:route "objects"
:skip-children skip-children}
:skip-children skip-children
:wasm "true"}
uri (-> (cf/get :public-uri)
(assoc :path "/render.html")
(assoc :query (u/map->query-string params)))]

View File

@@ -48,13 +48,13 @@
"watch:app:main": "clojure -M:dev:shadow-cljs watch main worker storybook",
"clear:shadow-cache": "rm -rf .shadow-cljs",
"watch": "exit 0",
"watch:app": "yarn run clear:shadow-cache && yarn run build:wasm && concurrently --kill-others-on-fail \"yarn run watch:app:assets\" \"yarn run watch:app:main\" \"yarn run watch:app:libs\"",
"watch:app": "yarn run clear:shadow-cache && concurrently --kill-others-on-fail \"yarn run watch:app:assets\" \"yarn run watch:app:main\" \"yarn run watch:app:libs\"",
"watch:storybook": "yarn run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\""
},
"devDependencies": {
"@penpot/draft-js": "portal:./packages/draft-js",
"@penpot/mousetrap": "portal:./packages/mousetrap",
"@penpot/plugins-runtime": "1.4.2",
"@penpot/plugins-runtime": "1.3.2",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "portal:./text-editor",
"@playwright/test": "1.57.0",

View File

@@ -1,155 +0,0 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~ud7430f09-4f59-8049-8007-6277bb7586f6",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "flex_index_position",
"~:revn": 114,
"~:modified-at": "~m1769430362161",
"~:vern": 0,
"~:id": "~u31fe2e21-73e7-80f3-8007-73894fb58240",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node"
]
},
"~:version": 67,
"~:project-id": "~ud7430f09-4f59-8049-8007-6277bb765abd",
"~:created-at": "~m1769007798998",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~u02e9633d-4ce7-80da-8007-736558496fa8"
],
"~:pages-index": {
"~u02e9633d-4ce7-80da-8007-736558496fa8": {
"~:id": "~u02e9633d-4ce7-80da-8007-736558496fa8",
"~:name": "Page 1",
"~:objects": {
"~#penpot/objects-map/v2": {
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^I\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d50980078e\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc2f\",\"~u94eaebe4-addd-80d1-8007-79d5055d6859\",\"~u77c71dba-32ee-804c-8007-736561cf857f\"]]]",
"~u77c71dba-32ee-804c-8007-736561cff457": "[\"~#shape\",[\"^ \",\"~:y\",396.00000357564704,\"~:rx\",8,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",80,\"~:transforming\",false,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",396.00000357564704]],[\"^>\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",396.00000357564704]],[\"^>\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",476.00000357564704]],[\"^>\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",476.00000357564704]]],\"~:r2\",8,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:r1\",8,\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cff457\",\"~:parent-id\",\"~u77c71dba-32ee-804c-8007-736561cf8584\",\"~:frame-id\",\"~u77c71dba-32ee-804c-8007-736561cf8584\",\"~:strokes\",[],\"~:x\",688.9999775886536,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",396.00000357564704,\"^9\",80,\"~:height\",80,\"~:x1\",688.9999775886536,\"~:y1\",396.00000357564704,\"~:x2\",768.9999775886536,\"~:y2\",476.00000357564704]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e8e9ea\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^M\",80,\"~:flip-y\",null]]",
"~u94eaebe4-addd-80d1-8007-79d508aa2885": "[\"~#shape\",[\"^ \",\"~:y\",612.0000188344361,\"~:rx\",8,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",80,\"~:transforming\",false,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",604.9999165534973,\"~:y\",612.0000188344361]],[\"^>\",[\"^ \",\"~:x\",684.9999165534973,\"~:y\",612.0000188344361]],[\"^>\",[\"^ \",\"~:x\",684.9999165534973,\"~:y\",692.0000188344361]],[\"^>\",[\"^ \",\"~:x\",604.9999165534973,\"~:y\",692.0000188344361]]],\"~:r2\",8,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2885\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\",\"~:strokes\",[],\"~:x\",604.9999165534973,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",604.9999165534973,\"~:y\",612.0000188344361,\"^9\",80,\"~:height\",80,\"~:x1\",604.9999165534973,\"~:y1\",612.0000188344361,\"~:x2\",684.9999165534973,\"~:y2\",692.0000188344361]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e8e9ea\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^M\",80,\"~:flip-y\",null]]",
"~u94eaebe4-addd-80d1-8007-79d508aa2886": "[\"~#shape\",[\"^ \",\"~:y\",636.0000188344361,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:rx\",8,\"~:layout-padding\",[\"^ \",\"~:p1\",8,\"~:p2\",12,\"~:p3\",8,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Dark / Button / Primary / Text / Default\",\"~:layout-align-items\",\"~:center\",\"~:width\",66,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",611.9999165534973,\"~:y\",636.0000188344361]],[\"^K\",[\"^ \",\"~:x\",677.9999165534973,\"~:y\",636.0000188344361]],[\"^K\",[\"^ \",\"~:x\",677.9999165534973,\"~:y\",668.0000188344361]],[\"^K\",[\"^ \",\"~:x\",611.9999165534973,\"~:y\",668.0000188344361]]],\"~:r2\",8,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^;\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:layout-justify-content\",\"^D\",\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2886\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\",\"~:layout-flex-dir\",\"~:row\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\",\"~:strokes\",[],\"~:x\",611.9999165534973,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",611.9999165534973,\"~:y\",636.0000188344361,\"^E\",66,\"~:height\",32,\"~:x1\",611.9999165534973,\"~:y1\",636.0000188344361,\"~:x2\",677.9999165534973,\"~:y2\",668.0000188344361]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#7efff5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^17\",32,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d508aa2887\"]]]",
"~u94eaebe4-addd-80d1-8007-79d508aa2887": "[\"~#shape\",[\"^ \",\"~:y\",644.0000188344361,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",2,\"~:p3\",0,\"~:p4\",2],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"_Utilities / Text / White\",\"~:layout-align-items\",\"~:start\",\"~:width\",42,\"~:layout-padding-type\",\"~:simple\",\"~:transforming\",false,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",623.9999165534973,\"~:y\",644.0000188344361]],[\"^K\",[\"^ \",\"~:x\",665.9999165534973,\"~:y\",644.0000188344361]],[\"^K\",[\"^ \",\"~:x\",665.9999165534973,\"~:y\",660.0000188344361]],[\"^K\",[\"^ \",\"~:x\",623.9999165534973,\"~:y\",660.0000188344361]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",0,\"~:column-gap\",6],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2887\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2886\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2886\",\"~:strokes\",[],\"~:x\",623.9999165534973,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",623.9999165534973,\"~:y\",644.0000188344361,\"^D\",42,\"~:height\",16,\"~:x1\",623.9999165534973,\"~:y1\",644.0000188344361,\"~:x2\",665.9999165534973,\"~:y2\",660.0000188344361]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",16,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d508aa2888\"]]]",
"~u94eaebe4-addd-80d1-8007-79d508aa2888": "[\"~#shape\",[\"^ \",\"~:y\",645.0000188344363,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:index\",null,\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^8\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:path\",\"\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"\",\"^<\",\"normal\",\"~:text-transform\",\"uppercase\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-work-sans\",\"~:font-size\",\"12\",\"~:font-weight\",\"500\",\"~:modified-at\",\"2024-06-04T14:15:09.786Z\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"underline\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-color-ref-file\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"~:fill-opacity\",1,\"~:fill-color-ref-id\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:font-family\",\"Work Sans\",\"~:text\",\"Label\"]],\"^=\",\"uppercase\",\"^>\",\"center\",\"^?\",\"gfont-work-sans\",\"^@\",\"12\",\"^A\",\"500\",\"^8\",\"paragraph\",\"^B\",\"2024-06-04T14:15:09.786Z\",\"^C\",\"500\",\"^D\",\"underline\",\"^E\",\"0\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"^K\",\"Work Sans\"]]]],\"~:vertical-align\",\"center\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^I\",1]]],\"~:hide-in-viewer\",true,\"~:name\",\"Input\",\"~:saved-component-root\",null,\"~:width\",38,\"^8\",\"^L\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",625.9999165534973,\"~:y\",645.0000188344363]],[\"^S\",[\"^ \",\"~:x\",663.9999165534973,\"~:y\",645.0000188344363]],[\"^S\",[\"^ \",\"~:x\",663.9999165534973,\"~:y\",660.0000188344359]],[\"^S\",[\"^ \",\"~:x\",625.9999165534973,\"~:y\",660.0000188344363]]],\"~:layout-item-h-sizing\",\"~:fix\",\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2888\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2887\",\"~:position-data\",[[\"^ \",\"~:y\",659.3400268554688,\"^:\",\"1.2\",\"^<\",\"normal\",\"^=\",\"uppercase\",\"^>\",\"left\",\"^?\",\"sourcesanspro\",\"^@\",\"12\",\"^A\",\"500\",\"~:text-direction\",\"ltr\",\"^Q\",37.94000244140625,\"^C\",\"regular\",\"^D\",\"underline\",\"^E\",\"0\",\"~:x\",626.0299682617188,\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:direction\",\"ltr\",\"^K\",\"Work Sans\",\"~:height\",14.08001708984375,\"^L\",\"Label\"]],\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d508aa2887\",\"~:strokes\",[],\"~:x\",625.9999165534973,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",625.9999165534973,\"~:y\",645.0000188344363,\"^Q\",38,\"^11\",15,\"~:x1\",625.9999165534973,\"~:y1\",645.0000188344363,\"~:x2\",663.9999165534973,\"~:y2\",660.0000188344363]],\"^F\",[],\"~:flip-x\",null,\"^11\",15,\"~:flip-y\",null]]",
"~u77c71dba-32ee-804c-8007-736561cff45a": "[\"~#shape\",[\"^ \",\"~:y\",429.00000357564727,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:index\",null,\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^8\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:path\",\"\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"\",\"^<\",\"normal\",\"~:text-transform\",\"uppercase\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-work-sans\",\"~:font-size\",\"12\",\"~:font-weight\",\"500\",\"~:modified-at\",\"2024-06-04T14:15:09.786Z\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"underline\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-color-ref-file\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"~:fill-opacity\",1,\"~:fill-color-ref-id\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:font-family\",\"Work Sans\",\"~:text\",\"Label\"]],\"^=\",\"uppercase\",\"^>\",\"center\",\"^?\",\"gfont-work-sans\",\"^@\",\"12\",\"^A\",\"500\",\"^8\",\"paragraph\",\"^B\",\"2024-06-04T14:15:09.786Z\",\"^C\",\"500\",\"^D\",\"underline\",\"^E\",\"0\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"^K\",\"Work Sans\"]]]],\"~:vertical-align\",\"center\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^I\",1]]],\"~:hide-in-viewer\",true,\"~:name\",\"Input\",\"~:saved-component-root\",null,\"~:width\",38,\"^8\",\"^L\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",429.00000357564727]],[\"^S\",[\"^ \",\"~:x\",747.9999775886536,\"~:y\",429.00000357564727]],[\"^S\",[\"^ \",\"~:x\",747.9999775886536,\"~:y\",444.0000035756468]],[\"^S\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",444.00000357564727]]],\"~:layout-item-h-sizing\",\"~:fix\",\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cff45a\",\"~:parent-id\",\"~u77c71dba-32ee-804c-8007-736561cff459\",\"~:position-data\",[[\"^ \",\"~:y\",443.3399963378906,\"^:\",\"1.2\",\"^<\",\"normal\",\"^=\",\"uppercase\",\"^>\",\"left\",\"^?\",\"sourcesanspro\",\"^@\",\"12\",\"^A\",\"500\",\"~:text-direction\",\"ltr\",\"^Q\",37.93994140625,\"^C\",\"regular\",\"^D\",\"underline\",\"^E\",\"0\",\"~:x\",710.030029296875,\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:direction\",\"ltr\",\"^K\",\"Work Sans\",\"~:height\",14.079986572265625,\"^L\",\"Label\"]],\"~:frame-id\",\"~u77c71dba-32ee-804c-8007-736561cff459\",\"~:strokes\",[],\"~:x\",709.9999775886536,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",429.00000357564727,\"^Q\",38,\"^11\",15,\"~:x1\",709.9999775886536,\"~:y1\",429.00000357564727,\"~:x2\",747.9999775886536,\"~:y2\",444.00000357564727]],\"^F\",[],\"~:flip-x\",null,\"^11\",15,\"~:flip-y\",null]]",
"~u77c71dba-32ee-804c-8007-736561cff459": "[\"~#shape\",[\"^ \",\"~:y\",428.00000357564704,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",2,\"~:p3\",0,\"~:p4\",2],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"_Utilities / Text / White\",\"~:layout-align-items\",\"~:start\",\"~:width\",42,\"~:layout-padding-type\",\"~:simple\",\"~:transforming\",false,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",428.00000357564704]],[\"^K\",[\"^ \",\"~:x\",749.9999775886536,\"~:y\",428.00000357564704]],[\"^K\",[\"^ \",\"~:x\",749.9999775886536,\"~:y\",444.00000357564704]],[\"^K\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",444.00000357564704]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",0,\"~:column-gap\",6],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cff459\",\"~:parent-id\",\"~u77c71dba-32ee-804c-8007-736561cff458\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u77c71dba-32ee-804c-8007-736561cff458\",\"~:strokes\",[],\"~:x\",707.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",428.00000357564704,\"^D\",42,\"~:height\",16,\"~:x1\",707.9999775886536,\"~:y1\",428.00000357564704,\"~:x2\",749.9999775886536,\"~:y2\",444.00000357564704]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",16,\"~:flip-y\",null,\"~:shapes\",[\"~u77c71dba-32ee-804c-8007-736561cff45a\"]]]",
"~u77c71dba-32ee-804c-8007-736561cff458": "[\"~#shape\",[\"^ \",\"~:y\",420.00000357564704,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:rx\",8,\"~:layout-padding\",[\"^ \",\"~:p1\",8,\"~:p2\",12,\"~:p3\",8,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Dark / Button / Primary / Text / Default\",\"~:layout-align-items\",\"~:center\",\"~:width\",66,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",420.00000357564704]],[\"^K\",[\"^ \",\"~:x\",761.9999775886536,\"~:y\",420.00000357564704]],[\"^K\",[\"^ \",\"~:x\",761.9999775886536,\"~:y\",452.00000357564704]],[\"^K\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",452.00000357564704]]],\"~:r2\",8,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^;\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:layout-justify-content\",\"^D\",\"~:r1\",8,\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cff458\",\"~:parent-id\",\"~u77c71dba-32ee-804c-8007-736561cf8584\",\"~:layout-flex-dir\",\"~:row\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u77c71dba-32ee-804c-8007-736561cf8584\",\"~:strokes\",[],\"~:x\",695.9999775886536,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",420.00000357564704,\"^E\",66,\"~:height\",32,\"~:x1\",695.9999775886536,\"~:y1\",420.00000357564704,\"~:x2\",761.9999775886536,\"~:y2\",452.00000357564704]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#7efff5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^17\",32,\"~:flip-y\",null,\"~:shapes\",[\"~u77c71dba-32ee-804c-8007-736561cff459\"]]]",
"~u77c71dba-32ee-804c-8007-736561cf857f": "[\"~#shape\",[\"^ \",\"~:y\",395.99997913999186,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",12,\"~:p3\",0,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:wrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Board Parent 1\",\"~:layout-align-items\",\"~:start\",\"~:width\",272,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",593.0000386238098,\"~:y\",395.99997913999186]],[\"^J\",[\"^ \",\"~:x\",865.0000386238098,\"~:y\",395.99997913999186]],[\"^J\",[\"^ \",\"~:x\",865.0000386238098,\"~:y\",475.9999669761459]],[\"^J\",[\"^ \",\"~:x\",593.0000386238098,\"~:y\",475.9999669761459]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-item-v-sizing\",\"~:fix\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cf857f\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-flex-dir\",\"~:row\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",593.0000386238098,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",593.0000386238098,\"~:y\",395.99997913999186,\"^D\",272,\"~:height\",79.99998783615405,\"~:x1\",593.0000386238098,\"~:y1\",395.99997913999186,\"~:x2\",865.0000386238098,\"~:y2\",475.9999669761459]],\"~:fills\",[],\"~:flip-x\",null,\"^15\",79.99998783615405,\"~:flip-y\",null,\"~:shapes\",[\"~u77c71dba-32ee-804c-8007-736561cf8584\"]]]",
"~u94eaebe4-addd-80d1-8007-79d50980078e": "[\"~#shape\",[\"^ \",\"~:y\",720.0000478045426,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",12,\"~:p3\",0,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:wrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Board Parent 4\",\"~:layout-align-items\",\"~:start\",\"~:width\",272,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",592.9998555183411,\"~:y\",720.0000478045426]],[\"^J\",[\"^ \",\"~:x\",864.9998555183411,\"~:y\",720.0000478045426]],[\"^J\",[\"^ \",\"~:x\",864.9998555183411,\"~:y\",800.0000356406968]],[\"^J\",[\"^ \",\"~:x\",592.9998555183411,\"~:y\",800.0000356406968]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-item-v-sizing\",\"~:fix\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d50980078e\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-flex-dir\",\"~:column-reverse\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",592.9998555183411,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",592.9998555183411,\"~:y\",720.0000478045426,\"^D\",272,\"~:height\",79.9999878361541,\"~:x1\",592.9998555183411,\"~:y1\",720.0000478045426,\"~:x2\",864.9998555183411,\"~:y2\",800.0000356406968]],\"~:fills\",[],\"~:flip-x\",null,\"^15\",79.9999878361541,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d50980078f\"]]]",
"~u94eaebe4-addd-80d1-8007-79d50980078f": "[\"~#shape\",[\"^ \",\"~:y\",719.9999806874634,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"Board Child\",\"~:width\",80,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",719.9999806874634]],[\"^;\",[\"^ \",\"~:x\",684.9999775886536,\"~:y\",719.9999806874634]],[\"^;\",[\"^ \",\"~:x\",684.9999775886536,\"~:y\",799.9999806874634]],[\"^;\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",799.9999806874634]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d50980078f\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078e\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078e\",\"~:strokes\",[],\"~:x\",604.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",719.9999806874634,\"^7\",80,\"~:height\",80,\"~:x1\",604.9999775886536,\"~:y1\",719.9999806874634,\"~:x2\",684.9999775886536,\"~:y2\",799.9999806874634]],\"~:fills\",[],\"~:flip-x\",null,\"^K\",80,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d509800790\",\"~u94eaebe4-addd-80d1-8007-79d509800791\"]]]",
"~u94eaebe4-addd-80d1-8007-79d508a9dc2f": "[\"~#shape\",[\"^ \",\"~:y\",612.000024916359,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",12,\"~:p3\",0,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:wrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Board Parent 3\",\"~:layout-align-items\",\"~:start\",\"~:width\",272,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",592.9999165534973,\"~:y\",612.000024916359]],[\"^J\",[\"^ \",\"~:x\",864.9999165534973,\"~:y\",612.000024916359]],[\"^J\",[\"^ \",\"~:x\",864.9999165534973,\"~:y\",692.0000127525132]],[\"^J\",[\"^ \",\"~:x\",592.9999165534973,\"~:y\",692.0000127525132]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-item-v-sizing\",\"~:fix\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc2f\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",592.9999165534973,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",592.9999165534973,\"~:y\",612.000024916359,\"^D\",272,\"~:height\",79.9999878361541,\"~:x1\",592.9999165534973,\"~:y1\",612.000024916359,\"~:x2\",864.9999165534973,\"~:y2\",692.0000127525132]],\"~:fills\",[],\"~:flip-x\",null,\"^15\",79.9999878361541,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\"]]]",
"~u94eaebe4-addd-80d1-8007-79d509800790": "[\"~#shape\",[\"^ \",\"~:y\",720.0000417226197,\"~:rx\",8,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",80,\"~:transforming\",false,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",604.9998555183411,\"~:y\",720.0000417226197]],[\"^>\",[\"^ \",\"~:x\",684.9998555183411,\"~:y\",720.0000417226197]],[\"^>\",[\"^ \",\"~:x\",684.9998555183411,\"~:y\",800.0000417226197]],[\"^>\",[\"^ \",\"~:x\",604.9998555183411,\"~:y\",800.0000417226197]]],\"~:r2\",8,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d509800790\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078f\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078f\",\"~:strokes\",[],\"~:x\",604.9998555183411,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",604.9998555183411,\"~:y\",720.0000417226197,\"^9\",80,\"~:height\",80,\"~:x1\",604.9998555183411,\"~:y1\",720.0000417226197,\"~:x2\",684.9998555183411,\"~:y2\",800.0000417226197]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e8e9ea\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^M\",80,\"~:flip-y\",null]]",
"~u94eaebe4-addd-80d1-8007-79d508a9dc30": "[\"~#shape\",[\"^ \",\"~:y\",612.0000188344361,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"Board Child\",\"~:width\",80,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",612.0000188344361]],[\"^;\",[\"^ \",\"~:x\",684.9999775886536,\"~:y\",612.0000188344361]],[\"^;\",[\"^ \",\"~:x\",684.9999775886536,\"~:y\",692.0000188344361]],[\"^;\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",692.0000188344361]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc30\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc2f\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d508a9dc2f\",\"~:strokes\",[],\"~:x\",604.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",604.9999775886536,\"~:y\",612.0000188344361,\"^7\",80,\"~:height\",80,\"~:x1\",604.9999775886536,\"~:y1\",612.0000188344361,\"~:x2\",684.9999775886536,\"~:y2\",692.0000188344361]],\"~:fills\",[],\"~:flip-x\",null,\"^K\",80,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d508aa2885\",\"~u94eaebe4-addd-80d1-8007-79d508aa2886\"]]]",
"~u94eaebe4-addd-80d1-8007-79d509800791": "[\"~#shape\",[\"^ \",\"~:y\",744.0000417226197,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:rx\",8,\"~:layout-padding\",[\"^ \",\"~:p1\",8,\"~:p2\",12,\"~:p3\",8,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Dark / Button / Primary / Text / Default\",\"~:layout-align-items\",\"~:center\",\"~:width\",66,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",611.9998555183411,\"~:y\",744.0000417226197]],[\"^K\",[\"^ \",\"~:x\",677.9998555183411,\"~:y\",744.0000417226197]],[\"^K\",[\"^ \",\"~:x\",677.9998555183411,\"~:y\",776.0000417226197]],[\"^K\",[\"^ \",\"~:x\",611.9998555183411,\"~:y\",776.0000417226197]]],\"~:r2\",8,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^;\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:layout-justify-content\",\"^D\",\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d509800791\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078f\",\"~:layout-flex-dir\",\"~:row\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d50980078f\",\"~:strokes\",[],\"~:x\",611.9998555183411,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",611.9998555183411,\"~:y\",744.0000417226197,\"^E\",66,\"~:height\",32,\"~:x1\",611.9998555183411,\"~:y1\",744.0000417226197,\"~:x2\",677.9998555183411,\"~:y2\",776.0000417226197]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#7efff5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^17\",32,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d509800792\"]]]",
"~u94eaebe4-addd-80d1-8007-79d509800792": "[\"~#shape\",[\"^ \",\"~:y\",752.0000417226197,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",2,\"~:p3\",0,\"~:p4\",2],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"_Utilities / Text / White\",\"~:layout-align-items\",\"~:start\",\"~:width\",42,\"~:layout-padding-type\",\"~:simple\",\"~:transforming\",false,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",623.9998555183411,\"~:y\",752.0000417226197]],[\"^K\",[\"^ \",\"~:x\",665.9998555183411,\"~:y\",752.0000417226197]],[\"^K\",[\"^ \",\"~:x\",665.9998555183411,\"~:y\",768.0000417226197]],[\"^K\",[\"^ \",\"~:x\",623.9998555183411,\"~:y\",768.0000417226197]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",0,\"~:column-gap\",6],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d509800792\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d509800791\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d509800791\",\"~:strokes\",[],\"~:x\",623.9998555183411,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",623.9998555183411,\"~:y\",752.0000417226197,\"^D\",42,\"~:height\",16,\"~:x1\",623.9998555183411,\"~:y1\",752.0000417226197,\"~:x2\",665.9998555183411,\"~:y2\",768.0000417226197]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",16,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d509800793\"]]]",
"~u94eaebe4-addd-80d1-8007-79d509800793": "[\"~#shape\",[\"^ \",\"~:y\",753.0000417226199,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:index\",null,\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^8\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:path\",\"\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"\",\"^<\",\"normal\",\"~:text-transform\",\"uppercase\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-work-sans\",\"~:font-size\",\"12\",\"~:font-weight\",\"500\",\"~:modified-at\",\"2024-06-04T14:15:09.786Z\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"underline\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-color-ref-file\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"~:fill-opacity\",1,\"~:fill-color-ref-id\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:font-family\",\"Work Sans\",\"~:text\",\"Label\"]],\"^=\",\"uppercase\",\"^>\",\"center\",\"^?\",\"gfont-work-sans\",\"^@\",\"12\",\"^A\",\"500\",\"^8\",\"paragraph\",\"^B\",\"2024-06-04T14:15:09.786Z\",\"^C\",\"500\",\"^D\",\"underline\",\"^E\",\"0\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"^K\",\"Work Sans\"]]]],\"~:vertical-align\",\"center\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^I\",1]]],\"~:hide-in-viewer\",true,\"~:name\",\"Input\",\"~:saved-component-root\",null,\"~:width\",38,\"^8\",\"^L\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",625.9998555183411,\"~:y\",753.0000417226199]],[\"^S\",[\"^ \",\"~:x\",663.9998555183411,\"~:y\",753.0000417226199]],[\"^S\",[\"^ \",\"~:x\",663.9998555183411,\"~:y\",768.0000417226195]],[\"^S\",[\"^ \",\"~:x\",625.9998555183411,\"~:y\",768.0000417226199]]],\"~:layout-item-h-sizing\",\"~:fix\",\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d509800793\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d509800792\",\"~:position-data\",[[\"^ \",\"~:y\",767.340087890625,\"^:\",\"1.2\",\"^<\",\"normal\",\"^=\",\"uppercase\",\"^>\",\"left\",\"^?\",\"sourcesanspro\",\"^@\",\"12\",\"^A\",\"500\",\"~:text-direction\",\"ltr\",\"^Q\",37.93994140625,\"^C\",\"regular\",\"^D\",\"underline\",\"^E\",\"0\",\"~:x\",626.0299072265625,\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:direction\",\"ltr\",\"^K\",\"Work Sans\",\"~:height\",14.08001708984375,\"^L\",\"Label\"]],\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d509800792\",\"~:strokes\",[],\"~:x\",625.9998555183411,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",625.9998555183411,\"~:y\",753.0000417226199,\"^Q\",38,\"^11\",15,\"~:x1\",625.9998555183411,\"~:y1\",753.0000417226199,\"~:x2\",663.9998555183411,\"~:y2\",768.0000417226199]],\"^F\",[],\"~:flip-x\",null,\"^11\",15,\"~:flip-y\",null]]",
"~u77c71dba-32ee-804c-8007-736561cf8584": "[\"~#shape\",[\"^ \",\"~:y\",396.00000357564704,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"Board Child\",\"~:width\",80,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",396.00000357564704]],[\"^;\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",396.00000357564704]],[\"^;\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",476.00000357564704]],[\"^;\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",476.00000357564704]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u77c71dba-32ee-804c-8007-736561cf8584\",\"~:parent-id\",\"~u77c71dba-32ee-804c-8007-736561cf857f\",\"~:frame-id\",\"~u77c71dba-32ee-804c-8007-736561cf857f\",\"~:strokes\",[],\"~:x\",688.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",396.00000357564704,\"^7\",80,\"~:height\",80,\"~:x1\",688.9999775886536,\"~:y1\",396.00000357564704,\"~:x2\",768.9999775886536,\"~:y2\",476.00000357564704]],\"~:fills\",[],\"~:flip-x\",null,\"^K\",80,\"~:flip-y\",null,\"~:shapes\",[\"~u77c71dba-32ee-804c-8007-736561cff457\",\"~u77c71dba-32ee-804c-8007-736561cff458\"]]]",
"~u94eaebe4-addd-80d1-8007-79d5055d6859": "[\"~#shape\",[\"^ \",\"~:y\",504.00000202817546,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",12,\"~:p3\",0,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:wrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Board Parent 2\",\"~:layout-align-items\",\"~:start\",\"~:width\",272,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",592.9999775886536,\"~:y\",504.00000202817546]],[\"^J\",[\"^ \",\"~:x\",864.9999775886536,\"~:y\",504.00000202817546]],[\"^J\",[\"^ \",\"~:x\",864.9999775886536,\"~:y\",583.9999898643296]],[\"^J\",[\"^ \",\"~:x\",592.9999775886536,\"~:y\",583.9999898643296]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-item-v-sizing\",\"~:fix\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d6859\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-flex-dir\",\"~:row-reverse\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",592.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",592.9999775886536,\"~:y\",504.00000202817546,\"^D\",272,\"~:height\",79.9999878361541,\"~:x1\",592.9999775886536,\"~:y1\",504.00000202817546,\"~:x2\",864.9999775886536,\"~:y2\",583.9999898643296]],\"~:fills\",[],\"~:flip-x\",null,\"^15\",79.9999878361541,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d5055d685a\"]]]",
"~u94eaebe4-addd-80d1-8007-79d5055d685a": "[\"~#shape\",[\"^ \",\"~:y\",503.9999959462525,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"Board Child\",\"~:width\",80,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",503.9999959462525]],[\"^;\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",503.9999959462525]],[\"^;\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",583.9999959462525]],[\"^;\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",583.9999959462525]]],\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685a\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d6859\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d6859\",\"~:strokes\",[],\"~:x\",688.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",503.9999959462525,\"^7\",80,\"~:height\",80,\"~:x1\",688.9999775886536,\"~:y1\",503.9999959462525,\"~:x2\",768.9999775886536,\"~:y2\",583.9999959462525]],\"~:fills\",[],\"~:flip-x\",null,\"^K\",80,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d5055d685b\",\"~u94eaebe4-addd-80d1-8007-79d5055d685c\"]]]",
"~u94eaebe4-addd-80d1-8007-79d5055d685b": "[\"~#shape\",[\"^ \",\"~:y\",503.9999959462525,\"~:rx\",8,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",80,\"~:transforming\",false,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",503.9999959462525]],[\"^>\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",503.9999959462525]],[\"^>\",[\"^ \",\"~:x\",768.9999775886536,\"~:y\",583.9999959462525]],[\"^>\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",583.9999959462525]]],\"~:r2\",8,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685b\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685a\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685a\",\"~:strokes\",[],\"~:x\",688.9999775886536,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",688.9999775886536,\"~:y\",503.9999959462525,\"^9\",80,\"~:height\",80,\"~:x1\",688.9999775886536,\"~:y1\",503.9999959462525,\"~:x2\",768.9999775886536,\"~:y2\",583.9999959462525]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e8e9ea\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^M\",80,\"~:flip-y\",null]]",
"~u94eaebe4-addd-80d1-8007-79d5055d685c": "[\"~#shape\",[\"^ \",\"~:y\",527.9999959462525,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:rx\",8,\"~:layout-padding\",[\"^ \",\"~:p1\",8,\"~:p2\",12,\"~:p3\",8,\"~:p4\",12],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Dark / Button / Primary / Text / Default\",\"~:layout-align-items\",\"~:center\",\"~:width\",66,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",527.9999959462525]],[\"^K\",[\"^ \",\"~:x\",761.9999775886536,\"~:y\",527.9999959462525]],[\"^K\",[\"^ \",\"~:x\",761.9999775886536,\"~:y\",559.9999959462525]],[\"^K\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",559.9999959462525]]],\"~:r2\",8,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^;\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:r3\",8,\"~:layout-justify-content\",\"^D\",\"~:r1\",8,\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685c\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685a\",\"~:layout-flex-dir\",\"~:row\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685a\",\"~:strokes\",[],\"~:x\",695.9999775886536,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",695.9999775886536,\"~:y\",527.9999959462525,\"^E\",66,\"~:height\",32,\"~:x1\",695.9999775886536,\"~:y1\",527.9999959462525,\"~:x2\",761.9999775886536,\"~:y2\",559.9999959462525]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#7efff5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^17\",32,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d5055d685d\"]]]",
"~u94eaebe4-addd-80d1-8007-79d5055d685d": "[\"~#shape\",[\"^ \",\"~:y\",535.9999959462525,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",2,\"~:p3\",0,\"~:p4\",2],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"_Utilities / Text / White\",\"~:layout-align-items\",\"~:start\",\"~:width\",42,\"~:layout-padding-type\",\"~:simple\",\"~:transforming\",false,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",535.9999959462525]],[\"^K\",[\"^ \",\"~:x\",749.9999775886536,\"~:y\",535.9999959462525]],[\"^K\",[\"^ \",\"~:x\",749.9999775886536,\"~:y\",551.9999959462525]],[\"^K\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",551.9999959462525]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",0,\"~:column-gap\",6],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:layout-justify-content\",\"~:center\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685d\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685c\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685c\",\"~:strokes\",[],\"~:x\",707.9999775886536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",707.9999775886536,\"~:y\",535.9999959462525,\"^D\",42,\"~:height\",16,\"~:x1\",707.9999775886536,\"~:y1\",535.9999959462525,\"~:x2\",749.9999775886536,\"~:y2\",551.9999959462525]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",16,\"~:flip-y\",null,\"~:shapes\",[\"~u94eaebe4-addd-80d1-8007-79d5055d685e\"]]]",
"~u94eaebe4-addd-80d1-8007-79d5055d685e": "[\"~#shape\",[\"^ \",\"~:y\",536.9999959462527,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:index\",null,\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^8\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:path\",\"\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"\",\"^<\",\"normal\",\"~:text-transform\",\"uppercase\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-work-sans\",\"~:font-size\",\"12\",\"~:font-weight\",\"500\",\"~:modified-at\",\"2024-06-04T14:15:09.786Z\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"underline\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-color-ref-file\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"~:fill-opacity\",1,\"~:fill-color-ref-id\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:font-family\",\"Work Sans\",\"~:text\",\"Label\"]],\"^=\",\"uppercase\",\"^>\",\"center\",\"^?\",\"gfont-work-sans\",\"^@\",\"12\",\"^A\",\"500\",\"^8\",\"paragraph\",\"^B\",\"2024-06-04T14:15:09.786Z\",\"^C\",\"500\",\"^D\",\"underline\",\"^E\",\"0\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"^K\",\"Work Sans\"]]]],\"~:vertical-align\",\"center\",\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^I\",1]]],\"~:hide-in-viewer\",true,\"~:name\",\"Input\",\"~:saved-component-root\",null,\"~:width\",38,\"^8\",\"^L\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",536.9999959462527]],[\"^S\",[\"^ \",\"~:x\",747.9999775886536,\"~:y\",536.9999959462527]],[\"^S\",[\"^ \",\"~:x\",747.9999775886536,\"~:y\",551.9999959462523]],[\"^S\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",551.9999959462527]]],\"~:layout-item-h-sizing\",\"~:fix\",\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u02e9633d-4ce7-80da-8007-736558496fa8\",\"~:id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685e\",\"~:parent-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685d\",\"~:position-data\",[[\"^ \",\"~:y\",551.3400268554688,\"^:\",\"1.2\",\"^<\",\"normal\",\"^=\",\"uppercase\",\"^>\",\"left\",\"^?\",\"sourcesanspro\",\"^@\",\"12\",\"^A\",\"500\",\"~:text-direction\",\"ltr\",\"^Q\",37.93994140625,\"^C\",\"regular\",\"^D\",\"underline\",\"^E\",\"0\",\"~:x\",710.030029296875,\"^F\",[[\"^ \",\"^G\",\"#000000\",\"^H\",\"~ucaa70d02-51e1-81ae-8007-735e7de3d7bc\",\"^I\",1,\"^J\",\"~udfa92acf-7d18-8079-8003-baba8789d8af\"]],\"~:direction\",\"ltr\",\"^K\",\"Work Sans\",\"~:height\",14.08001708984375,\"^L\",\"Label\"]],\"~:frame-id\",\"~u94eaebe4-addd-80d1-8007-79d5055d685d\",\"~:strokes\",[],\"~:x\",709.9999775886536,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",709.9999775886536,\"~:y\",536.9999959462527,\"^Q\",38,\"^11\",15,\"~:x1\",709.9999775886536,\"~:y1\",536.9999959462527,\"~:x2\",747.9999775886536,\"~:y2\",551.9999959462527]],\"^F\",[],\"~:flip-x\",null,\"^11\",15,\"~:flip-y\",null]]"
}
}
}
},
"~:id": "~u31fe2e21-73e7-80f3-8007-73894fb58240",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

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

View File

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

View File

@@ -1256,192 +1256,6 @@ test.describe("Tokens: Tokens Tab", () => {
).toBeEnabled();
});
test("User creates shadow token with negative spread", async ({ page }) => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page, {flags: ["enable-token-shadow"]});
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
const addTokenButton = tokensTabPanel.getByRole("button", {
name: `Add Token: Shadow`,
});
await addTokenButton.click();
await expect(tokensUpdateCreateModal).toBeVisible();
await expect(
tokensUpdateCreateModal.getByPlaceholder(
"Enter a value or alias with {alias}",
),
).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const colorField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Color",
});
const offsetXField = tokensUpdateCreateModal.getByRole("textbox", {
name: "X",
});
const offsetYField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Y",
});
const blurField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Blur",
});
const spreadField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Spread",
});
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
// 1. Check default values
await expect(offsetXField).toHaveValue("4");
await expect(offsetYField).toHaveValue("4");
await expect(blurField).toHaveValue("4");
await expect(spreadField).toHaveValue("0");
// 2. Name filled + empty value → disabled
await nameField.fill("my-token");
await expect(submitButton).toBeDisabled();
// 3. Invalid color → disabled + error message
await colorField.fill("1");
await expect(
tokensUpdateCreateModal.getByText("Invalid color value: 1"),
).toBeVisible();
await expect(submitButton).toBeDisabled();
await colorField.fill("{missing-reference}");
await expect(
tokensUpdateCreateModal.getByText(
"Missing token references: missing-reference",
),
).toBeVisible();
// 4. Empty name → disabled + error message
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
//
// ------- SUCCESSFUL FIELDS -------
//
// 5. Valid color → resolved
await colorField.fill("red");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: #ff0000"),
).toBeVisible();
const colorSwatch = tokensUpdateCreateModal.getByTestId(
"token-form-color-bullet",
);
await colorSwatch.click();
const rampSelector = tokensUpdateCreateModal.getByTestId(
"value-saturation-selector",
);
await expect(rampSelector).toBeVisible();
await rampSelector.click({ position: { x: 50, y: 50 } });
await expect(
tokensUpdateCreateModal.getByText("Resolved value:"),
).toBeVisible();
const sliderOpacity = tokensUpdateCreateModal.getByTestId("slider-opacity");
await sliderOpacity.click({ position: { x: 50, y: 0 } });
await expect(
tokensUpdateCreateModal.getByRole("textbox", { name: "Color" }),
).toHaveValue(/rgba\s*\([^)]*\)/);
// 6. Valid offset → resolved
await offsetXField.fill("3 + 3");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 6"),
).toBeVisible();
await offsetYField.fill("3 + 7");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 10"),
).toBeVisible();
// 7. Valid blur → resolved
await blurField.fill("3 + 1");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 4"),
).toBeVisible();
// 8. Valid spread → resolved
await spreadField.fill("3 - 3");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 0"),
).toBeVisible();
await spreadField.fill("1 - 3");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: -2"),
).toBeVisible();
await nameField.fill("my-token");
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(
tokensTabPanel.getByRole("button", { name: "my-token" }),
).toBeEnabled();
//
// ------- SECOND TOKEN WITH VALID REFERENCE -------
//
await addTokenButton.click();
await nameField.fill("my-token-2");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
const compositeToggle =
tokensUpdateCreateModal.getByTestId("composite-opt");
await referenceToggle.click();
const referenceInput = tokensUpdateCreateModal.getByPlaceholder(
"Enter a token shadow alias",
);
await expect(referenceInput).toBeVisible();
await compositeToggle.click();
await expect(colorField).toBeVisible();
await referenceToggle.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
await referenceField.fill("{my-token}");
await expect(
tokensUpdateCreateModal.getByText(
"Resolved value: - X: 6 - Y: 10 - Blur: 4 - Spread: -2",
),
).toBeVisible();
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(
tokensTabPanel.getByRole("button", { name: "my-token-2" }),
).toBeEnabled();
});
test("User creates typography token", async ({ page }) => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =

View File

@@ -174,7 +174,6 @@ export async function watch(baseDir, predicate, callback) {
const watcher = new Watcher(baseDir, {
persistent: true,
recursive: true,
debounce: 500
});
watcher.on("change", (path) => {
@@ -182,19 +181,8 @@ export async function watch(baseDir, predicate, callback) {
callback(path);
}
});
watcher.on("error", (cause) => {
console.log("WATCHER ERROR", cause);
});
}
export async function ensureDirectories() {
await fs.mkdir("./resources/public/js/worker/", { recursive: true });
await fs.mkdir("./resources/public/css/", { recursive: true });
}
async function readManifestFile(resource) {
const manifestPath = "resources/public/" + resource;
let content = await fs.readFile(manifestPath, { encoding: "utf8" });
@@ -272,9 +260,6 @@ const markedOptions = {
marked.use(markedOptions);
export async function compileTranslations() {
const outputDir = "resources/public/js/";
await fs.mkdir(outputDir, { recursive: true });
const langs = [
"ar",
"ca",
@@ -356,6 +341,7 @@ export async function compileTranslations() {
}
const esm = `export default ${JSON.stringify(result, null, 0)};\n`;
const outputDir = "resources/public/js/";
const outputFile = ph.join(outputDir, "translation." + lang + ".js");
await fs.writeFile(outputFile, esm);
}
@@ -513,43 +499,17 @@ export async function compileStyles() {
export async function compileSvgSprites() {
const start = process.hrtime();
log.info("init: compile svgsprite");
let error = false;
try {
await generateSvgSprites();
} catch (cause) {
error = cause;
}
await generateSvgSprites();
const end = process.hrtime(start);
if (error) {
log.error("error: compile svgsprite", `(${ppt(end)})`);
console.error(error);
} else {
log.info("done: compile svgsprite", `(${ppt(end)})`);
}
log.info("done: compile svgsprite", `(${ppt(end)})`);
}
export async function compileTemplates() {
const start = process.hrtime();
let error = false;
log.info("init: compile templates");
try {
await generateTemplates();
} catch (cause) {
error = cause;
}
await generateTemplates();
const end = process.hrtime(start);
if (error) {
log.error("error: compile templates", `(${ppt(end)})`);
console.error(error);
} else {
log.info("done: compile templates", `(${ppt(end)})`);
}
log.info("done: compile templates", `(${ppt(end)})`);
}
export async function compilePolyfills() {

View File

@@ -28,12 +28,14 @@ async function compileFile(path) {
],
sourceMap: false,
});
// console.dir(result);
resolve({
inputPath: path,
outputPath: dest,
css: result.css,
});
} catch (cause) {
console.error(cause);
reject(cause);
}
});

View File

@@ -1,6 +1,5 @@
import * as h from "./_helpers.js";
await h.ensureDirectories();
await h.compileStyles();
await h.copyAssets();
await h.copyWasmPlayground();

View File

@@ -12,31 +12,19 @@ let sass = null;
async function compileSassAll() {
const start = process.hrtime();
let error = false;
log.info("init: compile styles");
try {
sass = await h.compileSassAll(worker);
let output = await h.concatSass(sass);
await fs.writeFile("./resources/public/css/main.css", output);
sass = await h.compileSassAll(worker);
let output = await h.concatSass(sass);
await fs.writeFile("./resources/public/css/main.css", output);
if (isDebug) {
let debugCSS = await h.compileSassDebug(worker);
await fs.writeFile("./resources/public/css/debug.css", debugCSS);
}
} catch (cause) {
error = cause;
if (isDebug) {
let debugCSS = await h.compileSassDebug(worker);
await fs.writeFile("./resources/public/css/debug.css", debugCSS);
}
const end = process.hrtime(start);
if (error) {
log.error("error: compile styles", `(${ppt(end)})`);
console.error(error);
} else {
log.info("done: compile styles", `(${ppt(end)})`);
}
log.info("done: compile styles", `(${ppt(end)})`);
}
async function compileSass(path) {
@@ -60,7 +48,7 @@ async function compileSass(path) {
}
}
await h.ensureDirectories();
await fs.mkdir("./resources/public/css/", { recursive: true });
await compileSassAll();
await h.copyAssets();
await h.copyWasmPlayground();

View File

@@ -476,24 +476,23 @@
(when (and (some? (.-PerformanceObserver js/window)) (nil? @longtask-observer*))
(let [observer (js/PerformanceObserver.
(fn [list _]
(when (contains? cf/flags :perf-logs)
(doseq [entry (.getEntries list)]
(let [dur (.-duration entry)
start (.-startTime entry)
attrib (.-attribution entry)
attrib-count (when attrib (.-length attrib))
first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0))
attrib-name (when first-attrib (.-name first-attrib))
attrib-ctype (when first-attrib (.-containerType first-attrib))
attrib-cid (when first-attrib (.-containerId first-attrib))
attrib-csrc (when first-attrib (.-containerSrc first-attrib))]
(doseq [entry (.getEntries list)]
(let [dur (.-duration entry)
start (.-startTime entry)
attrib (.-attribution entry)
attrib-count (when attrib (.-length attrib))
first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0))
attrib-name (when first-attrib (.-name first-attrib))
attrib-ctype (when first-attrib (.-containerType first-attrib))
attrib-cid (when first-attrib (.-containerId first-attrib))
attrib-csrc (when first-attrib (.-containerSrc first-attrib))]
(.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms"
(when first-attrib
(str " attrib:name=" attrib-name
" ctype=" attrib-ctype
" cid=" attrib-cid
" csrc=" attrib-csrc)))))))))]
(.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms"
(when first-attrib
(str " attrib:name=" attrib-name
" ctype=" attrib-ctype
" cid=" attrib-cid
" csrc=" attrib-csrc))))))))]
(.observe observer #js{:entryTypes #js["longtask"]})
(reset! longtask-observer* observer))))
@@ -506,32 +505,30 @@
(let [last (atom (.now js/performance))
id (js/setInterval
(fn []
(when (contains? cf/flags :perf-logs)
(let [now (.now js/performance)
expected (+ @last interval-ms)
drift (- now expected)
current-op @current-op*
measures (.getEntriesByType js/performance "measure")
mlen (.-length measures)
last-measure (when (> mlen 0) (aget measures (dec mlen)))
meas-name (when last-measure (.-name last-measure))
meas-detail (when last-measure (.-detail last-measure))
meas-count (when meas-detail (unchecked-get meas-detail "count"))]
(reset! last now)
(when (> drift threshold-ms)
(.warn js/console
(str "[perf] event loop stall: " (Math/round drift) "ms"
(when current-op (str " op=" current-op))
(when meas-name (str " last=" meas-name))
(when meas-count (str " count=" meas-count))))))))
(let [now (.now js/performance)
expected (+ @last interval-ms)
drift (- now expected)
current-op @current-op*
measures (.getEntriesByType js/performance "measure")
mlen (.-length measures)
last-measure (when (> mlen 0) (aget measures (dec mlen)))
meas-name (when last-measure (.-name last-measure))
meas-detail (when last-measure (.-detail last-measure))
meas-count (when meas-detail (unchecked-get meas-detail "count"))]
(reset! last now)
(when (> drift threshold-ms)
(.warn js/console
(str "[perf] event loop stall: " (Math/round drift) "ms"
(when current-op (str " op=" current-op))
(when meas-name (str " last=" meas-name))
(when meas-count (str " count=" meas-count)))))))
interval-ms)]
(reset! stall-timer* id))))
(defn init!
"Install perf observers in dev builds. Safe to call multiple times.
Perf logs are disabled by default. Enable them with the :perf-logs flag in config."
"Install perf observers in dev builds. Safe to call multiple times."
[]
#_(when ^boolean js/goog.DEBUG
(when (and ^boolean js/goog.DEBUG *assert* false)
(install-long-task-observer!)
(start-event-loop-stall-logger! 50 100)
;; Expose simple API on window for manual control in devtools

View File

@@ -24,20 +24,6 @@
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(def ^:const default-chunk-size
(* 1024 1024 4)) ;; 4MiB
(defn- chunk-array
[data chunk-size]
(let [total-size (alength data)]
(loop [offset 0
chunks []]
(if (< offset total-size)
(let [end (min (+ offset chunk-size) total-size)
chunk (.subarray ^js data offset end)]
(recur end (conj chunks chunk)))
chunks))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; General purpose events & IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -130,9 +116,9 @@
(not= hhea-descender win-descent)
(and f-selection (or
(not= hhea-ascender os2-ascent)
(not= hhea-descender os2-descent))))
data (js/Uint8Array. data)]
{:content {:data (chunk-array data default-chunk-size)
(not= hhea-descender os2-descent))))]
{:content {:data (js/Uint8Array. data)
:name name
:type type}
:font-family (or family "")

View File

@@ -126,7 +126,7 @@
If the `value` is not parseable and/or has missing references returns a map with `:errors`.
If the `value` is parseable but is out of range returns a map with `warnings`."
[value]
(let [missing-references? (seq (cto/find-token-value-references value))
(let [missing-references? (seq (seq (cto/find-token-value-references value)))
parsed-value (cft/parse-token-value value)
out-of-scope (not (<= 0 (:value parsed-value) 1))
references (seq (cto/find-token-value-references value))]
@@ -152,14 +152,15 @@
[value]
(let [missing-references? (seq (cto/find-token-value-references value))
parsed-value (cft/parse-token-value value)
out-of-scope (< (:value parsed-value) 0)]
out-of-scope (< (:value parsed-value) 0)
references (seq (cto/find-token-value-references value))]
(cond
(and parsed-value (not out-of-scope))
parsed-value
missing-references?
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference missing-references?)]
:references missing-references?}
references
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference references)]
:references references}
(and (not missing-references?) out-of-scope)
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-stroke-width value)]}
@@ -364,7 +365,7 @@
"Parses shadow spread value (non-negative number)."
[value]
(let [parsed (parse-sd-token-general-value value)
valid? (:value parsed)]
valid? (and (:value parsed) (>= (:value parsed) 0))]
(cond
valid?
parsed

View File

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

View File

@@ -616,7 +616,7 @@
[modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis undo-transation?]
:or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true}
:as params}]
(ptk/reify ::apply-wasm-modifiers
(ptk/reify ::apply-wasm-modifiesr
ptk/WatchEvent
(watch [_ state _]
(wasm.api/clean-modifiers)

View File

@@ -105,15 +105,9 @@
(if (dsh/lookup-page state file-id page-id)
(rx/concat
(rx/of (initialize-page* file-id page-id)
(fdf/fix-deleted-fonts-for-page file-id page-id))
;; Disable thumbnail generation in wasm renderer
(if (features/active-feature? state "render-wasm/v1")
(rx/empty)
(rx/of (dwth/watch-state-changes file-id page-id)))
(rx/of (dwl/watch-component-changes))
(fdf/fix-deleted-fonts-for-page file-id page-id)
(dwth/watch-state-changes file-id page-id)
(dwl/watch-component-changes))
(let [profile (:profile state)
props (get profile :props)]
(when (not (:workspace-visited props))

View File

@@ -70,22 +70,20 @@
(= (-> content last :command) :move-to))
(into [] (take (dec (count content)) content))
content)]
(st/set-content state content)))
(-> state
(st/set-content content))))
ptk/WatchEvent
(watch [it state _]
(let [page-id (:current-page-id state)
local (get state :workspace-local)
id (get local :edition)
objects (dsh/lookup-page-objects state page-id)]
objects (dsh/lookup-page-objects state page-id)
id (dm/get-in state [:workspace-local :edition])
old-content (dm/get-in state [:workspace-local :edit-path id :old-content])
shape (st/get-path state)]
;; NOTE: we proceed only if the shape is present on the
;; objects, if shape is a ephimeral drawing shape, we should
;; do nothing
(when-let [shape (get objects id)]
(when-let [old-content (dm/get-in local [:edit-path id :old-content])]
(let [new-content (get shape :content)
changes (generate-path-changes it objects page-id shape old-content new-content)]
(rx/of (dch/commit-changes changes))))))))))
(if (and (some? old-content) (some? (:id shape)))
(let [changes (generate-path-changes it objects page-id shape old-content (:content shape))]
(rx/of (dch/commit-changes changes)))
(rx/empty)))))))

View File

@@ -8,6 +8,7 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.types.path :as path]
[app.common.types.path.helpers :as path.helpers]
@@ -288,34 +289,34 @@
(declare stop-path-edit)
(defn start-path-edit
[id]
(ptk/reify ::start-path-edit
ptk/UpdateEvent
(update [_ state]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
edit-path (dm/get-in state [:workspace-local :edit-path id])
content (st/get-path state :content)
state (cond-> state
(cfh/path-shape? objects id)
(st/set-content (path/close-subpaths content)))]
(-> state
(st/set-content (path/close-subpaths (:content shape)))
(update-in [:workspace-local :edit-path id]
(fn [state]
(let [state (if state
(if (= :move (:edit-mode state))
(assoc state :edit-mode :draw)
state)
{:edit-mode :move
:selected #{}
:snap-toggled false})]
(assoc state :old-content (:content shape))))))))
(cond-> state
(or (not edit-path)
(= :draw (:edit-mode edit-path)))
(assoc-in [:workspace-local :edit-path id] {:edit-mode :move
:selected #{}
:snap-toggled false})
(and (some? edit-path)
(= :move (:edit-mode edit-path)))
(assoc-in [:workspace-local :edit-path id :edit-mode] :draw))))
ptk/WatchEvent
(watch [_ _ stream]
(let [stopper (rx/filter #(let [type (ptk/type %)]
(= type ::dwe/clear-edition-mode)
(= type ::start-path-edit))
stream)]
(let [stopper (->> stream
(rx/filter #(let [type (ptk/type %)]
(= type ::dwe/clear-edition-mode)
(= type ::start-path-edit))))]
(rx/concat
(rx/of (undo/start-path-undo))
(->> stream
@@ -324,8 +325,7 @@
(rx/map #(stop-path-edit id))
(rx/take-until stopper)))))))
(defn stop-path-edit
[id]
(defn stop-path-edit [id]
(ptk/reify ::stop-path-edit
ptk/UpdateEvent
(update [_ state]
@@ -335,12 +335,13 @@
(watch [_ _ _]
(rx/of (ptk/data-event :layout/update {:ids [id]})))))
(defn- split-segments
[id {:keys [from-p to-p t]}]
(defn split-segments
[{:keys [from-p to-p t]}]
(ptk/reify ::split-segments
ptk/UpdateEvent
(update [_ state]
(let [content (st/get-path state :content)]
(let [id (st/get-path-id state)
content (st/get-path state :content)]
(-> state
(assoc-in [:workspace-local :edit-path id :old-content] content)
(st/set-content (-> content
@@ -352,10 +353,10 @@
(rx/of (changes/save-path-content {:preserve-move-to true})))))
(defn create-node-at-position
[params]
[event]
(ptk/reify ::create-node-at-position
ptk/WatchEvent
(watch [_ state _]
(let [id (st/get-path-id state)]
(rx/of (dwsh/update-shapes [id] path/convert-to-path)
(split-segments id params))))))
(split-segments event))))))

View File

@@ -794,7 +794,6 @@
(defn update-attrs
[id attrs]
;;(prn "update-attrs" id attrs)
(ptk/reify ::update-attrs
ptk/WatchEvent
(watch [_ state _]
@@ -821,7 +820,7 @@
(when (features/active-feature? state "text-editor/v2")
(rx/of (v2-update-text-editor-styles id attrs)))
#_(when (features/active-feature? state "render-wasm/v1")
(when (features/active-feature? state "render-wasm/v1")
;; This delay is to give time for the font to be correctly rendered
;; in wasm.
(cond->> (rx/of (resize-wasm-text id))

View File

@@ -191,63 +191,59 @@
[page-id [event [old-data new-data]]]
(let [changes (:changes event)
;; cache for the get-frame-ids function
frame-id-cache (atom {})]
lookup-data-objects
(fn [data page-id]
(dm/get-in data [:pages-index page-id :objects]))
(letfn [(lookup-data-objects [data page-id]
(dm/get-in data [:pages-index page-id :objects]))
(extract-ids [{:keys [page-id type] :as change}]
(case type
:add-obj [[page-id (:id change)]]
:mod-obj [[page-id (:id change)]]
:del-obj [[page-id (:id change)]]
:mov-objects (->> (:shapes change) (map #(vector page-id %)))
[]))
extract-ids
(fn [{:keys [page-id type] :as change}]
(case type
:add-obj [[page-id (:id change)]]
:mod-obj [[page-id (:id change)]]
:del-obj [[page-id (:id change)]]
:mov-objects (->> (:shapes change) (map #(vector page-id %)))
[]))
(get-frame-ids [id]
(let [old-objects (lookup-data-objects old-data page-id)
new-objects (lookup-data-objects new-data page-id)
get-frame-ids
(fn get-frame-ids [id]
(let [old-objects (lookup-data-objects old-data page-id)
new-objects (lookup-data-objects new-data page-id)
new-shape (get new-objects id)
old-shape (get old-objects id)
new-shape (get new-objects id)
old-shape (get old-objects id)
old-frame-id (if (cfh/frame-shape? old-shape) id (:frame-id old-shape))
new-frame-id (if (cfh/frame-shape? new-shape) id (:frame-id new-shape))
old-frame-id (if (cfh/frame-shape? old-shape) id (:frame-id old-shape))
new-frame-id (if (cfh/frame-shape? new-shape) id (:frame-id new-shape))
root-frame-old? (cfh/root-frame? old-objects old-frame-id)
root-frame-new? (cfh/root-frame? new-objects new-frame-id)
instance-root? (ctc/instance-root? new-shape)]
root-frame-old? (cfh/root-frame? old-objects old-frame-id)
root-frame-new? (cfh/root-frame? new-objects new-frame-id)
instance-root? (ctc/instance-root? new-shape)]
(cond-> #{}
root-frame-old?
(conj ["frame" old-frame-id])
(cond-> #{}
root-frame-old?
(conj ["frame" old-frame-id])
root-frame-new?
(conj ["frame" new-frame-id])
root-frame-new?
(conj ["frame" new-frame-id])
instance-root?
(conj ["component" id])
instance-root?
(conj ["component" id])
(and (uuid? (:frame-id old-shape))
(not= uuid/zero (:frame-id old-shape)))
(into (get-frame-ids (:frame-id old-shape)))
(and (uuid? (:frame-id old-shape))
(not= uuid/zero (:frame-id old-shape)))
(into (get-frame-ids (:frame-id old-shape)))
(and (uuid? (:frame-id new-shape))
(not= uuid/zero (:frame-id new-shape)))
(into (get-frame-ids (:frame-id new-shape))))))
(and (uuid? (:frame-id new-shape))
(not= uuid/zero (:frame-id new-shape)))
(into (get-frame-ids (:frame-id new-shape))))))]
(get-frame-ids-cached [id]
(or (get @frame-id-cache id)
(let [result (get-frame-ids id)]
(swap! frame-id-cache assoc id result)
result)))]
(into #{}
(comp (mapcat extract-ids)
(filter (fn [[page-id']] (= page-id page-id')))
(map (fn [[_ id]] id))
(mapcat get-frame-ids-cached))
changes))))
(into #{}
(comp (mapcat extract-ids)
(filter (fn [[page-id']] (= page-id page-id')))
(map (fn [[_ id]] id))
(mapcat get-frame-ids))
changes)))
(defn watch-state-changes
"Watch the state for changes inside frames. If a change is detected will force a rendering

View File

@@ -410,14 +410,9 @@
(-> (dwm/build-modif-tree ids objects get-modifier)
(gm/set-objects-modifiers objects))]
(.log js/console (clj->js modif-tree))
(if (features/active-feature? state "render-wasm/v1")
(rx/of (dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true}))
(rx/of (dwm/apply-modifiers* objects modif-tree nil options)))
;;(rx/empty)
)))))
(rx/of (dwm/apply-modifiers* objects modif-tree nil options))))))))
(defn change-orientation
"Change orientation of shapes, from the sidebar options form.

View File

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

View File

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

View File

@@ -480,9 +480,6 @@
(def workspace-token-sets-tree
(l/derived (d/nilf ctob/get-set-tree) tokens-lib))
(def workspace-all-tokens-map
(l/derived (d/nilf ctob/get-all-tokens) tokens-lib))
(def workspace-active-theme-paths
(l/derived (d/nilf ctob/get-active-theme-paths) tokens-lib))

View File

@@ -45,7 +45,9 @@
[app.main.ui.shapes.svg-raw :as svg-raw]
[app.main.ui.shapes.text :as text]
[app.main.ui.shapes.text.fontfaces :as ff]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
[app.util.globals :as g]
[app.util.http :as http]
[app.util.strings :as ust]
[app.util.thumbnails :as th]
@@ -53,6 +55,7 @@
[beicon.v2.core :as rx]
[clojure.set :as set]
[cuerdas.core :as str]
[promesa.core :as p]
[rumext.v2 :as mf]))
(def ^:const viewbox-decimal-precision 3)
@@ -171,6 +174,8 @@
;; Don't wrap svg elements inside a <g> otherwise some can break
[:> svg-raw-wrapper {:shape shape :frame frame}]))))))
(set! wasm.api/shape-wrapper-factory shape-wrapper-factory)
(defn format-viewbox
"Format a viewbox given a rectangle"
[{:keys [x y width height] :or {x 0 y 0 width 100 height 100}}]
@@ -480,6 +485,48 @@
[:& ff/fontfaces-style {:fonts fonts}]
[:& shape-wrapper {:shape object}]]]]))
(mf/defc object-wasm
{::mf/wrap [mf/memo]}
[{:keys [objects object-id embed skip-children]
:or {embed false}
:as props}]
(let [object (get objects object-id)
object (cond-> object
(:hide-fill-on-export object)
(assoc :fills [])
skip-children
(assoc :shapes []))
{:keys [width height] :as bounds}
(gsb/get-object-bounds objects object {:ignore-margin? false})
vbox (format-viewbox bounds)
zoom 1
canvas-ref (mf/use-ref nil)]
(mf/use-effect
(fn []
(let [canvas (mf/ref-val canvas-ref)]
(->> @wasm.api/module
(p/fmap
(fn [ready?]
(when ready?
(try
(when (wasm.api/init-canvas-context canvas)
(wasm.api/initialize-viewport
objects zoom vbox "transparent"
(fn []
(wasm.api/render-sync-shape object-id)
(dom/set-attribute! canvas "id" (dm/str "screenshot-" object-id)))))
(catch :default e
(js/console.error "Error initializing canvas context:" e)
false)))))))))
[:canvas {:ref canvas-ref
:width width
:height height
:style {:background "red"}}]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SPRITES (DEBUG)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -193,15 +193,16 @@
restore-fn
(fn [_]
(st/emit! (dd/restore-files-immediately
(with-meta {:team-id current-team-id
(with-meta {:team-id (:id current-team)
:ids (into #{} d/xf:map-id files)}
{:on-success #(st/emit! (ntf/success (tr "dashboard.restore-success-notification" (:name file)))
(dd/fetch-projects current-team-id)
(dd/fetch-deleted-files current-team-id))
(dd/fetch-projects (:id current-team))
(dd/fetch-deleted-files (:id current-team)))
:on-error #(st/emit! (ntf/error (tr "dashboard.errors.error-on-restore-file" (:name file))))}))))
on-restore-immediately
(fn []
(prn files)
(st/emit!
(modal/show {:type :confirm
:title (tr "dashboard-restore-file-confirmation.title")
@@ -213,7 +214,7 @@
on-delete-immediately
(fn []
(let [accept-fn #(st/emit! (dd/delete-files-immediately
{:team-id current-team-id
{:team-id (:id current-team)
:ids (into #{} d/xf:map-id files)}))]
(st/emit!
(modal/show {:type :confirm
@@ -243,7 +244,8 @@
(for [project current-projects]
{:name (get-project-name project)
:id (get-project-id project)
:handler (on-move current-team-id (:id project))})
:handler (on-move (:id current-team)
(:id project))})
(when (seq other-teams)
[{:name (tr "dashboard.move-to-other-team")
:id "move-to-other-team"

View File

@@ -628,7 +628,6 @@
width: $sz-400;
padding: var(--sp-xxxl);
background-color: var(--color-background-primary);
z-index: var(--z-index-set);
&.hero {
top: px2rem(216);

View File

@@ -90,8 +90,7 @@
instance
(dwt/create-editor editor-node canvas-node options)
;; Store original content to compare name later
original-content content
update-name? (nil? content)
on-key-up
(fn [event]
@@ -102,22 +101,10 @@
on-blur
(fn []
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
(let [state @st/state
objects (dsh/lookup-page-objects state)
shape (get objects shape-id)
current-name (:name shape)
generated-name (gen-name instance)
;; Update name if: (1) it's a new shape (nil original content), or
;; (2) the current name matches the generated name from original content
;; (meaning it was never manually renamed)
update-name? (or (nil? original-content)
(and (some? current-name)
(some? original-content)
(= current-name (txt/generate-shape-name (txt/content->text original-content)))))]
(st/emit! (dwt/v2-update-text-shape-content shape-id content
:update-name? update-name?
:name generated-name
:finalize? true))))
(st/emit! (dwt/v2-update-text-shape-content shape-id content
:update-name? update-name?
:name (gen-name instance)
:finalize? true)))
(let [container-node (mf/ref-val container-ref)]
(dom/set-style! container-node "opacity" 0)))
@@ -151,6 +138,7 @@
(st/emit! (dw/set-clipboard-style style))))]
(.addEventListener ^js global/document "keyup" on-key-up)
(.addEventListener ^js instance "blur" on-blur)
(.addEventListener ^js instance "focus" on-focus)
(.addEventListener ^js instance "needslayout" on-needs-layout)
(.addEventListener ^js instance "stylechange" on-style-change)
@@ -165,12 +153,8 @@
;; This function is called when the component is unmounted
(fn []
;; 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)
(.removeEventListener ^js global/document "keyup" on-key-up)
(.removeEventListener ^js instance "blur" on-blur)
(.removeEventListener ^js instance "focus" on-focus)
(.removeEventListener ^js instance "needslayout" on-needs-layout)
(.removeEventListener ^js instance "stylechange" on-style-change)

View File

@@ -423,8 +423,7 @@
(reset! observer-var nil))))
;; Re-observe sentinel whenever children-count changes (sentinel moves)
;; and (shapes item) to reconnect observer after shape changes
(mf/with-effect [children-count expanded? (:shapes item)]
(mf/with-effect [children-count expanded?]
(let [total (count (:shapes item))
node (mf/ref-val ref)
scroll-node (dom/get-parent-with-data node "scroll-container")

View File

@@ -375,7 +375,7 @@
(mf/use-fn
(mf/deps on-change ids)
(fn [value attr event]
(if (or (string? value) (number? value))
(if (or (string? value) (int? value))
(on-change :simple attr value event)
(do
(let [resolved-value (:resolved-value (first value))
@@ -489,7 +489,7 @@
(mf/use-fn
(mf/deps on-change ids)
(fn [value attr event]
(if (or (string? value) (number? value))
(if (or (string? value) (int? value))
(on-change :multiple attr value event)
(do
(let [resolved-value (:resolved-value (first value))]
@@ -724,7 +724,7 @@
(mf/use-fn
(mf/deps on-change wrap-type ids)
(fn [value event attr]
(if (or (string? value) (number? value))
(if (or (string? value) (int? value))
(on-change (= "nowrap" wrap-type) attr value event)
(do
(let [resolved-value (:resolved-value (first value))]

View File

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

View File

@@ -61,7 +61,7 @@
(mf/defc page-item
{::mf/wrap-props false}
[{:keys [page index deletable? selected? editing? hovering? current-page-id]}]
[{:keys [page index deletable? selected? editing? hovering?]}]
(let [input-ref (mf/use-ref)
id (:id page)
delete-fn (mf/use-fn (mf/deps id) #(st/emit! (dw/delete-page id)))
@@ -72,10 +72,8 @@
(mf/use-fn
(mf/deps id)
(fn []
;; For the wasm renderer, apply a blur effect to the viewport canvas
;; when we navigate to a different page.
(if (and (features/active-feature? @st/state "render-wasm/v1")
(not= id current-page-id))
;; when using the wasm renderer, apply a blur effect to the viewport canvas
(if (features/active-feature? @st/state "render-wasm/v1")
(do
(wasm.api/capture-canvas-pixels)
(wasm.api/apply-canvas-blur)
@@ -205,13 +203,12 @@
(mf/defc page-item-wrapper
{::mf/wrap-props false}
[{:keys [page-id index deletable? selected? editing? current-page-id]}]
[{:keys [page-id index deletable? selected? editing?]}]
(let [page-ref (mf/with-memo [page-id]
(make-page-ref page-id))
page (mf/deref page-ref)]
[:& page-item {:page page
:index index
:current-page-id current-page-id
:deletable? deletable?
:selected? selected?
:editing? editing?}]))
@@ -234,7 +231,6 @@
:deletable? deletable?
:editing? (= page-id editing-page-id)
:selected? (= page-id current-page-id)
:current-page-id current-page-id
:key page-id}])]]))
;; --- Sitemap Toolbox

View File

@@ -53,12 +53,10 @@
(defn- resolve-value
[tokens prev-token token-name value]
[tokens prev-token value]
(let [token
{:value value
:name (if (str/blank? token-name)
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}
:name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"}
tokens
(-> tokens
@@ -133,7 +131,6 @@
(let [form (mf/use-ctx fc/context)
input-name name
token-name (get-in @form [:data :name] nil)
touched?
@@ -143,9 +140,6 @@
error
(get-in @form [:errors input-name])
extra-error
(get-in @form [:extra-errors input-name])
value
(get-in @form [:data input-name] "")
@@ -253,20 +247,15 @@
:hint-type (:type hint)})
props
(cond
(and error touched?)
(if (and error touched?)
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
(and extra-error touched?)
(mf/spread-props props {:hint-type "error"
:hint-message (:message extra-error)})
:else
props)]
(mf/with-effect [resolve-stream tokens token input-name token-name]
(mf/with-effect [resolve-stream tokens token input-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
@@ -312,7 +301,7 @@
(let [form (mf/use-ctx fc/context)
input-name name
token-name (get-in @form [:data :name] nil)
error
(get-in @form [:errors :value value-subfield index input-name])
@@ -425,10 +414,10 @@
:hint-message (:message error)})
props)]
(mf/with-effect [resolve-stream tokens token input-name index value-subfield token-name]
(mf/with-effect [resolve-stream tokens token input-name index value-subfield]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]

View File

@@ -49,12 +49,10 @@
;; validate data within the form state.
(defn- resolve-value
[tokens prev-token token-name value]
[tokens prev-token value]
(let [token
{:value (cto/split-font-family value)
:name (if (str/blank? token-name)
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}
:name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"}
tokens
(-> tokens
@@ -75,7 +73,6 @@
[{:keys [token tokens name] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
token-name (get-in @form [:data :name] nil)
touched?
(and (contains? (:data @form) input-name)
@@ -155,10 +152,10 @@
:hint-message (:message error)})
props)]
(mf/with-effect [resolve-stream tokens token input-name touched? token-name]
(mf/with-effect [resolve-stream tokens token input-name touched?]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
@@ -203,7 +200,7 @@
[{:keys [token tokens name] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
token-name (get-in @form [:data :name] nil)
error
(get-in @form [:errors :value input-name])
@@ -279,10 +276,10 @@
:hint-message (:message error)})
props)]
(mf/with-effect [resolve-stream tokens token input-name token-name]
(mf/with-effect [resolve-stream tokens token input-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]

View File

@@ -139,12 +139,10 @@
(defn- resolve-value
[tokens prev-token token-name value]
[tokens prev-token value]
(let [token
{:value value
:name (if (str/blank? token-name)
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}
:name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"}
tokens
(-> tokens
;; Remove previous token when renaming a token
@@ -165,7 +163,6 @@
(let [form (mf/use-ctx fc/context)
input-name name
token-name (get-in @form [:data :name] nil)
touched?
(and (contains? (:data @form) input-name)
@@ -209,11 +206,11 @@
:hint-message (:message error)})
props)]
(mf/with-effect [resolve-stream tokens token input-name token-name]
(mf/with-effect [resolve-stream tokens token input-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
@@ -255,7 +252,6 @@
(let [form (mf/use-ctx fc/context)
input-name name
token-name (get-in @form [:data :name] nil)
error
(get-in @form [:errors :value input-name])
@@ -302,10 +298,10 @@
(mf/spread-props props {:hint-formated true})
props)]
(mf/with-effect [resolve-stream tokens token input-name name token-name]
(mf/with-effect [resolve-stream tokens token input-name name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
@@ -369,7 +365,7 @@
(let [form (mf/use-ctx fc/context)
input-name name
token-name (get-in @form [:data :name] nil)
error
(get-in @form [:errors :value value-subfield index input-name])
@@ -414,10 +410,10 @@
(mf/spread-props props {:hint-formated true})
props)]
(mf/with-effect [resolve-stream tokens token input-name index value-subfield token-name]
(mf/with-effect [resolve-stream tokens token input-name index value-subfield]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]

View File

@@ -14,7 +14,6 @@
[app.main.constants :refer [max-input-length]]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.refs :as refs]
@@ -111,7 +110,8 @@
token-title (str/lower (:title token-properties))
tokens (mf/deref refs/workspace-all-tokens-map)
tokens
(mf/deref refs/workspace-active-theme-sets-tokens)
tokens
(mf/with-memo [tokens token]
@@ -207,11 +207,7 @@
:value (:value valid-token)
:description description}))
(dwtp/propagate-workspace-tokens)
(modal/hide)))
(fn [{:keys [errors]}]
(let [error-messages (wte/humanize-errors errors)
error-message (first error-messages)]
(swap! form assoc-in [:extra-errors :value] {:message error-message}))))))))]
(modal/hide))))))))]
[:> fc/form* {:class (stl/css :form-wrapper)
:form form

View File

@@ -282,7 +282,12 @@
(let [n (d/parse-double blur)]
(or (nil? n) (not (< n 0)))))]]]
[:spread {:optional true}
[:maybe :string]]
[:and
[:maybe :string]
[:fn {:error/fn #(tr "workspace.tokens.shadow-token-spread-value-error")}
(fn [spread]
(let [n (d/parse-double spread)]
(or (nil? n) (not (< n 0)))))]]]
[:color {:optional true} [:maybe :string]]
[:color-result {:optional true} ::sm/any]
[:inset {:optional true} [:maybe :boolean]]]]]

View File

@@ -144,7 +144,7 @@
modifiers (hooks/use-equal-memo modifiers)
shapes (hooks/use-equal-memo shapes)]
[:g.outlines.blurrable
[:g.outlines
[:& shape-outlines-render {:shapes shapes
:zoom zoom
:modifiers modifiers}]]))

View File

@@ -252,7 +252,7 @@
edition (mf/deref refs/selected-edition)
grid-edition? (ctl/grid-layout? objects edition)]
[:g.frame-titles.blurrable
[:g.frame-titles
(for [{:keys [id parent-id] :as shape} shapes]
(when (and
(not= id uuid/zero)

View File

@@ -424,7 +424,6 @@
:xmlnsXlink "http://www.w3.org/1999/xlink"
:preserveAspectRatio "xMidYMid meet"
:key (str "viewport" page-id)
:id "viewport-controls"
:view-box (utils/format-viewbox vbox)
:ref on-viewport-ref
:class (dm/str @cursor (when drawing-tool " drawing") " " (stl/css :viewport-controls))
@@ -474,7 +473,7 @@
:zoom zoom}]
(when (ctl/any-layout? outlined-frame)
[:g.ghost-outline.blurrable
[:g.ghost-outline
[:& outline/shape-outlines
{:objects base-objects
:selected selected

View File

@@ -805,8 +805,7 @@
(u/display-not-valid :resize "Plugin doesn't have 'content:write' permission")
:else
(prn "resize" width height)
#_(st/emit! (dw/update-dimensions [id] :width width)
(st/emit! (dw/update-dimensions [id] :width width)
(dw/update-dimensions [id] :height height))))
:rotate

View File

@@ -63,7 +63,7 @@
(mf/defc object-svg
{::mf/wrap-props false}
[{:keys [object-id embed skip-children]}]
[{:keys [object-id embed skip-children wasm]}]
(let [objects (mf/deref ref:objects)]
;; Set the globa CSS to assign the page size, needed for PDF
@@ -77,27 +77,42 @@
(mth/ceil height) "px")}))))
(when objects
[:& (mf/provider ctx/is-render?) {:value true}
[:& render/object-svg
{:objects objects
:object-id object-id
:embed embed
:skip-children skip-children}]])))
(if wasm
[:& render/object-wasm
{:objects objects
:object-id object-id
:embed embed
:skip-children skip-children}]
(mf/defc objects-svg
{::mf/wrap-props false}
[{:keys [object-ids embed skip-children]}]
(when-let [objects (mf/deref ref:objects)]
(for [object-id object-ids]
(let [objects (render/adapt-objects-for-shape objects object-id)]
[:& (mf/provider ctx/is-render?) {:value true}
[:& render/object-svg
{:objects objects
:key (str object-id)
:object-id object-id
:embed embed
:skip-children skip-children}]]))))
(mf/defc objects-svg
{::mf/wrap-props false}
[{:keys [object-ids embed skip-children wasm]}]
(when-let [objects (mf/deref ref:objects)]
(for [object-id object-ids]
(let [objects (render/adapt-objects-for-shape objects object-id)]
(if wasm
[:& render/object-wasm
{:objects objects
:key (str object-id)
:object-id object-id
:embed embed
:skip-children skip-children}]
[:& (mf/provider ctx/is-render?) {:value true}
[:& render/object-svg
{:objects objects
:key (str object-id)
:object-id object-id
:embed embed
:skip-children skip-children}]])))))
(defn- fetch-objects-bundle
[& {:keys [file-id page-id share-id object-id] :as options}]
(ptk/reify ::fetch-objects-bundle
@@ -136,7 +151,7 @@
(defn- render-objects
[params]
(try
(let [{:keys [file-id page-id embed share-id object-id skip-children] :as params}
(let [{:keys [file-id page-id embed share-id object-id skip-children wasm] :as params}
(coerce-render-objects-params params)]
(st/emit! (fetch-objects-bundle :file-id file-id :page-id page-id :share-id share-id :object-id object-id))
(if (uuid? object-id)
@@ -147,7 +162,8 @@
:share-id share-id
:object-id object-id
:embed embed
:skip-children skip-children}])
:skip-children skip-children
:wasm wasm}])
(mf/html
[:& objects-svg
@@ -156,7 +172,8 @@
:share-id share-id
:object-ids (into #{} object-id)
:embed embed
:skip-children skip-children}])))
:skip-children skip-children
:wasm wasm}])))
(catch :default cause
(when-let [explain (-> cause ex-data ::sm/explain)]
(js/console.log "Unexpected error")
@@ -307,6 +324,3 @@
(defn ^:dev/after-load after-load
[]
(reinit))

View File

@@ -23,12 +23,10 @@
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.refs :as refs]
[app.main.render :as render]
[app.main.store :as st]
[app.main.ui.shapes.text]
[app.main.worker :as mw]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.shapes :as shapes]
[app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl]
[app.render-wasm.deserializers :as dr]
@@ -69,24 +67,14 @@
(def ^:const DEBOUNCE_DELAY_MS 100)
(def ^:const THROTTLE_DELAY_MS 10)
;; Number of shapes to process before yielding to browser
(def ^:const SHAPES_CHUNK_SIZE 100)
;; Threshold below which we use synchronous processing (no chunking overhead)
(def ^:const ASYNC_THRESHOLD 100)
(def dpr
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
(def noop-fn
(constantly nil))
(defn- yield-to-browser
"Returns a promise that resolves after yielding to the browser's event loop.
Uses requestAnimationFrame for smooth visual updates during loading."
[]
(p/create
(fn [resolve _reject]
(js/requestAnimationFrame (fn [_] (resolve nil))))))
;;
(def shape-wrapper-factory nil)
;; Based on app.main.render/object-svg
(mf/defc object-svg
@@ -95,7 +83,7 @@
(let [objects (mf/deref refs/workspace-page-objects)
shape-wrapper
(mf/with-memo [shape]
(render/shape-wrapper-factory objects))]
(shape-wrapper-factory objects))]
[:svg {:version "1.1"
:xmlns "http://www.w3.org/2000/svg"
@@ -134,70 +122,28 @@
(aget buffer 3))
(set! wasm/internal-frame-id nil))))
(defn render-preview!
"Render a lightweight preview without tile caching.
Used during progressive loading for fast feedback."
[]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render_preview")))
(defonce pending-render (atom false))
(defonce shapes-loading? (atom false))
(defonce deferred-render? (atom false))
(defn- register-deferred-render!
[]
(reset! deferred-render? true))
(defn request-render
[_requester]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(if @shapes-loading?
(register-deferred-render!)
(when-not @pending-render
(reset! pending-render true)
(let [frame-id
(js/requestAnimationFrame
(fn [ts]
(reset! pending-render false)
(set! wasm/internal-frame-id nil)
(render ts)))]
(set! wasm/internal-frame-id frame-id))))))
(defn- begin-shapes-loading!
[]
(reset! shapes-loading? true)
(let [frame-id wasm/internal-frame-id
was-pending @pending-render]
(when frame-id
(js/cancelAnimationFrame frame-id)
(set! wasm/internal-frame-id nil))
(reset! pending-render false)
(reset! deferred-render? was-pending)))
(defn- end-shapes-loading!
[]
(let [was-loading (compare-and-set! shapes-loading? true false)]
(reset! deferred-render? false)
;; Always trigger a render after loading completes
;; This ensures shapes are displayed even if no deferred render was requested
(when was-loading
(request-render "set-objects:flush"))))
(when (and wasm/context-initialized? (not @pending-render) (not @wasm/context-lost?))
(reset! pending-render true)
(js/requestAnimationFrame
(fn [ts]
(reset! pending-render false)
(render ts)))))
(declare get-text-dimensions)
(defn update-text-rect!
[id]
(when wasm/context-initialized?
(let [dimensions (get-text-dimensions id)
page-id (:current-page-id @st/state)]
;;(prn ">update-text-rect!" id dimensions)
(mw/emit!
{:cmd :index/update-text-rect
:page-id page-id
:shape-id id
:dimensions dimensions}))))
(mw/emit!
{:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))
(defn- ensure-text-content
@@ -240,12 +186,6 @@
(defn set-shape-selrect
[selrect]
#_(when (or (> (mth/abs (:x selrect)) 10000)
(> (mth/abs (:y selrect)) 10000)
(> (:width selrect) 10000)
(> (:height selrect) 10000))
(js-debugger)
)
(h/call wasm/internal-module "_set_shape_selrect"
(dm/get-prop selrect :x1)
(dm/get-prop selrect :y1)
@@ -953,62 +893,86 @@
(defn set-object
[shape]
(perf/begin-measure "set-object")
(let [shape (svg-filters/apply-svg-derived shape)
id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
(when shape
(let [shape (svg-filters/apply-svg-derived shape)
id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
masked (get shape :masked-group)
parent-id (get shape :parent-id)
masked (get shape :masked-group)
selrect (get shape :selrect)
constraint-h (get shape :constraints-h)
constraint-v (get shape :constraints-v)
clip-content (if (= type :frame)
(not (get shape :show-content))
false)
rotation (get shape :rotation)
transform (get shape :transform)
fills (get shape :fills)
strokes (if (= type :group)
[] (get shape :strokes))
children (get shape :shapes)
content (let [content (get shape :content)]
(if (= type :text)
(ensure-text-content content)
content))
bool-type (get shape :bool-type)
grow-type (get shape :grow-type)
blur (get shape :blur)
svg-attrs (get shape :svg-attrs)
shadows (get shape :shadow)]
fills (get shape :fills)
strokes (if (= type :group)
[] (get shape :strokes))
children (get shape :shapes)
blend-mode (get shape :blend-mode)
opacity (get shape :opacity)
hidden (get shape :hidden)
content (let [content (get shape :content)]
(if (= type :text)
(ensure-text-content content)
content))
bool-type (get shape :bool-type)
grow-type (get shape :grow-type)
blur (get shape :blur)
svg-attrs (get shape :svg-attrs)
shadows (get shape :shadow)
corners (map #(get shape %) [:r1 :r2 :r3 :r4])]
(shapes/set-shape-base-props shape)
(use-shape id)
(set-parent-id parent-id)
(set-shape-type type)
(set-shape-clip-content clip-content)
(set-shape-constraints constraint-h constraint-v)
;; Remaining properties that need separate calls (variable-length or conditional)
(set-shape-children children)
(set-shape-blur blur)
(when (= type :group)
(set-masked (boolean masked)))
(when (= type :bool)
(set-shape-bool-type bool-type))
(when (and (some? content)
(or (= type :path)
(= type :bool)))
(set-shape-path-content content))
(when (some? svg-attrs)
(set-shape-svg-attrs svg-attrs))
(when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape)))
(set-shape-shadows shadows)
(when (= type :text)
(set-shape-grow-type grow-type))
(set-shape-rotation rotation)
(set-shape-transform transform)
(set-shape-blend-mode blend-mode)
(set-shape-opacity opacity)
(set-shape-hidden hidden)
(set-shape-children children)
(set-shape-corners corners)
(set-shape-blur blur)
(when (= type :group)
(set-masked (boolean masked)))
(when (= type :bool)
(set-shape-bool-type bool-type))
(when (and (some? content)
(or (= type :path)
(= type :bool)))
(set-shape-path-content content))
(when (some? svg-attrs)
(set-shape-svg-attrs svg-attrs))
(when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape)))
(set-shape-shadows shadows)
(when (= type :text)
(set-shape-grow-type grow-type))
(set-shape-layout shape)
(set-layout-data shape)
(set-shape-layout shape)
(set-layout-data shape)
(set-shape-selrect selrect)
(let [pending_thumbnails (into [] (concat
(set-shape-text-content id content)
(set-shape-text-images id content true)
(set-shape-fills id fills true)
(set-shape-strokes id strokes true)))
pending_full (into [] (concat
(set-shape-text-images id content false)
(set-shape-fills id fills false)
(set-shape-strokes id strokes false)))]
(perf/end-measure "set-object")
{:thumbnails pending_thumbnails
:full pending_full})))
(let [pending_thumbnails (into [] (concat
(set-shape-text-content id content)
(set-shape-text-images id content true)
(set-shape-fills id fills true)
(set-shape-strokes id strokes true)))
pending_full (into [] (concat
(set-shape-text-images id content false)
(set-shape-fills id fills false)
(set-shape-strokes id strokes false)))]
(perf/end-measure "set-object")
{:thumbnails pending_thumbnails
:full pending_full}))))
(defn update-text-layouts
[shapes]
@@ -1051,143 +1015,30 @@
(let [{:keys [thumbnails full]} (set-object shape)]
(process-pending [shape] thumbnails full noop-fn)))
(defn- process-shapes-chunk
"Process a chunk of shapes synchronously, returning accumulated pending operations.
Returns {:thumbnails [...] :full [...] :next-index n}"
[shapes start-index chunk-size thumbnails-acc full-acc]
(let [total (count shapes)
end-index (min total (+ start-index chunk-size))]
(loop [index start-index
t-acc thumbnails-acc
f-acc full-acc]
(if (< index end-index)
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(into t-acc thumbnails)
(into f-acc full)))
{:thumbnails t-acc
:full f-acc
:next-index end-index}))))
(defn- set-objects-async
"Asynchronously process shapes in chunks, yielding to the browser between chunks.
Returns a promise that resolves when all shapes are processed.
Renders a preview only periodically during loading to show progress,
then does a full tile-based render at the end."
[shapes render-callback]
(let [total-shapes (count shapes)
total-chunks (mth/ceil (/ total-shapes SHAPES_CHUNK_SIZE))
;; Render at 25%, 50%, 75% of loading
render-at-chunks (set [(mth/floor (* total-chunks 0.25))
(mth/floor (* total-chunks 0.5))
(mth/floor (* total-chunks 0.75))])]
(p/create
(fn [resolve _reject]
(letfn [(process-next-chunk [index thumbnails-acc full-acc chunk-count]
(if (< index total-shapes)
;; Process one chunk
(let [{:keys [thumbnails full next-index]}
(process-shapes-chunk shapes index SHAPES_CHUNK_SIZE
thumbnails-acc full-acc)
new-chunk-count (inc chunk-count)]
;; Only render at specific progress milestones
(when (contains? render-at-chunks new-chunk-count)
(render-preview!))
;; Yield to browser, then continue with next chunk
(-> (yield-to-browser)
(p/then (fn [_]
(process-next-chunk next-index thumbnails full new-chunk-count)))))
;; All chunks done - finalize
(do
(perf/end-measure "set-objects")
(process-pending shapes thumbnails-acc full-acc noop-fn
(fn []
(end-shapes-loading!)
(if render-callback
(render-callback)
(render-finish))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))
(resolve nil))))))]
(process-next-chunk 0 [] [] 0))))))
(defn- set-objects-sync
"Synchronously process all shapes (for small shape counts)."
[shapes render-callback]
(let [total-shapes (count shapes)
{:keys [thumbnails full]}
(loop [index 0 thumbnails-acc [] full-acc []]
(if (< index total-shapes)
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(into thumbnails-acc thumbnails)
(into full-acc full)))
{:thumbnails thumbnails-acc :full full-acc}))]
(perf/end-measure "set-objects")
(process-pending shapes thumbnails full noop-fn
(fn []
(if render-callback
(render-callback)
(render-finish))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))
(defn- shapes-in-tree-order
"Returns shapes sorted in tree order (parents before children).
This ensures parent shapes are processed before their children,
maintaining proper shape reference consistency in WASM."
[objects]
;; Get IDs in tree order starting from root (uuid/zero)
;; If root doesn't exist (e.g., filtered thumbnail data), fall back to
;; finding top-level shapes (those without a parent in objects) and
;; traversing from there.
(if (contains? objects uuid/zero)
;; Normal case: traverse from root
(let [ordered-ids (cfh/get-children-ids-with-self objects uuid/zero)]
(into []
(keep #(get objects %))
ordered-ids))
;; Fallback for filtered data (thumbnails): find top-level shapes and traverse
(let [;; Find shapes whose parent is not in the objects map (top-level in this subset)
top-level-ids (->> (vals objects)
(filter (fn [shape]
(not (contains? objects (:parent-id shape)))))
(map :id))
;; Get all children in order for each top-level shape
all-ordered-ids (into []
(mapcat #(cfh/get-children-ids-with-self objects %))
top-level-ids)]
(into []
(keep #(get objects %))
all-ordered-ids))))
(defn set-objects
"Set all shape objects for rendering.
Shapes are processed in tree order (parents before children)
to maintain proper shape reference consistency in WASM."
([objects]
(set-objects objects nil))
([objects render-callback]
(perf/begin-measure "set-objects")
(let [shapes (shapes-in-tree-order objects)
total-shapes (count shapes)]
(if (< total-shapes ASYNC_THRESHOLD)
(set-objects-sync shapes render-callback)
(do
(begin-shapes-loading!)
(try
(-> (set-objects-async shapes render-callback)
(p/catch (fn [error]
(end-shapes-loading!)
(js/console.error "Async WASM shape loading failed" error))))
(catch :default error
(end-shapes-loading!)
(js/console.error "Async WASM shape loading failed" error)
(throw error)))
nil)))))
(let [shapes (into [] (vals objects))
total-shapes (count shapes)
;; Collect pending operations - set-object returns {:thumbnails [...] :full [...]}
{:keys [thumbnails full]}
(loop [index 0 thumbnails-acc [] full-acc []]
(if (< index total-shapes)
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(into thumbnails-acc thumbnails)
(into full-acc full)))
{:thumbnails thumbnails-acc :full full-acc}))]
(perf/end-measure "set-objects")
(process-pending shapes thumbnails full noop-fn
(fn []
(if render-callback
(render-callback)
(render-finish))
(ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
(defn clear-focus-mode
[]
@@ -1581,10 +1432,38 @@
(defn apply-canvas-blur
[]
(when wasm/canvas (dom/set-style! wasm/canvas "filter" "blur(4px)"))
(let [controls-to-blur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")]
(run! #(dom/set-style! % "filter" "blur(4px)") controls-to-blur)))
(when wasm/canvas
(dom/set-style! wasm/canvas "filter" "blur(4px)")))
(defn render-shape-pixels
[shape-id scale]
(let [buffer (uuid/get-u32 shape-id)
offset
(h/call wasm/internal-module "_render_shape_pixels"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
scale)
offset-32
(mem/->offset-32 offset)
heap (mem/get-heap-u8)
heapu32 (mem/get-heap-u32)
length (aget heapu32 (mem/->offset-32 offset))
width (aget heapu32 (+ (mem/->offset-32 offset) 1))
height (aget heapu32 (+ (mem/->offset-32 offset) 2))
result
(dr/read-image-bytes heap (+ offset 12) length)
]
(mem/free)
result
))
(defn init-wasm-module
[module]

View File

@@ -124,25 +124,19 @@
true))
(def fetching (atom #{}))
(defn- fetch-font
[shape-id font-data font-url emoji? fallback?]
(when-not (contains? @fetching font-url)
(swap! fetching conj font-url)
{:key font-url
:callback #(->> (http/send! {:method :get
:uri font-url
:response-type :buffer})
(rx/map (fn [{:keys [body]}]
(swap! fetching disj font-url)
(store-font-buffer shape-id font-data body emoji? fallback?)))
(rx/catch (fn [cause]
(swap! fetching disj font-url)
(log/error :hint "Could not fetch font"
:font-url font-url
:cause cause)
(rx/empty))))}))
{:key font-url
:callback #(->> (http/send! {:method :get
:uri font-url
:response-type :buffer})
(rx/map (fn [{:keys [body]}]
(store-font-buffer shape-id font-data body emoji? fallback?)))
(rx/catch (fn [cause]
(log/error :hint "Could not fetch font"
:font-url font-url
:cause cause)
(rx/empty))))})
(defn- google-font-ttf-url
[font-id font-variant-id font-weight font-style]

View File

@@ -1,193 +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.api.shapes
"Batched shape property serialization for improved WASM performance.
This module provides a single WASM call to set all base shape properties,
replacing multiple individual calls (use_shape, set_parent, set_shape_type,
etc.) with one batched operation."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.wasm :as wasm]))
;; Binary layout constants matching Rust implementation:
;;
;; | Offset | Size | Field | Type |
;; |--------|------|--------------|-----------------------------------|
;; | 0 | 16 | id | UUID (4 × u32 LE) |
;; | 16 | 16 | parent_id | UUID (4 × u32 LE) |
;; | 32 | 1 | shape_type | u8 |
;; | 33 | 1 | flags | u8 (bit0: clip, bit1: hidden) |
;; | 34 | 1 | blend_mode | u8 |
;; | 35 | 1 | constraint_h | u8 (0xFF = None) |
;; | 36 | 1 | constraint_v | u8 (0xFF = None) |
;; | 37 | 3 | padding | - |
;; | 40 | 4 | opacity | f32 LE |
;; | 44 | 4 | rotation | f32 LE |
;; | 48 | 24 | transform | 6 × f32 LE (a,b,c,d,e,f) |
;; | 72 | 16 | selrect | 4 × f32 LE (x1,y1,x2,y2) |
;; | 88 | 16 | corners | 4 × f32 LE (r1,r2,r3,r4) |
;; |--------|------|--------------|-----------------------------------|
;; | Total | 104 | | |
(def ^:const BASE-PROPS-SIZE 104)
(def ^:const FLAG-CLIP-CONTENT 0x01)
(def ^:const FLAG-HIDDEN 0x02)
(def ^:const CONSTRAINT-NONE 0xFF)
(defn- write-uuid-to-heap
"Write a UUID to the heap at the given byte offset using DataView."
[dview offset id]
(let [buffer (uuid/get-u32 id)]
(.setUint32 dview offset (aget buffer 0) true)
(.setUint32 dview (+ offset 4) (aget buffer 1) true)
(.setUint32 dview (+ offset 8) (aget buffer 2) true)
(.setUint32 dview (+ offset 12) (aget buffer 3) true)))
(defn- serialize-transform
"Extract transform matrix values, defaulting to identity matrix."
[transform]
(if (some? transform)
[(dm/get-prop transform :a)
(dm/get-prop transform :b)
(dm/get-prop transform :c)
(dm/get-prop transform :d)
(dm/get-prop transform :e)
(dm/get-prop transform :f)]
[1.0 0.0 0.0 1.0 0.0 0.0])) ; identity matrix
(defn- serialize-selrect
"Extract selrect values."
[selrect]
(if (some? selrect)
[(dm/get-prop selrect :x1)
(dm/get-prop selrect :y1)
(dm/get-prop selrect :x2)
(dm/get-prop selrect :y2)]
[0.0 0.0 0.0 0.0]))
(defn set-shape-base-props
"Set all base shape properties in a single WASM call.
This replaces the following individual calls:
- use-shape
- set-parent-id
- set-shape-type
- set-shape-clip-content
- set-shape-rotation
- set-shape-transform
- set-shape-blend-mode
- set-shape-opacity
- set-shape-hidden
- set-shape-selrect
- set-shape-corners
- set-shape-constraints (clear + h + v)
Returns nil."
[shape]
(when wasm/context-initialized?
(let [id (dm/get-prop shape :id)
parent-id (get shape :parent-id)
shape-type (dm/get-prop shape :type)
clip-content (if (= shape-type :frame)
(not (get shape :show-content))
false)
hidden (get shape :hidden false)
flags (cond-> 0
clip-content (bit-or FLAG-CLIP-CONTENT)
hidden (bit-or FLAG-HIDDEN))
blend-mode (sr/translate-blend-mode (get shape :blend-mode))
constraint-h (let [c (get shape :constraints-h)]
(if (some? c)
(sr/translate-constraint-h c)
CONSTRAINT-NONE))
constraint-v (let [c (get shape :constraints-v)]
(if (some? c)
(sr/translate-constraint-v c)
CONSTRAINT-NONE))
opacity (d/nilv (get shape :opacity) 1.0)
rotation (d/nilv (get shape :rotation) 0.0)
;; Transform matrix
[ta tb tc td te tf] (serialize-transform (get shape :transform))
;; Selrect
selrect (get shape :selrect)
[sx1 sy1 sx2 sy2] (serialize-selrect selrect)
;; Corners
r1 (d/nilv (get shape :r1) 0.0)
r2 (d/nilv (get shape :r2) 0.0)
r3 (d/nilv (get shape :r3) 0.0)
r4 (d/nilv (get shape :r4) 0.0)
;; Allocate buffer and get DataView
offset (mem/alloc BASE-PROPS-SIZE)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
;; Write id (offset 0, 16 bytes)
(write-uuid-to-heap dview offset id)
;; Write parent_id (offset 16, 16 bytes)
(write-uuid-to-heap dview (+ offset 16) (d/nilv parent-id uuid/zero))
;; Write shape_type (offset 32, 1 byte)
(.setUint8 dview (+ offset 32) (sr/translate-shape-type shape-type))
;; Write flags (offset 33, 1 byte)
(.setUint8 dview (+ offset 33) flags)
;; Write blend_mode (offset 34, 1 byte)
(.setUint8 dview (+ offset 34) blend-mode)
;; Write constraint_h (offset 35, 1 byte)
(.setUint8 dview (+ offset 35) constraint-h)
;; Write constraint_v (offset 36, 1 byte)
(.setUint8 dview (+ offset 36) constraint-v)
;; Padding at offset 37-39 (already zero from alloc)
;; Write opacity (offset 40, f32)
(.setFloat32 dview (+ offset 40) opacity true)
;; Write rotation (offset 44, f32)
(.setFloat32 dview (+ offset 44) rotation true)
;; Write transform matrix (offset 48, 6 × f32)
(.setFloat32 dview (+ offset 48) ta true)
(.setFloat32 dview (+ offset 52) tb true)
(.setFloat32 dview (+ offset 56) tc true)
(.setFloat32 dview (+ offset 60) td true)
(.setFloat32 dview (+ offset 64) te true)
(.setFloat32 dview (+ offset 68) tf true)
;; Write selrect (offset 72, 4 × f32)
(.setFloat32 dview (+ offset 72) sx1 true)
(.setFloat32 dview (+ offset 76) sy1 true)
(.setFloat32 dview (+ offset 80) sx2 true)
(.setFloat32 dview (+ offset 84) sy2 true)
;; Write corners (offset 88, 4 × f32)
(.setFloat32 dview (+ offset 88) r1 true)
(.setFloat32 dview (+ offset 92) r2 true)
(.setFloat32 dview (+ offset 96) r3 true)
(.setFloat32 dview (+ offset 100) r4 true)
(h/call wasm/internal-module "_set_shape_base_props")
nil)))

View File

@@ -151,8 +151,6 @@ void main() {
(.clear ^js context (.-DEPTH_BUFFER_BIT ^js context))
(.clear ^js context (.-STENCIL_BUFFER_BIT ^js context)))
(dom/set-style! wasm/canvas "filter" "none")
(let [controls-to-unblur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")]
(run! #(dom/set-style! % "filter" "none") controls-to-unblur))
(set! wasm/canvas-pixels nil)))
(defn capture-canvas-pixels

View File

@@ -45,6 +45,12 @@
:center (gpt/point cx cy)
:transform (gmt/matrix a b c d e f)}))
(defn read-image-bytes
[heap offset length]
(.slice ^js heap offset (+ offset length))
)
(defn read-position-data-entry
[heapu32 heapf32 offset]
(let [paragraph (aget heapu32 (+ offset 0))

View File

@@ -802,10 +802,9 @@
([uri name]
(open-new-window uri name "noopener,noreferrer"))
([uri name features]
(when-let [new-window (.open js/window (str uri) name features)]
(let [new-window (.open js/window (str uri) name features)]
(when (not= name "_blank")
(when-let [location (.-location new-window)]
(.reload location))))))
(.reload (.-location new-window))))))
(defn browser-back
[]

View File

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

View File

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

View File

@@ -6,6 +6,8 @@
(ns debug
(:require
[app.render-wasm.wasm :as wasm]
[app.render-wasm.api :as wasm.api]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.repair :as cfr]
@@ -456,3 +458,21 @@
(defn ^:export network-averages
[]
(.log js/console (clj->js @http/network-averages)))
(defn ^:export export-image
[]
(let [objects (dsh/lookup-page-objects @st/state)
shape-id (->> (get-selected @st/state) first)
bytes (wasm.api/render-shape-pixels shape-id 3.0)
blob (js/Blob. #js [bytes] #js {:type "image/png"})
url (.createObjectURL js/URL blob)
a (.createElement js/document "a")]
(set! (.-href a) url)
(set! (.-download a) "export.png")
(.click a)
(.revokeObjectURL js/URL url)
nil))

View File

@@ -20,7 +20,6 @@
"@vitest/browser": "^1.6.0",
"@vitest/coverage-v8": "^1.6.0",
"@vitest/ui": "^1.6.0",
"canvas": "^3.2.1",
"esbuild": "^0.24.0",
"jsdom": "^25.0.0",
"playwright": "^1.45.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -515,7 +515,6 @@ __metadata:
"@vitest/browser": "npm:^1.6.0"
"@vitest/coverage-v8": "npm:^1.6.0"
"@vitest/ui": "npm:^1.6.0"
canvas: "npm:^3.2.1"
esbuild: "npm:^0.24.0"
jsdom: "npm:^25.0.0"
playwright: "npm:^1.45.1"
@@ -903,24 +902,6 @@ __metadata:
languageName: node
linkType: hard
"base64-js@npm:^1.3.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf
languageName: node
linkType: hard
"bl@npm:^4.0.3":
version: 4.1.0
resolution: "bl@npm:4.1.0"
dependencies:
buffer: "npm:^5.5.0"
inherits: "npm:^2.0.4"
readable-stream: "npm:^3.4.0"
checksum: 10c0/02847e1d2cb089c9dc6958add42e3cdeaf07d13f575973963335ac0fdece563a50ac770ac4c8fa06492d2dd276f6cc3b7f08c7cd9c7a7ad0f8d388b2a28def5f
languageName: node
linkType: hard
"brace-expansion@npm:^1.1.7":
version: 1.1.11
resolution: "brace-expansion@npm:1.1.11"
@@ -949,16 +930,6 @@ __metadata:
languageName: node
linkType: hard
"buffer@npm:^5.5.0":
version: 5.7.1
resolution: "buffer@npm:5.7.1"
dependencies:
base64-js: "npm:^1.3.1"
ieee754: "npm:^1.1.13"
checksum: 10c0/27cac81cff434ed2876058d72e7c4789d11ff1120ef32c9de48f59eab58179b66710c488987d295ae89a228f835fc66d088652dffeb8e3ba8659f80eb091d55e
languageName: node
linkType: hard
"cac@npm:^6.7.14":
version: 6.7.14
resolution: "cac@npm:6.7.14"
@@ -986,17 +957,6 @@ __metadata:
languageName: node
linkType: hard
"canvas@npm:^3.2.1":
version: 3.2.1
resolution: "canvas@npm:3.2.1"
dependencies:
node-addon-api: "npm:^7.0.0"
node-gyp: "npm:latest"
prebuild-install: "npm:^7.1.3"
checksum: 10c0/c0fd572a8b28e075b40a42b523bdf05e985feaeb18b56085432bfb91a3b905af48f89ec73ed4e795de892cb13f7332ceb0c78cf84c64281c41c29995665b89c8
languageName: node
linkType: hard
"chai@npm:^4.3.10":
version: 4.4.1
resolution: "chai@npm:4.4.1"
@@ -1021,13 +981,6 @@ __metadata:
languageName: node
linkType: hard
"chownr@npm:^1.1.1":
version: 1.1.4
resolution: "chownr@npm:1.1.4"
checksum: 10c0/ed57952a84cc0c802af900cf7136de643d3aba2eecb59d29344bc2f3f9bf703a301b9d84cdc71f82c3ffc9ccde831b0d92f5b45f91727d6c9da62f23aef9d9db
languageName: node
linkType: hard
"chownr@npm:^2.0.0":
version: 2.0.0
resolution: "chownr@npm:2.0.0"
@@ -1130,15 +1083,6 @@ __metadata:
languageName: node
linkType: hard
"decompress-response@npm:^6.0.0":
version: 6.0.0
resolution: "decompress-response@npm:6.0.0"
dependencies:
mimic-response: "npm:^3.1.0"
checksum: 10c0/bd89d23141b96d80577e70c54fb226b2f40e74a6817652b80a116d7befb8758261ad073a8895648a29cc0a5947021ab66705cb542fa9c143c82022b27c5b175e
languageName: node
linkType: hard
"deep-eql@npm:^4.1.3":
version: 4.1.4
resolution: "deep-eql@npm:4.1.4"
@@ -1148,13 +1092,6 @@ __metadata:
languageName: node
linkType: hard
"deep-extend@npm:^0.6.0":
version: 0.6.0
resolution: "deep-extend@npm:0.6.0"
checksum: 10c0/1c6b0abcdb901e13a44c7d699116d3d4279fdb261983122a3783e7273844d5f2537dc2e1c454a23fcf645917f93fbf8d07101c1d03c015a87faa662755212566
languageName: node
linkType: hard
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"
@@ -1162,13 +1099,6 @@ __metadata:
languageName: node
linkType: hard
"detect-libc@npm:^2.0.0":
version: 2.1.2
resolution: "detect-libc@npm:2.1.2"
checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4
languageName: node
linkType: hard
"diff-sequences@npm:^29.6.3":
version: 29.6.3
resolution: "diff-sequences@npm:29.6.3"
@@ -1206,15 +1136,6 @@ __metadata:
languageName: node
linkType: hard
"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1":
version: 1.4.5
resolution: "end-of-stream@npm:1.4.5"
dependencies:
once: "npm:^1.4.0"
checksum: 10c0/b0701c92a10b89afb1cb45bf54a5292c6f008d744eb4382fa559d54775ff31617d1d7bc3ef617575f552e24fad2c7c1a1835948c66b3f3a4be0a6c1f35c883d8
languageName: node
linkType: hard
"entities@npm:^4.4.0":
version: 4.5.0
resolution: "entities@npm:4.5.0"
@@ -1425,13 +1346,6 @@ __metadata:
languageName: node
linkType: hard
"expand-template@npm:^2.0.3":
version: 2.0.3
resolution: "expand-template@npm:2.0.3"
checksum: 10c0/1c9e7afe9acadf9d373301d27f6a47b34e89b3391b1ef38b7471d381812537ef2457e620ae7f819d2642ce9c43b189b3583813ec395e2938319abe356a9b2f51
languageName: node
linkType: hard
"exponential-backoff@npm:^3.1.1":
version: 3.1.1
resolution: "exponential-backoff@npm:3.1.1"
@@ -1505,13 +1419,6 @@ __metadata:
languageName: node
linkType: hard
"fs-constants@npm:^1.0.0":
version: 1.0.0
resolution: "fs-constants@npm:1.0.0"
checksum: 10c0/a0cde99085f0872f4d244e83e03a46aa387b74f5a5af750896c6b05e9077fac00e9932fdf5aef84f2f16634cd473c63037d7a512576da7d5c2b9163d1909f3a8
languageName: node
linkType: hard
"fs-minipass@npm:^2.0.0":
version: 2.1.0
resolution: "fs-minipass@npm:2.1.0"
@@ -1589,13 +1496,6 @@ __metadata:
languageName: node
linkType: hard
"github-from-package@npm:0.0.0":
version: 0.0.0
resolution: "github-from-package@npm:0.0.0"
checksum: 10c0/737ee3f52d0a27e26332cde85b533c21fcdc0b09fb716c3f8e522cfaa9c600d4a631dec9fcde179ec9d47cca89017b7848ed4d6ae6b6b78f936c06825b1fcc12
languageName: node
linkType: hard
"glob-parent@npm:^5.1.2":
version: 5.1.2
resolution: "glob-parent@npm:5.1.2"
@@ -1708,13 +1608,6 @@ __metadata:
languageName: node
linkType: hard
"ieee754@npm:^1.1.13":
version: 1.2.1
resolution: "ieee754@npm:1.2.1"
checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb
languageName: node
linkType: hard
"imurmurhash@npm:^0.1.4":
version: 0.1.4
resolution: "imurmurhash@npm:0.1.4"
@@ -1739,20 +1632,13 @@ __metadata:
languageName: node
linkType: hard
"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:^2.0.4":
"inherits@npm:2":
version: 2.0.4
resolution: "inherits@npm:2.0.4"
checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2
languageName: node
linkType: hard
"ini@npm:~1.3.0":
version: 1.3.8
resolution: "ini@npm:1.3.8"
checksum: 10c0/ec93838d2328b619532e4f1ff05df7909760b6f66d9c9e2ded11e5c1897d6f2f9980c54dd638f88654b00919ce31e827040631eab0a3969e4d1abefa0719516a
languageName: node
linkType: hard
"ip-address@npm:^9.0.5":
version: 9.0.5
resolution: "ip-address@npm:9.0.5"
@@ -2050,13 +1936,6 @@ __metadata:
languageName: node
linkType: hard
"mimic-response@npm:^3.1.0":
version: 3.1.0
resolution: "mimic-response@npm:3.1.0"
checksum: 10c0/0d6f07ce6e03e9e4445bee655202153bdb8a98d67ee8dc965ac140900d7a2688343e6b4c9a72cfc9ef2f7944dfd76eef4ab2482eb7b293a68b84916bac735362
languageName: node
linkType: hard
"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1":
version: 3.1.2
resolution: "minimatch@npm:3.1.2"
@@ -2075,13 +1954,6 @@ __metadata:
languageName: node
linkType: hard
"minimist@npm:^1.2.0, minimist@npm:^1.2.3":
version: 1.2.8
resolution: "minimist@npm:1.2.8"
checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6
languageName: node
linkType: hard
"minipass-collect@npm:^2.0.1":
version: 2.0.1
resolution: "minipass-collect@npm:2.0.1"
@@ -2166,13 +2038,6 @@ __metadata:
languageName: node
linkType: hard
"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3":
version: 0.5.3
resolution: "mkdirp-classic@npm:0.5.3"
checksum: 10c0/95371d831d196960ddc3833cc6907e6b8f67ac5501a6582f47dfae5eb0f092e9f8ce88e0d83afcae95d6e2b61a01741ba03714eeafb6f7a6e9dcc158ac85b168
languageName: node
linkType: hard
"mkdirp@npm:^1.0.3":
version: 1.0.4
resolution: "mkdirp@npm:1.0.4"
@@ -2217,13 +2082,6 @@ __metadata:
languageName: node
linkType: hard
"napi-build-utils@npm:^2.0.0":
version: 2.0.0
resolution: "napi-build-utils@npm:2.0.0"
checksum: 10c0/5833aaeb5cc5c173da47a102efa4680a95842c13e0d9cc70428bd3ee8d96bb2172f8860d2811799b5daa5cbeda779933601492a2028a6a5351c6d0fcf6de83db
languageName: node
linkType: hard
"negotiator@npm:^0.6.3":
version: 0.6.3
resolution: "negotiator@npm:0.6.3"
@@ -2231,24 +2089,6 @@ __metadata:
languageName: node
linkType: hard
"node-abi@npm:^3.3.0":
version: 3.87.0
resolution: "node-abi@npm:3.87.0"
dependencies:
semver: "npm:^7.3.5"
checksum: 10c0/41cfc361edd1b0711d412ca9e1a475180c5b897868bd5583df7ff73e30e6044cc7de307df36c2257203320f17fadf7e82dfdf5a9f6fd510a8578e3fe3ed67ebb
languageName: node
linkType: hard
"node-addon-api@npm:^7.0.0":
version: 7.1.1
resolution: "node-addon-api@npm:7.1.1"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/fb32a206276d608037fa1bcd7e9921e177fe992fc610d098aa3128baca3c0050fc1e014fa007e9b3874cf865ddb4f5bd9f43ccb7cbbbe4efaff6a83e920b17e9
languageName: node
linkType: hard
"node-gyp@npm:latest":
version: 10.1.0
resolution: "node-gyp@npm:10.1.0"
@@ -2296,7 +2136,7 @@ __metadata:
languageName: node
linkType: hard
"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0":
"once@npm:^1.3.0":
version: 1.4.0
resolution: "once@npm:1.4.0"
dependencies:
@@ -2453,28 +2293,6 @@ __metadata:
languageName: node
linkType: hard
"prebuild-install@npm:^7.1.3":
version: 7.1.3
resolution: "prebuild-install@npm:7.1.3"
dependencies:
detect-libc: "npm:^2.0.0"
expand-template: "npm:^2.0.3"
github-from-package: "npm:0.0.0"
minimist: "npm:^1.2.3"
mkdirp-classic: "npm:^0.5.3"
napi-build-utils: "npm:^2.0.0"
node-abi: "npm:^3.3.0"
pump: "npm:^3.0.0"
rc: "npm:^1.2.7"
simple-get: "npm:^4.0.0"
tar-fs: "npm:^2.0.0"
tunnel-agent: "npm:^0.6.0"
bin:
prebuild-install: bin.js
checksum: 10c0/25919a42b52734606a4036ab492d37cfe8b601273d8dfb1fa3c84e141a0a475e7bad3ab848c741d2f810cef892fcf6059b8c7fe5b29f98d30e0c29ad009bedff
languageName: node
linkType: hard
"prettier@npm:^3.3.3":
version: 3.3.3
resolution: "prettier@npm:3.3.3"
@@ -2526,16 +2344,6 @@ __metadata:
languageName: node
linkType: hard
"pump@npm:^3.0.0":
version: 3.0.3
resolution: "pump@npm:3.0.3"
dependencies:
end-of-stream: "npm:^1.1.0"
once: "npm:^1.3.1"
checksum: 10c0/ada5cdf1d813065bbc99aa2c393b8f6beee73b5de2890a8754c9f488d7323ffd2ca5f5a0943b48934e3fcbd97637d0337369c3c631aeb9614915db629f1c75c9
languageName: node
linkType: hard
"punycode@npm:^2.1.1, punycode@npm:^2.3.1":
version: 2.3.1
resolution: "punycode@npm:2.3.1"
@@ -2557,20 +2365,6 @@ __metadata:
languageName: node
linkType: hard
"rc@npm:^1.2.7":
version: 1.2.8
resolution: "rc@npm:1.2.8"
dependencies:
deep-extend: "npm:^0.6.0"
ini: "npm:~1.3.0"
minimist: "npm:^1.2.0"
strip-json-comments: "npm:~2.0.1"
bin:
rc: ./cli.js
checksum: 10c0/24a07653150f0d9ac7168e52943cc3cb4b7a22c0e43c7dff3219977c2fdca5a2760a304a029c20811a0e79d351f57d46c9bde216193a0f73978496afc2b85b15
languageName: node
linkType: hard
"react-is@npm:^18.0.0":
version: 18.3.1
resolution: "react-is@npm:18.3.1"
@@ -2578,17 +2372,6 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
inherits: "npm:^2.0.3"
string_decoder: "npm:^1.1.1"
util-deprecate: "npm:^1.0.1"
checksum: 10c0/e37be5c79c376fdd088a45fa31ea2e423e5d48854be7a22a58869b4e84d25047b193f6acb54f1012331e1bcd667ffb569c01b99d36b0bd59658fb33f513511b7
languageName: node
linkType: hard
"requires-port@npm:^1.0.0":
version: 1.0.0
resolution: "requires-port@npm:1.0.0"
@@ -2696,13 +2479,6 @@ __metadata:
languageName: node
linkType: hard
"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0":
version: 5.2.1
resolution: "safe-buffer@npm:5.2.1"
checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3
languageName: node
linkType: hard
"safer-buffer@npm:>= 2.1.2 < 3.0.0":
version: 2.1.2
resolution: "safer-buffer@npm:2.1.2"
@@ -2758,24 +2534,6 @@ __metadata:
languageName: node
linkType: hard
"simple-concat@npm:^1.0.0":
version: 1.0.1
resolution: "simple-concat@npm:1.0.1"
checksum: 10c0/62f7508e674414008910b5397c1811941d457dfa0db4fd5aa7fa0409eb02c3609608dfcd7508cace75b3a0bf67a2a77990711e32cd213d2c76f4fd12ee86d776
languageName: node
linkType: hard
"simple-get@npm:^4.0.0":
version: 4.0.1
resolution: "simple-get@npm:4.0.1"
dependencies:
decompress-response: "npm:^6.0.0"
once: "npm:^1.3.1"
simple-concat: "npm:^1.0.0"
checksum: 10c0/b0649a581dbca741babb960423248899203165769747142033479a7dc5e77d7b0fced0253c731cd57cf21e31e4d77c9157c3069f4448d558ebc96cf9e1eebcf0
languageName: node
linkType: hard
"sirv@npm:^2.0.4":
version: 2.0.4
resolution: "sirv@npm:2.0.4"
@@ -2874,15 +2632,6 @@ __metadata:
languageName: node
linkType: hard
"string_decoder@npm:^1.1.1":
version: 1.3.0
resolution: "string_decoder@npm:1.3.0"
dependencies:
safe-buffer: "npm:~5.2.0"
checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d
languageName: node
linkType: hard
"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1":
version: 6.0.1
resolution: "strip-ansi@npm:6.0.1"
@@ -2908,13 +2657,6 @@ __metadata:
languageName: node
linkType: hard
"strip-json-comments@npm:~2.0.1":
version: 2.0.1
resolution: "strip-json-comments@npm:2.0.1"
checksum: 10c0/b509231cbdee45064ff4f9fd73609e2bcc4e84a4d508e9dd0f31f70356473fde18abfb5838c17d56fb236f5a06b102ef115438de0600b749e818a35fbbc48c43
languageName: node
linkType: hard
"strip-literal@npm:^2.0.0":
version: 2.1.0
resolution: "strip-literal@npm:2.1.0"
@@ -2940,31 +2682,6 @@ __metadata:
languageName: node
linkType: hard
"tar-fs@npm:^2.0.0":
version: 2.1.4
resolution: "tar-fs@npm:2.1.4"
dependencies:
chownr: "npm:^1.1.1"
mkdirp-classic: "npm:^0.5.2"
pump: "npm:^3.0.0"
tar-stream: "npm:^2.1.4"
checksum: 10c0/decb25acdc6839182c06ec83cba6136205bda1db984e120c8ffd0d80182bc5baa1d916f9b6c5c663ea3f9975b4dd49e3c6bb7b1707cbcdaba4e76042f43ec84c
languageName: node
linkType: hard
"tar-stream@npm:^2.1.4":
version: 2.2.0
resolution: "tar-stream@npm:2.2.0"
dependencies:
bl: "npm:^4.0.3"
end-of-stream: "npm:^1.4.1"
fs-constants: "npm:^1.0.0"
inherits: "npm:^2.0.3"
readable-stream: "npm:^3.1.1"
checksum: 10c0/2f4c910b3ee7196502e1ff015a7ba321ec6ea837667220d7bcb8d0852d51cb04b87f7ae471008a6fb8f5b1a1b5078f62f3a82d30c706f20ada1238ac797e7692
languageName: node
linkType: hard
"tar@npm:^6.1.11, tar@npm:^6.1.2":
version: 6.2.1
resolution: "tar@npm:6.2.1"
@@ -3055,15 +2772,6 @@ __metadata:
languageName: node
linkType: hard
"tunnel-agent@npm:^0.6.0":
version: 0.6.0
resolution: "tunnel-agent@npm:0.6.0"
dependencies:
safe-buffer: "npm:^5.0.1"
checksum: 10c0/4c7a1b813e7beae66fdbf567a65ec6d46313643753d0beefb3c7973d66fcec3a1e7f39759f0a0b4465883499c6dc8b0750ab8b287399af2e583823e40410a17a
languageName: node
linkType: hard
"type-detect@npm:^4.0.0, type-detect@npm:^4.0.8":
version: 4.0.8
resolution: "type-detect@npm:4.0.8"
@@ -3120,13 +2828,6 @@ __metadata:
languageName: node
linkType: hard
"util-deprecate@npm:^1.0.1":
version: 1.0.2
resolution: "util-deprecate@npm:1.0.2"
checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942
languageName: node
linkType: hard
"vite-node@npm:1.6.0":
version: 1.6.0
resolution: "vite-node@npm:1.6.0"

View File

@@ -7603,10 +7603,6 @@ msgstr "Error al importar: No se pudo procesar el JSON."
msgid "workspace.tokens.export"
msgstr "Exportar"
#: src/app/main/ui/workspace/tokens/export/modal.cljs:125
msgid "workspace.tokens.export-tokens"
msgstr "Exportar tokens"
#: src/app/main/ui/workspace/tokens/export/modal.cljs:118
msgid "workspace.tokens.export.multiple-files"
msgstr "Múltiples ficheros"
@@ -7651,26 +7647,10 @@ msgstr "Nombre del grupo"
msgid "workspace.tokens.grouping-set-alert"
msgstr "La agrupación de sets aun no está soportada."
#: src/app/main/ui/workspace/tokens/import/modal.cljs:233
msgid "workspace.tokens.import-button-prefix"
msgstr "Importar %s"
#: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37
msgid "workspace.tokens.import-error"
msgstr "Error al importar:"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:273
msgid "workspace.tokens.import-menu-folder-option"
msgstr "Carpeta"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:271
msgid "workspace.tokens.import-menu-json-option"
msgstr "Archivo JSON único"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:272
msgid "workspace.tokens.import-menu-zip-option"
msgstr "Archivo ZIP"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:241
msgid "workspace.tokens.import-multiple-files"
msgstr ""
@@ -7685,7 +7665,7 @@ msgstr ""
#: src/app/main/ui/workspace/tokens/import/modal.cljs:237
msgid "workspace.tokens.import-tokens"
msgstr "Importar tokens"
msgstr "Import tokens"
#: src/app/main/ui/workspace/tokens/sidebar.cljs:414, src/app/main/ui/workspace/tokens/sidebar.cljs:415
#, unused

View File

@@ -1191,21 +1191,21 @@ __metadata:
languageName: node
linkType: soft
"@penpot/plugin-types@npm:^1.4.2":
version: 1.4.2
resolution: "@penpot/plugin-types@npm:1.4.2"
checksum: 10c0/b0972fe75c97e697eb1044c89db660393886b3c30676f8436ff4ab56c5bf0397b2c675697ae1b9c5fe40bc95a803aecf6d7ac356dbf6d3535bf8baec5d05eab1
"@penpot/plugin-types@npm:^1.3.2":
version: 1.3.2
resolution: "@penpot/plugin-types@npm:1.3.2"
checksum: 10c0/3f624472c260721ad89bf8d944e75acf6a9c9577271a757acb77574102213914051d1a32d5ab16e6ba16ae077fff78cf7a0f6d11d18351dfc214426677a67468
languageName: node
linkType: hard
"@penpot/plugins-runtime@npm:1.4.2":
version: 1.4.2
resolution: "@penpot/plugins-runtime@npm:1.4.2"
"@penpot/plugins-runtime@npm:1.3.2":
version: 1.3.2
resolution: "@penpot/plugins-runtime@npm:1.3.2"
dependencies:
"@penpot/plugin-types": "npm:^1.4.2"
"@penpot/plugin-types": "npm:^1.3.2"
ses: "npm:^1.1.0"
zod: "npm:^3.22.4"
checksum: 10c0/af084d906cce9a6dea956fe5420111d7ea37c7620737a1e3d4f12958cb302a8f697c1229c237107c28fbb3b9f37eee308e6d16262b04ad56ae6f76c7a12f12e5
checksum: 10c0/b6d2cb3a57bcbe58232db52b8224d1817495e96b34997bfa72421629b5f34a8c9cc71357c315dcab9d52ea036ed632a5efe0ac50f52e730901c02d498dfa1313
languageName: node
linkType: hard
@@ -4176,7 +4176,7 @@ __metadata:
dependencies:
"@penpot/draft-js": "portal:./packages/draft-js"
"@penpot/mousetrap": "portal:./packages/mousetrap"
"@penpot/plugins-runtime": "npm:1.4.2"
"@penpot/plugins-runtime": "npm:1.3.2"
"@penpot/svgo": "penpot/svgo#v3.2"
"@penpot/text-editor": "portal:./text-editor"
"@playwright/test": "npm:1.57.0"

View File

@@ -1,38 +1,3 @@
## 1.4.2 (2026-01-21)
- **plugin-types:** fix atob/btoa functions
## 1.4.0 (2026-01-21)
### 🚀 Features
- switch component ([7d68450](https://github.com/penpot/penpot-plugins/commit/7d68450))
- Add variants to plugins API ([04f3c26](https://github.com/penpot/penpot-plugins/commit/04f3c26))
- format ci job ([17b5834](https://github.com/penpot/penpot-plugins/commit/17b5834))
- fix problem with ci ([4b3c50f](https://github.com/penpot/penpot-plugins/commit/4b3c50f))
- change in workflow ([3a69f51](https://github.com/penpot/penpot-plugins/commit/3a69f51))
- **plugin-types:** add methods to modify the index for shapes ([4ad50af](https://github.com/penpot/penpot-plugins/commit/4ad50af))
- **plugin-types:** change content type and added new attributes ([dbb68a5](https://github.com/penpot/penpot-plugins/commit/dbb68a5))
- **plugins-runtime:** add data method to image data ([f077481](https://github.com/penpot/penpot-plugins/commit/f077481))
- **plugins-runtime:** fix problem with linter ([30f4984](https://github.com/penpot/penpot-plugins/commit/30f4984))
- **plugins-runtime:** allow openPage() to toggle opening on a new window or not ([da8288b](https://github.com/penpot/penpot-plugins/commit/da8288b))
### 🩹 Fixes
- package-lock.json ([d1d940a](https://github.com/penpot/penpot-plugins/commit/d1d940a))
- fonts gdpr & switch provider ([d63231e](https://github.com/penpot/penpot-plugins/commit/d63231e))
- missing changes ([b8fc936](https://github.com/penpot/penpot-plugins/commit/b8fc936))
- format ci ([e0fab2e](https://github.com/penpot/penpot-plugins/commit/e0fab2e))
- fetch main only in pr ([e48c5d4](https://github.com/penpot/penpot-plugins/commit/e48c5d4))
### ❤️ Thank You
- alonso.torres
- Juanfran @juanfran
- Michał Korczak
- Miguel de Benito Delgado
- Pablo Alba
## 1.3.2 (2025-07-04)
### 🩹 Fixes

View File

@@ -1,8 +0,0 @@
name = "color-to-tokens-plugin"
compatibility_date = "2025-01-01"
assets = { directory = "../../dist/apps/colors-to-tokens-plugin/browser" }
[[routes]]
pattern = "WORKER_URI"
custom_domain = true

View File

@@ -1,8 +0,0 @@
name = "contrast-plugin"
compatibility_date = "2025-01-01"
assets = { directory = "../../dist/apps/contrast-plugin/browser" }
[[routes]]
pattern = "WORKER_URI"
custom_domain = true

View File

@@ -1,8 +0,0 @@
name = "create-palette-plugin"
compatibility_date = "2025-01-01"
assets = { directory = "../../dist/apps/create-palette-plugin" }
[[routes]]
pattern = "WORKER_URI"
custom_domain = true

View File

@@ -1,8 +0,0 @@
name = "icons-plugin"
compatibility_date = "2025-01-01"
assets = { directory = "../../dist/apps/icons-plugin/browser" }
[[routes]]
pattern = "WORKER_URI"
custom_domain = true

View File

@@ -1,8 +0,0 @@
name = "lorem-ipsum-plugin"
compatibility_date = "2025-01-01"
assets = { directory = "../../dist/apps/lorem-ipsum-plugin/browser" }
[[routes]]
pattern = "WORKER_URI"
custom_domain = true

View File

@@ -1,8 +0,0 @@
name = "rename-layers-plugin"
compatibility_date = "2025-01-01"
assets = { directory = "../../dist/apps/rename-layers-plugin/browser" }
[[routes]]
pattern = "WORKER_URI"
custom_domain = true

View File

@@ -1,8 +0,0 @@
name = "table-plugin"
compatibility_date = "2025-01-01"
assets = { directory = "../../dist/apps/table-plugin/browser" }
[[routes]]
pattern = "WORKER_URI"
custom_domain = true

View File

@@ -7,29 +7,6 @@ This guide details the process of publishing `plugin-types`,
for plugin development. Below is a walkthrough for publishing these
packages and managing releases.
**Warning**
Before generating the release, please, check the update the changelog with
the changes that will be released.
## Problem with pnpm
There is an issue with dependencies and release with pnpm. For it to work
you need to add the following into your `.npmrc`
```
link-workspace-packages=true
```
## NPM Authentication
You need to generate a temporary access token in the NPM website.
Once you have the token add the following to the `.npmrc`
```
//registry.npmjs.org/:_authToken=<TOKEN>
```
## Publishing Libraries
Publishing packages enables the distribution of types and styles
@@ -58,16 +35,28 @@ pnpm run release -- --dry-run false
This command will:
- Update the `CHANGELOG.md`
- Update the library's `package.json` version
- Generate a commit
- Create a new git tag
- Publish to NPM with the `latest` tag
Ensure everything is correct before proceeding with the git push. Once
verified, execute the following commands:
```shell
git commit -m ":arrow_up: Updated plugins release to X.X.X"
git push
git push origin vX.X.X
```
Replace `vX.X.X` with the new version number.
> 📘 To update the documentation site, you must also update the `stable` branch:
```shell
git checkout stable
git merge main
git push origin stable
```
For detailed information, refer to the [Nx Release

View File

@@ -1,6 +1,6 @@
{
"name": "@penpot/plugin-types",
"version": "1.4.2",
"version": "1.3.2",
"typings": "./index.d.ts",
"type": "module"
}

Some files were not shown because too many files have changed in this diff Show More