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: |
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
uses: cloudflare/wrangler-action@v3
with:

View File

@ -102,6 +102,23 @@ jobs:
run: |
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
uses: cloudflare/wrangler-action@v3
with:

View File

@ -28,9 +28,55 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check clojure code format
- name: Lint Common
working-directory: ./common
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:
name: "Common Tests"
@ -41,12 +87,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run tests on JVM
working-directory: ./common
run: |
clojure -M:dev:test
- name: Run tests on NODE
- name: Run tests
working-directory: ./common
run: |
./scripts/test

23
.gitignore vendored
View File

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

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)
### :boom: Breaking changes & Deprecations
- Deprecate `PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE` in favour of `PENPOT_HTTP_SERVER_MAX_BODY_SIZE`.
### :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 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 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
@ -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 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
### :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"
},
"scripts": {
"fmt:clj:check": "cljfmt check --parallel=false src/ test/",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"lint:clj": "clj-kondo --parallel --lint src/"
"lint": "clj-kondo --parallel --lint ../common/src src/",
"check-fmt": "cljfmt check --parallel=true src/ test/",
"fmt": "cljfmt fix --parallel=true src/ test/",
"test": "clojure -M:dev:test"
}
}

View File

@ -8,7 +8,6 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<!-- <div>[<a href="#props">props</a>]</div> -->
<div>[<a href="#context">context</a>]</div>
{% if report %}
<div>[<a href="#report">report</a>]</div>
@ -21,7 +20,8 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
<div class="table-val">
<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">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>

View File

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

View File

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

View File

@ -293,12 +293,17 @@
(defn download-image
"Download an image from the provided URI and return the media input object"
[{: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)
mtype (get headers "content-type")
format (cm/mtype->format mtype)
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
(ex/raise :type :validation
:code :unknown-size
@ -318,9 +323,32 @@
{:size size :mtype mtype :format format}))]
(let [{:keys [body] :as response} (http/req! client
{:method :get :uri uri}
{:response-type :input-stream})
(let [{:keys [body] :as response}
(try
(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)
path (tmp/tempfile :prefix "penpot.media.download.")
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"
{::doc/added "1.17"
::webhooks/event? true
::sm/params schema:link-file-to-library}
[cfg {:keys [::rpc/profile-id file-id library-id] :as params}]
::sm/params schema:link-file-to-library
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
(when (= file-id library-id)
(ex/raise :type :validation
:code :invalid-library
:hint "A file cannot be linked to itself"))
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(link-file-to-library conn params)
(bfc/get-libraries cfg [library-id]))))
(check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(link-file-to-library conn params)
(bfc/get-libraries cfg [library-id]))
;; --- MUTATION COMMAND: unlink-file-from-library
@ -1037,8 +1037,9 @@
::webhooks/event? true
::sm/params schema:unlink-file-to-library
::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 library-id)
(unlink-file-from-library conn params)
nil)
@ -1062,8 +1063,9 @@
{::doc/added "1.17"
::sm/params schema:update-file-library-sync-status
::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 library-id)
(update-sync conn params))
;; --- MUTATION COMMAND: ignore-sync

View File

@ -867,6 +867,52 @@
(t/is (th/ex-info? error))
(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
(let [profile1 (th/create-profile* 1)
file (th/create-file* 1 {:project-id (:default-project-id profile1)

View File

@ -9,11 +9,14 @@
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.db :as db]
[app.http.client :as http]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.storage :as sto]
[backend-tests.helpers :as th]
[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 :each th/database-reset)
@ -278,3 +281,100 @@
error-data (ex-data error)]
(t/is (th/ex-info? error))
(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": {
"concurrently": "^9.1.2",
"nodemon": "^3.1.10",
"prettier": "3.5.3",
"source-map-support": "^0.5.21",
"ws": "^8.18.2"
},
@ -20,12 +21,15 @@
"date-fns": "^4.1.0"
},
"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/",
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
"check-fmt:js": "prettier -c src/**/*.js",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:js": "prettier -c src/**/*.js -w",
"lint": "pnpm run lint:clj",
"watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"",
"build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "pnpm run build:test && node target/tests/test.js"
"test:js": "pnpm run build:test && node target/tests/test.js",
"test:jvm": "clojure -M:dev:test"
}
}

