Compare commits

...

90 Commits

Author SHA1 Message Date
alonso.torres
2ba68359d3 WIP 2026-01-26 22:34:57 +01:00
Aitor Moreno
f4f4f5bbb5 🐛 Fix multiple issues and tests 2026-01-26 14:14:06 +01:00
Andrey Antukh
3eeaaab17e Merge branch 'staging' into staging-render 2026-01-26 11:02:26 +01:00
Andrey Antukh
f07495ae95 🐛 Fix incorrect handling of numeric values layout padding and gap
Fixes https://github.com/penpot/penpot/issues/8113
2026-01-26 11:01:25 +01:00
Andrey Antukh
23d5fc7408 🐛 Prevent exception on open-new-window when no window is returned
Fixes https://github.com/penpot/penpot/issues/7787
2026-01-26 11:01:25 +01:00
Andrey Antukh
8632b18eec 🐛 Avoid json decoder liner limit exception by chunking
Happens only when we send large binary data serialized with transit
(mainly used for upload fonts data).
2026-01-26 11:01:25 +01:00
Andrey Antukh
33e650242c Add slugify to the filename on assets exportation
Fixes https://github.com/penpot/penpot/issues/8017
2026-01-26 11:01:25 +01:00
Alejandro Alonso
3dc9e28230 Merge pull request #8155 from penpot/elenatorro-13089-improve-page-load-render
🔧 Improve render UX on first load
2026-01-26 10:40:44 +01:00
Andrey Antukh
e03ad25118 🔧 Backport CI workflow from develop 2026-01-26 10:18:24 +01:00
David Barragán Merino
5d7e6afd76 🔧 Fix a typo in an interpolation 2026-01-23 19:52:20 +01:00
Elena Torró
68a77e9cc8 Merge pull request #8179 from penpot/superalex-adding-performance-logs-flag
🎉 Adding performance logs flag
2026-01-23 14:06:57 +01:00
Alejandro Alonso
e3148ea20e 🎉 Adding performance logs flag 2026-01-23 13:34:19 +01:00
Elena Torró
5da9bbea62 Merge pull request #8174 from penpot/superalex-fix-blur-events-text-editor-v2
🐛 Fix blur events for text editor v2 in firefox
2026-01-23 13:08:01 +01:00
Alonso Torres
15d369493b 🐛 Fix problem with z-index modal in dashboard (#8178) 2026-01-23 12:48:01 +01:00
Andrey Antukh
089d1667b6 Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-23 11:08:07 +01:00
Alejandro Alonso
4ad5282063 🐛 Fix blur events for text editor v2 in firefox 2026-01-23 10:58:54 +01:00
Elena Torró
d0e79c94b4 Merge pull request #8162 from penpot/superalex-fix-auto-height
🐛 Fix text boxes with auto-height don't update height when resized by dragging side handles
2026-01-23 10:57:54 +01:00
Eva Marco
9c9b672e3e 🐛 Fix spanish translations on import export token modal (#8172) 2026-01-23 10:05:20 +01:00
Eva Marco
5146221513 🐛 Fix allow negative spread values on shadow token creation (#8167)
* 🐛 Fix allow negative spread values on shadow token creation

* 🎉 Add test
2026-01-23 09:50:36 +01:00
Eva Marco
e53f335204 🐛 Fix unhandled error on tokens modal (#8165) 2026-01-23 09:35:53 +01:00
Alejandro Alonso
d112c0a33b 🐛 Fix text boxes with auto-height don't update height when resized by dragging side handles 2026-01-23 09:05:20 +01:00
Elena Torró
7b86518afa Merge pull request #8171 from penpot/ladybenko-13152-fix-blur
🐛 Fix blur when clicking on same page
2026-01-22 17:42:39 +01:00
Elena Torró
9991901ed8 Merge pull request #8161 from penpot/superalex-fix-editing-text-doesnt-update-layer-name
🐛 Bug: Editing the text inside a text object doesn’t update the text layer name.
2026-01-22 17:40:32 +01:00
Belén Albeza
3d0c6ad421 Blur board titles and outlines when switching pages 2026-01-22 16:00:24 +01:00
Belén Albeza
835ea97be7 🐛 Fix blur applied when clicking in the active page 2026-01-22 13:27:05 +01:00
David Barragán Merino
2574ad3315 🔧 Fixes to the API documentation deployer 2026-01-22 12:09:38 +01:00
David Barragán Merino
e6b5364a84 🔧 Deploy penpot api documentation 2026-01-22 12:09:38 +01:00
Elena Torro
f94c9cdb02 🐛 Fix objects sorting for thumbnail generation 2026-01-22 09:29:33 +01:00
Elena Torro
8637c46ba1 🐛 Fix empty pool state 2026-01-22 08:52:26 +01:00
Elena Torro
5d7d23a2c7 🔧 Keep clear cached canvas 2026-01-22 08:51:58 +01:00
Alejandro Alonso
a1a3966d7b 🐛 Editing the text inside a text object doesn’t update the text layer name 2026-01-22 08:24:13 +01:00
Alonso Torres
656f81f89f ⬆️ Update plugins to 1.4.2 (#8157) 2026-01-21 17:36:58 +01:00
Elena Torro
aab1d97c4c 🔧 Clean up and use proper imports 2026-01-21 16:01:06 +01:00
Elena Torro
499aac31a4 🔧 Improve tile invalidation to prevent visual flickering
When tiles are invalidated (during shape updates or page loading), the old tile
content is now kept visible until new content is rendered to replace it. This
provides a smoother visual experience during updates.
2026-01-21 15:42:52 +01:00
Alonso Torres
01a4ffeb8b ⬆️ Updated plugins release to 1.4.0 (#8148) 2026-01-21 15:41:00 +01:00
Elena Torro
962d7839a2 🔧 Add progressive rendering support for improved page load experience
When loading large pages with many shapes, the UI now remains responsive by
processing shapes in chunks (100 shapes at a time) and yielding to the browser
between chunks. Preview renders are triggered at 25%, 50%, and 75% progress to
give users visual feedback during loading.
2026-01-21 14:55:53 +01:00
Elena Torro
83387701a0 🔧 Add batched shape base properties serialization for improved WASM performance 2026-01-21 14:55:07 +01:00
Elena Torro
5775fa61ba 🔧 Refactor ShapesPool to use index-based storage instead of unsafe lifetime references
Replace `HashMap<&'a Uuid, ...>` with `HashMap<usize, ...>` for all auxiliary maps
(modifiers, structure, scale_content, modified_shape_cache)
2026-01-21 14:53:56 +01:00
Andrey Antukh
b8c70be9a2 Make frontend build and watch process more resilent to errors 2026-01-21 13:44:35 +01:00
Andrey Antukh
525adcfcbe Add wasm build on watch app script (devenv) 2026-01-21 13:44:35 +01:00
Eva Marco
7cce4c6532 🐛 Fix unhandled exception tokens creation dialog (#8136) 2026-01-21 13:09:22 +01:00
Alejandro Alonso
a3fdd8b691 Merge pull request #8147 from penpot/niwinz-staging-file-menu-issue
 Use correct team-id on file-menu on dashboard
2026-01-21 12:54:28 +01:00
Andrey Antukh
b6a9579c98 Use correct team-id on file-menu on dashboard
Before the changes on this commit, the team object is used for
retrieve the id, where we already have team-id. Additionally, the
team object resolution is async operation and is not available on
the first render which causes strange issues on automated flows
(playwright) where an option is clicked when the async flow is
still pending and we have no team object loaded.
2026-01-21 12:23:44 +01:00
Belén Albeza
5b1766835f 🐛 Fix broken selection on duplicated shapes on new pages 2026-01-21 10:32:13 +01:00
Andrey Antukh
ff25df0457 Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-21 10:17:22 +01:00
Eva Marco
8c7fd0af4b 🐛 Fix shadow reference validation (#8132) 2026-01-21 09:17:03 +01:00
Andrey Antukh
cf46051f56 🔥 Remove .traivis.yml file from the repository 2026-01-20 19:40:31 +01:00
Andrey Antukh
6393330ee1 Merge remote-tracking branch 'origin/staging' 2026-01-20 16:25:10 +01:00
Andrey Antukh
8252bc485e 📚 Fix oidc callback related documentation issue 2026-01-20 16:24:12 +01:00
Alejandro Alonso
b7c2d9a079 Merge pull request #8130 from penpot/superalex-improve-zoom-pan-performance-7
🐛 Fix some tiles disappear after fast zoom and pan
2026-01-20 12:56:02 +01:00
Alejandro Alonso
aeb34a6f64 Merge pull request #8109 from penpot/superalex-fix-text-selrect-calculation
🐛 Render wasm typography token issues
2026-01-20 12:54:45 +01:00
Alejandro Alonso
6fa0c3af0c 🐛 Fix some tiles disappear after fast zoom and pan 2026-01-20 12:40:01 +01:00
Alejandro Alonso
260b9fb040 🐛 Fix texts with auto size updated via tokens with render wasm
activated
2026-01-20 12:39:17 +01:00
Alejandro Alonso
884954f4ff 🐛 Fix text selrect calculation 2026-01-20 12:37:57 +01:00
Andrey Antukh
6fd0f5377c Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-20 10:08:58 +01:00
Elena Torró
eb54bc485e Merge pull request #8120 from penpot/alotor-fix-flex-layout
🐛 Fix problems with layout
2026-01-20 10:00:24 +01:00
Elena Torró
12c24a36b4 Merge pull request #8122 from penpot/fix-thumbnail-generation
🐛 Fix problem with thumbnail generation
2026-01-20 09:59:34 +01:00
Alejandro Alonso
324d54ad28 🐛 Fix set all rounded corners to 0 2026-01-20 09:34:06 +01:00
alonso.torres
f42ff27f3d 🐛 Fix problem with bools 2026-01-19 17:05:04 +01:00
alonso.torres
2c1cc89f53 🐛 Fix problem with thumbnail generation 2026-01-19 12:54:15 +01:00
alonso.torres
498b0b30fe 🐛 Fix problems with layout 2026-01-19 12:17:58 +01:00
Elena Torró
89f40dcda2 🔧 Move WebGL context error message to 'errors' namespace (#8117) 2026-01-19 11:24:19 +01:00
Elena Torró
ccac7bd510 Merge pull request #8108 from penpot/ladybenko-13022-blur-page
🎉 Apply blur effect when switching pages
2026-01-19 11:04:31 +01:00
Andrey Antukh
d73197625d Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-19 10:43:43 +01:00
Belén Albeza
43d1d127dc 🎉 Apply blur effect to previous canvas pixels while setting wasm objects 2026-01-16 13:04:59 +01:00
Belén Albeza
8bd3ef717c 🎉 Apply blur to canvas when switching pages 2026-01-16 13:04:59 +01:00
Elena Torro
53bc647783 🔧 Fix shape selection from canvas to sidebar 2026-01-16 13:02:25 +01:00
Elena Torró
6029f9bb51 Merge pull request #8089 from penpot/superalex-improve-zoom-pan-performance-5
🎉 Performance improvements
2026-01-15 16:46:07 +01:00
Elena Torro
e0fd8bac81 🔧 Optimize sidebar performance for deeply nested shapes
- Batch hover highlights using RAF to avoid long tasks from rapid events
- Run parent expansion asynchronously to not block selection
- Lazy-load children in layer items using IntersectionObserver
- Clarify expand-all-parents logic with explicit bindings
2026-01-15 13:41:54 +01:00
Elena Torro
34737ddfc9 🔧 Always lookup over a set 2026-01-15 13:41:10 +01:00
Elena Torro
a8dfd19338 🔧 Add performance debugging logs 2026-01-15 13:40:58 +01:00
Elena Torro
e33e8a8c3b 🔧 Lookup page objects only when value changes 2026-01-15 13:40:53 +01:00
Alejandro Alonso
c411aefc6c 🐛 Fix rotated shapes extrect calculation 2026-01-15 12:53:21 +01:00
Alejandro Alonso
311e124658 🎉 Reduce extrect work in tile traversal
Avoid repeated extrect calculations and simplify root ordering per tile.
2026-01-15 12:53:21 +01:00
Alejandro Alonso
afc914f486 🎉 Render simple shapes directly on Current
Bypass intermediate surfaces for simple shapes without effects.
2026-01-15 12:53:21 +01:00
Alejandro Alonso
84f750da0d 🎉 Skip heavy effects in fast mode
Avoid blur and shadow passes for text and shapes when FAST_MODE is enabled.
2026-01-15 08:45:21 +01:00
Elena Torro
a3119bef5e 🔧 Show message and button to reload the page when WebGL context is lost 2026-01-14 11:10:03 +01:00
Alejandro Alonso
c60d74df62 🐛 Fix nested frames border clipping 2026-01-14 11:10:03 +01:00
Alejandro Alonso
d593e299e3 🐛 Fix mask erros on save/restore optimizations 2026-01-14 11:10:03 +01:00
Alejandro Alonso
4a8e02987f 🐛 Fix mask erros on save/restore optimizations 2026-01-14 11:10:03 +01:00
Alejandro Alonso
ee766e85a0 🎉 Wasm render dirty surfaces 2026-01-14 11:10:03 +01:00
Alejandro Alonso
35e3b7f19a 🎉 Root ids refactor 2026-01-14 11:10:03 +01:00
Alejandro Alonso
1810df232b 🎉 Ignore frames and groups when they have no visual extra information 2026-01-14 11:10:03 +01:00
Alejandro Alonso
3e99ad036c 🎉 Avoid unnecesary saves and restores 2026-01-14 11:10:03 +01:00
Alejandro Alonso
042a3a4080 🐛 Fix wasm playgrounds 2026-01-14 11:10:03 +01:00
Belén Albeza
f0687fd1f7 🎉 Make workspace loader to wait for first render 2026-01-14 11:10:03 +01:00
Aitor Moreno
2c9159288f 🐛 Fix previous styles lost when changing selected text 2026-01-14 11:10:01 +01:00
Andrey Antukh
5c71c57dd9 Merge tag '2.12.1' 2025-12-30 15:37:30 +01:00
Andrey Antukh
5abc1aafb4 Merge tag '2.12.0-RC3' 2025-12-12 12:19:29 +01:00
Andrey Antukh
935728aa39 🔧 Backport build-tag github workflow from develop 2025-12-05 10:26:01 +01:00
119 changed files with 5567 additions and 2171 deletions

View File

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

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
dist: xenial
language: generic
sudo: required
cache:
directories:
- $HOME/.m2
services:
- docker
branches:
only:
- master
- develop
install:
- curl -O https://download.clojure.org/install/linux-install-1.10.1.447.sh
- chmod +x linux-install-1.10.1.447.sh
- sudo ./linux-install-1.10.1.447.sh
before_script:
- env | sort
script:
- ./manage.sh build-devenv
- ./manage.sh run-frontend-tests
- ./manage.sh run-backend-tests
- ./manage.sh build-images
- ./manage.sh run
after_script:
- docker images
notifications:
email: false
env:
- NODE_VERSION=10.16.0

View File

@@ -29,6 +29,14 @@
- Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956)
- Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959)
- Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865)
- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835)
- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
- Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167)
- Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171)
- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133)
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
## 2.12.1

View File

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

View File

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

View File

@@ -526,20 +526,25 @@
ids))
(defn clean-loops
"Clean a list of ids from circular references."
"Clean a list of ids from circular references. Optimized fast-path for single selections."
[objects ids]
(let [parent-selected?
(fn [id]
(let [parents (get-parent-ids objects id)]
(some ids parents)))
(if (<= (count ids) 1)
;; For single selection, there can't be circularity; return as ordered-set.
(into (d/ordered-set) ids)
(let [ids-set (if (set? ids) ids (set ids))
parent-selected?
(fn [id]
;; Stop early as soon as we find any selected parent
(let [parents (get-parent-ids objects id)]
(some #(contains? ids-set %) parents)))
add-element
(fn [result id]
(cond-> result
(not (parent-selected? id))
(conj id)))]
add-element
(fn [result id]
(cond-> result
(not (parent-selected? id))
(conj id)))]
(reduce add-element (d/ordered-set) ids)))
(reduce add-element (d/ordered-set) ids))))
(defn- indexed-shapes
"Retrieves a vector with the indexes for each element in the layer

View File

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

View File

@@ -114,14 +114,7 @@ configuration.
The callback has the following format:
```html
https://<your_domain>/api/auth/oauth/<oauth_provider>/callback
```
You will need to change <your_domain> and <oauth_provider> according to your setup.
This is how it looks with Gitlab provider:
```html
https://<your_domain>/api/auth/oauth/gitlab/callback
https://<your_domain>/api/auth/oidc/callback
```
#### Google

View File

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

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, addShapeSolidStrokeFill,

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, set_parent

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, set_parent, draw_star,

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, set_parent, allocBytes,

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, addShapeSolidStrokeFill

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, assignCanvas, setupInteraction, useShape, setShapeChildren, addTextShape, hexToU32ARGB,getRandomInt, getRandomColor, getRandomFloat, addShapeSolidFill, addShapeSolidStrokeFill
} from './js/lib.js';
@@ -102,4 +102,4 @@
});
</script>
</body>
</html>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -347,6 +347,12 @@
(with-meta {:team-id team-id
: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!))))
(->> stream
(rx/filter (ptk/type? ::dps/persistence-notification))
(rx/take 1)

View File

@@ -18,13 +18,13 @@
ptk/UpdateEvent
(update [_ state]
(let [expand-fn (fn [expanded]
(merge expanded
(->> ids
(map #(cfh/get-parent-ids objects %))
flatten
(remove #(= % uuid/zero))
(map (fn [id] {id true}))
(into {}))))]
(let [parents-seqs (map (fn [x] (cfh/get-parent-ids objects x)) ids)
flat-parents (apply concat parents-seqs)
non-root-parents (remove #(= % uuid/zero) flat-parents)
distinct-parents (into #{} non-root-parents)]
(merge expanded
(into {}
(map (fn [id] {id true}) distinct-parents)))))]
(update-in state [:workspace-local :expanded] expand-fn)))))

View File

@@ -264,10 +264,13 @@
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)]
(rx/of
(dwc/expand-all-parents ids objects)
::dwsp/interrupt)))))
(let [objects (dsh/lookup-page-objects state)
;; Schedule expanding parents asynchronously to avoid blocking
;; the event loop
expand-s (->> (rx/of (dwc/expand-all-parents ids objects))
(rx/observe-on :async))
interrupt-s (rx/of ::dwsp/interrupt)]
(rx/merge expand-s interrupt-s)))))
(defn select-all
[]

View File

@@ -205,9 +205,12 @@
:mov-objects (->> (:shapes change) (map #(vector page-id %)))
[]))
get-frame-ids-m (atom nil)
get-frame-ids
(fn get-frame-ids [id]
(let [old-objects (lookup-data-objects old-data page-id)
(fn [id]
(let [get-frame-ids @get-frame-ids-m
old-objects (lookup-data-objects old-data page-id)
new-objects (lookup-data-objects new-data page-id)
new-shape (get new-objects id)
@@ -238,6 +241,8 @@
(not= uuid/zero (:frame-id new-shape)))
(into (get-frame-ids (:frame-id new-shape))))))]
(reset! get-frame-ids-m (memoize get-frame-ids))
(into #{}
(comp (mapcat extract-ids)
(filter (fn [[page-id']] (= page-id page-id')))

View File

@@ -27,8 +27,10 @@
[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.features :as features]
[app.main.fonts :as fonts]
[app.main.store :as st]
[app.util.i18n :refer [tr]]
@@ -300,11 +302,20 @@
update-fn (fn [node _]
(-> node
(d/txt-merge txt-attrs)
(cty/remove-typography-from-node)))]
(dwsh/update-shapes shape-ids
#(txt/update-text-content % update-node? update-fn nil)
{:ignore-touched true
:page-id page-id})))
(cty/remove-typography-from-node)))
;; Check if any attribute affects text layout (requires resize)
affects-layout? (some #(contains? txt-attrs %) [:font-size :font-family :font-weight :letter-spacing :line-height])]
(ptk/reify ::generate-text-shape-update
ptk/WatchEvent
(watch [_ state _]
(cond-> (rx/of (dwsh/update-shapes shape-ids
#(txt/update-text-content % update-node? update-fn nil)
{:ignore-touched true
:page-id page-id}))
(and affects-layout?
(features/active-feature? state "render-wasm/v1"))
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(defn update-line-height
([value shape-ids attributes] (update-line-height value shape-ids attributes nil))
@@ -353,11 +364,17 @@
(-> node
(d/txt-merge txt-attrs)
(cty/remove-typography-from-node))))]
(dwsh/update-shapes shape-ids
(fn [shape]
(txt/update-text-content shape update-node? #(update-fn %1 (ctst/font-weight-applied? shape)) nil))
{:ignore-touched true
:page-id page-id})))
(ptk/reify ::generate-font-family-text-shape-update
ptk/WatchEvent
(watch [_ state _]
(cond-> (rx/of (dwsh/update-shapes shape-ids
(fn [shape]
(txt/update-text-content shape update-node? #(update-fn %1 (ctst/font-weight-applied? shape)) nil))
{:ignore-touched true
:page-id page-id}))
(features/active-feature? state "render-wasm/v1")
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(defn- create-font-family-text-attrs
[value]
@@ -425,10 +442,16 @@
(-> node
(d/txt-merge txt-attrs)
(cty/remove-typography-from-node))))]
(dwsh/update-shapes shape-ids
#(txt/update-text-content % update-node? update-fn nil)
{:ignore-touched true
:page-id page-id})))
(ptk/reify ::generate-font-weight-text-shape-update
ptk/WatchEvent
(watch [_ state _]
(cond-> (rx/of (dwsh/update-shapes shape-ids
#(txt/update-text-content % update-node? update-fn nil)
{:ignore-touched true
:page-id page-id}))
(features/active-feature? state "render-wasm/v1")
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(defn update-font-weight
([value shape-ids attributes] (update-font-weight value shape-ids attributes nil))

View File

@@ -305,7 +305,7 @@
(l/derived #(dsh/lookup-shape % page-id shape-id) st/state =))
(def workspace-page-objects
(l/derived dsh/lookup-page-objects st/state))
(l/derived dsh/lookup-page-objects st/state identical?))
(def workspace-read-only?
(l/derived :read-only? workspace-global))
@@ -480,6 +480,9 @@
(def workspace-token-sets-tree
(l/derived (d/nilf ctob/get-set-tree) tokens-lib))
(def workspace-all-tokens-map
(l/derived (d/nilf ctob/get-all-tokens) tokens-lib))
(def workspace-active-theme-paths
(l/derived (d/nilf ctob/get-active-theme-paths) tokens-lib))

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ $z-index-200: 200;
$z-index-300: 300;
$z-index-400: 400;
$z-index-500: 500;
$z-index-600: 600;
:global(:root) {
--z-index-auto: #{$z-index-auto}; // Index for elements such as workspace, rulers ...
@@ -18,4 +19,5 @@ $z-index-500: 500;
--z-index-set: #{$z-index-300}; // Index for configuration elements like modals, color picker, grid edition elements
--z-index-dropdown: #{$z-index-400}; // Index for dropdown like elements, selects, menus, dropdowns
--z-index-notifications: #{$z-index-500}; // Index for notification
--z-index-loaders: #{$z-index-600}; // Index for loaders
}

View File

@@ -23,6 +23,7 @@
touched? (and (contains? (:data @form) input-name)
(get-in @form [:touched input-name]))
error (get-in @form [:errors input-name])
value (get-in @form [:data input-name] "")

View File

@@ -308,6 +308,16 @@
[:div {:class (stl/css :sign-info)}
[:button {:on-click on-click} (tr "labels.retry")]]]))
(mf/defc webgl-context-lost*
[]
(let [on-reload (mf/use-fn #(js/location.reload))]
[:> error-container* {}
[:div {:class (stl/css :main-message)} (tr "errors.webgl-context-lost.main-message")]
[:div {:class (stl/css :desc-message)} (tr "errors.webgl-context-lost.desc-message")]
[:div {:class (stl/css :buttons-container)}
[:> button* {:variant "primary" :on-click on-reload}
(tr "labels.reload-page")]]]))
(defn- generate-report
[data]
(try
@@ -437,6 +447,7 @@
(rx/of default)
(rx/throw cause)))))))
(mf/defc exception-section*
{::mf/private true}
[{:keys [data route] :as props}]
@@ -469,6 +480,9 @@
:service-unavailable
[:> service-unavailable*]
:webgl-context-lost
[:> webgl-context-lost*]
[:> internal-error* props])))
(mf/defc context-wrapper*

View File

@@ -217,6 +217,10 @@
design-tokens? (features/use-feature "design-tokens/v1")
wasm-renderer-enabled? (features/use-feature "render-wasm/v1")
first-frame-rendered? (mf/use-state false)
background-color (:background-color wglobal)]
(mf/with-effect []
@@ -241,6 +245,17 @@
(when (and file-loaded? (not page-id))
(st/emit! (dcm/go-to-workspace :file-id file-id ::rt/replace true))))
(mf/with-effect [file-id page-id]
(reset! first-frame-rendered? false))
(mf/with-effect []
(let [handle-wasm-render
(fn [_]
(reset! first-frame-rendered? true))
listener-key (events/listen globals/document "penpot:wasm:render" handle-wasm-render)]
(fn []
(events/unlistenByKey listener-key))))
[:> (mf/provider ctx/current-project-id) {:value project-id}
[:> (mf/provider ctx/current-file-id) {:value file-id}
[:> (mf/provider ctx/current-page-id) {:value page-id}
@@ -249,15 +264,22 @@
[:> modal-container*]
[:section {:class (stl/css :workspace)
:style {:background-color background-color
:touch-action "none"}}
:touch-action "none"
:position "relative"}}
[:> context-menu*]
(if (and file-loaded? page-id)
(when (and file-loaded? page-id)
[:> workspace-inner*
{:page-id page-id
:file-id file-id
:file file
:wglobal wglobal
:layout layout}]
:layout layout}])
(when (or (not (and file-loaded? page-id))
;; in wasm renderer, extend the pixel loader until the first frame is rendered
;; but do not apply it when switching pages
(and wasm-renderer-enabled?
(not file-loaded?)
(not @first-frame-rendered?)))
[:> workspace-loader*])]]]]]]))
(mf/defc workspace-page*

View File

@@ -20,7 +20,13 @@
}
.workspace-loader {
grid-area: viewport;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: var(--z-index-loaders);
background-color: var(--color-background-primary);
}
.workspace-content {

View File

@@ -90,7 +90,8 @@
instance
(dwt/create-editor editor-node canvas-node options)
update-name? (nil? content)
;; Store original content to compare name later
original-content content
on-key-up
(fn [event]
@@ -101,10 +102,22 @@
on-blur
(fn []
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
(st/emit! (dwt/v2-update-text-shape-content shape-id content
:update-name? update-name?
:name (gen-name instance)
:finalize? true)))
(let [state @st/state
objects (dsh/lookup-page-objects state)
shape (get objects shape-id)
current-name (:name shape)
generated-name (gen-name instance)
;; Update name if: (1) it's a new shape (nil original content), or
;; (2) the current name matches the generated name from original content
;; (meaning it was never manually renamed)
update-name? (or (nil? original-content)
(and (some? current-name)
(some? original-content)
(= current-name (txt/generate-shape-name (txt/content->text original-content)))))]
(st/emit! (dwt/v2-update-text-shape-content shape-id content
:update-name? update-name?
:name generated-name
:finalize? true))))
(let [container-node (mf/ref-val container-ref)]
(dom/set-style! container-node "opacity" 0)))
@@ -138,7 +151,6 @@
(st/emit! (dw/set-clipboard-style style))))]
(.addEventListener ^js global/document "keyup" on-key-up)
(.addEventListener ^js instance "blur" on-blur)
(.addEventListener ^js instance "focus" on-focus)
(.addEventListener ^js instance "needslayout" on-needs-layout)
(.addEventListener ^js instance "stylechange" on-style-change)
@@ -153,8 +165,12 @@
;; This function is called when the component is unmounted
(fn []
;; Explicitly call on-blur here instead of relying on browser blur events,
;; because in Firefox blur is not reliably fired when leaving the text editor
;; by clicking elsewhere. The component does unmount when the shape is
;; deselected, so we can safely call the blur handler here to finalize the editor.
(on-blur)
(.removeEventListener ^js global/document "keyup" on-key-up)
(.removeEventListener ^js instance "blur" on-blur)
(.removeEventListener ^js instance "focus" on-focus)
(.removeEventListener ^js instance "needslayout" on-needs-layout)
(.removeEventListener ^js instance "stylechange" on-style-change)

View File

@@ -33,9 +33,24 @@
[okulary.core :as l]
[rumext.v2 :as mf]))
;; Coalesce sidebar hover highlights to 1 frame to avoid long tasks
(defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}}))
(defonce ^:private sidebar-hover-pending? (atom false))
(defn- schedule-sidebar-hover-flush []
(when (compare-and-set! sidebar-hover-pending? false true)
(ts/raf
(fn []
(let [{:keys [enter leave]} (swap! sidebar-hover-queue (constantly {:enter #{} :leave #{}}))]
(reset! sidebar-hover-pending? false)
(when (seq leave)
(apply st/emit! (map dw/dehighlight-shape leave)))
(when (seq enter)
(apply st/emit! (map dw/highlight-shape enter))))))))
(mf/defc layer-item-inner
{::mf/wrap-props false}
[{:keys [item depth parent-size name-ref children ref
[{:keys [item depth parent-size name-ref children ref style
;; Flags
read-only? highlighted? selected? component-tree?
filtered? expanded? dnd-over? dnd-over-top? dnd-over-bot? hide-toggle?
@@ -82,7 +97,8 @@
:dnd-over dnd-over?
:dnd-over-top dnd-over-top?
:dnd-over-bot dnd-over-bot?
:root-board parent-board?)}
:root-board parent-board?)
:style style}
[:span {:class (stl/css-case
:tab-indentation true
:filtered filtered?)
@@ -166,10 +182,12 @@
children]))
;; Memoized for performance
(mf/defc layer-item
{::mf/props :obj
::mf/memo true}
[{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted]}]
::mf/wrap [mf/memo]}
[{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted style render-children?]
:or {render-children? true}}]
(let [id (:id item)
blocked? (:blocked item)
hidden? (:hidden item)
@@ -246,13 +264,21 @@
(mf/use-fn
(mf/deps id)
(fn [_]
(st/emit! (dw/highlight-shape id))))
(swap! sidebar-hover-queue (fn [{:keys [enter leave] :as q}]
(-> q
(assoc :enter (conj enter id))
(assoc :leave (disj leave id)))))
(schedule-sidebar-hover-flush)))
on-pointer-leave
(mf/use-fn
(mf/deps id)
(fn [_]
(st/emit! (dw/dehighlight-shape id))))
(swap! sidebar-hover-queue (fn [{:keys [enter leave] :as q}]
(-> q
(assoc :enter (disj enter id))
(assoc :leave (conj leave id)))))
(schedule-sidebar-hover-flush)))
on-context-menu
(mf/use-fn
@@ -338,14 +364,18 @@
component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item))
enable-drag (mf/use-fn #(reset! drag-disabled* false))
disable-drag (mf/use-fn #(reset! drag-disabled* true))]
disable-drag (mf/use-fn #(reset! drag-disabled* true))
;; Lazy loading of child elements via IntersectionObserver
children-count* (mf/use-state 0)
children-count (deref children-count*)
lazy-ref (mf/use-ref nil)
observer-var (mf/use-var nil)
chunk-size 50]
(mf/with-effect [selected? selected]
(let [single? (= (count selected) 1)
node (mf/ref-val ref)
;; NOTE: Neither get-parent-at nor get-parent-with-selector
;; work if the component template changes, so we need to
;; seek for an alternate solution. Maybe use-context?
scroll-node (dom/get-parent-with-data node "scroll-container")
parent-node (dom/get-parent-at node 2)
first-child-node (dom/get-first-child parent-node)
@@ -363,6 +393,61 @@
#(when (some? subid)
(rx/dispose! subid))))
;; Setup scroll-driven lazy loading when expanded
;; and ensures selected children are loaded immediately
(mf/with-effect [expanded? (:shapes item) selected]
(let [shapes-vec (:shapes item)
total (count shapes-vec)]
(if expanded?
(let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec
;; Find if any selected id is a direct child and get its render index
selected-child-render-idx
(when (and (> total chunk-size) (seq selected))
(let [shapes-reversed (vec (reverse shapes-vec))]
(some (fn [sel-id]
(let [idx (.indexOf shapes-reversed sel-id)]
(when (>= idx 0) idx)))
selected)))
;; Load at least enough to include the selected child plus extra
;; for context (so it can be centered in the scroll view)
min-count (if selected-child-render-idx
(+ selected-child-render-idx chunk-size)
chunk-size)
current @children-count*
new-count (min total (max current chunk-size min-count))]
(reset! children-count* new-count))
(reset! children-count* 0)))
(fn []
(when-let [obs ^js @observer-var]
(.disconnect obs)
(reset! observer-var nil))))
;; Re-observe sentinel whenever children-count changes (sentinel moves)
(mf/with-effect [children-count expanded?]
(let [total (count (:shapes item))
node (mf/ref-val ref)
scroll-node (dom/get-parent-with-data node "scroll-container")
lazy-node (mf/ref-val lazy-ref)]
;; Disconnect previous observer
(when-let [obs ^js @observer-var]
(.disconnect obs)
(reset! observer-var nil))
;; Setup new observer if there are more children to load
(when (and expanded?
(< children-count total)
scroll-node
lazy-node)
(let [cb (fn [entries]
(when (and (seq entries)
(.-isIntersecting (first entries)))
;; Load next chunk when sentinel intersects
(let [current @children-count*
next-count (min total (+ current chunk-size))]
(reset! children-count* next-count))))
observer (js/IntersectionObserver. cb #js {:root scroll-node})]
(.observe observer lazy-node)
(reset! observer-var observer)))))
[:& layer-item-inner
{:ref dref
:item item
@@ -387,24 +472,32 @@
:on-enable-drag enable-drag
:on-disable-drag disable-drag
:on-toggle-visibility toggle-visibility
:on-toggle-blocking toggle-blocking}
:on-toggle-blocking toggle-blocking
:style style}
(when (and (:shapes item) expanded?)
(when (and render-children?
(:shapes item)
expanded?)
[:div {:class (stl/css-case
:element-children true
:parent-selected selected?
:sticky-children parent-board?)
:data-testid (dm/str "children-" id)}
(for [[index id] (reverse (d/enumerate (:shapes item)))]
(when-let [item (get objects id)]
[:& layer-item
{:item item
:highlighted highlighted
:selected selected
:index index
:objects objects
:key (dm/str id)
:sortable? sortable?
:depth depth
:parent-size parent-size
:component-child? component-tree?}]))])]))
(let [all-children (reverse (d/enumerate (:shapes item)))
visible (take children-count all-children)]
(for [[index id] visible]
(when-let [item (get objects id)]
[:& layer-item
{:item item
:highlighted highlighted
:selected selected
:index index
:objects objects
:key (dm/str id)
:sortable? sortable?
:depth depth
:parent-size parent-size
:component-child? component-tree?}])))
(when (< children-count (count (:shapes item)))
[:div {:ref lazy-ref
:style {:min-height 1}}])])]))

