Compare commits

..

33 Commits

Author SHA1 Message Date
Alejandro Alonso
2ae68d5752 Merge pull request #8244 from penpot/alotor-fix-modifiers-propagation
🐛 Fix problem with modifiers propagation
2026-01-29 17:34:36 +01:00
alonso.torres
913672e5c5 🐛 Fix problem with modifiers propagation 2026-01-29 17:15:01 +01:00
Alejandro Alonso
8c25fb00ac 🐛 Fix auto width/height texts on variant swithching 2026-01-29 12:25:38 +01:00
Alejandro Alonso
6a84215911 🐛 Fix stroke weight visually different with different levels of zoom 2026-01-29 12:18:26 +01:00
Andrey Antukh
b881e36875 Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-29 10:23:31 +01:00
Andrey Antukh
b40e775a70 Add minor improvements to performance events (#8217)
*  Move devtools perf logging helpers to util.perf ns

* 💄 Move flag check to the entry point instead of initialize event

* ♻️ Make performance events consistent with other events
2026-01-28 20:47:14 +01:00
David Barragán Merino
cc81e56d82 🔧 Fix CORS error 2026-01-28 13:40:26 +01:00
Aitor Moreno
2b00e4eec9 Merge pull request #8207 from penpot/alotor-wasm-disable-thumbnail-generation
🐛 Disable thumbnails render in wasm
2026-01-28 13:28:07 +01:00
Aitor Moreno
3b86d7c1b1 🐛 Fix initializing rasterizer 2026-01-28 12:59:16 +01:00
alonso.torres
3cb716ec30 🐛 Disable thumbnails render in wasm 2026-01-28 12:59:16 +01:00
Andrey Antukh
a9e2fc8d94 Backport linter fixes and config from develop 2026-01-28 12:58:54 +01:00
Andrey Antukh
17ffd9a5d0 Backport linter fixes and config from develop 2026-01-28 12:54:18 +01:00
David Barragán Merino
18aca16f98 🔧 Fix file name 2026-01-27 21:12:32 +01:00
David Barragán Merino
c6465e27e3 📚 Fix links related to penpot plugins 2026-01-27 21:12:32 +01:00
David Barragán Merino
1834a18263 🔧 Deploy plugin styles documentation 2026-01-27 21:12:32 +01:00
David Barragán Merino
d220d07875 🔧 Add custom domain 2026-01-27 21:12:32 +01:00
Andrey Antukh
faf91ac70d Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-27 17:53:16 +01:00
Eva Marco
9ca76c745f 🐛 Fix app freeze on token name change (#8214) 2026-01-27 17:31:50 +01:00
Elena Torró
9808b6ca57 Merge pull request #8205 from penpot/superalex-improve-huge-shapes-render
🎉 Improving huge shapes render
2026-01-27 13:08:25 +01:00
Aitor Moreno
de41cb5488 🐛 Fix add/remove fills to text nodes 2026-01-27 12:17:10 +01:00
Alejandro Alonso
b40ccaf030 🎉 Improve zoom actions for huge shapes 2026-01-27 11:11:38 +01:00
Alejandro Alonso
7d3ac38749 🎉 Improve huge shapes rendering 2026-01-27 11:11:38 +01:00
Elena Torro
8d1bc6c50c 🐛 Fix flex layout sorting on reverse order with no z-index 2026-01-27 09:34:36 +01:00
Andrey Antukh
3112b240a0 📎 Add missing entry on changelog 2026-01-27 09:28:41 +01:00
Andrey Antukh
56fd66b91a 🐛 Fix several issues related to path edition (#8187)
*  Improve save-path-content event consistency

Mainly removing possible race conditions from the event
implementation.

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

*  Reuse already available shape-id on split-segments
2026-01-27 09:27:42 +01:00
Elena Torro
2a7c24f6fd 🐛 Fix shape operations on sidebar when using interaction observer 2026-01-27 09:03:41 +01:00
Alejandro Alonso
947aa22dee Merge pull request #8173 from penpot/elenatorro-improve-surface-performance
🔧 Improve surface rendering performance
2026-01-27 07:21:23 +01:00
David Barragán Merino
1ce0b60e3d 🔧 Run all the jobs if the workflow is launched manually 2026-01-26 17:13:54 +01:00
Elena Torro
5209a8b423 🔧 Improve surface rendering performance 2026-01-26 16:10:22 +01:00
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
92 changed files with 1728 additions and 3307 deletions

View File

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

View File

@@ -7,11 +7,11 @@ on:
- staging
- main
paths:
- "plugins/libs/plugin-types/index.d.ts"
- "plugins/libs/plugin-types/REAME.md"
- "plugins/tools/typedoc.css"
- "plugins/CHANGELOG.md"
- "plugins/wrangle-penpot-plugins-api-doc.toml"
- 'plugins/libs/plugin-types/index.d.ts'
- 'plugins/libs/plugin-types/REAME.md'
- 'plugins/tools/typedoc.css'
- 'plugins/CHANGELOG.md'
- 'plugins/wrangler-penpot-plugins-api-doc.toml'
workflow_dispatch:
inputs:
gh_ref:
@@ -86,16 +86,40 @@ jobs:
run: |
REF="${{ steps.vars.outputs.gh_ref }}"
case "$REF" in
main) echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV ;;
staging) echo "WORKER_NAME=penpot-plugins-api-doc-pre" >> $GITHUB_ENV ;;
develop) echo "WORKER_NAME=penpot-plugins-api-doc-hourly" >> $GITHUB_ENV ;;
main)
echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $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 ;;
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
uses: cloudflare/wrangler-action@v3
with:
workingDirectory: plugins
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --config wrangle-penpot-plugins-api-doc.toml --name ${{ env.WORKER_NAME }}
command: deploy --config wrangler-penpot-plugins-api-doc.toml --name ${{ env.WORKER_NAME }}
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
❌ 🧩📚 *[PENPOT PLUGINS] Error deploying API documentation.*
📄 Triggered from ref: `${{ inputs.gh_ref }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -79,7 +79,7 @@
(db/insert-many! pool :audit-log event-columns events))))
(def valid-event-types
#{"action" "identify"})
#{"action" "identify" "trigger"})
(def schema:event
[:map {:title "Event"}

View File

@@ -55,7 +55,6 @@
"design-tokens/v1"
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"render-wasm/v1"
"variants/v1"})
@@ -79,7 +78,6 @@
"plugins/runtime"
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"tokens/numeric-input"
"render-wasm/v1"})
@@ -129,7 +127,6 @@
:feature-design-tokens "design-tokens/v1"
:feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-text-editor-wasm "text-editor-wasm/v1"
:feature-render-wasm "render-wasm/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"

View File

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

View File

@@ -2017,7 +2017,9 @@
(let [;; We need to sync only the position relative to the origin of the component.
;; (see update-attrs for a full explanation)
previous-shape (reposition-shape previous-shape prev-root current-root)
touched (get previous-shape :touched #{})]
touched (get previous-shape :touched #{})
text-auto? (and (cfh/text-shape? current-shape)
(contains? #{:auto-height :auto-width} (:grow-type current-shape)))]
(loop [attrs updatable-attrs
roperations [{:type :set-touched :touched (:touched previous-shape)}]
@@ -2026,6 +2028,10 @@
(let [attr-group (get ctk/sync-attrs attr)
skip-operations?
(or
;; For auto text, avoid copying geometry-driven attrs on switch.
(and text-auto?
(contains? #{:points :selrect :width :height :position-data} attr))
;; If the attribute is not valid for the destiny, don't copy it
(not (cts/is-allowed-switch-keep-attr? attr (:type current-shape)))

View File

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

View File

@@ -6,4 +6,4 @@ desc: Create, deploy, and use the Penpot plugin API with our comprehensive docum
# 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>
- 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:
[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:
[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
- 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.
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
<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
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.
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

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.
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>

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?
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?
@@ -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?
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?
@@ -58,7 +59,7 @@ Yes, it is possible to create components using:
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?

View File

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

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

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -16,6 +16,7 @@
[app.main.data.profile :as dp]
[app.main.data.websocket :as ws]
[app.main.errors]
[app.main.features :as feat]
[app.main.rasterizer :as thr]
[app.main.store :as st]
[app.main.ui :as ui]
@@ -65,8 +66,11 @@
ptk/WatchEvent
(watch [_ _ stream]
(rx/merge
(rx/of (ev/initialize)
(dp/refresh-profile))
(if (contains? cf/flags :audit-log)
(rx/of (ev/initialize))
(rx/empty))
(rx/of (dp/refresh-profile))
;; Watch for profile deletion events
(->> stream
@@ -87,7 +91,12 @@
(rx/map deref)
(rx/filter dp/is-authenticated?)
(rx/take 1)
(rx/map #(ws/initialize)))))))
(rx/map #(ws/initialize)))))
ptk/EffectEvent
(effect [_ state _]
(when-not (feat/active-feature? state "render-wasm/v1")
(thr/init!)))))
(defn ^:export init
[options]
@@ -97,7 +106,7 @@
(mw/init!)
(i18n/init)
(cur/init-styles)
(thr/init!)
(init-ui)
(st/emit! (plugins/initialize)
(initialize)))

View File

@@ -31,40 +31,34 @@
(l/set-level! :info)
;; Defines the maximum buffer size, after events start discarding.
(def max-buffer-size 1024)
(def ^:private ^:const max-buffer-size 1024)
;; Defines the maximum number of events that can go in a single batch.
(def max-chunk-size 100)
(def ^:private ^:const max-chunk-size 100)
;; Defines the time window (in ms) within events belong to the same session.
(def session-timeout (* 1000 60 30))
(def ^:private ^:const session-timeout (* 1000 60 30))
;; Min time for a long task to be reported to telemetry
(def min-longtask-time 1000)
(def ^:private ^:const min-longtask-time 1000)
;; Min time between long task reports
(def debounce-longtask-time 1000)
(def ^:private ^:const debounce-longtask-time 1000)
;; Min time for a long task to be reported to telemetry
(def min-browser-event-time 1000)
(def ^:private ^:const min-browser-event-time 1000)
;; Min time between long task reports
(def debounce-browser-event-time 1000)
(def ^:private ^:const debounce-browser-event-time 1000)
;; Min time for a long task to be reported to telemetry
(def min-performace-event-time 1000)
(def ^:private ^:const min-performace-event-time 1000)
;; Min time between long task reports
(def debounce-performance-event-time 1000)
(def ^:private ^:const debounce-performance-event-time 1000)
;; Def micro-benchmark iterations
(def micro-benchmark-iterations 1e6)
;; Performance logs
(defonce ^:private longtask-observer* (atom nil))
(defonce ^:private stall-timer* (atom nil))
(defonce ^:private current-op* (atom nil))
;; Default micro-benchmark iterations
(def ^:private ^:const micro-benchmark-iterations 1e6)
;; --- CONTEXT
@@ -142,12 +136,12 @@
data
data))
(defn add-external-context-info
(defn- add-external-context-info
[context]
(let [external-context-info (json/->clj (cf/external-context-info))]
(merge context external-context-info)))
(defn- process-event-by-proto
(defn- make-proto-event
[event]
(let [data (d/deep-merge (-data event) (meta event))
type (ptk/type event)
@@ -156,7 +150,6 @@
(assoc :event-origin (::origin data))
(assoc :event-namespace (namespace type))
(assoc :event-symbol ev-name)
(add-external-context-info)
(d/without-nils))
props (-> data d/without-qualified simplify-props)]
@@ -165,7 +158,7 @@
:context context
:props props}))
(defn- process-data-event
(defn- make-data-event
[event]
(let [data (deref event)
name (::name data)]
@@ -174,7 +167,6 @@
(let [type (::type data "action")
context (-> (::context data)
(assoc :event-origin (::origin data))
(add-external-context-info)
(d/without-nils))
props (-> data d/without-qualified simplify-props)]
{:type type
@@ -182,57 +174,62 @@
:context context
:props props}))))
(defn performance-payload
(defn- make-event
"Create a standard event"
([result]
(let [props (aget result 0)
profile-id (aget result 1)]
(performance-payload profile-id props)))
(make-event profile-id props)))
([profile-id event]
(when-let [event (cond
(satisfies? Event event)
(make-proto-event event)
(ptk/data-event? event)
(make-data-event event))]
(assoc event :profile-id profile-id))))
(defn- make-performance-event
"Create a performance trigger event"
([result]
(let [props (aget result 0)
profile-id (aget result 1)]
(make-performance-event profile-id props)))
([profile-id props]
(let [{:keys [performance-info]} @st/state]
{:type "action"
:name "performance"
:context (merge @context performance-info)
:props props
(let [perf-info (get @st/state :performance-info)
name (get props ::name)]
{:type "trigger"
:name (str "performance-" name)
:context {:file-stats (:counters perf-info)}
:props (-> props
(dissoc ::name)
(assoc :file-id (:file-id perf-info)))
:profile-id profile-id})))
(defn- process-performance-event
"Process performance sensitive events"
[result]
(let [event (aget result 0)
profile-id (aget result 1)]
(if (and (satisfies? PerformanceEvent event)
(exists? js/globalThis)
(exists? (.-requestAnimationFrame js/globalThis))
(exists? (.-scheduler js/globalThis))
(exists? (.-postTask (.-scheduler js/globalThis))))
(if (satisfies? PerformanceEvent event)
(rx/create
(fn [subs]
(let [start (perf/timestamp)]
(let [start (perf/now)]
(js/requestAnimationFrame
#(js/scheduler.postTask
(fn []
(let [time (- (perf/timestamp) start)]
(when (> time min-performace-event-time)
(rx/push!
subs
(performance-payload
profile-id
{::event (str (ptk/type event))
:time time}))))
(rx/end! subs))
#js {"priority" "user-blocking"})))
nil))
#(.postTask js/scheduler
(fn []
(let [time (- (perf/now) start)]
(when (> time min-performace-event-time)
(rx/push! subs
(make-performance-event profile-id
{::name "blocking-event"
:event-name (d/name (ptk/type event))
:duration time})))
(rx/end! subs)))
#js {:priority "user-blocking"}))
nil)))
(rx/empty))))
(defn- process-event
[event]
(cond
(satisfies? Event event)
(process-event-by-proto event)
(ptk/data-event? event)
(process-data-event event)))
;; --- MAIN LOOP
(defn- append-to-buffer
@@ -260,7 +257,8 @@
(rx/of nil)))
(defn performance-observer-event-stream
(defn- user-input-observer
"Create user interaction/input event observer. Returns rx stream."
[]
(if (and (exists? js/globalThis)
(exists? (.-PerformanceObserver js/globalThis)))
@@ -273,18 +271,17 @@
(fn [entry]
(when (and (= "event" (.-entryType entry))
(> (.-duration entry) min-browser-event-time))
(rx/push!
subs
{::event :observer-event
:duration (.-duration entry)
:event-name (.-name entry)})))
(rx/push! subs {::name "user-input"
:duration (.-duration entry)
:event-name (.-name entry)})))
(.getEntries list))))]
(.observe observer #js {:entryTypes #js ["event"]})
(fn []
(.disconnect observer)))))
(rx/empty)))
(defn performance-observer-longtask-stream
(defn- longtask-observer
"Create a Long-Task performance observer. Returns rx stream."
[]
(if (and (exists? js/globalThis)
(exists? (.-PerformanceObserver js/globalThis)))
@@ -298,7 +295,7 @@
(when (and (= "longtask" (.-entryType entry))
(> (.-duration entry) min-longtask-time))
(rx/push! subs
{::event :observer-longtask
{::name "long-task"
:duration (.-duration entry)})))
(.getEntries list))))]
(.observe observer #js {:entryTypes #js ["longtask"]})
@@ -306,238 +303,156 @@
(.disconnect observer)))))
(rx/empty)))
(defn- save-performance-info
[]
(ptk/reify ::save-performance-info
ptk/UpdateEvent
(update [_ state]
(letfn [(count-shapes [file]
(->> file :data :pages-index
(reduce-kv
(fn [sum _ page]
(+ sum (count (:objects page))))
0)))
(count-library-data [files {:keys [id]}]
(let [data (dm/get-in files [id :data])]
{:components (count (:components data))
:colors (count (:colors data))
:typographies (count (:typographies data))}))]
(let [file-id (get state :current-file-id)
file (get-in state [:files file-id])
file-size (count-shapes file)
(defn- snapshot-performance-info
[{:keys [file-id]}]
libraries
(-> (refs/select-libraries (:files state) (:id file))
(d/update-vals (partial count-library-data (:files state))))
(letfn [(count-shapes [file]
(->> file :data :pages-index
(reduce-kv
(fn [sum _ page]
(+ sum (count (:objects page))))
0)))
lib-sizes
(->> libraries
(reduce-kv
(fn [acc _ {:keys [components colors typographies]}]
(-> acc
(update :components + components)
(update :colors + colors)
(update :typographies + typographies)))
{}))]
(update state :performance-info
(fn [info]
(-> info
(assoc :file-size file-size)
(assoc :library-sizes lib-sizes)
(assoc :file-start-time (perf/now))))))))))
(add-libraries-counters [state files]
(reduce (fn [state library-id]
(let [data (dm/get-in files [library-id :data])]
(-> state
(update :total-components + (count (:components data)))
(update :total-colors + (count (:colors data)))
(update :total-typographies + (count (:typographies data))))))
state
(refs/select-libraries files file-id)))]
(defn store-performace-info
[]
(letfn [(micro-benchmark [state]
(let [start (perf/now)]
(loop [i micro-benchmark-iterations]
(when-not (zero? i)
(* (math/sin i) (math/sqrt i))
(recur (dec i))))
(let [end (perf/now)]
(update state :performance-info assoc :bench-result (- end start)))))]
(ptk/reify ::store-performace-info
(ptk/reify ::snapshot-performance-info
ptk/UpdateEvent
(update [_ state]
(-> state
micro-benchmark
(assoc-in [:performance-info :app-start-time] (perf/now))))
(update state :performance-info
(fn [info]
(let [files (get state :files)
file (get files file-id)]
(-> info
(assoc :file-id file-id)
(update :counters assoc :total-shapes (count-shapes file))
(update :counters add-libraries-counters files)))))))))
ptk/WatchEvent
(watch [_ _ stream]
(->> stream
(rx/filter (ptk/type? :app.main.data.workspace/all-libraries-resolved))
(rx/take 1)
(rx/map save-performance-info))))))
(defn- store-performace-info
[]
(ptk/reify ::store-performace-info
ptk/UpdateEvent
(update [_ state]
(let [start (perf/now)
_ (loop [i micro-benchmark-iterations]
(when-not (zero? i)
(* (math/sin i) (math/sqrt i))
(recur (dec i))))
end (perf/now)]
(update state :performance-info assoc :bench (- end start))))
ptk/WatchEvent
(watch [_ _ stream]
(->> stream
(rx/filter (ptk/type? :app.main.data.workspace/all-libraries-resolved))
(rx/take 1)
(rx/map deref)
(rx/map snapshot-performance-info)))))
(defn initialize
[]
(when (contains? cf/flags :audit-log)
(ptk/reify ::initialize
ptk/WatchEvent
(watch [_ _ _]
(rx/of (store-performace-info)))
(ptk/reify ::initialize
ptk/WatchEvent
(watch [_ _ _]
(rx/of (store-performace-info)))
ptk/EffectEvent
(effect [_ _ stream]
(let [session (atom nil)
stopper (rx/filter (ptk/type? ::initialize) stream)
buffer (atom #queue [])
profile (->> (rx/from-atom storage/user {:emit-current-value? true})
(rx/map :profile)
(rx/map :id)
(rx/pipe (rxo/distinct-contiguous)))]
ptk/EffectEvent
(effect [_ _ stream]
(let [session (atom nil)
stopper (rx/filter (ptk/type? ::initialize) stream)
buffer (atom #queue [])
profile (->> (rx/from-atom storage/user {:emit-current-value? true})
(rx/map :profile)
(rx/map :id)
(rx/pipe (rxo/distinct-contiguous)))]
(l/debug :hint "event instrumentation initialized")
(l/debug :hint "event instrumentation initialized")
(->> (rx/merge
(->> (rx/from-atom buffer)
(rx/filter #(pos? (count %)))
(rx/debounce 2000))
(->> stream
(rx/filter (ptk/type? :app.main.data.profile/logout))
(rx/observe-on :async)))
(rx/map (fn [_]
(into [] (take max-buffer-size) @buffer)))
(rx/with-latest-from profile)
(rx/mapcat (fn [[chunk profile-id]]
(let [events (filterv #(= profile-id (:profile-id %)) chunk)]
(->> (persist-events events)
(rx/tap (fn [_]
(l/debug :hint "events chunk persisted" :total (count chunk))))
(rx/map (constantly chunk))))))
(rx/take-until stopper)
(rx/subs! (fn [chunk]
(swap! buffer remove-from-buffer (count chunk)))
(fn [cause]
(l/error :hint "unexpected error on audit persistence" :cause cause))
(fn []
(l/debug :hint "audit persistence terminated"))))
(->> (rx/merge
(->> (rx/from-atom buffer)
(rx/filter #(pos? (count %)))
(rx/debounce 2000))
(->> stream
(rx/filter (ptk/type? :app.main.data.profile/logout))
(rx/observe-on :async)))
(rx/map (fn [_]
(into [] (take max-chunk-size) @buffer)))
(rx/with-latest-from profile)
(rx/mapcat (fn [[chunk profile-id]]
(let [events (filterv #(= profile-id (:profile-id %)) chunk)]
(->> (persist-events events)
(rx/tap (fn [_]
(l/debug :hint "events chunk persisted" :total (count chunk))))
(rx/map (constantly chunk))))))
(rx/take-until stopper)
(rx/subs! (fn [chunk]
(swap! buffer remove-from-buffer (count chunk)))
(fn [cause]
(l/error :hint "unexpected error on audit persistence" :cause cause))
(fn []
(l/debug :hint "audit persistence terminated"))))
(->> (rx/merge
(->> stream
(rx/with-latest-from profile)
(rx/map (fn [result]
(let [event (aget result 0)
profile-id (aget result 1)]
(some-> (process-event event)
(update :profile-id #(or % profile-id)))))))
(->> (rx/merge
(->> stream
(rx/with-latest-from profile)
(rx/map make-event))
(->> (performance-observer-event-stream)
(rx/with-latest-from profile)
(rx/map performance-payload)
(rx/debounce debounce-browser-event-time))
(->> (user-input-observer)
(rx/with-latest-from profile)
(rx/map make-performance-event)
(rx/debounce debounce-browser-event-time))
(->> (performance-observer-longtask-stream)
(rx/with-latest-from profile)
(rx/map performance-payload)
(rx/debounce debounce-longtask-time))
(->> (longtask-observer)
(rx/with-latest-from profile)
(rx/map make-performance-event)
(rx/debounce debounce-longtask-time))
(if (and (exists? js/globalThis)
(exists? (.-requestAnimationFrame js/globalThis))
(exists? (.-scheduler js/globalThis))
(exists? (.-postTask (.-scheduler js/globalThis))))
(->> stream
(rx/with-latest-from profile)
(rx/merge-map process-performance-event)
(rx/debounce debounce-performance-event-time)))
(rx/debounce debounce-performance-event-time))
(rx/empty)))
(rx/filter :profile-id)
(rx/map (fn [event]
(let [session* (or @session (ct/now))
context (-> @context
(merge (:context event))
(assoc :session session*)
(assoc :external-session-id (cf/external-session-id))
(d/without-nils))]
(reset! session session*)
(-> event
(assoc :timestamp (ct/now))
(assoc :context context)))))
(rx/filter :profile-id)
(rx/map (fn [event]
(let [session* (or @session (ct/now))
context (-> @context
(merge (:context event))
(assoc :session session*)
(assoc :external-session-id (cf/external-session-id))
(add-external-context-info)
(d/without-nils))]
(reset! session session*)
(-> event
(assoc :timestamp (ct/now))
(assoc :context context)))))
(rx/tap (fn [event]
(l/debug :hint "event enqueued")
(swap! buffer append-to-buffer event)))
(rx/tap (fn [event]
(l/debug :hint "event enqueued")
(swap! buffer append-to-buffer event)))
(rx/switch-map #(rx/timer session-timeout))
(rx/take-until stopper)
(rx/subs! (fn [_]
(l/debug :hint "session reinitialized")
(reset! session nil))
(fn [cause]
(l/error :hint "error on event batching stream" :cause cause))
(fn []
(l/debug :hitn "events batching stream terminated")))))))))
(rx/switch-map #(rx/timer session-timeout))
(rx/take-until stopper)
(rx/subs! (fn [_]
(l/debug :hint "session reinitialized")
(reset! session nil))
(fn [cause]
(l/error :hint "error on event batching stream" :cause cause))
(fn []
(l/debug :hitn "events batching stream terminated"))))))))
(defn event
[props]
(ptk/data-event ::event props))
;; --- DEVTOOLS PERF LOGGING
(defn install-long-task-observer! []
(when (and (some? (.-PerformanceObserver js/window)) (nil? @longtask-observer*))
(let [observer (js/PerformanceObserver.
(fn [list _]
(when (contains? cf/flags :perf-logs)
(doseq [entry (.getEntries list)]
(let [dur (.-duration entry)
start (.-startTime entry)
attrib (.-attribution entry)
attrib-count (when attrib (.-length attrib))
first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0))
attrib-name (when first-attrib (.-name first-attrib))
attrib-ctype (when first-attrib (.-containerType first-attrib))
attrib-cid (when first-attrib (.-containerId first-attrib))
attrib-csrc (when first-attrib (.-containerSrc first-attrib))]
(.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms"
(when first-attrib
(str " attrib:name=" attrib-name
" ctype=" attrib-ctype
" cid=" attrib-cid
" csrc=" attrib-csrc)))))))))]
(.observe observer #js{:entryTypes #js["longtask"]})
(reset! longtask-observer* observer))))
(defn start-event-loop-stall-logger!
"Log event loop stalls by measuring setInterval drift.
interval-ms: base interval
threshold-ms: drift over which we report"
[interval-ms threshold-ms]
(when (nil? @stall-timer*)
(let [last (atom (.now js/performance))
id (js/setInterval
(fn []
(when (contains? cf/flags :perf-logs)
(let [now (.now js/performance)
expected (+ @last interval-ms)
drift (- now expected)
current-op @current-op*
measures (.getEntriesByType js/performance "measure")
mlen (.-length measures)
last-measure (when (> mlen 0) (aget measures (dec mlen)))
meas-name (when last-measure (.-name last-measure))
meas-detail (when last-measure (.-detail last-measure))
meas-count (when meas-detail (unchecked-get meas-detail "count"))]
(reset! last now)
(when (> drift threshold-ms)
(.warn js/console
(str "[perf] event loop stall: " (Math/round drift) "ms"
(when current-op (str " op=" current-op))
(when meas-name (str " last=" meas-name))
(when meas-count (str " count=" meas-count))))))))
interval-ms)]
(reset! stall-timer* id))))
(defn init!
"Install perf observers in dev builds. Safe to call multiple times.
Perf logs are disabled by default. Enable them with the :perf-logs flag in config."
[]
(when ^boolean js/goog.DEBUG
(install-long-task-observer!)
(start-event-loop-stall-logger! 50 100)
;; Expose simple API on window for manual control in devtools
(let [api #js {:reset (fn []
(try
(.clearMarks js/performance)
(.clearMeasures js/performance)
(catch :default _ nil)))}]
(aset js/window "PenpotPerf" api))))

View File

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

View File

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

View File

@@ -46,7 +46,9 @@
[app.main.data.workspace.thumbnails :as dwt]
[app.main.data.workspace.transforms :as dwtr]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.data.workspace.zoom :as dwz]
[app.main.features :as features]
[app.main.features.pointer-map :as fpmap]
[app.main.refs :as refs]
[app.main.repo :as rp]
@@ -1012,6 +1014,13 @@
updated-objects (pcb/get-objects changes)
new-children-ids (cfh/get-children-ids-with-self updated-objects (:id new-shape))
new-text-ids (->> new-children-ids
(keep (fn [id]
(when-let [child (get updated-objects id)]
(when (and (cfh/text-shape? child)
(not= :fixed (:grow-type child)))
id))))
(vec))
[changes parents-of-swapped]
(if keep-touched?
@@ -1021,6 +1030,9 @@
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(when (and (features/active-feature? state "render-wasm/v1")
(seq new-text-ids))
(dwwt/resize-wasm-text-all new-text-ids))
(ptk/data-event :layout/update {:ids update-layout-ids :undo-group undo-group})
(dwu/commit-undo-transaction undo-id)
(dws/select-shape (:id new-shape) false))))))

View File

@@ -712,8 +712,7 @@
(ctm/rotation-modifiers shape center angle))
modif-tree
(-> (build-modif-tree ids objects get-modifier)
(gm/set-objects-modifiers objects))
(build-modif-tree ids objects get-modifier)
modifiers
(mapv (fn [[id {:keys [modifiers]}]]

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
@@ -29,10 +28,10 @@
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.router :as rt]
[app.render-wasm.api :as wasm.api]
[app.util.text-editor :as ted]
[app.util.text.content.styles :as styles]
[app.util.timers :as ts]
@@ -52,50 +51,6 @@
(declare v2-update-text-shape-content)
(declare v2-update-text-editor-styles)
(defn resize-wasm-text-modifiers
([shape]
(resize-wasm-text-modifiers shape (:content shape)))
([{:keys [id points selrect grow-type] :as shape} content]
(wasm.api/use-shape id)
(wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content)
(let [dimension (wasm.api/get-text-dimensions)
width-scale (if (#{:fixed :auto-height} grow-type)
1.0
(/ (:width dimension) (:width selrect)))
height-scale (if (= :fixed grow-type)
1.0
(/ (:height dimension) (:height selrect)))
resize-v (gpt/point width-scale height-scale)
origin (first points)]
{id
{:modifiers
(ctm/resize-modifiers
resize-v
origin
(:transform shape (gmt/matrix))
(:transform-inverse shape (gmt/matrix)))}})))
(defn resize-wasm-text
[id]
(ptk/reify ::resize-wasm-text
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))))))
(defn resize-wasm-text-all
[ids]
(ptk/reify ::resize-wasm-text-all
ptk/WatchEvent
(watch [_ _ _]
(->> (rx/from ids)
(rx/map resize-wasm-text)))))
;; -- Content helpers
(defn- v2-content-has-text?
@@ -178,7 +133,7 @@
{:undo-group (when new-shape? id)})
(dwm/apply-wasm-modifiers
(resize-wasm-text-modifiers shape content)
(dwwt/resize-wasm-text-modifiers shape content)
{:undo-group (when new-shape? id)})))))
(let [content (d/merge (ted/export-content content)
@@ -821,20 +776,11 @@
(rx/of (v2-update-text-editor-styles id attrs)))
(when (features/active-feature? state "render-wasm/v1")
(rx/concat
;; Apply style to selected spans and sync content
(when (wasm.api/text-editor-is-active?)
(let [span-attrs (select-keys attrs txt/text-node-attrs)]
(when (not (empty? span-attrs))
(let [result (wasm.api/apply-style-to-selection span-attrs)]
(when result
(rx/of (v2-update-text-shape-content
(:shape-id result) (:content result)
:update-name? true)))))))
;; Resize (with delay for font-id changes)
(cond->> (rx/of (resize-wasm-text id))
(contains? attrs :font-id)
(rx/delay 200))))))))
;; This delay is to give time for the font to be correctly rendered
;; in wasm.
(cond->> (rx/of (dwwt/resize-wasm-text id))
(contains? attrs :font-id)
(rx/delay 200)))))))
ptk/EffectEvent
(effect [_ state _]
@@ -982,11 +928,11 @@
(if (and (not= :fixed (:grow-type shape)) finalize?)
(dwm/apply-wasm-modifiers
(resize-wasm-text-modifiers shape content)
(dwwt/resize-wasm-text-modifiers shape content)
{:undo-group (when new-shape? id)})
(dwm/set-wasm-modifiers
(resize-wasm-text-modifiers shape content)
(dwwt/resize-wasm-text-modifiers shape content)
{:undo-group (when new-shape? id)})))
(when finalize?

View File

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

View File

@@ -27,9 +27,9 @@
[app.main.data.workspace.colors :as wdc]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.transforms :as dwtr]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.store :as st]
@@ -315,7 +315,7 @@
(and affects-layout?
(features/active-feature? state "render-wasm/v1"))
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(rx/of (dwwt/resize-wasm-text-all shape-ids))))))))
(defn update-line-height
([value shape-ids attributes] (update-line-height value shape-ids attributes nil))
@@ -374,7 +374,7 @@
:page-id page-id}))
(features/active-feature? state "render-wasm/v1")
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(rx/of (dwwt/resize-wasm-text-all shape-ids))))))))
(defn- create-font-family-text-attrs
[value]
@@ -451,7 +451,7 @@
:page-id page-id}))
(features/active-feature? state "render-wasm/v1")
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(rx/of (dwwt/resize-wasm-text-all shape-ids))))))))
(defn update-font-weight
([value shape-ids attributes] (update-font-weight value shape-ids attributes nil))

View File

@@ -406,13 +406,13 @@
(ctm/change-property :grow-type new-grow-type)))
modifiers)))
modif-tree
(-> (dwm/build-modif-tree ids objects get-modifier)
(gm/set-objects-modifiers objects))]
modif-tree (dwm/build-modif-tree ids objects get-modifier)]
(if (features/active-feature? state "render-wasm/v1")
(rx/of (dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true}))
(rx/of (dwm/apply-modifiers* objects modif-tree nil options))))))))
(let [modif-tree (gm/set-objects-modifiers modif-tree objects)]
(rx/of (dwm/apply-modifiers* objects modif-tree nil options)))))))))
(defn change-orientation
"Change orientation of shapes, from the sidebar options form.

View File

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

View File

@@ -0,0 +1,72 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.data.workspace.wasm-text
"Helpers/events to resize wasm text shapes without depending on workspace.texts.
This exists to avoid circular deps:
workspace.texts -> workspace.libraries -> workspace.texts"
(:require
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.modifiers :as dwm]
[app.render-wasm.api :as wasm.api]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn resize-wasm-text-modifiers
([shape]
(resize-wasm-text-modifiers shape (:content shape)))
([{:keys [id points selrect grow-type] :as shape} content]
(wasm.api/use-shape id)
(wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content)
(let [dimension (wasm.api/get-text-dimensions)
width-scale (if (#{:fixed :auto-height} grow-type)
1.0
(/ (:width dimension) (:width selrect)))
height-scale (if (= :fixed grow-type)
1.0
(/ (:height dimension) (:height selrect)))
resize-v (gpt/point width-scale height-scale)
origin (first points)]
{id
{:modifiers
(ctm/resize-modifiers
resize-v
origin
(:transform shape (gmt/matrix))
(:transform-inverse shape (gmt/matrix)))}})))
(defn resize-wasm-text
"Resize a single text shape (auto-width/auto-height) by id.
No-op if the id is not a text shape or is :fixed."
[id]
(ptk/reify ::resize-wasm-text
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
(if (and (some? shape)
(cfh/text-shape? shape)
(not= :fixed (:grow-type shape)))
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))
(rx/empty))))))
(defn resize-wasm-text-all
"Resize all text shapes (auto-width/auto-height) from a collection of ids."
[ids]
(ptk/reify ::resize-wasm-text-all
ptk/WatchEvent
(watch [_ _ _]
(->> (rx/from ids)
(rx/map resize-wasm-text)))))

View File

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

View File

@@ -108,6 +108,7 @@
"Initializes the rasterizer."
[]
(let [iframe (dom/create-element "iframe")]
(dom/set-attribute! iframe "id" "rasterizer")
(dom/set-attribute! iframe "src" origin)
(dom/set-attribute! iframe "hidden" true)
(.addEventListener js/window "message" on-message)

View File

@@ -345,9 +345,11 @@
max-height (max height selrect-height)
valign (-> shape :content :vertical-align)
y (:y selrect)
y (case valign
"bottom" (+ y (- selrect-height height))
"center" (+ y (/ (- selrect-height height) 2))
y (if (and valign (> height selrect-height))
(case valign
"bottom" (- y (- height selrect-height))
"center" (- y (/ (- height selrect-height) 2))
y)
y)]
[(assoc selrect :y y :width max-width :height max-height) transform])

View File

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

View File

@@ -15,6 +15,7 @@
[app.main.data.workspace.shortcuts :as sc]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -138,7 +139,7 @@
(dwsh/update-shapes ids #(assoc % :grow-type grow-type)))
(when (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwt/resize-wasm-text-all ids)))
(st/emit! (dwwt/resize-wasm-text-all ids)))
;; We asynchronously commit so every sychronous event is resolved first and inside the transaction
(ts/schedule #(st/emit! (dwu/commit-undo-transaction uid))))
(when (some? on-blur) (on-blur))))]

View File

@@ -19,14 +19,10 @@
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.path :as dwdp]
[app.main.data.workspace.specialized-panel :as-alias dwsp]
[app.main.data.workspace.texts :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.workspace.sidebar.assets.components :as wsac]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm.wasm]
[app.util.dom :as dom]
[app.util.dom.dnd :as dnd]
[app.util.dom.normalize-wheel :as nw]
@@ -95,17 +91,7 @@
::dwsp/interrupt)
(when (and (not= edition id) (or text-editing? grid-editing?))
(st/emit! (dw/clear-edition-mode))
;; Sync and stop WASM text editor when exiting edit mode
(when (and text-editing?
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? true)))
(wasm.api/text-editor-stop)))
(st/emit! (dw/clear-edition-mode)))
(when (and (not text-editing?)
(not blocked)
@@ -198,20 +184,6 @@
(not drawing-tool))
(st/emit! (dw/select-shape (:id @hover) shift?)))
;; If clicking on a text shape and wasm render is enabled, forward cursor position
(when (and hovering?
(not @space?)
edition ;; Only when already in edit mode
(not drawing-path?)
(not drawing-tool))
(let [hover-shape @hover]
(when (and (= :text (:type hover-shape))
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(let [raw-pt (dom/get-client-position event)]
;; FIXME
(wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt))))))
(when (and @z?
(not @space?)
(not edition)
@@ -251,17 +223,8 @@
(when (and (not drawing-path?) shape)
(cond
(and editable? (not= id edition) (not read-only?))
(do
(st/emit! (dw/select-shape id)
(dw/start-editing-selected))
;; If using wasm text-editor, notify WASM to start editing this shape
;; and set cursor position from the double-click location
(when (and (= type :text)
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(let [raw-pt (dom/get-client-position event)
viewport-pt (uwvv/point->viewport-relative raw-pt)]
(wasm.api/text-editor-start id))))
(st/emit! (dw/select-shape id)
(dw/start-editing-selected))
(some? selected-shape)
(do

View File

@@ -22,7 +22,6 @@
[app.main.data.workspace.path.shortcuts :as psc]
[app.main.data.workspace.shortcuts :as wsc]
[app.main.data.workspace.text.shortcuts :as tsc]
[app.main.data.workspace.texts :as dwt]
[app.main.features :as features]
[app.main.store :as st]
[app.main.streams :as ms]
@@ -32,7 +31,6 @@
[app.main.ui.workspace.viewport.utils :as utils]
[app.main.worker :as mw]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm.wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.globals :as globals]
@@ -166,6 +164,7 @@
;; for the release of the z key
(when-not ^boolean value
(reset! z* false))))
(hooks/use-stream kbd-zoom-s
(fn [kevent]
(dom/prevent-default kevent)

View File

@@ -66,14 +66,6 @@
(gpt/divide zoom)
(gpt/add box))))))
(defn point->viewport-relative
"Convert client coordinates to viewport-relative coordinates.
Unlike point->viewport, this does NOT convert to canvas coordinates -
it just subtracts the viewport's bounding rect offset."
[pt]
(when (some? @viewport-brect)
(gpt/subtract pt @viewport-brect)))
(defn inside-viewport?
[target]
(dom/is-child? @viewport-ref target))

View File

@@ -54,7 +54,6 @@
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.widgets :as widgets]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor-input :refer [text-editor-input]]
[app.util.debug :as dbg]
[app.util.text-editor :as ted]
[beicon.v2.core :as rx]
@@ -408,14 +407,7 @@
(when picking-color?
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
:canvas-ref canvas-ref}])
;; WASM text editor contenteditable (must be outside SVG to work)
(when (and show-text-editor?
(features/active-feature? @st/state "text-editor-wasm/v1"))
[:& text-editor-input {:shape editing-shape
:zoom zoom
:vbox vbox}])]
:canvas-ref canvas-ref}])]
[:canvas {:id "render"
:data-testid "canvas-wasm-shapes"
@@ -460,10 +452,7 @@
:height (max 0 (- (:height vbox) rule-area-size))}]]]
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
;; Text editor handling:
;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
(when (and show-text-editor?
(not (features/active-feature? @st/state "text-editor-wasm/v1")))
(when show-text-editor?
(if (features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref

View File

@@ -39,7 +39,6 @@
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.text-editor :as text-editor]
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
@@ -54,10 +53,6 @@
(def use-dpr? (contains? cf/flags :render-wasm-dpr))
;; Cache of text content per shape-id, populated when content is sent to WASM.
;; Used by the text editor sync to reconstruct the full content tree with styling.
(def ^:private shape-text-contents (atom {}))
(def ^:const UUID-U8-SIZE 16)
(def ^:const UUID-U32-SIZE (/ UUID-U8-SIZE 4))
@@ -79,18 +74,6 @@
;; Threshold below which we use synchronous processing (no chunking overhead)
(def ^:const ASYNC_THRESHOLD 100)
;; Re-export public WebGL functions
(def capture-canvas-pixels webgl/capture-canvas-pixels)
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
(def clear-canvas-pixels webgl/clear-canvas-pixels)
;; Re-export public text editor functions
(def text-editor-start text-editor/text-editor-start)
(def text-editor-stop text-editor/text-editor-stop)
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
(def text-editor-is-active? text-editor/text-editor-is-active?)
(def text-editor-sync-content text-editor/text-editor-sync-content)
(def dpr
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
@@ -126,36 +109,11 @@
(mf/element object-svg #js {:shape shape})
(rds/renderToStaticMarkup)))
;; forward declare helpers so render can call them
(declare request-render)
(declare set-shape-vertical-align fonts-from-text-content)
;; This should never be called from the outside.
(defn- render
[timestamp]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render" timestamp)
;; Update text editor blink (so cursor toggles) using the same timestamp
(try
(when wasm/context-initialized?
(text-editor/text-editor-update-blink timestamp)
;; Render text editor overlay on top of main canvas (only if feature enabled)
;; Determine if text-editor-wasm feature is active without requiring
;; app.main.features to avoid circular dependency: check runtime and
;; persisted feature sets in the store state.
(let [runtime-features (get @st/state :features-runtime)
enabled-features (get @st/state :features)]
(when (or (contains? runtime-features "text-editor-wasm/v1")
(contains? enabled-features "text-editor-wasm/v1"))
(text-editor/text-editor-render-overlay)))
;; Poll for editor events; if any event occurs, trigger a re-render
(let [ev (text-editor/text-editor-poll-event)]
(when (and ev (not= ev 0))
(request-render "text-editor-event"))))
(catch :default e
(js/console.error "text-editor overlay/update failed:" e)))
(set! wasm/internal-frame-id nil)
(ug/dispatch! (ug/event "penpot:wasm:render"))))
@@ -229,41 +187,6 @@
(declare get-text-dimensions)
(defn use-shape
[id]
(when wasm/context-initialized?
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_use_shape"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)))))
(defn set-shape-text-content
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
[shape-id content]
;; Cache content for text editor sync
(text-editor/cache-shape-text-content! shape-id content)
(h/call wasm/internal-module "_clear_shape_text")
(set-shape-vertical-align (get content :vertical-align))
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts shape-id all-fonts)]
(f/load-fallback-fonts-for-editor! fallback-fonts)
(h/call wasm/internal-module "_update_shape_text_layout")
result))
(defn apply-style-to-selection
"Apply style attrs to the currently selected text spans.
Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving."
[attrs]
(text-editor/apply-style-to-selection attrs use-shape set-shape-text-content))
(defn update-text-rect!
[id]
(when wasm/context-initialized?
@@ -273,6 +196,7 @@
:shape-id id
:dimensions (get-text-dimensions id)})))
(defn- ensure-text-content
"Guarantee that the shape always sends a valid text tree to WASM. When the
content is nil (freshly created text) we fall back to
@@ -280,6 +204,16 @@
[content]
(or content (tc/v2-default-text-content)))
(defn use-shape
[id]
(when wasm/context-initialized?
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_use_shape"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)))))
(defn set-parent-id
[id]
(let [buffer (uuid/get-u32 id)]
@@ -923,6 +857,22 @@
(if fallback-fonts-only? updated-fonts fallback-fonts))))))
(defn set-shape-text-content
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
[shape-id content]
(h/call wasm/internal-module "_clear_shape_text")
(set-shape-vertical-align (get content :vertical-align))
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts shape-id all-fonts)]
(f/load-fallback-fonts-for-editor! fallback-fonts)
(h/call wasm/internal-module "_update_shape_text_layout")
result))
(defn set-shape-grow-type
[grow-type]
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
@@ -1589,41 +1539,33 @@
(persistent! result)))
result
(into []
(keep
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
(let [content (:content shape)
element (-> content :children
(get 0) :children ;; paragraph-set
(get paragraph) :children ;; paragraph
(get span))
element-text (:text element)]
(->> result
(mapv
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
(let [content (:content shape)
element (-> content :children
(get 0) :children ;; paragraph-set
(get paragraph) :children ;; paragraph
(get span))
text (subs (:text element) start-pos end-pos)]
;; Add comprehensive nil-safety checks
(when (and element
element-text
(>= start-pos 0)
(<= end-pos (count element-text))
(<= start-pos end-pos))
(let [text (subs element-text start-pos end-pos)]
(d/patch-object
txt/default-text-attrs
(d/without-nils
{:x x
:y (+ y height)
:width width
:height height
:direction (dr/translate-direction direction)
:font-family (get element :font-family)
:font-size (get element :font-size)
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (get element :fills)
:text text})))))))
result)]
(d/patch-object
txt/default-text-attrs
(d/without-nils
{:x x
:y (+ y height)
:width width
:height height
:direction (dr/translate-direction direction)
:font-family (get element :font-family)
:font-size (get element :font-size)
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (get element :fills)
:text text}))))))]
(mem/free)
result)))
@@ -1657,4 +1599,7 @@
(p/resolved false)))))
(p/resolved false))))
;; Re-export public WebGL functions
(def capture-canvas-pixels webgl/capture-canvas-pixels)
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
(def clear-canvas-pixels webgl/clear-canvas-pixels)

View File

@@ -61,30 +61,6 @@
[]
(h/call wasm/internal-module "_free_bytes"))
(defn read-string
"Read a UTF-8 string from WASM memory given a byte pointer/offset.
Uses Emscripten's UTF8ToString to decode the string."
[ptr]
(h/call wasm/internal-module "UTF8ToString" ptr))
(defn read-null-terminated-string
"Read a null-terminated UTF-8 string from WASM memory.
Manually reads bytes until null terminator and decodes using TextDecoder."
[ptr]
(when (and ptr (not (zero? ptr)))
(let [heap (get-heap-u8)
;; Find the null terminator
end-idx (loop [idx ptr]
(if (zero? (aget heap idx))
idx
(recur (inc idx))))
;; Extract the bytes (excluding null terminator)
length (- end-idx ptr)
bytes (.slice heap ptr end-idx)
;; Decode using TextDecoder
decoder (js/TextDecoder. "utf-8")]
(.decode decoder bytes))))
(defn slice
"Returns a copy of a portion of a typed array into a new typed array
object selected from start to end."

View File

@@ -1,299 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.text-editor
"Text editor WASM bindings"
(:require
[app.common.uuid :as uuid]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.wasm :as wasm]))
(defn text-editor-start
[id]
(when wasm/context-initialized?
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_text_editor_start"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)))))
(defn text-editor-set-cursor-from-point
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
(defn text-editor-update-blink
[timestamp-ms]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms)))
(defn text-editor-render-overlay
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_render_overlay")))
(defn text-editor-poll-event
[]
(when wasm/context-initialized?
(let [res (h/call wasm/internal-module "_text_editor_poll_event")]
res)))
(defn text-editor-insert-text
[text]
(when wasm/context-initialized?
(let [encoder (js/TextEncoder.)
buf (.encode encoder text)
heapu8 (mem/get-heap-u8)
size (mem/size buf)
offset (mem/alloc size)]
(mem/write-buffer offset heapu8 buf)
(h/call wasm/internal-module "_text_editor_insert_text")
(mem/free))))
(defn text-editor-delete-backward []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_backward")))
(defn text-editor-delete-forward []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_forward")))
(defn text-editor-insert-paragraph []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
(defn text-editor-move-cursor
[direction extend-selection]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_move_cursor" direction (if extend-selection 1 0))))
(defn text-editor-select-all
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_select_all")))
(defn text-editor-stop
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_stop")))
(defn text-editor-is-active?
[]
(when wasm/context-initialized?
(not (zero? (h/call wasm/internal-module "_text_editor_is_active")))))
(defn text-editor-export-content
[]
(when wasm/context-initialized?
(let [ptr (h/call wasm/internal-module "_text_editor_export_content")]
(when (and ptr (not (zero? ptr)))
(let [json-str (mem/read-null-terminated-string ptr)]
(mem/free)
(js/JSON.parse json-str))))))
(defn text-editor-export-selection
"Export only the currently selected text as plain text from the WASM editor. Requires WASM support (_text_editor_export_selection)."
[]
(when wasm/context-initialized?
(let [ptr (h/call wasm/internal-module "_text_editor_export_selection")]
(when (and ptr (not (zero? ptr)))
(let [text (mem/read-null-terminated-string ptr)]
(mem/free)
text)))))
(defn text-editor-get-active-shape-id
[]
(when wasm/context-initialized?
(try
(let [byte-offset (mem/alloc 16)
u32-offset (mem/->offset-32 byte-offset)
heap (mem/get-heap-u32)]
(h/call wasm/internal-module "_text_editor_get_active_shape_id" byte-offset)
(let [a (aget heap u32-offset)
b (aget heap (+ u32-offset 1))
c (aget heap (+ u32-offset 2))
d (aget heap (+ u32-offset 3))
result (when (or (not= a 0) (not= b 0) (not= c 0) (not= d 0))
(uuid/from-unsigned-parts a b c d))]
(mem/free)
result))
(catch js/Error e
(js/console.error "[text-editor-get-active-shape-id] Error:" e)
nil))))
(defn text-editor-get-selection
[]
(when wasm/context-initialized?
(let [byte-offset (mem/alloc 16)
u32-offset (mem/->offset-32 byte-offset)
heap (mem/get-heap-u32)
active? (h/call wasm/internal-module "_text_editor_get_selection" byte-offset)]
(let [result (when (= active? 1)
{:anchor-para (aget heap u32-offset)
:anchor-offset (aget heap (+ u32-offset 1))
:focus-para (aget heap (+ u32-offset 2))
:focus-offset (aget heap (+ u32-offset 3))})]
(mem/free)
result))))
(def ^:private shape-text-contents (atom {}))
(defn- merge-exported-texts-into-content
"Merge exported span texts back into the existing content tree.
The WASM editor may split or merge paragraphs (Enter / Backspace at
paragraph boundary), so the exported structure can differ from the
original. When extra paragraphs or spans appear we clone styling from
the nearest existing sibling; when fewer appear we truncate.
exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]]
content existing Penpot content map (root -> paragraph-set -> …)"
[content exported-texts]
(let [para-set (first (get content :children))
orig-paras (get para-set :children)
num-orig (count orig-paras)
last-orig-para (when (seq orig-paras) (last orig-paras))
template-span (when last-orig-para
(-> last-orig-para :children last))
new-paras
(mapv (fn [para-idx exported-span-texts]
(let [orig-para (if (< para-idx num-orig)
(nth orig-paras para-idx)
(dissoc last-orig-para :children))
orig-spans (get orig-para :children)
num-orig-spans (count orig-spans)
last-orig-span (when (seq orig-spans) (last orig-spans))]
(assoc orig-para :children
(mapv (fn [span-idx new-text]
(let [orig-span (if (< span-idx num-orig-spans)
(nth orig-spans span-idx)
(or last-orig-span template-span))]
(assoc orig-span :text new-text)))
(range (count exported-span-texts))
exported-span-texts))))
(range (count exported-texts))
exported-texts)
new-para-set (assoc para-set :children new-paras)]
(assoc content :children [new-para-set])))
(defn text-editor-sync-content
"Sync text content from the WASM text editor back to the frontend shape.
Exports the current span texts from WASM, merges them into the shape's
cached content tree (preserving per-span styling), and returns the
shape-id and the fully merged content map ready for
v2-update-text-shape-content."
[]
(when (and wasm/context-initialized? (text-editor-is-active?))
(let [shape-id (text-editor-get-active-shape-id)
new-texts (text-editor-export-content)]
(when (and shape-id new-texts)
(let [texts-clj (js->clj new-texts)
content (get @shape-text-contents shape-id)]
(when content
(let [merged (merge-exported-texts-into-content content texts-clj)]
(swap! shape-text-contents assoc shape-id merged)
{:shape-id shape-id
:content merged})))))))
(defn cache-shape-text-content!
[shape-id content]
(when (some? content)
(swap! shape-text-contents assoc shape-id content)))
(defn get-cached-content
[shape-id]
(get @shape-text-contents shape-id))
(defn update-cached-content!
[shape-id content]
(swap! shape-text-contents assoc shape-id content))
(defn- normalize-selection
"Given anchor/focus para+offset, return {:start-para :start-offset :end-para :end-offset}
ordered so start <= end."
[{:keys [anchor-para anchor-offset focus-para focus-offset]}]
(if (or (< anchor-para focus-para)
(and (= anchor-para focus-para) (<= anchor-offset focus-offset)))
{:start-para anchor-para :start-offset anchor-offset
:end-para focus-para :end-offset focus-offset}
{:start-para focus-para :start-offset focus-offset
:end-para anchor-para :end-offset anchor-offset}))
(defn- apply-attrs-to-paragraph
"Apply attrs to spans within [sel-start, sel-end) char range of a single paragraph.
Splits spans at boundaries as needed."
[para sel-start sel-end attrs]
(let [spans (:children para)
result (loop [spans spans
pos 0
acc []]
(if (empty? spans)
acc
(let [span (first spans)
text (:text span)
span-len (count text)
span-end (+ pos span-len)
ol-start (max pos sel-start)
ol-end (min span-end sel-end)
has-overlap? (< ol-start ol-end)]
(if (not has-overlap?)
(recur (rest spans) span-end (conj acc span))
(let [before (when (> ol-start pos)
(assoc span :text (subs text 0 (- ol-start pos))))
selected (merge span attrs
{:text (subs text (- ol-start pos) (- ol-end pos))})
after (when (< ol-end span-end)
(assoc span :text (subs text (- ol-end pos))))]
(recur (rest spans) span-end
(-> acc
(into (keep identity [before selected after])))))))))]
(assoc para :children result)))
(defn- para-char-count
[para]
(apply + (map (fn [span] (count (:text span))) (:children para))))
(defn apply-style-to-selection
[attrs use-shape-fn set-shape-text-content-fn]
(when (and wasm/context-initialized? (text-editor-is-active?))
(let [shape-id (text-editor-get-active-shape-id)
sel (text-editor-get-selection)]
(when (and shape-id sel)
(let [content (get @shape-text-contents shape-id)]
(when content
(let [{:keys [start-para start-offset end-para end-offset]}
(normalize-selection sel)
collapsed? (and (= start-para end-para) (= start-offset end-offset))
para-set (first (:children content))
paras (:children para-set)
new-paras
(when (not collapsed?)
(mapv (fn [idx para]
(cond
(or (< idx start-para) (> idx end-para))
para
(= start-para end-para)
(apply-attrs-to-paragraph para start-offset end-offset attrs)
(= idx start-para)
(apply-attrs-to-paragraph para start-offset (para-char-count para) attrs)
(= idx end-para)
(apply-attrs-to-paragraph para 0 end-offset attrs)
:else
(apply-attrs-to-paragraph para 0 (para-char-count para) attrs)))
(range (count paras))
paras))
new-content (when new-paras
(assoc content :children
[(assoc para-set :children new-paras)]))]
(when new-content
(swap! shape-text-contents assoc shape-id new-content)
(use-shape-fn shape-id)
(set-shape-text-content-fn shape-id new-content)
{:shape-id shape-id
:content new-content}))))))))

View File

@@ -1,242 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.text-editor-input
"Contenteditable DOM element for WASM text editor input"
(:require
[app.common.geom.shapes :as gsh]
[app.main.data.workspace.texts :as dwt]
[app.main.store :as st]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor :as text-editor]
[app.util.dom :as dom]
[app.util.object :as obj]
[cuerdas.core :as str]
[goog.events :as events]
[rumext.v2 :as mf])
(:import goog.events.EventType))
(defn- sync-wasm-text-editor-content!
"Sync WASM text editor content back to the shape via the standard
commit pipeline. Called after every text-modifying input."
[& {:keys [finalize?]}]
(when-let [{:keys [shape-id content]} (text-editor/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? finalize?))))
(mf/defc text-editor-input
"Contenteditable element positioned over the text shape to capture input events."
{::mf/wrap-props false}
[props]
(let [shape (obj/get props "shape")
zoom (obj/get props "zoom")
vbox (obj/get props "vbox")
contenteditable-ref (mf/use-ref nil)
composing? (mf/use-state false)
;; Calculate screen position from shape bounds
shape-bounds (gsh/shape->rect shape)
screen-x (* (- (:x shape-bounds) (:x vbox)) zoom)
screen-y (* (- (:y shape-bounds) (:y vbox)) zoom)
screen-w (* (:width shape-bounds) zoom)
screen-h (* (:height shape-bounds) zoom)]
;; Focus contenteditable on mount
(mf/use-effect
(fn []
(when-let [node (mf/ref-val contenteditable-ref)]
(.focus node))
js/undefined))
;; Animation loop for cursor blink
(mf/use-effect
(fn []
(let [raf-id (atom nil)
animate (fn animate []
(when (text-editor/text-editor-is-active?)
(wasm.api/request-render "cursor-blink")
(reset! raf-id (js/requestAnimationFrame animate))))]
(animate)
(fn []
(when @raf-id
(js/cancelAnimationFrame @raf-id))))))
;; Document-level keydown handler for control keys
(mf/use-effect
(fn []
(let [on-doc-keydown
(fn [e]
(when (and (text-editor/text-editor-is-active?)
(not @composing?))
(let [key (.-key e)
ctrl? (or (.-ctrlKey e) (.-metaKey e))
shift? (.-shiftKey e)]
(cond
;; Escape: finalize and stop
(= key "Escape")
(do
(dom/prevent-default e)
(sync-wasm-text-editor-content! :finalize? true)
(text-editor/text-editor-stop))
;; Ctrl+A: select all (key is "a" or "A" depending on platform)
(and ctrl? (= (str/lower key) "a"))
(do
(dom/prevent-default e)
(text-editor/text-editor-select-all)
(wasm.api/request-render "text-select-all"))
;; Enter
(= key "Enter")
(do
(dom/prevent-default e)
(text-editor/text-editor-insert-paragraph)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paragraph"))
;; Backspace
(= key "Backspace")
(do
(dom/prevent-default e)
(text-editor/text-editor-delete-backward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-backward"))
;; Delete
(= key "Delete")
(do
(dom/prevent-default e)
(text-editor/text-editor-delete-forward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-forward"))
;; Arrow keys
(= key "ArrowLeft")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 0 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowRight")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 1 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowUp")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 2 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowDown")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 3 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "Home")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 4 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "End")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 5 shift?)
(wasm.api/request-render "text-cursor-move"))
;; Let contenteditable handle text input via on-input
:else nil))))]
(events/listen js/document EventType.KEYDOWN on-doc-keydown true)
(fn []
(events/unlisten js/document EventType.KEYDOWN on-doc-keydown true)))))
;; Composition and input events
(let [on-composition-start
(mf/use-fn
(fn [_event]
(reset! composing? true)))
on-composition-end
(mf/use-fn
(fn [^js event]
(reset! composing? false)
(let [data (.-data event)]
(when data
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-composition"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
on-paste
(mf/use-fn
(fn [^js event]
(dom/prevent-default event)
(let [clipboard-data (.-clipboardData event)
text (.getData clipboard-data "text/plain")]
(when (and text (seq text))
(text-editor/text-editor-insert-text text)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paste"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
on-copy
(mf/use-fn
(fn [^js event]
(when (text-editor/text-editor-is-active?)
(dom/prevent-default event)
(when-let [selection (text-editor/text-editor-get-selection)]
(let [text (text-editor/text-editor-export-selection)]
(let [clipboard-data (.-clipboardData event)]
(.setData clipboard-data "text/plain" text)))))))
on-input
(mf/use-fn
(fn [^js event]
(let [native-event (.-nativeEvent event)
input-type (.-inputType native-event)
data (.-data native-event)]
;; Skip composition-related input events - composition-end handles those
(when (and (not @composing?)
(not= input-type "insertCompositionText"))
(when (and data (seq data))
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-input"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) ""))))))]
[:div
{:ref contenteditable-ref
:contentEditable true
:suppressContentEditableWarning true
:on-composition-start on-composition-start
:on-composition-end on-composition-end
:on-input on-input
:on-paste on-paste
:on-copy on-copy
;; FIXME on-click
;; :on-click on-click
:id "text-editor-wasm-input"
;; FIXME
:style {:position "absolute"
:left (str screen-x "px")
:top (str screen-y "px")
:width (str screen-w "px")
:height (str screen-h "px")
:opacity 0
:overflow "hidden"
:white-space "pre"
:cursor "text"
:z-index 10}}])))

View File

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

View File

@@ -238,7 +238,8 @@ export class SelectionController extends EventTarget {
#applyStylesFromElementToCurrentStyle(element) {
for (let index = 0; index < element.style.length; index++) {
const styleName = element.style.item(index);
if (styleName === "--fills") {
// Only merge fill styles from text spans.
if (!isTextSpan(element) && styleName === "--fills") {
continue;
}
let styleValue = element.style.getPropertyValue(styleName);

View File

@@ -7,7 +7,7 @@ different parts of the platform, please refer to `docs/` directory.
## 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
clear when we have an internal fix in progress. Before filing a new
task, try to make sure your problem doesn't already exist.

View File

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

View File

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

View File

@@ -16,6 +16,7 @@
"polyfills": ["zone.js"],
"tsConfig": "apps/contrast-plugin/tsconfig.app.json",
"assets": [
"apps/contrast-plugin/src/_headers",
"apps/contrast-plugin/src/favicon.ico",
"apps/contrast-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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@
"polyfills": ["zone.js"],
"tsConfig": "apps/icons-plugin/tsconfig.app.json",
"assets": [
"apps/icons-plugin/src/_headers",
"apps/icons-plugin/src/favicon.ico",
"apps/icons-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

View File

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

View File

@@ -16,6 +16,7 @@
"polyfills": ["zone.js"],
"tsConfig": "apps/lorem-ipsum-plugin/tsconfig.app.json",
"assets": [
"apps/lorem-ipsum-plugin/src/_headers",
"apps/lorem-ipsum-plugin/src/favicon.ico",
"apps/lorem-ipsum-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

View File

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

View File

@@ -16,6 +16,7 @@
"polyfills": ["zone.js"],
"tsConfig": "apps/rename-layers-plugin/tsconfig.app.json",
"assets": [
"apps/rename-layers-plugin/src/_headers",
"apps/rename-layers-plugin/src/favicon.ico",
"apps/rename-layers-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

View File

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

View File

@@ -16,6 +16,7 @@
"polyfills": ["zone.js"],
"tsConfig": "apps/table-plugin/tsconfig.app.json",
"assets": [
"apps/table-plugin/src/_headers",
"apps/table-plugin/src/favicon.ico",
"apps/table-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

View File

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

View File

@@ -19,7 +19,7 @@ the latest changes from the `main` branch. This will trigger the
deployment at Cloudfare if the `libs/plugin-types/index.d.ts` or the
`tools/typedoc.css` files have been updated.
Take a look at the [Penpot plugins API](https://penpot-plugins-api-doc.pages.dev/) to see what's new.
Take a look at the [Penpot plugins API](https://doc.plugins.penpot.app/) to see what's new.
#### Styles

View File

@@ -20,7 +20,7 @@ Import the CSS file into your project:
For detailed examples and to see how to use the styles and components, visit the documentation at:
[Penpot Plugin Styles Documentation](https://penpot-plugins-styles.pages.dev)
[Penpot Plugin Styles Documentation](https://styles-doc.plugins.penpot.app)
#### Icons

View File

@@ -2,3 +2,7 @@ name = "penpot-plugins-api-doc"
compatibility_date = "2025-01-01"
assets = { directory = "dist/doc" }
[[routes]]
pattern = "WORKER_URI"
custom_domain = true

View File

@@ -0,0 +1,8 @@
name = "penpot-plugins-style-doc"
compatibility_date = "2025-01-01"
assets = { directory = "dist/apps/example-styles" }
[[routes]]
pattern = "WORKER_URI"
custom_domain = true

View File

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

View File

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

View File

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

View File

@@ -27,8 +27,8 @@ fn draw_stroke_on_rect(
// - The same rect if it's a center stroke
// - A bigger rect if it's an outer stroke
// - A smaller rect if it's an outer stroke
let stroke_rect = stroke.outer_rect(rect);
let mut paint = stroke.to_paint(selrect, svg_attrs, scale, antialias);
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
@@ -63,8 +63,8 @@ fn draw_stroke_on_circle(
// - The same oval if it's a center stroke
// - A bigger oval if it's an outer stroke
// - A smaller oval if it's an outer stroke
let stroke_rect = stroke.outer_rect(rect);
let mut paint = stroke.to_paint(selrect, svg_attrs, scale, antialias);
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
@@ -131,7 +131,6 @@ pub fn draw_stroke_on_path(
selrect: &Rect,
path_transform: Option<&Matrix>,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
antialias: bool,
@@ -142,7 +141,7 @@ pub fn draw_stroke_on_path(
let is_open = path.is_open();
let mut paint: skia_safe::Handle<_> =
stroke.to_stroked_paint(is_open, selrect, svg_attrs, scale, antialias);
stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias);
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
@@ -166,7 +165,6 @@ pub fn draw_stroke_on_path(
canvas,
is_open,
svg_attrs,
scale,
blur,
antialias,
);
@@ -218,7 +216,6 @@ fn handle_stroke_caps(
canvas: &skia::Canvas,
is_open: bool,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
blur: Option<&ImageFilter>,
antialias: bool,
) {
@@ -233,8 +230,7 @@ fn handle_stroke_caps(
let first_point = points.first().unwrap();
let last_point = points.last().unwrap();
let mut paint_stroke =
stroke.to_stroked_paint(is_open, selrect, svg_attrs, scale, antialias);
let mut paint_stroke = stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias);
if let Some(filter) = blur {
paint_stroke.set_image_filter(filter.clone());
@@ -405,7 +401,7 @@ fn draw_image_stroke_in_container(
// Draw the stroke based on the shape type, we are using this stroke as
// a "selector" of the area of the image we want to show.
let outer_rect = stroke.outer_rect(container);
let outer_rect = stroke.aligned_rect(container, scale);
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
@@ -450,8 +446,7 @@ fn draw_image_stroke_in_container(
}
}
let is_open = p.is_open();
let mut paint =
stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, scale, antialias);
let mut paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias);
canvas.draw_path(&path, &paint);
if stroke.render_kind(is_open) == StrokeKind::Outer {
// Small extra inner stroke to overlap with the fill
@@ -466,7 +461,6 @@ fn draw_image_stroke_in_container(
canvas,
is_open,
svg_attrs,
scale,
shape.image_filter(1.).as_ref(),
antialias,
);
@@ -662,7 +656,6 @@ fn render_internal(
&selrect,
path_transform.as_ref(),
svg_attrs,
scale,
shadow,
shape.image_filter(1.).as_ref(),
antialias,
@@ -685,14 +678,13 @@ pub fn render_text_paths(
shadow: Option<&ImageFilter>,
antialias: bool,
) {
let scale = render_state.get_scale();
let canvas = render_state
.surfaces
.canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::Strokes));
let selrect = &shape.selrect;
let svg_attrs = shape.svg_attrs.as_ref();
let mut paint: skia_safe::Handle<_> =
stroke.to_text_stroked_paint(false, selrect, svg_attrs, scale, antialias);
stroke.to_text_stroked_paint(false, selrect, svg_attrs, antialias);
if let Some(filter) = shadow {
paint.set_image_filter(filter.clone());

View File

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

View File

@@ -1,238 +0,0 @@
use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
use crate::state::{TextCursor, TextEditorState, TextSelection};
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{Canvas, Color, Matrix, Paint, Rect};
const CURSOR_WIDTH: f32 = 1.5;
/// FIXME: Use theme color, take into account background color for contrast
const SELECTION_COLOR: Color = Color::from_argb(80, 66, 133, 244);
const CURSOR_COLOR: Color = Color::BLACK;
pub fn render_overlay(
canvas: &Canvas,
editor_state: &TextEditorState,
shape: &Shape,
transform: &Matrix,
) {
if !editor_state.is_active {
return;
}
let Type::Text(text_content) = &shape.shape_type else {
return;
};
canvas.save();
canvas.concat(transform);
if editor_state.selection.is_selection() {
render_selection(canvas, &editor_state.selection, text_content, shape);
}
if editor_state.cursor_visible {
render_cursor(canvas, &editor_state.selection.focus, text_content, shape);
}
canvas.restore();
}
fn render_cursor(canvas: &Canvas, cursor: &TextCursor, text_content: &TextContent, shape: &Shape) {
let Some(rect) = calculate_cursor_rect(cursor, text_content, shape) else {
return;
};
let mut paint = Paint::default();
paint.set_color(CURSOR_COLOR);
paint.set_anti_alias(true);
canvas.draw_rect(rect, &paint);
}
fn render_selection(
canvas: &Canvas,
selection: &TextSelection,
text_content: &TextContent,
shape: &Shape,
) {
let rects = calculate_selection_rects(selection, text_content, shape);
if rects.is_empty() {
return;
}
let mut paint = Paint::default();
paint.set_color(SELECTION_COLOR);
paint.set_anti_alias(true);
for rect in rects {
canvas.draw_rect(rect, &paint);
}
}
fn vertical_align_offset(
shape: &Shape,
layout_paragraphs: &[&skia_safe::textlayout::Paragraph],
) -> f32 {
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
match shape.vertical_align() {
VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0,
VerticalAlign::Bottom => shape.selrect().height() - total_height,
_ => 0.0,
}
}
fn calculate_cursor_rect(
cursor: &TextCursor,
text_content: &TextContent,
shape: &Shape,
) -> Option<Rect> {
let paragraphs = text_content.paragraphs();
if cursor.paragraph >= paragraphs.len() {
return None;
}
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
if cursor.paragraph >= layout_paragraphs.len() {
return None;
}
let selrect = shape.selrect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.char_offset;
// For cursor, we get a zero-width range at the position
// We need to handle edge cases:
// - At start of paragraph: use position 0
// - At end of paragraph: use last position
let para = &paragraphs[cursor.paragraph];
let para_char_count: usize = para
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let (cursor_x, cursor_height) = if para_char_count == 0 {
// Empty paragraph - use default height
(0.0, laid_out_para.height())
} else if char_pos == 0 {
let rects = laid_out_para.get_rects_for_range(
0..1,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
} else {
(0.0, laid_out_para.height())
}
} else if char_pos >= para_char_count {
let rects = laid_out_para.get_rects_for_range(
para_char_count.saturating_sub(1)..para_char_count,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.right(), rects[0].rect.height())
} else {
(laid_out_para.longest_line(), laid_out_para.height())
}
} else {
let rects = laid_out_para.get_rects_for_range(
char_pos..char_pos + 1,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
} else {
// Fallback: use glyph position
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
(pos.position as f32, laid_out_para.height())
}
};
return Some(Rect::from_xywh(
selrect.x() + cursor_x,
selrect.y() + y_offset,
CURSOR_WIDTH,
cursor_height,
));
}
y_offset += laid_out_para.height();
}
None
}
fn calculate_selection_rects(
selection: &TextSelection,
text_content: &TextContent,
shape: &Shape,
) -> Vec<Rect> {
let mut rects = Vec::new();
let start = selection.start();
let end = selection.end();
let paragraphs = text_content.paragraphs();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let selrect = shape.selrect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
let para_height = laid_out_para.height();
// Check if this paragraph is in selection range
if para_idx < start.paragraph || para_idx > end.paragraph {
y_offset += para_height;
continue;
}
// Calculate character range for this paragraph
let para = &paragraphs[para_idx];
let para_char_count: usize = para
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
} else {
para_char_count
};
if range_start < range_end {
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let text_boxes = laid_out_para.get_rects_for_range(
range_start..range_end,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
for text_box in text_boxes {
let r = text_box.rect;
rects.push(Rect::from_xywh(
selrect.x() + r.left(),
selrect.y() + y_offset + r.top(),
r.width(),
r.height(),
));
}
}
y_offset += para_height;
}
rects
}

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
use crate::math::is_close_to;
use crate::shapes::fills::{Fill, SolidColor};
use skia_safe::{self as skia, Rect};
@@ -144,6 +145,15 @@ impl Stroke {
}
}
pub fn aligned_rect(&self, rect: &Rect, scale: f32) -> Rect {
let stroke_rect = self.outer_rect(rect);
if self.kind != StrokeKind::Center {
return stroke_rect;
}
align_rect_to_half_pixel(&stroke_rect, self.width, scale)
}
pub fn outer_corners(&self, corners: &Corners) -> Corners {
let offset = match self.kind {
StrokeKind::Center => 0.0,
@@ -162,7 +172,6 @@ impl Stroke {
&self,
rect: &Rect,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
antialias: bool,
) -> skia::Paint {
let mut paint = self.fill.to_paint(rect, antialias);
@@ -171,7 +180,7 @@ impl Stroke {
let width = match self.kind {
StrokeKind::Inner => self.width,
StrokeKind::Center => self.width,
StrokeKind::Outer => self.width + (1. / scale),
StrokeKind::Outer => self.width,
};
paint.set_stroke_width(width);
@@ -230,10 +239,9 @@ impl Stroke {
is_open: bool,
rect: &Rect,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
antialias: bool,
) -> skia::Paint {
let mut paint = self.to_paint(rect, svg_attrs, scale, antialias);
let mut paint = self.to_paint(rect, svg_attrs, antialias);
match self.render_kind(is_open) {
StrokeKind::Inner => {
paint.set_stroke_width(2. * paint.stroke_width());
@@ -254,10 +262,9 @@ impl Stroke {
is_open: bool,
rect: &Rect,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
antialias: bool,
) -> skia::Paint {
let mut paint = self.to_paint(rect, svg_attrs, scale, antialias);
let mut paint = self.to_paint(rect, svg_attrs, antialias);
match self.render_kind(is_open) {
StrokeKind::Inner => {
paint.set_stroke_width(2. * paint.stroke_width());
@@ -284,6 +291,38 @@ impl Stroke {
}
}
fn align_rect_to_half_pixel(rect: &Rect, stroke_width: f32, scale: f32) -> Rect {
if scale <= 0.0 {
return *rect;
}
let stroke_pixels = stroke_width * scale;
let stroke_pixels_rounded = stroke_pixels.round();
if !is_close_to(stroke_pixels, stroke_pixels_rounded) {
return *rect;
}
if (stroke_pixels_rounded as i32) % 2 == 0 {
return *rect;
}
let left_px = rect.left * scale;
let top_px = rect.top * scale;
let target_frac = 0.5;
let dx_px = target_frac - (left_px - left_px.floor());
let dy_px = target_frac - (top_px - top_px.floor());
if is_close_to(dx_px, 0.0) && is_close_to(dy_px, 0.0) {
return *rect;
}
Rect::from_xywh(
rect.left + (dx_px / scale),
rect.top + (dy_px / scale),
rect.width(),
rect.height(),
)
}
fn cap_margin_for_cap(cap: Option<StrokeCap>, width: f32) -> f32 {
match cap {
Some(StrokeCap::LineArrow)

View File

@@ -116,7 +116,6 @@ impl TextContentSize {
pub struct TextPositionWithAffinity {
pub position_with_affinity: PositionWithAffinity,
pub paragraph: i32,
#[allow(dead_code)]
pub span: i32,
pub offset: i32,
}
@@ -317,10 +316,6 @@ impl TextContent {
&self.paragraphs
}
pub fn paragraphs_mut(&mut self) -> &mut Vec<Paragraph> {
&mut self.paragraphs
}
pub fn width(&self) -> f32 {
self.size.width
}
@@ -433,16 +428,8 @@ impl TextContent {
let end_y = offset_y + layout_paragraph.height();
// We only test against paragraphs that can contain the current y
// coordinate. Use >= for start and handle zero-height paragraphs.
let paragraph_height = layout_paragraph.height();
let matches = if paragraph_height > 0.0 {
point.y >= start_y && point.y < end_y
} else {
// For zero-height paragraphs (empty lines), match if we're at the start position
point.y >= start_y && point.y <= start_y + 1.0
};
if matches {
// coordinate.
if point.y > start_y && point.y < end_y {
let position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate(*point);
if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) {
@@ -451,37 +438,18 @@ impl TextContent {
// in which span we are.
let mut computed_position = 0;
let mut span_offset = 0;
// If paragraph has no spans, default to span 0, offset 0
if paragraph.children().is_empty() {
span_index = 0;
span_offset = 0;
} else {
for span in paragraph.children() {
span_index += 1;
let length = span.text.chars().count();
let start_position = computed_position;
let end_position = computed_position + length;
let current_position = position_with_affinity.position as usize;
// Handle empty spans: if the span is empty and current position
// matches the start, this is the right span
if length == 0 && current_position == start_position {
span_offset = 0;
break;
}
if start_position <= current_position
&& end_position >= current_position
{
span_offset =
position_with_affinity.position - start_position as i32;
break;
}
computed_position += length;
for span in paragraph.children() {
span_index += 1;
let length = span.text.len();
let start_position = computed_position;
let end_position = computed_position + length;
let current_position = position_with_affinity.position as usize;
if start_position <= current_position && end_position >= current_position {
span_offset = position_with_affinity.position - start_position as i32;
break;
}
computed_position += length;
}
return Some(TextPositionWithAffinity::new(
position_with_affinity,
paragraph_index,
@@ -492,26 +460,6 @@ impl TextContent {
}
offset_y += layout_paragraph.height();
}
// Handle completely empty text shapes: if there are no paragraphs or all paragraphs
// are empty, and the click is within the text shape bounds, return a default position
if (self.paragraphs().is_empty() || self.layout.paragraphs.is_empty())
&& self.bounds.contains(*point)
{
// Create a default position at the start of the text
use skia_safe::textlayout::Affinity;
let default_position = PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
};
return Some(TextPositionWithAffinity::new(
default_position,
0, // paragraph 0
0, // span 0
0, // offset 0
));
}
None
}
@@ -890,10 +838,6 @@ impl Paragraph {
&self.children
}
pub fn children_mut(&mut self) -> &mut Vec<TextSpan> {
&mut self.children
}
#[allow(dead_code)]
fn add_span(&mut self, span: TextSpan) {
self.children.push(span);
@@ -903,26 +847,6 @@ impl Paragraph {
self.line_height
}
pub fn letter_spacing(&self) -> f32 {
self.letter_spacing
}
pub fn text_align(&self) -> TextAlign {
self.text_align
}
pub fn text_direction(&self) -> TextDirection {
self.text_direction
}
pub fn text_decoration(&self) -> Option<TextDecoration> {
self.text_decoration
}
pub fn text_transform(&self) -> Option<TextTransform> {
self.text_transform
}
pub fn paragraph_to_style(&self) -> ParagraphStyle {
let mut style = ParagraphStyle::default();
@@ -1304,21 +1228,14 @@ pub fn calculate_text_layout_data(
let current_y = para_layout.y;
let text_paragraph = text_paragraphs.get(paragraph_index);
if let Some(text_para) = text_paragraph {
let mut span_ranges: Vec<(usize, usize, usize, String, String)> = vec![];
let mut span_ranges: Vec<(usize, usize, usize)> = vec![];
let mut cur = 0;
for (span_index, span) in text_para.children().iter().enumerate() {
let transformed_text: String = span.apply_text_transform();
let original_text = span.text.clone();
let text = transformed_text.clone();
let text_len = text.len();
span_ranges.push((cur, cur + text_len, span_index, text, original_text));
cur += text_len;
let text: String = span.apply_text_transform();
span_ranges.push((cur, cur + text.len(), span_index));
cur += text.len();
}
for (start, end, span_index, transformed_text, original_text) in span_ranges {
// Skip empty spans to avoid invalid rect calculations
if start >= end {
continue;
}
for (start, end, span_index) in span_ranges {
let rects = para_layout.paragraph.get_rects_for_range(
start..end,
RectHeightStyle::Tight,
@@ -1328,43 +1245,22 @@ pub fn calculate_text_layout_data(
let direction = textbox.direct;
let mut rect = textbox.rect;
let cy = rect.top + rect.height() / 2.0;
// Get byte positions from Skia's transformed text layout
let glyph_start = para_layout
let start_pos = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.left + 0.1, cy))
.position as usize;
let glyph_end = para_layout
let end_pos = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.right - 0.1, cy))
.position as usize;
// Convert to byte positions relative to this span
let byte_start = glyph_start.saturating_sub(start);
let byte_end = glyph_end.saturating_sub(start);
// Convert byte positions to character positions in ORIGINAL text
// This handles multi-byte UTF-8 and text transform differences
let char_start = transformed_text
.char_indices()
.position(|(i, _)| i >= byte_start)
.unwrap_or(0);
let char_end = transformed_text
.char_indices()
.position(|(i, _)| i >= byte_end)
.unwrap_or_else(|| transformed_text.chars().count());
// Clamp to original text length for safety
let original_char_count = original_text.chars().count();
let final_start = char_start.min(original_char_count);
let final_end = char_end.min(original_char_count);
let start_pos = start_pos.saturating_sub(start);
let end_pos = end_pos.saturating_sub(start);
rect.offset((x, current_y));
position_data.push(PositionData {
paragraph: paragraph_index as u32,
span: span_index as u32,
start_pos: final_start as u32,
end_pos: final_end as u32,
start_pos: start_pos as u32,
end_pos: end_pos as u32,
x: rect.x(),
y: rect.y(),
width: rect.width(),

View File

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

View File

@@ -1,218 +1,9 @@
#![allow(dead_code)]
use crate::shapes::TextPositionWithAffinity;
use crate::uuid::Uuid;
/// Cursor position within text content.
/// Uses character offsets for precise positioning.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
pub struct TextCursor {
pub paragraph: usize,
pub char_offset: usize,
}
impl TextCursor {
pub fn new(paragraph: usize, char_offset: usize) -> Self {
Self {
paragraph,
char_offset,
}
}
pub fn zero() -> Self {
Self {
paragraph: 0,
char_offset: 0,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TextSelection {
pub anchor: TextCursor,
pub focus: TextCursor,
}
impl TextSelection {
pub fn new() -> Self {
Self::default()
}
pub fn from_cursor(cursor: TextCursor) -> Self {
Self {
anchor: cursor,
focus: cursor,
}
}
pub fn is_collapsed(&self) -> bool {
self.anchor == self.focus
}
pub fn is_selection(&self) -> bool {
!self.is_collapsed()
}
pub fn set_caret(&mut self, cursor: TextCursor) {
self.anchor = cursor;
self.focus = cursor;
}
pub fn extend_to(&mut self, cursor: TextCursor) {
self.focus = cursor;
}
pub fn collapse_to_focus(&mut self) {
self.anchor = self.focus;
}
pub fn collapse_to_anchor(&mut self) {
self.focus = self.anchor;
}
pub fn start(&self) -> TextCursor {
if self.anchor.paragraph < self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph > self.focus.paragraph {
self.focus
} else if self.anchor.char_offset <= self.focus.char_offset {
self.anchor
} else {
self.focus
}
}
pub fn end(&self) -> TextCursor {
if self.anchor.paragraph > self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph < self.focus.paragraph {
self.focus
} else if self.anchor.char_offset >= self.focus.char_offset {
self.anchor
} else {
self.focus
}
}
}
/// Events that the text editor can emit for frontend synchronization
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum EditorEvent {
None = 0,
ContentChanged = 1,
SelectionChanged = 2,
NeedsLayout = 3,
}
pub struct TextEditorState {
pub selection: TextSelection,
pub is_active: bool,
pub active_shape_id: Option<Uuid>,
pub cursor_visible: bool,
pub last_blink_time: f64,
pub x_affinity: Option<f32>,
pending_events: Vec<EditorEvent>,
}
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
impl TextEditorState {
pub fn new() -> Self {
Self {
selection: TextSelection::new(),
is_active: false,
active_shape_id: None,
cursor_visible: true,
last_blink_time: 0.0,
x_affinity: None,
pending_events: Vec::new(),
}
}
pub fn start(&mut self, shape_id: Uuid) {
self.is_active = true;
self.active_shape_id = Some(shape_id);
self.cursor_visible = true;
self.last_blink_time = 0.0;
self.selection = TextSelection::new();
self.x_affinity = None;
self.pending_events.clear();
}
pub fn stop(&mut self) {
self.is_active = false;
self.active_shape_id = None;
self.cursor_visible = false;
self.x_affinity = None;
self.pending_events.clear();
}
pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.set_caret(cursor);
self.reset_blink();
self.clear_x_affinity();
self.push_event(EditorEvent::SelectionChanged);
}
pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.extend_to(cursor);
self.reset_blink();
self.push_event(EditorEvent::SelectionChanged);
}
pub fn update_blink(&mut self, timestamp_ms: f64) {
if !self.is_active {
return;
}
if self.last_blink_time == 0.0 {
self.last_blink_time = timestamp_ms;
self.cursor_visible = true;
return;
}
let elapsed = timestamp_ms - self.last_blink_time;
if elapsed >= CURSOR_BLINK_INTERVAL_MS {
self.cursor_visible = !self.cursor_visible;
self.last_blink_time = timestamp_ms;
}
}
pub fn reset_blink(&mut self) {
self.cursor_visible = true;
self.last_blink_time = 0.0;
}
pub fn clear_x_affinity(&mut self) {
self.x_affinity = None;
}
pub fn push_event(&mut self, event: EditorEvent) {
if self.pending_events.last() != Some(&event) {
self.pending_events.push(event);
}
}
pub fn poll_event(&mut self) -> EditorEvent {
self.pending_events.pop().unwrap_or(EditorEvent::None)
}
pub fn has_pending_events(&self) -> bool {
!self.pending_events.is_empty()
}
pub fn set_caret_position_from(
&mut self,
text_position_with_affinity: TextPositionWithAffinity,
) {
self.set_caret_from_position(text_position_with_affinity);
}
}
/// TODO: Remove legacy code
/// TODO: Now this is just a tuple with 2 i32 working
/// as indices (paragraph and span).
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct TextNodePosition {
pub paragraph: i32,
@@ -224,7 +15,89 @@ impl TextNodePosition {
Self { paragraph, span }
}
#[allow(dead_code)]
pub fn is_invalid(&self) -> bool {
self.paragraph < 0 || self.span < 0
}
}
pub struct TextPosition {
node: Option<TextNodePosition>,
offset: i32,
}
impl TextPosition {
pub fn new() -> Self {
Self {
node: None,
offset: -1,
}
}
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.node = node;
self.offset = offset;
}
}
pub struct TextSelection {
focus: TextPosition,
anchor: TextPosition,
}
impl TextSelection {
pub fn new() -> Self {
Self {
focus: TextPosition::new(),
anchor: TextPosition::new(),
}
}
#[allow(dead_code)]
pub fn is_caret(&self) -> bool {
self.focus.node == self.anchor.node && self.focus.offset == self.anchor.offset
}
#[allow(dead_code)]
pub fn is_selection(&self) -> bool {
!self.is_caret()
}
pub fn set_focus(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.focus.set(node, offset);
}
pub fn set_anchor(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.anchor.set(node, offset);
}
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.set_focus(node, offset);
self.set_anchor(node, offset);
}
}
pub struct TextEditorState {
selection: TextSelection,
}
impl TextEditorState {
pub fn new() -> Self {
Self {
selection: TextSelection::new(),
}
}
pub fn set_caret_position_from(
&mut self,
text_position_with_affinity: TextPositionWithAffinity,
) {
self.selection.set(
Some(TextNodePosition::new(
text_position_with_affinity.paragraph,
text_position_with_affinity.span,
)),
text_position_with_affinity.offset,
);
}
}

View File

@@ -9,4 +9,3 @@ pub mod shapes;
pub mod strokes;
pub mod svg_attrs;
pub mod text;
pub mod text_editor;

View File

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

View File

File diff suppressed because it is too large Load Diff