10
common/pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

@ -299,3 +299,8 @@
(js/console.log (format-throwable cause))
(finally
(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)
(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
(into (d/ordered-set)
["legacy-2"
@ -1839,4 +1859,5 @@
"0014-clear-components-nil-objects"
"0015-fix-text-attrs-blank-strings"
"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"
:info "#4271ae"
: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
[level]
@ -137,7 +139,9 @@
:trace "TRC"
:info "INF"
: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
[level]
@ -146,7 +150,9 @@
:debug 20
:info 30
: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
[props]

View File

@ -2002,6 +2002,61 @@
:else
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
"Copy attributes that have changed in the shape previous to the switch
to the current shape (post switch). Used only on variants switch"
@ -2110,6 +2165,11 @@
origin-ref-shape
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
(get previous-shape attr)))

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -18,6 +18,9 @@
(t/use-fixtures :each thi/test-fixture)
;; ============================================================
;; BASIC SWITCH TESTS (no overrides)
;; ============================================================
(t/deftest test-basic-switch
(let [;; ==== Setup
@ -68,6 +71,9 @@
;; The rect has width 15 after the switch
(t/is (= (:width rect02') 15))))
;; ============================================================
;; SIMPLE ATTRIBUTE OVERRIDES (identical variants)
;; ============================================================
(t/deftest test-basic-switch-override
(let [;; ==== Setup
@ -142,6 +148,10 @@
;; The override is keept: The rect still has width 25 after the switch
(t/is (= (:width rect02') 25))))
;; ============================================================
;; SIMPLE ATTRIBUTE OVERRIDES (different variants)
;; ============================================================
(t/deftest test-switch-with-no-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@ -182,6 +192,10 @@
;; The rect has width 15 after the switch
(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-0 [:content :children 0 :children 0 :children 0 :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' text-path-0) "text overriden"))))
;; ============================================================
;; TEXT OVERRIDES (different property)
;; ============================================================
(t/deftest test-switch-with-different-prop-text-override
(let [;; ==== Setup
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' text-path-0) "text overriden"))))
;; ============================================================
;; TEXT OVERRIDES (different text)
;; ============================================================
(t/deftest test-switch-with-different-text-text-override
(let [;; ==== Setup
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' text-path-0) "bye"))))
;; ============================================================
;; TEXT OVERRIDES (different text AND property)
;; ============================================================
(t/deftest test-switch-with-different-text-and-prop-text-override
(let [;; ==== Setup
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' text-path-0) "bye"))))
;; ============================================================
;; TEXT STRUCTURE OVERRIDES (identical variants)
;; ============================================================
(t/deftest test-switch-with-identical-structure-text-override
(let [;; ==== Setup
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' text-path-1) "new line 2"))))
;; ============================================================
;; TEXT STRUCTURE OVERRIDES (different property)
;; ============================================================
(t/deftest test-switch-with-different-prop-structure-text-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@ -978,6 +1012,10 @@
(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)))))
;; ============================================================
;; TEXT STRUCTURE OVERRIDES (different text)
;; ============================================================
(t/deftest test-switch-with-different-text-structure-text-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@ -1104,6 +1142,10 @@
(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)))))
;; ============================================================
;; TEXT STRUCTURE OVERRIDES (different text AND property)
;; ============================================================
(t/deftest test-switch-with-different-text-and-prop-structure-text-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@ -1231,6 +1273,10 @@
(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)))))
;; ============================================================
;; NESTED COMPONENTS (with same component in both variants)
;; ============================================================
(t/deftest test-switch-variant-for-other-with-same-nested-component
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@ -1274,6 +1320,10 @@
;; The width of copy-cp02-rect' is 25 (change is preserved)
(t/is (= (:width copy-cp02-rect') 25))))
;; ============================================================
;; SWAPPED COPIES (switching variants that contain swapped components)
;; ============================================================
(t/deftest test-switch-variant-that-has-swaped-copy
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@ -1366,6 +1416,10 @@
;; The width of copy-cp02-rect' is 25 (change is preserved)
(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
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@ -1420,3 +1474,787 @@
(t/is (= (:width rect01) 25))
;; The rect still has width 25 after the switch
(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
{:tests [{:id :unit
:test-paths ["test"]}]
:kaocha/reporter [kaocha.report/dots]}
{:tests [{:id :unit
:test-paths ["test"]}]
:kaocha/reporter [kaocha.report/dots]}

View File

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

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 class="advice">References to existing tokens are case sensitive.</p>
<h2 id="design-tokens-equations">Using equations</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>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>
<h2 id="design-tokens-equations">Using math in token values</h2>
<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>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>
<img src="/img/design-tokens/04-tokens-math.webp" alt="Tokens math" />
</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>
<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 multiplication.</li>
<li><code class="language-js">/</code> for division.</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 multiplication</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>
<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>
<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>
<img src="/img/design-tokens/05-tokens-edit.webp" alt="Tokens edit" />
</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>
<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>
</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
<h2 id="design-tokens-settings">Tokens settings</h2>

View File

@ -142,7 +142,7 @@ a design.</p>
</figure>
<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>
<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",
"build:app": "clojure -M:dev:shadow-cljs release main",
"build": "pnpm run clear:shadow-cache && pnpm run build:app",
"fmt:clj:check": "cljfmt check --parallel=false src/",
"fmt:clj": "cljfmt fix --parallel=true src/",
"lint:clj": "clj-kondo --parallel --lint src/"
"fmt": "cljfmt fix --parallel=true src/",
"check-fmt": "cljfmt check --parallel=true src/",
"lint": "clj-kondo --parallel --lint src/"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1024,7 +1024,7 @@ test.describe("Tokens - creation", () => {
const nameField = tokensUpdateCreateModal.getByLabel("Name");
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
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
const fontFamilyField = tokensUpdateCreateModal
.getByLabel("Font family")
.getByRole("textbox", { name: "Font family" })
.first();
await fontFamilyField.fill("OneWord");
// 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 expect(
tokensUpdateCreateModal.getByText(/Invalid token value:/),
@ -1736,13 +1736,13 @@ test.describe("Tokens tab - edition", () => {
await fontSizeField.fill("16");
await expect(saveButton).toBeEnabled();
const fontWeightField = tokensUpdateCreateModal.getByLabel(/Font Weight/i);
const fontWeightField = tokensUpdateCreateModal.getByRole("textbox", { name: "Font Weight" });
const letterSpacingField =
tokensUpdateCreateModal.getByLabel(/Letter Spacing/i);
const lineHeightField = tokensUpdateCreateModal.getByLabel(/Line Height/i);
const textCaseField = tokensUpdateCreateModal.getByLabel(/Text Case/i);
tokensUpdateCreateModal.getByRole("textbox", { name: "Letter Spacing" });
const lineHeightField = tokensUpdateCreateModal.getByRole("textbox", { name: "Line Height" });
const textCaseField = tokensUpdateCreateModal.getByRole("textbox", { name: "Text Case" });
const textDecorationField =
tokensUpdateCreateModal.getByLabel(/Text Decoration/i);
tokensUpdateCreateModal.getByRole("textbox", { name: "Text Decoration" });
// Capture all values before switching tabs
const originalValues = {
@ -1800,6 +1800,7 @@ test.describe("Tokens tab - edition", () => {
const colorToken = tokensSidebar.getByRole("button", {
name: "100",
});
await expect(colorToken).toBeVisible();
await colorToken.click({ button: "right" });

View File

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

View File

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

View File

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

View File

@ -377,7 +377,15 @@
(defn- parse-single-shadow
"Parses a single shadow map with properties: x, y, blur, spread, color, type."
[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
(map #(assoc % :shadow-key k :shadow-index shadow-index) errors)))
parsers {:offset-x parse-sd-token-general-value

View File

@ -1016,7 +1016,7 @@
(update [_ state]
(update state :colorpicker
(fn [state]
(let [type (:type state)
(let [type (:type state)
state (-> state
(update :current-color merge changes)
(update :current-color materialize-color-components)
@ -1024,6 +1024,7 @@
;; current color can be a library one
;; I'm changing via colorpicker
(update :current-color dissoc :ref-id :ref-file))]
(if-let [stop (:editing-stop state)]
(update-in state [:stops stop] (fn [data] (->> changes
(merge data)
@ -1044,7 +1045,9 @@
(and (= type :color) (nil? (:color state)))]
(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))))))))
(defn update-colorpicker-gradient

View File

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

View File

@ -317,8 +317,10 @@
(let [objects (dsh/lookup-page-objects state)
edition (get-in state [:workspace-local :edition])
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)
items (:items undo)
index (or (:index undo) (dec (count items)))]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,6 @@
(ns app.main.ui.ds.tooltip.tooltip
(:require-macros
[app.common.data.macros :as dm]
[app.main.style :as stl])
(:require
[app.common.data :as d]
@ -15,10 +14,10 @@
[app.util.timers :as ts]
[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)
(defonce active-tooltip (atom nil))
(defn- clear-schedule
[ref]
(when-let [schedule (mf/ref-val ref)]
@ -29,20 +28,6 @@
[ref 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
"Given a placement, calcultates the bounding rect for it taking in
account provided tooltip bounding rect and the origin bounding
@ -72,18 +57,18 @@
:height tooltip-height}
"left"
{:top (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2))
:left (- trigger-left tooltip-width arrow-height)
{:top (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2))
:left (- trigger-left 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
:height tooltip-height}
"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)
: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
:height tooltip-height}
@ -153,22 +138,6 @@
(recur (rest placements))
#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
[:map
[:class {:optional true} [:maybe :string]]
@ -176,19 +145,26 @@
[:offset {:optional true} :int]
[:delay {:optional true} :int]
[:content [:or fn? :string map?]]
[:trigger-ref {:optional true} [:maybe :any]]
[:placement {:optional true}
[:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]]])
(mf/defc 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
(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
(d/nilv id internal-id)
tooltip-id
(mf/use-id)
placement*
(mf/use-state #(d/nilv placement "top"))
@ -201,35 +177,35 @@
schedule-ref
(mf/use-ref nil)
visible*
(mf/use-state false)
visible (deref visible*)
on-show
(mf/use-fn
(mf/deps id placement offset)
(fn [event]
(let [current (dom/get-current-target event)
related (dom/get-related-target event)
is-node? (fn [node] (and node (.-nodeType node)))]
(when-not (and related (is-node? related) (.contains current related))
(clear-schedule schedule-ref)
(when-let [tooltip (dom/get-element id)]
(let [origin-brect
(dom/get-bounding-rect (mf/ref-val trigger-ref))
update-position
(fn []
(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)))))))
(mf/deps tooltip-id delay)
(fn [_]
(let [trigger-el (mf/ref-val trigger-ref)]
(clear-schedule schedule-ref)
(add-schedule schedule-ref (d/nilv delay 300)
(fn []
(prn tooltip-id)
(when-let [active @active-tooltip]
(when (not= (:id active) tooltip-id)
(when-let [tooltip-el (dom/get-element (:id active))]
(dom/set-css-property! tooltip-el "display" "none"))
(reset! active-tooltip nil)))
(reset! active-tooltip {:id tooltip-id :trigger trigger-el})
(reset! visible* true))))))
on-hide
(mf/use-fn
(mf/deps id)
(mf/deps tooltip-id)
(fn []
(when-let [tooltip (dom/get-element id)]
(clear-schedule schedule-ref)
(hide-popover tooltip))))
(clear-schedule schedule-ref)
(reset! visible* false)
(when (= (:id @active-tooltip) tooltip-id)
(reset! active-tooltip nil))))
handle-key-down
(mf/use-fn
@ -250,28 +226,62 @@
:tooltip-bottom-left (identical? placement "bottom-left")
:tooltip-top-left (identical? placement "top-left"))
content
(if (fn? content)
(content)
content)
props
(mf/spread-props props
{:on-mouse-enter on-show
:on-mouse-leave on-hide
:on-focus on-show
:on-blur on-hide
:ref internal-trigger-ref
:on-key-down handle-key-down
:ref trigger-ref
:id id
:class [class (stl/css :tooltip-trigger)]
:aria-describedby id})
content
(if (fn? content)
(content)
content)]
:aria-label (if (string? content)
content
aria-label)})]
(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
children
[:div {:class (stl/css :tooltip)
:id id
:popover "auto"
:role "tooltip"}
[:div {:class tooltip-class}
[:div {:class (stl/css :tooltip-content)} content]
[:div {:class (stl/css :tooltip-arrow)
:id "tooltip-arrow"}]]]]))
(when visible
(mf/portal
(mf/html
[:div {:class (stl/css :tooltip)
:role "tooltip"
:id tooltip-id
:ref tooltip-ref}
[: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/_borders.scss" as *;
@use "ds/z-index.scss" as *;
@use "ds/typography.scss" as t;
$arrow-side: 12px;
.tooltip {
position: absolute;
position: fixed;
max-inline-size: $sz-352;
background-color: transparent;
overflow: hidden;
inline-size: fit-content;
block-size: fit-content;
z-index: var(--z-index-notifications);
}
.tooltip-content-wrapper {

View File

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

View File

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

View File

@ -23,11 +23,12 @@
(mf/defc 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
:property-detail-copied copied
:property-detail-copiable-color (some? color))
:on-click on-click}
:on-click on-click
:ref ref}
(when color
[:> swatch* {:background color
:size "small"}])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,17 +4,27 @@
//
// 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 {
@extend .modal-container-base;
@include deprecated.menuShadow;
border-radius: $br-4;
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;
width: auto;
min-width: auto;
z-index: 11;
z-index: var(--z-index-set);
overflow-y: auto;
overflow-x: hidden;
padding: var(--sp-xxxl);
&.token-modal-large {
max-block-size: 95vh;
}
@ -22,6 +32,6 @@
.close-btn {
position: absolute;
top: deprecated.$s-6;
right: deprecated.$s-6;
top: px2rem(6);
right: px2rem(6);
}

View File

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

View File

@ -265,7 +265,39 @@
(if (and (natural-child-ordering? plugin-id) (not (ctl/reverse? shape)))
0
(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]
(obj/type-of? p "LayoutChildProxy"))

View File

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

View File

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

View File

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

View File

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

View File

@ -243,11 +243,17 @@ export interface Board extends ShapeBase {
/**
* 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';
/**
* 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';
@ -738,19 +744,19 @@ export interface CommonLayout {
/**
* The `horizontalSizing` property specifies the horizontal sizing behavior of the container.
* It can be one of the following values:
* - 'fit-content': The container fits the content.
* - 'fill': The container fills the available space.
* - 'auto': The container size is determined automatically.
* - 'fix': The containers has its own intrinsic fixed size.
* - 'fill': The container fills the available space. Only can be set if it's inside another layout.
* - '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.
* It can be one of the following values:
* - 'fit-content': The container fits the content.
* - 'fill': The container fills the available space.
* - 'auto': The container size is determined automatically.
* - 'fix': The containers has its own intrinsic fixed size.
* - 'fill': The container fills the available space. Only can be set if it's inside another layout.
* - 'auto': The container fits the content.
*/
verticalSizing: 'fit-content' | 'fill' | 'auto';
verticalSizing: 'fix' | 'fill' | 'auto';
/**
* 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
cljfmt check --parallel=true \
common/src/ \
common/test/ \
frontend/src/ \
frontend/test/ \
backend/src/ \
backend/test/ \
exporter/src/ \
library/src;
clj-kondo --parallel=true --lint common/src;
clj-kondo --parallel=true --lint frontend/src;
clj-kondo --parallel=true --lint backend/src;