View File

@@ -116,13 +116,29 @@
(->> (dm/get-in grid-edition [edition :selected])
(map #(dm/get-in objects [edition :layout-grid-cells %])))
shapes-with-children
(mf/with-memo [selected objects shapes]
(let [xform (comp (remove nil?)
(mapcat #(cfh/get-children-ids objects %)))
selected (into selected xform selected)]
(sequence (keep (d/getf objects)) selected)))
shapes-with-children*
(mf/use-state nil)
_ (mf/use-effect
(mf/deps selected objects shapes)
(fn []
(reset! shapes-with-children* nil)
(let [result
(loop [queue (into #queue [] selected)
visited selected]
(if-let [id (peek queue)]
(let [shape (get objects id)
children (:shapes shape)]
(if (seq children)
(let [new-children (remove visited children)]
(recur (into (pop queue) new-children)
(into visited new-children)))
(recur (pop queue) visited)))
(sequence (keep (d/getf objects)) visited)))]
(reset! shapes-with-children* result))))
shapes-with-children
(deref shapes-with-children*)
total-selected
(count selected)]

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.modal :as modal]
[app.main.data.workspace :as dw]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.title-bar :refer [title-bar*]]
@@ -22,9 +23,11 @@
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.notifications.badge :refer [badge-notification]]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.timers :as timers]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
@@ -52,17 +55,37 @@
refs/workspace-data
=))
;; --- Page Item
(mf/defc page-item
{::mf/wrap-props false}
[{:keys [page index deletable? selected? editing? hovering?]}]
[{:keys [page index deletable? selected? editing? hovering? current-page-id]}]
(let [input-ref (mf/use-ref)
id (:id page)
delete-fn (mf/use-fn (mf/deps id) #(st/emit! (dw/delete-page id)))
navigate-fn (mf/use-fn (mf/deps id) #(st/emit! :interrupt (dcm/go-to-workspace :page-id id)))
read-only? (mf/use-ctx ctx/workspace-read-only?)
on-click
(mf/use-fn
(mf/deps id)
(fn []
;; For the wasm renderer, apply a blur effect to the viewport canvas
;; when we navigate to a different page.
(if (and (features/active-feature? @st/state "render-wasm/v1")
(not= id current-page-id))
(do
(wasm.api/capture-canvas-pixels)
(wasm.api/apply-canvas-blur)
;; NOTE: it seems we need two RAF so the blur is actually applied and visible
;; in the canvas :(
(timers/raf
(fn []
(timers/raf navigate-fn))))
(navigate-fn))))
on-delete
(mf/use-fn
(mf/deps id)
@@ -155,7 +178,7 @@
:selected selected?)
:data-testid (dm/str "page-" id)
:tab-index "0"
:on-click navigate-fn
:on-click on-click
:on-double-click on-double-click
:on-context-menu on-context-menu}
[:div {:class (stl/css :page-icon)}
@@ -182,12 +205,13 @@
(mf/defc page-item-wrapper
{::mf/wrap-props false}
[{:keys [page-id index deletable? selected? editing?]}]
[{:keys [page-id index deletable? selected? editing? current-page-id]}]
(let [page-ref (mf/with-memo [page-id]
(make-page-ref page-id))
page (mf/deref page-ref)]
[:& page-item {:page page
:index index
:current-page-id current-page-id
:deletable? deletable?
:selected? selected?
:editing? editing?}]))
@@ -210,6 +234,7 @@
:deletable? deletable?
:editing? (= page-id editing-page-id)
:selected? (= page-id current-page-id)
:current-page-id current-page-id
:key page-id}])]]))
;; --- Sitemap Toolbox

