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

This commit is contained in:
Andrey Antukh 2026-03-11 15:45:55 +01:00
commit 0708b0f334
82 changed files with 3439 additions and 930 deletions

View File

@ -104,6 +104,23 @@ jobs:
run: | run: |
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml
- name: Add noindex header and robots.txt files for non-production environments
if: ${{ steps.vars.outputs.gh_ref != 'main' }}
working-directory: plugins
shell: bash
run: |
ASSETS_DIR="dist/doc"
cat > "${ASSETS_DIR}/_headers" << 'EOF'
/*
X-Robots-Tag: noindex, nofollow
EOF
cat > "${ASSETS_DIR}/robots.txt" << 'EOF'
User-agent: *
Disallow: /
EOF
- name: Deploy to Cloudflare Workers - name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3 uses: cloudflare/wrangler-action@v3
with: with:

View File

@ -102,6 +102,23 @@ jobs:
run: | run: |
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-styles-doc.toml sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-styles-doc.toml
- name: Add noindex header and robots.txt files for non-production environments
if: ${{ steps.vars.outputs.gh_ref != 'main' }}
working-directory: plugins
shell: bash
run: |
ASSETS_DIR="dist/apps/example-styles"
cat > "${ASSETS_DIR}/_headers" << 'EOF'
/*
X-Robots-Tag: noindex, nofollow
EOF
cat > "${ASSETS_DIR}/robots.txt" << 'EOF'
User-agent: *
Disallow: /
EOF
- name: Deploy to Cloudflare Workers - name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3 uses: cloudflare/wrangler-action@v3
with: with:

View File

@ -28,9 +28,55 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Check clojure code format - name: Lint Common
working-directory: ./common
run: | run: |
./scripts/lint corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run check-fmt:js
pnpm run lint:clj
- name: Lint Frontend
working-directory: ./frontend
run: |
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
- name: Lint Backend
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Exporter
working-directory: ./exporter
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Library
working-directory: ./library
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
test-common: test-common:
name: "Common Tests" name: "Common Tests"
@ -41,12 +87,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Run tests on JVM - name: Run tests
working-directory: ./common
run: |
clojure -M:dev:test
- name: Run tests on NODE
working-directory: ./common working-directory: ./common
run: | run: |
./scripts/test ./scripts/test

23
.gitignore vendored
View File

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

499
AGENTS.md Normal file
View File

@ -0,0 +1,499 @@
# Penpot Instructions
## Architecture Overview
Penpot is a full-stack design tool composed of several distinct components:
| Component | Language | Role |
|-----------|----------|------|
| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) |
| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis |
| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities |
| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) |
| `render-wasm/` | Rust → WebAssembly | High-performance canvas renderer using Skia |
| `mcp/` | TypeScript | Model Context Protocol integration |
| `plugins/` | TypeScript | Plugin runtime and example plugins |
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
### Frontend (`cd frontend`)
Run `./scripts/setup` for setup all dependencies.
```bash
# Build (Producution)
./scripts/build
# Tests
pnpm run test # Build ClojureScript tests + run node target/tests/test.js
# Lint
pnpm run lint:js # Linter for JS/TS
pnpm run lint:clj # Linter for CLJ/CLJS/CLJC
pnpm run lint:scss # Linter 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
`test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm
run build:test && node target/tests/test.js`.
### Backend (`cd backend`)
Run `pnpm install` for install all dependencies.
```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/` 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
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
./test # Rust unit tests (cargo test)
./build # Compile to WASM (requires Emscripten)
cargo fmt --check
./lint --debug
```
## Key Conventions
### 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
- `app.db.*` Database layer
- `app.tasks.*` Background job tasks
- `app.main` Integrant system setup and entrypoint
- `app.loggers` Internal loggers (auditlog, mattermost, etc) (do not be confused with `app.common.loggin`)
**Frontend:**
- `app.main.ui.*` React UI components (`workspace`, `dashboard`, `viewer`)
- `app.main.data.*` Potok event handlers (state mutations + side effects)
- `app.main.refs` Reactive subscriptions (okulary lenses)
- `app.main.store` Potok event store
- `app.util.*` Utilities (DOM, HTTP, i18n, keyboard shortcuts)
**Common:**
- `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
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
{::rpc/auth true ;; requires auth
::doc/added "1.18"
::sm/params [:map ...] ;; malli input schema
::sm/result [:map ...]} ;; malli output schema
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
;; return a plain map or throw
{: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
(funcool/potok library):
```clojure
(defn my-event
"doc string"
[data]
(ptk/reify ::my-event
ptk/UpdateEvent
(update [_ state] ;; synchronous state transition
(assoc state :key data))
ptk/WatchEvent
(watch [_ state stream] ;; async: returns an observable
(->> (rp/cmd! :some-rpc-command params)
(rx/map success-event)
(rx/catch error-handler)))
ptk/EffectEvent
(effect [_ state _] ;; pure side effects (DOM, logging)
(dom/focus (dom/get-element "id")))))
```
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)
Styles are co-located with components. Each `.cljs` file has a corresponding
`.scss` file:
```clojure
;; In the component namespace:
(require '[app.main.style :as stl])
;; In the render function:
[:div {:class (stl/css :container :active)}]
;; Conditional:
[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}]
;; 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`)
Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript:
```clojure
(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys
(dm/get-in obj [:a :b :c]) ;; faster than core/get-in
(dm/str "a" "b" "c") ;; string concatenation
```
### Shared Code under Common (CLJC)
Files in `common/src/app/common/` use reader conditionals to target both runtimes:
```clojure
#?(:clj (import java.util.UUID)
:cljs (:require [cljs.core :as core]))
```
Both frontend and backend depend on `common` as a local library (`penpot/common
{:local/root "../common"}`).
### Component Standards & Syntax (React & Rumext: mf/defc)
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]} ;; Equivalent to React.memo
[{:keys [name on-click]}] ;; Destructured props
[:div {:class (stl/css :root)
:on-click on-click}
name])
```
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:
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)))))
;; 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>`
```
:bug: Fix unexpected error on launching modal
Optional body explaining the why.
Signed-off-by: Fullname <email>
```
**Subject rules:** imperative mood, capitalize first letter, no
trailing period, ≤ 80 characters. Add an entry to `CHANGES.md` if
applicable.
**Code patches must include a DCO sign-off** (`git commit -s`).
| Emoji | Emoji-Code | Use for |
|-------|------|---------|
| 🐛 | `:bug:` | Bug fix |
| ✨ | `:sparkles:` | Improvement |
| 🎉 | `:tada:` | New feature |
| ♻️ | `:recycle:` | Refactor |
| 💄 | `:lipstick:` | Cosmetic changes |
| 🚑 | `:ambulance:` | Critical bug fix |
| 📚 | `:books:` | Docs |
| 🚧 | `:construction:` | WIP |
| 💥 | `:boom:` | Breaking change |
| 🔧 | `:wrench:` | Config update |
| ⚡ | `:zap:` | Performance |
| 🐳 | `:whale:` | Docker |
| 📎 | `:paperclip:` | Other non-relevant changes |
| ⬆️ | `:arrow_up:` | Dependency upgrade |
| ⬇️ | `: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

@ -3,6 +3,7 @@
## 2.14.0 (Unreleased) ## 2.14.0 (Unreleased)
### :boom: Breaking changes & Deprecations ### :boom: Breaking changes & Deprecations
- Deprecate `PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE` in favour of `PENPOT_HTTP_SERVER_MAX_BODY_SIZE`. - Deprecate `PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE` in favour of `PENPOT_HTTP_SERVER_MAX_BODY_SIZE`.
### :sparkles: New features & Enhancements ### :sparkles: New features & Enhancements
@ -33,6 +34,9 @@
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128) - Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306) - Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306)
- Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513) - Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513)
- Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528)
- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
- Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463)
## 2.13.3 ## 2.13.3
@ -47,7 +51,6 @@
- Fix modifying shapes by apply negative tokens to border radius [Taiga #13317](https://tree.taiga.io/project/penpot/issue/13317) - Fix modifying shapes by apply negative tokens to border radius [Taiga #13317](https://tree.taiga.io/project/penpot/issue/13317)
- Fix arbitrary file read security issue on create-font-variant rpc method (https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2) - Fix arbitrary file read security issue on create-font-variant rpc method (https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)
## 2.13.1 ## 2.13.1
### :bug: Bugs fixed ### :bug: Bugs fixed

87
backend/AGENTS.md Normal file
View File

@ -0,0 +1,87 @@
# backend Agent Instructions
Clojure service running on the JVM. Uses Integrant for dependency injection, PostgreSQL for storage, and Redis for messaging/caching.
## Commands
```bash
# REPL (primary dev workflow)
./scripts/repl # Start nREPL + load dev/user.clj utilities
# Tests (Kaocha)
clojure -M:dev:test # Full suite
clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace
# Lint / Format
pnpm run lint:clj
pnpm run fmt:clj
```
Test namespaces match `.*-test$` under `test/`. Config is in `tests.edn`.
## Integrant System
`src/app/main.clj` declares the system map. Each key is a component;
values are config maps with `::ig/ref` for dependencies. Components
implement `ig/init-key` / `ig/halt-key!`.
From the REPL (`dev/user.clj` is auto-loaded):
```clojure
(start!) ; boot the system
(stop!) ; halt the system
(restart!) ; stop + reload namespaces + start
```
## RPC Commands
All API calls: `POST /api/rpc/command/<cmd-name>`.
```clojure
(sv/defmethod ::my-command
{::rpc/auth true ;; requires authentication (default)
::doc/added "1.18"
::sm/params [:map ...] ;; malli input schema
::sm/result [:map ...]} ;; malli output schema
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
;; return a plain map; throw via ex/raise for errors
{:id (uuid/next)})
```
Add new commands in `src/app/rpc/commands/`.
## Database
`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case.
```clojure
;; Query helpers
(db/get pool :table {:id id}) ; fetch one row (throws if missing)
(db/get* pool :table {:id id}) ; fetch one row (returns nil)
(db/query pool :table {:team-id team-id}) ; fetch multiple rows
(db/insert! pool :table {:name "x" :team-id id}) ; insert
(db/update! pool :table {:name "y"} {:id id}) ; update
(db/delete! pool :table {:id id}) ; delete
;; Transactions
(db/tx-run cfg (fn [{:keys [::db/conn]}]
(db/insert! conn :table row)))
```
Almost all methods on `app.db` namespace accepts `pool`, `conn` or
`cfg` as params.
Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup.
## Error Handling
```clojure
(ex/raise :type :not-found
:code :object-not-found
:hint "File does not exist"
:context {:id file-id})
```
Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`.
## Configuration
`src/app/config.clj` reads `PENPOT_*` environment variables, validated with Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags :enable-smtp)`.

View File

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

View File

@ -8,7 +8,6 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
<nav> <nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div> <div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div> <div>[<a href="#head">head</a>]</div>
<!-- <div>[<a href="#props">props</a>]</div> -->
<div>[<a href="#context">context</a>]</div> <div>[<a href="#context">context</a>]</div>
{% if report %} {% if report %}
<div>[<a href="#report">report</a>]</div> <div>[<a href="#report">report</a>]</div>
@ -21,7 +20,8 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
<div class="table-val"> <div class="table-val">
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1> <h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2> <h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
<h2><span class="not-important">Report ID:</span> <br/> {{id}}</h2> <h2><span class="not-important">Origin:</span> <br/> {{origin}}</h2>
<h2><span class="not-important">HREF:</span> <br/> {{href}}</h2>
</div> </div>
</div> </div>

View File

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

View File

@ -117,7 +117,8 @@
{:context (-> (into (sorted-map) context) {:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50)) (pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50) :origin (::audit/name record)
:href (get props :href)
:hint (get props :hint) :hint (get props :hint)
:report (get props :report)})) :report (get props :report)}))

View File

@ -293,12 +293,17 @@
(defn download-image (defn download-image
"Download an image from the provided URI and return the media input object" "Download an image from the provided URI and return the media input object"
[{:keys [::http/client]} uri] [{:keys [::http/client]} uri]
(letfn [(parse-and-validate [{:keys [headers] :as response}] (letfn [(parse-and-validate [{:keys [status headers] :as response}]
(let [size (some-> (get headers "content-length") d/parse-integer) (let [size (some-> (get headers "content-length") d/parse-integer)
mtype (get headers "content-type") mtype (get headers "content-type")
format (cm/mtype->format mtype) format (cm/mtype->format mtype)
max-size (cf/get :media-max-file-size default-max-file-size)] max-size (cf/get :media-max-file-size default-max-file-size)]
(when-not (<= 200 status 299)
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': unexpected status code %" uri status)))
(when-not size (when-not size
(ex/raise :type :validation (ex/raise :type :validation
:code :unknown-size :code :unknown-size
@ -318,9 +323,32 @@
{:size size :mtype mtype :format format}))] {:size size :mtype mtype :format format}))]
(let [{:keys [body] :as response} (http/req! client (let [{:keys [body] :as response}
{:method :get :uri uri} (try
{:response-type :input-stream}) (http/req! client
{:method :get :uri uri}
{:response-type :input-stream})
(catch java.net.ConnectException cause
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': connection refused or host unreachable" uri)
:cause cause))
(catch java.net.http.HttpConnectTimeoutException cause
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': connection timeout" uri)
:cause cause))
(catch java.net.http.HttpTimeoutException cause
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': request timeout" uri)
:cause cause))
(catch java.io.IOException cause
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': I/O error" uri)
:cause cause)))
{:keys [size mtype]} (parse-and-validate response) {:keys [size mtype]} (parse-and-validate response)
path (tmp/tempfile :prefix "penpot.media.download.") path (tmp/tempfile :prefix "penpot.media.download.")
written (io/write* path body :size size)] written (io/write* path body :size size)]

View File

@ -1005,19 +1005,19 @@
"Link a file to a library. Returns the recursive list of libraries used by that library" "Link a file to a library. Returns the recursive list of libraries used by that library"
{::doc/added "1.17" {::doc/added "1.17"
::webhooks/event? true ::webhooks/event? true
::sm/params schema:link-file-to-library} ::sm/params schema:link-file-to-library
[cfg {:keys [::rpc/profile-id file-id library-id] :as params}] ::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
(when (= file-id library-id) (when (= file-id library-id)
(ex/raise :type :validation (ex/raise :type :validation
:code :invalid-library :code :invalid-library
:hint "A file cannot be linked to itself")) :hint "A file cannot be linked to itself"))
(db/tx-run! cfg (check-edition-permissions! conn profile-id file-id)
(fn [{:keys [::db/conn]}] (check-edition-permissions! conn profile-id library-id)
(check-edition-permissions! conn profile-id file-id) (link-file-to-library conn params)
(check-edition-permissions! conn profile-id library-id) (bfc/get-libraries cfg [library-id]))
(link-file-to-library conn params)
(bfc/get-libraries cfg [library-id]))))
;; --- MUTATION COMMAND: unlink-file-from-library ;; --- MUTATION COMMAND: unlink-file-from-library
@ -1037,8 +1037,9 @@
::webhooks/event? true ::webhooks/event? true
::sm/params schema:unlink-file-to-library ::sm/params schema:unlink-file-to-library
::db/transaction true} ::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
(check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(unlink-file-from-library conn params) (unlink-file-from-library conn params)
nil) nil)
@ -1062,8 +1063,9 @@
{::doc/added "1.17" {::doc/added "1.17"
::sm/params schema:update-file-library-sync-status ::sm/params schema:update-file-library-sync-status
::db/transaction true} ::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id file-id] :as params}] [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}]
(check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(update-sync conn params)) (update-sync conn params))
;; --- MUTATION COMMAND: ignore-sync ;; --- MUTATION COMMAND: ignore-sync

View File

@ -867,6 +867,52 @@
(t/is (th/ex-info? error)) (t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))) (t/is (th/ex-of-type? error :not-found))))
(t/deftest permissions-checks-unlink-library
(let [profile1 (th/create-profile* 1)
profile2 (th/create-profile* 2)
file1 (th/create-file* 1 {:project-id (:default-project-id profile1)
:profile-id (:id profile1)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:default-project-id profile1)
:profile-id (:id profile1)})]
(let [data {::th/type :unlink-file-from-library
::rpc/profile-id (:id profile2)
:file-id (:id file2)
:library-id (:id file1)}
out (th/command! data)
error (:error out)]
;; (th/print-result! out)
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))
(t/deftest permissions-checks-update-file-library-status
(let [profile1 (th/create-profile* 1)
profile2 (th/create-profile* 2)
file1 (th/create-file* 1 {:project-id (:default-project-id profile1)
:profile-id (:id profile1)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:default-project-id profile1)
:profile-id (:id profile1)})]
(let [data {::th/type :update-file-library-sync-status
::rpc/profile-id (:id profile2)
:file-id (:id file2)
:library-id (:id file1)}
out (th/command! data)
error (:error out)]
;; (th/print-result! out)
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))
(t/deftest deletion (t/deftest deletion
(let [profile1 (th/create-profile* 1) (let [profile1 (th/create-profile* 1)
file (th/create-file* 1 {:project-id (:default-project-id profile1) file (th/create-file* 1 {:project-id (:default-project-id profile1)

View File

@ -9,11 +9,14 @@
[app.common.time :as ct] [app.common.time :as ct]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[app.http.client :as http]
[app.media :as media]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.storage :as sto] [app.storage :as sto]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
[clojure.test :as t] [clojure.test :as t]
[datoteka.fs :as fs])) [datoteka.fs :as fs]
[mockery.core :refer [with-mocks]]))
(t/use-fixtures :once th/state-init) (t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset) (t/use-fixtures :each th/database-reset)
@ -278,3 +281,100 @@
error-data (ex-data error)] error-data (ex-data error)]
(t/is (th/ex-info? error)) (t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))))) (t/is (= (:type error-data) :not-found)))))
(t/deftest download-image-connection-error
(t/testing "connection refused raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
:throw (java.net.ConnectException. "Connection refused")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://unreachable.invalid/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "connection timeout raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
:throw (java.net.http.HttpConnectTimeoutException. "Connect timed out")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://unreachable.invalid/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "request timeout raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
:throw (java.net.http.HttpTimeoutException. "Request timed out")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://unreachable.invalid/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "I/O error raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
:throw (java.io.IOException. "Stream closed")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://unreachable.invalid/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err))))))))
(t/deftest download-image-status-code-error
(t/testing "404 status raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
:return {:status 404
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://example.com/not-found.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "500 status raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
:return {:status 500
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://example.com/server-error.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "302 status raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
:return {:status 302
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://example.com/redirect.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err))))))))

View File

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

10
common/pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

@ -299,3 +299,8 @@
(js/console.log (format-throwable cause)) (js/console.log (format-throwable cause))
(finally (finally
(js/console.groupEnd)))))) (js/console.groupEnd))))))
(defn get-hint
[cause]
(or (some-> (ex-data cause) (get :hint) first-line)
(some-> (ex-message cause) first-line)))

View File

@ -1766,6 +1766,26 @@
(update :pages-index d/update-vals update-container) (update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container)))) (d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0017-fix-layout-flex-dir"
[data _]
(let [fix-layout-flex-dir
(fn [value]
(if (= value :reverse-row)
:row-reverse
value))
update-object
(fn [object]
(d/update-when object :layout-flex-dir fix-layout-flex-dir))
update-container
(fn [container]
(d/update-when container :objects d/update-vals update-object))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(def available-migrations (def available-migrations
(into (d/ordered-set) (into (d/ordered-set)
["legacy-2" ["legacy-2"
@ -1839,4 +1859,5 @@
"0014-clear-components-nil-objects" "0014-clear-components-nil-objects"
"0015-fix-text-attrs-blank-strings" "0015-fix-text-attrs-blank-strings"
"0015-clean-shadow-color" "0015-clean-shadow-color"
"0016-copy-fills-from-position-data-to-text-node"])) "0016-copy-fills-from-position-data-to-text-node"
"0017-fix-layout-flex-dir"]))

View File

@ -128,7 +128,9 @@
:warn "#f5871f" :warn "#f5871f"
:info "#4271ae" :info "#4271ae"
:debug "#969896" :debug "#969896"
:trace "#8e908c")) :trace "#8e908c"
(let [hint (str "invalid level provided to `level->color` function: " (pr-str level))]
(throw (ex-info hint {:level level})))))
(defn- level->name (defn- level->name
[level] [level]
@ -137,7 +139,9 @@
:trace "TRC" :trace "TRC"
:info "INF" :info "INF"
:warn "WRN" :warn "WRN"
:error "ERR")) :error "ERR"
(let [hint (str "invalid level provided to `level->name` function: " (pr-str level))]
(throw (ex-info hint {:level level})))))
(defn level->int (defn level->int
[level] [level]
@ -146,7 +150,9 @@
:debug 20 :debug 20
:info 30 :info 30
:warn 40 :warn 40
:error 50)) :error 50
(let [hint (str "invalid level provided to `level->int` function: " (pr-str level))]
(throw (ex-info hint {:level level})))))
(defn build-message (defn build-message
[props] [props]

View File

@ -2002,6 +2002,61 @@
:else :else
current-content))) current-content)))
(defn- switch-fixed-layout-geom-change-value
[prev-shape ; The shape before the switch
current-shape ; The shape after the switch (a clean copy)
attr]
;; When there is a layout with fixed h or v sizing, we need
;; to keep the width/height (and recalculate selrect and points)
(let [prev-width (-> prev-shape :selrect :width)
current-width (-> current-shape :selrect :width)
prev-height (-> prev-shape :selrect :height)
current-height (-> current-shape :selrect :height)
x (-> current-shape :selrect :x)
y (-> current-shape :selrect :y)
h-sizing (:layout-item-h-sizing prev-shape)
v-sizing (:layout-item-v-sizing prev-shape)
final-width (if (= :fix h-sizing)
current-width
prev-width)
final-height (if (= :fix v-sizing)
current-height
prev-height)
selrect (assoc (:selrect current-shape)
:width final-width
:height final-height
:x x
:y y
:x1 x
:y1 y
:x2 (+ x final-width)
:y2 (+ y final-height))]
(case attr
:width
final-width
:height
final-height
:selrect
selrect
:points
(-> selrect
(grc/rect->points)
(gsh/transform-points
(grc/rect->center selrect)
(or (:transform current-shape) (gmt/matrix)))))))
(defn update-attrs-on-switch (defn update-attrs-on-switch
"Copy attributes that have changed in the shape previous to the switch "Copy attributes that have changed in the shape previous to the switch
to the current shape (post switch). Used only on variants switch" to the current shape (post switch). Used only on variants switch"
@ -2110,6 +2165,11 @@
origin-ref-shape origin-ref-shape
attr) attr)
(and (or (= :fix (:layout-item-h-sizing previous-shape))
(= :fix (:layout-item-v-sizing previous-shape)))
(contains? #{:points :selrect :width :height} attr))
(switch-fixed-layout-geom-change-value previous-shape current-shape attr)
:else :else
(get previous-shape attr))) (get previous-shape attr)))

View File

@ -188,7 +188,7 @@ Some naming conventions:
[segments separator] [segments separator]
(let [sorted (sort-by-children segments separator) (let [sorted (sort-by-children segments separator)
grouped (group-by-first-segment sorted separator)] grouped (group-by-first-segment sorted separator)]
grouped)) (into (sorted-map) grouped)))
(defn- build-tree-node (defn- build-tree-node
"Builds a single tree node with lazy children." "Builds a single tree node with lazy children."

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -18,6 +18,9 @@
(t/use-fixtures :each thi/test-fixture) (t/use-fixtures :each thi/test-fixture)
;; ============================================================
;; BASIC SWITCH TESTS (no overrides)
;; ============================================================
(t/deftest test-basic-switch (t/deftest test-basic-switch
(let [;; ==== Setup (let [;; ==== Setup
@ -68,6 +71,9 @@
;; The rect has width 15 after the switch ;; The rect has width 15 after the switch
(t/is (= (:width rect02') 15)))) (t/is (= (:width rect02') 15))))
;; ============================================================
;; SIMPLE ATTRIBUTE OVERRIDES (identical variants)
;; ============================================================
(t/deftest test-basic-switch-override (t/deftest test-basic-switch-override
(let [;; ==== Setup (let [;; ==== Setup
@ -142,6 +148,10 @@
;; The override is keept: The rect still has width 25 after the switch ;; The override is keept: The rect still has width 25 after the switch
(t/is (= (:width rect02') 25)))) (t/is (= (:width rect02') 25))))
;; ============================================================
;; SIMPLE ATTRIBUTE OVERRIDES (different variants)
;; ============================================================
(t/deftest test-switch-with-no-override (t/deftest test-switch-with-no-override
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@ -182,6 +192,10 @@
;; The rect has width 15 after the switch ;; The rect has width 15 after the switch
(t/is (= (:width rect02') 15)))) (t/is (= (:width rect02') 15))))
;; ============================================================
;; TEXT OVERRIDES (identical variants)
;; ============================================================
(def font-size-path-paragraph [:content :children 0 :children 0 :font-size]) (def font-size-path-paragraph [:content :children 0 :children 0 :font-size])
(def font-size-path-0 [:content :children 0 :children 0 :children 0 :font-size]) (def font-size-path-0 [:content :children 0 :children 0 :children 0 :font-size])
(def font-size-path-1 [:content :children 0 :children 0 :children 1 :font-size]) (def font-size-path-1 [:content :children 0 :children 0 :children 1 :font-size])
@ -346,6 +360,10 @@
(t/is (= (get-in copy-both-t' font-size-path-0) "25")) (t/is (= (get-in copy-both-t' font-size-path-0) "25"))
(t/is (= (get-in copy-both-t' text-path-0) "text overriden")))) (t/is (= (get-in copy-both-t' text-path-0) "text overriden"))))
;; ============================================================
;; TEXT OVERRIDES (different property)
;; ============================================================
(t/deftest test-switch-with-different-prop-text-override (t/deftest test-switch-with-different-prop-text-override
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@ -472,6 +490,10 @@
(t/is (= (get-in copy-both-t' font-size-path-0) "50")) (t/is (= (get-in copy-both-t' font-size-path-0) "50"))
(t/is (= (get-in copy-both-t' text-path-0) "text overriden")))) (t/is (= (get-in copy-both-t' text-path-0) "text overriden"))))
;; ============================================================
;; TEXT OVERRIDES (different text)
;; ============================================================
(t/deftest test-switch-with-different-text-text-override (t/deftest test-switch-with-different-text-text-override
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@ -596,6 +618,10 @@
(t/is (= (get-in copy-both-t' font-size-path-0) "25")) (t/is (= (get-in copy-both-t' font-size-path-0) "25"))
(t/is (= (get-in copy-both-t' text-path-0) "bye")))) (t/is (= (get-in copy-both-t' text-path-0) "bye"))))
;; ============================================================
;; TEXT OVERRIDES (different text AND property)
;; ============================================================
(t/deftest test-switch-with-different-text-and-prop-text-override (t/deftest test-switch-with-different-text-and-prop-text-override
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@ -722,6 +748,10 @@
(t/is (= (get-in copy-both-t' font-size-path-0) "50")) (t/is (= (get-in copy-both-t' font-size-path-0) "50"))
(t/is (= (get-in copy-both-t' text-path-0) "bye")))) (t/is (= (get-in copy-both-t' text-path-0) "bye"))))
;; ============================================================
;; TEXT STRUCTURE OVERRIDES (identical variants)
;; ============================================================
(t/deftest test-switch-with-identical-structure-text-override (t/deftest test-switch-with-identical-structure-text-override
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@ -851,6 +881,10 @@
(t/is (= (get-in copy-structure-mixed-t' font-size-path-1) "40")) (t/is (= (get-in copy-structure-mixed-t' font-size-path-1) "40"))
(t/is (= (get-in copy-structure-mixed-t' text-path-1) "new line 2")))) (t/is (= (get-in copy-structure-mixed-t' text-path-1) "new line 2"))))
;; ============================================================
;; TEXT STRUCTURE OVERRIDES (different property)
;; ============================================================
(t/deftest test-switch-with-different-prop-structure-text-override (t/deftest test-switch-with-different-prop-structure-text-override
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@ -978,6 +1012,10 @@
(t/is (= (get-in copy-structure-mixed-t' text-path-0) "hello world")) (t/is (= (get-in copy-structure-mixed-t' text-path-0) "hello world"))
(t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1))))) (t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1)))))
;; ============================================================
;; TEXT STRUCTURE OVERRIDES (different text)
;; ============================================================
(t/deftest test-switch-with-different-text-structure-text-override (t/deftest test-switch-with-different-text-structure-text-override
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@ -1104,6 +1142,10 @@
(t/is (= (get-in copy-structure-mixed-t' text-path-0) "bye")) (t/is (= (get-in copy-structure-mixed-t' text-path-0) "bye"))
(t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1))))) (t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1)))))
;; ============================================================
;; TEXT STRUCTURE OVERRIDES (different text AND property)
;; ============================================================
(t/deftest test-switch-with-different-text-and-prop-structure-text-override (t/deftest test-switch-with-different-text-and-prop-structure-text-override
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@ -1231,6 +1273,10 @@
(t/is (= (get-in copy-structure-mixed-t' text-path-0) "bye")) (t/is (= (get-in copy-structure-mixed-t' text-path-0) "bye"))
(t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1))))) (t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1)))))
;; ============================================================
;; NESTED COMPONENTS (with same component in both variants)
;; ============================================================
(t/deftest test-switch-variant-for-other-with-same-nested-component (t/deftest test-switch-variant-for-other-with-same-nested-component
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@ -1274,6 +1320,10 @@
;; The width of copy-cp02-rect' is 25 (change is preserved) ;; The width of copy-cp02-rect' is 25 (change is preserved)
(t/is (= (:width copy-cp02-rect') 25)))) (t/is (= (:width copy-cp02-rect') 25))))
;; ============================================================
;; SWAPPED COPIES (switching variants that contain swapped components)
;; ============================================================
(t/deftest test-switch-variant-that-has-swaped-copy (t/deftest test-switch-variant-that-has-swaped-copy
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@ -1366,6 +1416,10 @@
;; The width of copy-cp02-rect' is 25 (change is preserved) ;; The width of copy-cp02-rect' is 25 (change is preserved)
(t/is (= (:width copy-cp02-rect') 25)))) (t/is (= (:width copy-cp02-rect') 25))))
;; ============================================================
;; TOUCHED PARENT (switch without touched but with touched parent)
;; ============================================================
(t/deftest test-switch-variant-without-touched-but-touched-parent (t/deftest test-switch-variant-without-touched-but-touched-parent
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@ -1420,3 +1474,787 @@
(t/is (= (:width rect01) 25)) (t/is (= (:width rect01) 25))
;; The rect still has width 25 after the switch ;; The rect still has width 25 after the switch
(t/is (= (:width rect02') 25)))) (t/is (= (:width rect02') 25))))
;; ============================================================
;; LAYOUT ITEM SIZING - HORIZONTAL (fix, auto, fill, none)
;; ============================================================
(t/deftest test-switch-with-layout-item-h-sizing-fix
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
;; Create a variant with a child that has layout-item-h-sizing :fix
;; When :fix is set, the width should NOT be preserved on switch
;; but should take the new component's width
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 100
:height 50
:layout-item-h-sizing :fix}
:child2-params {:width 200
:height 50
:layout-item-h-sizing :fix}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; Change width of the child rect (creating an override)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id rect01)}
(fn [shape]
(assoc shape :width 150))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had width 150 before the switch (with override)
(t/is (= (:width rect01) 150))
;; With layout-item-h-sizing :fix, the width should be taken from the new component
;; (not preserving the override), so it should be 200
(t/is (= (:width rect02') 200))
;; Verify layout-item-h-sizing is still :fix after switch
(t/is (= (:layout-item-h-sizing rect02') :fix))))
(t/deftest test-switch-with-layout-item-h-sizing-auto
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
;; Create a variant with a child that has layout-item-h-sizing :auto
;; When :auto is set, the width override SHOULD be preserved on switch
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 100
:height 50
:layout-item-h-sizing :auto}
:child2-params {:width 200
:height 50
:layout-item-h-sizing :auto}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; Change width of the child rect (creating an override)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id rect01)}
(fn [shape]
(assoc shape :width 150))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had width 150 before the switch (with override)
(t/is (= (:width rect01) 150))
;; With layout-item-h-sizing :auto, since the two variants have different widths (100 vs 200),
;; the override is not preserved and the new component's width (200) is used
(t/is (= (:width rect02') 200))
;; Verify layout-item-h-sizing is still :auto after switch
(t/is (= (:layout-item-h-sizing rect02') :auto))))
(t/deftest test-switch-with-layout-item-h-sizing-fill
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
;; Create a variant with a child that has layout-item-h-sizing :fill
;; When :fill is set, the width override SHOULD be preserved on switch
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 100
:height 50
:layout-item-h-sizing :fill}
:child2-params {:width 200
:height 50
:layout-item-h-sizing :fill}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; Change width of the child rect (creating an override)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id rect01)}
(fn [shape]
(assoc shape :width 150))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had width 150 before the switch (with override)
(t/is (= (:width rect01) 150))
;; With layout-item-h-sizing :fill, since the two variants have different widths (100 vs 200),
;; the override is not preserved and the new component's width (200) is used
(t/is (= (:width rect02') 200))
;; Verify layout-item-h-sizing is still :fill after switch
(t/is (= (:layout-item-h-sizing rect02') :fill))))
(t/deftest test-switch-without-layout-item-h-sizing
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
;; Create a variant with a child without layout-item-h-sizing
;; When not set, the width override SHOULD be preserved on switch
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 100
:height 50}
:child2-params {:width 200
:height 50}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; Change width of the child rect (creating an override)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id rect01)}
(fn [shape]
(assoc shape :width 150))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had width 150 before the switch (with override)
(t/is (= (:width rect01) 150))
;; Without layout-item-h-sizing, since the two variants have different widths (100 vs 200),
;; the override is not preserved and the new component's width (200) is used
(t/is (= (:width rect02') 200))
;; Verify layout-item-h-sizing is still nil after switch
(t/is (nil? (:layout-item-h-sizing rect02')))))
;; ============================================================
;; LAYOUT ITEM SIZING - VERTICAL (fix, auto, fill, none)
;; ============================================================
(t/deftest test-switch-with-layout-item-v-sizing-fix
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
;; Create a variant with a child that has layout-item-v-sizing :fix
;; When :fix is set, the height should NOT be preserved on switch
;; but should take the new component's height
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 50
:height 100
:layout-item-v-sizing :fix}
:child2-params {:width 50
:height 200
:layout-item-v-sizing :fix}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; Change height of the child rect (creating an override)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id rect01)}
(fn [shape]
(assoc shape :height 150))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had height 150 before the switch (with override)
(t/is (= (:height rect01) 150))
;; With layout-item-v-sizing :fix, the height should be taken from the new component
;; (not preserving the override), so it should be 200
(t/is (= (:height rect02') 200))
;; Verify layout-item-v-sizing is still :fix after switch
(t/is (= (:layout-item-v-sizing rect02') :fix))))
(t/deftest test-switch-with-layout-item-v-sizing-auto
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
;; Create a variant with a child that has layout-item-v-sizing :auto
;; When :auto is set, the height override SHOULD be preserved on switch
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 50
:height 100
:layout-item-v-sizing :auto}
:child2-params {:width 50
:height 200
:layout-item-v-sizing :auto}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; Change height of the child rect (creating an override)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id rect01)}
(fn [shape]
(assoc shape :height 150))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had height 150 before the switch (with override)
(t/is (= (:height rect01) 150))
;; With layout-item-v-sizing :auto, since the two variants have different heights (100 vs 200),
;; the override is not preserved and the new component's height (200) is used
(t/is (= (:height rect02') 200))
;; Verify layout-item-v-sizing is still :auto after switch
(t/is (= (:layout-item-v-sizing rect02') :auto))))
(t/deftest test-switch-with-layout-item-v-sizing-fill
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
;; Create a variant with a child that has layout-item-v-sizing :fill
;; When :fill is set, the height override SHOULD be preserved on switch
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 50
:height 100
:layout-item-v-sizing :fill}
:child2-params {:width 50
:height 200
:layout-item-v-sizing :fill}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; Change height of the child rect (creating an override)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id rect01)}
(fn [shape]
(assoc shape :height 150))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had height 150 before the switch (with override)
(t/is (= (:height rect01) 150))
;; With layout-item-v-sizing :fill, since the two variants have different heights (100 vs 200),
;; the override is not preserved and the new component's height (200) is used
(t/is (= (:height rect02') 200))
;; Verify layout-item-v-sizing is still :fill after switch
(t/is (= (:layout-item-v-sizing rect02') :fill))))
(t/deftest test-switch-without-layout-item-v-sizing
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
;; Create a variant with a child without layout-item-v-sizing
;; When not set, the height override SHOULD be preserved on switch
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 50
:height 100}
:child2-params {:width 50
:height 200}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; Change height of the child rect (creating an override)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id rect01)}
(fn [shape]
(assoc shape :height 150))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had height 150 before the switch (with override)
(t/is (= (:height rect01) 150))
;; Without layout-item-v-sizing, since the two variants have different heights (100 vs 200),
;; the override is not preserved and the new component's height (200) is used
(t/is (= (:height rect02') 200))
;; Verify layout-item-v-sizing is still nil after switch
(t/is (nil? (:layout-item-v-sizing rect02')))))
;; ============================================================
;; ROTATION OVERRIDES
;; ============================================================
(t/deftest test-switch-with-rotation-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 100
:height 100
:rotation 0}
:child2-params {:width 100
:height 100
:rotation 0}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; Apply rotation to the child rect (creating an override)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id rect01)}
(fn [shape]
(assoc shape :rotation 45))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had rotation 45 before the switch (with override)
(t/is (= (:rotation rect01) 45))
;; The rotation override should be preserved after switch since both variants have the same rotation
(t/is (= (:rotation rect02') 45))
;; The transform matrix should also be preserved
(t/is (some? (:transform rect02')))))
(t/deftest test-switch-with-rotation-different-variants
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 100
:height 100
:rotation 0}
:child2-params {:width 100
:height 100
:rotation 90}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; Apply rotation to the child rect (creating an override)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id rect01)}
(fn [shape]
(assoc shape :rotation 45))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had rotation 45 before the switch (with override)
(t/is (= (:rotation rect01) 45))
;; The override should NOT be preserved since the two variants have different rotations (0 vs 90)
;; The new rotation should be 90 (from c02)
(t/is (= (:rotation rect02') 90))))
;; ============================================================
;; SPECIAL CASES (auto-text, geometry, touched attributes, position data)
;; ============================================================
(t/deftest test-switch-with-auto-text-geometry-not-copied
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
;; Create variants with auto-text (grow-type :auto-width or :auto-height)
(thv/add-variant-with-text
:v01 :c01 :m01 :c02 :m02 :t01 :t02 "hello" "world"))
page (thf/current-page file)
;; Modify the first text shape to have grow-type :auto-width
t01 (ths/get-shape file :t01)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id t01)}
(fn [shape]
(assoc shape :grow-type :auto-width))
(:objects page)
{})
file (thf/apply-changes file changes)
;; Also modify t02
page (thf/current-page file)
t02 (ths/get-shape file :t02)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id t02)}
(fn [shape]
(assoc shape :grow-type :auto-width))
(:objects page)
{})
file (thf/apply-changes file changes)
;; Now create a copy and modify its width
file (thc/instantiate-component file :c01
:copy01
:children-labels [:copy-t01])
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
text01 (get-in page [:objects (-> copy01 :shapes first)])
;; Change width of the text (creating an override)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id text01)}
(fn [shape]
(assoc shape :width 200))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
text01 (get-in page [:objects (:id text01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
text02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The text had width 200 before the switch (with override)
(t/is (= (:width text01) 200))
;; For auto-text shapes, geometry attributes like width should NOT be copied on switch
;; So the width should be from the new component (t02's width)
(t/is (not= (:width text02') 200))
;; Verify grow-type is preserved
(t/is (= (:grow-type text02') :auto-width))))
(t/deftest test-switch-different-shape-types-content-not-copied
(let [;; ==== Setup - Create a variant with a rect in first component
;; This test is simplified to just test attributes, not changing shape types
file (-> (thf/sample-file :file1)
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 100 :height 100 :type :rect}
:child2-params {:width 100 :height 100 :type :rect}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; ==== Action - Try to switch to a component with different shape type
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
child02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; Verify the shapes are still rects
(t/is (= (:type rect01) :rect))
(t/is (= (:type child02') :rect))
;; This test demonstrates that content with different types isn't copied
;; In practice this means proper attribute filtering
(t/is (= (:width child02') 100))))
(t/deftest test-switch-with-path-shape-geometry-override
(let [;; ==== Setup - Create variants with path shapes
;; Using rect shapes as path shapes are complex - the principle is the same
file (-> (thf/sample-file :file1)
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 100 :height 100 :type :rect}
:child2-params {:width 200 :height 200 :type :rect}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-path01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
path01 (get-in page [:objects (-> copy01 :shapes first)])
;; Resize the path (creating an override by changing selrect)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id path01)}
(fn [shape]
(assoc shape :width 150))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
path01 (get-in page [:objects (:id path01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
path02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had width 150 before the switch
(t/is (= (:width path01) 150))
;; For shapes with geometry changes, the transformed geometry is applied
;; Since variants have different widths (100 vs 200), override is discarded
(t/is (= (:width path02') 200))
;; Verify it's still a rect type
(t/is (= (:type path02') :rect))))
(t/deftest test-switch-preserves-touched-attributes-only
(let [;; ==== Setup - Test that only touched attributes are copied
;; Use opacity since it's a simpler attribute than fill-color
file (-> (thf/sample-file :file1)
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 100
:height 100
:opacity 1}
:child2-params {:width 200
:height 200
:opacity 1}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; Change the opacity (creating a touched attribute)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id rect01)}
(fn [shape]
(assoc shape :opacity 0.5))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had opacity 0.5 before the switch (touched)
(t/is (= (:opacity rect01) 0.5))
;; The rect had width 100 before the switch (not touched)
(t/is (= (:width rect01) 100))
;; After switch:
;; - opacity override SHOULD be preserved because:
;; 1. It was touched
;; 2. Both variants have same opacity (1)
(t/is (= (:opacity rect02') 0.5))
;; - width should NOT be preserved (it wasn't touched, and variants have different widths)
(t/is (= (:width rect02') 200))
;; - height should match the new variant
(t/is (= (:height rect02') 200))))
(t/deftest test-switch-with-equal-values-not-copied
(let [;; ==== Setup - Test that when previous-shape and current-shape have equal values,
;; no copy operation occurs (optimization in update-attrs-on-switch)
;; Both variants start with opacity 0.5
file (-> (thf/sample-file :file1)
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 100
:height 100
:opacity 0.5}
:child2-params {:width 100
:height 100
:opacity 0.5}})
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-r01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The rect had opacity 0.5 before the switch
(t/is (= (:opacity rect01) 0.5))
;; After switch, opacity should still be 0.5
;; This validates that the equality check works correctly
(t/is (= (:opacity rect02') 0.5))))
(t/deftest test-switch-with-position-data-reset
(let [;; ==== Setup - Test that position-data is reset when geometry-group is touched
file (-> (thf/sample-file :file1)
;; Create variants with text shapes
(thv/add-variant-with-text
:v01 :c01 :m01 :c02 :m02 :t01 :t02 "hello world" "hello world"))
page (thf/current-page file)
;; Modify the first text shape to have specific geometry
t01 (ths/get-shape file :t01)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id t01)}
(fn [shape]
(assoc shape :width 200))
(:objects page)
{})
file (thf/apply-changes file changes)
;; Create a copy and modify its geometry (touching geometry-group)
file (thc/instantiate-component file :c01
:copy01
:children-labels [:copy-t01])
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
text01 (get-in page [:objects (-> copy01 :shapes first)])
;; Change width of the text (touching geometry)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id text01)}
(fn [shape]
(assoc shape :width 300))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
text01 (get-in page [:objects (:id text01)])
old-position-data (:position-data text01)
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
text02' (get-in page' [:objects (-> copy02' :shapes first)])
new-position-data (:position-data text02')]
;; position-data should be reset (nil or different) when geometry group is touched
;; This allows the system to recalculate it based on the new geometry
;; Note: old-position-data may be nil initially, which is fine
;; After switch with geometry changes, if old data existed and was different,
;; or if it needs recalculation, the test validates the behavior
(t/is (or (nil? old-position-data)
(nil? new-position-data)
(not= old-position-data new-position-data)))))

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -42,26 +42,83 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
<p>If the value of the referenced token changes, this will also change the value of the tokens where it is referenced.</p> <p>If the value of the referenced token changes, this will also change the value of the tokens where it is referenced.</p>
<p class="advice">References to existing tokens are case sensitive.</p> <p class="advice">References to existing tokens are case sensitive.</p>
<h2 id="design-tokens-equations">Using equations</h2> <h2 id="design-tokens-equations">Using math in token values</h2>
<p>Token types with numerical values also accept mathematical equations. If, for example, you create a <strong>spacing.small</strong> token with the value of <strong>2</strong>, and you then want to create a <strong>spacing.medium</strong> token that is twice as large, you could do so by writing <code class="language-js">{spacing.small} * 2</code> in its value. As a result, <strong>spacing.medium</strong> would have a value of <strong>4</strong>.</p> <p>Token types with numerical values accept mathematical equations to calculate their values. This allows you to create dynamic relationships between tokens and build flexible design systems.</p>
<p>Say you have a <strong>spacing.scale</strong> token with a value of <strong>2</strong>. You could also use this token in the equation to calculate the value of <strong>spacing.medium</strong> by writing <code class="language-js">{spacing.small} * {spacing.scale}</code> in its value.</p> <p>For example, if you create a <strong>spacing.small</strong> token with the value of <strong>2</strong>, and you want to create a <strong>spacing.medium</strong> token that is twice as large, you can write <code class="language-js">{spacing.small} * 2</code> in its value. As a result, <strong>spacing.medium</strong> would have a value of <strong>4</strong>.</p>
<p>You can also reference other tokens in your equations. Say you have a <strong>spacing.scale</strong> token with a value of <strong>2</strong>. You could use this token in the equation to calculate the value of <strong>spacing.medium</strong> by writing <code class="language-js">{spacing.small} * {spacing.scale}</code> in its value.</p>
<figure> <figure>
<img src="/img/design-tokens/04-tokens-math.webp" alt="Tokens math" /> <img src="/img/design-tokens/04-tokens-math.webp" alt="Tokens math" />
</figure> </figure>
<p>Mathematical equations can be performed using:</p>
<h3 id="design-tokens-math-operators">Basic operators</h3>
<p>Mathematical equations can be performed using these basic operators:</p>
<ul> <ul>
<li><code class="language-js">+</code> for addition.</li> <li><code class="language-js">+</code> for addition</li>
<li><code class="language-js">-</code> for subtraction.</li> <li><code class="language-js">-</code> for subtraction</li>
<li><code class="language-js">*</code> for multiplication.</li> <li><code class="language-js">*</code> for multiplication</li>
<li><code class="language-js">/</code> for division.</li> <li><code class="language-js">/</code> for division</li>
<li><code class="language-js">%</code> for modulo (remainder)</li>
<li><code class="language-js">^</code> for exponentiation</li>
</ul> </ul>
<h3 id="design-tokens-math-functions">Math functions</h3>
<p>In addition to basic operators, you can use various math functions in your token values:</p>
<ul>
<li><code class="language-js">abs(x)</code> - absolute value</li>
<li><code class="language-js">ceil(x)</code> - round up to nearest integer</li>
<li><code class="language-js">floor(x)</code> - round down to nearest integer</li>
<li><code class="language-js">round(x)</code> - round to nearest integer</li>
<li><code class="language-js">max(x, y, ...)</code> - maximum value</li>
<li><code class="language-js">min(x, y, ...)</code> - minimum value</li>
<li><code class="language-js">sqrt(x)</code> - square root</li>
<li><code class="language-js">pow(x, y)</code> - x raised to the power of y</li>
<li><code class="language-js">log(x)</code> - natural logarithm</li>
<li><code class="language-js">exp(x)</code> - e raised to the power of x</li>
<li><code class="language-js">sin(x)</code> - sine</li>
<li><code class="language-js">cos(x)</code> - cosine</li>
<li><code class="language-js">tan(x)</code> - tangent</li>
<li><code class="language-js">asin(x)</code> - arcsine</li>
<li><code class="language-js">acos(x)</code> - arccosine</li>
<li><code class="language-js">atan(x)</code> - arctangent</li>
<li><code class="language-js">atan2(y, x)</code> - arctangent of y/x</li>
</ul>
<h3 id="design-tokens-math-syntax">Syntax and best practices</h3>
<p>When writing math equations in token values, keep these guidelines in mind:</p>
<ul>
<li>Simple equations can be written with or without brackets. For example, <code class="language-js">8 * 8</code> and <code class="language-js">(8 * 8)</code> both resolve to <code class="language-js">64</code>.</li>
<li>Complex formulas require spaces between operators to ensure tokens are transformed correctly. For example, use <code class="language-js">8 * 8</code> instead of <code class="language-js">8*8</code>.</li>
<li>Reference tokens using curly braces: <code class="language-js">{token.name}</code>.</li>
<li>You can combine hard-coded values with token references: <code class="language-js">{spacing.base} * 1.5</code>.</li>
</ul>
<h3 id="design-tokens-math-examples">Practical examples</h3>
<p>Here are some common use cases for math in token values:</p>
<h4>Round to the nearest whole number</h4>
<p>When using multipliers that result in decimals, you can use the <code class="language-js">round()</code> function to get whole numbers. For example, if <strong>sizing.sm</strong> has a value of <strong>2</strong>:</p>
<pre><code class="language-js">round({sizing.sm} * 1.33)</code></pre>
<p>This calculates <code class="language-js">2 * 1.33 = 2.66</code>, which rounds to <strong>3</strong>.</p>
<h4>Create a percentage from a unitless number</h4>
<p>You can convert unitless numbers to percentages. For example, a Number token called <strong>lineHeights.heading.relaxed</strong> with a value of <strong>1.5</strong> can be written in a Line Height token as:</p>
<pre><code class="language-js">{lineHeights.heading.relaxed} * 100%</code></pre>
<p>This calculates a resolved value of <strong>150%</strong>.</p>
<h4>Calculate maximum or minimum values</h4>
<p>Use <code class="language-js">max()</code> or <code class="language-js">min()</code> to ensure values stay within bounds. For example:</p>
<pre><code class="language-js">max({spacing.base}, 8)</code></pre>
<p>This ensures the spacing is at least 8, even if the base token is smaller.</p>
<h2 id="design-tokens-edit">Editing a token</h2> <h2 id="design-tokens-edit">Editing a token</h2>
<p>Tokens can be edited by right-clicking the token and selecting <strong>Edit token</strong>. This will allow you to change the tokens name, value and description. Once the changes are made, click <strong>Save</strong>.</p> <p>Tokens can be edited by right-clicking the token and selecting <strong>Edit token</strong>. This will allow you to change the tokens name, value and description. Once the changes are made, click <strong>Save</strong>.</p>
<figure> <figure>
<img src="/img/design-tokens/05-tokens-edit.webp" alt="Tokens edit" /> <img src="/img/design-tokens/05-tokens-edit.webp" alt="Tokens edit" />
</figure> </figure>
<p class="advice">Renaming tokens will break any references to their old names. If a token is already applied somewhere, you'll need to reapply it after renaming. This can lead to extra work, so rename with caution. We're actively working on a solution to handle this automatically, ensuring renamed tokens stay linked to their properties without additional effort.</p> <p class="advice">When you rename a token that has references (like aliases, math operations, or applied tokens), Penpot will prompt you to remap those references to the new name. If you choose to remap, all references will be updated automatically, including token aliases, design tab inputs, tooltips, and any elements where the token is applied. If the file is a library, remapping will also update references in files that use that library. You can also choose not to remap if you prefer to keep the old references, though this will break the connections.</p>
<figure>
<img src="/img/design-tokens/39-tokens-remap.webp" alt="Tokens remap" />
</figure>
<h2 id="design-tokens-duplicate">Duplicating a token</h2> <h2 id="design-tokens-duplicate">Duplicating a token</h2>
<p>Tokens can be duplicated by right-clicking the token you wish to duplicate and selecting <strong>Duplicate token</strong>. This will create a copy of the selected token within the same set, with <code class="language-js">-copy</code> added to its name.</p> <p>Tokens can be duplicated by right-clicking the token you wish to duplicate and selecting <strong>Duplicate token</strong>. This will create a copy of the selected token within the same set, with <code class="language-js">-copy</code> added to its name.</p>
@ -690,6 +747,50 @@ ExtraBold Italic
<figcaption>Exporting tokens as a single file.</figcaption> <figcaption>Exporting tokens as a single file.</figcaption>
</figure> </figure>
<h2 id="design-tokens-groups">Token groups</h2>
<p>Token names are rarely short and simple. They often contain multiple sections that represent token type, state, property, variant, and more. To help manage this complexity, Penpot automatically organizes tokens into groups based on the structure of their names.</p>
<h3 id="design-tokens-groups-structure">How token groups work</h3>
<p>When Penpot encounters a dot (<code class="language-js">.</code>) in a token name, it breaks down the name and structures it as nested groups. For example, a token named <code class="language-js">button.primary.default.background-color</code> is organized into groups like this:</p>
<ul>
<li><strong>button</strong> (group)</li>
<li>→ <strong>primary</strong> (group)</li>
<li>→ → <strong>default</strong> (group)</li>
<li>→ → → <strong>background-color</strong> (token)</li>
</ul>
<p>If you have another token like <code class="language-js">button.primary.hover.background-color</code>, it shares the same group structure (<code class="language-js">button.primary</code>) and appears nested within those groups.</p>
<p>This structure matches how tokens are organized in JSON format. When you export tokens, a token like <code class="language-js">button.primary.default.background-color</code> is structured like this:</p>
<pre class="language-json"><code class="language-json">{
"button": {
"primary": {
"default": {
"background-color": {
"$value": "#f00",
"$type": "color"
}
}
}
}
}</code></pre>
<h3 id="design-tokens-groups-visual">Visual appearance</h3>
<p>In the Tokens panel, token groups appear as nested, collapsible folders. Only the last segment of the token name (the actual token) appears as a pill. The segments before it appear as group folders that you can expand or collapse.</p>
<p>When you create a new token, Penpot automatically unfolds the required path so you can see your newly created token. If you manually unfold a group path, it stays open even when you navigate to other areas of the app (this state resets if you reload the page).</p>
<figure>
<img src="/img/design-tokens/40-tokens-groups.webp" alt="Design Tokens Grouping" />
</figure>
<h3 id="design-tokens-groups-actions">Working with token groups</h3>
<p>Token pills keep the same actions as before: you can delete, edit, and duplicate tokens from the context menu. When editing a token name, you'll see the full token path including all group segments.</p>
<h4>Moving tokens between groups</h4>
<p>When you edit a token name and change the group segments, the token moves to its new group automatically. If the new group doesn't exist, Penpot creates it. If the group already exists, the token is moved there.</p>
<p>For example, if you rename <code class="language-js">color.background.secondary</code> to <code class="language-js">c.bg.sec</code>, the token moves from the <code class="language-js">color.background</code> group to a new <code class="language-js">c.bg</code> group.</p>
<h4>Deleting token groups</h4>
<p>When you delete a token, if it's the last token in its group, the empty group is automatically removed as well. You can also delete entire token groups directly, which removes all tokens within that group.</p>
<p class="advice">Deleting a token group removes all tokens it contains. Make sure you want to delete all tokens in a group before removing it.</p>
<!-- Leaving this section as a comment as the feature will be released very soon <!-- Leaving this section as a comment as the feature will be released very soon
<h2 id="design-tokens-settings">Tokens settings</h2> <h2 id="design-tokens-settings">Tokens settings</h2>

View File

@ -142,7 +142,7 @@ a design.</p>
</figure> </figure>
<h3 id="text">Text</h3> <h3 id="text">Text</h3>
<p> (NOTA: El grosso de este contenido está en su propia sección. Aquí vendría un texto introductorio y un link a la <a href="/user-guide/designing/text-typo/">sección en cuestión</a>. )</p> <p>Text layers are how you add copy to your designs in Penpot. If you want to go deeper into fonts, typography and advanced text options, check the dedicated <a href="/user-guide/designing/text-typo/">Text & Typography</a> section.</p>
<h3 id="curves">Curves (freehand)</h3> <h3 id="curves">Curves (freehand)</h3>
<p>The curve tool allows a path to be created directly in a freehand mode. <p>The curve tool allows a path to be created directly in a freehand mode.

View File

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

View File

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

View File

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

View File

@ -94,7 +94,7 @@ test("Create a LINEAR gradient", async ({ page }) => {
await expect(inputOpacityGlobal).toBeVisible(); await expect(inputOpacityGlobal).toBeVisible();
await expect( await expect(
workspacePage.page.getByText("Linear gradient").nth(1), workspacePage.page.getByText("Linear gradient")
).toBeVisible(); ).toBeVisible();
}); });
@ -178,7 +178,7 @@ test("Create a RADIAL gradient", async ({ page }) => {
await expect(inputOpacityGlobal).toBeVisible(); await expect(inputOpacityGlobal).toBeVisible();
await expect( await expect(
workspacePage.page.getByText("Radial gradient").nth(1), workspacePage.page.getByText("Radial gradient")
).toBeVisible(); ).toBeVisible();
}); });

View File

@ -83,7 +83,7 @@ test.describe("Tokens: Apply token", () => {
await brTokenPillSM.click(); await brTokenPillSM.click();
// Change token from dropdown // Change token from dropdown
const brTokenOptionXl = borderRadiusSection.getByLabel("borderRadius.xl"); const brTokenOptionXl = borderRadiusSection.getByRole('option', { name: 'borderRadius.xl' })
await expect(brTokenOptionXl).toBeVisible(); await expect(brTokenOptionXl).toBeVisible();
await brTokenOptionXl.click(); await brTokenOptionXl.click();
@ -149,7 +149,7 @@ test.describe("Tokens: Apply token", () => {
await detachButton.click(); await detachButton.click();
// Open dropdown from input // Open dropdown from input
const dropdownBtn = layerMenuSection.getByLabel("Open token list"); const dropdownBtn = layerMenuSection.getByRole('button', { name: 'Open token list' })
await expect(dropdownBtn).toBeVisible(); await expect(dropdownBtn).toBeVisible();
await dropdownBtn.click(); await dropdownBtn.click();
@ -225,8 +225,8 @@ test.describe("Tokens: Apply token", () => {
await expect(firstShadowFields).toBeVisible(); await expect(firstShadowFields).toBeVisible();
// Fill in the shadow values // Fill in the shadow values
const offsetXInput = firstShadowFields.getByLabel("X"); const offsetXInput = firstShadowFields.getByRole('textbox', { name: 'X' });
const offsetYInput = firstShadowFields.getByLabel("Y"); const offsetYInput = firstShadowFields.getByRole('textbox', { name: 'Y' });
const blurInput = firstShadowFields.getByRole("textbox", { const blurInput = firstShadowFields.getByRole("textbox", {
name: "Blur", name: "Blur",
}); });
@ -299,8 +299,8 @@ test.describe("Tokens: Apply token", () => {
await expect(thirdShadowFields).toBeVisible(); await expect(thirdShadowFields).toBeVisible();
// User adds values for the third shadow // User adds values for the third shadow
const thirdOffsetXInput = thirdShadowFields.getByLabel("X"); const thirdOffsetXInput = thirdShadowFields.getByRole('textbox', { name: 'X' });
const thirdOffsetYInput = thirdShadowFields.getByLabel("Y"); const thirdOffsetYInput = thirdShadowFields.getByRole('textbox', { name: 'Y' });
const thirdBlurInput = thirdShadowFields.getByRole("textbox", { const thirdBlurInput = thirdShadowFields.getByRole("textbox", {
name: "Blur", name: "Blur",
}); });
@ -328,10 +328,10 @@ test.describe("Tokens: Apply token", () => {
// Verify that the first shadow kept its values // Verify that the first shadow kept its values
const firstOffsetXValue = await firstShadowFields const firstOffsetXValue = await firstShadowFields
.getByLabel("X") .getByRole('textbox', { name: 'X' })
.inputValue(); .inputValue();
const firstOffsetYValue = await firstShadowFields const firstOffsetYValue = await firstShadowFields
.getByLabel("Y") .getByRole('textbox', { name: 'Y' })
.inputValue(); .inputValue();
const firstBlurValue = await firstShadowFields const firstBlurValue = await firstShadowFields
.getByRole("textbox", { name: "Blur" }) .getByRole("textbox", { name: "Blur" })
@ -357,10 +357,10 @@ test.describe("Tokens: Apply token", () => {
await expect(newSecondShadowFields).toBeVisible(); await expect(newSecondShadowFields).toBeVisible();
const secondOffsetXValue = await newSecondShadowFields const secondOffsetXValue = await newSecondShadowFields
.getByLabel("X") .getByRole('textbox', { name: 'X' })
.inputValue(); .inputValue();
const secondOffsetYValue = await newSecondShadowFields const secondOffsetYValue = await newSecondShadowFields
.getByLabel("Y") .getByRole('textbox', { name: 'Y' })
.inputValue(); .inputValue();
const secondBlurValue = await newSecondShadowFields const secondBlurValue = await newSecondShadowFields
.getByRole("textbox", { name: "Blur" }) .getByRole("textbox", { name: "Blur" })
@ -410,10 +410,10 @@ test.describe("Tokens: Apply token", () => {
// Verify first shadow values are still there // Verify first shadow values are still there
const restoredFirstOffsetX = await firstShadowFields const restoredFirstOffsetX = await firstShadowFields
.getByLabel("X") .getByRole('textbox', { name: 'X' })
.inputValue(); .inputValue();
const restoredFirstOffsetY = await firstShadowFields const restoredFirstOffsetY = await firstShadowFields
.getByLabel("Y") .getByRole('textbox', { name: 'Y' })
.inputValue(); .inputValue();
const restoredFirstBlur = await firstShadowFields const restoredFirstBlur = await firstShadowFields
.getByRole("textbox", { name: "Blur" }) .getByRole("textbox", { name: "Blur" })
@ -433,10 +433,10 @@ test.describe("Tokens: Apply token", () => {
// Verify second shadow values are still there // Verify second shadow values are still there
const restoredSecondOffsetX = await newSecondShadowFields const restoredSecondOffsetX = await newSecondShadowFields
.getByLabel("X") .getByRole('textbox', { name: 'X' })
.inputValue(); .inputValue();
const restoredSecondOffsetY = await newSecondShadowFields const restoredSecondOffsetY = await newSecondShadowFields
.getByLabel("Y") .getByRole('textbox', { name: 'Y' })
.inputValue(); .inputValue();
const restoredSecondBlur = await newSecondShadowFields const restoredSecondBlur = await newSecondShadowFields
.getByRole("textbox", { name: "Blur" }) .getByRole("textbox", { name: "Blur" })
@ -518,7 +518,7 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.nth(1).click(); await dimensionSMTokenPill.nth(1).click();
// Change token from dropdown // Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl"); const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' })
await expect(dimensionTokenOptionXl).toBeVisible(); await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click(); await dimensionTokenOptionXl.click();
@ -572,7 +572,7 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.click(); await dimensionSMTokenPill.click();
// Change token from dropdown // Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl"); const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' });
await expect(dimensionTokenOptionXl).toBeVisible(); await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click(); await dimensionTokenOptionXl.click();
@ -626,7 +626,7 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.click(); await dimensionSMTokenPill.click();
// Change token from dropdown // Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl"); const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' });
await expect(dimensionTokenOptionXl).toBeVisible(); await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click(); await dimensionTokenOptionXl.click();
@ -682,7 +682,7 @@ test.describe("Tokens: Apply token", () => {
// Change token from dropdown // Change token from dropdown
const dimensionTokenOptionXl = const dimensionTokenOptionXl =
borderRadiusSection.getByLabel("dimension.xl"); borderRadiusSection.getByRole('option', { name: 'dimension.xl' });
await expect(dimensionTokenOptionXl).toBeVisible(); await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click(); await dimensionTokenOptionXl.click();
@ -751,7 +751,7 @@ test.describe("Tokens: Apply token", () => {
}); });
await tokenDropdown.click(); await tokenDropdown.click();
const widthOptionSmall = firstStrokeRow.getByLabel("width-small"); const widthOptionSmall = firstStrokeRow.getByRole('option', { name: 'width-small' });
await expect(widthOptionSmall).toBeVisible(); await expect(widthOptionSmall).toBeVisible();
await widthOptionSmall.click(); await widthOptionSmall.click();
const StrokeWidthPillSmall = firstStrokeRow.getByRole("button", { const StrokeWidthPillSmall = firstStrokeRow.getByRole("button", {
@ -831,15 +831,10 @@ test.describe("Tokens: Apply token", () => {
}); });
await detachButton.click(); await detachButton.click();
await expect(marginPillXL).not.toBeVisible(); await expect(marginPillXL).not.toBeVisible();
const horizontalMarginInput = layoutItemSectionSidebar.getByText( const horizontalMarginInput = layoutItemSectionSidebar.getByRole("button", {
"Horizontal marginOpen token",
);
await expect(horizontalMarginInput).toBeVisible();
const tokenDropdown = horizontalMarginInput.getByRole("button", {
name: "Open token list", name: "Open token list",
}); });
await tokenDropdown.click(); await horizontalMarginInput.nth(1).click();
await expect(dimensionTokenOptionXl).toBeVisible(); await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click(); await dimensionTokenOptionXl.click();

View File

@ -1024,7 +1024,7 @@ test.describe("Tokens - creation", () => {
const nameField = tokensUpdateCreateModal.getByLabel("Name"); const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("typography.empty"); await nameField.fill("typography.empty");
const valueField = tokensUpdateCreateModal.getByLabel("Font Size"); const valueField = tokensUpdateCreateModal.getByRole("textbox", {name: "Font Size"});
// Insert a value and then delete it // Insert a value and then delete it
await valueField.fill("1"); await valueField.fill("1");
@ -1716,12 +1716,12 @@ test.describe("Tokens tab - edition", () => {
// Fill font-family to verify to verify that input value doesn't get split into list of characters // Fill font-family to verify to verify that input value doesn't get split into list of characters
const fontFamilyField = tokensUpdateCreateModal const fontFamilyField = tokensUpdateCreateModal
.getByLabel("Font family") .getByRole("textbox", { name: "Font family" })
.first(); .first();
await fontFamilyField.fill("OneWord"); await fontFamilyField.fill("OneWord");
// Invalidate incorrect values for font size // Invalidate incorrect values for font size
const fontSizeField = tokensUpdateCreateModal.getByLabel(/Font Size/i); const fontSizeField = tokensUpdateCreateModal.getByRole("textbox", { name: "Font Size" });
await fontSizeField.fill("invalid"); await fontSizeField.fill("invalid");
await expect( await expect(
tokensUpdateCreateModal.getByText(/Invalid token value:/), tokensUpdateCreateModal.getByText(/Invalid token value:/),
@ -1736,13 +1736,13 @@ test.describe("Tokens tab - edition", () => {
await fontSizeField.fill("16"); await fontSizeField.fill("16");
await expect(saveButton).toBeEnabled(); await expect(saveButton).toBeEnabled();
const fontWeightField = tokensUpdateCreateModal.getByLabel(/Font Weight/i); const fontWeightField = tokensUpdateCreateModal.getByRole("textbox", { name: "Font Weight" });
const letterSpacingField = const letterSpacingField =
tokensUpdateCreateModal.getByLabel(/Letter Spacing/i); tokensUpdateCreateModal.getByRole("textbox", { name: "Letter Spacing" });
const lineHeightField = tokensUpdateCreateModal.getByLabel(/Line Height/i); const lineHeightField = tokensUpdateCreateModal.getByRole("textbox", { name: "Line Height" });
const textCaseField = tokensUpdateCreateModal.getByLabel(/Text Case/i); const textCaseField = tokensUpdateCreateModal.getByRole("textbox", { name: "Text Case" });
const textDecorationField = const textDecorationField =
tokensUpdateCreateModal.getByLabel(/Text Decoration/i); tokensUpdateCreateModal.getByRole("textbox", { name: "Text Decoration" });
// Capture all values before switching tabs // Capture all values before switching tabs
const originalValues = { const originalValues = {
@ -1800,6 +1800,7 @@ test.describe("Tokens tab - edition", () => {
const colorToken = tokensSidebar.getByRole("button", { const colorToken = tokensSidebar.getByRole("button", {
name: "100", name: "100",
}); });
await expect(colorToken).toBeVisible(); await expect(colorToken).toBeVisible();
await colorToken.click({ button: "right" }); await colorToken.click({ button: "right" });

View File

@ -5,14 +5,17 @@ import { readFile } from "node:fs/promises";
* esbuild plugin to watch a directory recursively * esbuild plugin to watch a directory recursively
*/ */
const watchExtraDirPlugin = { const watchExtraDirPlugin = {
name: 'watch-extra-dir', name: "watch-extra-dir",
setup(build) { setup(build) {
build.onLoad({ filter: /target\/index.js/, namespace: 'file' }, async (args) => { build.onLoad(
return { { filter: /target\/index.js/, namespace: "file" },
watchDirs: ["packages/ui/dist"], async (args) => {
}; return {
}); watchDirs: ["packages/ui/dist"],
} };
},
);
},
}; };
const filter = const filter =

