Merge remote-tracking branch 'origin/staging-render' into develop

This commit is contained in:
Andrey Antukh 2026-03-11 15:50:58 +01:00
commit 2de3ead14f
75 changed files with 7409 additions and 1254 deletions

View File

@ -34,6 +34,8 @@ jobs:
corepack enable; corepack enable;
corepack install; corepack install;
pnpm install; pnpm install;
pnpm run check-fmt:clj
pnpm run check-fmt:js
pnpm run lint:clj pnpm run lint:clj
- name: Lint Frontend - name: Lint Frontend
@ -42,6 +44,9 @@ jobs:
corepack enable; corepack enable;
corepack install; corepack install;
pnpm install; pnpm install;
pnpm run check-fmt:js
pnpm run check-fmt:clj
pnpm run check-fmt:scss
pnpm run lint:clj pnpm run lint:clj
pnpm run lint:js pnpm run lint:js
pnpm run lint:scss pnpm run lint:scss
@ -52,7 +57,8 @@ jobs:
corepack enable; corepack enable;
corepack install; corepack install;
pnpm install; pnpm install;
pnpm run lint:clj pnpm run check-fmt
pnpm run lint
- name: Lint Exporter - name: Lint Exporter
working-directory: ./exporter working-directory: ./exporter
@ -60,7 +66,8 @@ jobs:
corepack enable; corepack enable;
corepack install; corepack install;
pnpm install; pnpm install;
pnpm run lint:clj pnpm run check-fmt
pnpm run lint
- name: Lint Library - name: Lint Library
working-directory: ./library working-directory: ./library
@ -68,7 +75,8 @@ jobs:
corepack enable; corepack enable;
corepack install; corepack install;
pnpm install; pnpm install;
pnpm run lint:clj pnpm run check-fmt
pnpm run lint
test-common: test-common:
name: "Common Tests" name: "Common Tests"
@ -79,12 +87,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Run tests on JVM - name: Run tests
working-directory: ./common
run: |
clojure -M:dev:test
- name: Run tests on NODE
working-directory: ./common working-directory: ./common
run: | run: |
./scripts/test ./scripts/test

22
.gitignore vendored
View File

@ -1,11 +1,4 @@
.pnp.* .pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnpm-store
*-init.clj *-init.clj
*.css.json *.css.json
*.jar *.jar
@ -20,8 +13,6 @@
.nyc_output .nyc_output
.rebel_readline_history .rebel_readline_history
.repl .repl
.shadow-cljs
.pnpm-store/
/*.jpg /*.jpg
/*.md /*.md
/*.png /*.png
@ -36,6 +27,7 @@
/playground/ /playground/
/backend/*.md /backend/*.md
!/backend/AGENTS.md !/backend/AGENTS.md
/backend/.shadow-cljs
/backend/*.sql /backend/*.sql
/backend/*.txt /backend/*.txt
/backend/assets/ /backend/assets/
@ -48,13 +40,13 @@
/backend/experiments /backend/experiments
/backend/scripts/_env.local /backend/scripts/_env.local
/bundle* /bundle*
/cd.md
/clj-profiler/ /clj-profiler/
/common/coverage /common/coverage
/common/target /common/target
/deploy /common/.shadow-cljs
/docker/images/bundle* /docker/images/bundle*
/exporter/target /exporter/target
/exporter/.shadow-cljs
/frontend/.storybook/preview-body.html /frontend/.storybook/preview-body.html
/frontend/.storybook/preview-head.html /frontend/.storybook/preview-head.html
/frontend/playwright-report/ /frontend/playwright-report/
@ -68,9 +60,9 @@
/frontend/storybook-static/ /frontend/storybook-static/
/frontend/target/ /frontend/target/
/frontend/test-results/ /frontend/test-results/
/frontend/.shadow-cljs
/other/ /other/
/scripts/ /nexus/
/telemetry/
/tmp/ /tmp/
/vendor/**/target /vendor/**/target
/vendor/svgclean/bundle*.js /vendor/svgclean/bundle*.js
@ -79,13 +71,11 @@
/library/*.zip /library/*.zip
/external /external
/penpot-nitrate /penpot-nitrate
clj-profiler/
node_modules
/test-results/ /test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/render-wasm/target/ /render-wasm/target/
/**/node_modules
/**/.yarn/* /**/.yarn/*
/.pnpm-store /.pnpm-store

352
AGENTS.md
View File

@ -1,4 +1,4 @@
# Penpot Copilot Instructions # Penpot Instructions
## Architecture Overview ## Architecture Overview
@ -18,7 +18,13 @@ The monorepo is managed with `pnpm` workspaces. The `manage.sh`
orchestrates cross-component builds. `run-ci.sh` defines the CI orchestrates cross-component builds. `run-ci.sh` defines the CI
pipeline. pipeline.
--- ## Search Standards
When searching code, always use `ripgrep` (rg) instead of grep if
available, as it respects `.gitignore` by default.
If using grep, try to exclude node_modules and .shadow-cljs directories
## Build, Test & Lint Commands ## Build, Test & Lint Commands
@ -28,27 +34,26 @@ Run `./scripts/setup` for setup all dependencies.
```bash ```bash
# Dev # Build (Producution)
pnpm run watch:app # Full dev build (WASM + CLJS + assets)
# Production Build
./scripts/build ./scripts/build
# Tests # Tests
pnpm run test # Build ClojureScript tests + run node target/tests/test.js pnpm run test # Build ClojureScript tests + run node target/tests/test.js
pnpm run watch:test # Watch + auto-rerun on change
pnpm run test:e2e # Playwright e2e tests
pnpm run test:e2e --grep "pattern" # Single e2e test by pattern
# Lint # Lint
pnpm run lint:js # format and linter check for JS pnpm run lint:js # Linter for JS/TS
pnpm run lint:clj # format and linter check for CLJ pnpm run lint:clj # Linter for CLJ/CLJS/CLJC
pnpm run lint:scss # prettier check for SCSS pnpm run lint:scss # Linter for SCSS
# Code formatting # Check Code Formart
pnpm run fmt:clj # Format CLJ pnpm run check-fmt:clj # Format CLJ/CLJS/CLJC
pnpm run fmt:js # prettier for JS pnpm run check-fmt:js # Format JS/TS
pnpm run fmt:scss # prettier for SCSS pnpm run check-fmt:scss # Format SCSS
# Code Format (Automatic Formating)
pnpm run fmt:clj # Format CLJ/CLJS/CLJC
pnpm run fmt:js # Format JS/TS
pnpm run fmt:scss # Format SCSS
``` ```
To run a focused ClojureScript unit test: edit To run a focused ClojureScript unit test: edit
@ -58,28 +63,63 @@ run build:test && node target/tests/test.js`.
### Backend (`cd backend`) ### Backend (`cd backend`)
```bash Run `pnpm install` for install all dependencies.
# Tests (Kaocha)
clojure -M:dev:test # Full suite
clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace
# Lint / Format ```bash
pnpm run lint:clj # Run full test suite
pnpm run fmt:clj pnpm run test
# Run single namespace
pnpm run test --focus backend-tests.rpc-doc-test
# Check Code Format
pnpm run check-fmt
# Code Format (Automatic Formatting)
pnpm run fmt
# Code Linter
pnpm run lint
``` ```
Test config is in `backend/tests.edn`; test namespaces match `.*-test$` under `test/`. Test config is in `backend/tests.edn`; test namespaces match
`.*-test$` under `test/` directory. You should not touch this file,
just use it for reference.
### Common (`cd common`) ### Common (`cd common`)
This contains code that should compile and run under different runtimes: JVM & JS so the commands are
separarated for each runtime.
```bash ```bash
pnpm run test # Build + run node target/tests/test.js clojure -M:dev:test # Run full test suite under JVM
pnpm run watch:test # Watch mode clojure -M:dev:test --focus backend-tests.my-ns-test # Run single namespace under JVM
pnpm run lint:clj
pnpm run fmt:clj # Run full test suite under JS or JVM runtimes
pnpm run test:js
pnpm run test:jvm
# Run single namespace (only on JVM)
pnpm run test:jvm --focus common-tests.my-ns-test
# Lint
pnpm run lint:clj # Lint CLJ/CLJS/CLJC code
# Check Format
pnpm run check-fmt:clj # Check CLJ/CLJS/CLJS code
pnpm run check-fmt:js # Check JS/TS code
# Code Format (Automatic Formatting)
pnpm run fmt:clj # Check CLJ/CLJS/CLJS code
pnpm run fmt:js # Check JS/TS code
``` ```
To run a focused ClojureScript unit test: edit
`test/common_tests/runner.cljs` to narrow the test suite, then `pnpm
run build:test && node target/tests/test.js`.
### Render-WASM (`cd render-wasm`) ### Render-WASM (`cd render-wasm`)
```bash ```bash
@ -93,6 +133,10 @@ cargo fmt --check
### Namespace Structure ### Namespace Structure
The backend, frontend and exporter are developed using clojure and
clojurescript and code is organized in namespaces. This is a general
overview of the available namespaces.
**Backend:** **Backend:**
- `app.rpc.commands.*` RPC command implementations (`auth`, `files`, `teams`, etc.) - `app.rpc.commands.*` RPC command implementations (`auth`, `files`, `teams`, etc.)
- `app.http.*` HTTP routes and middleware - `app.http.*` HTTP routes and middleware
@ -109,14 +153,26 @@ cargo fmt --check
- `app.util.*` Utilities (DOM, HTTP, i18n, keyboard shortcuts) - `app.util.*` Utilities (DOM, HTTP, i18n, keyboard shortcuts)
**Common:** **Common:**
- `app.common.types.*` Shared data types for shapes, files, pages - `app.common.types.*` Shared data types for shapes, files, pages using Malli schemas
- `app.common.schema` Malli validation schemas - `app.common.schema` Malli abstraction layer, exposes the most used functions from malli
- `app.common.geom.*` Geometry utilities - `app.common.geom.*` Geometry and shape transformation helpers
- `app.common.data` Generic helpers used around all application
- `app.common.math` Generic math helpers used around all aplication
- `app.common.json` Generic JSON encoding/decoding helpers
- `app.common.data.macros` Performance macros used everywhere - `app.common.data.macros` Performance macros used everywhere
### Backend RPC Commands ### Backend RPC Commands
All API calls go through a single RPC endpoint: `POST /api/rpc/command/<cmd-name>`. The PRC methods are implement in a some kind of multimethod structure using
`app.util.serivices` namespace. All RPC methods are collected under `app.rpc`
namespace and exposed under `/api/rpc/command/<cmd-name>`. The RPC method
accepts POST and GET requests indistinctly and uses `Accept` header for
negotiate the response encoding (which can be transit, the defaut or plain
json). It also accepts transit (defaut) or json as input, which should be
indicated using `Content-Type` header.
This is an example:
```clojure ```clojure
(sv/defmethod ::my-command (sv/defmethod ::my-command
@ -129,12 +185,18 @@ All API calls go through a single RPC endpoint: `POST /api/rpc/command/<cmd-name
{:id (uuid/next)}) {:id (uuid/next)})
``` ```
Look under `src/app/rpc/commands/*.clj` to see more examples.
### Frontend State Management (Potok) ### Frontend State Management (Potok)
State is a single atom managed by a Potok store. Events implement protocols: State is a single atom managed by a Potok store. Events implement protocols
(funcool/potok library):
```clojure ```clojure
(defn my-event [data] (defn my-event
"doc string"
[data]
(ptk/reify ::my-event (ptk/reify ::my-event
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] ;; synchronous state transition (update [_ state] ;; synchronous state transition
@ -148,19 +210,40 @@ State is a single atom managed by a Potok store. Events implement protocols:
ptk/EffectEvent ptk/EffectEvent
(effect [_ state _] ;; pure side effects (DOM, logging) (effect [_ state _] ;; pure side effects (DOM, logging)
(.focus (dom/get-element "id"))))) (dom/focus (dom/get-element "id")))))
``` ```
Dispatch with `(st/emit! (my-event data))`. Read state via reactive The state is located under `app.main.store` namespace where we have
refs: `(deref refs/selected-shapes)`. Prefer helpers from the `emit!` function responsible of emiting events.
`app.util.dom` instead of using direct dom calls, if no helper is
Example:
```cljs
(ns some.ns
(:require
[app.main.data.my-events :refer [my-event]]
[app.main.store :as st]))
(defn on-click
[event]
(st/emit! (my-event)))
```
On `app.main.refs` we have reactive references which lookup into the main state
for just inner data or precalculated data. That references are very usefull but
should be used with care because, per example if we have complex operation, this
operation will be executed on each state change, and sometimes is better to have
simple references and use react `use-memo` for more granular memoization.
Prefer helpers from `app.util.dom` instead of using direct dom calls, if no helper is
available, prefer adding a new helper for handling it and the use the available, prefer adding a new helper for handling it and the use the
new helper. new helper.
### CSS Modules Pattern ### CSS (Modules Pattern)
Styles are co-located with components. Each `.cljs` file has a corresponding `.scss` file: Styles are co-located with components. Each `.cljs` file has a corresponding
`.scss` file:
```clojure ```clojure
;; In the component namespace: ;; In the component namespace:
@ -174,8 +257,24 @@ Styles are co-located with components. Each `.cljs` file has a corresponding `.s
;; When you need concat an existing class: ;; When you need concat an existing class:
[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}] [:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}]
```
### Integration tests (Playwright)
Integration tests are developed under `frontend/playwright` directory, we use
mocks for remove communication with backend.
The tests should be executed under `./frontend` directory:
``` ```
cd frontend/
pnpm run test:e2e # Playwright e2e tests
pnpm run test:e2e --grep "pattern" # Single e2e test by pattern
```
Ensure everything installed with `./scripts/setup` script.
### Performance Macros (`app.common.data.macros`) ### Performance Macros (`app.common.data.macros`)
@ -187,7 +286,7 @@ Always prefer these macros over their `clojure.core` equivalents — they compil
(dm/str "a" "b" "c") ;; string concatenation (dm/str "a" "b" "c") ;; string concatenation
``` ```
### Shared Code (cljc) ### Shared Code under Common (CLJC)
Files in `common/src/app/common/` use reader conditionals to target both runtimes: Files in `common/src/app/common/` use reader conditionals to target both runtimes:
@ -196,37 +295,129 @@ Files in `common/src/app/common/` use reader conditionals to target both runtime
:cljs (:require [cljs.core :as core])) :cljs (:require [cljs.core :as core]))
``` ```
Both frontend and backend depend on `common` as a local library (`penpot/common {:local/root "../common"}`). Both frontend and backend depend on `common` as a local library (`penpot/common
{:local/root "../common"}`).
### Component Definition (Rumext / React)
The codebase has several kind of components, some of them use legacy ### Component Standards & Syntax (React & Rumext: mf/defc)
syntax. The current and the most recent syntax uses `*` suffix on the
name. This indicates to the `mf/defc` macro apply concrete rules on
how props should be treated.
```clojure The codebase contains various component patterns. When creating or refactoring
components, follow the Modern Syntax rules outlined below.
1. The * Suffix Convention
The most recent syntax uses a * suffix in the component name (e.g.,
my-component*). This suffix signals the mf/defc macro to apply specific rules
for props handling and destructuring and optimization.
2. Component Definition
Modern components should use the following structure:
```clj
(mf/defc my-component* (mf/defc my-component*
{::mf/wrap [mf/memo]} ;; React.memo {::mf/wrap [mf/memo]} ;; Equivalent to React.memo
[{:keys [name on-click]}] [{:keys [name on-click]}] ;; Destructured props
[:div {:class (stl/css :root) [:div {:class (stl/css :root)
:on-click on-click} :on-click on-click}
name]) name])
``` ```
Hooks: `(mf/use-state)`, `(mf/use-effect)`, `(mf/use-memo)` analgous to react hooks. 3. Hooks
Use the mf namespace for hooks to maintain consistency with the macro's
lifecycle management. These are analogous to standard React hooks:
```clj
(mf/use-state) ;; analogous to React.useState adapted to cljs semantics
(mf/use-effect) ;; analogous to React.useEffect
(mf/use-memo) ;; analogous to React.useMemo
(mf/use-fn) ;; analogous to React.useCallback
```
The `mf/use-state` in difference with React.useState, returns an atom-like
object, where you can use `swap!` or `reset!` for to perform an update and
`deref` for get the current value.
You also has `mf/deref` hook (which does not follow the `use-` naming pattern)
and it's purpose is watch (subscribe to changes) on atom or derived atom (from
okulary) and get the current value. Is mainly used for subscribe to lenses
defined in `app.main.refs` or (private lenses defined in namespaces).
Rumext also comes with improved syntax macros as alternative to `mf/use-effect`
and `mf/use-memo` functions. Examples:
The component usage should always follow the `[:> my-component* Example for `mf/with-memo` macro:
props]`, where props should be a map literal or symbol pointing to
javascript props objects. The javascript props object can be created
manually `#js {:data-foo "bar"}` or using `mf/spread-object` helper
macro.
--- ```clj
;; Using functions
(mf/use-effect
(mf/deps team-id)
(fn []
(st/emit! (dd/initialize team-id))
(fn []
(st/emit! (dd/finalize team-id)))))
## Commit Guidelines ;; The same effect but using mf/with-effect
(mf/with-effect [team-id]
(st/emit! (dd/initialize team-id))
(fn []
(st/emit! (dd/finalize team-id))))
```
Example for `mf/with-memo` macro:
```
;; Using functions
(mf/use-memo
(mf/deps projects team-id)
(fn []
(->> (vals projects)
(filterv #(= team-id (:team-id %))))))
;; Using the macro
(mf/with-memo [projects team-id]
(->> (vals projects)
(filterv #(= team-id (:team-id %)))))
```
Prefer using the macros for it syntax simplicity.
4. Component Usage (Hiccup Syntax)
When invoking a component within Hiccup, always use the [:> component* props]
pattern.
Requirements for props:
- Must be a map literal or a symbol pointing to a JavaScript props object.
- To create a JS props object, use the `#js` literal or the `mf/spread-object` helper macro.
Examples:
```clj
;; Using object literal (no need of #js because macro already interprets it)
[:> my-component* {:data-foo "bar"}]
;; Using object literal (no need of #js because macro already interprets it)
(let [props #js {:data-foo "bar"
:className "myclass"}]
[:> my-component* props])
;; Using the spread helper
(let [props (mf/spread-object base-props {:extra "data"})]
[:> my-component* props])
```
4. Checklist
- [ ] Does the component name end with *?
## Commit Format Guidelines
Format: `<emoji-code> <subject>` Format: `<emoji-code> <subject>`
@ -263,3 +454,46 @@ applicable.
| ⬇️ | `:arrow_down:` | Dependency downgrade | | ⬇️ | `:arrow_down:` | Dependency downgrade |
| 🔥 | `:fire:` | Remove files or code | | 🔥 | `:fire:` | Remove files or code |
| 🌐 | `:globe_with_meridians:` | Translations | | 🌐 | `:globe_with_meridians:` | Translations |
## SCSS Rules & Migration
### General rules
- Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss
variables and get the already defined properties from `_sizes.scss`. The SCSS
variables are allowed and still used, just prefer properties if they are
already defined.
- If a value isn't in the DS, use the `px2rem(n)` mixin: `@use "ds/_utils.scss"
as *; padding: px2rem(23);`.
- Do **not** create new SCSS variables for one-off values.
- Use physical directions with logical ones to support RTL/LTR naturally.
- ❌ `margin-left`, `padding-right`, `left`, `right`.
- ✅ `margin-inline-start`, `padding-inline-end`, `inset-inline-start`.
- Always use the `use-typography` mixin from `ds/typography.scss`.
- ✅ `@include t.use-typography("title-small");`
- Use `$br-*` for radius and `$b-*` for thickness from `ds/_borders.scss`.
- Use only tokens from `ds/colors.scss`. Do **NOT** use `design-tokens.scss` or
legacy color variables.
- Use mixins only those defined in`ds/mixins.scss`. Avoid legacy mixins like
`@include flexCenter;`. Write standard CSS (flex/grid) instead.
### Syntax & Structure
- Use the `@use` instead of `@import`. If you go to refactor existing SCSS file,
try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as
*;` (Use `as *` to expose variables directly).
- Avoid deep selector nesting or high-specificity (IDs). Flatten selectors:
- ❌ `.card { .title { ... } }`
- ✅ `.card-title { ... }`
- Leverage component-level CSS variables for state changes (hover/focus) instead
of rewriting properties.
### Checklist
- [ ] No references to `common/refactor/`
- [ ] All `@import` converted to `@use` (only if refactoring)
- [ ] Physical properties (left/right) using logical properties (inline-start/end).
- [ ] Typography implemented via `use-typography()` mixin.
- [ ] Hardcoded pixel values wrapped in `px2rem()`.
- [ ] Selectors are flat (no deep nesting).

View File

@ -61,6 +61,7 @@
- Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513) - Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513)
- Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528) - Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528)
- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) - Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
- Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463)
## 2.13.3 ## 2.13.3

View File

@ -19,7 +19,9 @@
"ws": "^8.17.0" "ws": "^8.17.0"
}, },
"scripts": { "scripts": {
"lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", "lint": "clj-kondo --parallel --lint ../common/src src/",
"fmt:clj": "cljfmt fix --parallel=true src/ test/" "check-fmt": "cljfmt check --parallel=true src/ test/",
"fmt": "cljfmt fix --parallel=true src/ test/",
"test": "clojure -M:dev:test"
} }
} }

View File

@ -138,6 +138,7 @@
c.deleted_at c.deleted_at
FROM snapshots1 AS c FROM snapshots1 AS c
WHERE c.file_id = ? WHERE c.file_id = ?
ORDER BY c.created_at DESC
), snapshots3 AS ( ), snapshots3 AS (
(SELECT * FROM snapshots2 (SELECT * FROM snapshots2
WHERE created_by = 'system' WHERE created_by = 'system'
@ -150,8 +151,7 @@
AND deleted_at IS NULL AND deleted_at IS NULL
LIMIT 500) LIMIT 500)
) )
SELECT * FROM snapshots3 SELECT * FROM snapshots3;"))
ORDER BY created_at DESC"))
(defn get-visible-snapshots (defn get-visible-snapshots
"Return a list of snapshots fecheable from the API, it has a limited "Return a list of snapshots fecheable from the API, it has a limited

View File

@ -13,6 +13,7 @@
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"prettier": "3.5.3",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"ws": "^8.18.2" "ws": "^8.18.2"
}, },
@ -20,11 +21,15 @@
"date-fns": "^4.1.0" "date-fns": "^4.1.0"
}, },
"scripts": { "scripts": {
"lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel=true --lint src/", "lint:clj": "clj-kondo --parallel=true --lint src/",
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
"check-fmt:js": "prettier -c src/**/*.js",
"fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:js": "prettier -c src/**/*.js -w",
"lint": "pnpm run lint:clj", "lint": "pnpm run lint:clj",
"watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"", "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"",
"build:test": "clojure -M:dev:shadow-cljs compile test", "build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "pnpm run build:test && node target/tests/test.js" "test:js": "pnpm run build:test && node target/tests/test.js",
"test:jvm": "clojure -M:dev:test"
} }
} }

10
common/pnpm-lock.yaml generated
View File