View File

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

View File

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

View File

@@ -139,10 +139,12 @@
(defn- resolve-value
[tokens prev-token value]
[tokens prev-token token-name value]
(let [token
{:value value
:name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"}
:name (if (str/blank? token-name)
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}
tokens
(-> tokens
;; Remove previous token when renaming a token
@@ -163,6 +165,7 @@
(let [form (mf/use-ctx fc/context)
input-name name
token-name (get-in @form [:data :name] nil)
touched?
(and (contains? (:data @form) input-name)
@@ -206,11 +209,11 @@
:hint-message (:message error)})
props)]
(mf/with-effect [resolve-stream tokens token input-name]
(mf/with-effect [resolve-stream tokens token input-name token-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token))
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
@@ -236,12 +239,14 @@
(on-composite-input-change form field value false))
([form field value trim?]
(letfn [(clean-errors [errors]
(-> errors
(dissoc field)
(not-empty)))]
(some-> errors
(update :value #(when (map? %) (dissoc % field)))
(update :value #(when (seq %) %))
(not-empty)))]
(swap! form (fn [state]
(-> state
(assoc-in [:data :value field] (if trim? (str/trim value) value))
(assoc-in [:touched :value field] true)
(update :errors clean-errors)
(update :extra-errors clean-errors)))))))
@@ -250,6 +255,7 @@
(let [form (mf/use-ctx fc/context)
input-name name
token-name (get-in @form [:data :name] nil)
error
(get-in @form [:errors :value input-name])
@@ -257,6 +263,9 @@
value
(get-in @form [:data :value input-name] "")
touched?
(get-in @form [:touched :value input-name])
resolve-stream
(mf/with-memo [token]
(if-let [value (get-in token [:value input-name])]
@@ -284,7 +293,7 @@
:hint-message (:message hint)
:hint-type (:type hint)})
props
(if error
(if (and touched? error)
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
props)
@@ -293,10 +302,10 @@
(mf/spread-props props {:hint-formated true})
props)]
(mf/with-effect [resolve-stream tokens token input-name name]
(mf/with-effect [resolve-stream tokens token input-name name token-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token))
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
@@ -360,7 +369,7 @@
(let [form (mf/use-ctx fc/context)
input-name name
token-name (get-in @form [:data :name] nil)
error
(get-in @form [:errors :value value-subfield index input-name])
@@ -405,10 +414,10 @@
(mf/spread-props props {:hint-formated true})
props)]
(mf/with-effect [resolve-stream tokens token input-name index value-subfield]
(mf/with-effect [resolve-stream tokens token input-name index value-subfield token-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token))
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]

View File

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

View File

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

View File

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

View File

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

View File

@@ -312,6 +312,11 @@
(js/console.error "Error initializing canvas context:" e)
false))]
(reset! canvas-init? init?)
(when init?
;; Restore previous canvas pixels immediately after context initialization
;; This happens before initialize-viewport is called
(wasm.api/apply-canvas-blur)
(wasm.api/restore-previous-canvas-pixels))
(when-not init?
(js/alert "WebGL not supported")
(st/emit! (dcm/go-to-dashboard-recent))))))))
@@ -340,6 +345,7 @@
(mf/with-effect [@canvas-init? zoom vbox background]
(when (and @canvas-init? (not @initialized?))
(wasm.api/clear-canvas-pixels)
(wasm.api/initialize-viewport base-objects zoom vbox background)
(reset! initialized? true)))
@@ -418,6 +424,7 @@
:xmlnsXlink "http://www.w3.org/1999/xlink"
:preserveAspectRatio "xMidYMid meet"
:key (str "viewport" page-id)
:id "viewport-controls"
:view-box (utils/format-viewbox vbox)
:ref on-viewport-ref
:class (dm/str @cursor (when drawing-tool " drawing") " " (stl/css :viewport-controls))
@@ -467,7 +474,7 @@
:zoom zoom}]
(when (ctl/any-layout? outlined-frame)
[:g.ghost-outline
[:g.ghost-outline.blurrable
[:& outline/shape-outlines
{:objects base-objects
:selected selected

View File

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

View File

@@ -10,6 +10,7 @@
["react-dom/server" :as rds]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[app.common.math :as mth]
@@ -21,14 +22,15 @@
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.render-wasm :as drw]
[app.main.refs :as refs]
[app.main.render :as render]
[app.main.store :as st]
[app.main.ui.shapes.text]
[app.main.worker :as mw]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.shapes :as shapes]
[app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl]
[app.render-wasm.deserializers :as dr]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
@@ -37,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]
;; FIXME: rename; confunsing name
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
@@ -68,12 +69,25 @@
(def ^:const DEBOUNCE_DELAY_MS 100)
(def ^:const THROTTLE_DELAY_MS 10)
;; Number of shapes to process before yielding to browser
(def ^:const SHAPES_CHUNK_SIZE 100)
;; Threshold below which we use synchronous processing (no chunking overhead)
(def ^:const ASYNC_THRESHOLD 100)
(def dpr
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
(def noop-fn
(constantly nil))
(defn- yield-to-browser
"Returns a promise that resolves after yielding to the browser's event loop.
Uses requestAnimationFrame for smooth visual updates during loading."
[]
(p/create
(fn [resolve _reject]
(js/requestAnimationFrame (fn [_] (resolve nil))))))
;; Based on app.main.render/object-svg
(mf/defc object-svg
{::mf/props :obj}
@@ -120,17 +134,56 @@
(aget buffer 3))
(set! wasm/internal-frame-id nil))))
(defn render-preview!
"Render a lightweight preview without tile caching.
Used during progressive loading for fast feedback."
[]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render_preview")))
(defonce pending-render (atom false))
(defonce shapes-loading? (atom false))
(defonce deferred-render? (atom false))
(defn- register-deferred-render!
[]
(reset! deferred-render? true))
(defn request-render
[_requester]
(when (and wasm/context-initialized? (not @pending-render) (not @wasm/context-lost?))
(reset! pending-render true)
(js/requestAnimationFrame
(fn [ts]
(reset! pending-render false)
(render ts)))))
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(if @shapes-loading?
(register-deferred-render!)
(when-not @pending-render
(reset! pending-render true)
(let [frame-id
(js/requestAnimationFrame
(fn [ts]
(reset! pending-render false)
(set! wasm/internal-frame-id nil)
(render ts)))]
(set! wasm/internal-frame-id frame-id))))))
(defn- begin-shapes-loading!
[]
(reset! shapes-loading? true)
(let [frame-id wasm/internal-frame-id
was-pending @pending-render]
(when frame-id
(js/cancelAnimationFrame frame-id)
(set! wasm/internal-frame-id nil))
(reset! pending-render false)
(reset! deferred-render? was-pending)))
(defn- end-shapes-loading!
[]
(let [was-loading (compare-and-set! shapes-loading? true false)]
(reset! deferred-render? false)
;; Always trigger a render after loading completes
;; This ensures shapes are displayed even if no deferred render was requested
(when was-loading
(request-render "set-objects:flush"))))
(declare get-text-dimensions)
@@ -279,30 +332,6 @@
[string]
(+ (count string) 1))
(defn- create-webgl-texture-from-image
"Creates a WebGL texture from an HTMLImageElement or ImageBitmap and returns the texture object"
[gl image-element]
(let [texture (.createTexture ^js gl)]
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_S ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_T ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MIN_FILTER ^js gl) (.-LINEAR ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MAG_FILTER ^js gl) (.-LINEAR ^js gl))
(.texImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 (.-RGBA ^js gl) (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) image-element)
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
texture))
(defn- get-webgl-context
"Gets the WebGL context from the WASM module"
[]
(when wasm/context-initialized?
(let [gl-obj (unchecked-get wasm/internal-module "GL")]
(when gl-obj
;; Get the current WebGL context from Emscripten
;; The GL object has a currentContext property that contains the context handle
(let [current-ctx (.-currentContext ^js gl-obj)]
(when current-ctx
(.-GLctx ^js current-ctx)))))))
(defn- get-texture-id-for-gl-object
"Registers a WebGL texture with Emscripten's GL object system and returns its ID"
@@ -332,8 +361,8 @@
(->> (retrieve-image url)
(rx/map
(fn [img]
(when-let [gl (get-webgl-context)]
(let [texture (create-webgl-texture-from-image gl img)
(when-let [gl (webgl/get-webgl-context)]
(let [texture (webgl/create-webgl-texture-from-image gl img)
texture-id (get-texture-id-for-gl-object texture)
width (.-width ^js img)
height (.-height ^js img)
@@ -919,24 +948,12 @@
id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
parent-id (get shape :parent-id)
masked (get shape :masked-group)
selrect (get shape :selrect)
constraint-h (get shape :constraints-h)
constraint-v (get shape :constraints-v)
clip-content (if (= type :frame)
(not (get shape :show-content))
false)
rotation (get shape :rotation)
transform (get shape :transform)
fills (get shape :fills)
strokes (if (= type :group)
[] (get shape :strokes))
children (get shape :shapes)
blend-mode (get shape :blend-mode)
opacity (get shape :opacity)
hidden (get shape :hidden)
content (let [content (get shape :content)]
(if (= type :text)
(ensure-text-content content)
@@ -945,22 +962,12 @@
grow-type (get shape :grow-type)
blur (get shape :blur)
svg-attrs (get shape :svg-attrs)
shadows (get shape :shadow)
corners (map #(get shape %) [:r1 :r2 :r3 :r4])]
shadows (get shape :shadow)]
(use-shape id)
(set-parent-id parent-id)
(set-shape-type type)
(set-shape-clip-content clip-content)
(set-shape-constraints constraint-h constraint-v)
(shapes/set-shape-base-props shape)
(set-shape-rotation rotation)
(set-shape-transform transform)
(set-shape-blend-mode blend-mode)
(set-shape-opacity opacity)
(set-shape-hidden hidden)
;; Remaining properties that need separate calls (variable-length or conditional)
(set-shape-children children)
(set-shape-corners corners)
(set-shape-blur blur)
(when (= type :group)
(set-masked (boolean masked)))
@@ -979,7 +986,7 @@
(set-shape-grow-type grow-type))
(set-shape-layout shape)
(set-shape-selrect selrect)
(set-layout-data shape)
(let [pending_thumbnails (into [] (concat
(set-shape-text-content id content)
@@ -1035,29 +1042,143 @@
(let [{:keys [thumbnails full]} (set-object shape)]
(process-pending [shape] thumbnails full noop-fn)))
(defn- process-shapes-chunk
"Process a chunk of shapes synchronously, returning accumulated pending operations.
Returns {:thumbnails [...] :full [...] :next-index n}"
[shapes start-index chunk-size thumbnails-acc full-acc]
(let [total (count shapes)
end-index (min total (+ start-index chunk-size))]
(loop [index start-index
t-acc thumbnails-acc
f-acc full-acc]
(if (< index end-index)
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(into t-acc thumbnails)
(into f-acc full)))
{:thumbnails t-acc
:full f-acc
:next-index end-index}))))
(defn- set-objects-async
"Asynchronously process shapes in chunks, yielding to the browser between chunks.
Returns a promise that resolves when all shapes are processed.
Renders a preview only periodically during loading to show progress,
then does a full tile-based render at the end."
[shapes render-callback]
(let [total-shapes (count shapes)
total-chunks (mth/ceil (/ total-shapes SHAPES_CHUNK_SIZE))
;; Render at 25%, 50%, 75% of loading
render-at-chunks (set [(mth/floor (* total-chunks 0.25))
(mth/floor (* total-chunks 0.5))
(mth/floor (* total-chunks 0.75))])]
(p/create
(fn [resolve _reject]
(letfn [(process-next-chunk [index thumbnails-acc full-acc chunk-count]
(if (< index total-shapes)
;; Process one chunk
(let [{:keys [thumbnails full next-index]}
(process-shapes-chunk shapes index SHAPES_CHUNK_SIZE
thumbnails-acc full-acc)
new-chunk-count (inc chunk-count)]
;; Only render at specific progress milestones
(when (contains? render-at-chunks new-chunk-count)
(render-preview!))
;; Yield to browser, then continue with next chunk
(-> (yield-to-browser)
(p/then (fn [_]
(process-next-chunk next-index thumbnails full new-chunk-count)))))
;; All chunks done - finalize
(do
(perf/end-measure "set-objects")
(process-pending shapes thumbnails-acc full-acc noop-fn
(fn []
(end-shapes-loading!)
(if render-callback
(render-callback)
(render-finish))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))
(resolve nil))))))]
(process-next-chunk 0 [] [] 0))))))
(defn- set-objects-sync
"Synchronously process all shapes (for small shape counts)."
[shapes render-callback]
(let [total-shapes (count shapes)
{:keys [thumbnails full]}
(loop [index 0 thumbnails-acc [] full-acc []]
(if (< index total-shapes)
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(into thumbnails-acc thumbnails)
(into full-acc full)))
{:thumbnails thumbnails-acc :full full-acc}))]
(perf/end-measure "set-objects")
(process-pending shapes thumbnails full noop-fn
(fn []
(if render-callback
(render-callback)
(render-finish))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))
(defn- shapes-in-tree-order
"Returns shapes sorted in tree order (parents before children).
This ensures parent shapes are processed before their children,
maintaining proper shape reference consistency in WASM."
[objects]
;; Get IDs in tree order starting from root (uuid/zero)
;; If root doesn't exist (e.g., filtered thumbnail data), fall back to
;; finding top-level shapes (those without a parent in objects) and
;; traversing from there.
(if (contains? objects uuid/zero)
;; Normal case: traverse from root
(let [ordered-ids (cfh/get-children-ids-with-self objects uuid/zero)]
(into []
(keep #(get objects %))
ordered-ids))
;; Fallback for filtered data (thumbnails): find top-level shapes and traverse
(let [;; Find shapes whose parent is not in the objects map (top-level in this subset)
top-level-ids (->> (vals objects)
(filter (fn [shape]
(not (contains? objects (:parent-id shape)))))
(map :id))
;; Get all children in order for each top-level shape
all-ordered-ids (into []
(mapcat #(cfh/get-children-ids-with-self objects %))
top-level-ids)]
(into []
(keep #(get objects %))
all-ordered-ids))))
(defn set-objects
"Set all shape objects for rendering.
Shapes are processed in tree order (parents before children)
to maintain proper shape reference consistency in WASM."
([objects]
(set-objects objects nil))
([objects render-callback]
(perf/begin-measure "set-objects")
(let [shapes (into [] (vals objects))
total-shapes (count shapes)
;; Collect pending operations - set-object returns {:thumbnails [...] :full [...]}
{:keys [thumbnails full]}
(loop [index 0 thumbnails-acc [] full-acc []]
(if (< index total-shapes)
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(into thumbnails-acc thumbnails)
(into full-acc full)))
{:thumbnails thumbnails-acc :full full-acc}))]
(perf/end-measure "set-objects")
(process-pending shapes thumbnails full noop-fn
(fn []
(when render-callback (render-callback))
(render-finish)
(ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
(let [shapes (shapes-in-tree-order objects)
total-shapes (count shapes)]
(if (< total-shapes ASYNC_THRESHOLD)
(set-objects-sync shapes render-callback)
(do
(begin-shapes-loading!)
(try
(-> (set-objects-async shapes render-callback)
(p/catch (fn [error]
(end-shapes-loading!)
(js/console.error "Async WASM shape loading failed" error))))
(catch :default error
(end-shapes-loading!)
(js/console.error "Async WASM shape loading failed" error)
(throw error)))
nil)))))
(defn clear-focus-mode
[]
@@ -1236,7 +1357,8 @@
(dom/prevent-default event)
(reset! wasm/context-lost? true)
(log/warn :hint "WebGL context lost")
(st/emit! (drw/context-lost)))
(ex/raise :type :webgl-context-lost
:hint "WebGL context lost"))
(defn init-canvas-context
[canvas]
@@ -1383,8 +1505,9 @@
all-children
(->> ids
(mapcat #(cfh/get-children-with-self objects %)))]
(h/call wasm/internal-module "_init_shapes_pool" (count all-children))
(run! (partial set-object objects) all-children)
(run! set-object all-children)
(let [content (-> (calculate-bool* bool-type ids)
(path.impl/path-data))]
@@ -1447,6 +1570,13 @@
result)))
(defn apply-canvas-blur
[]
(when wasm/canvas (dom/set-style! wasm/canvas "filter" "blur(4px)"))
(let [controls-to-blur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")]
(run! #(dom/set-style! % "filter" "blur(4px)") controls-to-blur)))
(defn init-wasm-module
[module]
(let [default-fn (unchecked-get module "default")
@@ -1468,3 +1598,8 @@
(js/console.error cause)
(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

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

View File

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

View File

@@ -0,0 +1,168 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.api.webgl
"WebGL utilities for pixel capture and rendering"
(:require
[app.common.logging :as log]
[app.render-wasm.wasm :as wasm]
[app.util.dom :as dom]))
(defn get-webgl-context
"Gets the WebGL context from the WASM module"
[]
(when wasm/context-initialized?
(let [gl-obj (unchecked-get wasm/internal-module "GL")]
(when gl-obj
;; Get the current WebGL context from Emscripten
;; The GL object has a currentContext property that contains the context handle
(let [current-ctx (.-currentContext ^js gl-obj)]
(when current-ctx
(.-GLctx ^js current-ctx)))))))
(defn create-webgl-texture-from-image
"Creates a WebGL texture from an HTMLImageElement or ImageBitmap and returns the texture object"
[gl image-element]
(let [texture (.createTexture ^js gl)]
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_S ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_T ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MIN_FILTER ^js gl) (.-LINEAR ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MAG_FILTER ^js gl) (.-LINEAR ^js gl))
(.texImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 (.-RGBA ^js gl) (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) image-element)
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
texture))
;; FIXME: temporary function until we are able to keep the same <canvas> across pages.
(defn- draw-imagedata-to-webgl
"Draws ImageData to a WebGL2 context by creating a texture"
[gl image-data]
(let [width (.-width ^js image-data)
height (.-height ^js image-data)
texture (.createTexture ^js gl)]
;; Bind texture and set parameters
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_S ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_T ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MIN_FILTER ^js gl) (.-LINEAR ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MAG_FILTER ^js gl) (.-LINEAR ^js gl))
(.texImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 (.-RGBA ^js gl) (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) image-data)
;; Set up viewport
(.viewport ^js gl 0 0 width height)
;; Vertex & Fragment shaders
;; Since we are only calling this function once (on page switch), we don't need
;; to cache the compiled shaders somewhere else (cannot be reused in a differen context).
(let [vertex-shader-source "#version 300 es
in vec2 a_position;
in vec2 a_texCoord;
out vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}"
fragment-shader-source "#version 300 es
precision highp float;
in vec2 v_texCoord;
uniform sampler2D u_texture;
out vec4 fragColor;
void main() {
fragColor = texture(u_texture, v_texCoord);
}"
vertex-shader (.createShader ^js gl (.-VERTEX_SHADER ^js gl))
fragment-shader (.createShader ^js gl (.-FRAGMENT_SHADER ^js gl))
program (.createProgram ^js gl)]
(.shaderSource ^js gl vertex-shader vertex-shader-source)
(.compileShader ^js gl vertex-shader)
(when-not (.getShaderParameter ^js gl vertex-shader (.-COMPILE_STATUS ^js gl))
(log/error :hint "Vertex shader compilation failed"
:log (.getShaderInfoLog ^js gl vertex-shader)))
(.shaderSource ^js gl fragment-shader fragment-shader-source)
(.compileShader ^js gl fragment-shader)
(when-not (.getShaderParameter ^js gl fragment-shader (.-COMPILE_STATUS ^js gl))
(log/error :hint "Fragment shader compilation failed"
:log (.getShaderInfoLog ^js gl fragment-shader)))
(.attachShader ^js gl program vertex-shader)
(.attachShader ^js gl program fragment-shader)
(.linkProgram ^js gl program)
(if (.getProgramParameter ^js gl program (.-LINK_STATUS ^js gl))
(do
(.useProgram ^js gl program)
;; Create full-screen quad vertices (normalized device coordinates)
(let [position-location (.getAttribLocation ^js gl program "a_position")
texcoord-location (.getAttribLocation ^js gl program "a_texCoord")
position-buffer (.createBuffer ^js gl)
texcoord-buffer (.createBuffer ^js gl)
positions #js [-1.0 -1.0 1.0 -1.0 -1.0 1.0 -1.0 1.0 1.0 -1.0 1.0 1.0]
texcoords #js [0.0 0.0 1.0 0.0 0.0 1.0 0.0 1.0 1.0 0.0 1.0 1.0]]
;; Set up position buffer
(.bindBuffer ^js gl (.-ARRAY_BUFFER ^js gl) position-buffer)
(.bufferData ^js gl (.-ARRAY_BUFFER ^js gl) (js/Float32Array. positions) (.-STATIC_DRAW ^js gl))
(.enableVertexAttribArray ^js gl position-location)
(.vertexAttribPointer ^js gl position-location 2 (.-FLOAT ^js gl) false 0 0)
;; Set up texcoord buffer
(.bindBuffer ^js gl (.-ARRAY_BUFFER ^js gl) texcoord-buffer)
(.bufferData ^js gl (.-ARRAY_BUFFER ^js gl) (js/Float32Array. texcoords) (.-STATIC_DRAW ^js gl))
(.enableVertexAttribArray ^js gl texcoord-location)
(.vertexAttribPointer ^js gl texcoord-location 2 (.-FLOAT ^js gl) false 0 0)
;; Set texture uniform
(.activeTexture ^js gl (.-TEXTURE0 ^js gl))
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(let [texture-location (.getUniformLocation ^js gl program "u_texture")]
(.uniform1i ^js gl texture-location 0))
;; draw
(.drawArrays ^js gl (.-TRIANGLES ^js gl) 0 6)
;; cleanup
(.deleteBuffer ^js gl position-buffer)
(.deleteBuffer ^js gl texcoord-buffer)
(.deleteShader ^js gl vertex-shader)
(.deleteShader ^js gl fragment-shader)
(.deleteProgram ^js gl program)))
(log/error :hint "Program linking failed"
:log (.getProgramInfoLog ^js gl program)))
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
(.deleteTexture ^js gl texture))))
(defn restore-previous-canvas-pixels
"Restores previous canvas pixels into the new canvas"
[]
(when-let [previous-canvas-pixels wasm/canvas-pixels]
(when-let [gl wasm/gl-context]
(draw-imagedata-to-webgl gl previous-canvas-pixels)
(set! wasm/canvas-pixels nil))))
(defn clear-canvas-pixels
[]
(when wasm/canvas
(let [context wasm/gl-context]
(.clearColor ^js context 0 0 0 0.0)
(.clear ^js context (.-COLOR_BUFFER_BIT ^js context))
(.clear ^js context (.-DEPTH_BUFFER_BIT ^js context))
(.clear ^js context (.-STENCIL_BUFFER_BIT ^js context)))
(dom/set-style! wasm/canvas "filter" "none")
(let [controls-to-unblur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")]
(run! #(dom/set-style! % "filter" "none") controls-to-unblur))
(set! wasm/canvas-pixels nil)))
(defn capture-canvas-pixels
"Captures the pixels of the viewport canvas"
[]
(when wasm/canvas
(let [context wasm/gl-context
width (.-width wasm/canvas)
height (.-height wasm/canvas)
buffer (js/Uint8ClampedArray. (* width height 4))
_ (.readPixels ^js context 0 0 width height (.-RGBA ^js context) (.-UNSIGNED_BYTE ^js context) buffer)
image-data (js/ImageData. buffer width height)]
(set! wasm/canvas-pixels image-data))))

View File

@@ -227,7 +227,7 @@
:svg-attrs
(do
(api/set-shape-svg-attrs v)
;; Always update fills/blur/shadow to clear previous state if filters disappear
;; Always update fills/blur/shadow to clear previous state if filters disappear
(api/set-shape-fills id (:fills shape) false)
(api/set-shape-blur (:blur shape))
(api/set-shape-shadows (:shadow shape)))
@@ -397,12 +397,18 @@
(next es))
(throw (js/Error. "conj on a map takes map entries or seqables of map entries"))))))))
(def ^:private xf:without-id-and-type
(remove (fn [kvpair]
(let [k (key kvpair)]
(or (= k :id)
(= k :type))))))
(defn create-shape
"Instanciate a shape from a map"
[attrs]
(ShapeProxy. (:id attrs)
(:type attrs)
(dissoc attrs :id :type)))
(into {} xf:without-id-and-type attrs)))
(t/add-handlers!
;; We only add a write handler, read handler uses the dynamic dispatch

View File

@@ -12,6 +12,8 @@
;; Reference to the HTML canvas element.
(defonce canvas nil)
;; Reference to the captured pixels of the canvas (for page switching effect)
(defonce canvas-pixels nil)
;; Reference to the Emscripten GL context wrapper.
(defonce gl-context-handle nil)
@@ -56,3 +58,4 @@
:stroke-linecap shared/RawStrokeLineCap
:stroke-linejoin shared/RawStrokeLineJoin
:fill-rule shared/RawFillRule})

View File

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

View File

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

View File

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

View File

@@ -58,6 +58,8 @@
(swap! state update ::snap snap/update-page old-page new-page)
(swap! state update ::selection selection/update-page old-page new-page))
(catch :default cause
(log/error :hint "error updating page index" :id page-id :cause cause))
(finally
(let [elapsed (tpoint)]
(log/dbg :hint "page index updated" :id page-id :elapsed elapsed ::log/sync? true))))

View File

@@ -179,6 +179,7 @@
(->> (render-canvas-blob canvas width height bgcolor)
(p/fnly (fn [data cause]
(wasm.api/clear-canvas)
(if cause
(rx/error! subs cause)
(rx/push! subs

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ import TextEditor from "../TextEditor.js";
import CommandMutations from "../commands/CommandMutations.js";
import { isRoot, setRootStyles } from "../content/dom/Root.js";
import { SelectionDirection } from "./SelectionDirection.js";
import SafeGuard from "./SafeGuard.js";
import { SafeGuard } from "./SafeGuard.js";
import { sanitizeFontFamily } from "../content/dom/Style.js";
import StyleDeclaration from "./StyleDeclaration.js";
@@ -167,7 +167,7 @@ export class SelectionController extends EventTarget {
/**
* @type {TextEditorOptions}
*/
#options;
#options = {};
/**
* Constructor
@@ -185,7 +185,7 @@ export class SelectionController extends EventTarget {
throw new TypeError("Invalid EventTarget");
}
*/
this.#options = options;
this.#options = options ?? {};
this.#debug = options?.debug;
this.#styleDefaults = options?.styleDefaults;
this.#selection = selection;
@@ -242,7 +242,6 @@ export class SelectionController extends EventTarget {
continue;
}
let styleValue = element.style.getPropertyValue(styleName);
if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue);
}
@@ -277,22 +276,29 @@ export class SelectionController extends EventTarget {
this.#applyDefaultStylesToCurrentStyle();
const root = startNode.parentElement.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(root);
// FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing"
// the cascade.
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const paragraph = textNode.parentElement.parentElement;
if (startNode === endNode) {
const paragraph = startNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
}
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const textSpan = textNode.parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
const textSpan = startNode.parentElement;
this.#applyStylesFromElementToCurrentStyle(textSpan);
} else {
// FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing"
// the cascade.
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const paragraph = textNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
}
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const textSpan = textNode.parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
}
}
return this;
}
@@ -1692,7 +1698,8 @@ export class SelectionController extends EventTarget {
* @param {RemoveSelectedOptions} [options]
*/
removeSelected(options) {
if (this.isCollapsed) return;
if (this.isCollapsed)
return;
const affectedTextSpans = new Set();
const affectedParagraphs = new Set();
@@ -1701,7 +1708,6 @@ export class SelectionController extends EventTarget {
let nextNode = null;
let { startNode, endNode, startOffset, endOffset } = this.getRanges();
if (this.shouldHandleCompleteDeletion(startNode, endNode)) {
return this.handleCompleteContentDeletion();
}
@@ -1746,9 +1752,10 @@ export class SelectionController extends EventTarget {
const endTextSpan = getTextSpan(endNode);
const endParagraph = getParagraph(endNode);
SafeGuard.start();
const safeGuard = new SafeGuard("removeSelected");
safeGuard.start();
do {
SafeGuard.update();
safeGuard.update();
const { currentNode } = this.#textNodeIterator;
@@ -1760,6 +1767,8 @@ export class SelectionController extends EventTarget {
affectedParagraphs.add(paragraph);
let shouldRemoveNodeCompletely = false;
const isEndNode = currentNode === endNode;
if (currentNode === startNode) {
if (startOffset === 0) {
// We should remove this node completely.
@@ -1768,11 +1777,11 @@ export class SelectionController extends EventTarget {
// We should remove this node partially.
currentNode.nodeValue = currentNode.nodeValue.slice(0, startOffset);
}
} else if (currentNode === endNode) {
} else if (isEndNode) {
if (
isLineBreak(endNode) ||
(isTextNode(endNode) &&
endOffset === (endNode.nodeValue?.length || 0))
endOffset >= (endNode.nodeValue?.length || 0))
) {
// We should remove this node completely.
shouldRemoveNodeCompletely = true;
@@ -1785,9 +1794,13 @@ export class SelectionController extends EventTarget {
shouldRemoveNodeCompletely = true;
}
// We need to step to the next node before
// we remove them completely from the DOM tree
// because we need to iterate through parents
// and childrens.
this.#textNodeIterator.nextNode();
// Realizamos el borrado del nodo actual.
// We remove the current node.
if (shouldRemoveNodeCompletely) {
currentNode.remove();
if (currentNode === startNode) {
@@ -1798,12 +1811,14 @@ export class SelectionController extends EventTarget {
textSpan.remove();
}
if (paragraph !== startParagraph && paragraph.children.length === 0) {
if (paragraph !== startParagraph
&& paragraph.children.length === 0) {
paragraph.remove();
}
}
if (currentNode === endNode) {
// Break immediately after processing endNode, before advancing iterator
if (isEndNode) {
break;
}
} while (this.#textNodeIterator.currentNode);
@@ -1854,16 +1869,28 @@ export class SelectionController extends EventTarget {
return this.collapse(startNode, startOffset);
}
/**
* Returns an object with ranges.
*
* @returns {}
*/
getRanges() {
let startNode = getClosestTextNode(this.#range.startContainer);
let endNode = getClosestTextNode(this.#range.endContainer);
let startOffset = this.#range.startOffset;
let endOffset = this.#range.startOffset + this.#range.toString().length;
let endOffset = this.#range.endOffset;
return { startNode, endNode, startOffset, endOffset };
}
/**
* Returns true if we should remove the complete root.
*
* @param {*} startNode
* @param {*} endNode
* @returns {boolean}
*/
shouldHandleCompleteDeletion(startNode, endNode) {
const root = this.#textEditor.root;
return (
@@ -1991,11 +2018,12 @@ export class SelectionController extends EventTarget {
// then we need to iterate through those nodes to apply
// the styles.
} else if (startNode !== endNode) {
SafeGuard.start();
const safeGuard = new SafeGuard("applyStylesTo");
safeGuard.start();
const expectedEndNode = getClosestTextNode(endNode);
this.#textNodeIterator.currentNode = getClosestTextNode(startNode);
do {
SafeGuard.update();
safeGuard.update();
const paragraph = getParagraph(this.#textNodeIterator.currentNode);
setParagraphStyles(paragraph, newStyles);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1559,6 +1559,12 @@ msgstr "Old password is incorrect"
msgid "feedback.description"
msgstr "Description"
msgid "errors.webgl-context-lost.main-message"
msgstr "Oops! The canvas context was lost"
msgid "errors.webgl-context-lost.desc-message"
msgstr "WebGL has stopped working. Please reload the page to reset it"
#: src/app/main/ui/settings/feedback.cljs:122
msgid "feedback.description-placeholder"
msgstr "Please describe the reason of your feedback"
@@ -2521,6 +2527,9 @@ msgstr "Release notes"
msgid "labels.reload-file"
msgstr "Reload file"
msgid "labels.reload-page"
msgstr "Reload page"
#: src/app/main/ui/workspace/libraries.cljs, src/app/main/ui/dashboard/team.cljs
#, unused
msgid "labels.remove"

View File

@@ -1552,6 +1552,12 @@ msgstr "El email o la contraseña son incorrectos."
msgid "errors.wrong-old-password"
msgstr "La contraseña anterior no es correcta"
msgid "errors.webgl-context-lost.main-message"
msgstr "Ups! Se ha perdido el contexto del canvas"
msgid "errors.webgl-context-lost.desc-message"
msgstr "WebGL ha dejado de funcionar. Por favor, recarga la página para restaurarlo"
#: src/app/main/ui/settings/feedback.cljs:120
msgid "feedback.description"
msgstr "Descripción"
@@ -2502,6 +2508,9 @@ msgstr "Notas de versión"
msgid "labels.reload-file"
msgstr "Recargar archivo"
msgid "labels.reload-page"
msgstr "Recargar página"
#: src/app/main/ui/workspace/libraries.cljs, src/app/main/ui/dashboard/team.cljs
#, unused
msgid "labels.remove"
@@ -7594,6 +7603,10 @@ msgstr "Error al importar: No se pudo procesar el JSON."
msgid "workspace.tokens.export"
msgstr "Exportar"
#: src/app/main/ui/workspace/tokens/export/modal.cljs:125
msgid "workspace.tokens.export-tokens"
msgstr "Exportar tokens"
#: src/app/main/ui/workspace/tokens/export/modal.cljs:118
msgid "workspace.tokens.export.multiple-files"
msgstr "Múltiples ficheros"
@@ -7638,10 +7651,26 @@ msgstr "Nombre del grupo"
msgid "workspace.tokens.grouping-set-alert"
msgstr "La agrupación de sets aun no está soportada."
#: src/app/main/ui/workspace/tokens/import/modal.cljs:233
msgid "workspace.tokens.import-button-prefix"
msgstr "Importar %s"
#: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37
msgid "workspace.tokens.import-error"
msgstr "Error al importar:"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:273
msgid "workspace.tokens.import-menu-folder-option"
msgstr "Carpeta"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:271
msgid "workspace.tokens.import-menu-json-option"
msgstr "Archivo JSON único"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:272
msgid "workspace.tokens.import-menu-zip-option"
msgstr "Archivo ZIP"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:241
msgid "workspace.tokens.import-multiple-files"
msgstr ""
@@ -7656,7 +7685,7 @@ msgstr ""
#: src/app/main/ui/workspace/tokens/import/modal.cljs:237
msgid "workspace.tokens.import-tokens"
msgstr "Import tokens"
msgstr "Importar tokens"
#: src/app/main/ui/workspace/tokens/sidebar.cljs:414, src/app/main/ui/workspace/tokens/sidebar.cljs:415
#, unused
@@ -7757,7 +7786,7 @@ msgstr "Line height (multiplicador, px o %) o {alias}"
#: src/app/main/data/workspace/tokens/errors.cljs:57
msgid "workspace.tokens.missing-references"
msgstr "Referéncias de tokens no encontradas:"
msgstr "Referencias de tokens no encontradas: "
#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:123
msgid "workspace.tokens.more-options"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
{
"name": "@penpot/plugins-runtime",
"version": "1.3.2",
"version": "1.4.2",
"dependencies": {
"@penpot/plugin-types": "^1.3.2",
"@penpot/plugin-types": "^1.4.2",
"ses": "^1.1.0",
"zod": "^3.22.4"
},

View File

@@ -118,8 +118,8 @@ export function createSandbox(
// Window properties
console: ses.harden(window.console),
devicePixelRatio: ses.harden(window.devicePixelRatio),
atob: ses.harden(window.atob),
btoa: ses.harden(window.btoa),
atob: ses.harden(window.atob.bind(null)),
btoa: ses.harden(window.btoa.bind(null)),
structuredClone: ses.harden(window.structuredClone),
};

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