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

22
.gitignore vendored
View File

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

352
AGENTS.md
View File

@ -1,4 +1,4 @@
# Penpot Copilot Instructions
# Penpot Instructions
## 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
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
@ -28,27 +34,26 @@ Run `./scripts/setup` for setup all dependencies.
```bash
# Dev
pnpm run watch:app # Full dev build (WASM + CLJS + assets)
# Production Build
# Build (Producution)
./scripts/build
# Tests
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
pnpm run test # Build ClojureScript tests + run node target/tests/test.js
# Lint
pnpm run lint:js # format and linter check for JS
pnpm run lint:clj # format and linter check for CLJ
pnpm run lint:scss # prettier check for SCSS
pnpm run lint:js # Linter for JS/TS
pnpm run lint:clj # Linter for CLJ/CLJS/CLJC
pnpm run lint:scss # Linter for SCSS
# Code formatting
pnpm run fmt:clj # Format CLJ
pnpm run fmt:js # prettier for JS
pnpm run fmt:scss # prettier for SCSS
# Check Code Formart
pnpm run check-fmt:clj # Format CLJ/CLJS/CLJC
pnpm run check-fmt:js # Format JS/TS
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
@ -58,28 +63,63 @@ run build:test && node target/tests/test.js`.
### Backend (`cd backend`)
```bash
# Tests (Kaocha)
clojure -M:dev:test # Full suite
clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace
Run `pnpm install` for install all dependencies.
# Lint / Format
pnpm run lint:clj
pnpm run fmt:clj
```bash
# Run full test suite
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`)
This contains code that should compile and run under different runtimes: JVM & JS so the commands are
separarated for each runtime.
```bash
pnpm run test # Build + run node target/tests/test.js
pnpm run watch:test # Watch mode
pnpm run lint:clj
pnpm run fmt:clj
clojure -M:dev:test # Run full test suite under JVM
clojure -M:dev:test --focus backend-tests.my-ns-test # Run single namespace under JVM
# 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`)
```bash
@ -93,6 +133,10 @@ cargo fmt --check
### 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:**
- `app.rpc.commands.*` RPC command implementations (`auth`, `files`, `teams`, etc.)
- `app.http.*` HTTP routes and middleware
@ -109,14 +153,26 @@ cargo fmt --check
- `app.util.*` Utilities (DOM, HTTP, i18n, keyboard shortcuts)
**Common:**
- `app.common.types.*` Shared data types for shapes, files, pages
- `app.common.schema` Malli validation schemas
- `app.common.geom.*` Geometry utilities
- `app.common.types.*` Shared data types for shapes, files, pages using Malli schemas
- `app.common.schema` Malli abstraction layer, exposes the most used functions from malli
- `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
### 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
(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)})
```
Look under `src/app/rpc/commands/*.clj` to see more examples.
### 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
(defn my-event [data]
(defn my-event
"doc string"
[data]
(ptk/reify ::my-event
ptk/UpdateEvent
(update [_ state] ;; synchronous state transition
@ -148,19 +210,40 @@ State is a single atom managed by a Potok store. Events implement protocols:
ptk/EffectEvent
(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
refs: `(deref refs/selected-shapes)`. Prefer helpers from
`app.util.dom` instead of using direct dom calls, if no helper is
The state is located under `app.main.store` namespace where we have
the `emit!` function responsible of emiting events.
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
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
;; 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:
[: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`)
@ -187,7 +286,7 @@ Always prefer these macros over their `clojure.core` equivalents — they compil
(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:
@ -196,37 +295,129 @@ Files in `common/src/app/common/` use reader conditionals to target both runtime
: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
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.
### Component Standards & Syntax (React & Rumext: mf/defc)
```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/wrap [mf/memo]} ;; React.memo
[{:keys [name on-click]}]
{::mf/wrap [mf/memo]} ;; Equivalent to React.memo
[{:keys [name on-click]}] ;; Destructured props
[:div {:class (stl/css :root)
:on-click on-click}
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*
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.
Example for `mf/with-memo` 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>`
@ -263,3 +454,46 @@ applicable.
| ⬇️ | `:arrow_down:` | Dependency downgrade |
| 🔥 | `:fire:` | Remove files or code |
| 🌐 | `: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 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 incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463)
## 2.13.3

View File

@ -19,7 +19,9 @@
"ws": "^8.17.0"
},
"scripts": {
"lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/",
"fmt:clj": "cljfmt fix --parallel=true src/ test/"
"lint": "clj-kondo --parallel --lint ../common/src src/",
"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
FROM snapshots1 AS c
WHERE c.file_id = ?
ORDER BY c.created_at DESC
), snapshots3 AS (
(SELECT * FROM snapshots2
WHERE created_by = 'system'
@ -150,8 +151,7 @@
AND deleted_at IS NULL
LIMIT 500)
)
SELECT * FROM snapshots3
ORDER BY created_at DESC"))
SELECT * FROM snapshots3;"))
(defn get-visible-snapshots
"Return a list of snapshots fecheable from the API, it has a limited

View File

@ -13,6 +13,7 @@
"devDependencies": {
"concurrently": "^9.1.2",
"nodemon": "^3.1.10",
"prettier": "3.5.3",
"source-map-support": "^0.5.21",
"ws": "^8.18.2"
},
@ -20,11 +21,15 @@
"date-fns": "^4.1.0"
},
"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:js": "prettier -c src/**/*.js -w",
"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'\"",
"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:
specifier: ^3.1.10
version: 3.1.11
prettier:
specifier: 3.5.3
version: 3.5.3
source-map-support:
specifier: ^0.5.21
version: 0.5.21
@ -169,6 +172,11 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
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:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
@ -405,6 +413,8 @@ snapshots:
picomatch@2.3.1: {}
prettier@3.5.3: {}
pstree.remy@1.1.8: {}
readdirp@3.6.0:

View File

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

View File

@ -14,7 +14,7 @@
goog.provide("app.common.svg.path.arc_to_bezier");
// 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;
var TAU = Math.PI * 2;
@ -27,20 +27,23 @@ goog.scope(function() {
// we can use simplified math (without length normalization)
//
function unit_vector_angle(ux, uy, vx, vy) {
var sign = (ux * vy - uy * vx < 0) ? -1 : 1;
var dot = ux * vx + uy * vy;
var sign = ux * vy - uy * vx < 0 ? -1 : 1;
var dot = ux * vx + uy * vy;
// Add this to work with arbitrary vectors:
// dot /= Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy);
// rounding errors, e.g. -1.0000000000000002 can screw up this
if (dot > 1.0) { dot = 1.0; }
if (dot < -1.0) { dot = -1.0; }
if (dot > 1.0) {
dot = 1.0;
}
if (dot < -1.0) {
dot = -1.0;
}
return sign * Math.acos(dot);
}
// Convert from endpoint to center parameterization,
// 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
// axes.
//
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 x1p = (cos_phi * (x1 - x2)) / 2 + (sin_phi * (y1 - y2)) / 2;
var y1p = (-sin_phi * (x1 - x2)) / 2 + (cos_phi * (y1 - y2)) / 2;
var rx_sq = rx * rx;
var ry_sq = ry * ry;
var rx_sq = rx * rx;
var ry_sq = ry * ry;
var x1p_sq = x1p * x1p;
var y1p_sq = y1p * y1p;
@ -66,33 +69,33 @@ goog.scope(function() {
// Compute coordinates of the centre of this ellipse (cx', cy')
// 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) {
// due to rounding errors it might be e.g. -1.3877787807814457e-17
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);
var cxp = radicant * rx/ry * y1p;
var cyp = radicant * -ry/rx * x1p;
var cxp = ((radicant * rx) / ry) * y1p;
var cyp = ((radicant * -ry) / rx) * x1p;
// Step 3.
//
// Transform back to get centre coordinates (cx, cy) in the original
// coordinate system.
//
var cx = cos_phi*cxp - sin_phi*cyp + (x1+x2)/2;
var cy = sin_phi*cxp + cos_phi*cyp + (y1+y2)/2;
var cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2;
var cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2;
// Step 4.
//
// Compute angles (theta1, delta_theta).
//
var v1x = (x1p - cxp) / rx;
var v1y = (y1p - cyp) / ry;
var v1x = (x1p - cxp) / rx;
var v1y = (y1p - cyp) / ry;
var v2x = (-x1p - cxp) / rx;
var v2y = (-y1p - cyp) / ry;
@ -106,7 +109,7 @@ goog.scope(function() {
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
//
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 y1 = Math.sin(theta1);
var x2 = Math.cos(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) {
var sin_phi = Math.sin(phi * TAU / 360);
var cos_phi = Math.cos(phi * TAU / 360);
var sin_phi = Math.sin((phi * TAU) / 360);
var cos_phi = Math.cos((phi * TAU) / 360);
// Make sure radii are valid
//
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 x1p = (cos_phi * (x1 - x2)) / 2 + (sin_phi * (y1 - y2)) / 2;
var y1p = (-sin_phi * (x1 - x2)) / 2 + (cos_phi * (y1 - y2)) / 2;
// console.log("L", x1p, y1p)
@ -145,7 +157,6 @@ goog.scope(function() {
return [];
}
// Compensate out-of-range radii
//
rx = Math.abs(rx);
@ -157,25 +168,20 @@ goog.scope(function() {
ry *= Math.sqrt(lambda);
}
// 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 result = [];
var theta1 = cc[2];
var delta_theta = cc[3];
// Split an arc to multiple segments, so each segment
// will be less than τ/4 (= 90°)
//
var segments = Math.max(Math.ceil(Math.abs(delta_theta) / (TAU / 4)), 1);
delta_theta /= segments;
for (var i = 0; i < segments; i++) {
var item = approximate_unit_arc(theta1, delta_theta);
result.push(item);
@ -195,8 +201,8 @@ goog.scope(function() {
y *= ry;
// rotate
var xp = cos_phi*x - sin_phi*y;
var yp = sin_phi*x + cos_phi*y;
var xp = cos_phi * x - sin_phi * y;
var yp = sin_phi * x + cos_phi * y;
// translate
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")
:localized-date-time
(dfn-format v "PPPp")
(dfn-format v "PPP . p")
(if (string? fmt)
(dfn-format v fmt)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -100,14 +100,12 @@
(def browser-pool-factory
(letfn [(create []
(-> (p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]}
browser (.launch pw/chromium opts)
id (swap! pool-browser-id inc)]
(l/info :origin "factory" :action "create" :browser-id id)
(unchecked-set browser "__id" id)
browser)
(p/catch (fn [cause]
(l/error :hint "Cannot launch the headless browser" :cause cause)))))
(p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]}
browser (.launch pw/chromium opts)
id (swap! pool-browser-id inc)]
(l/info :origin "factory" :action "create" :browser-id id)
(unchecked-set browser "__id" id)
browser))
(destroy [obj]
(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: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",
"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: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",
"lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/",
"lint:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js",
"lint:scss": "prettier -c resources/styles -c src/**/*.scss",
"lint:clj": "clj-kondo --parallel --lint ../common/src src/",
"lint:js": "exit 0",
"lint:scss": "exit 0",
"build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "pnpm run build:test && node target/tests/test.js",
"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

@ -455,4 +455,39 @@ test("Check inner stroke artifacts", async ({
maxDiffPixelRatio: 0,
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 });
});
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();
});
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 ({
page,
}) => {

View File

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

View File

@ -127,6 +127,24 @@
(ex/print-throwable cause :prefix "Unexpected Error")
(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
;; workspace, dashboard, viewer or settings, then assign the exception
;; 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 "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]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "error")]

View File

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

View File

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

View File

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

View File

@ -477,8 +477,12 @@
:service-unavailable
[:> service-unavailable*]
:webgl-context-lost
[:> webgl-context-lost*]
:wasm-exception
(case (get data :exception-type)
:webgl-context-lost
[:> webgl-context-lost*]
[:> 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;
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.path :as dwdp]
[app.main.data.workspace.specialized-panel :as-alias dwsp]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.workspace.sidebar.assets.components :as wsac]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm.wasm]
[app.util.dom :as dom]
[app.util.dom.dnd :as dnd]
[app.util.dom.normalize-wheel :as nw]
@ -74,7 +72,6 @@
shift? (kbd/shift? native-event)
alt? (kbd/alt? native-event)
mod? (kbd/mod? native-event)
off-pt (dom/get-offset-position native-event)
left-click? (and (not panning) (dom/left-mouse? event))
middle-click? (and (not panning) (dom/middle-mouse? event))]
@ -94,23 +91,8 @@
(st/emit! (mse/->MouseEvent :down ctrl? shift? alt? meta?)
::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?))
(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)))
(st/emit! (dw/clear-edition-mode)))
(when (and (not text-editing?)
(not blocked)
@ -192,8 +174,6 @@
alt? (kbd/alt? event)
meta? (kbd/meta? event)
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)
pt (uwvv/point->viewport raw-pt)]
(st/emit! (mse/->MouseEvent :click ctrl? shift? alt? meta?))
@ -207,20 +187,6 @@
(not drawing-tool))
(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?
(not @space?)
(not edition)
@ -262,19 +228,7 @@
(and editable? (not= id edition) (not read-only?))
(do
(st/emit! (dw/select-shape id)
(dw/start-editing-selected))
;; If using wasm text-editor, notify WASM to start editing this shape
;; and set cursor position from the double-click location
(when (and (= type :text)
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(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)
(dw/start-editing-selected)))
(some? selected-shape)
(do

View File

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

View File

@ -86,12 +86,14 @@
;; Re-export public text editor functions
(def text-editor-start text-editor/text-editor-start)
(def text-editor-stop text-editor/text-editor-stop)
(def text-editor-set-cursor-from-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-pointer-down text-editor/text-editor-pointer-down)
(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-is-active? text-editor/text-editor-is-active?)
(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 dpr
@ -1419,7 +1421,9 @@
(dom/prevent-default event)
(reset! wasm/context-lost? true)
(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"))
(defn init-canvas-context

View File

@ -7,11 +7,30 @@
(ns 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
"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]
(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)]
;; DEBUG
;; (println "##" ~name)
(~fn-sym ~@params))))
(try
(~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]
(when wasm/context-initialized?
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_text_editor_start"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)))))
(when-not (h/call wasm/internal-module "_text_editor_start"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(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
[x y]
"Sets caret position from screen (canvas) coordinates"
[{:keys [x y]}]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
(defn text-editor-pointer-down
[x y]
[{:keys [x y]}]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_down" x y)))
(defn text-editor-pointer-move
[x y]
[{:keys [x y]}]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_move" x y)))
(defn text-editor-pointer-up
[x y]
[{:keys [x y]}]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_up" x y)))
@ -92,10 +100,16 @@
(when wasm/context-initialized?
(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
[]
(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?
([id]
@ -160,6 +174,7 @@
(finally
(mem/free))))))
;; This is used as a intermediate cache between Clojure global state and WASM state.
(def ^:private shape-text-contents (atom {}))
(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",
"build": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs release library",
"build:bundle": "./scripts/build",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/",
"fmt": "cljfmt fix --parallel=true src/ test/",
"check-fmt": "cljfmt check --parallel=true src/ test/",
"lint": "clj-kondo --parallel --lint src/",
"test": "node --test",
"watch:test": "node --test --watch",
"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"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
@ -426,6 +428,7 @@ dependencies = [
"indexmap",
"macros",
"skia-safe",
"thiserror",
"uuid",
]
@ -579,6 +582,26 @@ dependencies = [
"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]]
name = "toml"
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",
"webp",
] }
thiserror = "2.0.18"
uuid = { version = "1.11.0", features = ["v4", "js"] }
[profile.release]

View File

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

View File

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

View File

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

View File

@ -1,29 +1,29 @@
use std::alloc::{alloc, Layout};
use std::ptr;
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]
pub extern "C" fn alloc_bytes(len: usize) -> *mut u8 {
let mut guard = BUFFERU8.lock().unwrap();
if guard.is_some() {
panic!("Bytes already allocated");
}
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
pub extern "C" fn read_error_code() -> u8 {
if let Ok(guard) = BUFFER_ERROR.lock() {
*guard
} else {
CRITICAL_ERROR
}
}
@ -40,13 +40,6 @@ pub fn write_bytes(mut bytes: Vec<u8>) -> *mut u8 {
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> {
let mut guard = BUFFERU8.lock().unwrap();
guard.take().expect("Buffer is not initialized")
@ -57,6 +50,15 @@ pub fn bytes_or_empty() -> Vec<u8> {
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> {
type BytesType;
fn clone_to_slice(&self, slice: &mut [u8]);

View File

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

View File

@ -155,6 +155,7 @@ pub fn render_text_shadows(
None,
blur_filter.as_ref(),
None,
None,
);
for stroke_paragraphs in stroke_paragraphs_group.iter_mut() {
@ -167,6 +168,7 @@ pub fn render_text_shadows(
None,
blur_filter.as_ref(),
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
// clipping to prevent the dotted pattern from appearing in wrong areas.
// Dotted inner/outer strokes need clipping to prevent the dotted
// pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
// Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying
@ -60,6 +60,35 @@ fn draw_stroke_on_rect(
}
draw_stroke();
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 {
draw_stroke();
}
@ -83,8 +112,8 @@ fn draw_stroke_on_circle(
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
// By default just draw the circle. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
// Dotted inner/outer strokes need clipping to prevent the dotted
// pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
// Use a neutral layer (no extra paint) so opacity and filters
// 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.draw_oval(stroke_rect, &paint);
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 {
canvas.draw_oval(stroke_rect, &paint);
}
@ -164,16 +211,25 @@ fn draw_stroke_on_path(
blur: Option<&ImageFilter>,
antialias: bool,
) {
let skia_path = path
.to_skia_path()
.make_transform(path_transform.unwrap_or(&Matrix::default()));
let is_open = path.is_open();
let mut draw_paint = paint.clone();
let filter = compose_filters(blur, shadow);
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) {
StrokeKind::Inner => {
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);
canvas.restore_to_count(save_count);
}
fn handle_stroke_cap(

View File

@ -20,11 +20,12 @@ pub fn stroke_paragraph_builder_group_from_text(
bounds: &Rect,
count_inner_strokes: usize,
use_shadow: Option<bool>,
) -> Vec<ParagraphBuilderGroup> {
) -> (Vec<ParagraphBuilderGroup>, Option<f32>) {
let fallback_fonts = get_fallback_fonts();
let fonts = get_font_collection();
let mut paragraph_group = Vec::new();
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() {
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() {
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,
bounds,
&text_paint,
@ -40,6 +41,10 @@ pub fn stroke_paragraph_builder_group_from_text(
remove_stroke_alpha,
);
if group_layer_opacity.is_none() {
group_layer_opacity = stroke_layer_opacity;
}
let text: String = span.apply_text_transform();
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
(paragraph_group, group_layer_opacity)
}
fn get_text_stroke_paints(
@ -76,8 +81,25 @@ fn get_text_stroke_paints(
text_paint: &Paint,
count_inner_strokes: usize,
remove_stroke_alpha: bool,
) -> Vec<Paint> {
) -> (Vec<Paint>, Option<f32>) {
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 {
StrokeKind::Inner => {
@ -99,7 +121,7 @@ fn get_text_stroke_paints(
paint.set_blend_mode(skia::BlendMode::SrcIn);
paint.set_anti_alias(true);
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);
} else {
let mut paint = skia::Paint::default();
@ -108,7 +130,12 @@ fn get_text_stroke_paints(
paint.set_alpha(255);
} else {
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);
@ -132,7 +159,7 @@ fn get_text_stroke_paints(
paint.set_style(skia::PaintStyle::Stroke);
paint.set_anti_alias(true);
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);
}
StrokeKind::Outer => {
@ -141,7 +168,7 @@ fn get_text_stroke_paints(
paint.set_blend_mode(skia::BlendMode::DstOver);
paint.set_anti_alias(true);
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);
let mut paint = skia::Paint::default();
@ -153,7 +180,7 @@ fn get_text_stroke_paints(
}
}
paints
(paints, layer_opacity)
}
#[allow(clippy::too_many_arguments)]
@ -167,6 +194,7 @@ pub fn render_with_bounds_outset(
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) {
if let Some(render_state) = render_state {
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
@ -195,6 +223,7 @@ pub fn render_with_bounds_outset(
shadow,
Some(&blur_filter_clone),
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);
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;
}
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>,
blur: Option<&ImageFilter>,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) {
render_with_bounds_outset(
render_state,
@ -234,6 +280,7 @@ pub fn render(
blur,
0.0,
fill_inset,
layer_opacity,
);
}
@ -244,6 +291,7 @@ fn render_text_on_canvas(
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) {
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
@ -255,7 +303,7 @@ fn render_text_on_canvas(
if let Some(shadow_paint) = shadow {
let layer_rec = SaveLayerRec::default().paint(shadow_paint);
canvas.save_layer(&layer_rec);
draw_text(canvas, shape, paragraph_builders);
draw_text(canvas, shape, paragraph_builders, layer_opacity);
canvas.restore();
} 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) {
@ -263,13 +311,13 @@ fn render_text_on_canvas(
layer_paint.set_image_filter(erode);
let layer_rec = SaveLayerRec::default().paint(&layer_paint);
canvas.save_layer(&layer_rec);
draw_text(canvas, shape, paragraph_builders);
draw_text(canvas, shape, paragraph_builders, layer_opacity);
canvas.restore();
} else {
draw_text(canvas, shape, paragraph_builders);
draw_text(canvas, shape, paragraph_builders, layer_opacity);
}
} else {
draw_text(canvas, shape, paragraph_builders);
draw_text(canvas, shape, paragraph_builders, layer_opacity);
}
if blur.is_some() {
@ -283,13 +331,20 @@ fn draw_text(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
layer_opacity: Option<f32>,
) {
let text_content = shape.get_text_content();
let layout_info =
calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
let layer_rec = SaveLayerRec::default();
canvas.save_layer(&layer_rec);
if let Some(opacity) = layer_opacity {
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 {
para.paragraph.paint(canvas, (para.x, para.y));

View File

@ -66,7 +66,7 @@ fn render_selection(
}
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_anti_alias(true);

View File

@ -705,9 +705,8 @@ impl Shape {
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));
Ok(())
}
pub fn set_blend_mode(&mut self, mode: BlendMode) {

View File

@ -140,6 +140,38 @@ pub enum 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 {
match self {
Self::Solid(SolidColor(color)) => {

View File

@ -161,6 +161,13 @@ impl TextPositionWithAffinity {
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)]
@ -569,6 +576,7 @@ impl TextContent {
for paragraph in self.paragraphs() {
let paragraph_style = paragraph.paragraph_to_style();
let mut builder = ParagraphBuilder::new(&paragraph_style, fonts);
let mut has_text = false;
for span in paragraph.children() {
let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent();
let text_style = span.to_style(
@ -578,9 +586,15 @@ impl TextContent {
paragraph.line_height(),
);
let text: String = span.apply_text_transform();
if !text.is_empty() {
has_text = true;
}
builder.push_style(&text_style);
builder.add_text(&text);
}
if !has_text {
builder.add_text(" ");
}
paragraph_group.push(vec![builder]);
}

View File

@ -33,6 +33,11 @@ impl TextSelection {
!self.is_collapsed()
}
pub fn reset(&mut self) {
self.anchor.reset();
self.focus.reset();
}
pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) {
self.anchor = 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.
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_COLOR: Color = Color::BLACK;
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
@ -133,7 +138,7 @@ impl TextEditorState {
self.active_shape_id = Some(shape_id);
self.cursor_visible = true;
self.last_blink_time = 0.0;
self.selection = TextSelection::new();
self.selection.reset();
self.is_pointer_selection_active = false;
self.pending_events.clear();
}
@ -142,9 +147,10 @@ impl TextEditorState {
self.is_active = false;
self.active_shape_id = None;
self.cursor_visible = false;
self.last_blink_time = 0.0;
self.selection.reset();
self.is_pointer_selection_active = false;
self.pending_events.clear();
self.reset_blink();
}
pub fn start_pointer_selection(&mut self) -> bool {
@ -193,15 +199,83 @@ impl TextEditorState {
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) {
self.selection.set_caret(*position);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
}
pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.extend_to(*position);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
}
@ -242,3 +316,7 @@ impl TextEditorState {
!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 fonts;
pub mod layouts;
pub mod mem;
pub mod paths;
pub mod shadows;
pub mod shapes;

View File

@ -1,4 +1,4 @@
use macros::ToJs;
use macros::{wasm_error, ToJs};
use crate::mem;
use crate::shapes;
@ -67,7 +67,8 @@ pub fn parse_fills_from_bytes(buffer: &[u8], num_fills: usize) -> Vec<shapes::Fi
}
#[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| {
let bytes = mem::bytes();
// 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
let fills = parse_fills_from_bytes(&bytes[4..], num_fills);
shape.set_fills(fills);
mem::free_bytes();
mem::free_bytes()?;
});
Ok(())
}
#[no_mangle]

View File

@ -1,5 +1,7 @@
use crate::mem;
use macros::wasm_error;
// use crate::mem::SerializableResult;
use crate::error::Error;
use crate::uuid::Uuid;
use crate::with_state_mut;
use crate::STATE;
@ -65,7 +67,8 @@ impl TryFrom<Vec<u8>> for ShapeImageIds {
}
#[no_mangle]
pub extern "C" fn store_image() {
#[wasm_error]
pub extern "C" fn store_image() -> crate::error::Result<()> {
let bytes = mem::bytes();
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);
});
mem::free_bytes();
mem::free_bytes()?;
Ok(())
}
/// 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 44-47: height (i32)
#[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();
if bytes.len() < 48 {
// FIXME: Review if this should be an critical or a recoverable error.
eprintln!("store_image_from_texture: insufficient data");
mem::free_bytes();
return;
mem::free_bytes()?;
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();
@ -139,5 +147,6 @@ pub extern "C" fn store_image_from_texture() {
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::shapes::{FontFamily, FontStyle};
@ -30,6 +30,7 @@ impl From<RawFontStyle> for FontStyle {
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn store_font(
a: u32,
b: u32,
@ -39,7 +40,7 @@ pub extern "C" fn store_font(
style: u8,
is_emoji: bool,
is_fallback: bool,
) {
) -> Result<()> {
with_state_mut!(state, {
let id = uuid_from_u32_quartet(a, b, c, d);
let font_bytes = mem::bytes();
@ -52,8 +53,9 @@ pub extern "C" fn store_font(
.fonts_mut()
.add(family, &font_bytes, is_emoji, is_fallback);
mem::free_bytes();
mem::free_bytes()?;
});
Ok(())
}
#[no_mangle]

View File

@ -1,4 +1,4 @@
use macros::ToJs;
use macros::{wasm_error, ToJs};
use crate::mem;
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;
#[allow(unused_imports)]
use crate::error::Result;
#[derive(Debug)]
#[repr(C, align(1))]
struct RawGridCell {
@ -168,7 +171,8 @@ pub extern "C" fn set_grid_layout_data(
}
#[no_mangle]
pub extern "C" fn set_grid_columns() {
#[wasm_error]
pub extern "C" fn set_grid_columns() -> Result<()> {
let bytes = mem::bytes();
let entries: Vec<GridTrack> = bytes
@ -181,11 +185,13 @@ pub extern "C" fn set_grid_columns() {
shape.set_grid_columns(entries);
});
mem::free_bytes();
mem::free_bytes()?;
Ok(())
}
#[no_mangle]
pub extern "C" fn set_grid_rows() {
#[wasm_error]
pub extern "C" fn set_grid_rows() -> Result<()> {
let bytes = mem::bytes();
let entries: Vec<GridTrack> = bytes
@ -198,11 +204,13 @@ pub extern "C" fn set_grid_rows() {
shape.set_grid_rows(entries);
});
mem::free_bytes();
mem::free_bytes()?;
Ok(())
}
#[no_mangle]
pub extern "C" fn set_grid_cells() {
#[wasm_error]
pub extern "C" fn set_grid_cells() -> Result<()> {
let bytes = mem::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());
});
mem::free_bytes();
mem::free_bytes()?;
Ok(())
}
#[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)]
use macros::ToJs;
use macros::{wasm_error, ToJs};
use mem::SerializableResult;
use std::mem::size_of;
use std::sync::{Mutex, OnceLock};
@ -161,12 +161,14 @@ pub extern "C" fn start_shape_path_buffer() {
}
#[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 buffer = get_path_upload_buffer();
let mut buffer = buffer.lock().unwrap();
buffer.extend_from_slice(&bytes);
mem::free_bytes();
mem::free_bytes()?;
Ok(())
}
#[no_mangle]

View File

@ -1,4 +1,4 @@
use macros::ToJs;
use macros::{wasm_error, ToJs};
use super::RawSegmentData;
use crate::math;
@ -8,6 +8,9 @@ use crate::{mem, SerializableResult};
use crate::{with_current_shape_mut, with_state, STATE};
use std::mem::size_of;
#[allow(unused_imports)]
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
@ -43,15 +46,19 @@ pub extern "C" fn set_shape_bool_type(raw_bool_type: u8) {
}
#[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 entries: Vec<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::try_from(data).unwrap())
.collect();
.map(|data| {
// 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 result;
@ -64,5 +71,5 @@ pub extern "C" fn calculate_bool(raw_bool_type: u8) -> *mut u8 {
.map(RawSegmentData::from_segment)
.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};
@ -9,6 +9,8 @@ use crate::shapes::{
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::error::Error;
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
@ -285,16 +287,22 @@ pub extern "C" fn clear_shape_text() {
}
#[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();
with_current_shape_mut!(state, |shape: &mut Shape| {
let raw_text_data = RawParagraph::try_from(&bytes).unwrap();
if shape.add_paragraph(raw_text_data.into()).is_err() {
println!("Error with set_shape_text_content on {:?}", shape.id);
}
shape.add_paragraph(raw_text_data.into()).map_err(|_| {
Error::RecoverableError(format!(
"Error with set_shape_text_content on {:?}",
shape.id
))
})?;
});
mem::free_bytes();
mem::free_bytes()?;
Ok(())
}
#[no_mangle]

View File

@ -1,3 +1,5 @@
use macros::{wasm_error, ToJs};
use crate::math::{Matrix, Point, Rect};
use crate::mem;
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_to_u32_quartet;
use crate::{with_state, with_state_mut, STATE};
use macros::ToJs;
use skia_safe::{textlayout::TextDirection, Color};
#[derive(PartialEq, ToJs)]
#[repr(u8)]
@ -23,6 +25,21 @@ pub enum CursorDirection {
// 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]
pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool {
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]
pub extern "C" fn text_editor_stop() {
pub extern "C" fn text_editor_stop() -> bool {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return false;
}
state.text_editor_state.stop();
});
true
})
}
#[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]
pub extern "C" fn text_editor_poll_event() -> 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;
};
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();
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
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 {
return;
}
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
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 {
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 {
return;
}
@ -163,9 +202,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
return;
};
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
state
.text_editor_state
.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 {
return;
}
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
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 {
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 {
return;
}
let Type::Text(text_content) = &shape.shape_type else {
return;
};
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
state
.text_editor_state
.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]
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
with_state_mut!(state, {
@ -240,29 +292,31 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
// TEXT OPERATIONS
// ============================================================================
// FIXME: Review if all the return Ok(()) should be Err instead.
#[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 text = match String::from_utf8(bytes) {
Ok(s) => s,
Err(_) => return,
Ok(text) => text,
Err(_) => return Ok(()),
};
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
return Ok(());
}
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 {
return;
return Ok(());
};
let Type::Text(text_content) = &mut shape.shape_type else {
return;
return Ok(());
};
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;
if let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) {
let new_cursor =
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, new_offset);
if let Some(new_cursor) = insert_text_with_newlines(text_content, &cursor, &text) {
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);
});
crate::mem::free_bytes();
crate::mem::free_bytes()?;
Ok(())
}
#[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 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::Forward => move_cursor_forward(&current, paragraphs),
CursorDirection::LineBefore => {
@ -744,12 +815,10 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 {
char_pos += span_len;
}
}
if !para_text.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str(&para_text);
if para_idx > start.paragraph {
result.push('\n');
}
result.push_str(&para_text);
}
let mut bytes = result.into_bytes();
bytes.push(0);
@ -1046,6 +1115,59 @@ fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, u
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.
fn insert_text_at_cursor(
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
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 frontend/src;
clj-kondo --parallel=true --lint backend/src;