Compare commits

...

40 Commits

Author SHA1 Message Date
Pablo Alba
0bb74ed722 🐛 Fix viewer can update library (#8231) 2026-01-28 20:48:52 +01:00
Eva Marco
2b4e315744 ♻️ Replace layout item numeric inputs. (#8163)
*  Replace opacity numeric input

*  Add test

* ♻️ Replace margin inputs

* 🎉 Add test
2026-01-28 14:30:18 +01:00
Pablo Alba
4ca82821c1 🐛 Fix shared keys init should be by keywords (2) (#8230) 2026-01-28 13:41:37 +01:00
David Barragán Merino
a90f672a5e 🔧 Fix CORS error 2026-01-28 13:30:08 +01:00
Pablo Alba
f76598f638 🐛 Fix shared keys init should be by keywords (#8228) 2026-01-28 12:56:04 +01:00
Xaviju
eacc033567 🐛 Fix long token names overflow remap modal (#8224) 2026-01-28 12:44:07 +01:00
Andrey Antukh
71c349479f Merge pull request #8196 from penpot/niwinz-develop-management-auth-changes
♻️ Make several improvements to management API authentication
2026-01-28 10:52:26 +01:00
David Barragán Merino
fda31624c1 🔧 Fix file name 2026-01-27 21:04:25 +01:00
David Barragán Merino
7f640569bd 📚 Fix links related to penpot plugins 2026-01-27 20:59:54 +01:00
David Barragán Merino
91f1323802 🔧 Deploy plugin styles documentation 2026-01-27 20:59:54 +01:00
David Barragán Merino
dbd4a2366f 🔧 Add custom domain 2026-01-27 20:59:54 +01:00
Pablo Alba
cbb6d098a7 🐛 Fix boolean operators in menu for boards (#8177) 2026-01-27 17:58:07 +01:00
Andrey Antukh
b6f5000d1c ⬆️ Update pnpm 2026-01-27 17:57:07 +01:00
Andrey Antukh
0527124f2f Merge remote-tracking branch 'origin/staging-render' into develop 2026-01-27 17:56:03 +01:00
Andrey Antukh
faf91ac70d Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-27 17:53:16 +01:00
Eva Marco
9ca76c745f 🐛 Fix app freeze on token name change (#8214) 2026-01-27 17:31:50 +01:00
Andrey Antukh
89935e2174 Make nitrate module loading conditional to flag
This removes the flag checking on each rpc method
2026-01-27 15:16:36 +01:00
Andrey Antukh
7f27e0326d Reuse basic team and profile schemas on nitrate 2026-01-27 15:14:32 +01:00
Andrey Antukh
9c539dfb2f 🔥 Remove subscriptions related management module 2026-01-27 15:14:32 +01:00
Andrey Antukh
50a4cf8b99 📎 Adapt nitrate module to auth changes 2026-01-27 15:14:32 +01:00
Andrey Antukh
f5996a7235 ♻️ Make several improvements to management API authentication 2026-01-27 15:14:32 +01:00
Andrey Antukh
e8fd4698c9 🔧 Update caddy configuration 2026-01-27 15:10:53 +01:00
Andrey Antukh
0ab126748f 💄 Add format rule for code comments (#8211)
* 💄 Add format rule for code comments

* ⬆️ Update linter and formatter on devenv
2026-01-27 15:07:18 +01:00
Yamila Moreno
71a5ab9913 🔧 Delete unused workflow 2026-01-27 13:47:05 +01:00
Elena Torró
9808b6ca57 Merge pull request #8205 from penpot/superalex-improve-huge-shapes-render
🎉 Improving huge shapes render
2026-01-27 13:08:25 +01:00
Aitor Moreno
de41cb5488 🐛 Fix add/remove fills to text nodes 2026-01-27 12:17:10 +01:00
Alejandro Alonso
b40ccaf030 🎉 Improve zoom actions for huge shapes 2026-01-27 11:11:38 +01:00
Alejandro Alonso
7d3ac38749 🎉 Improve huge shapes rendering 2026-01-27 11:11:38 +01:00
Elena Torro
8d1bc6c50c 🐛 Fix flex layout sorting on reverse order with no z-index 2026-01-27 09:34:36 +01:00
Andrey Antukh
3112b240a0 📎 Add missing entry on changelog 2026-01-27 09:28:41 +01:00
Andrey Antukh
56fd66b91a 🐛 Fix several issues related to path edition (#8187)
*  Improve save-path-content event consistency

Mainly removing possible race conditions from the event
implementation.

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

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

View File

@@ -45,6 +45,15 @@
:potok/reify-type :potok/reify-type
{:level :error} {:level :error}
:redundant-primitive-coercion
{:level :off}
:unused-excluded-var
{:level :off}
:unresolved-excluded-var
{:level :off}
:missing-protocol-method :missing-protocol-method
{:level :off} {:level :off}

View File

@@ -2,6 +2,11 @@
:remove-multiple-non-indenting-spaces? false :remove-multiple-non-indenting-spaces? false
:remove-surrounding-whitespace? true :remove-surrounding-whitespace? true
:remove-consecutive-blank-lines? false :remove-consecutive-blank-lines? false
:indent-line-comments? true
:parallel? true
:align-form-columns? false
;; :align-map-columns? false
;; :align-single-column-lines? false
:extra-indents {rumext.v2/fnc [[:inner 0]] :extra-indents {rumext.v2/fnc [[:inner 0]]
cljs.test/async [[:inner 0]] cljs.test/async [[:inner 0]]
promesa.exec/thread [[:inner 0]] promesa.exec/thread [[:inner 0]]

View File

@@ -1,21 +0,0 @@
name: _NITRATE MODULE
on:
schedule:
- cron: '36 5-20 * * 1-5'
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "nitrate-module"
build_wasm: "yes"
build_storybook: "yes"
build-docker:
needs: build-bundle
uses: ./.github/workflows/build-docker.yml
secrets: inherit
with:
gh_ref: "nitrate-module"

View File

@@ -7,11 +7,11 @@ on:
- staging - staging
- main - main
paths: paths:
- "plugins/libs/plugin-types/index.d.ts" - 'plugins/libs/plugin-types/index.d.ts'
- "plugins/libs/plugin-types/REAME.md" - 'plugins/libs/plugin-types/REAME.md'
- "plugins/tools/typedoc.css" - 'plugins/tools/typedoc.css'
- "plugins/CHANGELOG.md" - 'plugins/CHANGELOG.md'
- "plugins/wrangler-penpot-plugins-api-doc.toml" - 'plugins/wrangler-penpot-plugins-api-doc.toml'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
gh_ref: gh_ref:
@@ -86,12 +86,24 @@ jobs:
run: | run: |
REF="${{ steps.vars.outputs.gh_ref }}" REF="${{ steps.vars.outputs.gh_ref }}"
case "$REF" in case "$REF" in
main) echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV ;; main)
staging) echo "WORKER_NAME=penpot-plugins-api-doc-pre" >> $GITHUB_ENV ;; echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV
develop) echo "WORKER_NAME=penpot-plugins-api-doc-hourly" >> $GITHUB_ENV ;; echo "WORKER_URI=doc.plugins.penpot.app" >> $GITHUB_ENV ;;
staging)
echo "WORKER_NAME=penpot-plugins-api-doc-pre" >> $GITHUB_ENV
echo "WORKER_URI=doc.plugins.penpot.dev" >> $GITHUB_ENV ;;
develop)
echo "WORKER_NAME=penpot-plugins-api-doc-hourly" >> $GITHUB_ENV
echo "WORKER_URI=doc.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
*) echo "Unsupported branch ${REF}" && exit 1 ;; *) echo "Unsupported branch ${REF}" && exit 1 ;;
esac esac
- name: Set the custom url
working-directory: plugins
shell: bash
run: |
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml
- name: Deploy to Cloudflare Workers - name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3 uses: cloudflare/wrangler-action@v3
with: with:

View File

@@ -0,0 +1,123 @@
name: Plugins/styles-doc deployer
on:
push:
branches:
- develop
- staging
- main
paths:
- 'plugins/apps/example-styles/**'
- 'plugins/libs/plugins-styles/**'
- 'plugins/wrangler-penpot-plugins-styles-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 styles
working-directory: plugins
shell: bash
run: npx nx run example-styles:build
- name: Select Worker name
run: |
REF="${{ steps.vars.outputs.gh_ref }}"
case "$REF" in
main)
echo "WORKER_NAME=penpot-plugins-styles-doc-pro" >> $GITHUB_ENV
echo "WORKER_URI=styles-doc.plugins.penpot.app" >> $GITHUB_ENV ;;
staging)
echo "WORKER_NAME=penpot-plugins-styles-doc-pre" >> $GITHUB_ENV
echo "WORKER_URI=styles-doc.plugins.penpot.dev" >> $GITHUB_ENV ;;
develop)
echo "WORKER_NAME=penpot-plugins-styles-doc-hourly" >> $GITHUB_ENV
echo "WORKER_URI=styles-doc.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" wrangler-penpot-plugins-styles-doc.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 wrangler-penpot-plugins-styles-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 Styles documentation.*
📄 Triggered from ref: `${{ inputs.gh_ref }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

1
.gitignore vendored
View File

@@ -66,6 +66,7 @@
/frontend/resources/public/* /frontend/resources/public/*
/frontend/storybook-static/ /frontend/storybook-static/
/frontend/target/ /frontend/target/
/frontend/test-results/
/other/ /other/
/scripts/ /scripts/
/telemetry/ /telemetry/

View File

@@ -30,6 +30,8 @@
- Fix displaying a hidden user avatar when there is only one more [Taiga #13058](https://tree.taiga.io/project/penpot/issue/13058) - Fix displaying a hidden user avatar when there is only one more [Taiga #13058](https://tree.taiga.io/project/penpot/issue/13058)
- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787) - Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135) - Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
## 2.13.0 (Unreleased) ## 2.13.0 (Unreleased)
@@ -69,7 +71,8 @@
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135) - 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 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 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 ## 2.12.1

View File

@@ -1,7 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-key export PENPOT_SECRET_KEY=super-secret-devenv-key
# DEPRECATED: only used for subscriptions
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
export PENPOT_HOST=devenv export PENPOT_HOST=devenv
export PENPOT_PUBLIC_URI=https://localhost:3449 export PENPOT_PUBLIC_URI=https://localhost:3449

View File

@@ -873,11 +873,8 @@
(import-storage-objects cfg) (import-storage-objects cfg)
(let [files (get manifest :files) (let [files (get manifest :files)
result (reduce (fn [result {:keys [id] :as file}] result (reduce (fn [result file]
(let [name' (get file :name) (let [name' (get file :name)
name' (if (map? name)
(get name id)
name')
file (assoc file :name name')] file (assoc file :name name')]
(conj result (import-file cfg file)))) (conj result (import-file cfg file))))
[] []

View File

@@ -102,6 +102,8 @@
[:http-server-io-threads {:optional true} ::sm/int] [:http-server-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-threads {:optional true} ::sm/int] [:http-server-max-worker-threads {:optional true} ::sm/int]
[:exporter-shared-key {:optional true} :string]
[:nitrate-shared-key {:optional true} :string]
[:management-api-key {:optional true} :string] [:management-api-key {:optional true} :string]
[:telemetry-uri {:optional true} :string] [:telemetry-uri {:optional true} :string]

View File

@@ -13,13 +13,13 @@
[app.common.time :as ct] [app.common.time :as ct]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.http.middleware :as mw]
[app.main :as-alias main] [app.main :as-alias main]
[app.rpc.commands.profile :as cmd.profile] [app.rpc.commands.profile :as cmd.profile]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.worker :as-alias wrk] [app.worker :as-alias wrk]
[integrant.core :as ig] [integrant.core :as ig]
[yetti.request :as yreq]
[yetti.response :as-alias yres])) [yetti.response :as-alias yres]))
;; ---- ROUTES ;; ---- ROUTES
@@ -49,13 +49,25 @@
(fn [cfg request] (fn [cfg request]
(db/tx-run! cfg handler request)))))}) (db/tx-run! cfg handler request)))))})
(def ^:private shared-key-auth
{:name ::shared-key-auth
:compile
(fn [_ _]
(fn [handler key]
(if key
(fn [request]
(if-let [key' (yreq/get-header request "x-shared-key")]
(if (= key key')
(handler request)
{::yres/status 403})
{::yres/status 403}))
(fn [_ _]
{::yres/status 403}))))})
(defmethod ig/init-key ::routes (defmethod ig/init-key ::routes
[_ {:keys [::setup/props] :as cfg}] [_ cfg]
(let [management-key (or (cf/get :management-api-key) ["" {:middleware [[shared-key-auth (cf/get :management-api-key)]
(get props :management-key))]
["" {:middleware [[mw/shared-key-auth management-key]
[default-system cfg] [default-system cfg]
[transaction]]} [transaction]]}
["/authenticate" ["/authenticate"
@@ -70,7 +82,7 @@
["/update-customer" ["/update-customer"
{:handler update-customer {:handler update-customer
:allowed-methods #{:post} :allowed-methods #{:post}
:transaction true}]])) :transaction true}]])
;; ---- HELPERS ;; ---- HELPERS

View File

@@ -16,7 +16,6 @@
[app.http.errors :as errors] [app.http.errors :as errors]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.pointer-map :as pmap] [app.util.pointer-map :as pmap]
[buddy.core.codecs :as bc]
[cuerdas.core :as str] [cuerdas.core :as str]
[yetti.adapter :as yt] [yetti.adapter :as yt]
[yetti.middleware :as ymw] [yetti.middleware :as ymw]
@@ -301,16 +300,20 @@
:compile (constantly wrap-auth)}) :compile (constantly wrap-auth)})
(defn- wrap-shared-key-auth (defn- wrap-shared-key-auth
[handler shared-key] [handler keys]
(if shared-key (if (seq keys)
(let [shared-key (if (string? shared-key)
shared-key
(bc/bytes->b64-str shared-key true))]
(fn [request] (fn [request]
(let [key (yreq/get-header request "x-shared-key")] (if-let [[key-id key] (some-> (yreq/get-header request "x-shared-key")
(if (= key shared-key) (str/split #"\s+" 2))]
(handler (assoc request ::http/auth-with-shared-key true)) (let [key-id (-> key-id str/lower keyword)]
{::yres/status 403})))) (if (and (string? key)
(contains? keys key-id)
(= key (get keys key-id)))
(-> request
(assoc ::http/auth-key-id key-id)
(handler))
{::yres/status 403}))
{::yres/status 403}))
(fn [_ _] (fn [_ _]
{::yres/status 403}))) {::yres/status 403})))

View File

@@ -6,7 +6,6 @@
(ns app.http.sse (ns app.http.sse
"SSE (server sent events) helpers" "SSE (server sent events) helpers"
(:refer-clojure :exclude [tap])
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.logging :as l] [app.common.logging :as l]

View File

@@ -140,10 +140,14 @@
client-version (get-client-version request) client-version (get-client-version request)
client-user-agent (get-client-user-agent request) client-user-agent (get-client-user-agent request)
session-id (get-external-session-id request) session-id (get-external-session-id request)
token-id (::actoken/id request)] key-id (::http/auth-key-id request)
token-id (::actoken/id request)
token-type (::actoken/type request)]
(d/without-nils (d/without-nils
{:external-session-id session-id {:external-session-id session-id
:initiator (or key-id "app")
:access-token-id (some-> token-id str) :access-token-id (some-> token-id str)
:access-token-type (some-> token-type str)
:client-event-origin client-event-origin :client-event-origin client-event-origin
:client-user-agent client-user-agent :client-user-agent client-user-agent
:client-version client-version :client-version client-version

View File

@@ -275,8 +275,7 @@
::email/whitelist (ig/ref ::email/whitelist)} ::email/whitelist (ig/ref ::email/whitelist)}
::mgmt/routes ::mgmt/routes
{::db/pool (ig/ref ::db/pool) {::db/pool (ig/ref ::db/pool)}
::setup/props (ig/ref ::setup/props)}
:app.http/router :app.http/router
{::session/manager (ig/ref ::session/manager) {::session/manager (ig/ref ::session/manager)
@@ -341,7 +340,8 @@
::email/whitelist (ig/ref ::email/whitelist)} ::email/whitelist (ig/ref ::email/whitelist)}
:app.nitrate/client :app.nitrate/client
{::http.client/client (ig/ref ::http.client/client)} {::http.client/client (ig/ref ::http.client/client)
::setup/shared-keys (ig/ref ::setup/shared-keys)}
:app.rpc/management-methods :app.rpc/management-methods
{::http.client/client (ig/ref ::http.client/client) {::http.client/client (ig/ref ::http.client/client)
@@ -363,7 +363,7 @@
;; FIXME: revisit if db/pool is necessary here ;; FIXME: revisit if db/pool is necessary here
::db/pool (ig/ref ::db/pool) ::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager) ::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)} ::setup/shared-keys (ig/ref ::setup/shared-keys)}
::wrk/registry ::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics) {::mtx/metrics (ig/ref ::mtx/metrics)
@@ -451,6 +451,11 @@
;; module requires the migrations to run before initialize. ;; module requires the migrations to run before initialize.
::migrations (ig/ref :app.migrations/migrations)} ::migrations (ig/ref :app.migrations/migrations)}
::setup/shared-keys
{::setup/props (ig/ref ::setup/props)
:nitrate (cf/get :nitrate-shared-key)
:exporter (cf/get :exporter-shared-key)}
::setup/clock ::setup/clock
{} {}

View File

@@ -1,9 +1,3 @@
;; 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.nitrate (ns app.nitrate
"Module that make calls to the external nitrate aplication" "Module that make calls to the external nitrate aplication"
(:require (:require
@@ -17,18 +11,17 @@
[clojure.core :as c] [clojure.core :as c]
[integrant.core :as ig])) [integrant.core :as ig]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS ;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- request-builder (defn- request-builder
[cfg method uri management-key profile-id] [cfg method uri shared-key profile-id]
(fn [] (fn []
(http/req! cfg {:method method (http/req! cfg {:method method
:headers {"content-type" "application/json" :headers {"content-type" "application/json"
"accept" "application/json" "accept" "application/json"
"x-shared-key" management-key "x-shared-key" shared-key
"x-profile-id" (str profile-id)} "x-profile-id" (str profile-id)}
:uri uri :uri uri
:version :http1.1}))) :version :http1.1})))
@@ -65,8 +58,9 @@
nil))))) nil)))))
(defn- request-to-nitrate (defn- request-to-nitrate
[{:keys [::management-key] :as cfg} method uri schema {:keys [::rpc/profile-id] :as params}] [cfg method uri schema {:keys [::rpc/profile-id] :as params}]
(let [full-http-call (-> (request-builder cfg method uri management-key profile-id) (let [shared-key (-> cfg ::setup/shared-keys :nitrate)
full-http-call (-> (request-builder cfg method uri shared-key profile-id)
(with-retries 3) (with-retries 3)
(with-validate uri schema))] (with-validate uri schema))]
(full-http-call))) (full-http-call)))
@@ -103,26 +97,15 @@
(let [baseuri (cf/get :nitrate-backend-uri)] (let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params))) (request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION ;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::client (defmethod ig/init-key ::client
[_ {:keys [::setup/props] :as cfg}] [_ cfg]
(if (contains? cf/flags :nitrate) (when (contains? cf/flags :nitrate)
(let [management-key (or (cf/get :management-api-key)
(get props :management-key))
cfg (assoc cfg ::management-key management-key)]
{:get-team-org (partial get-team-org cfg) {:get-team-org (partial get-team-org cfg)
:is-valid-user (partial is-valid-user cfg)}) :is-valid-user (partial is-valid-user cfg)}))
{}))
(defmethod ig/halt-key! ::client
[_ {:keys []}]
(do :stuff))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS ;; UTILS

View File

@@ -92,11 +92,11 @@
(fn [{:keys [params path-params method] :as request}] (fn [{:keys [params path-params method] :as request}]
(let [handler-name (:type path-params) (let [handler-name (:type path-params)
etag (yreq/get-header request "if-none-match") etag (yreq/get-header request "if-none-match")
key-id (get request ::http/auth-key-id)
profile-id (or (::session/profile-id request) profile-id (or (::session/profile-id request)
(::actoken/profile-id request) (::actoken/profile-id request)
(if (::http/auth-with-shared-key request) (if key-id uuid/zero nil))
uuid/zero
nil))
ip-addr (inet/parse-request request) ip-addr (inet/parse-request request)
@@ -298,11 +298,12 @@
(defn- resolve-management-methods (defn- resolve-management-methods
[cfg] [cfg]
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)] (let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)
(->> (sv/scan-ns mods (cond->> (list 'app.rpc.management.exporter)
'app.rpc.management.subscription (contains? cf/flags :nitrate)
'app.rpc.management.nitrate (cons 'app.rpc.management.nitrate))]
'app.rpc.management.exporter)
(->> (apply sv/scan-ns mods)
(map (partial process-method cfg "management" wrap-management)) (map (partial process-method cfg "management" wrap-management))
(into {})))) (into {}))))
@@ -346,23 +347,20 @@
(defmethod ig/assert-key ::routes (defmethod ig/assert-key ::routes
[_ params] [_ params]
(assert (map? (::setup/shared-keys params)))
(assert (db/pool? (::db/pool params)) "expect valid database pool") (assert (db/pool? (::db/pool params)) "expect valid database pool")
(assert (some? (::setup/props params)))
(assert (session/manager? (::session/manager params)) "expect valid session manager") (assert (session/manager? (::session/manager params)) "expect valid session manager")
(assert (valid-methods? (::methods params)) "expect valid methods map") (assert (valid-methods? (::methods params)) "expect valid methods map")
(assert (valid-methods? (::management-methods params)) "expect valid methods map")) (assert (valid-methods? (::management-methods params)) "expect valid methods map"))
(defmethod ig/init-key ::routes (defmethod ig/init-key ::routes
[_ {:keys [::methods ::management-methods ::setup/props] :as cfg}] [_ {:keys [::methods ::management-methods ::setup/shared-keys] :as cfg}]
(let [public-uri (cf/get :public-uri)
management-key (or (cf/get :management-api-key)
(get props :management-key))]
(let [public-uri (cf/get :public-uri)]
["/api" ["/api"
["/management" ["/management"
["/methods/:type" ["/methods/:type"
{:middleware [[mw/shared-key-auth management-key] {:middleware [[mw/shared-key-auth shared-keys]
[session/authz cfg]] [session/authz cfg]]
:handler (make-rpc-handler management-methods)}] :handler (make-rpc-handler management-methods)}]

View File

@@ -5,15 +5,13 @@
;; Copyright (c) KALEIDOS INC ;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.nitrate (ns app.rpc.management.nitrate
"Internal Nitrate HTTP API. "Internal Nitrate HTTP RPC API. Provides authenticated access to
Provides authenticated access to organization management and token validation endpoints. organization management and token validation endpoints."
All requests must include a valid shared key token in the `x-shared-key` header, and
a cookie `auth-token` with the user token.
They will return `401 Unauthorized` if the shared key or user token are invalid."
(:require (:require
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.types.profile :refer [schema:profile]]
[app.common.types.team :refer [schema:team]]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db] [app.db :as db]
[app.msgbus :as mbus] [app.msgbus :as mbus]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
@@ -23,19 +21,11 @@
[app.util.services :as sv])) [app.util.services :as sv]))
;; ---- API: authenticate ;; ---- API: authenticate
(def ^:private schema:profile
[:map
[:id ::sm/uuid]
[:name :string]
[:email :string]
[:photo-url :string]])
(sv/defmethod ::authenticate (sv/defmethod ::authenticate
"Authenticate an user "Authenticate the current user"
@api GET /authenticate {::doc/added "2.14"
@returns ::sm/params [:map]
200 OK: Returns the authenticated user."
{::doc/added "2.12"
::sm/result schema:profile} ::sm/result schema:profile}
[cfg {:keys [::rpc/profile-id] :as params}] [cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)] (let [profile (profile/get-profile cfg profile-id)]
@@ -51,30 +41,22 @@
FROM team AS t FROM team AS t
JOIN team_profile_rel AS tpr ON t.id = tpr.team_id JOIN team_profile_rel AS tpr ON t.id = tpr.team_id
WHERE tpr.profile_id = ? WHERE tpr.profile_id = ?
AND tpr.is_owner = 't' AND tpr.is_owner IS TRUE
AND t.is_default = 'f' AND t.is_default IS FALSE
AND t.deleted_at is null;") AND t.deleted_at IS NULL;")
(def ^:private schema:team
[:map
[:id ::sm/uuid]
[:name :string]])
(def ^:private schema:get-teams-result (def ^:private schema:get-teams-result
[:vector schema:team]) [:vector schema:team])
(sv/defmethod ::get-teams (sv/defmethod ::get-teams
"List teams for which current user is owner. "List teams for which current user is owner"
@api GET /get-teams {::doc/added "2.14"
@returns ::sm/params [:map]
200 OK: Returns the list of teams for the user."
{::doc/added "2.12"
::sm/result schema:get-teams-result} ::sm/result schema:get-teams-result}
[cfg {:keys [::rpc/profile-id]}] [cfg {:keys [::rpc/profile-id]}]
(when (contains? cf/flags :nitrate)
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(->> (db/exec! cfg [sql:get-teams current-user-id]) (->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name])))))) (map #(select-keys % [:id :name])))))
;; ---- API: notify-team-change ;; ---- API: notify-team-change
@@ -83,17 +65,12 @@
[:id ::sm/uuid] [:id ::sm/uuid]
[:organization-id ::sm/text]]) [:organization-id ::sm/text]])
(sv/defmethod ::notify-team-change (sv/defmethod ::notify-team-change
"Notify to Penpot a team change from nitrate "Notify to Penpot a team change from nitrate"
@api POST /notify-team-change {::doc/added "2.14"
@returns
200 OK"
{::doc/added "2.12"
::sm/params schema:notify-team-change ::sm/params schema:notify-team-change
::rpc/auth false} ::rpc/auth false}
[cfg {:keys [id organization-id organization-name]}] [cfg {:keys [id organization-id organization-name]}]
(when (contains? cf/flags :nitrate)
(let [msgbus (::mbus/msgbus cfg)] (let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus (mbus/pub! msgbus
;;TODO There is a bug on dashboard with teams notifications. ;;TODO There is a bug on dashboard with teams notifications.
@@ -102,11 +79,4 @@
:message {:type :team-org-change :message {:type :team-org-change
:team-id id :team-id id
:organization-id organization-id :organization-id organization-id
:organization-name organization-name})))) :organization-name organization-name})))

View File

@@ -1,183 +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.rpc.management.subscription
(:require
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.time :as ct]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as doc]
[app.util.services :as sv]))
;; ---- RPC METHOD: AUTHENTICATE
(def ^:private
schema:authenticate-params
[:map {:title "authenticate-params"}])
(def ^:private
schema:authenticate-result
[:map {:title "authenticate-result"}
[:profile-id ::sm/uuid]])
(sv/defmethod ::auth
{::doc/added "2.12"
::sm/params schema:authenticate-params
::sm/result schema:authenticate-result}
[_ {:keys [::rpc/profile-id]}]
{:profile-id profile-id})
;; ---- RPC METHOD: GET-CUSTOMER
;; FIXME: move to app.common.time
(def ^:private schema:timestamp
(sm/type-schema
{:type ::timestamp
:pred ct/inst?
:type-properties
{:title "inst"
:description "The same as :app.common.time/inst but encodes to epoch"
:error/message "should be an instant"
:gen/gen (->> (sg/small-int)
(sg/fmap (fn [v] (ct/inst v))))
:decode/string #(some-> % ct/inst)
:encode/string #(some-> % inst-ms)
:decode/json #(some-> % ct/inst)
:encode/json #(some-> % inst-ms)}}))
(def ^:private schema:subscription
[:map {:title "Subscription"}
[:id ::sm/text]
[:customer-id ::sm/text]
[:type [:enum
"unlimited"
"professional"
"enterprise"]]
[:status [:enum
"active"
"canceled"
"incomplete"
"incomplete_expired"
"past_due"
"paused"
"trialing"
"unpaid"]]
[:billing-period [:enum
"month"
"day"
"week"
"year"]]
[:quantity :int]
[:description [:maybe ::sm/text]]
[:created-at schema:timestamp]
[:start-date [:maybe schema:timestamp]]
[:ended-at [:maybe schema:timestamp]]
[:trial-end [:maybe schema:timestamp]]
[:trial-start [:maybe schema:timestamp]]
[:cancel-at [:maybe schema:timestamp]]
[:canceled-at [:maybe schema:timestamp]]
[:current-period-end [:maybe schema:timestamp]]
[:current-period-start [:maybe schema:timestamp]]
[:cancel-at-period-end :boolean]
[:cancellation-details
[:map {:title "CancellationDetails"}
[:comment [:maybe ::sm/text]]
[:reason [:maybe ::sm/text]]
[:feedback [:maybe
[:enum
"customer_service"
"low_quality"
"missing_feature"
"other"
"switched_service"
"too_complex"
"too_expensive"
"unused"]]]]]])
(def ^:private sql:get-customer-slots
"WITH teams AS (
SELECT tpr.team_id AS id,
tpr.profile_id AS profile_id
FROM team_profile_rel AS tpr
WHERE tpr.is_owner IS true
AND tpr.profile_id = ?
), teams_with_slots AS (
SELECT tpr.team_id AS id,
count(*) AS total
FROM team_profile_rel AS tpr
WHERE tpr.team_id IN (SELECT id FROM teams)
AND tpr.can_edit IS true
GROUP BY 1
ORDER BY 2
)
SELECT max(total) AS total FROM teams_with_slots;")
(defn- get-customer-slots
[cfg profile-id]
(let [result (db/exec-one! cfg [sql:get-customer-slots profile-id])]
(:total result)))
(def ^:private schema:get-customer-params
[:map])
(def ^:private schema:get-customer-result
[:map
[:id ::sm/uuid]
[:name :string]
[:num-editors ::sm/int]
[:subscription {:optional true} schema:subscription]])
(sv/defmethod ::get-customer
{::doc/added "2.12"
::sm/params schema:get-customer-params
::sm/result schema:get-customer-result}
[cfg {:keys [::rpc/profile-id]}]
(let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:num-editors (get-customer-slots cfg profile-id)
:subscription (-> profile :props :subscription)}))
;; ---- RPC METHOD: GET-CUSTOMER
(def ^:private schema:update-customer-params
[:map
[:subscription [:maybe schema:subscription]]])
(def ^:private schema:update-customer-result
[:map])
(sv/defmethod ::update-customer
{::doc/added "2.12"
::sm/params schema:update-customer-params
::sm/result schema:update-customer-result}
[cfg {:keys [::rpc/profile-id subscription]}]
(let [{:keys [props] :as profile}
(profile/get-profile cfg profile-id ::db/for-update true)
props
(assoc props :subscription subscription)]
(l/dbg :hint "update customer"
:profile-id (str profile-id)
:subscription-type (get subscription :type)
:subscription-status (get subscription :status)
:subscription-quantity (get subscription :quantity))
(db/update! cfg :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
nil))

View File

@@ -17,6 +17,7 @@
[app.setup.templates] [app.setup.templates]
[buddy.core.codecs :as bc] [buddy.core.codecs :as bc]
[buddy.core.nonce :as bn] [buddy.core.nonce :as bn]
[cuerdas.core :as str]
[integrant.core :as ig])) [integrant.core :as ig]))
(defn- generate-random-key (defn- generate-random-key
@@ -88,7 +89,38 @@
(-> (get-all-props conn) (-> (get-all-props conn)
(assoc :secret-key secret) (assoc :secret-key secret)
(assoc :tokens-key (keys/derive secret :salt "tokens")) (assoc :tokens-key (keys/derive secret :salt "tokens"))
(assoc :management-key (keys/derive secret :salt "management"))
(update :instance-id handle-instance-id conn (db/read-only? pool))))))) (update :instance-id handle-instance-id conn (db/read-only? pool)))))))
(sm/register! ::props [:map-of :keyword ::sm/any]) (sm/register! ::props [:map-of :keyword ::sm/any])
(defmethod ig/init-key ::shared-keys
[_ {:keys [::props] :as cfg}]
(let [secret (get props :secret-key)]
(d/without-nils
{:exporter
(let [key (or (get cfg :exporter)
(-> (keys/derive secret :salt "exporter")
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :hint "exporter key is disabled because empty string found")
nil)
(do
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key))
key)))
:nitrate
(let [key (or (get cfg :nitrate)
(-> (keys/derive secret :salt "nitrate")
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :hint "nitrate key is disabled because empty string found")
nil)
(do
(l/inf :hint "nitrate key initialized" :key (d/obfuscate-string key))
key)))})))

View File

@@ -8,7 +8,7 @@
"A generic asynchronous events notifications subsystem; used mainly "A generic asynchronous events notifications subsystem; used mainly
for mark event points in functions and be able to attach listeners for mark event points in functions and be able to attach listeners
to them. Mainly used in http.sse for progress reporting." to them. Mainly used in http.sse for progress reporting."
(:refer-clojure :exclude [tap run!]) (:refer-clojure :exclude [run!])
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]

View File

@@ -86,7 +86,7 @@
(t/deftest shared-key-auth (t/deftest shared-key-auth
(let [handler (#'app.http.middleware/wrap-shared-key-auth (let [handler (#'app.http.middleware/wrap-shared-key-auth
(fn [req] {::yres/status 200}) (fn [req] {::yres/status 200})
"secret-key")] {:test1 "secret-key"})]
(let [response (handler (->DummyRequest {} {}))] (let [response (handler (->DummyRequest {} {}))]
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
@@ -95,6 +95,9 @@
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))] (let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "test1 secret-key"} {}))]
(t/is (= 200 (::yres/status response)))))) (t/is (= 200 (::yres/status response))))))
(t/deftest access-token-authz (t/deftest access-token-authz

View File

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

View File

@@ -44,7 +44,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Set parent to root frame. ;; Set parent to root frame.
(log/debug :hint " -> set to " :parent-id uuid/zero) (log/debug :hint " -> set to " :parent-id uuid/zero)
(assoc shape :parent-id uuid/zero))] (assoc shape :parent-id uuid/zero))]
@@ -57,7 +57,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [parent-shape] (fn [parent-shape]
; Add shape to parent's children list ;; Add shape to parent's children list
(log/debug :hint " -> add children to" :parent-id (:id parent-shape)) (log/debug :hint " -> add children to" :parent-id (:id parent-shape))
(update parent-shape :shapes conj (:id shape)))] (update parent-shape :shapes conj (:id shape)))]
@@ -70,7 +70,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Remove duplicated ;; Remove duplicated
(log/debug :hint " -> remove duplicated children") (log/debug :hint " -> remove duplicated children")
(update shape :shapes distinct))] (update shape :shapes distinct))]
@@ -102,7 +102,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Locate the first frame in parents and set frame-id to it. ;; Locate the first frame in parents and set frame-id to it.
(let [page (ctpl/get-page file-data page-id) (let [page (ctpl/get-page file-data page-id)
frame (cfh/get-frame (:objects page) (:parent-id shape)) frame (cfh/get-frame (:objects page) (:parent-id shape))
frame-id (or (:id frame) uuid/zero)] frame-id (or (:id frame) uuid/zero)]
@@ -118,7 +118,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Locate the first frame in parents and set frame-id to it. ;; Locate the first frame in parents and set frame-id to it.
(let [page (ctpl/get-page file-data page-id) (let [page (ctpl/get-page file-data page-id)
frame (cfh/get-frame (:objects page) (:parent-id shape)) frame (cfh/get-frame (:objects page) (:parent-id shape))
frame-id (or (:id frame) uuid/zero)] frame-id (or (:id frame) uuid/zero)]
@@ -134,7 +134,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Set the :shape as main instance root ;; Set the :shape as main instance root
(log/debug :hint " -> set :main-instance") (log/debug :hint " -> set :main-instance")
(assoc shape :main-instance true))] (assoc shape :main-instance true))]
@@ -147,10 +147,11 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Set :component-file to local file ;; Set :component-file to local file
(log/debug :hint " -> set :component-file to local file") (log/debug :hint " -> set :component-file to local file")
(assoc shape :component-file (:id file-data)))] (assoc shape :component-file (:id file-data)))]
; There is no solution that may recover it with confidence
;; There is no solution that may recover it with confidence
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") ;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
;; shape)] ;; shape)]
@@ -166,10 +167,10 @@
repair-shape repair-shape
(fn [shape] (fn [shape]
; Detach the shape and convert it to non instance. ;; Detach the shape and convert it to non instance.
(log/debug :hint " -> detach shape" :shape-id (:id shape)) (log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))] (ctk/detach-shape shape))]
; There is no solution that may recover it with confidence ;; There is no solution that may recover it with confidence
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") ;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
;; shape)] ;; shape)]
@@ -184,7 +185,7 @@
repair-component repair-component
(fn [component] (fn [component]
; Assign main instance in the component to current shape ;; Assign main instance in the component to current shape
(log/debug :hint " -> assign main-instance-id" :component-id (:id component)) (log/debug :hint " -> assign main-instance-id" :component-id (:id component))
(assoc component :main-instance-id (:id shape))) (assoc component :main-instance-id (:id shape)))
@@ -207,7 +208,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-component (let [repair-component
(fn [component] (fn [component]
; Assign main instance in the component to current shape ;; Assign main instance in the component to current shape
(log/debug :hint " -> assign main-instance-page" :component-id (:id component)) (log/debug :hint " -> assign main-instance-page" :component-id (:id component))
(assoc component :main-instance-page page-id))] (assoc component :main-instance-page page-id))]
(log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id) (log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
@@ -219,7 +220,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; There is no solution that may recover it with confidence ;; There is no solution that may recover it with confidence
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
shape)] shape)]
@@ -232,7 +233,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Unset the :shape as main instance root ;; Unset the :shape as main instance root
(log/debug :hint " -> unset :main-instance") (log/debug :hint " -> unset :main-instance")
(dissoc shape :main-instance))] (dissoc shape :main-instance))]
@@ -245,7 +246,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a top copy root. ;; Convert the shape in a top copy root.
(log/debug :hint " -> set :component-root") (log/debug :hint " -> set :component-root")
(assoc shape :component-root true))] (assoc shape :component-root true))]
@@ -258,7 +259,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a nested copy root. ;; Convert the shape in a nested copy root.
(log/debug :hint " -> unset :component-root") (log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))] (dissoc shape :component-root))]
@@ -307,8 +308,8 @@
(log/debug :hint " -> detach shape" :shape-id (:id shape)) (log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))] (ctk/detach-shape shape))]
; If the shape still refers to the remote component, try to find the corresponding near one ;; If the shape still refers to the remote component, try to find the corresponding near one
; and link to it. If not, detach the shape. ;; and link to it. If not, detach the shape.
(log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(if (some? matching-shape) (if (some? matching-shape)
(-> (pcb/empty-changes nil page-id) (-> (pcb/empty-changes nil page-id)
@@ -324,7 +325,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert shape in a normal copy, removing nested copy status ;; Convert shape in a normal copy, removing nested copy status
(log/debug :hint " -> unhead shape") (log/debug :hint " -> unhead shape")
(ctk/unhead-shape shape))] (ctk/unhead-shape shape))]
@@ -337,7 +338,7 @@
[_ {:keys [shape page-id args] :as error} file-data _] [_ {:keys [shape page-id args] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert shape in a nested head, adding component info ;; Convert shape in a nested head, adding component info
(log/debug :hint " -> reroot shape") (log/debug :hint " -> reroot shape")
(ctk/rehead-shape shape (:component-file args) (:component-id args)))] (ctk/rehead-shape shape (:component-file args) (:component-id args)))]
@@ -350,7 +351,8 @@
[_ {:keys [shape args] :as error} file-data _] [_ {:keys [shape args] :as error} file-data _]
(let [repair-component (let [repair-component
(fn [component] (fn [component]
(let [objects (:objects component) ;; we only have encounter this on deleted components, (let [objects (:objects component)
;; we only have encounter this on deleted components,
;; so the relevant objects are inside the component ;; so the relevant objects are inside the component
to-detach (->> (:cycles-ids args) to-detach (->> (:cycles-ids args)
(map #(get objects %)) (map #(get objects %))
@@ -378,7 +380,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Remove shape-ref ;; Remove shape-ref
(log/debug :hint " -> unset :shape-ref") (log/debug :hint " -> unset :shape-ref")
(dissoc shape :shape-ref))] (dissoc shape :shape-ref))]
@@ -391,7 +393,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a nested main head. ;; Convert the shape in a nested main head.
(log/debug :hint " -> unset :component-root") (log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))] (dissoc shape :component-root))]
@@ -404,7 +406,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a top main head. ;; Convert the shape in a top main head.
(log/debug :hint " -> set :component-root") (log/debug :hint " -> set :component-root")
(assoc shape :component-root true))] (assoc shape :component-root true))]
@@ -418,7 +420,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a nested copy head. ;; Convert the shape in a nested copy head.
(log/debug :hint " -> unset :component-root") (log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))] (dissoc shape :component-root))]
@@ -431,7 +433,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a top copy root. ;; Convert the shape in a top copy root.
(log/debug :hint " -> set :component-root") (log/debug :hint " -> set :component-root")
(assoc shape :component-root true))] (assoc shape :component-root true))]
@@ -444,7 +446,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Detach the shape and convert it to non instance. ;; Detach the shape and convert it to non instance.
(log/debug :hint " -> detach shape" :shape-id (:id shape)) (log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))] (ctk/detach-shape shape))]
@@ -457,7 +459,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Detach the shape and convert it to non instance. ;; Detach the shape and convert it to non instance.
(log/debug :hint " -> detach shape" :shape-id (:id shape)) (log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))] (ctk/detach-shape shape))]
@@ -470,7 +472,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; There is no solution that may recover it with confidence ;; There is no solution that may recover it with confidence
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
shape)] shape)]
@@ -483,7 +485,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a frame. ;; Convert the shape in a frame.
(log/debug :hint " -> set :type :frame") (log/debug :hint " -> set :type :frame")
(assoc shape :type :frame (assoc shape :type :frame
:fills [] :fills []
@@ -502,7 +504,7 @@
[_ {:keys [shape] :as error} file-data _] [_ {:keys [shape] :as error} file-data _]
(let [repair-component (let [repair-component
(fn [component] (fn [component]
; Remove the objects key, or set it to {} if the component is deleted ;; Remove the objects key, or set it to {} if the component is deleted
(if (:deleted component) (if (:deleted component)
(do (do
(log/debug :hint " -> set :objects {}") (log/debug :hint " -> set :objects {}")

View File

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

View File

@@ -1898,10 +1898,10 @@
(gsh/absolute-move shape new-pos))) (gsh/absolute-move shape new-pos)))
(defn- switch-path-change-value (defn- switch-path-change-value
[prev-shape ;; The shape before the switch [prev-shape ; The shape before the switch
current-shape ;; The shape after the switch (a clean copy) current-shape ; The shape after the switch (a clean copy)
ref-shape ;; The referenced shape on the main component ref-shape ; The referenced shape on the main component
;; before the switch ; before the switch
attr] attr]
(let [old-width (-> ref-shape :selrect :width) (let [old-width (-> ref-shape :selrect :width)
new-width (-> prev-shape :selrect :width) new-width (-> prev-shape :selrect :width)
@@ -1918,10 +1918,9 @@
(defn- switch-text-change-value (defn- switch-text-change-value
[prev-content ;; The :content of the text before the switch [prev-content ; The :content of the text before the switch
current-content ;; The :content of the text after the switch (a clean copy) current-content ; The :content of the text after the switch (a clean copy)
ref-content touched] ;; The :content of the referenced text on the main component ref-content touched] ; The :content of the referenced text on the main component before the switch
;; before the switch
(let [;; We need the differences between the contents on the main (let [;; We need the differences between the contents on the main
;; components. current-content is the content of a clean copy, ;; components. current-content is the content of a clean copy,
;; so for all effects its the same as the content on its main ;; so for all effects its the same as the content on its main

View File

@@ -13,14 +13,15 @@
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.schema.generators :as sg])) [app.common.schema.generators :as sg]))
;; WARNING: options are not deleted when changing event or action type, so it can be ;; WARNING: options are not deleted when changing event or action
;; restored if the user changes it back later. ;; type, so it can be restored if the user changes it back later.
;; ;;
;; But that means that an interaction may have for example a delay or ;; But that means that an interaction may have for example a delay or
;; destination, even if its type does not require it (but a previous type did). ;; destination, even if its type does not require it (but a previous
;; type did).
;; ;;
;; So make sure to use has-delay/has-destination... functions, or similar, ;; So make sure to use has-delay/has-destination... functions, or
;; before reading them. ;; similar, before reading them.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA ;; SCHEMA
@@ -452,16 +453,15 @@
(gpt/point 0 0))) (gpt/point 0 0)))
(defn calc-overlay-position (defn calc-overlay-position
[interaction ;; interaction data [interaction ; interaction data
shape ;; Shape with the interaction shape ; Shape with the interaction
objects ;; the objects tree objects ; the objects tree
relative-to-shape ;; the interaction position is realtive to this relative-to-shape ; the interaction position is realtive to this shape
;; sape base-frame ; the base frame of the current interaction
base-frame ;; the base frame of the current interaction dest-frame ; the frame to display with this interaction
dest-frame ;; the frame to display with this interaction frame-offset] ; if this interaction starts in a frame opened
frame-offset] ;; if this interaction starts in a frame opened ; on another interaction, this is the position
;; on another interaction, this is the position ; of that frame
;; of that frame
(assert (check-interaction interaction)) (assert (check-interaction interaction))
(assert (has-overlay-opts interaction) (assert (has-overlay-opts interaction)
"expected compatible interaction map") "expected compatible interaction map")

View File

@@ -19,3 +19,10 @@
(def schema:role (def schema:role
[::sm/one-of {:title "TeamRole"} valid-roles]) [::sm/one-of {:title "TeamRole"} valid-roles])
;; FIXME: specify more fields
(def schema:team
[:map {:title "Team"}
[:id ::sm/uuid]
[:name :string]])

View File

@@ -111,7 +111,7 @@
(def token-name-ref (def token-name-ref
[:re {:title "TokenNameRef" :gen/gen sg/text} [: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 (def ^:private schema:color
[:map [:map
@@ -474,6 +474,8 @@
:height #{:sizing :dimensions} :height #{:sizing :dimensions}
:max-width #{:sizing :dimensions} :max-width #{:sizing :dimensions}
:max-height #{:sizing :dimensions} :max-height #{:sizing :dimensions}
:min-width #{:sizing :dimensions}
:min-height #{:sizing :dimensions}
:x #{:dimensions} :x #{:dimensions}
:y #{:dimensions} :y #{:dimensions}
:rotation #{:number :rotation} :rotation #{:number :rotation}

View File

@@ -179,9 +179,9 @@ RUN set -eux; \
FROM base AS setup-utils FROM base AS setup-utils
ENV CLJKONDO_VERSION=2025.07.28 \ ENV CLJKONDO_VERSION=2026.01.19 \
BABASHKA_VERSION=1.12.208 \ BABASHKA_VERSION=1.12.208 \
CLJFMT_VERSION=0.13.1 CLJFMT_VERSION=0.15.6
RUN set -ex; \ RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \ ARCH="$(dpkg --print-architecture)"; \
@@ -398,7 +398,6 @@ COPY files/Caddyfile /home/
COPY files/selfsigned.crt /home/ COPY files/selfsigned.crt /home/
COPY files/selfsigned.key /home/ COPY files/selfsigned.key /home/
COPY files/start-tmux.sh /home/start-tmux.sh COPY files/start-tmux.sh /home/start-tmux.sh
COPY files/start-tmux-back.sh /home/start-tmux-back.sh
COPY files/entrypoint.sh /home/entrypoint.sh COPY files/entrypoint.sh /home/entrypoint.sh
COPY files/init.sh /home/init.sh COPY files/init.sh /home/init.sh

View File

@@ -5,6 +5,7 @@
localhost:3449 { localhost:3449 {
reverse_proxy localhost:4449 reverse_proxy localhost:4449
tls /home/selfsigned.crt /home/selfsigned.key tls /home/selfsigned.crt /home/selfsigned.key
header -Strict-Transport-Security
} }
http://localhost:3450 { http://localhost:3450 {

View File

@@ -6,4 +6,4 @@ desc: Create, deploy, and use the Penpot plugin API with our comprehensive docum
# Penpot plugins API # Penpot plugins API
We've got all the documentation you need for the API right <a target="_blank" href="https://penpot-plugins-api-doc.pages.dev/">here</a>. We've got all the documentation you need for the API right <a target="_blank" href="https://doc.plugins.penpot.app/">here</a>.

View File

@@ -9,13 +9,13 @@ desc: See the Penpot plugin API changelog for version 1.0! Find breaking changes
### <g-emoji class="g-emoji" alias="boom" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f680.png"><img class="emoji" alt="boom" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f680.png"></g-emoji> Epics and highlights</code> ### <g-emoji class="g-emoji" alias="boom" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f680.png"><img class="emoji" alt="boom" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f680.png"></g-emoji> Epics and highlights</code>
- This marks the release of version 1.0, and from this point forward, well do our best to avoid making any more breaking changes (or make deprecations backward compatible). - This marks the release of version 1.0, and from this point forward, well do our best to avoid making any more breaking changes (or make deprecations backward compatible).
- Weve redone the documentation. You can check the API here: - Weve redone the documentation. You can check the API here:
[https://penpot-plugins-api-doc.pages.dev/](https://penpot-plugins-api-doc.pages.dev/) [https://doc.plugins.penpot.app/](https://doc.plugins.penpot.app/)
- New samples repository with lots of samples to use the API: - New samples repository with lots of samples to use the API:
[https://github.com/penpot/penpot-plugins-samples](https://github.com/penpot/penpot-plugins-samples) [https://github.com/penpot/penpot-plugins-samples](https://github.com/penpot/penpot-plugins-samples)
### <g-emoji class="g-emoji" alias="boom" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f4a5.png"><img class="emoji" alt="boom" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f4a5.png"></g-emoji> Breaking changes & Deprecations ### <g-emoji class="g-emoji" alias="boom" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f4a5.png"><img class="emoji" alt="boom" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f4a5.png"></g-emoji> Breaking changes & Deprecations
- Changed types names to remove the Penpot prefix. So for example: <code class="language-js">PenpotShape</code> becomes <code class="language-js">Shape</code>; <code class="language-js">PenpotFile</code> becomes <code class="language-js">File</code>, and so on. Check the [API documentation](https://penpot-plugins-api-doc.pages.dev/) for more details. - Changed types names to remove the Penpot prefix. So for example: <code class="language-js">PenpotShape</code> becomes <code class="language-js">Shape</code>; <code class="language-js">PenpotFile</code> becomes <code class="language-js">File</code>, and so on. Check the [API documentation](https://doc.plugins.penpot.app/) for more details.
- Changes on the <code class="language-js">penpot.on</code> and <code class="language-js">penpot.off</code> methods. - Changes on the <code class="language-js">penpot.on</code> and <code class="language-js">penpot.off</code> methods.
Previously you had to send the original callback to the off method in order to remove an event listener. Now, <code class="language-js">penpot.on</code> will return an *id* that you can pass to the <code class="language-js">penpot.off</code> method in order to remove the listener. Previously you had to send the original callback to the off method in order to remove an event listener. Now, <code class="language-js">penpot.on</code> will return an *id* that you can pass to the <code class="language-js">penpot.off</code> method in order to remove the listener.

View File

@@ -49,7 +49,7 @@ There are two libraries that can help you with your plugin's development. They a
### Plugin styles ### Plugin styles
<code class="language-js">@penpot/plugin-styles</code> contains styles to help build the UI for Penpot plugins. To check the styles go to <a target="_blank" href="https://penpot-plugins-styles.pages.dev/">Plugin styles</a>. <code class="language-js">@penpot/plugin-styles</code> contains styles to help build the UI for Penpot plugins. To check the styles go to <a target="_blank" href="https://styles-doc.plugins.penpot.app/">Plugin styles</a>.
```bash ```bash
npm install @penpot/plugin-styles npm install @penpot/plugin-styles
@@ -139,7 +139,7 @@ parent.postMessage(responseMessage, targetOrigin);
By using these message-based events, any data retrieved through the Penpot API can be communicated to and from your plugin interface seamlessly. By using these message-based events, any data retrieved through the Penpot API can be communicated to and from your plugin interface seamlessly.
For more detailed information, refer to the [Penpot Plugins API Documentation](https://penpot-plugins-api-doc.pages.dev/). For more detailed information, refer to the [Penpot Plugins API Documentation](https://doc.plugins.penpot.app/).
## 2.5. Step 5. Build the plugin file ## 2.5. Step 5. Build the plugin file

View File

@@ -86,7 +86,7 @@ penpot.library.local.createTypography();
Penpot has dark and light modes, and you can easily add this to your plugin so your interface adapts to both themes. When you add theme support, your plugin will automatically sync with Penpot's interface settings, so the user experience is consistent no matter which mode is selected. This makes your plugin look better and also ensures it stays in line with Penpot's overall design. Penpot has dark and light modes, and you can easily add this to your plugin so your interface adapts to both themes. When you add theme support, your plugin will automatically sync with Penpot's interface settings, so the user experience is consistent no matter which mode is selected. This makes your plugin look better and also ensures it stays in line with Penpot's overall design.
Just a heads-up: if you use the <a target="_blank" href="https://penpot-plugins-styles.pages.dev/">plugin-styles library</a>, many elements will automatically adapt to dark or light mode without any extra effort from you. However, if you need to customize specific elements, be sure to use the selectors provided in the <code class="language-bash">styles.css</code> of the example. Just a heads-up: if you use the <a target="_blank" href="https://styles-doc.plugins.penpot.app/">plugin-styles library</a>, many elements will automatically adapt to dark or light mode without any extra effort from you. However, if you need to customize specific elements, be sure to use the selectors provided in the <code class="language-bash">styles.css</code> of the example.
<a target="_blank" href="https://github.com/penpot/penpot-plugins-samples/tree/main/theme">Theme example</a> <a target="_blank" href="https://github.com/penpot/penpot-plugins-samples/tree/main/theme">Theme example</a>

View File

@@ -40,7 +40,7 @@ The plugin <a target="_blank" href="https://www.npmjs.com/package/@penpot/plugin
### Is the API ready to use the prototyping features? ### Is the API ready to use the prototyping features?
Absolutely! You can definitely create flows and interactions in the same elements as in the interface, like frames, shapes, and groups. Just check out the API documentation for the methods: createFlow, addInteraction, or removeInteraction. And if you need more help, you can always check out the <a target="_blank" href="https://penpot-plugins-api-doc.pages.dev/interfaces/PenpotFlow">PenpotFlow</a> or <a target="_blank" href="https://penpot-plugins-api-doc.pages.dev/interfaces/PenpotInteraction">PenpotInteraction</a> interfaces. Absolutely! You can definitely create flows and interactions in the same elements as in the interface, like frames, shapes, and groups. Just check out the API documentation for the methods: createFlow, addInteraction, or removeInteraction. And if you need more help, you can always check out the <a target="_blank" href="https://doc.plugins.penpot.app/interfaces/Flow">Flow</a> or <a target="_blank" href="https://doc.plugins.penpot.app/interfaces/Interaction">Interaction</a> interfaces.
### Are there any security or quality criteria I should be aware of? ### Are there any security or quality criteria I should be aware of?
@@ -48,7 +48,8 @@ There are no set requirements. However, we can recommend the use of <a target="_
### Is it necessary to create plugins with a UI? ### Is it necessary to create plugins with a UI?
No, its completely optional, in fact, we have an example of a plugin without UI. Try the plugin using this url to install it: <code class="language-js">https:\/\/create-palette-penpot-plugin.pages.dev/assets/manifest.json</code> or check the code <a target="_blank" href="https://github.com/penpot/penpot-plugins/tree/main/apps/create-palette-plugin">here</a> No, its completely optional, in fact, we have an example of a plugin without UI. Try the plugin using this url to install it: <code class="language-js">https:\/\/create-palette.plugins.penpot.app/assets/manifest.json</code> or check the code <a target="_blank" href="https://github.com/penpot/penpot/tree/main/plugins/apps/create-palette-plugin">here</a>
### Can I create components? ### Can I create components?
@@ -58,7 +59,7 @@ Yes, it is possible to create components using:
createComponent(shapes: Shape[]): LibraryComponent; createComponent(shapes: Shape[]): LibraryComponent;
``` ```
Take a look at the Penpot Library methods in the <a target="_blank" href="https://penpot-plugins-api-doc.pages.dev/interfaces/Library">API documentation</a> or this <a target="_blank" href="https://github.com/penpot/penpot-plugins-samples/tree/main/components-library">simple example</a>. Take a look at the Penpot Library methods in the <a target="_blank" href="https://doc.plugins.penpot.app/interfaces/Library">API documentation</a> or this <a target="_blank" href="https://github.com/penpot/penpot-plugins-samples/tree/main/components-library">simple example</a>.
### Is there a place where I can share my plugin? ### Is there a place where I can share my plugin?

View File

@@ -69,12 +69,13 @@ You need to provide the plugin's manifest URL for the installation. If there are
| Name | URL | | Name | URL |
| ------------- | ------------------------------------------------------------------- | | ------------- | ------------------------------------------------------------------- |
| Lorem Ipsum | https://lorem-ipsum-penpot-plugin.pages.dev/assets/manifest.json | | Color palette | https://create-palette.plugins.penpot.app/assets/manifest.json |
| Contrast | https://contrast-penpot-plugin.pages.dev/assets/manifest.json | | Contrast | https://contrast.plugins.penpot.app/assets/manifest.json |
| Feather icons | https://icons-penpot-plugin.pages.dev/assets/manifest.json | | Feather icons | https://icons.plugins.penpot.app/assets/manifest.json |
| Tables | https://table-penpot-plugin.pages.dev/assets/manifest.json | | Lorem ipsum | https://lorem-ipsum.plugins.penpot.app/assets/manifest.json |
| Color palette | https://create-palette-penpot-plugin.pages.dev/assets/manifest.json | | Rename layers | https://rename-layers.plugins.penpot.app/assets/manifest.json |
| Rename layers | https://rename-layers-penpot-plugin.pages.dev/assets/manifest.json | | Tables | https://table.plugins.penpot.app/assets/manifest.json |
## 1.4. Plugin's basics ## 1.4. Plugin's basics

View File

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

View File

@@ -12,11 +12,14 @@
["node:process" :as process] ["node:process" :as process]
[app.common.data :as d] [app.common.data :as d]
[app.common.flags :as flags] [app.common.flags :as flags]
[app.common.logging :as l]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.version :as v] [app.common.version :as v]
[cljs.core :as c] [cljs.core :as c]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(l/set-level! :info)
(def ^:private defaults (def ^:private defaults
{:public-uri "http://localhost:3449" {:public-uri "http://localhost:3449"
:tenant "default" :tenant "default"
@@ -30,7 +33,7 @@
[:map {:title "config"} [:map {:title "config"}
[:secret-key :string] [:secret-key :string]
[:public-uri {:optional true} ::sm/uri] [:public-uri {:optional true} ::sm/uri]
[:management-api-key {:optional true} :string] [:exporter-shared-key {:optional true} :string]
[:host {:optional true} :string] [:host {:optional true} :string]
[:tenant {:optional true} :string] [:tenant {:optional true} :string]
[:flags {:optional true} [::sm/set :keyword]] [:flags {:optional true} [::sm/set :keyword]]
@@ -98,8 +101,10 @@
(c/get config key default))) (c/get config key default)))
(def management-key (def management-key
(or (c/get config :management-api-key) (let [key (or (c/get config :exporter-shared-key)
(let [secret-key (c/get config :secret-key) (let [secret-key (c/get config :secret-key)
derived-key (crypto/hkdfSync "blake2b512" secret-key, "management" "" 32)] derived-key (crypto/hkdfSync "blake2b512" secret-key, "exporter" "" 32)]
(-> (.from buffer/Buffer derived-key) (-> (.from buffer/Buffer derived-key)
(.toString "base64url"))))) (.toString "base64url"))))]
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key))
key))

View File

@@ -73,7 +73,7 @@
(p/mcat (fn [blob] (p/mcat (fn [blob]
(let [fdata (new http/FormData) (let [fdata (new http/FormData)
agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}}) agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers #js {"X-Shared-Key" cf/management-key headers #js {"X-Shared-Key" (str "exporter " cf/management-key)
"Authorization" (str "Bearer " auth-token)} "Authorization" (str "Bearer " auth-token)}
request #js {:headers headers request #js {:headers headers

View File

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

View File

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

View File

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

View File

@@ -210,6 +210,22 @@ test("Renders a file with shadows applied to any kind of shape", async ({
await expect(workspace.canvas).toHaveScreenshot(); 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 ({ test("Renders a file with a closed path shape with multiple segments using strokes and shadow", async ({
page, page,
}) => { }) => {

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -759,4 +759,87 @@ test.describe("Tokens: Apply token", () => {
}); });
await expect(StrokeWidthPillSmall).toBeVisible(); await expect(StrokeWidthPillSmall).toBeVisible();
}); });
test("User applies margin token to a shape", async ({ page }) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
});
// Set up
await workspace.mockConfigFlags(["enable-feature-token-input"]);
await workspace.setupEmptyFile();
await workspace.mockGetFile("workspace/get-file-layout-stroke-token-json");
await workspace.goToWorkspace();
// Select shape apply stroke
await workspace.layers
.getByTestId("layer-row")
.nth(1)
.getByRole("button", { name: "Toggle layer" })
.click();
await workspace.layers.getByTestId("layer-row").nth(2).click();
const rightSidebar = page.getByTestId("right-sidebar");
await expect(rightSidebar).toBeVisible();
await rightSidebar.getByTestId("add-stroke").click();
// Apply margin token from token panel
const tokensTab = page.getByRole("tab", { name: "Tokens" });
await expect(tokensTab).toBeVisible();
await tokensTab.click();
await page.getByRole("button", { name: "Dimensions 4" }).click();
await page.getByRole("button", { name: "dim", exact: true }).click();
const tokensSidebar = workspace.tokensSidebar;
await expect(
tokensSidebar.getByRole("button", { name: "dim.md" }),
).toBeVisible();
await tokensSidebar
.getByRole("button", { name: "dim.md" })
.click({ button: "right" });
await page
.getByTestId("tokens-context-menu-for-token")
.getByText("Spacing")
.hover();
await page
.getByTestId("tokens-context-menu-for-token")
.getByText("Horizontal")
.click();
// Check if token pill is visible on right sidebar
const layoutItemSectionSidebar = rightSidebar.getByRole("region", {
name: "layout item menu",
});
await expect(layoutItemSectionSidebar).toBeVisible();
const marginPillMd = layoutItemSectionSidebar.getByRole("button", {
name: "dim.md",
});
await expect(marginPillMd).toBeVisible();
await marginPillMd.click();
const dimensionTokenOptionXl = page.getByRole("option", { name: "dim.xl" });
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
const marginPillXL = layoutItemSectionSidebar.getByRole("button", {
name: "dim.xl",
});
await expect(marginPillXL).toBeVisible();
// Detach token from right sidebar and apply another from dropdown
const detachButton = layoutItemSectionSidebar.getByRole("button", {
name: "Detach token",
});
await detachButton.click();
await expect(marginPillXL).not.toBeVisible();
const horizontalMarginInput = layoutItemSectionSidebar.getByText('Horizontal marginOpen token');
await expect(horizontalMarginInput).toBeVisible();
const tokenDropdown = horizontalMarginInput.getByRole('button', { name: 'Open token list' });
await tokenDropdown.click();
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
await expect(marginPillXL).toBeVisible();
});
}); });

226
frontend/pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -1205,6 +1205,7 @@
file (dsh/lookup-file state file-id) file (dsh/lookup-file state file-id)
file-data (get file :data) file-data (get file :data)
ignore-until (get file :ignore-sync-until) ignore-until (get file :ignore-sync-until)
permissions (:permissions state)
libraries-need-sync libraries-need-sync
(->> (vals (get state :files)) (->> (vals (get state :files))
@@ -1224,7 +1225,8 @@
do-dismiss do-dismiss
#(st/emit! ignore-sync (ntf/hide))] #(st/emit! ignore-sync (ntf/hide))]
(when (seq libraries-need-sync) (when (and (:can-edit permissions)
(seq libraries-need-sync))
(rx/of (ntf/dialog (rx/of (ntf/dialog
:content (tr "workspace.updates.there-are-updates") :content (tr "workspace.updates.there-are-updates")
:controls :inline-actions :controls :inline-actions

View File

@@ -539,10 +539,11 @@
value shape-ids value shape-ids
#{:stroke-width} #{:stroke-width}
page-id)) page-id))
(some attributes #{:max-width :max-height})
(some attributes #{:max-width :max-height :layout-item-max-h :layout-item-max-w :layout-item-min-h :layout-item-min-w})
(conj #(update-layout-sizing-limits (conj #(update-layout-sizing-limits
value shape-ids value shape-ids
(set (filter attributes #{:max-width :max-height})) (set (filter attributes #{:max-width :max-height :layout-item-max-h :layout-item-max-w :layout-item-min-h :layout-item-min-w}))
page-id)))) page-id))))
(defn apply-dimensions-token (defn apply-dimensions-token

View File

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

View File

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

View File

@@ -189,6 +189,7 @@
:float :float
:string :string
[:= :multiple]]]] [:= :multiple]]]]
[:text-icon {:optional true} :string]
[:default {:optional true} [:maybe :string]] [:default {:optional true} [:maybe :string]]
[:placeholder {:optional true} :string] [:placeholder {:optional true} :string]
[:icon {:optional true} [:maybe schema:icon]] [:icon {:optional true} [:maybe schema:icon]]
@@ -216,7 +217,8 @@
is-selected-on-focus nillable is-selected-on-focus nillable
tokens applied-token empty-to-end tokens applied-token empty-to-end
on-change on-blur on-focus on-detach on-change on-blur on-focus on-detach
property align ref name] property align ref name
text-icon]
:rest props}] :rest props}]
(let [;; NOTE: we use mfu/bean here for transparently handle (let [;; NOTE: we use mfu/bean here for transparently handle
@@ -637,14 +639,23 @@
:on-change store-raw-value :on-change store-raw-value
:variant "comfortable" :variant "comfortable"
:disabled disabled :disabled disabled
:slot-start (when icon :slot-start (when (or icon text-icon)
(mf/html [:> tooltip* (mf/html
[:> tooltip*
{:content property {:content property
:id property} :id property}
[:> icon* {:icon-id icon (cond
icon
[:> icon*
{:icon-id icon
:size "s" :size "s"
:aria-labelledby property :aria-labelledby property
:class (stl/css :icon)}]])) :class (stl/css :icon)}]
text-icon
[:div {:class (stl/css :text-icon)
:aria-labelledby property}
text-icon])]))
:slot-end (when-not disabled :slot-end (when-not disabled
(when (some? tokens) (when (some? tokens)
(mf/html [:> icon-button* {:variant "ghost" (mf/html [:> icon-button* {:variant "ghost"
@@ -676,14 +687,23 @@
:disabled disabled :disabled disabled
:on-blur on-blur :on-blur on-blur
:class inner-class :class inner-class
:slot-start (when icon :slot-start (when (or icon text-icon)
(mf/html [:> tooltip* (mf/html
[:> tooltip*
{:content property {:content property
:id property} :id property}
[:> icon* {:icon-id icon (cond
icon
[:> icon*
{:icon-id icon
:size "s" :size "s"
:aria-labelledby property :aria-labelledby property
:class (stl/css :icon)}]])) :class (stl/css :icon)}]
text-icon
[:div {:class (stl/css :text-icon)
:aria-labelledby property}
text-icon])]))
:token-wrapper-ref token-wrapper-ref :token-wrapper-ref token-wrapper-ref
:token-detach-btn-ref token-detach-btn-ref :token-detach-btn-ref token-detach-btn-ref
:detach-token detach-token})))] :detach-token detach-token})))]
@@ -718,7 +738,8 @@
(mf/with-effect [dropdown-options] (mf/with-effect [dropdown-options]
(mf/set-ref-val! options-ref dropdown-options)) (mf/set-ref-val! options-ref dropdown-options))
[:div {:class (dm/str class " " (stl/css :input-wrapper)) (if (some? icon)
[:div {:class [class (stl/css :input-wrapper)]
:ref wrapper-ref} :ref wrapper-ref}
(if (and (some? token-applied) (if (and (some? token-applied)
@@ -735,4 +756,22 @@
:focused focused-id :focused focused-id
:align align :align align
:empty-to-end empty-to-end :empty-to-end empty-to-end
:ref set-option-ref}]))])) :ref set-option-ref}]))]
[:div {:class [class (stl/css :input-wrapper)]
:ref wrapper-ref}
(if (and (some? token-applied)
(not= :multiple token-applied))
[:> token-field* token-props]
[:> input-field* input-props])
(when ^boolean is-open
(let [options (if (delay? dropdown-options) @dropdown-options dropdown-options)]
[:> options-dropdown* {:on-click on-option-click
:id listbox-id
:options options
:selected selected-id
:focused focused-id
:align align
:empty-to-end empty-to-end
:ref set-option-ref}]))])))

View File

@@ -8,6 +8,7 @@
@use "ds/spacing.scss" as *; @use "ds/spacing.scss" as *;
@use "ds/_sizes.scss" as *; @use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as t; @use "ds/typography.scss" as t;
@use "ds/_utils.scss" as *;
.input-wrapper { .input-wrapper {
--input-padding-size: var(--sp-xs); --input-padding-size: var(--sp-xs);
@@ -29,7 +30,14 @@
.icon { .icon {
color: var(--color-foreground-secondary); color: var(--color-foreground-secondary);
min-width: var(--sp-l); min-inline-size: var(--sp-l);
}
.text-icon {
color: var(--color-foreground-secondary);
@include t.use-typography("code-font");
inline-size: fit-content;
min-inline-size: px2rem(40);
} }
.invisible-button { .invisible-button {

View File

@@ -17,7 +17,7 @@
--token-field-outline-color: none; --token-field-outline-color: none;
--token-field-height: var(--sp-xxxl); --token-field-height: var(--sp-xxxl);
--token-field-margin: unset; --token-field-margin: unset;
display: grid; display: inline-flex;
column-gap: var(--sp-xs); column-gap: var(--sp-xs);
align-items: center; align-items: center;
position: relative; position: relative;

View File

@@ -437,7 +437,8 @@
[:> menu-entry* {:title (tr "workspace.shape.menu.flatten") [:> menu-entry* {:title (tr "workspace.shape.menu.flatten")
:on-click do-transform-to-path}]) :on-click do-transform-to-path}])
(when (and (not disable-booleans) (when (and (not has-frame?)
(not disable-booleans)
(or multiple? (and single? (or is-group? is-bool?)))) (or multiple? (and single? (or is-group? is-bool?))))
[:> menu-entry* {:title (tr "workspace.shape.menu.path")} [:> menu-entry* {:title (tr "workspace.shape.menu.path")}
[:> menu-entry* {:title (tr "workspace.shape.menu.union") [:> menu-entry* {:title (tr "workspace.shape.menu.union")

View File

@@ -119,6 +119,7 @@
[:button {:class (stl/css-case [:button {:class (stl/css-case
:toggle-content true :toggle-content true
:inverse expanded?) :inverse expanded?)
:aria-label "Toggle layer"
:on-click on-toggle-collapse} :on-click on-toggle-collapse}
deprecated-icon/arrow]) deprecated-icon/arrow])

View File

@@ -8,14 +8,20 @@
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.schema :as sm]
[app.common.types.shape.layout :as ctl] [app.common.types.shape.layout :as ctl]
[app.common.types.token :as tk]
[app.main.data.workspace :as udw] [app.main.data.workspace :as udw]
[app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.features :as features]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.numeric-input :as deprecated-input]
[app.main.ui.components.title-bar :refer [title-bar*]] [app.main.ui.components.title-bar :refer [title-bar*]]
[app.main.ui.context :as muc]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
[app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]] [app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]]
[app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.icons :as deprecated-icon] [app.main.ui.icons :as deprecated-icon]
@@ -24,6 +30,51 @@
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(mf/defc numeric-input-wrapper*
{::mf/private true}
[{:keys [values name applied-tokens align on-detach placeholder] :rest props}]
(let [tokens (mf/use-ctx muc/active-tokens-by-type)
input-type (cond
(some #{:m2 :m4} [name])
:horizontal-margin
(some #{:m1 :m3} [name])
:vertical-margin
(= name :layout-item-max-w)
:max-width
(= name :layout-item-max-h)
:max-height
(= name :layout-item-min-w)
:min-width
(= name :layout-item-min-h)
:min-height
:else
name)
tokens (mf/with-memo [tokens input-type]
(delay
(-> (deref tokens)
(select-keys (get tk/tokens-by-input input-type))
(not-empty))))
on-detach-attr
(mf/use-fn
(mf/deps on-detach name)
#(on-detach % name))
props (mf/spread-props props
{:placeholder (or placeholder "--")
:applied-token (get applied-tokens name)
:tokens tokens
:align align
:on-detach on-detach-attr
:value (get values name)})]
[:> numeric-input* props]))
(def layout-item-attrs (def layout-item-attrs
[:layout-item-margin ;; {:m1 0 :m2 0 :m3 0 :m4 0} [:layout-item-margin ;; {:m1 0 :m2 0 :m3 0 :m4 0}
:layout-item-margin-type ;; :simple :multiple :layout-item-margin-type ;; :simple :multiple
@@ -46,40 +97,113 @@
(select-margins (= prop :m1) (= prop :m2) (= prop :m3) (= prop :m4))) (select-margins (= prop :m1) (= prop :m2) (= prop :m3) (= prop :m4)))
(mf/defc margin-simple* (mf/defc margin-simple*
[{:keys [value on-change on-blur]}] [{:keys [value on-change on-blur applied-tokens ids]}]
(let [m1 (:m1 value) (let [token-numeric-inputs
(features/use-feature "tokens/numeric-input")
m1 (:m1 value)
m2 (:m2 value) m2 (:m2 value)
m3 (:m3 value) m3 (:m3 value)
m4 (:m4 value) m4 (:m4 value)
m1-placeholder (if (and (not= value :multiple) (not= m1 m3)) (tr "settings.multiple") "--")
m2-placeholder (if (and (not= value :multiple) (not= m2 m4)) (tr "settings.multiple") "--")
m1 (when (and (not= value :multiple) (= m1 m3)) m1) m1 (when (and (not= value :multiple) (= m1 m3)) m1)
m2 (when (and (not= value :multiple) (= m2 m4)) m2) m2 (when (and (not= value :multiple) (= m2 m4)) m2)
token-applied-m1 (:m1 applied-tokens)
token-applied-m2 (:m2 applied-tokens)
token-applied-m3 (:m3 applied-tokens)
token-applied-m4 (:m4 applied-tokens)
token-applied-m1 (if (and (not= applied-tokens :multiple) (= token-applied-m1 token-applied-m3)) token-applied-m1
:multiple)
token-applied-m2 (if (and (not= applied-tokens :multiple) (= token-applied-m2 token-applied-m4)) token-applied-m2
:multiple)
m1-placeholder (if (and (not= value :multiple)
(= m1 m3)
(= token-applied-m1 token-applied-m3))
"--"
(tr "settings.multiple"))
m2-placeholder (if (and (not= value :multiple)
(= m2 m4)
(= token-applied-m2 token-applied-m4))
"--"
(tr "settings.multiple"))
on-focus on-focus
(mf/use-fn (mf/use-fn
(fn [event] (mf/deps select-margins)
(let [attr (-> (dom/get-current-target event) (fn [attr event]
(dom/get-data "name")
(keyword))]
(case attr (case attr
:m1 (select-margins true false true false) :m1 (select-margins true false true false)
:m2 (select-margins false true false true)) :m2 (select-margins false true false true))
(dom/select-target event)))
(dom/select-target event)))) on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
:attributes #{attr}
:shape-ids ids}))))
on-detach-horizontal
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(run! #(on-detach-token token %) [:m2 :m4])))
on-detach-vertical
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(run! #(on-detach-token token %) [:m1 :m3])))
on-change' on-change'
(mf/use-fn (mf/use-fn
(mf/deps on-change) (mf/deps on-change ids)
(fn [value event] (fn [value attr]
(let [attr (-> (dom/get-current-target event) (if (or (string? value) (int? value))
(dom/get-data "name") (on-change :simple attr value)
(keyword))] (do
(on-change :simple attr value))))] (st/emit!
(dwta/toggle-token {:token (first value)
:attrs (if (= :m1 attr)
#{:m1 :m3}
#{:m2 :m4})
:shape-ids ids}))))))
on-focus-m1
(mf/use-fn (mf/deps on-focus) #(on-focus :m1))
on-focus-m2
(mf/use-fn (mf/deps on-focus) #(on-focus :m2))
on-m1-change
(mf/use-fn (mf/deps on-change') #(on-change' % :m1))
on-m2-change
(mf/use-fn (mf/deps on-change') #(on-change' % :m2))]
[:div {:class (stl/css :margin-simple)} [:div {:class (stl/css :margin-simple)}
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-m1-change
:on-detach on-detach-vertical
:class (stl/css :vertical-margin-wrapper)
:on-blur on-blur
:on-focus on-focus-m1
:placeholder m1-placeholder
:icon i/margin-top-bottom
:min 0
:name :m1
:property "Vertical margin "
:nillable true
:applied-tokens {:m1 token-applied-m1}
:values {:m1 m1}}]
[:div {:class (stl/css :vertical-margin) [:div {:class (stl/css :vertical-margin)
:title "Vertical margin"} :title "Vertical margin"}
[:span {:class (stl/css :icon)} [:span {:class (stl/css :icon)}
@@ -87,11 +211,28 @@
[:> deprecated-input/numeric-input* {:class (stl/css :numeric-input) [:> deprecated-input/numeric-input* {:class (stl/css :numeric-input)
:placeholder m1-placeholder :placeholder m1-placeholder
:data-name "m1" :data-name "m1"
:on-focus on-focus :on-focus on-focus-m1
:on-change on-change' :on-change on-m1-change
:on-blur on-blur :on-blur on-blur
:nillable true :nillable true
:value m1}]] :value m1}]])
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-m2-change
:on-detach on-detach-horizontal
:on-blur on-blur
:on-focus on-focus-m2
:placeholder m2-placeholder
:icon i/margin-left-right
:class (stl/css :horizontal-margin-wrapper)
:min 0
:name :m2
:align :right
:property "Horizontal margin"
:nillable true
:applied-tokens {:m2 token-applied-m2}
:values {:m2 m2}}]
[:div {:class (stl/css :horizontal-margin) [:div {:class (stl/css :horizontal-margin)
:title "Horizontal margin"} :title "Horizontal margin"}
@@ -100,38 +241,95 @@
[:> deprecated-input/numeric-input* {:class (stl/css :numeric-input) [:> deprecated-input/numeric-input* {:class (stl/css :numeric-input)
:placeholder m2-placeholder :placeholder m2-placeholder
:data-name "m2" :data-name "m2"
:on-focus on-focus :on-focus on-focus-m2
:on-change on-change' :on-change on-m2-change
:on-blur on-blur :on-blur on-blur
:nillable true :nillable true
:value m2}]]])) :value m2}]])]))
(mf/defc margin-multiple* (mf/defc margin-multiple*
[{:keys [value on-change on-blur]}] [{:keys [value on-change on-blur applied-tokens ids]}]
(let [m1 (:m1 value) (let [token-numeric-inputs
(features/use-feature "tokens/numeric-input")
m1 (:m1 value)
m2 (:m2 value) m2 (:m2 value)
m3 (:m3 value) m3 (:m3 value)
m4 (:m4 value) m4 (:m4 value)
applied-token-to-m1 (:m1 applied-tokens)
applied-token-to-m2 (:m2 applied-tokens)
applied-token-to-m3 (:m3 applied-tokens)
applied-token-to-m4 (:m4 applied-tokens)
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
:attributes #{attr}
:shape-ids ids}))))
on-focus on-focus
(mf/use-fn (mf/use-fn
(fn [event] (mf/deps select-margin)
(let [attr (-> (dom/get-current-target event) (fn [attr event]
(dom/get-data "name")
(keyword))]
(select-margin attr) (select-margin attr)
(dom/select-target event)))) (dom/select-target event)))
on-focus-m1
(mf/use-fn (mf/deps on-focus) #(on-focus :m1))
on-focus-m2
(mf/use-fn (mf/deps on-focus) #(on-focus :m2))
on-focus-m3
(mf/use-fn (mf/deps on-focus) #(on-focus :m1))
on-focus-m4
(mf/use-fn (mf/deps on-focus) #(on-focus :m2))
on-change' on-change'
(mf/use-fn (mf/use-fn
(mf/deps on-change) (mf/deps on-change ids)
(fn [value event] (fn [value attr]
(let [attr (-> (dom/get-current-target event) (if (or (string? value) (int? value))
(dom/get-data "name") (on-change :multiple attr value)
(keyword))] (do
(on-change :multiple attr value))))] (st/emit!
(dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids}))))))
on-m1-change
(mf/use-fn (mf/deps on-change') #(on-change' % :m1))
on-m2-change
(mf/use-fn (mf/deps on-change') #(on-change' % :m2))
on-m3-change
(mf/use-fn (mf/deps on-change') #(on-change' % :m3))
on-m4-change
(mf/use-fn (mf/deps on-change') #(on-change' % :m4))]
[:div {:class (stl/css :margin-multiple)} [:div {:class (stl/css :margin-multiple)}
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-m1-change
:on-detach on-detach-token
:on-blur on-blur
:on-focus on-focus-m1
:icon i/margin-top
:class (stl/css :top-margin-wrapper)
:min 0
:name :m1
:property "Top margin"
:nillable true
:applied-tokens {:m1 applied-token-to-m1}
:values {:m1 m1}}]
[:div {:class (stl/css :top-margin) [:div {:class (stl/css :top-margin)
:title "Top margin"} :title "Top margin"}
[:span {:class (stl/css :icon)} [:span {:class (stl/css :icon)}
@@ -139,11 +337,27 @@
[:> deprecated-input/numeric-input* {:class (stl/css :numeric-input) [:> deprecated-input/numeric-input* {:class (stl/css :numeric-input)
:placeholder "--" :placeholder "--"
:data-name "m1" :data-name "m1"
:on-focus on-focus :on-focus on-focus-m1
:on-change on-change' :on-change on-m1-change
:on-blur on-blur :on-blur on-blur
:nillable true :nillable true
:value m1}]] :value m1}]])
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-m2-change
:on-detach on-detach-token
:on-blur on-blur
:on-focus on-focus-m2
:icon i/margin-right
:class (stl/css :right-margin-wrapper)
:min 0
:name :m2
:align :right
:property "Right margin"
:nillable true
:applied-tokens {:m2 applied-token-to-m2}
:values {:m2 m2}}]
[:div {:class (stl/css :right-margin) [:div {:class (stl/css :right-margin)
:title "Right margin"} :title "Right margin"}
[:span {:class (stl/css :icon)} [:span {:class (stl/css :icon)}
@@ -151,11 +365,27 @@
[:> deprecated-input/numeric-input* {:class (stl/css :numeric-input) [:> deprecated-input/numeric-input* {:class (stl/css :numeric-input)
:placeholder "--" :placeholder "--"
:data-name "m2" :data-name "m2"
:on-focus on-focus :on-focus on-focus-m2
:on-change on-change' :on-change on-m2-change
:on-blur on-blur :on-blur on-blur
:nillable true :nillable true
:value m2}]] :value m2}]])
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-m3-change
:on-detach on-detach-token
:on-blur on-blur
:on-focus on-focus-m3
:icon i/margin-bottom
:class (stl/css :bottom-margin-wrapper)
:min 0
:name :m3
:align :right
:property "Bottom margin"
:nillable true
:applied-tokens {:m3 applied-token-to-m3}
:values {:m3 m3}}]
[:div {:class (stl/css :bottom-margin) [:div {:class (stl/css :bottom-margin)
:title "Bottom margin"} :title "Bottom margin"}
@@ -164,11 +394,26 @@
[:> deprecated-input/numeric-input* {:class (stl/css :numeric-input) [:> deprecated-input/numeric-input* {:class (stl/css :numeric-input)
:placeholder "--" :placeholder "--"
:data-name "m3" :data-name "m3"
:on-focus on-focus :on-focus on-focus-m3
:on-change on-change' :on-change on-m3-change
:on-blur on-blur :on-blur on-blur
:nillable true :nillable true
:value m3}]] :value m3}]])
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-m4-change
:on-detach on-detach-token
:on-blur on-blur
:on-focus on-focus-m4
:icon i/margin-left
:class (stl/css :left-margin-wrapper)
:min 0
:name :m4
:property "Left margin"
:nillable true
:applied-tokens {:m4 applied-token-to-m4}
:values {:m4 m4}}]
[:div {:class (stl/css :left-margin) [:div {:class (stl/css :left-margin)
:title "Left margin"} :title "Left margin"}
@@ -177,19 +422,20 @@
[:> deprecated-input/numeric-input* {:class (stl/css :numeric-input) [:> deprecated-input/numeric-input* {:class (stl/css :numeric-input)
:placeholder "--" :placeholder "--"
:data-name "m4" :data-name "m4"
:on-focus on-focus :on-focus on-focus-m4
:on-change on-change' :on-change on-m4-change
:on-blur on-blur :on-blur on-blur
:nillable true :nillable true
:value m4}]]])) :value m4}]])]))
(mf/defc margin-section* (mf/defc margin-section*
{::mf/private true {::mf/private true
::mf/expect-props #{:value :type :on-type-change :on-change}} ::mf/expect-props #{:value :type :on-type-change :on-change :applied-tokens :ids}}
[{:keys [type on-type-change] :as props}] [{:keys [type on-type-change] :as props}]
(let [type (d/nilv type :simple) (let [type (d/nilv type :simple)
on-blur (mf/use-fn #(select-margins false false false false)) on-blur (mf/use-fn
(mf/deps select-margins)
#(select-margins false false false false))
props (mf/spread-props props {:on-blur on-blur}) props (mf/spread-props props {:on-blur on-blur})
on-type-change' on-type-change'
@@ -292,8 +538,215 @@
:label "Align self end" :label "Align self end"
:value "end"}]}]) :value "end"}]}])
(def ^:private schema:layout-item-props-schema
[:map
[:layout-item-margin
{:optional true}
[:map
[:m1 {:optional true} [:or :float :int]]
[:m2 {:optional true} [:or :float :int]]
[:m3 {:optional true} [:or :float :int]]
[:m4 {:optional true} [:or :float :int]]]]
[:layout-item-margin-type {:optional true} :keyword]
[:layout-item-h-sizing {:optional true} :keyword]
[:layout-item-v-sizing {:optional true} :keyword]
[:layout-item-min-w {:optional true} [:or :float :int]]
[:layout-item-max-w {:optional true} [:or :float :int]]
[:layout-item-min-h {:optional true} [:or :float :int]]
[:layout-item-max-h {:optional true} [:or :float :int]]])
(def ^:private schema:layout-size-constraints
[:map
[:values schema:layout-item-props-schema]
[:applied-tokens [:map-of :keyword :string]]
[:ids [::sm/vec ::sm/uuid]]
[:v-sizing {:optional true} [:maybe [:= :fill]]]])
(mf/defc layout-size-constraints*
{::mf/private true
::mf/schema (sm/schema schema:layout-size-constraints)}
[{:keys [values v-sizing ids applied-tokens] :as props}]
(let [token-numeric-inputs
(features/use-feature "tokens/numeric-input")
min-w (get values :layout-item-min-w)
max-w (get values :layout-item-max-w)
min-h (get values :layout-item-min-h)
max-h (get values :layout-item-max-h)
applied-token-to-min-w (get applied-tokens :layout-item-min-w)
applied-token-to-max-w (get applied-tokens :layout-item-max-w)
applied-token-to-min-h (get applied-tokens :layout-item-min-h)
applied-token-to-max-h (get applied-tokens :layout-item-max-h)
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
:attributes #{attr}
:shape-ids ids}))))
on-size-change
(mf/use-fn
(mf/deps ids)
(fn [value attr]
(if (or (string? value) (int? value))
(st/emit! (dwsl/update-layout-child ids {attr value}))
(do
(st/emit!
(dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids}))))))
on-layout-item-min-w-change
(mf/use-fn (mf/deps on-size-change) #(on-size-change % :layout-item-min-w))
on-layout-item-max-w-change
(mf/use-fn (mf/deps on-size-change) #(on-size-change % :layout-item-max-w))
on-layout-item-min-h-change
(mf/use-fn (mf/deps on-size-change) #(on-size-change % :layout-item-min-h))
on-layout-item-max-h-change
(mf/use-fn (mf/deps on-size-change) #(on-size-change % :layout-item-max-h))]
[:div {:class (stl/css :advanced-options)}
(when (= (:layout-item-h-sizing values) :fill)
[:div {:class (stl/css :horizontal-fill)}
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-layout-item-min-w-change
:on-detach on-detach-token
:class (stl/css :min-w-wrapper)
:min 0
:name :layout-item-min-w
:property (tr "workspace.options.layout-item.layout-item-min-w")
:text-icon "MIN W"
:nillable true
:applied-tokens {:layout-item-min-w applied-token-to-min-w}
:tooltip-class (stl/css :tooltip-wrapper)
:values {:layout-item-min-w min-w}}]
[:div {:class (stl/css :layout-item-min-w)
:title (tr "workspace.options.layout-item.layout-item-min-w")}
[:span {:class (stl/css :icon-text)} "MIN W"]
[:> deprecated-input/numeric-input*
{:class (stl/css :numeric-input)
:no-validate true
:min 0
:data-wrap true
:placeholder "--"
:data-attr "layout-item-min-w"
:on-focus dom/select-target
:on-change on-layout-item-min-w-change
:value (get values :layout-item-min-w)
:nillable true}]])
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-layout-item-max-w-change
:on-detach on-detach-token
:text-icon "MAX W"
:class (stl/css :max-w-wrapper)
:min 0
:name :layout-item-max-w
:align :right
:property (tr "workspace.options.layout-item.layout-item-max-w")
:nillable true
:tooltip-class (stl/css :tooltip-wrapper)
:applied-tokens {:layout-item-max-w applied-token-to-max-w}
:values {:layout-item-max-w max-w}}]
[:div {:class (stl/css :layout-item-max-w)
:title (tr "workspace.options.layout-item.layout-item-max-w")}
[:span {:class (stl/css :icon-text)} "MAX W"]
[:> deprecated-input/numeric-input*
{:class (stl/css :numeric-input)
:no-validate true
:min 0
:data-wrap true
:placeholder "--"
:data-attr "layout-item-max-w"
:on-focus dom/select-target
:on-change on-layout-item-max-w-change
:value (get values :layout-item-max-w)
:nillable true}]])])
(when (= v-sizing :fill)
[:div {:class (stl/css :vertical-fill)}
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-layout-item-min-h-change
:on-detach on-detach-token
:text-icon "MIN H"
:class (stl/css :min-h-wrapper)
:min 0
:name :layout-item-min-h
:property (tr "workspace.options.layout-item.layout-item-min-h")
:nillable true
:tooltip-class (stl/css :tooltip-wrapper)
:applied-tokens {:layout-item-min-h applied-token-to-min-h}
:values {:layout-item-min-h min-h}}]
[:div {:class (stl/css :layout-item-min-h)
:title (tr "workspace.options.layout-item.layout-item-min-h")}
[:span {:class (stl/css :icon-text)} "MIN H"]
[:> deprecated-input/numeric-input*
{:class (stl/css :numeric-input)
:no-validate true
:min 0
:data-wrap true
:placeholder "--"
:data-attr "layout-item-min-h"
:on-focus dom/select-target
:on-change on-layout-item-min-h-change
:value (get values :layout-item-min-h)
:nillable true}]])
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-layout-item-max-h-change
:on-detach on-detach-token
:class (stl/css :max-h-wrapper)
:min 0
:text-icon "MAX H"
:name :layout-item-max-h
:align :right
:property (tr "workspace.options.layout-item.layout-item-max-h")
:nillable true
:tooltip-class (stl/css :tooltip-wrapper)
:applied-tokens {:layout-item-max-h applied-token-to-max-h}
:values {:layout-item-max-h max-h}}]
[:div {:class (stl/css :layout-item-max-h)
:title (tr "workspace.options.layout-item.layout-item-max-h")}
[:span {:class (stl/css :icon-text)} "MAX H"]
[:> deprecated-input/numeric-input*
{:class (stl/css :numeric-input)
:no-validate true
:min 0
:data-wrap true
:placeholder "--"
:data-attr "layout-item-max-h"
:on-focus dom/select-target
:on-change on-layout-item-max-h-change
:value (get values :layout-item-max-h)
:nillable true}]])])]))
(mf/defc layout-item-menu (mf/defc layout-item-menu
{::mf/memo #{:ids :values :type :is-layout-child? :is-grid-parent :is-flex-parent? :is-grid-layout? :is-flex-layout?} {::mf/memo #{:ids :values :type :is-layout-child? :is-grid-parent :is-flex-parent? :is-grid-layout? :is-flex-layout? :applied-tokens}
::mf/props :obj} ::mf/props :obj}
[{:keys [ids values [{:keys [ids values
^boolean is-layout-child? ^boolean is-layout-child?
@@ -301,7 +754,8 @@
^boolean is-grid-parent? ^boolean is-grid-parent?
^boolean is-flex-parent? ^boolean is-flex-parent?
^boolean is-flex-layout? ^boolean is-flex-layout?
^boolean is-grid-layout?]}] ^boolean is-grid-layout?
applied-tokens]}]
(let [selection-parents* (mf/use-memo (mf/deps ids) #(refs/parents-by-ids ids)) (let [selection-parents* (mf/use-memo (mf/deps ids) #(refs/parents-by-ids ids))
selection-parents (mf/deref selection-parents*) selection-parents (mf/deref selection-parents*)
@@ -397,16 +851,7 @@
(fn [value] (fn [value]
(st/emit! (dwsl/update-layout-child ids {:layout-item-v-sizing (keyword value)})))) (st/emit! (dwsl/update-layout-child ids {:layout-item-v-sizing (keyword value)}))))
;; Size and position ;; Position
on-size-change
(mf/use-fn
(mf/deps ids)
(fn [value event]
(let [attr (-> (dom/get-current-target event)
(dom/get-data "attr")
(keyword))]
(st/emit! (dwsl/update-layout-child ids {attr value})))))
on-change-position on-change-position
(mf/use-fn (mf/use-fn
(mf/deps ids) (mf/deps ids)
@@ -423,7 +868,8 @@
(fn [value] (fn [value]
(st/emit! (dwsl/update-layout-child ids {:layout-item-z-index value}))))] (st/emit! (dwsl/update-layout-child ids {:layout-item-z-index value}))))]
[:div {:class (stl/css :element-set)} [:section {:class (stl/css :element-set)
:aria-label "layout item menu"}
[:div {:class (stl/css :element-title)} [:div {:class (stl/css :element-title)}
[:> title-bar* {:collapsable has-content? [:> title-bar* {:collapsable has-content?
:collapsed (not open?) :collapsed (not open?)
@@ -483,74 +929,13 @@
[:> margin-section* {:value (:layout-item-margin values) [:> margin-section* {:value (:layout-item-margin values)
:type (:layout-item-margin-type values) :type (:layout-item-margin-type values)
:on-type-change on-margin-type-change :on-type-change on-margin-type-change
:applied-tokens applied-tokens
:ids ids
:on-change on-margin-change}]) :on-change on-margin-change}])
(when (or (= h-sizing :fill) (when (or (= h-sizing :fill)
(= v-sizing :fill)) (= v-sizing :fill))
[:div {:class (stl/css :advanced-options)} [:> layout-size-constraints* {:ids ids
(when (= (:layout-item-h-sizing values) :fill) :values values
[:div {:class (stl/css :horizontal-fill)} :applied-tokens applied-tokens
[:div {:class (stl/css :layout-item-min-w) :v-sizing v-sizing}])])]))
:title (tr "workspace.options.layout-item.layout-item-min-w")}
[:span {:class (stl/css :icon-text)} "MIN W"]
[:> deprecated-input/numeric-input*
{:class (stl/css :numeric-input)
:no-validate true
:min 0
:data-wrap true
:placeholder "--"
:data-attr "layout-item-min-w"
:on-focus dom/select-target
:on-change on-size-change
:value (get values :layout-item-min-w)
:nillable true}]]
[:div {:class (stl/css :layout-item-max-w)
:title (tr "workspace.options.layout-item.layout-item-max-w")}
[:span {:class (stl/css :icon-text)} "MAX W"]
[:> deprecated-input/numeric-input*
{:class (stl/css :numeric-input)
:no-validate true
:min 0
:data-wrap true
:placeholder "--"
:data-attr "layout-item-max-w"
:on-focus dom/select-target
:on-change on-size-change
:value (get values :layout-item-max-w)
:nillable true}]]])
(when (= v-sizing :fill)
[:div {:class (stl/css :vertical-fill)}
[:div {:class (stl/css :layout-item-min-h)
:title (tr "workspace.options.layout-item.layout-item-min-h")}
[:span {:class (stl/css :icon-text)} "MIN H"]
[:> deprecated-input/numeric-input*
{:class (stl/css :numeric-input)
:no-validate true
:min 0
:data-wrap true
:placeholder "--"
:data-attr "layout-item-min-h"
:on-focus dom/select-target
:on-change on-size-change
:value (get values :layout-item-min-h)
:nillable true}]]
[:div {:class (stl/css :layout-item-max-h)
:title (tr "workspace.options.layout-item.layout-item-max-h")}
[:span {:class (stl/css :icon-text)} "MAX H"]
[:> deprecated-input/numeric-input*
{:class (stl/css :numeric-input)
:no-validate true
:min 0
:data-wrap true
:placeholder "--"
:data-attr "layout-item-max-h"
:on-focus dom/select-target
:on-change on-size-change
:value (get values :layout-item-max-h)
:nillable true}]]])])])]))

View File

@@ -6,17 +6,21 @@
@use "refactor/common-refactor.scss" as deprecated; @use "refactor/common-refactor.scss" as deprecated;
@use "../../../sidebar/common/sidebar.scss" as sidebar; @use "../../../sidebar/common/sidebar.scss" as sidebar;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as *;
@use "ds/_utils.scss" as *;
.element-set { .element-set {
margin: 0; margin: 0;
} }
.title-spacing-layout-element { .title-spacing-layout-element {
margin: 0 0 deprecated.$s-4 0; margin: 0 0 var(--sp-xs) 0;
} }
.title-spacing-empty { .title-spacing-empty {
padding-left: deprecated.$s-2; padding-inline-start: var(--sp-xxs);
} }
.flex-element-menu { .flex-element-menu {
@@ -35,8 +39,8 @@
} }
.z-index-wrapper { .z-index-wrapper {
@include use-typography("body-small");
@extend .input-element; @extend .input-element;
@include deprecated.bodySmallTypography;
grid-column: 6 / span 3; grid-column: 6 / span 3;
} }
@@ -55,7 +59,7 @@
} }
.position-options { .position-options {
width: 100%; inline-size: 100%;
grid-column: 1 / span 5; grid-column: 1 / span 5;
} }
@@ -75,7 +79,7 @@
.vertical-margin, .vertical-margin,
.horizontal-margin { .horizontal-margin {
@extend .input-element; @extend .input-element;
@include deprecated.bodySmallTypography; @include use-typography("body-small");
} }
.vertical-margin { .vertical-margin {
grid-column: 1; grid-column: 1;
@@ -85,6 +89,16 @@
} }
} }
.vertical-margin-wrapper {
grid-column: 1;
--dropdown-width: var(--7-columns-dropdown-width);
}
.horizontal-margin-wrapper {
grid-column: 2;
--dropdown-width: var(--7-columns-dropdown-width);
}
.margin-multiple { .margin-multiple {
display: grid; display: grid;
grid-template-columns: subgrid; grid-template-columns: subgrid;
@@ -96,29 +110,44 @@
.left-margin, .left-margin,
.right-margin { .right-margin {
@extend .input-element; @extend .input-element;
@include deprecated.bodySmallTypography; @include use-typography("body-small");
} }
.top-margin { .top-margin,
.top-margin-wrapper {
--dropdown-width: var(--7-columns-dropdown-width);
grid-column: 1; grid-column: 1;
grid-row: 1; grid-row: 1;
} }
.bottom-margin { .bottom-margin,
.bottom-margin-wrapper {
--dropdown-width: var(--7-columns-dropdown-width);
grid-column: 2; grid-column: 2;
grid-row: 1; grid-row: 1;
} }
.left-margin { .left-margin,
.left-margin-wrapper {
--dropdown-width: var(--7-columns-dropdown-width);
grid-column: 1; grid-column: 1;
grid-row: 2; grid-row: 2;
} }
.right-margin { .right-margin,
.right-margin-wrapper {
--dropdown-width: var(--7-columns-dropdown-width);
grid-column: 2; grid-column: 2;
grid-row: 2; grid-row: 2;
} }
.min-w-wrapper,
.max-w-wrapper,
.min-h-wrapper,
.max-h-wrapper {
--dropdown-width: var(--7-columns-dropdown-width);
}
.advanced-options { .advanced-options {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
@@ -140,14 +169,18 @@
.layout-item-max-w, .layout-item-max-w,
.layout-item-max-h { .layout-item-max-h {
@extend .input-element; @extend .input-element;
@include deprecated.bodySmallTypography; @include use-typography("body-small");
.icon-text { .icon-text {
justify-content: flex-start; justify-content: flex-start;
width: deprecated.$s-80; inline-size: px2rem(80);
padding-top: deprecated.$s-2; padding-block-start: var(--sp-xxs);
} }
} }
.inputs-wrapper { .inputs-wrapper {
grid-column: 1 / span 2; grid-column: 1 / span 2;
} }
.tooltip-wrapper {
inline-size: 100%;
}

View File

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

View File

@@ -114,6 +114,7 @@
:is-layout-child? true :is-layout-child? true
:is-flex-parent? is-flex-parent? :is-flex-parent? is-flex-parent?
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:applied-tokens applied-tokens
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?) (when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)

View File

@@ -113,6 +113,7 @@
:is-layout-container? false :is-layout-container? false
:is-flex-parent? is-flex-parent? :is-flex-parent? is-flex-parent?
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:applied-tokens applied-tokens
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?) (when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)

View File

@@ -135,6 +135,7 @@
:is-flex-layout? is-flex-layout? :is-flex-layout? is-flex-layout?
:is-grid-layout? is-grid-layout? :is-grid-layout? is-grid-layout?
:is-layout-child? is-layout-child? :is-layout-child? is-layout-child?
:applied-tokens applied-tokens
:is-layout-container? is-layout-container? :is-layout-container? is-layout-container?
:shape shape}]) :shape shape}])

View File

@@ -139,6 +139,7 @@
:is-layout-container? false :is-layout-container? false
:is-flex-parent? is-flex-parent? :is-flex-parent? is-flex-parent?
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:applied-tokens applied-tokens
:values layout-item-values}]) :values layout-item-values}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?) (when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)

View File

@@ -409,7 +409,7 @@
[layout-container-ids layout-container-values layout-container-tokens] [layout-container-ids layout-container-values layout-container-tokens]
(get-attrs shapes objects :layout-container) (get-attrs shapes objects :layout-container)
[layout-item-ids layout-item-values {}] [layout-item-ids layout-item-values layout-item-tokens]
(get-attrs shapes objects :layout-item) (get-attrs shapes objects :layout-item)
components components
@@ -471,6 +471,7 @@
:is-layout-container? all-flex-layout-container? :is-layout-container? all-flex-layout-container?
:is-flex-parent? is-flex-parent? :is-flex-parent? is-flex-parent?
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:applied-tokens layout-item-tokens
:values layout-item-values}]) :values layout-item-values}])
(when-not (or (empty? constraint-ids) ^boolean is-layout-child?) (when-not (or (empty? constraint-ids) ^boolean is-layout-child?)

View File

@@ -113,6 +113,7 @@
:is-layout-container? false :is-layout-container? false
:is-flex-parent? is-flex-parent? :is-flex-parent? is-flex-parent?
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:applied-tokens applied-tokens
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?) (when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)

View File

@@ -112,6 +112,7 @@
:values layout-item-values :values layout-item-values
:is-layout-child? true :is-layout-child? true
:is-flex-parent? is-flex-parent? :is-flex-parent? is-flex-parent?
:applied-tokens applied-tokens
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:shape shape}]) :shape shape}])

View File

@@ -180,6 +180,7 @@
:is-layout-child? true :is-layout-child? true
:is-flex-parent? is-flex-parent? :is-flex-parent? is-flex-parent?
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:applied-tokens applied-tokens
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?) (when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)

View File

@@ -154,6 +154,7 @@
:is-layout-child? true :is-layout-child? true
:is-flex-parent? is-flex-parent? :is-flex-parent? is-flex-parent?
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:applied-tokens applied-tokens
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?) (when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)

View File

@@ -50,6 +50,7 @@
.modal-title { .modal-title {
@include t.use-typography("headline-medium"); @include t.use-typography("headline-medium");
color: var(--modal-title-foreground-color); color: var(--modal-title-foreground-color);
word-wrap: break-word;
} }
.modal-content { .modal-content {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6719,19 +6719,19 @@ msgstr "Advanced options"
#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:543 #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:543
msgid "workspace.options.layout-item.layout-item-max-h" msgid "workspace.options.layout-item.layout-item-max-h"
msgstr "Max.Height" msgstr "Max height"
#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:510 #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:510
msgid "workspace.options.layout-item.layout-item-max-w" msgid "workspace.options.layout-item.layout-item-max-w"
msgstr "Max.Width" msgstr "Max width"
#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:527 #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:527
msgid "workspace.options.layout-item.layout-item-min-h" msgid "workspace.options.layout-item.layout-item-min-h"
msgstr "Min.Height" msgstr "Min height"
#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:494 #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:494
msgid "workspace.options.layout-item.layout-item-min-w" msgid "workspace.options.layout-item.layout-item-min-w"
msgstr "Min.Width" msgstr "Min width"
#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs
#, unused #, unused

View File

@@ -7170,11 +7170,11 @@ msgstr "Ancho"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:535, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:552 #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:535, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:552
msgid "workspace.options.x" msgid "workspace.options.x"
msgstr "eje X" msgstr "Eje X"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:545, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:563 #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:545, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:563
msgid "workspace.options.y" msgid "workspace.options.y"
msgstr "eje Y" msgstr "Eje Y"
#: src/app/main/ui/workspace/viewport/path_actions.cljs:140 #: src/app/main/ui/workspace/viewport/path_actions.cljs:140
msgid "workspace.path.actions.add-node" msgid "workspace.path.actions.add-node"

View File

@@ -7,7 +7,7 @@ different parts of the platform, please refer to `docs/` directory.
## Reporting Bugs ## Reporting Bugs
We are using [GitHub Issues](https://github.com/penpot/penpot-plugins/issues) We are using [GitHub Issues](https://github.com/penpot/penpot/issues)
for our public bugs. We keep a close eye on this and try to make it for our public bugs. We keep a close eye on this and try to make it
clear when we have an internal fix in progress. Before filing a new clear when we have an internal fix in progress. Before filing a new
task, try to make sure your problem doesn't already exist. task, try to make sure your problem doesn't already exist.

View File

@@ -16,6 +16,7 @@
"polyfills": ["zone.js"], "polyfills": ["zone.js"],
"tsConfig": "apps/colors-to-tokens-plugin/tsconfig.app.json", "tsConfig": "apps/colors-to-tokens-plugin/tsconfig.app.json",
"assets": [ "assets": [
"apps/colors-to-tokens-plugin/src/_headers",
"apps/colors-to-tokens-plugin/src/favicon.ico", "apps/colors-to-tokens-plugin/src/favicon.ico",
"apps/colors-to-tokens-plugin/src/assets" "apps/colors-to-tokens-plugin/src/assets"
], ],

View File

@@ -0,0 +1,4 @@
/*
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type

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