@ -18,6 +18,9 @@ importers:
nodemon: nodemon:
specifier: ^3.1.10 specifier: ^3.1.10
version: 3.1.11 version: 3.1.11
prettier:
specifier: 3.5.3
version: 3.5.3
source-map-support: source-map-support:
specifier: ^0.5.21 specifier: ^0.5.21
version: 0.5.21 version: 0.5.21
@ -169,6 +172,11 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
prettier@3.5.3:
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
engines: {node: '>=14'}
hasBin: true
pstree.remy@1.1.8: pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
@ -405,6 +413,8 @@ snapshots:
picomatch@2.3.1: {} picomatch@2.3.1: {}
prettier@3.5.3: {}
pstree.remy@1.1.8: {} pstree.remy@1.1.8: {}
readdirp@3.6.0: readdirp@3.6.0:

View File

@ -4,4 +4,5 @@ set -ex
corepack enable; corepack enable;
corepack install; corepack install;
pnpm install; pnpm install;
pnpm run test; pnpm run test:js;
pnpm run test:jvm;

View File

@ -10,7 +10,7 @@
goog.require("cljs.core"); goog.require("cljs.core");
goog.provide("app.common.encoding_impl"); goog.provide("app.common.encoding_impl");
goog.scope(function() { goog.scope(function () {
const core = cljs.core; const core = cljs.core;
const global = goog.global; const global = goog.global;
const self = app.common.encoding_impl; const self = app.common.encoding_impl;
@ -28,8 +28,10 @@ goog.scope(function() {
// Accept UUID hex format // Accept UUID hex format
input = input.replace(/-/g, ""); input = input.replace(/-/g, "");
if ((input.length % 2) !== 0) { if (input.length % 2 !== 0) {
throw new RangeError("Expected string to be an even number of characters") throw new RangeError(
"Expected string to be an even number of characters",
);
} }
const view = new Uint8Array(input.length / 2); const view = new Uint8Array(input.length / 2);
@ -44,7 +46,11 @@ goog.scope(function() {
function bufferToHex(source, isUuid) { function bufferToHex(source, isUuid) {
if (source instanceof Uint8Array) { if (source instanceof Uint8Array) {
} else if (ArrayBuffer.isView(source)) { } else if (ArrayBuffer.isView(source)) {
source = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); source = new Uint8Array(
source.buffer,
source.byteOffset,
source.byteLength,
);
} else if (Array.isArray(source)) { } else if (Array.isArray(source)) {
source = Uint8Array.from(source); source = Uint8Array.from(source);
} }
@ -56,22 +62,28 @@ goog.scope(function() {
const spacer = isUuid ? "-" : ""; const spacer = isUuid ? "-" : "";
let i = 0; let i = 0;
return (hexMap[source[i++]] + return (
hexMap[source[i++]] + hexMap[source[i++]] +
hexMap[source[i++]] + hexMap[source[i++]] +
hexMap[source[i++]] + spacer + hexMap[source[i++]] +
hexMap[source[i++]] + hexMap[source[i++]] +
hexMap[source[i++]] + spacer + spacer +
hexMap[source[i++]] + hexMap[source[i++]] +
hexMap[source[i++]] + spacer + hexMap[source[i++]] +
hexMap[source[i++]] + spacer +
hexMap[source[i++]] + spacer + hexMap[source[i++]] +
hexMap[source[i++]] + hexMap[source[i++]] +
hexMap[source[i++]] + spacer +
hexMap[source[i++]] + hexMap[source[i++]] +
hexMap[source[i++]] + hexMap[source[i++]] +
hexMap[source[i++]] + spacer +
hexMap[source[i++]]); hexMap[source[i++]] +
hexMap[source[i++]] +
hexMap[source[i++]] +
hexMap[source[i++]] +
hexMap[source[i++]] +
hexMap[source[i++]]
);
} }
self.hexToBuffer = hexToBuffer; self.hexToBuffer = hexToBuffer;
@ -87,8 +99,10 @@ goog.scope(function() {
// for base16 (hex), base32, or base64 encoding in a standards // for base16 (hex), base32, or base64 encoding in a standards
// compliant manner. // compliant manner.
function getBaseCodec (ALPHABET) { function getBaseCodec(ALPHABET) {
if (ALPHABET.length >= 255) { throw new TypeError("Alphabet too long"); } if (ALPHABET.length >= 255) {
throw new TypeError("Alphabet too long");
}
let BASE_MAP = new Uint8Array(256); let BASE_MAP = new Uint8Array(256);
for (let j = 0; j < BASE_MAP.length; j++) { for (let j = 0; j < BASE_MAP.length; j++) {
BASE_MAP[j] = 255; BASE_MAP[j] = 255;
@ -96,22 +110,32 @@ goog.scope(function() {
for (let i = 0; i < ALPHABET.length; i++) { for (let i = 0; i < ALPHABET.length; i++) {
let x = ALPHABET.charAt(i); let x = ALPHABET.charAt(i);
let xc = x.charCodeAt(0); let xc = x.charCodeAt(0);
if (BASE_MAP[xc] !== 255) { throw new TypeError(x + " is ambiguous"); } if (BASE_MAP[xc] !== 255) {
throw new TypeError(x + " is ambiguous");
}
BASE_MAP[xc] = i; BASE_MAP[xc] = i;
} }
let BASE = ALPHABET.length; let BASE = ALPHABET.length;
let LEADER = ALPHABET.charAt(0); let LEADER = ALPHABET.charAt(0);
let FACTOR = Math.log(BASE) / Math.log(256); // log(BASE) / log(256), rounded up let FACTOR = Math.log(BASE) / Math.log(256); // log(BASE) / log(256), rounded up
let iFACTOR = Math.log(256) / Math.log(BASE); // log(256) / log(BASE), rounded up let iFACTOR = Math.log(256) / Math.log(BASE); // log(256) / log(BASE), rounded up
function encode (source) { function encode(source) {
if (source instanceof Uint8Array) { if (source instanceof Uint8Array) {
} else if (ArrayBuffer.isView(source)) { } else if (ArrayBuffer.isView(source)) {
source = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); source = new Uint8Array(
source.buffer,
source.byteOffset,
source.byteLength,
);
} else if (Array.isArray(source)) { } else if (Array.isArray(source)) {
source = Uint8Array.from(source); source = Uint8Array.from(source);
} }
if (!(source instanceof Uint8Array)) { throw new TypeError("Expected Uint8Array"); } if (!(source instanceof Uint8Array)) {
if (source.length === 0) { return ""; } throw new TypeError("Expected Uint8Array");
}
if (source.length === 0) {
return "";
}
// Skip & count leading zeroes. // Skip & count leading zeroes.
let zeroes = 0; let zeroes = 0;
let length = 0; let length = 0;
@ -129,12 +153,18 @@ goog.scope(function() {
let carry = source[pbegin]; let carry = source[pbegin];
// Apply "b58 = b58 * 256 + ch". // Apply "b58 = b58 * 256 + ch".
let i = 0; let i = 0;
for (let it1 = size - 1; (carry !== 0 || i < length) && (it1 !== -1); it1--, i++) { for (
let it1 = size - 1;
(carry !== 0 || i < length) && it1 !== -1;
it1--, i++
) {
carry += (256 * b58[it1]) >>> 0; carry += (256 * b58[it1]) >>> 0;
b58[it1] = (carry % BASE) >>> 0; b58[it1] = carry % BASE >>> 0;
carry = (carry / BASE) >>> 0; carry = (carry / BASE) >>> 0;
} }
if (carry !== 0) { throw new Error("Non-zero carry"); } if (carry !== 0) {
throw new Error("Non-zero carry");
}
length = i; length = i;
pbegin++; pbegin++;
} }
@ -145,13 +175,19 @@ goog.scope(function() {
} }
// Translate the result into a string. // Translate the result into a string.
let str = LEADER.repeat(zeroes); let str = LEADER.repeat(zeroes);
for (; it2 < size; ++it2) { str += ALPHABET.charAt(b58[it2]); } for (; it2 < size; ++it2) {
str += ALPHABET.charAt(b58[it2]);
}
return str; return str;
} }
function decodeUnsafe (source) { function decodeUnsafe(source) {
if (typeof source !== "string") { throw new TypeError("Expected String"); } if (typeof source !== "string") {
if (source.length === 0) { return new Uint8Array(); } throw new TypeError("Expected String");
}
if (source.length === 0) {
return new Uint8Array();
}
let psz = 0; let psz = 0;
// Skip and count leading '1's. // Skip and count leading '1's.
let zeroes = 0; let zeroes = 0;
@ -161,21 +197,29 @@ goog.scope(function() {
psz++; psz++;
} }
// Allocate enough space in big-endian base256 representation. // Allocate enough space in big-endian base256 representation.
let size = (((source.length - psz) * FACTOR) + 1) >>> 0; // log(58) / log(256), rounded up. let size = ((source.length - psz) * FACTOR + 1) >>> 0; // log(58) / log(256), rounded up.
let b256 = new Uint8Array(size); let b256 = new Uint8Array(size);
// Process the characters. // Process the characters.
while (source[psz]) { while (source[psz]) {
// Decode character // Decode character
let carry = BASE_MAP[source.charCodeAt(psz)]; let carry = BASE_MAP[source.charCodeAt(psz)];
// Invalid character // Invalid character
if (carry === 255) { return; } if (carry === 255) {
return;
}
let i = 0; let i = 0;
for (let it3 = size - 1; (carry !== 0 || i < length) && (it3 !== -1); it3--, i++) { for (
let it3 = size - 1;
(carry !== 0 || i < length) && it3 !== -1;
it3--, i++
) {
carry += (BASE * b256[it3]) >>> 0; carry += (BASE * b256[it3]) >>> 0;
b256[it3] = (carry % 256) >>> 0; b256[it3] = carry % 256 >>> 0;
carry = (carry / 256) >>> 0; carry = (carry / 256) >>> 0;
} }
if (carry !== 0) { throw new Error("Non-zero carry"); } if (carry !== 0) {
throw new Error("Non-zero carry");
}
length = i; length = i;
psz++; psz++;
} }
@ -192,20 +236,22 @@ goog.scope(function() {
return vch; return vch;
} }
function decode (string) { function decode(string) {
let buffer = decodeUnsafe(string); let buffer = decodeUnsafe(string);
if (buffer) { return buffer; } if (buffer) {
return buffer;
}
throw new Error("Non-base" + BASE + " character"); throw new Error("Non-base" + BASE + " character");
} }
return { return {
encode: encode, encode: encode,
decodeUnsafe: decodeUnsafe, decodeUnsafe: decodeUnsafe,
decode: decode decode: decode,
}; };
} }
// MORE bases here: https://github.com/cryptocoinjs/base-x/tree/master // MORE bases here: https://github.com/cryptocoinjs/base-x/tree/master
const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const BASE62 =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
self.bufferToBase62 = getBaseCodec(BASE62).encode; self.bufferToBase62 = getBaseCodec(BASE62).encode;
}); });

View File

@ -14,7 +14,7 @@
goog.provide("app.common.svg.path.arc_to_bezier"); goog.provide("app.common.svg.path.arc_to_bezier");
// https://raw.githubusercontent.com/fontello/svgpath/master/lib/a2c.js // https://raw.githubusercontent.com/fontello/svgpath/master/lib/a2c.js
goog.scope(function() { goog.scope(function () {
const self = app.common.svg.path.arc_to_bezier; const self = app.common.svg.path.arc_to_bezier;
var TAU = Math.PI * 2; var TAU = Math.PI * 2;
@ -27,20 +27,23 @@ goog.scope(function() {
// we can use simplified math (without length normalization) // we can use simplified math (without length normalization)
// //
function unit_vector_angle(ux, uy, vx, vy) { function unit_vector_angle(ux, uy, vx, vy) {
var sign = (ux * vy - uy * vx < 0) ? -1 : 1; var sign = ux * vy - uy * vx < 0 ? -1 : 1;
var dot = ux * vx + uy * vy; var dot = ux * vx + uy * vy;
// Add this to work with arbitrary vectors: // Add this to work with arbitrary vectors:
// dot /= Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy); // dot /= Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy);
// rounding errors, e.g. -1.0000000000000002 can screw up this // rounding errors, e.g. -1.0000000000000002 can screw up this
if (dot > 1.0) { dot = 1.0; } if (dot > 1.0) {
if (dot < -1.0) { dot = -1.0; } dot = 1.0;
}
if (dot < -1.0) {
dot = -1.0;
}
return sign * Math.acos(dot); return sign * Math.acos(dot);
} }
// Convert from endpoint to center parameterization, // Convert from endpoint to center parameterization,
// see http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes // see http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes
// //
@ -53,11 +56,11 @@ goog.scope(function() {
// points. After that, rotate it to line up ellipse axes with coordinate // points. After that, rotate it to line up ellipse axes with coordinate
// axes. // axes.
// //
var x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2; var x1p = (cos_phi * (x1 - x2)) / 2 + (sin_phi * (y1 - y2)) / 2;
var y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2; var y1p = (-sin_phi * (x1 - x2)) / 2 + (cos_phi * (y1 - y2)) / 2;
var rx_sq = rx * rx; var rx_sq = rx * rx;
var ry_sq = ry * ry; var ry_sq = ry * ry;
var x1p_sq = x1p * x1p; var x1p_sq = x1p * x1p;
var y1p_sq = y1p * y1p; var y1p_sq = y1p * y1p;
@ -66,33 +69,33 @@ goog.scope(function() {
// Compute coordinates of the centre of this ellipse (cx', cy') // Compute coordinates of the centre of this ellipse (cx', cy')
// in the new coordinate system. // in the new coordinate system.
// //
var radicant = (rx_sq * ry_sq) - (rx_sq * y1p_sq) - (ry_sq * x1p_sq); var radicant = rx_sq * ry_sq - rx_sq * y1p_sq - ry_sq * x1p_sq;
if (radicant < 0) { if (radicant < 0) {
// due to rounding errors it might be e.g. -1.3877787807814457e-17 // due to rounding errors it might be e.g. -1.3877787807814457e-17
radicant = 0; radicant = 0;
} }
radicant /= (rx_sq * y1p_sq) + (ry_sq * x1p_sq); radicant /= rx_sq * y1p_sq + ry_sq * x1p_sq;
radicant = Math.sqrt(radicant) * (fa === fs ? -1 : 1); radicant = Math.sqrt(radicant) * (fa === fs ? -1 : 1);
var cxp = radicant * rx/ry * y1p; var cxp = ((radicant * rx) / ry) * y1p;
var cyp = radicant * -ry/rx * x1p; var cyp = ((radicant * -ry) / rx) * x1p;
// Step 3. // Step 3.
// //
// Transform back to get centre coordinates (cx, cy) in the original // Transform back to get centre coordinates (cx, cy) in the original
// coordinate system. // coordinate system.
// //
var cx = cos_phi*cxp - sin_phi*cyp + (x1+x2)/2; var cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2;
var cy = sin_phi*cxp + cos_phi*cyp + (y1+y2)/2; var cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2;
// Step 4. // Step 4.
// //
// Compute angles (theta1, delta_theta). // Compute angles (theta1, delta_theta).
// //
var v1x = (x1p - cxp) / rx; var v1x = (x1p - cxp) / rx;
var v1y = (y1p - cyp) / ry; var v1y = (y1p - cyp) / ry;
var v2x = (-x1p - cxp) / rx; var v2x = (-x1p - cxp) / rx;
var v2y = (-y1p - cyp) / ry; var v2y = (-y1p - cyp) / ry;
@ -106,7 +109,7 @@ goog.scope(function() {
delta_theta += TAU; delta_theta += TAU;
} }
return [ cx, cy, theta1, delta_theta ]; return [cx, cy, theta1, delta_theta];
} }
// //
@ -114,24 +117,33 @@ goog.scope(function() {
// see http://math.stackexchange.com/questions/873224 // see http://math.stackexchange.com/questions/873224
// //
function approximate_unit_arc(theta1, delta_theta) { function approximate_unit_arc(theta1, delta_theta) {
var alpha = 4/3 * Math.tan(delta_theta/4); var alpha = (4 / 3) * Math.tan(delta_theta / 4);
var x1 = Math.cos(theta1); var x1 = Math.cos(theta1);
var y1 = Math.sin(theta1); var y1 = Math.sin(theta1);
var x2 = Math.cos(theta1 + delta_theta); var x2 = Math.cos(theta1 + delta_theta);
var y2 = Math.sin(theta1 + delta_theta); var y2 = Math.sin(theta1 + delta_theta);
return [ x1, y1, x1 - y1*alpha, y1 + x1*alpha, x2 + y2*alpha, y2 - x2*alpha, x2, y2 ]; return [
x1,
y1,
x1 - y1 * alpha,
y1 + x1 * alpha,
x2 + y2 * alpha,
y2 - x2 * alpha,
x2,
y2,
];
} }
function calculate_beziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { function calculate_beziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) {
var sin_phi = Math.sin(phi * TAU / 360); var sin_phi = Math.sin((phi * TAU) / 360);
var cos_phi = Math.cos(phi * TAU / 360); var cos_phi = Math.cos((phi * TAU) / 360);
// Make sure radii are valid // Make sure radii are valid
// //
var x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2; var x1p = (cos_phi * (x1 - x2)) / 2 + (sin_phi * (y1 - y2)) / 2;
var y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2; var y1p = (-sin_phi * (x1 - x2)) / 2 + (cos_phi * (y1 - y2)) / 2;
// console.log("L", x1p, y1p) // console.log("L", x1p, y1p)
@ -145,7 +157,6 @@ goog.scope(function() {
return []; return [];
} }
// Compensate out-of-range radii // Compensate out-of-range radii
// //
rx = Math.abs(rx); rx = Math.abs(rx);
@ -157,25 +168,20 @@ goog.scope(function() {
ry *= Math.sqrt(lambda); ry *= Math.sqrt(lambda);
} }
// Get center parameters (cx, cy, theta1, delta_theta) // Get center parameters (cx, cy, theta1, delta_theta)
// //
var cc = get_arc_center(x1, y1, x2, y2, fa, fs, rx, ry, sin_phi, cos_phi); var cc = get_arc_center(x1, y1, x2, y2, fa, fs, rx, ry, sin_phi, cos_phi);
var result = []; var result = [];
var theta1 = cc[2]; var theta1 = cc[2];
var delta_theta = cc[3]; var delta_theta = cc[3];
// Split an arc to multiple segments, so each segment // Split an arc to multiple segments, so each segment
// will be less than τ/4 (= 90°) // will be less than τ/4 (= 90°)
// //
var segments = Math.max(Math.ceil(Math.abs(delta_theta) / (TAU / 4)), 1); var segments = Math.max(Math.ceil(Math.abs(delta_theta) / (TAU / 4)), 1);
delta_theta /= segments; delta_theta /= segments;
for (var i = 0; i < segments; i++) { for (var i = 0; i < segments; i++) {
var item = approximate_unit_arc(theta1, delta_theta); var item = approximate_unit_arc(theta1, delta_theta);
result.push(item); result.push(item);
@ -195,8 +201,8 @@ goog.scope(function() {
y *= ry; y *= ry;
// rotate // rotate
var xp = cos_phi*x - sin_phi*y; var xp = cos_phi * x - sin_phi * y;
var yp = sin_phi*x + cos_phi*y; var yp = sin_phi * x + cos_phi * y;
// translate // translate
curve[i + 0] = xp + cc[0]; curve[i + 0] = xp + cc[0];

File diff suppressed because it is too large Load Diff

View File

@ -234,7 +234,7 @@
(dfn-format v "p") (dfn-format v "p")
:localized-date-time :localized-date-time
(dfn-format v "PPPp") (dfn-format v "PPP . p")
(if (string? fmt) (if (string? fmt)
(dfn-format v fmt) (dfn-format v fmt)

View File

@ -10,16 +10,18 @@
goog.require("app.common.encoding_impl"); goog.require("app.common.encoding_impl");
goog.provide("app.common.uuid_impl"); goog.provide("app.common.uuid_impl");
goog.scope(function() { goog.scope(function () {
const global = goog.global; const global = goog.global;
const encoding = app.common.encoding_impl; const encoding = app.common.encoding_impl;
const self = app.common.uuid_impl; const self = app.common.uuid_impl;
const timeRef = 1640995200000; // ms since 2022-01-01T00:00:00 const timeRef = 1640995200000; // ms since 2022-01-01T00:00:00
const fill = (() => { const fill = (() => {
if (typeof global.crypto !== "undefined" && if (
typeof global.crypto.getRandomValues !== "undefined") { typeof global.crypto !== "undefined" &&
typeof global.crypto.getRandomValues !== "undefined"
) {
return (buf) => { return (buf) => {
global.crypto.getRandomValues(buf); global.crypto.getRandomValues(buf);
return buf; return buf;
@ -30,7 +32,7 @@ goog.scope(function() {
return (buf) => { return (buf) => {
const bytes = randomBytes(buf.length); const bytes = randomBytes(buf.length);
buf.set(bytes) buf.set(bytes);
return buf; return buf;
}; };
} else { } else {
@ -39,8 +41,10 @@ goog.scope(function() {
return (buf) => { return (buf) => {
for (let i = 0, r; i < buf.length; i++) { for (let i = 0, r; i < buf.length; i++) {
if ((i & 0x03) === 0) { r = Math.random() * 0x100000000; } if ((i & 0x03) === 0) {
buf[i] = r >>> ((i & 0x03) << 3) & 0xff; r = Math.random() * 0x100000000;
}
buf[i] = (r >>> ((i & 0x03) << 3)) & 0xff;
} }
return buf; return buf;
}; };
@ -50,31 +54,38 @@ goog.scope(function() {
function toHexString(buf) { function toHexString(buf) {
const hexMap = encoding.hexMap; const hexMap = encoding.hexMap;
let i = 0; let i = 0;
return (hexMap[buf[i++]] + return (
hexMap[buf[i++]] + hexMap[buf[i++]] +
hexMap[buf[i++]] + hexMap[buf[i++]] +
hexMap[buf[i++]] + '-' + hexMap[buf[i++]] +
hexMap[buf[i++]] + hexMap[buf[i++]] +
hexMap[buf[i++]] + '-' + "-" +
hexMap[buf[i++]] + hexMap[buf[i++]] +
hexMap[buf[i++]] + '-' + hexMap[buf[i++]] +
hexMap[buf[i++]] + "-" +
hexMap[buf[i++]] + '-' + hexMap[buf[i++]] +
hexMap[buf[i++]] + hexMap[buf[i++]] +
hexMap[buf[i++]] + "-" +
hexMap[buf[i++]] + hexMap[buf[i++]] +
hexMap[buf[i++]] + hexMap[buf[i++]] +
hexMap[buf[i++]] + "-" +
hexMap[buf[i++]]); hexMap[buf[i++]] +
}; hexMap[buf[i++]] +
hexMap[buf[i++]] +
hexMap[buf[i++]] +
hexMap[buf[i++]] +
hexMap[buf[i++]]
);
}
function getBigUint64(view, byteOffset, le) { function getBigUint64(view, byteOffset, le) {
const a = view.getUint32(byteOffset, le); const a = view.getUint32(byteOffset, le);
const b = view.getUint32(byteOffset + 4, le); const b = view.getUint32(byteOffset + 4, le);
const leMask = Number(!!le); const leMask = Number(!!le);
const beMask = Number(!le); const beMask = Number(!le);
return ((BigInt(a * beMask + b * leMask) << 32n) | return (
(BigInt(a * leMask + b * beMask))); (BigInt(a * beMask + b * leMask) << 32n) | BigInt(a * leMask + b * beMask)
);
} }
function setBigUint64(view, byteOffset, value, le) { function setBigUint64(view, byteOffset, value, le) {
@ -83,8 +94,7 @@ goog.scope(function() {
if (le) { if (le) {
view.setUint32(byteOffset + 4, hi, le); view.setUint32(byteOffset + 4, hi, le);
view.setUint32(byteOffset, lo, le); view.setUint32(byteOffset, lo, le);
} } else {
else {
view.setUint32(byteOffset, hi, le); view.setUint32(byteOffset, hi, le);
view.setUint32(byteOffset + 4, lo, le); view.setUint32(byteOffset + 4, lo, le);
} }
@ -104,17 +114,18 @@ goog.scope(function() {
} }
self.shortID = (function () { self.shortID = (function () {
const buff = new ArrayBuffer(8); const buff = new ArrayBuffer(8);
const int8 = new Uint8Array(buff); const int8 = new Uint8Array(buff);
const view = new DataView(buff); const view = new DataView(buff);
const base = 0x0000_0000_0000_0000n; const base = 0x0000_0000_0000_0000n;
return function shortID(ts) { return function shortID(ts) {
const tss = currentTimestamp(timeRef); const tss = currentTimestamp(timeRef);
const msb = (base const msb =
| (nextLong() & 0xffff_ffff_0000_0000n) base |
| (tss & 0x0000_0000_ffff_ffffn)); (nextLong() & 0xffff_ffff_0000_0000n) |
(tss & 0x0000_0000_ffff_ffffn);
setBigUint64(view, 0, msb, false); setBigUint64(view, 0, msb, false);
return encoding.toBase62(int8); return encoding.toBase62(int8);
}; };
@ -139,9 +150,9 @@ goog.scope(function() {
const maxCs = 0x0000_0000_0000_3fffn; // 14 bits space const maxCs = 0x0000_0000_0000_3fffn; // 14 bits space
let countCs = 0n; let countCs = 0n;
let lastRd = 0n; let lastRd = 0n;
let lastCs = 0n; let lastCs = 0n;
let lastTs = 0n; let lastTs = 0n;
let baseMsb = 0x0000_0000_0000_8000n; let baseMsb = 0x0000_0000_0000_8000n;
let baseLsb = 0x8000_0000_0000_0000n; let baseLsb = 0x8000_0000_0000_0000n;
@ -149,12 +160,9 @@ goog.scope(function() {
lastCs = nextLong() & maxCs; lastCs = nextLong() & maxCs;
const create = function create(ts, lastRd, lastCs) { const create = function create(ts, lastRd, lastCs) {
const msb = (baseMsb const msb = baseMsb | (lastRd & 0xffff_ffff_ffff_0fffn);
| (lastRd & 0xffff_ffff_ffff_0fffn));
const lsb = (baseLsb const lsb = baseLsb | ((ts << 14n) & 0x3fff_ffff_ffff_c000n) | lastCs;
| ((ts << 14n) & 0x3fff_ffff_ffff_c000n)
| lastCs);
setBigUint64(view, 0, msb, false); setBigUint64(view, 0, msb, false);
setBigUint64(view, 8, lsb, false); setBigUint64(view, 8, lsb, false);
@ -167,10 +175,10 @@ goog.scope(function() {
let ts = currentTimestamp(timeRef); let ts = currentTimestamp(timeRef);
// Protect from clock regression // Protect from clock regression
if ((ts - lastTs) < 0) { if (ts - lastTs < 0) {
lastRd = (lastRd lastRd =
& 0x0000_0000_0000_0f00n (lastRd & 0x0000_0000_0000_0f00n) |
| (nextLong() & 0xffff_ffff_ffff_f0ffn)); (nextLong() & 0xffff_ffff_ffff_f0ffn);
countCs = 0n; countCs = 0n;
continue; continue;
} }
@ -209,63 +217,63 @@ goog.scope(function() {
// Parse ........-....-....-####-............ // Parse ........-....-....-####-............
int8[8] = (rest = parseInt(uuid.slice(19, 23), 16)) >>> 8; int8[8] = (rest = parseInt(uuid.slice(19, 23), 16)) >>> 8;
int8[9] = rest & 0xff, (int8[9] = rest & 0xff),
// Parse ........-....-....-....-############
// Parse ........-....-....-....-############ // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes)
// (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) (int8[10] =
int8[10] = ((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff; ((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff);
int8[11] = (rest / 0x100000000) & 0xff; int8[11] = (rest / 0x100000000) & 0xff;
int8[12] = (rest >>> 24) & 0xff; int8[12] = (rest >>> 24) & 0xff;
int8[13] = (rest >>> 16) & 0xff; int8[13] = (rest >>> 16) & 0xff;
int8[14] = (rest >>> 8) & 0xff; int8[14] = (rest >>> 8) & 0xff;
int8[15] = rest & 0xff; int8[15] = rest & 0xff;
} };
const fromPair = (hi, lo) => { const fromPair = (hi, lo) => {
view.setBigInt64(0, hi); view.setBigInt64(0, hi);
view.setBigInt64(8, lo); view.setBigInt64(8, lo);
return encoding.bufferToHex(int8, true); return encoding.bufferToHex(int8, true);
} };
const getHi = (uuid) => { const getHi = (uuid) => {
fillBytes(uuid); fillBytes(uuid);
return view.getBigInt64(0); return view.getBigInt64(0);
} };
const getLo = (uuid) => { const getLo = (uuid) => {
fillBytes(uuid); fillBytes(uuid);
return view.getBigInt64(8); return view.getBigInt64(8);
} };
const getBytes = (uuid) => { const getBytes = (uuid) => {
fillBytes(uuid); fillBytes(uuid);
return Int8Array.from(int8); return Int8Array.from(int8);
} };
const getUnsignedParts = (uuid) => { const getUnsignedParts = (uuid) => {
fillBytes(uuid); fillBytes(uuid);
const result = new Uint32Array(4); const result = new Uint32Array(4);
result[0] = view.getUint32(0) result[0] = view.getUint32(0);
result[1] = view.getUint32(4); result[1] = view.getUint32(4);
result[2] = view.getUint32(8); result[2] = view.getUint32(8);
result[3] = view.getUint32(12); result[3] = view.getUint32(12);
return result; return result;
} };
const fromUnsignedParts = (a, b, c, d) => { const fromUnsignedParts = (a, b, c, d) => {
view.setUint32(0, a) view.setUint32(0, a);
view.setUint32(4, b) view.setUint32(4, b);
view.setUint32(8, c) view.setUint32(8, c);
view.setUint32(12, d) view.setUint32(12, d);
return encoding.bufferToHex(int8, true); return encoding.bufferToHex(int8, true);
} };
const fromArray = (u8data) => { const fromArray = (u8data) => {
int8.set(u8data); int8.set(u8data);
return encoding.bufferToHex(int8, true); return encoding.bufferToHex(int8, true);
} };
const setTag = (tag) => { const setTag = (tag) => {
tag = BigInt.asUintN(64, "" + tag); tag = BigInt.asUintN(64, "" + tag);
@ -273,9 +281,9 @@ goog.scope(function() {
throw new Error("illegal arguments: tag value should fit in 4bits"); throw new Error("illegal arguments: tag value should fit in 4bits");
} }
lastRd = (lastRd lastRd =
& 0xffff_ffff_ffff_f0ffn (lastRd & 0xffff_ffff_ffff_f0ffn) |
| ((tag << 8) & 0x0000_0000_0000_0f00n)); ((tag << 8) & 0x0000_0000_0000_0f00n);
}; };
factory.create = create; factory.create = create;
@ -290,9 +298,9 @@ goog.scope(function() {
return factory; return factory;
})(); })();
self.shortV8 = function(uuid) { self.shortV8 = function (uuid) {
const buff = encoding.hexToBuffer(uuid); const buff = encoding.hexToBuffer(uuid);
const short = new Uint8Array(buff, 4); const short = new Uint8Array(buff, 4);
return encoding.bufferToBase62(short); return encoding.bufferToBase62(short);
}; };
@ -307,7 +315,7 @@ goog.scope(function() {
return self.v8.fromPair(hi, lo); return self.v8.fromPair(hi, lo);
}; };
self.fromBytes = function(data) { self.fromBytes = function (data) {
if (data instanceof Uint8Array) { if (data instanceof Uint8Array) {
return self.v8.fromArray(data); return self.v8.fromArray(data);
} else if (data instanceof Int8Array) { } else if (data instanceof Int8Array) {
@ -325,15 +333,15 @@ goog.scope(function() {
return self.v8.getUnsignedParts(uuid); return self.v8.getUnsignedParts(uuid);
}; };
self.fromUnsignedParts = function(a,b,c,d) { self.fromUnsignedParts = function (a, b, c, d) {
return self.v8.fromUnsignedParts(a,b,c,d); return self.v8.fromUnsignedParts(a, b, c, d);
}; };
self.getHi = function (uuid) { self.getHi = function (uuid) {
return self.v8.getHi(uuid); return self.v8.getHi(uuid);
} };
self.getLo = function (uuid) { self.getLo = function (uuid) {
return self.v8.getLo(uuid); return self.v8.getLo(uuid);
} };
}); });

View File

@ -67,8 +67,11 @@ export class WeakEqMap {
} }
set(key, value) { set(key, value) {
if (key === null || (typeof key !== 'object' && typeof key !== 'function')) { if (
throw new TypeError('WeakEqMap keys must be objects (like WeakMap).'); key === null ||
(typeof key !== "object" && typeof key !== "function")
) {
throw new TypeError("WeakEqMap keys must be objects (like WeakMap).");
} }
const hash = this._hash(key); const hash = this._hash(key);
const bucket = this._getBucket(hash); const bucket = this._getBucket(hash);

View File

@ -1,4 +1,4 @@
#kaocha/v1 #kaocha/v1
{:tests [{:id :unit {:tests [{:id :unit
:test-paths ["test"]}] :test-paths ["test"]}]
:kaocha/reporter [kaocha.report/dots]} :kaocha/reporter [kaocha.report/dots]}

View File

@ -18,6 +18,7 @@ RUN set -ex; \
curl \ curl \
bash \ bash \
git \ git \
ripgrep \
\ \
curl \ curl \
ca-certificates \ ca-certificates \

View File

@ -34,7 +34,8 @@
"watch": "pnpm run watch:app", "watch": "pnpm run watch:app",
"build:app": "clojure -M:dev:shadow-cljs release main", "build:app": "clojure -M:dev:shadow-cljs release main",
"build": "pnpm run clear:shadow-cache && pnpm run build:app", "build": "pnpm run clear:shadow-cache && pnpm run build:app",
"fmt:clj": "cljfmt fix --parallel=true src/", "fmt": "cljfmt fix --parallel=true src/",
"lint:clj": "cljfmt check --parallel src/ && clj-kondo --parallel --lint src/" "check-fmt": "cljfmt check --parallel=true src/",
"lint": "clj-kondo --parallel --lint src/"
} }
} }

View File

@ -100,14 +100,12 @@
(def browser-pool-factory (def browser-pool-factory
(letfn [(create [] (letfn [(create []
(-> (p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]} (p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]}
browser (.launch pw/chromium opts) browser (.launch pw/chromium opts)
id (swap! pool-browser-id inc)] id (swap! pool-browser-id inc)]
(l/info :origin "factory" :action "create" :browser-id id) (l/info :origin "factory" :action "create" :browser-id id)
(unchecked-set browser "__id" id) (unchecked-set browser "__id" id)
browser) browser))
(p/catch (fn [cause]
(l/error :hint "Cannot launch the headless browser" :cause cause)))))
(destroy [obj] (destroy [obj]
(let [id (unchecked-get obj "__id")] (let [id (unchecked-get obj "__id")]

View File

@ -23,12 +23,15 @@
"build:app:main": "clojure -M:dev:shadow-cljs release main worker", "build:app:main": "clojure -M:dev:shadow-cljs release main worker",
"build:app:worker": "clojure -M:dev:shadow-cljs release worker", "build:app:worker": "clojure -M:dev:shadow-cljs release worker",
"build:app": "pnpm run clear:shadow-cache && pnpm run build:app:main && pnpm run build:app:libs", "build:app": "pnpm run clear:shadow-cache && pnpm run build:app:main && pnpm run build:app:libs",
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
"check-fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js",
"check-fmt:scss": "prettier -c resources/styles -c src/**/*.scss",
"fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w", "fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w",
"fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w", "fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w",
"lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", "lint:clj": "clj-kondo --parallel --lint ../common/src src/",
"lint:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js", "lint:js": "exit 0",
"lint:scss": "prettier -c resources/styles -c src/**/*.scss", "lint:scss": "exit 0",
"build:test": "clojure -M:dev:shadow-cljs compile test", "build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "pnpm run build:test && node target/tests/test.js", "test": "pnpm run build:test && node target/tests/test.js",
"test:storybook": "vitest run --project=storybook", "test:storybook": "vitest run --project=storybook",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,513 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u0b5bcbca-32ab-81eb-8005-a15fc4484678",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "New File 6",
"~:revn": 1,
"~:modified-at": "~m1773140377840",
"~:vern": 0,
"~:id": "~ueffcbebc-b8c8-802f-8007-b11dd34fe190",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node"
]
},
"~:version": 67,
"~:project-id": "~u0b5bcbca-32ab-81eb-8005-a15fc448f334",
"~:created-at": "~m1773140371775",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~ueffcbebc-b8c8-802f-8007-b11dd34fe191"
],
"~:pages-index": {
"~ueffcbebc-b8c8-802f-8007-b11dd34fe191": {
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0,
"~:y": 0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~ub952fb5e-cae5-8054-8007-b11dd63f79f9",
"~ub952fb5e-cae5-8054-8007-b11dd63f79fa",
"~ub952fb5e-cae5-8054-8007-b11dd63f79fb"
]
}
},
"~ub952fb5e-cae5-8054-8007-b11dd63f79f9": {
"~#shape": {
"~:y": 660.000001521671,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Rectangle",
"~:width": 99.9999986310249,
"~:type": "~:rect",
"~:points": [
{
"~#point": {
"~:x": 989,
"~:y": 660.000001521671
}
},
{
"~#point": {
"~:x": 1088.99999863103,
"~:y": 660.000001521671
}
},
{
"~#point": {
"~:x": 1088.99999863103,
"~:y": 760.000000795896
}
},
{
"~#point": {
"~:x": 989,
"~:y": 760.000000795896
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~ub952fb5e-cae5-8054-8007-b11dd63f79f9",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-alignment": "~:inner",
"~:stroke-style": "~:solid",
"~:stroke-color": "#000000",
"~:stroke-opacity": 1,
"~:stroke-width": 100
}
],
"~:x": 989,
"~:proportion": 1,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 989,
"~:y": 660.000001521671,
"~:width": 99.9999986310249,
"~:height": 99.9999992742251,
"~:x1": 989,
"~:y1": 660.000001521671,
"~:x2": 1088.99999863103,
"~:y2": 760.000000795896
}
},
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 99.9999992742251,
"~:flip-y": null
}
},
"~ub952fb5e-cae5-8054-8007-b11dd63f79fa": {
"~#shape": {
"~:y": 457.999994456768,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Ellipse",
"~:width": 299.99999499321,
"~:type": "~:circle",
"~:points": [
{
"~#point": {
"~:x": 1171.99998355202,
"~:y": 457.999994456768
}
},
{
"~#point": {
"~:x": 1471.99997854523,
"~:y": 457.999994456768
}
},
{
"~#point": {
"~:x": 1471.99997854523,
"~:y": 757.999989449978
}
},
{
"~#point": {
"~:x": 1171.99998355202,
"~:y": 757.999989449978
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:id": "~ub952fb5e-cae5-8054-8007-b11dd63f79fa",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-alignment": "~:inner",
"~:stroke-style": "~:solid",
"~:stroke-color": "#000000",
"~:stroke-opacity": 1,
"~:stroke-width": 400
}
],
"~:x": 1171.99998355202,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 1171.99998355202,
"~:y": 457.999994456768,
"~:width": 299.99999499321,
"~:height": 299.99999499321,
"~:x1": 1171.99998355202,
"~:y1": 457.999994456768,
"~:x2": 1471.99997854523,
"~:y2": 757.999989449978
}
},
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 299.99999499321,
"~:flip-y": null
}
},
"~ub952fb5e-cae5-8054-8007-b11dd63f79fb": {
"~#shape": {
"~:y": 444,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:hide-in-viewer": false,
"~:name": "Board",
"~:width": 100,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 989,
"~:y": 444
}
},
{
"~#point": {
"~:x": 1089,
"~:y": 444
}
},
{
"~#point": {
"~:x": 1089,
"~:y": 544
}
},
{
"~#point": {
"~:x": 989,
"~:y": 544
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~ub952fb5e-cae5-8054-8007-b11dd63f79fb",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-alignment": "~:inner",
"~:stroke-style": "~:solid",
"~:stroke-color": "#000000",
"~:stroke-opacity": 1,
"~:stroke-width": 200
}
],
"~:x": 989,
"~:proportion": 1,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 989,
"~:y": 444,
"~:width": 100,
"~:height": 100,
"~:x1": 989,
"~:y1": 444,
"~:x2": 1089,
"~:y2": 544
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 100,
"~:flip-y": null,
"~:shapes": []
}
}
},
"~:id": "~ueffcbebc-b8c8-802f-8007-b11dd34fe191",
"~:name": "Page 1"
}
},
"~:id": "~ueffcbebc-b8c8-802f-8007-b11dd34fe190",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@ -0,0 +1,737 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u0b5bcbca-32ab-81eb-8005-a15fc4484678",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "holadios",
"~:revn": 54,
"~:modified-at": "~m1773136426990",
"~:vern": 0,
"~:id": "~ueffcbebc-b8c8-802f-8007-b0ebecd7ebf4",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0004-clean-shadow-color",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0016-copy-fills-from-position-data-to-text-node",
"0015-clean-shadow-color"
]
},
"~:version": 67,
"~:project-id": "~u0b5bcbca-32ab-81eb-8005-a15fc448f334",
"~:created-at": "~m1773127290716",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~u3e9e17c3-fc57-80ce-8007-101743996fe9"
],
"~:pages-index": {
"~u3e9e17c3-fc57-80ce-8007-101743996fe9": {
"~:id": "~u3e9e17c3-fc57-80ce-8007-101743996fe9",
"~:name": "Page 1",
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0,
"~:y": 0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:page-id": "~u3e9e17c3-fc57-80ce-8007-101743996fe9",
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~u7d004cdb-8305-806a-8007-b0f01ee65230",
"~u2ae0abdc-99ff-8009-8007-b0f7f123b5ea",
"~u2ae0abdc-99ff-8009-8007-b0f7f45177dc"
]
}
},
"~u7d004cdb-8305-806a-8007-b0f01ee65230": {
"~#shape": {
"~:y": -161.000001410182,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "6hv3a5x8wb",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "sourcesanspro",
"~:key": "219sqyfv11",
"~:font-size": "250",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "HELLO WORLD"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:key": "21rct71nkal",
"~:font-size": "250",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "HELLO WORLD",
"~:width": 1529.00000393592,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 109.999998223851,
"~:y": -161.000001410182
}
},
{
"~#point": {
"~:x": 1639.00000215977,
"~:y": -161.000001410182
}
},
{
"~#point": {
"~:x": 1639.00000215977,
"~:y": 138.999988970841
}
},
{
"~#point": {
"~:x": 109.999998223851,
"~:y": 138.999988970841
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:id": "~u7d004cdb-8305-806a-8007-b0f01ee65230",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:position-data": [
{
"~:y": 150.869995117188,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "250",
"~:font-weight": "400",
"~:text-direction": "ltr",
"~:width": 1528.56005859375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:x": 110,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 323.739990234375,
"~:text": "HELLO WORLD"
}
],
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-style": "~:solid",
"~:stroke-alignment": "~:center",
"~:stroke-width": 50,
"~:stroke-color": "#43e50b",
"~:stroke-opacity": 0.6
}
],
"~:x": 109.999998223851,
"~:selrect": {
"~#rect": {
"~:x": 109.999998223851,
"~:y": -161.000001410182,
"~:width": 1529.00000393592,
"~:height": 299.999990381022,
"~:x1": 109.999998223851,
"~:y1": -161.000001410182,
"~:x2": 1639.00000215977,
"~:y2": 138.999988970841
}
},
"~:flip-x": null,
"~:height": 299.999990381022,
"~:flip-y": null
}
},
"~u2ae0abdc-99ff-8009-8007-b0f7f123b5ea": {
"~#shape": {
"~:y": -462.000004970439,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "6hv3a5x8wb",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:text-transform": "none",
"~:font-id": "sourcesanspro",
"~:key": "219sqyfv11",
"~:font-size": "250",
"~:font-weight": "400",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "HELLO WORLD"
}
],
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:key": "21rct71nkal",
"~:font-size": "250",
"~:font-weight": "400",
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "HELLO WORLD",
"~:width": 1529.00000393592,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 92.9999982852505,
"~:y": -462.000004970439
}
},
{
"~#point": {
"~:x": 1622.00000222117,
"~:y": -462.000004970439
}
},
{
"~#point": {
"~:x": 1622.00000222117,
"~:y": -162.000040815452
}
},
{
"~#point": {
"~:x": 92.9999982852505,
"~:y": -162.000040815452
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:id": "~u2ae0abdc-99ff-8009-8007-b0f7f123b5ea",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:position-data": [
{
"~:y": -150.130004882813,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "250",
"~:font-weight": "400",
"~:text-direction": "ltr",
"~:width": 1528.56005859375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:x": 93,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 323.739990234375,
"~:text": "HELLO WORLD"
}
],
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-style": "~:solid",
"~:stroke-alignment": "~:inner",
"~:stroke-width": 50,
"~:stroke-color": "#43e50b",
"~:stroke-opacity": 0.6
}
],
"~:x": 92.9999982852505,
"~:selrect": {
"~#rect": {
"~:x": 92.9999982852505,
"~:y": -462.000004970439,
"~:width": 1529.00000393592,
"~:height": 299.999964154987,
"~:x1": 92.9999982852505,
"~:y1": -462.000004970439,
"~:x2": 1622.00000222117,
"~:y2": -162.000040815452
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": 299.999964154987,
"~:flip-y": null
}
},
"~u2ae0abdc-99ff-8009-8007-b0f7f45177dc": {
"~#shape": {
"~:y": 169.999996321908,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "6hv3a5x8wb",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:text-transform": "none",
"~:font-id": "sourcesanspro",
"~:key": "219sqyfv11",
"~:font-size": "250",
"~:font-weight": "400",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "HELLO WORLD"
}
],
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:key": "21rct71nkal",
"~:font-size": "250",
"~:font-weight": "400",
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "HELLO WORLD",
"~:width": 1529.00000393592,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 92.9999984013972,
"~:y": 169.999996321908
}
},
{
"~#point": {
"~:x": 1622.00000233732,
"~:y": 169.999996321908
}
},
{
"~#point": {
"~:x": 1622.00000233732,
"~:y": 470.000003392238
}
},
{
"~#point": {
"~:x": 92.9999984013972,
"~:y": 470.000003392238
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:id": "~u2ae0abdc-99ff-8009-8007-b0f7f45177dc",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:position-data": [
{
"~:y": 481.869995117188,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "250",
"~:font-weight": "400",
"~:text-direction": "ltr",
"~:width": 1528.56005859375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:x": 93,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 323.739990234375,
"~:text": "HELLO WORLD"
}
],
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-style": "~:solid",
"~:stroke-alignment": "~:outer",
"~:stroke-width": 50,
"~:stroke-color": "#43e50b",
"~:stroke-opacity": 0.6
}
],
"~:x": 92.9999984013973,
"~:selrect": {
"~#rect": {
"~:x": 92.9999984013973,
"~:y": 169.999996321908,
"~:width": 1529.00000393592,
"~:height": 300.00000707033,
"~:x1": 92.9999984013973,
"~:y1": 169.999996321908,
"~:x2": 1622.00000233732,
"~:y2": 470.000003392238
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": 300.00000707033,
"~:flip-y": null
}
}
}
}
},
"~:id": "~ueffcbebc-b8c8-802f-8007-b0ebecd7ebf4",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@ -456,3 +456,38 @@ test("Check inner stroke artifacts", async ({
threshold: 0.1, threshold: 0.1,
}); });
}); });
test("BUG 13551 - Blurs affecting other elements", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-blurs-affecting-other-elements.json");
await workspace.goToWorkspace({
id: "effcbebc-b8c8-802f-8007-a7dc677169cd",
pageId: "a5508528-5928-8008-8007-a7de9feef61bd",
});
await workspace.waitForFirstRenderWithoutUI();
// Stricter comparison: blur is very subtle
await expect(workspace.canvas).toHaveScreenshot({
maxDiffPixelRatio: 0,
threshold: 0.1,
});
});
test("BUG 13610 - Huge inner strokes", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-huge-inner-strokes.json");
await workspace.goToWorkspace({
id: "effcbebc-b8c8-802f-8007-b11dd34fe190",
pageId: "effcbebc-b8c8-802f-8007-b11dd34fe191",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -587,3 +587,23 @@ test.skip("Updates text alignment edition - part 3", async ({ page }) => {
await expect(workspace.canvas).toHaveScreenshot({ timeout: 10000 }); await expect(workspace.canvas).toHaveScreenshot({ timeout: 10000 });
}); });
test("Renders a file with group with strokes and not 100% opacities", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-strokes-and-not-100-percent-opacities.json");
await workspace.goToWorkspace({
id: "effcbebc-b8c8-802f-8007-b0ebecd7ebf4",
pageId: "3e9e17c3-fc57-80ce-8007-101743996fe9",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot({
maxDiffPixelRatio: 0,
threshold: 0.01,
});
});

View File

@ -16,6 +16,29 @@ test.skip("BUG 10867 - Crash when loading comments", async ({ page }) => {
).toBeVisible(); ).toBeVisible();
}); });
test("BUG 13541 - Shows error page when WebGL context is lost", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
await workspacePage.waitForFirstRender();
// Simulate a WebGL context loss by dispatching the event on the canvas
await workspacePage.canvas.evaluate((canvas) => {
const event = new Event("webglcontextlost", { cancelable: true });
canvas.dispatchEvent(event);
});
await expect(
page.getByText("Oops! The canvas context was lost"),
).toBeVisible();
await expect(
page.getByText("WebGL has stopped working"),
).toBeVisible();
await expect(page.getByText("Reload page")).toBeVisible();
});
test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({ test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({
page, page,
}) => { }) => {

View File

@ -4,8 +4,6 @@ TARGET=${1:-app};
set -ex set -ex
rm -rf node_modules;
corepack enable; corepack enable;
corepack install; corepack install;
pnpm install; pnpm install;

View File

@ -127,6 +127,24 @@
(ex/print-throwable cause :prefix "Unexpected Error") (ex/print-throwable cause :prefix "Unexpected Error")
(flash :cause cause :type :unhandled)))) (flash :cause cause :type :unhandled))))
(defmethod ptk/handle-error :wasm-non-blocking
[error]
(when-let [cause (::instance error)]
(show-not-blocking-error cause)))
(defmethod ptk/handle-error :wasm-critical
[error]
(when-let [cause (::instance error)]
(ex/print-throwable cause :prefix "WASM critical error"))
(st/emit! (rt/assign-exception error)))
(defmethod ptk/handle-error :wasm-exception
[error]
(when-let [cause (::instance error)]
(let [prefix (or (:prefix error) "Exception")]
(ex/print-throwable cause :prefix prefix)))
(st/emit! (rt/assign-exception error)))
;; We receive a explicit authentication error; If the uri is for ;; We receive a explicit authentication error; If the uri is for
;; workspace, dashboard, viewer or settings, then assign the exception ;; workspace, dashboard, viewer or settings, then assign the exception
;; for show the error page. Otherwise this explicitly clears all ;; for show the error page. Otherwise this explicitly clears all
@ -338,6 +356,17 @@
(str/starts-with? message "invalid props on component") (str/starts-with? message "invalid props on component")
(str/starts-with? message "Unexpected token ")))) (str/starts-with? message "Unexpected token "))))
(handle-uncaught [cause]
(when cause
(set! last-exception cause)
(let [data (ex-data cause)
type (get data :type)]
(if (#{:wasm-critical :wasm-non-blocking :wasm-exception} type)
(on-error cause)
(when-not (is-ignorable-exception? cause)
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/schedule #(show-not-blocking-error cause)))))))
(on-unhandled-error [event] (on-unhandled-error [event]
(.preventDefault ^js event) (.preventDefault ^js event)
(when-let [cause (unchecked-get event "error")] (when-let [cause (unchecked-get event "error")]

View File

@ -114,4 +114,5 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
outline: $b-1 solid var(--tab-panel-outline-color); outline: $b-1 solid var(--tab-panel-outline-color);
overflow-y: auto;
} }

View File

@ -39,7 +39,6 @@
(mf/spread-props props (mf/spread-props props
{:class [class class'] {:class [class class']
:data-testid "milestone"}) :data-testid "milestone"})
open* open*
(mf/use-state false) (mf/use-state false)
@ -57,7 +56,13 @@
(dom/get-data "index") (dom/get-data "index")
(d/parse-integer))] (d/parse-integer))]
(when (fn? on-menu-click) (when (fn? on-menu-click)
(on-menu-click index event)))))] (on-menu-click index event)))))
snapshots
(mf/with-memo [snapshots]
(map-indexed (fn [index date]
(d/vec2 date index))
snapshots))]
[:> :div props [:> :div props
[:> text* {:as "span" :typography t/body-small :class (stl/css :name)} label] [:> text* {:as "span" :typography t/body-small :class (stl/css :name)} label]
@ -76,14 +81,14 @@
:icon-arrow-toggled open?)}]] :icon-arrow-toggled open?)}]]
(when ^boolean open? (when ^boolean open?
(for [[idx d] (d/enumerate snapshots)] (for [[date index] snapshots]
[:div {:key (dm/str "entry-" idx) [:div {:key (dm/str "entry-" index)
:class (stl/css :version-entry)} :class (stl/css :version-entry)}
[:> date* {:date d :class (stl/css :date) :typography t/body-small}] [:> date* {:date date :class (stl/css :date) :typography t/body-small}]
[:> icon-button* {:class (stl/css :entry-button) [:> icon-button* {:class (stl/css :entry-button)
:variant "ghost" :variant "ghost"
:icon i/menu :icon i/menu
:aria-label (tr "workspace.versions.version-menu") :aria-label (tr "workspace.versions.version-menu")
:data-index idx :data-index index
:on-click on-menu-click}]]))]])) :on-click on-menu-click}]]))]]))

View File

@ -6,10 +6,8 @@
(ns app.main.ui.ds.utilities.date (ns app.main.ui.ds.utilities.date
(:require-macros (:require-macros
[app.common.data.macros :as dm]
[app.main.style :as stl]) [app.main.style :as stl])
(:require (:require
[app.common.data :as d]
[app.common.time :as ct] [app.common.time :as ct]
[app.main.ui.ds.foundations.typography :as t] [app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.foundations.typography.text :refer [text*]]
@ -30,15 +28,10 @@
(mf/defc date* (mf/defc date*
{::mf/schema schema:date} {::mf/schema schema:date}
[{:keys [class date selected typography] :rest props}] [{:keys [class date selected typography] :rest props}]
(let [class (d/append-class class (stl/css-case :date true :is-selected selected)) (let [date (cond-> date (not (ct/inst? date)) ct/inst)
date (cond-> date (not (ct/inst? date)) ct/inst)
typography (or typography t/body-medium)] typography (or typography t/body-medium)]
[:> text* {:as "time" [:> text* {:as "time"
:typography typography :typography typography
:class class :class [class (stl/css-case :date true :is-selected selected)]
:date-time (ct/format-inst date :iso)} :date-time (ct/format-inst date :iso)}
(dm/str (ct/format-inst date :localized-date-time)]))
(ct/format-inst date :localized-date)
" . "
(ct/format-inst date :localized-time)
"h")]))

View File

@ -477,8 +477,12 @@
:service-unavailable :service-unavailable
[:> service-unavailable*] [:> service-unavailable*]
:webgl-context-lost :wasm-exception
[:> webgl-context-lost*] (case (get data :exception-type)
:webgl-context-lost
[:> webgl-context-lost*]
[:> internal-error* props])
[:> internal-error* props]))) [:> internal-error* props])))

View File

@ -0,0 +1,343 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.shapes.text.v3-editor
"Contenteditable DOM element for WASM text editor input"
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.texts :as dwt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.css-cursors :as cur]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor :as text-editor]
[app.util.dom :as dom]
[app.util.object :as obj]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def caret-blink-interval-ms 250)
(defn- sync-wasm-text-editor-content!
"Sync WASM text editor content back to the shape via the standard
commit pipeline. Called after every text-modifying input."
[& {:keys [finalize?]}]
(when-let [{:keys [shape-id content]}
(text-editor/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? finalize?))))
(defn- font-family-from-font-id [font-id]
(if (str/includes? font-id "gfont-noto-sans")
(let [lang (str/replace font-id #"gfont\-noto\-sans\-" "")]
(if (>= (count lang) 3) (str/capital lang) (str/upper lang)))
"Noto Color Emoji"))
(mf/defc text-editor
"Contenteditable element positioned over the text shape to capture input events."
{::mf/wrap-props false}
[props]
(let [shape (obj/get props "shape")
shape-id (dm/get-prop shape :id)
clip-id (dm/str "text-edition-clip" shape-id)
contenteditable-ref (mf/use-ref nil)
composing? (mf/use-state false)
fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false)
fallback-families (map (fn [font]
(font-family-from-font-id (:font-id font))) fallback-fonts)
[{:keys [x y width height]} transform]
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)
selrect-height (:height selrect)
selrect-width (:width selrect)
max-width (max width selrect-width)
max-height (max height selrect-height)
valign (-> shape :content :vertical-align)
y (:y selrect)
y (case valign
"bottom" (+ y (- selrect-height height))
"center" (+ y (/ (- selrect-height height) 2))
y)]
[(assoc selrect :y y :width max-width :height max-height) transform])
on-composition-start
(mf/use-fn
(fn [_event]
(reset! composing? true)))
on-composition-end
(mf/use-fn
(fn [^js event]
(reset! composing? false)
(let [data (.-data event)]
(when data
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-composition"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
on-paste
(mf/use-fn
(fn [^js event]
(dom/prevent-default event)
(let [clipboard-data (.-clipboardData event)
text (.getData clipboard-data "text/plain")]
(when (and text (seq text))
(text-editor/text-editor-insert-text text)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paste"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
on-copy
(mf/use-fn
(fn [^js event]
(when (text-editor/text-editor-is-active?)
(dom/prevent-default event)
(when (text-editor/text-editor-get-selection)
(let [text (text-editor/text-editor-export-selection)]
(.setData (.-clipboardData event) "text/plain" text))))))
on-cut
(mf/use-fn
(fn [^js event]
(when (text-editor/text-editor-is-active?)
(dom/prevent-default event)
(when (text-editor/text-editor-get-selection)
(let [text (text-editor/text-editor-export-selection)]
(.setData (.-clipboardData event) "text/plain" (or text ""))
(when (and text (seq text))
(text-editor/text-editor-delete-backward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-cut"))))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
on-key-down
(mf/use-fn
(fn [^js event]
(when (and (text-editor/text-editor-is-active?)
(not @composing?))
(let [key (.-key event)
ctrl? (or (.-ctrlKey event) (.-metaKey event))
shift? (.-shiftKey event)]
(cond
;; Escape: finalize and stop
(= key "Escape")
(do
(dom/prevent-default event)
(when-let [node (mf/ref-val contenteditable-ref)]
(.blur node)))
;; Ctrl+A: select all (key is "a" or "A" depending on platform)
(and ctrl? (= (str/lower key) "a"))
(do
(dom/prevent-default event)
(text-editor/text-editor-select-all)
(wasm.api/request-render "text-select-all"))
;; Enter
(= key "Enter")
(do
(dom/prevent-default event)
(text-editor/text-editor-insert-paragraph)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paragraph"))
;; Backspace
(= key "Backspace")
(do
(dom/prevent-default event)
(text-editor/text-editor-delete-backward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-backward"))
;; Delete
(= key "Delete")
(do
(dom/prevent-default event)
(text-editor/text-editor-delete-forward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-forward"))
;; Arrow keys
(= key "ArrowLeft")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 0 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowRight")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 1 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowUp")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 2 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowDown")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 3 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "Home")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 4 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "End")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 5 shift?)
(wasm.api/request-render "text-cursor-move"))
;; Let contenteditable handle text input via on-input
:else nil)))))
on-input
(mf/use-fn
(fn [^js event]
(let [native-event (.-nativeEvent event)
input-type (.-inputType native-event)
data (.-data native-event)]
;; Skip composition-related input events - composition-end handles those
(when (and (not @composing?)
(not= input-type "insertCompositionText"))
(when (and data (seq data))
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-input"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) ""))))))
on-pointer-down
(mf/use-fn
(fn [^js event]
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-pointer-down off-pt))))
on-pointer-move
(mf/use-fn
(fn [^js event]
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-pointer-move off-pt))))
on-pointer-up
(mf/use-fn
(fn [^js event]
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-pointer-up off-pt))))
on-click
(mf/use-fn
(fn [^js event]
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-set-cursor-from-offset off-pt))))
on-double-click
(mf/use-fn
(fn [^js event]
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-select-word-boundary off-pt))))
on-focus
(mf/use-fn
(fn [^js _event]
(wasm.api/text-editor-start shape-id)))
on-blur
(mf/use-fn
(fn [^js _event]
(sync-wasm-text-editor-content! {:finalize? true})
(wasm.api/text-editor-stop)))
style #js {:pointerEvents "all"
"--editor-container-width" (dm/str width "px")
"--editor-container-height" (dm/str height "px")
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}]
;; Focus contenteditable on mount
(mf/use-effect
(mf/deps contenteditable-ref)
(fn []
(when-let [node (mf/ref-val contenteditable-ref)]
(.focus node))
;; Explicitly call on-blur here instead of relying on browser blur events,
;; because in Firefox blur is not reliably fired when leaving the text editor
;; by clicking elsewhere. The component does unmount when the shape is
;; deselected, so we can safely call the blur handler here to finalize the editor.
on-blur))
(mf/use-effect
(fn []
(let [timeout-id (atom nil)
schedule-blink (fn schedule-blink []
(when (text-editor/text-editor-is-active?)
(wasm.api/request-render "cursor-blink"))
(reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))]
(schedule-blink)
(fn []
(when @timeout-id
(js/clearTimeout @timeout-id))))))
;; Composition and input events
[:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id)
:transform (dm/str transform)
:data-testid "text-editor"}
[:defs
[:clipPath {:id clip-id}
[:rect {:x x :y y :width width :height height}]]]
[:foreignObject {:x x :y y :width width :height height}
[:div {:on-click on-click
:on-double-click on-double-click
:on-pointer-down on-pointer-down
:on-pointer-move on-pointer-move
:on-pointer-up on-pointer-up
:class (stl/css :text-editor)
:style style}
[:div
{:ref contenteditable-ref
:contentEditable true
:suppressContentEditableWarning true
:on-composition-start on-composition-start
:on-composition-end on-composition-end
:on-key-down on-key-down
:on-input on-input
:on-paste on-paste
:on-copy on-copy
:on-cut on-cut
:on-focus on-focus
:on-blur on-blur
;; FIXME on-click
;; :on-click on-click
:id "text-editor-wasm-input"
:class (dm/str (cur/get-dynamic "text" (:rotation shape))
" "
(stl/css :text-editor-container))
:data-testid "text-editor-container"}]]]]))

View File

@ -0,0 +1,13 @@
.text-editor {
height: 100%;
}
.text-editor-container {
width: 100%;
height: 100%;
position: absolute;
opacity: 0;
overflow: hidden;
white-space: pre;
}

View File

@ -159,3 +159,7 @@
overflow: hidden; overflow: hidden;
height: calc(100vh - deprecated.$s-88); height: calc(100vh - deprecated.$s-88);
} }
.history-tab {
overflow-y: auto;
}

View File

@ -19,13 +19,11 @@
[app.main.data.workspace.media :as dwm] [app.main.data.workspace.media :as dwm]
[app.main.data.workspace.path :as dwdp] [app.main.data.workspace.path :as dwdp]
[app.main.data.workspace.specialized-panel :as-alias dwsp] [app.main.data.workspace.specialized-panel :as-alias dwsp]
[app.main.features :as features]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.workspace.sidebar.assets.components :as wsac] [app.main.ui.workspace.sidebar.assets.components :as wsac]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv] [app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.render-wasm.api :as wasm.api] [app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm.wasm]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.dom.dnd :as dnd] [app.util.dom.dnd :as dnd]
[app.util.dom.normalize-wheel :as nw] [app.util.dom.normalize-wheel :as nw]
@ -74,7 +72,6 @@
shift? (kbd/shift? native-event) shift? (kbd/shift? native-event)
alt? (kbd/alt? native-event) alt? (kbd/alt? native-event)
mod? (kbd/mod? native-event) mod? (kbd/mod? native-event)
off-pt (dom/get-offset-position native-event)
left-click? (and (not panning) (dom/left-mouse? event)) left-click? (and (not panning) (dom/left-mouse? event))
middle-click? (and (not panning) (dom/middle-mouse? event))] middle-click? (and (not panning) (dom/middle-mouse? event))]
@ -94,23 +91,8 @@
(st/emit! (mse/->MouseEvent :down ctrl? shift? alt? meta?) (st/emit! (mse/->MouseEvent :down ctrl? shift? alt? meta?)
::dwsp/interrupt) ::dwsp/interrupt)
(when (wasm.api/text-editor-is-active?)
(wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt)))
(when (and (not= edition id) (or text-editing? grid-editing?)) (when (and (not= edition id) (or text-editing? grid-editing?))
(st/emit! (dw/clear-edition-mode)) (st/emit! (dw/clear-edition-mode)))
;; FIXME: I think this is not completely correct because this
;; is going to happen even when clicking or selecting text.
;; Sync and stop WASM text editor when exiting edit mode
#_(when (and text-editing?
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? true)))
(wasm.api/text-editor-stop)))
(when (and (not text-editing?) (when (and (not text-editing?)
(not blocked) (not blocked)
@ -192,8 +174,6 @@
alt? (kbd/alt? event) alt? (kbd/alt? event)
meta? (kbd/meta? event) meta? (kbd/meta? event)
hovering? (some? @hover) hovering? (some? @hover)
native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)
raw-pt (dom/get-client-position event) raw-pt (dom/get-client-position event)
pt (uwvv/point->viewport raw-pt)] pt (uwvv/point->viewport raw-pt)]
(st/emit! (mse/->MouseEvent :click ctrl? shift? alt? meta?)) (st/emit! (mse/->MouseEvent :click ctrl? shift? alt? meta?))
@ -207,20 +187,6 @@
(not drawing-tool)) (not drawing-tool))
(st/emit! (dw/select-shape (:id @hover) shift?))) (st/emit! (dw/select-shape (:id @hover) shift?)))
;; FIXME: Maybe we can move into a function of the kind
;; "text-editor-on-click"
;; If clicking on a text shape and wasm render is enabled, forward cursor position
(when (and hovering?
(not @space?)
edition ;; Only when already in edit mode
(not drawing-path?)
(not drawing-tool))
(let [hover-shape @hover]
(when (and (= :text (:type hover-shape))
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(wasm.api/text-editor-set-cursor-from-point (.-x off-pt) (.-y off-pt)))))
(when (and @z? (when (and @z?
(not @space?) (not @space?)
(not edition) (not edition)
@ -262,19 +228,7 @@
(and editable? (not= id edition) (not read-only?)) (and editable? (not= id edition) (not read-only?))
(do (do
(st/emit! (dw/select-shape id) (st/emit! (dw/select-shape id)
(dw/start-editing-selected)) (dw/start-editing-selected)))
;; If using wasm text-editor, notify WASM to start editing this shape
;; and set cursor position from the double-click location
(when (and (= type :text)
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(wasm.api/text-editor-start id)))
(and editable? (= id edition) (not read-only?)
(= type :text)
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(wasm.api/text-editor-select-all)
(some? selected-shape) (some? selected-shape)
(do (do

View File

@ -30,6 +30,7 @@
[app.main.ui.workspace.shapes.text.editor :as editor-v1] [app.main.ui.workspace.shapes.text.editor :as editor-v1]
[app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline]] [app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline]]
[app.main.ui.workspace.shapes.text.v2-editor :as editor-v2] [app.main.ui.workspace.shapes.text.v2-editor :as editor-v2]
[app.main.ui.workspace.shapes.text.v3-editor :as editor-v3]
[app.main.ui.workspace.top-toolbar :refer [top-toolbar*]] [app.main.ui.workspace.top-toolbar :refer [top-toolbar*]]
[app.main.ui.workspace.viewport.actions :as actions] [app.main.ui.workspace.viewport.actions :as actions]
[app.main.ui.workspace.viewport.comments :as comments] [app.main.ui.workspace.viewport.comments :as comments]
@ -54,7 +55,6 @@
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]] [app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.widgets :as widgets] [app.main.ui.workspace.viewport.widgets :as widgets]
[app.render-wasm.api :as wasm.api] [app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor-input :refer [text-editor-input]]
[app.util.debug :as dbg] [app.util.debug :as dbg]
[app.util.text-editor :as ted] [app.util.text-editor :as ted]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
@ -417,14 +417,7 @@
(when picking-color? (when picking-color?
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref [:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
:canvas-ref canvas-ref}]) :canvas-ref canvas-ref}])]
;; WASM text editor contenteditable (must be outside SVG to work)
(when (and show-text-editor?
(features/active-feature? @st/state "text-editor-wasm/v1"))
[:& text-editor-input {:shape editing-shape
:zoom zoom
:vbox vbox}])]
[:canvas {:id "render" [:canvas {:id "render"
:data-testid "canvas-wasm-shapes" :data-testid "canvas-wasm-shapes"
@ -471,14 +464,20 @@
[:g {:style {:pointer-events (if disable-events? "none" "auto")}} [:g {:style {:pointer-events (if disable-events? "none" "auto")}}
;; Text editor handling: ;; Text editor handling:
;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM) ;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
(when (and show-text-editor? (when show-text-editor?
(not (features/active-feature? @st/state "text-editor-wasm/v1"))) (cond
(if (features/active-feature? @st/state "text-editor/v2") (features/active-feature? @st/state "text-editor-wasm/v1")
[:& editor-v3/text-editor {:shape editing-shape
:canvas-ref canvas-ref
:ref text-editor-ref}]
(features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape [:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref :canvas-ref canvas-ref
:ref text-editor-ref}] :ref text-editor-ref}]
[:& editor-v1/text-editor-svg {:shape editing-shape
:ref text-editor-ref}])) :else [:& editor-v1/text-editor-svg {:shape editing-shape
:ref text-editor-ref}]))
(when show-frame-outline? (when show-frame-outline?
(let [outlined-frame-id (let [outlined-frame-id

View File

@ -86,12 +86,14 @@
;; Re-export public text editor functions ;; Re-export public text editor functions
(def text-editor-start text-editor/text-editor-start) (def text-editor-start text-editor/text-editor-start)
(def text-editor-stop text-editor/text-editor-stop) (def text-editor-stop text-editor/text-editor-stop)
(def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset)
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point) (def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
(def text-editor-pointer-down text-editor/text-editor-pointer-down) (def text-editor-pointer-down text-editor/text-editor-pointer-down)
(def text-editor-pointer-move text-editor/text-editor-pointer-move) (def text-editor-pointer-move text-editor/text-editor-pointer-move)
(def text-editor-pointer-up text-editor/text-editor-pointer-up) (def text-editor-pointer-up text-editor/text-editor-pointer-up)
(def text-editor-is-active? text-editor/text-editor-is-active?) (def text-editor-is-active? text-editor/text-editor-is-active?)
(def text-editor-select-all text-editor/text-editor-select-all) (def text-editor-select-all text-editor/text-editor-select-all)
(def text-editor-select-word-boundary text-editor/text-editor-select-word-boundary)
(def text-editor-sync-content text-editor/text-editor-sync-content) (def text-editor-sync-content text-editor/text-editor-sync-content)
(def dpr (def dpr
@ -1419,7 +1421,9 @@
(dom/prevent-default event) (dom/prevent-default event)
(reset! wasm/context-lost? true) (reset! wasm/context-lost? true)
(log/warn :hint "WebGL context lost") (log/warn :hint "WebGL context lost")
(ex/raise :type :webgl-context-lost (ex/raise :type :wasm-exception
:exception-type :webgl-context-lost
:prefix "WebGL context lost"
:hint "WebGL context lost")) :hint "WebGL context lost"))
(defn init-canvas-context (defn init-canvas-context

View File

@ -7,11 +7,30 @@
(ns app.render-wasm.helpers (ns app.render-wasm.helpers
#?(:cljs (:require-macros [app.render-wasm.helpers]))) #?(:cljs (:require-macros [app.render-wasm.helpers])))
(def ^:export error-code
"WASM error code constants (must match render-wasm/src/error.rs and mem.rs)."
{0x01 :wasm-non-blocking 0x02 :wasm-critical})
(defmacro call (defmacro call
"A helper for easy call wasm defined function in a module." "A helper for calling a wasm function.
Catches any exception thrown by the WASM function, reads the error code from
WASM when available, and routes it based on the error type:
- :wasm-non-blocking: call app.main.errors/on-error (eventually, shows a toast and logs the error)
- :wasm-critical or unknown: throws an exception to be handled by the global error handler (eventually, shows the internal error page)"
[module name & params] [module name & params]
(let [fn-sym (with-meta (gensym "fn-") {:tag 'function})] (let [fn-sym (with-meta (gensym "fn-") {:tag 'function})
e-sym (gensym "e")
code-sym (gensym "code")]
`(let [~fn-sym (cljs.core/unchecked-get ~module ~name)] `(let [~fn-sym (cljs.core/unchecked-get ~module ~name)]
;; DEBUG (try
;; (println "##" ~name) (~fn-sym ~@params)
(~fn-sym ~@params)))) (catch :default ~e-sym
(let [read-code# (cljs.core/unchecked-get ~module "_read_error_code")
~code-sym (when read-code# (read-code#))
type# (or (get app.render-wasm.helpers/error-code ~code-sym) :wasm-critical)
ex# (ex-info (str "WASM error (type: " type# ")")
{:fn ~name :type type# :message (.-message ~e-sym) :error-code ~code-sym}
~e-sym)]
(if (= type# :wasm-non-blocking)
(@~'app.main.store/on-error ex#)
(throw ex#))))))))

View File

@ -16,29 +16,37 @@
[id] [id]
(when wasm/context-initialized? (when wasm/context-initialized?
(let [buffer (uuid/get-u32 id)] (let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_text_editor_start" (when-not (h/call wasm/internal-module "_text_editor_start"
(aget buffer 0) (aget buffer 0)
(aget buffer 1) (aget buffer 1)
(aget buffer 2) (aget buffer 2)
(aget buffer 3))))) (aget buffer 3))
(throw (js/Error. "TextEditor initialization failed"))))))
(defn text-editor-set-cursor-from-offset
"Sets caret position from shape relative coordinates"
[{:keys [x y]}]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_offset" x y)))
(defn text-editor-set-cursor-from-point (defn text-editor-set-cursor-from-point
[x y] "Sets caret position from screen (canvas) coordinates"
[{:keys [x y]}]
(when wasm/context-initialized? (when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y))) (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
(defn text-editor-pointer-down (defn text-editor-pointer-down
[x y] [{:keys [x y]}]
(when wasm/context-initialized? (when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_down" x y))) (h/call wasm/internal-module "_text_editor_pointer_down" x y)))
(defn text-editor-pointer-move (defn text-editor-pointer-move
[x y] [{:keys [x y]}]
(when wasm/context-initialized? (when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_move" x y))) (h/call wasm/internal-module "_text_editor_pointer_move" x y)))
(defn text-editor-pointer-up (defn text-editor-pointer-up
[x y] [{:keys [x y]}]
(when wasm/context-initialized? (when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_up" x y))) (h/call wasm/internal-module "_text_editor_pointer_up" x y)))
@ -92,10 +100,16 @@
(when wasm/context-initialized? (when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_select_all"))) (h/call wasm/internal-module "_text_editor_select_all")))
(defn text-editor-select-word-boundary
[{:keys [x y]}]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_select_word_boundary" x y)))
(defn text-editor-stop (defn text-editor-stop
[] []
(when wasm/context-initialized? (when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_stop"))) (when-not (h/call wasm/internal-module "_text_editor_stop")
(throw (js/Error. "TextEditor finalization failed")))))
(defn text-editor-is-active? (defn text-editor-is-active?
([id] ([id]
@ -160,6 +174,7 @@
(finally (finally
(mem/free)))))) (mem/free))))))
;; This is used as a intermediate cache between Clojure global state and WASM state.
(def ^:private shape-text-contents (atom {})) (def ^:private shape-text-contents (atom {}))
(defn- merge-exported-texts-into-content (defn- merge-exported-texts-into-content

View File

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

View File

@ -26,8 +26,9 @@
"clear:shadow-cache": "rm -rf .shadow-cljs", "clear:shadow-cache": "rm -rf .shadow-cljs",
"build": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs release library", "build": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs release library",
"build:bundle": "./scripts/build", "build:bundle": "./scripts/build",
"fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt": "cljfmt fix --parallel=true src/ test/",
"lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", "check-fmt": "cljfmt check --parallel=true src/ test/",
"lint": "clj-kondo --parallel --lint src/",
"test": "node --test", "test": "node --test",
"watch:test": "node --test --watch", "watch:test": "node --test --watch",
"watch": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch library" "watch": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch library"

23
render-wasm/Cargo.lock generated
View File

@ -297,6 +297,8 @@ name = "macros"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2",
"quote",
"syn", "syn",
] ]
@ -426,6 +428,7 @@ dependencies = [
"indexmap", "indexmap",
"macros", "macros",
"skia-safe", "skia-safe",
"thiserror",
"uuid", "uuid",
] ]
@ -579,6 +582,26 @@ dependencies = [
"xattr", "xattr",
] ]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "1.0.3+spec-1.1.0" version = "1.0.3+spec-1.1.0"

View File

@ -32,6 +32,7 @@ skia-safe = { version = "0.93.1", default-features = false, features = [
"binary-cache", "binary-cache",
"webp", "webp",
] } ] }
thiserror = "2.0.18"
uuid = { version = "1.11.0", features = ["v4", "js"] } uuid = { version = "1.11.0", features = ["v4", "js"] }
[profile.release] [profile.release]

View File

@ -13,6 +13,8 @@ name = "macros"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2",
"quote",
"syn", "syn",
] ]

View File

@ -1,11 +1,13 @@
[package] [package]
name = "macros" name = "macros"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2021"
[lib] [lib]
proc-macro = true proc-macro = true
[dependencies] [dependencies]
heck = "0.5.0" heck = "0.5.0"
proc-macro2 = "1.0"
quote = "1.0"
syn = "2.0.106" syn = "2.0.106"

View File

@ -6,9 +6,109 @@ use std::sync;
use heck::{ToKebabCase, ToPascalCase}; use heck::{ToKebabCase, ToPascalCase};
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Block, GenericArgument, ItemFn, ReturnType, Type};
type Result<T> = std::result::Result<T, String>; type Result<T> = std::result::Result<T, String>;
/// Attribute macro for WASM-exported functions. The function **must** return
/// `std::result::Result<T, E>` where T is a C ABI type and E implements
/// `std::error::Error` and `Into<u8>`. The macro:
/// - Clears the error code at entry.
/// - Runs the body in `std::panic::catch_unwind`.
/// - Unwraps the Result: `Ok(x)` → return x; `Err(e)` → set error code in memory and panic
/// (so ClojureScript can catch the exception and read the code via `read_error_code`).
/// - On panic from the body: sets critical error code (0x02) and resumes unwind.
#[proc_macro_attribute]
pub fn wasm_error(_attr: TokenStream, item: TokenStream) -> TokenStream {
let mut input = parse_macro_input!(item as ItemFn);
let body = (*input.block).clone();
let (attrs, boxed_ty) = match &input.sig.output {
ReturnType::Type(attrs, boxed_ty) => (attrs, boxed_ty),
ReturnType::Default => {
return quote! {
compile_error!(
"#[wasm_error] requires the function to return std::result::Result<T, E> where E: std::error::Error + Into<u8>"
);
}
.into();
}
};
let (inner_ty, error_ty) = match crate_error_result_inner_type(boxed_ty) {
Some(t) => (t, quote!(crate::error::Error)),
None => {
return quote! {
compile_error!(
"#[wasm_error] requires the function to return crate::error::Result<T>. T must be a C ABI type (u32, u8, bool, (), etc.)"
);
}
.into();
}
};
let block: Block = syn::parse2(quote! {
{
crate::mem::clear_error_code();
let __wasm_err_result = std::panic::catch_unwind(|| -> std::result::Result<#inner_ty, #error_ty> {
#body
});
match __wasm_err_result {
Ok(__inner) => match __inner {
Ok(__val) => __val,
Err(__e) => {
let _: &dyn std::error::Error = &__e;
let __msg = __e.to_string();
crate::mem::set_error_code(__e.into());
panic!("WASM error: {}",__msg);
}
},
Err(__payload) => {
crate::mem::set_error_code(0x02); // critical, same as Error::Critical
std::panic::resume_unwind(__payload);
}
}
}
})
.expect("block parse");
input.sig.output = ReturnType::Type(attrs.clone(), Box::new(inner_ty.clone()));
input.block = Box::new(block);
quote! { #input }.into()
}
/// If the type is crate::error::Result<T> or a single-segment Result<T> (e.g. with
/// `use crate::error::Result`), returns Some(T). Otherwise None.
fn crate_error_result_inner_type(ty: &Type) -> Option<&Type> {
let path = match ty {
Type::Path(tp) => &tp.path,
_ => return None,
};
let segs: Vec<_> = path.segments.iter().collect();
let last = path.segments.last()?;
if last.ident != "Result" {
return None;
}
let args = match &last.arguments {
syn::PathArguments::AngleBracketed(a) => &a.args,
_ => return None,
};
if args.len() != 1 {
return None;
}
// Accept crate::error::Result<T> or bare Result<T> (from use)
let ok = segs.len() == 1
|| (segs.len() == 3 && segs[0].ident == "crate" && segs[1].ident == "error");
if !ok {
return None;
}
match &args[0] {
GenericArgument::Type(t) => Some(t),
_ => None,
}
}
#[proc_macro_derive(ToJs)] #[proc_macro_derive(ToJs)]
pub fn derive_to_cljs(input: TokenStream) -> TokenStream { pub fn derive_to_cljs(input: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(input as syn::DeriveInput); let input = syn::parse_macro_input!(input as syn::DeriveInput);

25
render-wasm/src/error.rs Normal file
View File

@ -0,0 +1,25 @@
use thiserror::Error;
pub const RECOVERABLE_ERROR: u8 = 0x01;
pub const CRITICAL_ERROR: u8 = 0x02;
// This is not really dead code, #[wasm_error] macro replaces this by something else.
#[allow(dead_code)]
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Error, Debug)]
pub enum Error {
#[error("[Recoverable] {0}")]
RecoverableError(String),
#[error("[Critical] {0}")]
CriticalError(String),
}
impl From<Error> for u8 {
fn from(error: Error) -> Self {
match error {
Error::RecoverableError(_) => RECOVERABLE_ERROR,
Error::CriticalError(_) => CRITICAL_ERROR,
}
}
}

View File

@ -1,5 +1,6 @@
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
mod emscripten; mod emscripten;
mod error;
mod math; mod math;
mod mem; mod mem;
mod options; mod options;
@ -14,12 +15,16 @@ mod view;
mod wapi; mod wapi;
mod wasm; mod wasm;
use std::collections::HashMap;
#[allow(unused_imports)]
use crate::error::{Error, Result};
use macros::wasm_error;
use math::{Bounds, Matrix}; use math::{Bounds, Matrix};
use mem::SerializableResult; use mem::SerializableResult;
use shapes::{StructureEntry, StructureEntryType, TransformEntry}; use shapes::{StructureEntry, StructureEntryType, TransformEntry};
use skia_safe as skia; use skia_safe as skia;
use state::State; use state::State;
use std::collections::HashMap;
use utils::uuid_from_u32_quartet; use utils::uuid_from_u32_quartet;
use uuid::Uuid; use uuid::Uuid;
@ -95,22 +100,27 @@ macro_rules! with_state_mut_current_shape {
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn init(width: i32, height: i32) { #[wasm_error]
pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
let state_box = Box::new(State::new(width, height)); let state_box = Box::new(State::new(width, height));
unsafe { unsafe {
STATE = Some(state_box); STATE = Some(state_box);
} }
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_browser(browser: u8) { #[wasm_error]
pub extern "C" fn set_browser(browser: u8) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
state.set_browser(browser); state.set_browser(browser);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn clean_up() { #[wasm_error]
pub extern "C" fn clean_up() -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
// Cancel the current animation frame if it exists so // Cancel the current animation frame if it exists so
// it won't try to render without context // it won't try to render without context
@ -118,49 +128,60 @@ pub extern "C" fn clean_up() {
render_state.cancel_animation_frame(); render_state.cancel_animation_frame();
}); });
unsafe { STATE = None } unsafe { STATE = None }
mem::free_bytes(); mem::free_bytes()?;
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_render_options(debug: u32, dpr: f32) { #[wasm_error]
pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
let render_state = state.render_state_mut(); let render_state = state.render_state_mut();
render_state.set_debug_flags(debug); render_state.set_debug_flags(debug);
render_state.set_dpr(dpr); render_state.set_dpr(dpr);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_canvas_background(raw_color: u32) { #[wasm_error]
pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
let color = skia::Color::new(raw_color); let color = skia::Color::new(raw_color);
state.set_background_color(color); state.set_background_color(color);
state.rebuild_tiles_shallow(); state.rebuild_tiles_shallow();
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn render(_: i32) { #[wasm_error]
pub extern "C" fn render(_: i32) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
state.rebuild_touched_tiles(); state.rebuild_touched_tiles();
state state
.start_render_loop(performance::get_time()) .start_render_loop(performance::get_time())
.expect("Error rendering"); .expect("Error rendering");
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn render_sync() { #[wasm_error]
pub extern "C" fn render_sync() -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
state.rebuild_tiles(); state.rebuild_tiles();
state state
.render_sync(performance::get_time()) .render_sync(performance::get_time())
.expect("Error rendering"); .expect("Error rendering");
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) { #[wasm_error]
pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
let id = uuid_from_u32_quartet(a, b, c, d); let id = uuid_from_u32_quartet(a, b, c, d);
state.use_shape(id); state.use_shape(id);
@ -179,34 +200,42 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) {
state.rebuild_tiles_from(Some(&id)); state.rebuild_tiles_from(Some(&id));
state state
.render_sync_shape(&id, performance::get_time()) .render_sync_shape(&id, performance::get_time())
.expect("Error rendering"); .map_err(|e| Error::RecoverableError(e.to_string()))?;
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn render_from_cache(_: i32) { #[wasm_error]
pub extern "C" fn render_from_cache(_: i32) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
state.render_state.cancel_animation_frame(); state.render_state.cancel_animation_frame();
state.render_from_cache(); state.render_from_cache();
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_preview_mode(enabled: bool) { #[wasm_error]
pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
state.render_state.set_preview_mode(enabled); state.render_state.set_preview_mode(enabled);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn render_preview() { #[wasm_error]
pub extern "C" fn render_preview() -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
state.render_preview(performance::get_time()); state.render_preview(performance::get_time());
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn process_animation_frame(timestamp: i32) { #[wasm_error]
pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> {
let result = std::panic::catch_unwind(|| { let result = std::panic::catch_unwind(|| {
with_state_mut!(state, { with_state_mut!(state, {
state state
@ -225,37 +254,45 @@ pub extern "C" fn process_animation_frame(timestamp: i32) {
std::panic::resume_unwind(err); std::panic::resume_unwind(err);
} }
} }
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn reset_canvas() { #[wasm_error]
pub extern "C" fn reset_canvas() -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
state.render_state_mut().reset_canvas(); state.render_state_mut().reset_canvas();
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn resize_viewbox(width: i32, height: i32) { #[wasm_error]
pub extern "C" fn resize_viewbox(width: i32, height: i32) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
state.resize(width, height); state.resize(width, height);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) { #[wasm_error]
pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
performance::begin_measure!("set_view"); performance::begin_measure!("set_view");
let render_state = state.render_state_mut(); let render_state = state.render_state_mut();
render_state.set_view(zoom, x, y); render_state.set_view(zoom, x, y);
performance::end_measure!("set_view"); performance::end_measure!("set_view");
}); });
Ok(())
} }
#[cfg(feature = "profile-macros")] #[cfg(feature = "profile-macros")]
static mut VIEW_INTERACTION_START: i32 = 0; static mut VIEW_INTERACTION_START: i32 = 0;
#[no_mangle] #[no_mangle]
pub extern "C" fn set_view_start() { #[wasm_error]
pub extern "C" fn set_view_start() -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
#[cfg(feature = "profile-macros")] #[cfg(feature = "profile-macros")]
unsafe { unsafe {
@ -265,10 +302,12 @@ pub extern "C" fn set_view_start() {
state.render_state.options.set_fast_mode(true); state.render_state.options.set_fast_mode(true);
performance::end_measure!("set_view_start"); performance::end_measure!("set_view_start");
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_view_end() { #[wasm_error]
pub extern "C" fn set_view_end() -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
let _end_start = performance::begin_timed_log!("set_view_end"); let _end_start = performance::begin_timed_log!("set_view_end");
performance::begin_measure!("set_view_end"); performance::begin_measure!("set_view_end");
@ -304,17 +343,21 @@ pub extern "C" fn set_view_end() {
performance::console_log!("[PERF] view_interaction: {}ms", total_time); performance::console_log!("[PERF] view_interaction: {}ms", total_time);
} }
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn clear_focus_mode() { #[wasm_error]
pub extern "C" fn clear_focus_mode() -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
state.clear_focus_mode(); state.clear_focus_mode();
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_focus_mode() { #[wasm_error]
pub extern "C" fn set_focus_mode() -> Result<()> {
let bytes = mem::bytes(); let bytes = mem::bytes();
let entries: Vec<Uuid> = bytes let entries: Vec<Uuid> = bytes
@ -325,83 +368,111 @@ pub extern "C" fn set_focus_mode() {
with_state_mut!(state, { with_state_mut!(state, {
state.set_focus_mode(entries); state.set_focus_mode(entries);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn init_shapes_pool(capacity: usize) { #[wasm_error]
pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
state.init_shapes_pool(capacity); state.init_shapes_pool(capacity);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) { #[wasm_error]
pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
let id = uuid_from_u32_quartet(a, b, c, d); let id = uuid_from_u32_quartet(a, b, c, d);
state.use_shape(id); state.use_shape(id);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) { #[wasm_error]
pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d); let shape_id = uuid_from_u32_quartet(a, b, c, d);
state.touch_shape(shape_id); state.touch_shape(shape_id);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) { #[wasm_error]
pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
let id = uuid_from_u32_quartet(a, b, c, d); let id = uuid_from_u32_quartet(a, b, c, d);
state.set_parent_for_current_shape(id); state.set_parent_for_current_shape(id);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_masked_group(masked: bool) { #[wasm_error]
pub extern "C" fn set_shape_masked_group(masked: bool) -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_masked(masked); shape.set_masked(masked);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_selrect(left: f32, top: f32, right: f32, bottom: f32) { #[wasm_error]
pub extern "C" fn set_shape_selrect(left: f32, top: f32, right: f32, bottom: f32) -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_selrect(left, top, right, bottom); shape.set_selrect(left, top, right, bottom);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_clip_content(clip_content: bool) { #[wasm_error]
pub extern "C" fn set_shape_clip_content(clip_content: bool) -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_clip(clip_content); shape.set_clip(clip_content);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_rotation(rotation: f32) { #[wasm_error]
pub extern "C" fn set_shape_rotation(rotation: f32) -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_rotation(rotation); shape.set_rotation(rotation);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_transform(a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) { #[wasm_error]
pub extern "C" fn set_shape_transform(
a: f32,
b: f32,
c: f32,
d: f32,
e: f32,
f: f32,
) -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_transform(a, b, c, d, e, f); shape.set_transform(a, b, c, d, e, f);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn add_shape_child(a: u32, b: u32, c: u32, d: u32) { #[wasm_error]
pub extern "C" fn add_shape_child(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
let id = uuid_from_u32_quartet(a, b, c, d); let id = uuid_from_u32_quartet(a, b, c, d);
shape.add_child(id); shape.add_child(id);
}); });
Ok(())
} }
fn set_children_set(entries: Vec<Uuid>) { fn set_children_set(entries: Vec<Uuid>) -> Result<()> {
let mut deleted = Vec::new(); let mut deleted = Vec::new();
let mut parent_id = None; let mut parent_id = None;
@ -420,7 +491,9 @@ fn set_children_set(entries: Vec<Uuid>) {
with_state_mut!(state, { with_state_mut!(state, {
let Some(parent_id) = parent_id else { let Some(parent_id) = parent_id else {
return; return Err(Error::RecoverableError(
"set_children_set: Parent ID not found".to_string(),
));
}; };
for id in deleted { for id in deleted {
@ -428,21 +501,27 @@ fn set_children_set(entries: Vec<Uuid>) {
state.touch_shape(id); state.touch_shape(id);
} }
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_children_0() { #[wasm_error]
pub extern "C" fn set_children_0() -> Result<()> {
let entries = vec![]; let entries = vec![];
set_children_set(entries); set_children_set(entries)?;
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_children_1(a1: u32, b1: u32, c1: u32, d1: u32) { #[wasm_error]
pub extern "C" fn set_children_1(a1: u32, b1: u32, c1: u32, d1: u32) -> Result<()> {
let entries = vec![uuid_from_u32_quartet(a1, b1, c1, d1)]; let entries = vec![uuid_from_u32_quartet(a1, b1, c1, d1)];
set_children_set(entries); set_children_set(entries)?;
Ok(())
} }
#[no_mangle] #[no_mangle]
#[wasm_error]
pub extern "C" fn set_children_2( pub extern "C" fn set_children_2(
a1: u32, a1: u32,
b1: u32, b1: u32,
@ -452,15 +531,17 @@ pub extern "C" fn set_children_2(
b2: u32, b2: u32,
c2: u32, c2: u32,
d2: u32, d2: u32,
) { ) -> Result<()> {
let entries = vec![ let entries = vec![
uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a1, b1, c1, d1),
uuid_from_u32_quartet(a2, b2, c2, d2), uuid_from_u32_quartet(a2, b2, c2, d2),
]; ];
set_children_set(entries); set_children_set(entries)?;
Ok(())
} }
#[no_mangle] #[no_mangle]
#[wasm_error]
pub extern "C" fn set_children_3( pub extern "C" fn set_children_3(
a1: u32, a1: u32,
b1: u32, b1: u32,
@ -474,16 +555,18 @@ pub extern "C" fn set_children_3(
b3: u32, b3: u32,
c3: u32, c3: u32,
d3: u32, d3: u32,
) { ) -> Result<()> {
let entries = vec![ let entries = vec![
uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a1, b1, c1, d1),
uuid_from_u32_quartet(a2, b2, c2, d2), uuid_from_u32_quartet(a2, b2, c2, d2),
uuid_from_u32_quartet(a3, b3, c3, d3), uuid_from_u32_quartet(a3, b3, c3, d3),
]; ];
set_children_set(entries); set_children_set(entries)?;
Ok(())
} }
#[no_mangle] #[no_mangle]
#[wasm_error]
pub extern "C" fn set_children_4( pub extern "C" fn set_children_4(
a1: u32, a1: u32,
b1: u32, b1: u32,
@ -501,17 +584,19 @@ pub extern "C" fn set_children_4(
b4: u32, b4: u32,
c4: u32, c4: u32,
d4: u32, d4: u32,
) { ) -> Result<()> {
let entries = vec![ let entries = vec![
uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a1, b1, c1, d1),
uuid_from_u32_quartet(a2, b2, c2, d2), uuid_from_u32_quartet(a2, b2, c2, d2),
uuid_from_u32_quartet(a3, b3, c3, d3), uuid_from_u32_quartet(a3, b3, c3, d3),
uuid_from_u32_quartet(a4, b4, c4, d4), uuid_from_u32_quartet(a4, b4, c4, d4),
]; ];
set_children_set(entries); set_children_set(entries)?;
Ok(())
} }
#[no_mangle] #[no_mangle]
#[wasm_error]
pub extern "C" fn set_children_5( pub extern "C" fn set_children_5(
a1: u32, a1: u32,
b1: u32, b1: u32,
@ -533,7 +618,7 @@ pub extern "C" fn set_children_5(
b5: u32, b5: u32,
c5: u32, c5: u32,
d5: u32, d5: u32,
) { ) -> Result<()> {
let entries = vec![ let entries = vec![
uuid_from_u32_quartet(a1, b1, c1, d1), uuid_from_u32_quartet(a1, b1, c1, d1),
uuid_from_u32_quartet(a2, b2, c2, d2), uuid_from_u32_quartet(a2, b2, c2, d2),
@ -541,11 +626,13 @@ pub extern "C" fn set_children_5(
uuid_from_u32_quartet(a4, b4, c4, d4), uuid_from_u32_quartet(a4, b4, c4, d4),
uuid_from_u32_quartet(a5, b5, c5, d5), uuid_from_u32_quartet(a5, b5, c5, d5),
]; ];
set_children_set(entries); set_children_set(entries)?;
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_children() { #[wasm_error]
pub extern "C" fn set_children() -> Result<()> {
let bytes = mem::bytes_or_empty(); let bytes = mem::bytes_or_empty();
let entries: Vec<Uuid> = bytes let entries: Vec<Uuid> = bytes
@ -553,58 +640,76 @@ pub extern "C" fn set_children() {
.map(|data| Uuid::try_from(data).unwrap()) .map(|data| Uuid::try_from(data).unwrap())
.collect(); .collect();
set_children_set(entries); set_children_set(entries)?;
if !bytes.is_empty() { if !bytes.is_empty() {
mem::free_bytes(); mem::free_bytes()?;
} }
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn is_image_cached(a: u32, b: u32, c: u32, d: u32, is_thumbnail: bool) -> bool { #[wasm_error]
pub extern "C" fn is_image_cached(
a: u32,
b: u32,
c: u32,
d: u32,
is_thumbnail: bool,
) -> Result<bool> {
with_state_mut!(state, { with_state_mut!(state, {
let id = uuid_from_u32_quartet(a, b, c, d); let id = uuid_from_u32_quartet(a, b, c, d);
state.render_state().has_image(&id, is_thumbnail) let result = state.render_state().has_image(&id, is_thumbnail);
Ok(result)
}) })
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_svg_raw_content() { #[wasm_error]
pub extern "C" fn set_shape_svg_raw_content() -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
let bytes = mem::bytes(); let bytes = mem::bytes();
let svg_raw_content = String::from_utf8(bytes) let svg_raw_content = String::from_utf8(bytes)
.unwrap() .map_err(|e| Error::RecoverableError(e.to_string()))?
.trim_end_matches('\0') .trim_end_matches('\0')
.to_string(); .to_string();
shape shape.set_svg_raw_content(svg_raw_content);
.set_svg_raw_content(svg_raw_content)
.expect("Failed to set svg raw content");
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_opacity(opacity: f32) { #[wasm_error]
pub extern "C" fn set_shape_opacity(opacity: f32) -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_opacity(opacity); shape.set_opacity(opacity);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_hidden(hidden: bool) { #[wasm_error]
pub extern "C" fn set_shape_hidden(hidden: bool) -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_hidden(hidden); shape.set_hidden(hidden);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_corners(r1: f32, r2: f32, r3: f32, r4: f32) { #[wasm_error]
pub extern "C" fn set_shape_corners(r1: f32, r2: f32, r3: f32, r4: f32) -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_corners((r1, r2, r3, r4)); shape.set_corners((r1, r2, r3, r4));
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn get_selection_rect() -> *mut u8 { #[wasm_error]
pub extern "C" fn get_selection_rect() -> Result<*mut u8> {
let bytes = mem::bytes(); let bytes = mem::bytes();
let entries: Vec<Uuid> = bytes let entries: Vec<Uuid> = bytes
@ -619,40 +724,41 @@ pub extern "C" fn get_selection_rect() -> *mut u8 {
}) })
.collect(); .collect();
with_state_mut!(state, { let result_bound = with_state_mut!(state, {
let bbs: Vec<_> = entries let bbs: Vec<_> = entries
.iter() .iter()
.flat_map(|id| state.shapes.get(id).map(|b| b.bounds())) .flat_map(|id| state.shapes.get(id).map(|b| b.bounds()))
.collect(); .collect();
let result_bound = if bbs.len() == 1 { if bbs.len() == 1 {
bbs[0] bbs[0]
} else { } else {
Bounds::join_bounds(&bbs) Bounds::join_bounds(&bbs)
}; }
});
let width = result_bound.width(); let width = result_bound.width();
let height = result_bound.height(); let height = result_bound.height();
let center = result_bound.center(); let center = result_bound.center();
let transform = result_bound.transform_matrix().unwrap_or(Matrix::default()); let transform = result_bound.transform_matrix().unwrap_or(Matrix::default());
let mut bytes = vec![0; 40]; let mut bytes = vec![0; 40];
bytes[0..4].clone_from_slice(&width.to_le_bytes()); bytes[0..4].clone_from_slice(&width.to_le_bytes());
bytes[4..8].clone_from_slice(&height.to_le_bytes()); bytes[4..8].clone_from_slice(&height.to_le_bytes());
bytes[8..12].clone_from_slice(&center.x.to_le_bytes()); bytes[8..12].clone_from_slice(&center.x.to_le_bytes());
bytes[12..16].clone_from_slice(&center.y.to_le_bytes()); bytes[12..16].clone_from_slice(&center.y.to_le_bytes());
bytes[16..20].clone_from_slice(&transform[0].to_le_bytes()); bytes[16..20].clone_from_slice(&transform[0].to_le_bytes());
bytes[20..24].clone_from_slice(&transform[3].to_le_bytes()); bytes[20..24].clone_from_slice(&transform[3].to_le_bytes());
bytes[24..28].clone_from_slice(&transform[1].to_le_bytes()); bytes[24..28].clone_from_slice(&transform[1].to_le_bytes());
bytes[28..32].clone_from_slice(&transform[4].to_le_bytes()); bytes[28..32].clone_from_slice(&transform[4].to_le_bytes());
bytes[32..36].clone_from_slice(&transform[2].to_le_bytes()); bytes[32..36].clone_from_slice(&transform[2].to_le_bytes());
bytes[36..40].clone_from_slice(&transform[5].to_le_bytes()); bytes[36..40].clone_from_slice(&transform[5].to_le_bytes());
mem::write_bytes(bytes) Ok(mem::write_bytes(bytes))
})
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_structure_modifiers() { #[wasm_error]
pub extern "C" fn set_structure_modifiers() -> Result<()> {
let bytes = mem::bytes(); let bytes = mem::bytes();
let entries: Vec<_> = bytes let entries: Vec<_> = bytes
@ -690,18 +796,22 @@ pub extern "C" fn set_structure_modifiers() {
} }
}); });
mem::free_bytes(); mem::free_bytes()?;
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn clean_modifiers() { #[wasm_error]
pub extern "C" fn clean_modifiers() -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
state.shapes.clean_all(); state.shapes.clean_all();
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_modifiers() { #[wasm_error]
pub extern "C" fn set_modifiers() -> Result<()> {
let bytes = mem::bytes(); let bytes = mem::bytes();
let entries: Vec<_> = bytes let entries: Vec<_> = bytes
@ -720,26 +830,31 @@ pub extern "C" fn set_modifiers() {
state.set_modifiers(modifiers); state.set_modifiers(modifiers);
state.rebuild_modifier_tiles(ids); state.rebuild_modifier_tiles(ids);
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn start_temp_objects() { #[wasm_error]
pub extern "C" fn start_temp_objects() -> Result<()> {
unsafe { unsafe {
#[allow(static_mut_refs)] #[allow(static_mut_refs)]
let mut state = STATE.take().expect("Got an invalid state pointer"); let mut state = STATE.take().expect("Got an invalid state pointer");
state = Box::new(state.start_temp_objects()); state = Box::new(state.start_temp_objects());
STATE = Some(state); STATE = Some(state);
} }
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn end_temp_objects() { #[wasm_error]
pub extern "C" fn end_temp_objects() -> Result<()> {
unsafe { unsafe {
#[allow(static_mut_refs)] #[allow(static_mut_refs)]
let mut state = STATE.take().expect("Got an invalid state pointer"); let mut state = STATE.take().expect("Got an invalid state pointer");
state = Box::new(state.end_temp_objects()); state = Box::new(state.end_temp_objects());
STATE = Some(state); STATE = Some(state);
} }
Ok(())
} }
fn main() { fn main() {

View File

@ -1,29 +1,29 @@
use std::alloc::{alloc, Layout};
use std::ptr;
use std::sync::Mutex; use std::sync::Mutex;
const LAYOUT_ALIGN: usize = 4; use crate::error::{Error, Result, CRITICAL_ERROR};
static BUFFERU8: Mutex<Option<Vec<u8>>> = Mutex::new(None); pub const LAYOUT_ALIGN: usize = 4;
pub static BUFFERU8: Mutex<Option<Vec<u8>>> = Mutex::new(None);
pub static BUFFER_ERROR: Mutex<u8> = Mutex::new(0x00);
pub fn clear_error_code() {
let mut guard = BUFFER_ERROR.lock().unwrap();
*guard = 0x00;
}
/// Sets the error buffer from a byte. Used by #[wasm_error] when E: Into<u8>.
pub fn set_error_code(code: u8) {
let mut guard = BUFFER_ERROR.lock().unwrap();
*guard = code;
}
#[no_mangle] #[no_mangle]
pub extern "C" fn alloc_bytes(len: usize) -> *mut u8 { pub extern "C" fn read_error_code() -> u8 {
let mut guard = BUFFERU8.lock().unwrap(); if let Ok(guard) = BUFFER_ERROR.lock() {
*guard
if guard.is_some() { } else {
panic!("Bytes already allocated"); CRITICAL_ERROR
}
unsafe {
let layout = Layout::from_size_align_unchecked(len, LAYOUT_ALIGN);
let ptr = alloc(layout);
if ptr.is_null() {
panic!("Allocation failed");
}
// TODO: Maybe this could be removed.
ptr::write_bytes(ptr, 0, len);
*guard = Some(Vec::from_raw_parts(ptr, len, len));
ptr
} }
} }
@ -40,13 +40,6 @@ pub fn write_bytes(mut bytes: Vec<u8>) -> *mut u8 {
ptr ptr
} }
#[no_mangle]
pub extern "C" fn free_bytes() {
let mut guard = BUFFERU8.lock().unwrap();
*guard = None;
std::mem::drop(guard);
}
pub fn bytes() -> Vec<u8> { pub fn bytes() -> Vec<u8> {
let mut guard = BUFFERU8.lock().unwrap(); let mut guard = BUFFERU8.lock().unwrap();
guard.take().expect("Buffer is not initialized") guard.take().expect("Buffer is not initialized")
@ -57,6 +50,15 @@ pub fn bytes_or_empty() -> Vec<u8> {
guard.take().unwrap_or_default() guard.take().unwrap_or_default()
} }
pub fn free_bytes() -> Result<()> {
let mut guard = BUFFERU8
.lock()
.map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?;
*guard = None;
std::mem::drop(guard);
Ok(())
}
pub trait SerializableResult: From<Self::BytesType> + Into<Self::BytesType> { pub trait SerializableResult: From<Self::BytesType> + Into<Self::BytesType> {
type BytesType; type BytesType;
fn clone_to_slice(&self, slice: &mut [u8]); fn clone_to_slice(&self, slice: &mut [u8]);

View File

@ -53,6 +53,8 @@ pub struct NodeRenderState {
visited_mask: bool, visited_mask: bool,
// This bool indicates that we're drawing the mask shape. // This bool indicates that we're drawing the mask shape.
mask: bool, mask: bool,
// True when this container was flattened (enter/exit skipped).
flattened: bool,
} }
/// Get simplified children of a container, flattening nested flattened containers /// Get simplified children of a container, flattening nested flattened containers
@ -745,16 +747,17 @@ impl RenderState {
s.canvas().concat(transform); s.canvas().concat(transform);
}); });
// Hard clip edge (antialias = false) to avoid alpha seam when clipping
// semi-transparent content larger than the frame.
if let Some(corners) = corners { if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(*bounds, corners); let rrect = RRect::new_rect_radii(*bounds, corners);
self.surfaces.apply_mut(surface_ids, |s| { self.surfaces.apply_mut(surface_ids, |s| {
s.canvas() s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false);
.clip_rrect(rrect, skia::ClipOp::Intersect, antialias);
}); });
} else { } else {
self.surfaces.apply_mut(surface_ids, |s| { self.surfaces.apply_mut(surface_ids, |s| {
s.canvas() s.canvas()
.clip_rect(*bounds, skia::ClipOp::Intersect, antialias); .clip_rect(*bounds, skia::ClipOp::Intersect, false);
}); });
} }
@ -865,7 +868,7 @@ impl RenderState {
let text_stroke_blur_outset = let text_stroke_blur_outset =
Stroke::max_bounds_width(shape.visible_strokes(), false); Stroke::max_bounds_width(shape.visible_strokes(), false);
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let mut stroke_paragraphs_list = shape let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape
.visible_strokes() .visible_strokes()
.rev() .rev()
.map(|stroke| { .map(|stroke| {
@ -877,7 +880,7 @@ impl RenderState {
None, None,
) )
}) })
.collect::<Vec<_>>(); .unzip();
if fast_mode { if fast_mode {
// Fast path: render fills and strokes only (skip shadows/blur). // Fast path: render fills and strokes only (skip shadows/blur).
text::render( text::render(
@ -889,9 +892,13 @@ impl RenderState {
None, None,
None, None,
text_fill_inset, text_fill_inset,
None,
); );
for stroke_paragraphs in stroke_paragraphs_list.iter_mut() { for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list
.iter_mut()
.zip(stroke_opacities.iter())
{
text::render_with_bounds_outset( text::render_with_bounds_outset(
Some(self), Some(self),
None, None,
@ -902,6 +909,7 @@ impl RenderState {
None, None,
text_stroke_blur_outset, text_stroke_blur_outset,
None, None,
*layer_opacity,
); );
} }
} else { } else {
@ -915,7 +923,10 @@ impl RenderState {
let blur_filter = shape.image_filter(1.); let blur_filter = shape.image_filter(1.);
let mut paragraphs_with_shadows = let mut paragraphs_with_shadows =
text_content.paragraph_builder_group_from_text(Some(true)); text_content.paragraph_builder_group_from_text(Some(true));
let mut stroke_paragraphs_with_shadows_list = shape let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): (
Vec<_>,
Vec<_>,
) = shape
.visible_strokes() .visible_strokes()
.rev() .rev()
.map(|stroke| { .map(|stroke| {
@ -927,7 +938,7 @@ impl RenderState {
Some(true), Some(true),
) )
}) })
.collect::<Vec<_>>(); .unzip();
if let Some(parent_shadows) = parent_shadows { if let Some(parent_shadows) = parent_shadows {
if !shape.has_visible_strokes() { if !shape.has_visible_strokes() {
@ -941,6 +952,7 @@ impl RenderState {
Some(&shadow), Some(&shadow),
blur_filter.as_ref(), blur_filter.as_ref(),
None, None,
None,
); );
} }
} else { } else {
@ -967,6 +979,7 @@ impl RenderState {
Some(shadow), Some(shadow),
blur_filter.as_ref(), blur_filter.as_ref(),
None, None,
None,
); );
} }
} }
@ -981,6 +994,7 @@ impl RenderState {
None, None,
blur_filter.as_ref(), blur_filter.as_ref(),
text_fill_inset, text_fill_inset,
None,
); );
// 3. Stroke drop shadows // 3. Stroke drop shadows
@ -995,7 +1009,10 @@ impl RenderState {
); );
// 4. Stroke fills // 4. Stroke fills
for stroke_paragraphs in stroke_paragraphs_list.iter_mut() { for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list
.iter_mut()
.zip(stroke_opacities.iter())
{
text::render_with_bounds_outset( text::render_with_bounds_outset(
Some(self), Some(self),
None, None,
@ -1006,6 +1023,7 @@ impl RenderState {
blur_filter.as_ref(), blur_filter.as_ref(),
text_stroke_blur_outset, text_stroke_blur_outset,
None, None,
*layer_opacity,
); );
} }
@ -1032,6 +1050,7 @@ impl RenderState {
Some(shadow), Some(shadow),
blur_filter.as_ref(), blur_filter.as_ref(),
None, None,
None,
); );
} }
} }
@ -1462,6 +1481,7 @@ impl RenderState {
clip_bounds: None, clip_bounds: None,
visited_mask: true, visited_mask: true,
mask: false, mask: false,
flattened: false,
}); });
if let Some(&mask_id) = element.mask_id() { if let Some(&mask_id) = element.mask_id() {
self.pending_nodes.push(NodeRenderState { self.pending_nodes.push(NodeRenderState {
@ -1470,6 +1490,7 @@ impl RenderState {
clip_bounds: None, clip_bounds: None,
visited_mask: false, visited_mask: false,
mask: true, mask: true,
flattened: false,
}); });
} }
} }
@ -1999,8 +2020,7 @@ impl RenderState {
} }
if visited_children { if visited_children {
// Skip render_shape_exit for flattened containers if !node_render_state.flattened {
if !element.can_flatten() {
self.render_shape_exit(element, visited_mask, clip_bounds); self.render_shape_exit(element, visited_mask, clip_bounds);
} }
continue; continue;
@ -2149,6 +2169,7 @@ impl RenderState {
clip_bounds: clip_bounds.clone(), clip_bounds: clip_bounds.clone(),
visited_mask: false, visited_mask: false,
mask, mask,
flattened: can_flatten,
}); });
if element.is_recursive() { if element.is_recursive() {
@ -2175,13 +2196,13 @@ impl RenderState {
ids.reverse(); ids.reverse();
} }
// Sort by z_index descending (higher z renders on top). // Sort by z_index descending (higher z renders on top).
// When z_index is equal, absolute children go behind // When z_index is equal, absolute children go above
// non-absolute children (false < true). // non-absolute children
ids.sort_by_key(|id| { ids.sort_by_key(|id| {
let s = tree.get(id); let s = tree.get(id);
let z = s.map(|s| s.z_index()).unwrap_or(0); let z = s.map(|s| s.z_index()).unwrap_or(0);
let abs = s.map(|s| s.is_absolute()).unwrap_or(false); let abs = s.map(|s| s.is_absolute()).unwrap_or(false);
(std::cmp::Reverse(z), abs) (std::cmp::Reverse(z), !abs)
}); });
ids ids
} else { } else {
@ -2195,6 +2216,7 @@ impl RenderState {
clip_bounds: children_clip_bounds.clone(), clip_bounds: children_clip_bounds.clone(),
visited_mask: false, visited_mask: false,
mask: false, mask: false,
flattened: false,
}); });
} }
} }
@ -2309,6 +2331,7 @@ impl RenderState {
clip_bounds: None, clip_bounds: None,
visited_mask: false, visited_mask: false,
mask: false, mask: false,
flattened: false,
} }
})); }));
} }

View File

@ -155,6 +155,7 @@ pub fn render_text_shadows(
None, None,
blur_filter.as_ref(), blur_filter.as_ref(),
None, None,
None,
); );
for stroke_paragraphs in stroke_paragraphs_group.iter_mut() { for stroke_paragraphs in stroke_paragraphs_group.iter_mut() {
@ -167,6 +168,7 @@ pub fn render_text_shadows(
None, None,
blur_filter.as_ref(), blur_filter.as_ref(),
None, None,
None,
); );
} }

View File

@ -41,8 +41,8 @@ fn draw_stroke_on_rect(
} }
}; };
// By default just draw the rect. Only dotted inner/outer strokes need // Dotted inner/outer strokes need clipping to prevent the dotted
// clipping to prevent the dotted pattern from appearing in wrong areas. // pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() { if let Some(clip_op) = stroke.clip_op() {
// Use a neutral layer (no extra paint) so opacity and filters // Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying // come solely from the stroke paint. This avoids applying
@ -60,6 +60,35 @@ fn draw_stroke_on_rect(
} }
draw_stroke(); draw_stroke();
canvas.restore(); canvas.restore();
} else if stroke.kind == StrokeKind::Inner
&& (stroke.width >= rect.width() || stroke.width >= rect.height())
{
// When the inner stroke width exceeds a shape dimension, the inset
// rect goes negative and the stroke overflows outside the shape.
// Fall back to the same approach as the SVG renderer: draw with
// doubled width centered on the original shape and clip to it.
canvas.save();
match corners {
Some(radii) => {
let rrect = RRect::new_rect_radii(*rect, radii);
canvas.clip_rrect(rrect, skia::ClipOp::Intersect, antialias);
}
None => {
canvas.clip_rect(*rect, skia::ClipOp::Intersect, antialias);
}
}
let mut inner_paint = paint.clone();
inner_paint.set_stroke_width(stroke.width * 2.0);
match corners {
Some(radii) => {
let rrect = RRect::new_rect_radii(*rect, radii);
canvas.draw_rrect(rrect, &inner_paint);
}
None => {
canvas.draw_rect(*rect, &inner_paint);
}
}
canvas.restore();
} else { } else {
draw_stroke(); draw_stroke();
} }
@ -83,8 +112,8 @@ fn draw_stroke_on_circle(
let filter = compose_filters(blur, shadow); let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter); paint.set_image_filter(filter);
// By default just draw the circle. Only dotted inner/outer strokes need // Dotted inner/outer strokes need clipping to prevent the dotted
// clipping to prevent the dotted pattern from appearing in wrong areas. // pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() { if let Some(clip_op) = stroke.clip_op() {
// Use a neutral layer (no extra paint) so opacity and filters // Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying // come solely from the stroke paint. This avoids applying
@ -99,6 +128,24 @@ fn draw_stroke_on_circle(
canvas.clip_path(&clip_path, clip_op, antialias); canvas.clip_path(&clip_path, clip_op, antialias);
canvas.draw_oval(stroke_rect, &paint); canvas.draw_oval(stroke_rect, &paint);
canvas.restore(); canvas.restore();
} else if stroke.kind == StrokeKind::Inner
&& (stroke.width >= rect.width() || stroke.width >= rect.height())
{
// When the inner stroke width exceeds a shape dimension, the inset
// rect goes negative and the stroke overflows outside the shape.
// Fall back to the same approach as the SVG renderer: draw with
// doubled width centered on the original shape and clip to it.
canvas.save();
let clip_path = {
let mut pb = skia::PathBuilder::new();
pb.add_oval(rect, None, None);
pb.detach()
};
canvas.clip_path(&clip_path, skia::ClipOp::Intersect, antialias);
let mut inner_paint = paint.clone();
inner_paint.set_stroke_width(stroke.width * 2.0);
canvas.draw_oval(*rect, &inner_paint);
canvas.restore();
} else { } else {
canvas.draw_oval(stroke_rect, &paint); canvas.draw_oval(stroke_rect, &paint);
} }
@ -164,16 +211,25 @@ fn draw_stroke_on_path(
blur: Option<&ImageFilter>, blur: Option<&ImageFilter>,
antialias: bool, antialias: bool,
) { ) {
let skia_path = path
.to_skia_path()
.make_transform(path_transform.unwrap_or(&Matrix::default()));
let is_open = path.is_open(); let is_open = path.is_open();
let mut draw_paint = paint.clone(); let mut draw_paint = paint.clone();
let filter = compose_filters(blur, shadow); let filter = compose_filters(blur, shadow);
draw_paint.set_image_filter(filter); draw_paint.set_image_filter(filter);
// Move path_transform from the path geometry to the canvas so the
// stroke width is not distorted by non-uniform shape scaling.
// The path coordinates are already in world space, so we draw the
// raw path on a canvas where the shape transform has been undone:
// canvas * path_transform = View × parents (no shape scale/rotation)
// This matches the SVG renderer, which bakes the transform into path
// coordinates and never sets a transform attribute on the element.
let save_count = canvas.save();
if let Some(pt) = path_transform {
canvas.concat(pt);
}
let skia_path = path.to_skia_path();
match stroke.render_kind(is_open) { match stroke.render_kind(is_open) {
StrokeKind::Inner => { StrokeKind::Inner => {
draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias); draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
@ -187,6 +243,8 @@ fn draw_stroke_on_path(
} }
handle_stroke_caps(&skia_path, stroke, canvas, is_open, paint, blur, antialias); handle_stroke_caps(&skia_path, stroke, canvas, is_open, paint, blur, antialias);
canvas.restore_to_count(save_count);
} }
fn handle_stroke_cap( fn handle_stroke_cap(

View File

@ -20,11 +20,12 @@ pub fn stroke_paragraph_builder_group_from_text(
bounds: &Rect, bounds: &Rect,
count_inner_strokes: usize, count_inner_strokes: usize,
use_shadow: Option<bool>, use_shadow: Option<bool>,
) -> Vec<ParagraphBuilderGroup> { ) -> (Vec<ParagraphBuilderGroup>, Option<f32>) {
let fallback_fonts = get_fallback_fonts(); let fallback_fonts = get_fallback_fonts();
let fonts = get_font_collection(); let fonts = get_font_collection();
let mut paragraph_group = Vec::new(); let mut paragraph_group = Vec::new();
let remove_stroke_alpha = use_shadow.unwrap_or(false) && !stroke.is_transparent(); let remove_stroke_alpha = use_shadow.unwrap_or(false) && !stroke.is_transparent();
let mut group_layer_opacity: Option<f32> = None;
for paragraph in text_content.paragraphs() { for paragraph in text_content.paragraphs() {
let mut stroke_paragraphs_map: std::collections::HashMap<usize, ParagraphBuilder> = let mut stroke_paragraphs_map: std::collections::HashMap<usize, ParagraphBuilder> =
@ -32,7 +33,7 @@ pub fn stroke_paragraph_builder_group_from_text(
for span in paragraph.children().iter() { for span in paragraph.children().iter() {
let text_paint: skia_safe::Handle<_> = merge_fills(span.fills(), *bounds); let text_paint: skia_safe::Handle<_> = merge_fills(span.fills(), *bounds);
let stroke_paints = get_text_stroke_paints( let (stroke_paints, stroke_layer_opacity) = get_text_stroke_paints(
stroke, stroke,
bounds, bounds,
&text_paint, &text_paint,
@ -40,6 +41,10 @@ pub fn stroke_paragraph_builder_group_from_text(
remove_stroke_alpha, remove_stroke_alpha,
); );
if group_layer_opacity.is_none() {
group_layer_opacity = stroke_layer_opacity;
}
let text: String = span.apply_text_transform(); let text: String = span.apply_text_transform();
for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() { for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() {
@ -67,7 +72,7 @@ pub fn stroke_paragraph_builder_group_from_text(
paragraph_group.push(stroke_paragraphs); paragraph_group.push(stroke_paragraphs);
} }
paragraph_group (paragraph_group, group_layer_opacity)
} }
fn get_text_stroke_paints( fn get_text_stroke_paints(
@ -76,8 +81,25 @@ fn get_text_stroke_paints(
text_paint: &Paint, text_paint: &Paint,
count_inner_strokes: usize, count_inner_strokes: usize,
remove_stroke_alpha: bool, remove_stroke_alpha: bool,
) -> Vec<Paint> { ) -> (Vec<Paint>, Option<f32>) {
let mut paints = Vec::new(); let mut paints = Vec::new();
let mut layer_opacity: Option<f32> = None;
let stroke_opacity = stroke.fill.opacity();
let needs_opacity_layer = stroke_opacity < 1.0 && !remove_stroke_alpha;
let fill_for_paint = |paint: &mut Paint| {
if needs_opacity_layer {
let opaque_fill = stroke.fill.with_full_opacity();
set_paint_fill(paint, &opaque_fill, bounds, remove_stroke_alpha);
} else {
set_paint_fill(paint, &stroke.fill, bounds, remove_stroke_alpha);
}
};
if needs_opacity_layer {
layer_opacity = Some(stroke_opacity);
}
match stroke.kind { match stroke.kind {
StrokeKind::Inner => { StrokeKind::Inner => {
@ -99,7 +121,7 @@ fn get_text_stroke_paints(
paint.set_blend_mode(skia::BlendMode::SrcIn); paint.set_blend_mode(skia::BlendMode::SrcIn);
paint.set_anti_alias(true); paint.set_anti_alias(true);
paint.set_stroke_width(stroke.width * 2.0); paint.set_stroke_width(stroke.width * 2.0);
set_paint_fill(&mut paint, &stroke.fill, bounds, remove_stroke_alpha); fill_for_paint(&mut paint);
paints.push(paint); paints.push(paint);
} else { } else {
let mut paint = skia::Paint::default(); let mut paint = skia::Paint::default();
@ -108,7 +130,12 @@ fn get_text_stroke_paints(
paint.set_alpha(255); paint.set_alpha(255);
} else { } else {
paint = text_paint.clone(); paint = text_paint.clone();
set_paint_fill(&mut paint, &stroke.fill, bounds, false); if needs_opacity_layer {
let opaque_fill = stroke.fill.with_full_opacity();
set_paint_fill(&mut paint, &opaque_fill, bounds, false);
} else {
set_paint_fill(&mut paint, &stroke.fill, bounds, false);
}
} }
paint.set_style(skia::PaintStyle::Fill); paint.set_style(skia::PaintStyle::Fill);
@ -132,7 +159,7 @@ fn get_text_stroke_paints(
paint.set_style(skia::PaintStyle::Stroke); paint.set_style(skia::PaintStyle::Stroke);
paint.set_anti_alias(true); paint.set_anti_alias(true);
paint.set_stroke_width(stroke.width); paint.set_stroke_width(stroke.width);
set_paint_fill(&mut paint, &stroke.fill, bounds, remove_stroke_alpha); fill_for_paint(&mut paint);
paints.push(paint); paints.push(paint);
} }
StrokeKind::Outer => { StrokeKind::Outer => {
@ -141,7 +168,7 @@ fn get_text_stroke_paints(
paint.set_blend_mode(skia::BlendMode::DstOver); paint.set_blend_mode(skia::BlendMode::DstOver);
paint.set_anti_alias(true); paint.set_anti_alias(true);
paint.set_stroke_width(stroke.width * 2.0); paint.set_stroke_width(stroke.width * 2.0);
set_paint_fill(&mut paint, &stroke.fill, bounds, remove_stroke_alpha); fill_for_paint(&mut paint);
paints.push(paint); paints.push(paint);
let mut paint = skia::Paint::default(); let mut paint = skia::Paint::default();
@ -153,7 +180,7 @@ fn get_text_stroke_paints(
} }
} }
paints (paints, layer_opacity)
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@ -167,6 +194,7 @@ pub fn render_with_bounds_outset(
blur: Option<&ImageFilter>, blur: Option<&ImageFilter>,
stroke_bounds_outset: f32, stroke_bounds_outset: f32,
fill_inset: Option<f32>, fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) { ) {
if let Some(render_state) = render_state { if let Some(render_state) = render_state {
let target_surface = surface_id.unwrap_or(SurfaceId::Fills); let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
@ -195,6 +223,7 @@ pub fn render_with_bounds_outset(
shadow, shadow,
Some(&blur_filter_clone), Some(&blur_filter_clone),
fill_inset, fill_inset,
layer_opacity,
); );
}, },
) { ) {
@ -204,12 +233,28 @@ pub fn render_with_bounds_outset(
} }
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset); render_text_on_canvas(
canvas,
shape,
paragraph_builders,
shadow,
blur,
fill_inset,
layer_opacity,
);
return; return;
} }
if let Some(canvas) = canvas { if let Some(canvas) = canvas {
render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset); render_text_on_canvas(
canvas,
shape,
paragraph_builders,
shadow,
blur,
fill_inset,
layer_opacity,
);
} }
} }
@ -223,6 +268,7 @@ pub fn render(
shadow: Option<&Paint>, shadow: Option<&Paint>,
blur: Option<&ImageFilter>, blur: Option<&ImageFilter>,
fill_inset: Option<f32>, fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) { ) {
render_with_bounds_outset( render_with_bounds_outset(
render_state, render_state,
@ -234,6 +280,7 @@ pub fn render(
blur, blur,
0.0, 0.0,
fill_inset, fill_inset,
layer_opacity,
); );
} }
@ -244,6 +291,7 @@ fn render_text_on_canvas(
shadow: Option<&Paint>, shadow: Option<&Paint>,
blur: Option<&ImageFilter>, blur: Option<&ImageFilter>,
fill_inset: Option<f32>, fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) { ) {
if let Some(blur_filter) = blur { if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default(); let mut blur_paint = Paint::default();
@ -255,7 +303,7 @@ fn render_text_on_canvas(
if let Some(shadow_paint) = shadow { if let Some(shadow_paint) = shadow {
let layer_rec = SaveLayerRec::default().paint(shadow_paint); let layer_rec = SaveLayerRec::default().paint(shadow_paint);
canvas.save_layer(&layer_rec); canvas.save_layer(&layer_rec);
draw_text(canvas, shape, paragraph_builders); draw_text(canvas, shape, paragraph_builders, layer_opacity);
canvas.restore(); canvas.restore();
} else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) { } else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) {
if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) { if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) {
@ -263,13 +311,13 @@ fn render_text_on_canvas(
layer_paint.set_image_filter(erode); layer_paint.set_image_filter(erode);
let layer_rec = SaveLayerRec::default().paint(&layer_paint); let layer_rec = SaveLayerRec::default().paint(&layer_paint);
canvas.save_layer(&layer_rec); canvas.save_layer(&layer_rec);
draw_text(canvas, shape, paragraph_builders); draw_text(canvas, shape, paragraph_builders, layer_opacity);
canvas.restore(); canvas.restore();
} else { } else {
draw_text(canvas, shape, paragraph_builders); draw_text(canvas, shape, paragraph_builders, layer_opacity);
} }
} else { } else {
draw_text(canvas, shape, paragraph_builders); draw_text(canvas, shape, paragraph_builders, layer_opacity);
} }
if blur.is_some() { if blur.is_some() {
@ -283,13 +331,20 @@ fn draw_text(
canvas: &Canvas, canvas: &Canvas,
shape: &Shape, shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>], paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
layer_opacity: Option<f32>,
) { ) {
let text_content = shape.get_text_content(); let text_content = shape.get_text_content();
let layout_info = let layout_info =
calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true); calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
let layer_rec = SaveLayerRec::default(); if let Some(opacity) = layer_opacity {
canvas.save_layer(&layer_rec); let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity);
let layer_rec = SaveLayerRec::default().paint(&opacity_paint);
canvas.save_layer(&layer_rec);
} else {
canvas.save_layer(&SaveLayerRec::default());
}
for para in &layout_info.paragraphs { for para in &layout_info.paragraphs {
para.paragraph.paint(canvas, (para.x, para.y)); para.paragraph.paint(canvas, (para.x, para.y));

View File

@ -66,7 +66,7 @@ fn render_selection(
} }
let mut paint = Paint::default(); let mut paint = Paint::default();
paint.set_blend_mode(BlendMode::Multiply); paint.set_blend_mode(BlendMode::default());
paint.set_color(editor_state.theme.selection_color); paint.set_color(editor_state.theme.selection_color);
paint.set_anti_alias(true); paint.set_anti_alias(true);

View File

@ -705,9 +705,8 @@ impl Shape {
self.invalidate_extrect(); self.invalidate_extrect();
} }
pub fn set_svg_raw_content(&mut self, content: String) -> Result<(), String> { pub fn set_svg_raw_content(&mut self, content: String) {
self.shape_type = Type::SVGRaw(SVGRaw::from_content(content)); self.shape_type = Type::SVGRaw(SVGRaw::from_content(content));
Ok(())
} }
pub fn set_blend_mode(&mut self, mode: BlendMode) { pub fn set_blend_mode(&mut self, mode: BlendMode) {

View File

@ -140,6 +140,38 @@ pub enum Fill {
} }
impl Fill { impl Fill {
pub fn opacity(&self) -> f32 {
match self {
Fill::Solid(SolidColor(color)) => color.a() as f32 / 255.0,
Fill::LinearGradient(g) => g.opacity as f32 / 255.0,
Fill::RadialGradient(g) => g.opacity as f32 / 255.0,
Fill::Image(i) => i.opacity as f32 / 255.0,
}
}
pub fn with_full_opacity(&self) -> Fill {
match self {
Fill::Solid(SolidColor(color)) => Fill::Solid(SolidColor(skia::Color::from_argb(
255,
color.r(),
color.g(),
color.b(),
))),
Fill::LinearGradient(g) => Fill::LinearGradient(Gradient {
opacity: 255,
..g.clone()
}),
Fill::RadialGradient(g) => Fill::RadialGradient(Gradient {
opacity: 255,
..g.clone()
}),
Fill::Image(i) => Fill::Image(ImageFill {
opacity: 255,
..i.clone()
}),
}
}
pub fn to_paint(&self, rect: &Rect, anti_alias: bool) -> skia::Paint { pub fn to_paint(&self, rect: &Rect, anti_alias: bool) -> skia::Paint {
match self { match self {
Self::Solid(SolidColor(color)) => { Self::Solid(SolidColor(color)) => {

View File

@ -161,6 +161,13 @@ impl TextPositionWithAffinity {
offset, offset,
} }
} }
pub fn reset(&mut self) {
self.position_with_affinity.position = 0;
self.position_with_affinity.affinity = Affinity::Downstream;
self.paragraph = 0;
self.offset = 0;
}
} }
#[derive(Debug)] #[derive(Debug)]
@ -569,6 +576,7 @@ impl TextContent {
for paragraph in self.paragraphs() { for paragraph in self.paragraphs() {
let paragraph_style = paragraph.paragraph_to_style(); let paragraph_style = paragraph.paragraph_to_style();
let mut builder = ParagraphBuilder::new(&paragraph_style, fonts); let mut builder = ParagraphBuilder::new(&paragraph_style, fonts);
let mut has_text = false;
for span in paragraph.children() { for span in paragraph.children() {
let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent(); let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent();
let text_style = span.to_style( let text_style = span.to_style(
@ -578,9 +586,15 @@ impl TextContent {
paragraph.line_height(), paragraph.line_height(),
); );
let text: String = span.apply_text_transform(); let text: String = span.apply_text_transform();
if !text.is_empty() {
has_text = true;
}
builder.push_style(&text_style); builder.push_style(&text_style);
builder.add_text(&text); builder.add_text(&text);
} }
if !has_text {
builder.add_text(" ");
}
paragraph_group.push(vec![builder]); paragraph_group.push(vec![builder]);
} }

View File

@ -33,6 +33,11 @@ impl TextSelection {
!self.is_collapsed() !self.is_collapsed()
} }
pub fn reset(&mut self) {
self.anchor.reset();
self.focus.reset();
}
pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) { pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) {
self.anchor = cursor; self.anchor = cursor;
self.focus = cursor; self.focus = cursor;
@ -86,7 +91,7 @@ pub enum TextEditorEvent {
} }
/// FIXME: It should be better to get these constants from the frontend through the API. /// FIXME: It should be better to get these constants from the frontend through the API.
const SELECTION_COLOR: Color = Color::from_argb(255, 0, 209, 184); const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184);
const CURSOR_WIDTH: f32 = 1.5; const CURSOR_WIDTH: f32 = 1.5;
const CURSOR_COLOR: Color = Color::BLACK; const CURSOR_COLOR: Color = Color::BLACK;
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0; const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
@ -133,7 +138,7 @@ impl TextEditorState {
self.active_shape_id = Some(shape_id); self.active_shape_id = Some(shape_id);
self.cursor_visible = true; self.cursor_visible = true;
self.last_blink_time = 0.0; self.last_blink_time = 0.0;
self.selection = TextSelection::new(); self.selection.reset();
self.is_pointer_selection_active = false; self.is_pointer_selection_active = false;
self.pending_events.clear(); self.pending_events.clear();
} }
@ -142,9 +147,10 @@ impl TextEditorState {
self.is_active = false; self.is_active = false;
self.active_shape_id = None; self.active_shape_id = None;
self.cursor_visible = false; self.cursor_visible = false;
self.last_blink_time = 0.0;
self.selection.reset();
self.is_pointer_selection_active = false; self.is_pointer_selection_active = false;
self.pending_events.clear(); self.pending_events.clear();
self.reset_blink();
} }
pub fn start_pointer_selection(&mut self) -> bool { pub fn start_pointer_selection(&mut self) -> bool {
@ -193,15 +199,83 @@ impl TextEditorState {
true true
} }
pub fn select_word_boundary(
&mut self,
content: &TextContent,
position: &TextPositionWithAffinity,
) {
self.is_pointer_selection_active = false;
let paragraphs = content.paragraphs();
if paragraphs.is_empty() || position.paragraph >= paragraphs.len() {
return;
}
let paragraph = &paragraphs[position.paragraph];
let paragraph_text: String = paragraph
.children()
.iter()
.map(|span| span.text.as_str())
.collect();
let chars: Vec<char> = paragraph_text.chars().collect();
if chars.is_empty() {
self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity(
position.paragraph,
0,
));
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
return;
}
let mut offset = position.offset.min(chars.len());
if offset == chars.len() {
offset = offset.saturating_sub(1);
} else if !is_word_char(chars[offset]) && offset > 0 && is_word_char(chars[offset - 1]) {
offset -= 1;
}
if !is_word_char(chars[offset]) {
self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity(
position.paragraph,
position.offset.min(chars.len()),
));
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
return;
}
let mut start = offset;
while start > 0 && is_word_char(chars[start - 1]) {
start -= 1;
}
let mut end = offset + 1;
while end < chars.len() && is_word_char(chars[end]) {
end += 1;
}
self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity(
position.paragraph,
start,
));
self.extend_selection_from_position(&TextPositionWithAffinity::new_without_affinity(
position.paragraph,
end,
));
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
}
pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.set_caret(*position); self.selection.set_caret(*position);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged); self.push_event(TextEditorEvent::SelectionChanged);
} }
pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) { pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.extend_to(*position); self.selection.extend_to(*position);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged); self.push_event(TextEditorEvent::SelectionChanged);
} }
@ -242,3 +316,7 @@ impl TextEditorState {
!self.pending_events.is_empty() !self.pending_events.is_empty()
} }
} }
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}

View File

@ -3,6 +3,7 @@ pub mod blurs;
pub mod fills; pub mod fills;
pub mod fonts; pub mod fonts;
pub mod layouts; pub mod layouts;
pub mod mem;
pub mod paths; pub mod paths;
pub mod shadows; pub mod shadows;
pub mod shapes; pub mod shapes;

View File

@ -1,4 +1,4 @@
use macros::ToJs; use macros::{wasm_error, ToJs};
use crate::mem; use crate::mem;
use crate::shapes; use crate::shapes;
@ -67,7 +67,8 @@ pub fn parse_fills_from_bytes(buffer: &[u8], num_fills: usize) -> Vec<shapes::Fi
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_fills() { #[wasm_error]
pub extern "C" fn set_shape_fills() -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
let bytes = mem::bytes(); let bytes = mem::bytes();
// The first byte contains the actual number of fills // The first byte contains the actual number of fills
@ -75,8 +76,9 @@ pub extern "C" fn set_shape_fills() {
// Skip the first 4 bytes (header with fill count) and parse only the actual fills // Skip the first 4 bytes (header with fill count) and parse only the actual fills
let fills = parse_fills_from_bytes(&bytes[4..], num_fills); let fills = parse_fills_from_bytes(&bytes[4..], num_fills);
shape.set_fills(fills); shape.set_fills(fills);
mem::free_bytes(); mem::free_bytes()?;
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]

View File

@ -1,5 +1,7 @@
use crate::mem; use crate::mem;
use macros::wasm_error;
// use crate::mem::SerializableResult; // use crate::mem::SerializableResult;
use crate::error::Error;
use crate::uuid::Uuid; use crate::uuid::Uuid;
use crate::with_state_mut; use crate::with_state_mut;
use crate::STATE; use crate::STATE;
@ -65,7 +67,8 @@ impl TryFrom<Vec<u8>> for ShapeImageIds {
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn store_image() { #[wasm_error]
pub extern "C" fn store_image() -> crate::error::Result<()> {
let bytes = mem::bytes(); let bytes = mem::bytes();
let ids = ShapeImageIds::try_from(bytes[0..IMAGE_IDS_SIZE].to_vec()).unwrap(); let ids = ShapeImageIds::try_from(bytes[0..IMAGE_IDS_SIZE].to_vec()).unwrap();
@ -87,7 +90,8 @@ pub extern "C" fn store_image() {
state.touch_shape(ids.shape_id); state.touch_shape(ids.shape_id);
}); });
mem::free_bytes(); mem::free_bytes()?;
Ok(())
} }
/// Stores an image from an existing WebGL texture, avoiding re-decoding /// Stores an image from an existing WebGL texture, avoiding re-decoding
@ -99,13 +103,17 @@ pub extern "C" fn store_image() {
/// - bytes 40-43: width (i32) /// - bytes 40-43: width (i32)
/// - bytes 44-47: height (i32) /// - bytes 44-47: height (i32)
#[no_mangle] #[no_mangle]
pub extern "C" fn store_image_from_texture() { #[wasm_error]
pub extern "C" fn store_image_from_texture() -> crate::error::Result<()> {
let bytes = mem::bytes(); let bytes = mem::bytes();
if bytes.len() < 48 { if bytes.len() < 48 {
// FIXME: Review if this should be an critical or a recoverable error.
eprintln!("store_image_from_texture: insufficient data"); eprintln!("store_image_from_texture: insufficient data");
mem::free_bytes(); mem::free_bytes()?;
return; return Err(Error::RecoverableError(
"store_image_from_texture: insufficient data".to_string(),
));
} }
let ids = ShapeImageIds::try_from(bytes[0..IMAGE_IDS_SIZE].to_vec()).unwrap(); let ids = ShapeImageIds::try_from(bytes[0..IMAGE_IDS_SIZE].to_vec()).unwrap();
@ -139,5 +147,6 @@ pub extern "C" fn store_image_from_texture() {
state.touch_shape(ids.shape_id); state.touch_shape(ids.shape_id);
}); });
mem::free_bytes(); mem::free_bytes()?;
Ok(())
} }

View File

@ -1,4 +1,4 @@
use macros::ToJs; use macros::{wasm_error, ToJs};
use crate::mem; use crate::mem;
use crate::shapes::{FontFamily, FontStyle}; use crate::shapes::{FontFamily, FontStyle};
@ -30,6 +30,7 @@ impl From<RawFontStyle> for FontStyle {
} }
#[no_mangle] #[no_mangle]
#[wasm_error]
pub extern "C" fn store_font( pub extern "C" fn store_font(
a: u32, a: u32,
b: u32, b: u32,
@ -39,7 +40,7 @@ pub extern "C" fn store_font(
style: u8, style: u8,
is_emoji: bool, is_emoji: bool,
is_fallback: bool, is_fallback: bool,
) { ) -> Result<()> {
with_state_mut!(state, { with_state_mut!(state, {
let id = uuid_from_u32_quartet(a, b, c, d); let id = uuid_from_u32_quartet(a, b, c, d);
let font_bytes = mem::bytes(); let font_bytes = mem::bytes();
@ -52,8 +53,9 @@ pub extern "C" fn store_font(
.fonts_mut() .fonts_mut()
.add(family, &font_bytes, is_emoji, is_fallback); .add(family, &font_bytes, is_emoji, is_fallback);
mem::free_bytes(); mem::free_bytes()?;
}); });
Ok(())
} }
#[no_mangle] #[no_mangle]

View File

@ -1,4 +1,4 @@
use macros::ToJs; use macros::{wasm_error, ToJs};
use crate::mem; use crate::mem;
use crate::shapes::{GridCell, GridDirection, GridTrack, GridTrackType}; use crate::shapes::{GridCell, GridDirection, GridTrack, GridTrackType};
@ -7,6 +7,9 @@ use crate::{uuid_from_u32_quartet, with_current_shape_mut, with_state, with_stat
use super::align; use super::align;
#[allow(unused_imports)]
use crate::error::Result;
#[derive(Debug)] #[derive(Debug)]
#[repr(C, align(1))] #[repr(C, align(1))]
struct RawGridCell { struct RawGridCell {
@ -168,7 +171,8 @@ pub extern "C" fn set_grid_layout_data(
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_grid_columns() { #[wasm_error]
pub extern "C" fn set_grid_columns() -> Result<()> {
let bytes = mem::bytes(); let bytes = mem::bytes();
let entries: Vec<GridTrack> = bytes let entries: Vec<GridTrack> = bytes
@ -181,11 +185,13 @@ pub extern "C" fn set_grid_columns() {
shape.set_grid_columns(entries); shape.set_grid_columns(entries);
}); });
mem::free_bytes(); mem::free_bytes()?;
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_grid_rows() { #[wasm_error]
pub extern "C" fn set_grid_rows() -> Result<()> {
let bytes = mem::bytes(); let bytes = mem::bytes();
let entries: Vec<GridTrack> = bytes let entries: Vec<GridTrack> = bytes
@ -198,11 +204,13 @@ pub extern "C" fn set_grid_rows() {
shape.set_grid_rows(entries); shape.set_grid_rows(entries);
}); });
mem::free_bytes(); mem::free_bytes()?;
Ok(())
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_grid_cells() { #[wasm_error]
pub extern "C" fn set_grid_cells() -> Result<()> {
let bytes = mem::bytes(); let bytes = mem::bytes();
let cells: Vec<RawGridCell> = bytes let cells: Vec<RawGridCell> = bytes
@ -215,7 +223,8 @@ pub extern "C" fn set_grid_cells() {
shape.set_grid_cells(cells.into_iter().map(|raw| raw.into()).collect()); shape.set_grid_cells(cells.into_iter().map(|raw| raw.into()).collect());
}); });
mem::free_bytes(); mem::free_bytes()?;
Ok(())
} }
#[no_mangle] #[no_mangle]

View File

@ -0,0 +1,38 @@
use std::alloc::{alloc, Layout};
use std::ptr;
#[allow(unused_imports)]
use crate::error::{Error, Result};
use crate::mem::{BUFFERU8, LAYOUT_ALIGN};
use macros::wasm_error;
#[no_mangle]
#[wasm_error]
pub extern "C" fn alloc_bytes(len: usize) -> Result<*mut u8> {
let mut guard = BUFFERU8
.lock()
.map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?;
if guard.is_some() {
return Err(Error::CriticalError("Bytes already allocated".to_string()));
}
unsafe {
let layout = Layout::from_size_align_unchecked(len, LAYOUT_ALIGN);
let ptr = alloc(layout);
if ptr.is_null() {
return Err(Error::CriticalError("Allocation failed".to_string()));
}
// TODO: Maybe this could be removed.
ptr::write_bytes(ptr, 0, len);
*guard = Some(Vec::from_raw_parts(ptr, len, len));
Ok(ptr)
}
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn free_bytes() -> Result<()> {
crate::mem::free_bytes()?;
Ok(())
}

View File

@ -1,5 +1,5 @@
#![allow(unused_mut, unused_variables)] #![allow(unused_mut, unused_variables)]
use macros::ToJs; use macros::{wasm_error, ToJs};
use mem::SerializableResult; use mem::SerializableResult;
use std::mem::size_of; use std::mem::size_of;
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
@ -161,12 +161,14 @@ pub extern "C" fn start_shape_path_buffer() {
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_path_chunk_buffer() { #[wasm_error]
pub extern "C" fn set_shape_path_chunk_buffer() -> Result<()> {
let bytes = mem::bytes(); let bytes = mem::bytes();
let buffer = get_path_upload_buffer(); let buffer = get_path_upload_buffer();
let mut buffer = buffer.lock().unwrap(); let mut buffer = buffer.lock().unwrap();
buffer.extend_from_slice(&bytes); buffer.extend_from_slice(&bytes);
mem::free_bytes(); mem::free_bytes()?;
Ok(())
} }
#[no_mangle] #[no_mangle]

View File

@ -1,4 +1,4 @@
use macros::ToJs; use macros::{wasm_error, ToJs};
use super::RawSegmentData; use super::RawSegmentData;
use crate::math; use crate::math;
@ -8,6 +8,9 @@ use crate::{mem, SerializableResult};
use crate::{with_current_shape_mut, with_state, STATE}; use crate::{with_current_shape_mut, with_state, STATE};
use std::mem::size_of; use std::mem::size_of;
#[allow(unused_imports)]
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, ToJs)] #[derive(Debug, Clone, Copy, PartialEq, ToJs)]
#[repr(u8)] #[repr(u8)]
#[allow(dead_code)] #[allow(dead_code)]
@ -43,15 +46,19 @@ pub extern "C" fn set_shape_bool_type(raw_bool_type: u8) {
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn calculate_bool(raw_bool_type: u8) -> *mut u8 { #[wasm_error]
pub extern "C" fn calculate_bool(raw_bool_type: u8) -> Result<*mut u8> {
let bytes = mem::bytes_or_empty(); let bytes = mem::bytes_or_empty();
let entries: Vec<Uuid> = bytes let entries: Vec<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>()) .chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::try_from(data).unwrap()) .map(|data| {
.collect(); // FIXME: Review if this should be an critical or a recoverable error.
Uuid::try_from(data).map_err(|_| Error::RecoverableError("Invalid UUID".to_string()))
})
.collect::<Result<Vec<Uuid>>>()?;
mem::free_bytes(); mem::free_bytes()?;
let bool_type = RawBoolType::from(raw_bool_type).into(); let bool_type = RawBoolType::from(raw_bool_type).into();
let result; let result;
@ -64,5 +71,5 @@ pub extern "C" fn calculate_bool(raw_bool_type: u8) -> *mut u8 {
.map(RawSegmentData::from_segment) .map(RawSegmentData::from_segment)
.collect(); .collect();
}); });
mem::write_vec(result) Ok(mem::write_vec(result))
} }

View File

@ -1,4 +1,4 @@
use macros::ToJs; use macros::{wasm_error, ToJs};
use super::{fills::RawFillData, fonts::RawFontStyle}; use super::{fills::RawFillData, fonts::RawFontStyle};
@ -9,6 +9,8 @@ use crate::shapes::{
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_mut, STATE}; use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_mut, STATE};
use crate::error::Error;
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>(); const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>(); const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
@ -285,16 +287,22 @@ pub extern "C" fn clear_shape_text() {
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_shape_text_content() { #[wasm_error]
pub extern "C" fn set_shape_text_content() -> crate::error::Result<()> {
let bytes = mem::bytes(); let bytes = mem::bytes();
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
let raw_text_data = RawParagraph::try_from(&bytes).unwrap(); let raw_text_data = RawParagraph::try_from(&bytes).unwrap();
if shape.add_paragraph(raw_text_data.into()).is_err() { shape.add_paragraph(raw_text_data.into()).map_err(|_| {
println!("Error with set_shape_text_content on {:?}", shape.id); Error::RecoverableError(format!(
} "Error with set_shape_text_content on {:?}",
shape.id
))
})?;
}); });
mem::free_bytes();
mem::free_bytes()?;
Ok(())
} }
#[no_mangle] #[no_mangle]

View File

@ -1,3 +1,5 @@
use macros::{wasm_error, ToJs};
use crate::math::{Matrix, Point, Rect}; use crate::math::{Matrix, Point, Rect};
use crate::mem; use crate::mem;
use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
@ -5,7 +7,7 @@ use crate::state::TextSelection;
use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_from_u32_quartet;
use crate::utils::uuid_to_u32_quartet; use crate::utils::uuid_to_u32_quartet;
use crate::{with_state, with_state_mut, STATE}; use crate::{with_state, with_state_mut, STATE};
use macros::ToJs; use skia_safe::{textlayout::TextDirection, Color};
#[derive(PartialEq, ToJs)] #[derive(PartialEq, ToJs)]
#[repr(u8)] #[repr(u8)]
@ -23,6 +25,21 @@ pub enum CursorDirection {
// STATE MANAGEMENT // STATE MANAGEMENT
// ============================================================================ // ============================================================================
#[no_mangle]
pub extern "C" fn text_editor_apply_theme(
selection_color: u32,
cursor_width: f32,
cursor_color: u32,
) {
with_state_mut!(state, {
// NOTE: In the future could be interesting to fill al this data from
// a structure pointer.
state.text_editor_state.theme.selection_color = Color::new(selection_color);
state.text_editor_state.theme.cursor_width = cursor_width;
state.text_editor_state.theme.cursor_color = Color::new(cursor_color);
})
}
#[no_mangle] #[no_mangle]
pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool { pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool {
with_state_mut!(state, { with_state_mut!(state, {
@ -42,10 +59,14 @@ pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool {
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn text_editor_stop() { pub extern "C" fn text_editor_stop() -> bool {
with_state_mut!(state, { with_state_mut!(state, {
if !state.text_editor_state.is_active {
return false;
}
state.text_editor_state.stop(); state.text_editor_state.stop();
}); true
})
} }
#[no_mangle] #[no_mangle]
@ -101,6 +122,34 @@ pub extern "C" fn text_editor_select_all() -> bool {
}) })
} }
#[no_mangle]
pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let Type::Text(text_content) = &shape.shape_type else {
return;
};
let point = Point::new(x, y);
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
state
.text_editor_state
.select_word_boundary(text_content, &position);
}
})
}
#[no_mangle] #[no_mangle]
pub extern "C" fn text_editor_poll_event() -> u8 { pub extern "C" fn text_editor_poll_event() -> u8 {
with_state_mut!(state, { state.text_editor_state.poll_event() as u8 }) with_state_mut!(state, { state.text_editor_state.poll_event() as u8 })
@ -126,12 +175,8 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
return; return;
}; };
let point = Point::new(x, y); let point = Point::new(x, y);
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let shape_matrix = shape.get_matrix();
state.text_editor_state.start_pointer_selection(); state.text_editor_state.start_pointer_selection();
if let Some(position) = if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state.text_editor_state.set_caret_from_position(&position); state.text_editor_state.set_caret_from_position(&position);
} }
}); });
@ -143,7 +188,6 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
if !state.text_editor_state.is_active { if !state.text_editor_state.is_active {
return; return;
} }
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y); let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else { let Some(shape_id) = state.text_editor_state.active_shape_id else {
return; return;
@ -151,11 +195,6 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
let Some(shape) = state.shapes.get(&shape_id) else { let Some(shape) = state.shapes.get(&shape_id) else {
return; return;
}; };
let shape_matrix = shape.get_matrix();
let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix)
else {
return;
};
if !state.text_editor_state.is_pointer_selection_active { if !state.text_editor_state.is_pointer_selection_active {
return; return;
} }
@ -163,9 +202,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
return; return;
}; };
if let Some(position) = if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state state
.text_editor_state .text_editor_state
.extend_selection_from_position(&position); .extend_selection_from_position(&position);
@ -179,7 +216,6 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
if !state.text_editor_state.is_active { if !state.text_editor_state.is_active {
return; return;
} }
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y); let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else { let Some(shape_id) = state.text_editor_state.active_shape_id else {
return; return;
@ -187,20 +223,13 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
let Some(shape) = state.shapes.get(&shape_id) else { let Some(shape) = state.shapes.get(&shape_id) else {
return; return;
}; };
let shape_matrix = shape.get_matrix();
let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix)
else {
return;
};
if !state.text_editor_state.is_pointer_selection_active { if !state.text_editor_state.is_pointer_selection_active {
return; return;
} }
let Type::Text(text_content) = &shape.shape_type else { let Type::Text(text_content) = &shape.shape_type else {
return; return;
}; };
if let Some(position) = if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state state
.text_editor_state .text_editor_state
.extend_selection_from_position(&position); .extend_selection_from_position(&position);
@ -209,6 +238,29 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
}); });
} }
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let Type::Text(text_content) = &shape.shape_type else {
return;
};
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
state.text_editor_state.set_caret_from_position(&position);
}
});
}
#[no_mangle] #[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
with_state_mut!(state, { with_state_mut!(state, {
@ -240,29 +292,31 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
// TEXT OPERATIONS // TEXT OPERATIONS
// ============================================================================ // ============================================================================
// FIXME: Review if all the return Ok(()) should be Err instead.
#[no_mangle] #[no_mangle]
pub extern "C" fn text_editor_insert_text() { #[wasm_error]
pub extern "C" fn text_editor_insert_text() -> Result<()> {
let bytes = crate::mem::bytes(); let bytes = crate::mem::bytes();
let text = match String::from_utf8(bytes) { let text = match String::from_utf8(bytes) {
Ok(s) => s, Ok(text) => text,
Err(_) => return, Err(_) => return Ok(()),
}; };
with_state_mut!(state, { with_state_mut!(state, {
if !state.text_editor_state.is_active { if !state.text_editor_state.is_active {
return; return Ok(());
} }
let Some(shape_id) = state.text_editor_state.active_shape_id else { let Some(shape_id) = state.text_editor_state.active_shape_id else {
return; return Ok(());
}; };
let Some(shape) = state.shapes.get_mut(&shape_id) else { let Some(shape) = state.shapes.get_mut(&shape_id) else {
return; return Ok(());
}; };
let Type::Text(text_content) = &mut shape.shape_type else { let Type::Text(text_content) = &mut shape.shape_type else {
return; return Ok(());
}; };
let selection = state.text_editor_state.selection; let selection = state.text_editor_state.selection;
@ -275,9 +329,7 @@ pub extern "C" fn text_editor_insert_text() {
let cursor = state.text_editor_state.selection.focus; let cursor = state.text_editor_state.selection.focus;
if let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) { if let Some(new_cursor) = insert_text_with_newlines(text_content, &cursor, &text) {
let new_cursor =
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, new_offset);
state.text_editor_state.selection.set_caret(new_cursor); state.text_editor_state.selection.set_caret(new_cursor);
} }
@ -295,7 +347,8 @@ pub extern "C" fn text_editor_insert_text() {
state.render_state.mark_touched(shape_id); state.render_state.mark_touched(shape_id);
}); });
crate::mem::free_bytes(); crate::mem::free_bytes()?;
Ok(())
} }
#[no_mangle] #[no_mangle]
@ -474,7 +527,25 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
let current = state.text_editor_state.selection.focus; let current = state.text_editor_state.selection.focus;
let new_cursor = match direction { // Get the text direction of the span at the current cursor position
let span_text_direction = if current.paragraph < paragraphs.len() {
get_span_text_direction_at_offset(&paragraphs[current.paragraph], current.offset)
} else {
TextDirection::LTR
};
// For horizontal navigation, swap Backward/Forward when in RTL text
let adjusted_direction = if span_text_direction == TextDirection::RTL {
match direction {
CursorDirection::Backward => CursorDirection::Forward,
CursorDirection::Forward => CursorDirection::Backward,
other => other,
}
} else {
direction
};
let new_cursor = match adjusted_direction {
CursorDirection::Backward => move_cursor_backward(&current, paragraphs), CursorDirection::Backward => move_cursor_backward(&current, paragraphs),
CursorDirection::Forward => move_cursor_forward(&current, paragraphs), CursorDirection::Forward => move_cursor_forward(&current, paragraphs),
CursorDirection::LineBefore => { CursorDirection::LineBefore => {
@ -744,12 +815,10 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 {
char_pos += span_len; char_pos += span_len;
} }
} }
if !para_text.is_empty() { if para_idx > start.paragraph {
if !result.is_empty() { result.push('\n');
result.push('\n');
}
result.push_str(&para_text);
} }
result.push_str(&para_text);
} }
let mut bytes = result.into_bytes(); let mut bytes = result.into_bytes();
bytes.push(0); bytes.push(0);
@ -1046,6 +1115,59 @@ fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, u
None None
} }
/// Insert text at a cursor position, splitting on newlines into multiple paragraphs.
/// Returns the final cursor position after insertion.
fn insert_text_with_newlines(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
text: &str,
) -> Option<TextPositionWithAffinity> {
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
let lines: Vec<&str> = normalized.split('\n').collect();
if lines.is_empty() {
return None;
}
let mut current_cursor = *cursor;
if let Some(new_offset) = insert_text_at_cursor(text_content, &current_cursor, lines[0]) {
current_cursor =
TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset);
} else {
return None;
}
for line in lines.iter().skip(1) {
if !split_paragraph_at_cursor(text_content, &current_cursor) {
break;
}
current_cursor =
TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0);
if let Some(new_offset) = insert_text_at_cursor(text_content, &current_cursor, line) {
current_cursor = TextPositionWithAffinity::new_without_affinity(
current_cursor.paragraph,
new_offset,
);
}
}
Some(current_cursor)
}
/// Get the text direction of the span at a given offset in a paragraph.
fn get_span_text_direction_at_offset(
para: &Paragraph,
char_offset: usize,
) -> skia_safe::textlayout::TextDirection {
if let Some((span_idx, _)) = find_span_at_offset(para, char_offset) {
if let Some(span) = para.children().get(span_idx) {
return span.text_direction;
}
}
// Fallback to paragraph's text direction
para.text_direction()
}
/// Insert text at a cursor position. Returns the new character offset after insertion. /// Insert text at a cursor position. Returns the new character offset after insertion.
fn insert_text_at_cursor( fn insert_text_at_cursor(
text_content: &mut TextContent, text_content: &mut TextContent,

13
scripts/check-fmt Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -ex
cljfmt --parallel=true check \
common/src/ \
common/test/ \
frontend/src/ \
frontend/test/ \
backend/src/ \
backend/test/ \
exporter/src/ \
library/src;

View File

@ -2,16 +2,6 @@
set -ex set -ex
cljfmt check --parallel=true \
common/src/ \
common/test/ \
frontend/src/ \
frontend/test/ \
backend/src/ \
backend/test/ \
exporter/src/ \
library/src;
clj-kondo --parallel=true --lint common/src; clj-kondo --parallel=true --lint common/src;
clj-kondo --parallel=true --lint frontend/src; clj-kondo --parallel=true --lint frontend/src;
clj-kondo --parallel=true --lint backend/src; clj-kondo --parallel=true --lint backend/src;