View File

@ -49,13 +49,14 @@
(defn notify-start-loading (defn notify-start-loading
[] []
(st/emit! (ntf/show {:content (tr "media.loading") (st/emit! (ntf/show {:content (tr "media.loading")
:tag ::media-upload
:type :toast :type :toast
:level :info :level :info
:timeout nil}))) :timeout nil})))
(defn notify-finished-loading (defn notify-finished-loading
[] []
(st/emit! (ntf/hide))) (st/emit! (ntf/hide :tag ::media-upload)))
(defn process-error (defn process-error
[error] [error]

View File

@ -76,6 +76,7 @@
(rx/filter #(not= route-id (:id %))) (rx/filter #(not= route-id (:id %)))
(rx/map hide) (rx/map hide)
(rx/take-until stopper))) (rx/take-until stopper)))
(when (:timeout data) (when (:timeout data)
(let [stopper (rx/filter (ptk/type? ::show) stream)] (let [stopper (rx/filter (ptk/type? ::show) stream)]
(->> (rx/of (hide)) (->> (rx/of (hide))

View File

@ -377,7 +377,15 @@
(defn- parse-single-shadow (defn- parse-single-shadow
"Parses a single shadow map with properties: x, y, blur, spread, color, type." "Parses a single shadow map with properties: x, y, blur, spread, color, type."
[shadow-map shadow-index] [shadow-map shadow-index]
(let [add-keyed-errors (fn [shadow-result k errors] (let [shadow-map (merge {:offset-x nil ;; Ensure that all keys are processed, even if missing in the original token
:offset-y nil
:blur nil
:spread nil
:color nil
:inset false}
shadow-map)
add-keyed-errors (fn [shadow-result k errors]
(update shadow-result :errors concat (update shadow-result :errors concat
(map #(assoc % :shadow-key k :shadow-index shadow-index) errors))) (map #(assoc % :shadow-key k :shadow-index shadow-index) errors)))
parsers {:offset-x parse-sd-token-general-value parsers {:offset-x parse-sd-token-general-value

View File

@ -1016,7 +1016,7 @@
(update [_ state] (update [_ state]
(update state :colorpicker (update state :colorpicker
(fn [state] (fn [state]
(let [type (:type state) (let [type (:type state)
state (-> state state (-> state
(update :current-color merge changes) (update :current-color merge changes)
(update :current-color materialize-color-components) (update :current-color materialize-color-components)
@ -1024,6 +1024,7 @@
;; current color can be a library one ;; current color can be a library one
;; I'm changing via colorpicker ;; I'm changing via colorpicker
(update :current-color dissoc :ref-id :ref-file))] (update :current-color dissoc :ref-id :ref-file))]
(if-let [stop (:editing-stop state)] (if-let [stop (:editing-stop state)]
(update-in state [:stops stop] (fn [data] (->> changes (update-in state [:stops stop] (fn [data] (->> changes
(merge data) (merge data)
@ -1044,7 +1045,9 @@
(and (= type :color) (nil? (:color state)))] (and (= type :color) (nil? (:color state)))]
(when (and add-recent? (not ignore-color?)) (when (and add-recent? (not ignore-color?))
(let [color (select-keys state [:image :gradient :color :opacity])] (when-let [color (-> state
(select-keys [:image :gradient :color :opacity])
(not-empty))]
(rx/of (add-recent-color color)))))))) (rx/of (add-recent-color color))))))))
(defn update-colorpicker-gradient (defn update-colorpicker-gradient

View File

@ -251,9 +251,8 @@
(defn upload-fill-image (defn upload-fill-image
[file on-success] [file on-success]
(dm/assert! (assert (dmm/blob? file) "expected a valid blob for `file` param")
"expected a valid blob for `file` param"
(dmm/blob? file))
(ptk/reify ::upload-fill-image (ptk/reify ::upload-fill-image
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]

View File

@ -317,8 +317,10 @@
(let [objects (dsh/lookup-page-objects state) (let [objects (dsh/lookup-page-objects state)
edition (get-in state [:workspace-local :edition]) edition (get-in state [:workspace-local :edition])
drawing (get state :workspace-drawing)] drawing (get state :workspace-drawing)]
(when (and (or (nil? edition) (ctl/grid-layout? objects edition))
(or (empty? drawing) (= :curve (:tool drawing)))) ;; Editors handle their own undo's
(when (or (and (nil? edition) (nil? (:object drawing)))
(ctl/grid-layout? objects edition))
(let [undo (:workspace-undo state) (let [undo (:workspace-undo state)
items (:items undo) items (:items undo)
index (or (:index undo) (dec (count items)))] index (or (:index undo) (dec (count items)))]

View File

@ -72,7 +72,6 @@
(when-let [file-id (or (:file-id data) file-id)] (when-let [file-id (or (:file-id data) file-id)]
(println "File ID: " (str file-id))) (println "File ID: " (str file-id)))
(println "Version: " (:full cf/version)) (println "Version: " (:full cf/version))
(println "URI: " (str cf/public-uri))
(println "HREF: " (rt/get-current-href)) (println "HREF: " (rt/get-current-href))
(println) (println)
@ -88,24 +87,36 @@
(.error js/console "error on generating report" cause) (.error js/console "error on generating report" cause)
nil))) nil)))
(defn- show-not-blocking-error (defn submit-report
"Show a non user blocking error notification" "Report the error report to the audit log subsystem"
[cause] [& {:keys [event-name report hint] :or {event-name "unhandled-exception"}}]
(let [data (ex-data cause) (when (and (not (str/empty? hint))
hint (or (some-> (:hint data) ex/first-line) (string? report)
(ex-message cause))] (string? event-name))
(st/emit! (st/emit!
(ev/event {::ev/name "unhandled-exception" (ev/event {::ev/name event-name
:hint hint :hint hint
:href (rt/get-current-href) :href (rt/get-current-href)
:type (get data :type :unknown) :report report}))))
:report (generate-report cause)})
(ntf/show {:content (tr "errors.unexpected-exception" hint) (defn flash
:type :toast "Show error notification banner and emit error report"
:level :error [& {:keys [type hint cause] :or {type :handled}}]
:timeout 3000})))) (when (ex/exception? cause)
(when-let [event-name (case type
:handled "handled-exception"
:unhandled "unhandled-exception"
:silent nil)]
(let [report (generate-report cause)]
(submit-report :event-name event-name
:report report
:hint (ex/get-hint cause)))))
(st/emit!
(ntf/show {:content (or ^boolean hint (tr "errors.generic"))
:type :toast
:level :error
:timeout 5000})))
(defmethod ptk/handle-error :default (defmethod ptk/handle-error :default
[error] [error]
@ -114,7 +125,7 @@
(ptk/handle-error (assoc error :type :assertion)) (ptk/handle-error (assoc error :type :assertion))
(when-let [cause (::instance error)] (when-let [cause (::instance error)]
(ex/print-throwable cause :prefix "Unexpected Error") (ex/print-throwable cause :prefix "Unexpected Error")
(show-not-blocking-error cause)))) (flash :cause cause :type :unhandled))))
(defmethod ptk/handle-error :wasm-non-blocking (defmethod ptk/handle-error :wasm-non-blocking
[error] [error]
@ -221,7 +232,7 @@
(defmethod ptk/handle-error :assertion (defmethod ptk/handle-error :assertion
[error] [error]
(when-let [cause (::instance error)] (when-let [cause (::instance error)]
(show-not-blocking-error cause) (flash :cause cause :type :handled)
(ex/print-throwable cause :prefix "Assertion Error"))) (ex/print-throwable cause :prefix "Assertion Error")))
;; ;; All the errors that happens on worker are handled here. ;; ;; All the errors that happens on worker are handled here.
@ -325,7 +336,7 @@
:else :else
(when-let [cause (::instance error)] (when-let [cause (::instance error)]
(ex/print-throwable cause :prefix "Restriction Error") (ex/print-throwable cause :prefix "Restriction Error")
(show-not-blocking-error cause)))) (flash :cause cause :type :unhandled))))
;; This happens when the backed server fails to process the ;; This happens when the backed server fails to process the
;; request. This can be caused by an internal assertion or any other ;; request. This can be caused by an internal assertion or any other
@ -358,11 +369,18 @@
(on-unhandled-error [event] (on-unhandled-error [event]
(.preventDefault ^js event) (.preventDefault ^js event)
(handle-uncaught (unchecked-get event "error"))) (when-let [cause (unchecked-get event "error")]
(set! last-exception cause)
(when-not (is-ignorable-exception? cause)
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/schedule #(flash :cause cause :type :unhandled)))))
(on-unhandled-rejection [event] (on-unhandled-rejection [event]
(.preventDefault ^js event) (.preventDefault ^js event)
(handle-uncaught (unchecked-get event "reason")))] (when-let [cause (unchecked-get event "reason")]
(set! last-exception cause)
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/schedule #(flash :cause cause :type :unhandled))))]
(.addEventListener g/window "error" on-unhandled-error) (.addEventListener g/window "error" on-unhandled-error)
(.addEventListener g/window "unhandledrejection" on-unhandled-rejection) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection)

View File

@ -33,6 +33,8 @@
(let [variant (let [variant
(d/nilv variant "primary") (d/nilv variant "primary")
button-ref (mf/use-ref nil)
tooltip-id tooltip-id
(mf/use-id) (mf/use-id)
@ -47,10 +49,12 @@
props props
(mf/spread-props props (mf/spread-props props
{:class [class button-class] {:class [class button-class]
:ref button-ref
:aria-labelledby tooltip-id})] :aria-labelledby tooltip-id})]
[:> tooltip* {:content aria-label [:> tooltip* {:content aria-label
:class tooltip-class :class tooltip-class
:trigger-ref button-ref
:placement tooltip-placement :placement tooltip-placement
:id tooltip-id} :id tooltip-id}
[:> :button props [:> :button props

View File

@ -28,7 +28,8 @@
{::mf/schema schema:token-option} {::mf/schema schema:token-option}
[{:keys [id name on-click selected ref focused resolved] :rest props}] [{:keys [id name on-click selected ref focused resolved] :rest props}]
(let [internal-id (mf/use-id) (let [internal-id (mf/use-id)
id (d/nilv id internal-id)] id (d/nilv id internal-id)
element-ref (mf/use-ref nil)]
[:li {:value id [:li {:value id
:class (stl/css-case :token-option true :class (stl/css-case :token-option true
:option-with-pill true :option-with-pill true
@ -50,10 +51,12 @@
:aria-hidden (when name true)}] :aria-hidden (when name true)}]
[:span {:class (stl/css :icon-placeholder)}]) [:span {:class (stl/css :icon-placeholder)}])
[:> tooltip* {:content name [:> tooltip* {:content name
:trigger-ref element-ref
:id (dm/str id "-name") :id (dm/str id "-name")
:class (stl/css :option-text)} :class (stl/css :option-text)}
;; Add ellipsis ;; Add ellipsis
[:span {:aria-labelledby (dm/str id "-name")} [:span {:aria-labelledby (dm/str id "-name")
:ref element-ref}
name]] name]]
(when resolved (when resolved
[:> :span {:class (stl/css :option-pill)} [:> :span {:class (stl/css :option-pill)}

View File

@ -84,6 +84,7 @@
:on-click on-icon-click}]) :on-click on-icon-click}])
(if aria-label (if aria-label
[:> tooltip* {:content aria-label [:> tooltip* {:content aria-label
:trigger-ref (or ref input-ref)
:class (stl/css :tooltip-wrapper) :class (stl/css :tooltip-wrapper)
:id tooltip-id} :id tooltip-id}
[:> "input" props]] [:> "input" props]]

View File

@ -43,6 +43,7 @@
(tr "ds.inputs.token-field.no-active-token-option")) (tr "ds.inputs.token-field.no-active-token-option"))
default-id (mf/use-id) default-id (mf/use-id)
id (d/nilv id default-id) id (d/nilv id default-id)
pill-ref (mf/use-ref nil)
focus-wrapper focus-wrapper
(mf/use-fn (mf/use-fn
@ -53,6 +54,7 @@
(dom/focus! (mf/ref-val token-wrapper-ref)))))] (dom/focus! (mf/ref-val token-wrapper-ref)))))]
[:> tooltip* {:content property [:> tooltip* {:content property
:class (stl/css :token-field-wrapper) :class (stl/css :token-field-wrapper)
:trigger-ref token-wrapper-ref
:id (dm/str default-id "-input")} :id (dm/str default-id "-input")}
[:div {:class [class (stl/css-case :token-field true [:div {:class [class (stl/css-case :token-field true
:with-icon (some? slot-start) :with-icon (some? slot-start)
@ -70,8 +72,10 @@
[:div {:class (stl/css :content-wrapper)} [:div {:class (stl/css :content-wrapper)}
[:> tooltip* {:content content [:> tooltip* {:content content
:trigger-ref pill-ref
:id (dm/str id "-pill")} :id (dm/str id "-pill")}
[:button {:on-click on-click [:button {:on-click on-click
:ref pill-ref
:class (stl/css-case :pill true :class (stl/css-case :pill true
:no-set-pill (not set-active?) :no-set-pill (not set-active?)
:pill-disabled disabled) :pill-disabled disabled)

View File

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

View File

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

View File

@ -6,7 +6,6 @@
(ns app.main.ui.ds.tooltip.tooltip (ns app.main.ui.ds.tooltip.tooltip
(:require-macros (:require-macros
[app.common.data.macros :as dm]
[app.main.style :as stl]) [app.main.style :as stl])
(:require (:require
[app.common.data :as d] [app.common.data :as d]
@ -15,10 +14,10 @@
[app.util.timers :as ts] [app.util.timers :as ts]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(def ^:private ^:const arrow-height 12)
(def ^:private ^:const half-arrow-height (/ arrow-height 2))
(def ^:private ^:const overlay-offset 32) (def ^:private ^:const overlay-offset 32)
(defonce active-tooltip (atom nil))
(defn- clear-schedule (defn- clear-schedule
[ref] [ref]
(when-let [schedule (mf/ref-val ref)] (when-let [schedule (mf/ref-val ref)]
@ -29,20 +28,6 @@
[ref delay f] [ref delay f]
(mf/set-ref-val! ref (ts/schedule delay f))) (mf/set-ref-val! ref (ts/schedule delay f)))
(defn- show-popover
[node]
(when (.-isConnected ^js node)
(.showPopover ^js node)))
(defn- hide-popover
[node]
(when (and (some? node)
(fn? (.-hidePopover node)))
(dom/unset-css-property! node "block-size")
(dom/unset-css-property! node "inset-block-start")
(dom/unset-css-property! node "inset-inline-start")
(.hidePopover ^js node)))
(defn- calculate-placement-bounding-rect (defn- calculate-placement-bounding-rect
"Given a placement, calcultates the bounding rect for it taking in "Given a placement, calcultates the bounding rect for it taking in
account provided tooltip bounding rect and the origin bounding account provided tooltip bounding rect and the origin bounding
@ -72,18 +57,18 @@
:height tooltip-height} :height tooltip-height}
"left" "left"
{:top (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) {:top (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2))
:left (- trigger-left tooltip-width arrow-height) :left (- trigger-left tooltip-width)
:right (+ (- trigger-left tooltip-width) tooltip-width) :right (+ (- trigger-left tooltip-width) tooltip-width)
:bottom (+ (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) tooltip-height) :bottom (+ (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2)) tooltip-height)
:width tooltip-width :width tooltip-width
:height tooltip-height} :height tooltip-height}
"right" "right"
{:top (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) {:top (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2))
:left (+ trigger-right offset) :left (+ trigger-right offset)
:right (+ trigger-right offset tooltip-width) :right (+ trigger-right offset tooltip-width)
:bottom (+ (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) tooltip-height) :bottom (+ (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2)) tooltip-height)
:width tooltip-width :width tooltip-width
:height tooltip-height} :height tooltip-height}
@ -153,22 +138,6 @@
(recur (rest placements)) (recur (rest placements))
#js [placement placement-brect]))))) #js [placement placement-brect])))))
(defn- update-tooltip-position
"Update the tooltip position having in account the current window
size, placement. It calculates the appropriate placement and updates
the dom with the result."
[tooltip placement origin-brect offset]
(show-popover tooltip)
(let [tooltip-brect (dom/get-bounding-rect tooltip)
tooltip-brect (assoc tooltip-brect :height (:height tooltip-brect) :width (:width tooltip-brect))
window-size (dom/get-window-size)]
(when-let [[placement placement-rect] (find-matching-placement placement tooltip-brect origin-brect window-size offset)]
(let [height (:height placement-rect)]
(dom/set-css-property! tooltip "block-size" (dm/str height "px"))
(dom/set-css-property! tooltip "inset-block-start" (dm/str (:top placement-rect) "px"))
(dom/set-css-property! tooltip "inset-inline-start" (dm/str (:left placement-rect) "px")))
placement)))
(def ^:private schema:tooltip (def ^:private schema:tooltip
[:map [:map
[:class {:optional true} [:maybe :string]] [:class {:optional true} [:maybe :string]]
@ -176,19 +145,26 @@
[:offset {:optional true} :int] [:offset {:optional true} :int]
[:delay {:optional true} :int] [:delay {:optional true} :int]
[:content [:or fn? :string map?]] [:content [:or fn? :string map?]]
[:trigger-ref {:optional true} [:maybe :any]]
[:placement {:optional true} [:placement {:optional true}
[:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]]]) [:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]]])
(mf/defc tooltip* (mf/defc tooltip*
{::mf/schema schema:tooltip} {::mf/schema schema:tooltip}
[{:keys [class id children content placement offset delay] :rest props}] [{:keys [class id children content placement offset delay trigger-ref aria-label] :rest props}]
(let [internal-id (let [internal-id
(mf/use-id) (mf/use-id)
trigger-ref (mf/use-ref nil) internal-trigger-ref (mf/use-ref nil)
trigger-ref (or trigger-ref internal-trigger-ref)
tooltip-ref (mf/use-ref nil)
id id
(d/nilv id internal-id) (d/nilv id internal-id)
tooltip-id
(mf/use-id)
placement* placement*
(mf/use-state #(d/nilv placement "top")) (mf/use-state #(d/nilv placement "top"))
@ -201,35 +177,35 @@
schedule-ref schedule-ref
(mf/use-ref nil) (mf/use-ref nil)
visible*
(mf/use-state false)
visible (deref visible*)
on-show on-show
(mf/use-fn (mf/use-fn
(mf/deps id placement offset) (mf/deps tooltip-id delay)
(fn [event] (fn [_]
(let [trigger-el (mf/ref-val trigger-ref)]
(let [current (dom/get-current-target event) (clear-schedule schedule-ref)
related (dom/get-related-target event) (add-schedule schedule-ref (d/nilv delay 300)
is-node? (fn [node] (and node (.-nodeType node)))] (fn []
(when-not (and related (is-node? related) (.contains current related)) (prn tooltip-id)
(clear-schedule schedule-ref) (when-let [active @active-tooltip]
(when-let [tooltip (dom/get-element id)] (when (not= (:id active) tooltip-id)
(let [origin-brect (when-let [tooltip-el (dom/get-element (:id active))]
(dom/get-bounding-rect (mf/ref-val trigger-ref)) (dom/set-css-property! tooltip-el "display" "none"))
(reset! active-tooltip nil)))
update-position (reset! active-tooltip {:id tooltip-id :trigger trigger-el})
(fn [] (reset! visible* true))))))
(let [new-placement (update-tooltip-position tooltip placement origin-brect offset)]
(when (not= new-placement placement)
(reset! placement* new-placement))))]
(add-schedule schedule-ref delay update-position)))))))
on-hide on-hide
(mf/use-fn (mf/use-fn
(mf/deps id) (mf/deps tooltip-id)
(fn [] (fn []
(when-let [tooltip (dom/get-element id)] (clear-schedule schedule-ref)
(clear-schedule schedule-ref) (reset! visible* false)
(hide-popover tooltip)))) (when (= (:id @active-tooltip) tooltip-id)
(reset! active-tooltip nil))))
handle-key-down handle-key-down
(mf/use-fn (mf/use-fn
@ -250,28 +226,62 @@
:tooltip-bottom-left (identical? placement "bottom-left") :tooltip-bottom-left (identical? placement "bottom-left")
:tooltip-top-left (identical? placement "top-left")) :tooltip-top-left (identical? placement "top-left"))
content
(if (fn? content)
(content)
content)
props props
(mf/spread-props props (mf/spread-props props
{:on-mouse-enter on-show {:on-mouse-enter on-show
:on-mouse-leave on-hide :on-mouse-leave on-hide
:on-focus on-show :on-focus on-show
:on-blur on-hide :on-blur on-hide
:ref internal-trigger-ref
:on-key-down handle-key-down :on-key-down handle-key-down
:ref trigger-ref :id id
:class [class (stl/css :tooltip-trigger)] :class [class (stl/css :tooltip-trigger)]
:aria-describedby id}) :aria-label (if (string? content)
content content
(if (fn? content) aria-label)})]
(content)
content)] (mf/use-effect
(mf/deps visible placement offset)
(fn []
(when visible
(let [trigger-el (mf/ref-val trigger-ref)
tooltip-el (mf/ref-val tooltip-ref)]
(when (and trigger-el tooltip-el)
(js/requestAnimationFrame
(fn []
(let [origin-brect (dom/get-bounding-rect trigger-el)
tooltip-brect (dom/get-bounding-rect tooltip-el)
window-size (dom/get-window-size)]
(when-let [[new-placement placement-rect]
(find-matching-placement
placement
tooltip-brect
origin-brect
window-size
offset)]
(dom/set-css-property! tooltip-el "inset-block-start"
(str (:top placement-rect) "px"))
(dom/set-css-property! tooltip-el "inset-inline-start"
(str (:left placement-rect) "px"))
(when (not= new-placement placement)
(reset! placement* new-placement)))))))))))
[:> :div props [:> :div props
children children
[:div {:class (stl/css :tooltip) (when visible
:id id (mf/portal
:popover "auto" (mf/html
:role "tooltip"} [:div {:class (stl/css :tooltip)
[:div {:class tooltip-class} :role "tooltip"
[:div {:class (stl/css :tooltip-content)} content] :id tooltip-id
[:div {:class (stl/css :tooltip-arrow) :ref tooltip-ref}
:id "tooltip-arrow"}]]]])) [:div {:class tooltip-class}
[:div {:class (stl/css :tooltip-content)} content]
[:div {:class (stl/css :tooltip-arrow)
:id "tooltip-arrow"}]]])
(.-body js/document)))]))

View File

@ -6,17 +6,19 @@
@use "ds/_sizes.scss" as *; @use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *; @use "ds/_borders.scss" as *;
@use "ds/z-index.scss" as *;
@use "ds/typography.scss" as t; @use "ds/typography.scss" as t;
$arrow-side: 12px; $arrow-side: 12px;
.tooltip { .tooltip {
position: absolute; position: fixed;
max-inline-size: $sz-352; max-inline-size: $sz-352;
background-color: transparent; background-color: transparent;
overflow: hidden; overflow: hidden;
inline-size: fit-content; inline-size: fit-content;
block-size: fit-content; block-size: fit-content;
z-index: var(--z-index-notifications);
} }
.tooltip-content-wrapper { .tooltip-content-wrapper {

View File

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

View File

@ -96,8 +96,9 @@
image (:image background) image (:image background)
format (if id? "rounded" "square") format (if id? "rounded" "square")
element-id (mf/use-id) element-id (mf/use-id)
has-opacity? (and (some? (:color background)) has-opacity? (and (some? (:color background))
(< (:opacity background) 1)) (< (:opacity background) 1))
element-ref (mf/use-ref nil)
on-click on-click
(mf/use-fn (mf/use-fn
(mf/deps background on-click) (mf/deps background on-click)
@ -120,7 +121,8 @@
(mf/spread-props props {:class class (mf/spread-props props {:class class
:on-click on-click :on-click on-click
:type button-type :type button-type
:aria-labelledby element-id}) :aria-labelledby element-id
:ref element-ref})
children (mf/html children (mf/html
[:> element-type props [:> element-type props
(cond (cond
@ -147,6 +149,7 @@
[:> tooltip* {:content (if tooltip-content [:> tooltip* {:content (if tooltip-content
tooltip-content tooltip-content
(color-title background)) (color-title background))
:trigger-ref element-ref
:id element-id} :id element-id}
children] children]

View File

@ -23,11 +23,12 @@
(mf/defc property-detail-copiable* (mf/defc property-detail-copiable*
{::mf/schema schema:property-detail-copiable} {::mf/schema schema:property-detail-copiable}
[{:keys [color token copied on-click children]}] [{:keys [color token copied on-click children ref]}]
[:button {:class (stl/css-case :property-detail-copiable true [:button {:class (stl/css-case :property-detail-copiable true
:property-detail-copied copied :property-detail-copied copied
:property-detail-copiable-color (some? color)) :property-detail-copiable-color (some? color))
:on-click on-click} :on-click on-click
:ref ref}
(when color (when color
[:> swatch* {:background color [:> swatch* {:background color
:size "small"}]) :size "small"}])

View File

@ -41,6 +41,7 @@
color-image-name (:name color-image) color-image-name (:name color-image)
color-image-url (when (some? color-image) color-image-url (when (some? color-image)
(cfg/resolve-file-media color-image)) (cfg/resolve-file-media color-image))
row-ref (mf/use-ref nil)
color-opacity (mf/use-memo color-opacity (mf/use-memo
(mf/deps color) (mf/deps color)
#(dm/str (-> color #(dm/str (-> color
@ -96,6 +97,7 @@
(if token (if token
[:> tooltip* {:id (:name token) [:> tooltip* {:id (:name token)
:class (stl/css :tooltip-token-wrapper) :class (stl/css :tooltip-token-wrapper)
:trigger-ref row-ref
:content #(mf/html :content #(mf/html
[:div {:class (stl/css :tooltip-token)} [:div {:class (stl/css :tooltip-token)}
[:div {:class (stl/css :tooltip-token-title)} [:div {:class (stl/css :tooltip-token-title)}
@ -104,6 +106,7 @@
(:resolved-value token)]])} (:resolved-value token)]])}
[:> property-detail-copiable* {:color color [:> property-detail-copiable* {:color color
:token token :token token
:ref row-ref
:copied copied :copied copied
:on-click copy-attr} formatted-color-value]] :on-click copy-attr} formatted-color-value]]

View File

@ -37,6 +37,7 @@
copiable-value (if (some? token) copiable-value (if (some? token)
(:name token) (:name token)
property) property)
row-ref (mf/use-ref nil)
copy-attr copy-attr
(mf/use-fn (mf/use-fn
@ -54,6 +55,7 @@
(let [token-type (:type token)] (let [token-type (:type token)]
[:> tooltip* {:id (:name token) [:> tooltip* {:id (:name token)
:class (stl/css :tooltip-token-wrapper) :class (stl/css :tooltip-token-wrapper)
:trigger-ref row-ref
:content #(mf/html :content #(mf/html
[:div {:class (stl/css :tooltip-token)} [:div {:class (stl/css :tooltip-token)}
[:div {:class (stl/css :tooltip-token-title)} [:div {:class (stl/css :tooltip-token-title)}
@ -75,6 +77,7 @@
(:resolved-value token))]])} (:resolved-value token))]])}
[:> property-detail-copiable* {:token token [:> property-detail-copiable* {:token token
:copied copied :copied copied
:ref row-ref
:on-click copy-attr} detail]]) :on-click copy-attr} detail]])
[:> property-detail-copiable* {:copied copied [:> property-detail-copiable* {:copied copied
:on-click copy-attr} detail]) :on-click copy-attr} detail])

View File

@ -9,13 +9,12 @@
(:require (:require
["rxjs" :as rxjs] ["rxjs" :as rxjs]
[app.common.data :as d] [app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.pprint :as pp] [app.common.pprint :as pp]
[app.common.uri :as u]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.main.data.auth :refer [is-authenticated?]] [app.main.data.auth :refer [is-authenticated?]]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.event :as ev]
[app.main.errors :as errors] [app.main.errors :as errors]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.repo :as rp] [app.main.repo :as rp]
@ -448,22 +447,22 @@
(mf/defc exception-section* (mf/defc exception-section*
{::mf/private true} {::mf/private true}
[{:keys [data route] :as props}] [{:keys [data] :as props}]
(let [type (get data :type) (let [type (get data :type)
report (mf/with-memo [data] cause (get data ::errors/instance)
(some-> data ::errors/instance errors/generate-report))
report (mf/with-memo [cause]
(when (ex/exception? cause)
(errors/generate-report cause)))
props (mf/spread-props props {:report report})] props (mf/spread-props props {:report report})]
(mf/with-effect [data route report] (mf/with-effect [report type cause]
(let [params (:query-params route) (when (and (ex/exception? cause)
params (u/map->query-string params)] (not (contains? #{:not-found :authentication} type)))
(st/emit! (ev/event {::ev/name "exception-page" (errors/submit-report :event-name "exception-page"
:type (get data :type :unknown) :report report
:href (rt/get-current-href) :hint (ex/get-hint cause))))
:hint (get data :hint)
:path (get route :path)
:report report
:params params}))))
(case type (case type
:not-found :not-found

View File

@ -44,12 +44,15 @@
(on-token-pill-click event token))) (on-token-pill-click event token)))
id-tooltip (mf/use-id) id-tooltip (mf/use-id)
resolved (:resolved-value token) resolved (:resolved-value token)
color-value (dwta/value->color resolved)] color-value (dwta/value->color resolved)
item-ref (mf/use-ref nil)]
[:> tooltip* {:id id-tooltip [:> tooltip* {:id id-tooltip
:style {:width "100%"} :style {:width "100%"}
:trigger-ref item-ref
:content (:name token)} :content (:name token)}
[:button {:class (stl/css-case :color-token-item true [:button {:class (stl/css-case :color-token-item true
:color-token-selected selected) :color-token-selected selected)
:ref item-ref
:aria-labelledby id-tooltip :aria-labelledby id-tooltip
:on-click on-click} :on-click on-click}
[:> swatch* {:background color-value [:> swatch* {:background color-value

View File

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

View File

@ -137,12 +137,13 @@
(fn [value] (fn [value]
(on-blur) (on-blur)
(let [uid (js/Symbol) (let [uid (js/Symbol)
grow-type (keyword value) grow-type (keyword value)]
content (when editor-instance
(content/dom->cljs (dwt/get-editor-root editor-instance)))]
(st/emit! (dwu/start-undo-transaction uid)) (st/emit! (dwu/start-undo-transaction uid))
(when (some? content) (when (features/active-feature? @st/state "text-editor/v2")
(st/emit! (dwt/v2-update-text-shape-content (first ids) content :finalize? true))) (let [content (when editor-instance
(content/dom->cljs (dwt/get-editor-root editor-instance)))]
(when (some? content)
(st/emit! (dwt/v2-update-text-shape-content (first ids) content :finalize? true)))))
(st/emit! (dwsh/update-shapes ids #(assoc % :grow-type grow-type))) (st/emit! (dwsh/update-shapes ids #(assoc % :grow-type grow-type)))
(when (features/active-feature? @st/state "render-wasm/v1") (when (features/active-feature? @st/state "render-wasm/v1")

View File

@ -94,6 +94,7 @@
not-active (or (empty? active-tokens) not-active (or (empty? active-tokens)
(nil? token)) (nil? token))
id (dm/str (:id token) "-name") id (dm/str (:id token) "-name")
token-name-ref (mf/use-ref nil)
swatch-tooltip-content (cond swatch-tooltip-content (cond
not-active not-active
(tr "ds.inputs.token-field.no-active-token-option") (tr "ds.inputs.token-field.no-active-token-option")
@ -126,8 +127,11 @@
:size "small"}]] :size "small"}]]
[:> tooltip* {:content name-tooltip-content [:> tooltip* {:content name-tooltip-content
:id id :id id
:aria-label (str (tr "workspace.tokens.token-name") ": " applied-token-name)
:trigger-ref token-name-ref
:class (stl/css :token-tooltip)} :class (stl/css :token-tooltip)}
[:div {:class (stl/css :token-name) [:div {:class (stl/css :token-name)
:ref token-name-ref
:aria-labelledby id} :aria-labelledby id}
(or token-name applied-token-name)]] (or token-name applied-token-name)]]
[:div {:class (stl/css :token-actions)} [:div {:class (stl/css :token-actions)}

View File

@ -94,12 +94,12 @@
(mf/use-fn (mf/use-fn
(mf/deps index on-stroke-width-change) (mf/deps index on-stroke-width-change)
(fn [value] (fn [value]
(if (or (string? value) (int? value)) (if (or (string? value) (number? value))
(on-stroke-width-change index value) (on-stroke-width-change index value)
(do
(st/emit! (dwta/toggle-token {:token (first value) (st/emit! (dwta/toggle-token {:token (first value)
:attrs #{:stroke-width} :attrs #{:stroke-width}
:shape-ids ids})))))) :shape-ids ids})))))
stroke-alignment (or (:stroke-alignment stroke) :center) stroke-alignment (or (:stroke-alignment stroke) :center)

View File

@ -4,17 +4,27 @@
// //
// Copyright (c) KALEIDOS INC // Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated; @use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/z-index.scss" as *;
.token-modal-wrapper { .token-modal-wrapper {
@extend .modal-container-base; border-radius: $br-4;
@include deprecated.menuShadow; background-color: var(--color-background-primary);
border: $b-2 solid var(--color-background-quaternary);
min-width: $sz-364;
min-height: $sz-192;
max-width: $sz-512;
max-height: $sz-512;
box-shadow: 0px 0px $sz-12 0px var(--color-shadow-dark);
position: absolute; position: absolute;
width: auto; width: auto;
min-width: auto; min-width: auto;
z-index: 11; z-index: var(--z-index-set);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: var(--sp-xxxl);
&.token-modal-large { &.token-modal-large {
max-block-size: 95vh; max-block-size: 95vh;
} }
@ -22,6 +32,6 @@
.close-btn { .close-btn {
position: absolute; position: absolute;
top: deprecated.$s-6; top: px2rem(6);
right: deprecated.$s-6; right: px2rem(6);
} }

View File

@ -144,10 +144,11 @@
(if (and not-editing? (seq selected-shapes) (not= (:type token) :number)) (if (and not-editing? (seq selected-shapes) (not= (:type token) :number))
(st/emit! (dwta/toggle-token {:token token (st/emit! (dwta/toggle-token {:token token
:shape-ids selected-ids})) :shape-ids selected-ids}))
(st/emit! (ntf/show {:content (tr "workspace.tokens.error-text-edition") (when (seq selected-shapes)
:type :toast (st/emit! (ntf/show {:content (tr "workspace.tokens.error-text-edition")
:level :warning :type :toast
:timeout 3000}))))))] :level :warning
:timeout 3000})))))))]
[:div {:class (stl/css :token-section-wrapper) [:div {:class (stl/css :token-section-wrapper)
:data-testid (dm/str "section-" (name type))} :data-testid (dm/str "section-" (name type))}

View File

@ -265,7 +265,39 @@
(if (and (natural-child-ordering? plugin-id) (not (ctl/reverse? shape))) (if (and (natural-child-ordering? plugin-id) (not (ctl/reverse? shape)))
0 0
(count (:shapes shape)))] (count (:shapes shape)))]
(st/emit! (dwsh/relocate-shapes #{child-id} id index))))))) (st/emit! (dwsh/relocate-shapes #{child-id} id index)))))
:horizontalSizing
{:this true
:get #(-> % u/proxy->shape :layout-item-h-sizing (d/nilv :fix) d/name)
:set
(fn [_ value]
(let [value (keyword value)]
(cond
(not (contains? ctl/item-h-sizing-types value))
(u/display-not-valid :horizontalSizing value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :horizontalSizing "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))}
:verticalSizing
{:this true
:get #(-> % u/proxy->shape :layout-item-v-sizing (d/nilv :fix) d/name)
:set
(fn [_ value]
(let [value (keyword value)]
(cond
(not (contains? ctl/item-v-sizing-types value))
(u/display-not-valid :verticalSizing value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))}))
(defn layout-child-proxy? [p] (defn layout-child-proxy? [p]
(obj/type-of? p "LayoutChildProxy")) (obj/type-of? p "LayoutChildProxy"))

View File

@ -164,8 +164,8 @@
(obj/without-empty (obj/without-empty
#js {:id (-> id format-id) #js {:id (-> id format-id)
:style (-> style format-key) :style (-> style format-key)
:offset-x offset-x :offsetX offset-x
:offset-y offset-y :offsetY offset-y
:blur blur :blur blur
:spread spread :spread spread
:hidden hidden :hidden hidden

View File

@ -147,7 +147,7 @@
;; export interface Shadow { ;; export interface Shadow {
;; id?: string; ;; id?: string;
;; style?: 'drop-shadow' | 'inner-shadow'; ;; style?: 'drop-shadow' | 'inner-shadow';
;; offset--y?: number; ;; offsetX?: number;
;; offsetY?: number; ;; offsetY?: number;
;; blur?: number; ;; blur?: number;
;; spread?: number; ;; spread?: number;
@ -160,8 +160,8 @@
(d/without-nils (d/without-nils
{:id (-> (obj/get shadow "id") parse-id) {:id (-> (obj/get shadow "id") parse-id)
:style (-> (obj/get shadow "style") parse-keyword) :style (-> (obj/get shadow "style") parse-keyword)
:offset-x (obj/get shadow "offset-x") :offset-x (obj/get shadow "offsetX")
:offset-y (obj/get shadow "offset-y") :offset-y (obj/get shadow "offsetY")
:blur (obj/get shadow "blur") :blur (obj/get shadow "blur")
:spread (obj/get shadow "spread") :spread (obj/get shadow "spread")
:hidden (obj/get shadow "hidden") :hidden (obj/get shadow "hidden")

View File

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

View File

@ -15,6 +15,7 @@
"fmt": "./scripts/fmt" "fmt": "./scripts/fmt"
}, },
"devDependencies": { "devDependencies": {
"@github/copilot": "^1.0.2",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"esbuild": "^0.25.9" "esbuild": "^0.25.9"
} }

View File

@ -243,11 +243,17 @@ export interface Board extends ShapeBase {
/** /**
* The horizontal sizing behavior of the board. * The horizontal sizing behavior of the board.
* It can be one of the following values:
* - 'fix': The containers has its own intrinsic fixed size.
* - 'auto': The container fits the content.
*/ */
horizontalSizing?: 'auto' | 'fix'; horizontalSizing?: 'auto' | 'fix';
/** /**
* The vertical sizing behavior of the board. * The vertical sizing behavior of the board.
* It can be one of the following values:
* - 'fix': The containers has its own intrinsic fixed size.
* - 'auto': The container fits the content.
*/ */
verticalSizing?: 'auto' | 'fix'; verticalSizing?: 'auto' | 'fix';
@ -738,19 +744,19 @@ export interface CommonLayout {
/** /**
* The `horizontalSizing` property specifies the horizontal sizing behavior of the container. * The `horizontalSizing` property specifies the horizontal sizing behavior of the container.
* It can be one of the following values: * It can be one of the following values:
* - 'fit-content': The container fits the content. * - 'fix': The containers has its own intrinsic fixed size.
* - 'fill': The container fills the available space. * - 'fill': The container fills the available space. Only can be set if it's inside another layout.
* - 'auto': The container size is determined automatically. * - 'auto': The container fits the content.
*/ */
horizontalSizing: 'fit-content' | 'fill' | 'auto'; horizontalSizing: 'fix' | 'fill' | 'auto';
/** /**
* The `verticalSizing` property specifies the vertical sizing behavior of the container. * The `verticalSizing` property specifies the vertical sizing behavior of the container.
* It can be one of the following values: * It can be one of the following values:
* - 'fit-content': The container fits the content. * - 'fix': The containers has its own intrinsic fixed size.
* - 'fill': The container fills the available space. * - 'fill': The container fills the available space. Only can be set if it's inside another layout.
* - 'auto': The container size is determined automatically. * - 'auto': The container fits the content.
*/ */
verticalSizing: 'fit-content' | 'fill' | 'auto'; verticalSizing: 'fix' | 'fill' | 'auto';
/** /**
* The `remove` method removes the layout. * The `remove` method removes the layout.

370
pnpm-lock.yaml generated Normal file
View File

@ -0,0 +1,370 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@github/copilot':
specifier: ^1.0.2
version: 1.0.2
'@types/node':
specifier: ^20.12.7
version: 20.19.37
esbuild:
specifier: ^0.25.9
version: 0.25.12
packages:
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.12':
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.12':
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.12':
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.12':
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.12':
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.12':
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.12':
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.12':
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.12':
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.12':
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.12':
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.12':
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.12':
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.12':
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.12':
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.12':
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.12':
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.12':
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.12':
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.12':
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.12':
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.12':
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.12':
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.12':
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.12':
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@github/copilot-darwin-arm64@1.0.2':
resolution: {integrity: sha512-dYoeaTidsphRXyMjvAgpjEbBV41ipICnXURrLFEiATcjC4IY6x2BqPOocrExBYW/Tz2VZvDw51iIZaf6GXrTmw==}
cpu: [arm64]
os: [darwin]
hasBin: true
'@github/copilot-darwin-x64@1.0.2':
resolution: {integrity: sha512-8+Z9dYigEfXf0wHl9c2tgFn8Cr6v4RAY8xTgHMI9mZInjQyxVeBXCxbE2VgzUtDUD3a705Ka2d8ZOz05aYtGsg==}
cpu: [x64]
os: [darwin]
hasBin: true
'@github/copilot-linux-arm64@1.0.2':
resolution: {integrity: sha512-ik0Y5aTXOFRPLFrNjZJdtfzkozYqYeJjVXGBAH3Pp1nFZRu/pxJnrnQ1HrqO/LEgQVbJzAjQmWEfMbXdQIxE4Q==}
cpu: [arm64]
os: [linux]
hasBin: true
'@github/copilot-linux-x64@1.0.2':
resolution: {integrity: sha512-mHSPZjH4nU9rwbfwLxYJ7CQ90jK/Qu1v2CmvBCUPfmuGdVwrpGPHB5FrB+f+b0NEXjmemDWstk2zG53F7ppHfw==}
cpu: [x64]
os: [linux]
hasBin: true
'@github/copilot-win32-arm64@1.0.2':
resolution: {integrity: sha512-tLW2CY/vg0fYLp8EuiFhWIHBVzbFCDDpohxT/F/XyMAdTVSZLnopCcxQHv2BOu0CVGrYjlf7YOIwPfAKYml1FA==}
cpu: [arm64]
os: [win32]
hasBin: true
'@github/copilot-win32-x64@1.0.2':
resolution: {integrity: sha512-cFlc3xMkKKFRIYR00EEJ2XlYAemeh5EZHsGA8Ir2G0AH+DOevJbomdP1yyCC5gaK/7IyPkHX3sGie5sER2yPvQ==}
cpu: [x64]
os: [win32]
hasBin: true
'@github/copilot@1.0.2':
resolution: {integrity: sha512-716SIZMYftldVcJay2uZOzsa9ROGGb2Mh2HnxbDxoisFsWNNgZlQXlV7A+PYoGsnAo2Zk/8e1i5SPTscGf2oww==}
hasBin: true
'@types/node@20.19.37':
resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==}
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@esbuild/aix-ppc64@0.25.12':
optional: true
'@esbuild/android-arm64@0.25.12':
optional: true
'@esbuild/android-arm@0.25.12':
optional: true
'@esbuild/android-x64@0.25.12':
optional: true
'@esbuild/darwin-arm64@0.25.12':
optional: true
'@esbuild/darwin-x64@0.25.12':
optional: true
'@esbuild/freebsd-arm64@0.25.12':
optional: true
'@esbuild/freebsd-x64@0.25.12':
optional: true
'@esbuild/linux-arm64@0.25.12':
optional: true
'@esbuild/linux-arm@0.25.12':
optional: true
'@esbuild/linux-ia32@0.25.12':
optional: true
'@esbuild/linux-loong64@0.25.12':
optional: true
'@esbuild/linux-mips64el@0.25.12':
optional: true
'@esbuild/linux-ppc64@0.25.12':
optional: true
'@esbuild/linux-riscv64@0.25.12':
optional: true
'@esbuild/linux-s390x@0.25.12':
optional: true
'@esbuild/linux-x64@0.25.12':
optional: true
'@esbuild/netbsd-arm64@0.25.12':
optional: true
'@esbuild/netbsd-x64@0.25.12':
optional: true
'@esbuild/openbsd-arm64@0.25.12':
optional: true
'@esbuild/openbsd-x64@0.25.12':
optional: true
'@esbuild/openharmony-arm64@0.25.12':
optional: true
'@esbuild/sunos-x64@0.25.12':
optional: true
'@esbuild/win32-arm64@0.25.12':
optional: true
'@esbuild/win32-ia32@0.25.12':
optional: true
'@esbuild/win32-x64@0.25.12':
optional: true
'@github/copilot-darwin-arm64@1.0.2':
optional: true
'@github/copilot-darwin-x64@1.0.2':
optional: true
'@github/copilot-linux-arm64@1.0.2':
optional: true
'@github/copilot-linux-x64@1.0.2':
optional: true
'@github/copilot-win32-arm64@1.0.2':
optional: true
'@github/copilot-win32-x64@1.0.2':
optional: true
'@github/copilot@1.0.2':
optionalDependencies:
'@github/copilot-darwin-arm64': 1.0.2
'@github/copilot-darwin-x64': 1.0.2
'@github/copilot-linux-arm64': 1.0.2
'@github/copilot-linux-x64': 1.0.2
'@github/copilot-win32-arm64': 1.0.2
'@github/copilot-win32-x64': 1.0.2
'@types/node@20.19.37':
dependencies:
undici-types: 6.21.0
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
'@esbuild/android-arm': 0.25.12
'@esbuild/android-arm64': 0.25.12
'@esbuild/android-x64': 0.25.12
'@esbuild/darwin-arm64': 0.25.12
'@esbuild/darwin-x64': 0.25.12
'@esbuild/freebsd-arm64': 0.25.12
'@esbuild/freebsd-x64': 0.25.12
'@esbuild/linux-arm': 0.25.12
'@esbuild/linux-arm64': 0.25.12
'@esbuild/linux-ia32': 0.25.12
'@esbuild/linux-loong64': 0.25.12
'@esbuild/linux-mips64el': 0.25.12
'@esbuild/linux-ppc64': 0.25.12
'@esbuild/linux-riscv64': 0.25.12
'@esbuild/linux-s390x': 0.25.12
'@esbuild/linux-x64': 0.25.12
'@esbuild/netbsd-arm64': 0.25.12
'@esbuild/netbsd-x64': 0.25.12
'@esbuild/openbsd-arm64': 0.25.12
'@esbuild/openbsd-x64': 0.25.12
'@esbuild/openharmony-arm64': 0.25.12
'@esbuild/sunos-x64': 0.25.12
'@esbuild/win32-arm64': 0.25.12
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
undici-types@6.21.0: {}

61
render-wasm/AGENTS.md Normal file
View File

@ -0,0 +1,61 @@
# render-wasm Agent Instructions
This component compiles Rust to WebAssembly using Emscripten + Skia. It is consumed by the frontend as a canvas renderer.
## Commands
```bash
./build # Compile Rust → WASM (requires Emscripten environment)
./watch # Incremental rebuild on file change
./test # Run Rust unit tests (cargo test)
./lint # clippy -D warnings
cargo fmt --check
```
Run a single test:
```bash
cargo test my_test_name # by test function name
cargo test shapes:: # by module prefix
```
Build output lands in `../frontend/resources/public/js/` (consumed directly by the frontend dev server).
## Build Environment
The `_build_env` script sets required env vars (Emscripten paths,
`EMCC_CFLAGS`). `./build` sources it automatically. The WASM heap is
configured to 256 MB initial with geometric growth.
## Architecture
**Global state** — a single `unsafe static mut State` accessed
exclusively through `with_state!` / `with_state_mut!` macros. Never
access it directly.
**Tile-based rendering** — only 512×512 tiles within the viewport
(plus a pre-render buffer) are drawn each frame. Tiles outside the
range are skipped.
**Two-phase updates** — shape data is written via exported setter
functions (called from ClojureScript), then a single `render_frame()`
triggers the actual Skia draw calls.
**Shape hierarchy** — shapes live in a flat pool indexed by UUID;
parent/child relationships are tracked separately.
## Key Source Modules
| Path | Role |
|------|------|
| `src/lib.rs` | WASM exports — all functions callable from JS |
| `src/state.rs` | Global `State` struct definition |
| `src/render/` | Tile rendering pipeline, Skia surface management |
| `src/shapes/` | Shape types and Skia draw logic per shape |
| `src/wasm/` | JS interop helpers (memory, string encoding) |
## Frontend Integration
The WASM module is loaded by `app.render-wasm.*` namespaces in the
frontend. ClojureScript calls exported Rust functions to push shape
data, then calls `render_frame`. Do not change export function
signatures without updating the ClojureScript bridge.

View File

@ -1,50 +0,0 @@
#!/bin/bash
set -e
echo "################ test common ################"
pushd common
pnpm install
pnpm run fmt:clj:check
pnpm run lint:clj
clojure -M:dev:test
pnpm run test
popd
echo "################ test frontend ################"
pushd frontend
pnpm install
pnpm run fmt:clj:check
pnpm run fmt:js:check
pnpm run lint:scss
pnpm run lint:clj
pnpm run test
popd
echo "################ test integration ################"
pushd frontend
pnpm install
pnpm run test:e2e -x --workers=4
popd
echo "################ test backend ################"
pushd backend
pnpm install
pnpm run fmt:clj:check
pnpm run lint:clj
clojure -M:dev:test --reporter kaocha.report/documentation
popd
echo "################ test exporter ################"
pushd exporter
pnpm install
pnpm run fmt:clj:check
pnpm run lint:clj
popd
echo "################ test render-wasm ################"
pushd render-wasm
cargo fmt --check
./lint --debug
./test
popd

13
scripts/check-fmt Executable file
View File

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

View File

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