Compare commits

...
Author SHA1 Message Date
Andrés Moya 8d8f0bb125 🔧 Add tests and validations to variant functions 2026-09-09 17:18:20 +02:00
Andrés Moya ea8a7dd3d8 💄 Rename props -> properties 2026-09-09 15:14:23 +02:00
Andrés Moya 65b578a549 🔧 Validate changes immediately after processing them 2026-09-09 15:14:23 +02:00
Alonso Torres bf2fcba3be 🐛 Fix problems with pixel precission in WASM renderer (#11560) 2026-09-09 15:11:58 +02:00
Andrey Antukh d6e82f44a6 Enforce absolute maximum session duration (#11447)
Session tokens now carry an :exp claim anchored to created-at (not
modified-at), so activity cannot extend the session beyond the
absolute maximum (default 30 days, configurable via
PENPOT_AUTH_TOKEN_COOKIE_MAX_AGE_ABSOLUTE). The existing token
verification already rejects expired tokens, so enforcement is
automatic. Also extends the GC task to purge expired
http_session_v2 rows, which were previously never cleaned up.

Closes #11444

AI-assisted-by: longcat-2.0
2026-09-09 11:45:28 +02:00
Alejandro Alonso e67ebacef2 🐛 Export svg-raw shapes through WASM SVG draw_svg (#11567)
SVG export treated SVGRaw like a fill/stroke leaf and never called
draw_svg, so imported SVG content exported as an empty document.

Relates to #10546
2026-09-09 11:16:01 +02:00
Andrey Antukh 91860f6916 📎 Update changelog 2026-09-09 11:12:57 +02:00
Andrey Antukh 831953c41e Merge remote-tracking branch 'origin/staging' into develop 2026-09-09 11:02:06 +02:00
Andrey Antukh 8a3540336b 📚 Add local-ci skill and scripts/ci memory
Teach agents to verify their changes with ./scripts/ci: module list,
task selection flags, log locations under .ci-logs/, and per-module
workflows (lint-only pass, --fix, --paren-repair, common/ consumer
checks). Register the skill in the skills README, point AGENTS.md at
the new memory, and add the script to the critical-info dev scripts.

AI-assisted-by: omen-alpha
2026-09-09 11:01:49 +02:00
Andrey Antukh 6f4a68100d Add direct mode to implement-plan flow
The flow only supported continue mode (existing branch) or standalone
mode (issue + branch). Add a "direct" argument (aliases: "no branch",
"direct commit") that skips both and commits on the current branch as
it is, even when it is a base branch — for small tooling changes the
user wants committed in place.

AI-assisted-by: omen-alpha
2026-09-09 11:01:49 +02:00
esther-morenoandmiryamgduque 62fdc02c0f 📚 Adding enterprise section in the user guide (#10821)
* 📚 Add enterprise section

* 📚 Update images

* 📚 Add SSO module section to Enterprise plan user guide

* 📚 Add more FAQs to Enterprise plan user guide

* 📚 Add SSO configuration screenshot to Enterprise plan user guide

* 📚 Replace membership screenshot with webp to fix broken reference

The membership image reference already pointed to .webp while the tracked
file was .png, leaving a broken image. Swap the asset to match.

* 📚 Document how to access the Admin Console

Add a paragraph explaining how to reach the Admin Console and an
accompanying screenshot to the Enterprise plan user guide.

* 📚 Update Admin Console access screenshot

* 📚 Open Enterprise plan images in a new tab on click

Wrap the enterprise screenshots in a target="_blank" link so they open
full size, matching the behavior on other user-guide pages. The
organization hierarchy diagram is left inline.

* 📚 Document identity provider configuration for SSO

* 📚 Refine SSO session, Admin Console docs, and screenshots

* 📚 Split Organizations content out of the Enterprise plan page

* 📚 Fix wording in Enterprise plan FAQ answers

---------

Co-authored-by: miryamgduque <miryam.gonzalez@kaleidos.net>
2026-09-09 10:59:17 +02:00
57 changed files with 2131 additions and 303 deletions

No files matched your search

+2 -1
View File
@@ -34,7 +34,7 @@ JSON, REPL access, and so on.
|---|---|---|
| [`make-a-plan`](skills/make-a-plan/SKILL.md) | Researches the task, writes an implementation plan, asks you the open questions in plain language, and saves the plan to `.agents/plans/`. | "make a plan for the token refresh bug" |
| [`review-plan`](skills/review-plan/SKILL.md) | Evaluates a plan before anyone writes code: completeness, ordering, risks. Approves it or asks for changes. | "review this plan before we start" |
| [`implement-plan`](skills/implement-plan/SKILL.md) | Shows you the full flow first — the issue and branch it will create (or the branch it continues on), the execution style, and the task checklist — and, after your go-ahead, executes a ready plan. Default: every task, one commit. On request ("step by step"): one task, one commit, your confirmation between tasks. | "implement the plan" · "step by step, one commit per task" |
| [`implement-plan`](skills/implement-plan/SKILL.md) | Shows you the full flow first — the issue and branch it will create (or the branch it continues on), the execution style, and the task checklist — and, after your go-ahead, executes a ready plan. Default: every task, one commit. On request ("step by step"): one task, one commit, your confirmation between tasks. On request ("direct"): no issue and no branch, commits on the current branch. | "implement the plan" · "step by step, one commit per task" · "direct, no branch" |
| [`review-code`](skills/review-code/SKILL.md) | Reviews a diff, branch, or PR and returns findings ranked by impact. | "review my changes before I push" |
| [`create-pr`](skills/create-pr/SKILL.md) | Opens a pull request for the current branch — with checks on base branch, commits, issue, and push state — or updates an existing PR's title and description. | "open a PR for this branch" |
| [`resolve-git-conflicts`](skills/resolve-git-conflicts/SKILL.md) | Untangles merge or rebase conflicts: explains both sides, proposes a resolution, applies it after you approve. Never runs `git rebase --continue`. | "resolve these conflicts" |
@@ -65,6 +65,7 @@ JSON, REPL access, and so on.
| [`nrepl-eval`](skills/nrepl-eval/SKILL.md) | Run Clojure or ClojureScript code in the live REPL sessions (backend and frontend). |
| [`taiga`](skills/taiga/SKILL.md) | Look up Penpot issues, user stories, and tasks in Taiga. |
| [`testing`](skills/testing/SKILL.md) | The repo's testing rules and TDD workflow, loaded before writing tests. |
| [`local-ci`](skills/local-ci/SKILL.md) | Run CI-style lint, test, and format checks for the modules you touched with `scripts/ci`, and read the logs when they fail. |
| [`security-and-hardening`](skills/security-and-hardening/SKILL.md) | Security checks for code that handles user input, auth, or external services. |
| [`ste`](skills/ste/SKILL.md) | Rewrites prose in Simplified Technical English. Loads only when you name it. |
| [`refine-prompt`](skills/refine-prompt/SKILL.md) | Rewrites a rough prompt into a clearer one. Never runs the prompt. |
+22 -10
View File
@@ -1,6 +1,6 @@
---
name: implement-plan
description: Implementation flow — execute a ready plan from the session context: read the plan, detect the flow, then present the full picture (issue and branch to create or the branch to continue on, execution style, task checklist) and wait for confirmation. Default is every task with one final commit; on request ("step by step"), one task and one commit at a time with a pause after each. Use it when the user asks to implement or execute a plan, in any phrasing.
description: Implementation flow — execute a ready plan from the session context: read the plan, detect the flow, then present the full picture (issue and branch to create or the branch to continue on, execution style, task checklist) and wait for confirmation. Default is every task with one final commit; on request ("step by step"), one task and one commit at a time with a pause after each; on request ("direct"), no issue and no branch — the commit lands on the current branch. Use it when the user asks to implement or execute a plan, in any phrasing.
---
# Implement Plan
@@ -45,9 +45,14 @@ read-only: nothing is created until the user confirms (step 3).
No issue or branch is created. The branch name provides the issue
reference when it follows the `issue-NNNN` pattern.
Arguments override detection: `standalone`, `continue`,
`no issue` / `without issue`, or an explicit base such as
`from origin/develop`.
Arguments override detection: `standalone`, `continue`, `direct`
(`no branch` / `direct commit`), `no issue` / `without issue`, or an
explicit base such as `from origin/develop`.
**Direct mode** (`direct`, `no branch`, `direct commit`): no issue and
no branch — the implementation and the commit land on the current branch
as it is, even when it is a base branch. Best for small or tooling-only
changes the user wants committed in place.
**Standalone while already on a feature branch:** stop and explain that this
would stack branches. Ask the user to re-run with an explicit base, for
@@ -59,8 +64,10 @@ Before touching the repository, show the user the full picture:
- **The flow**: whether the GitHub issue and the branch will be created
(standalone mode — give the planned branch name, `issue-NNNN` or
`plan-<slug>`), or whether you continue on the current branch
(continue mode — name it).
`plan-<slug>`), whether you continue on the current branch
(continue mode — name it), or whether everything lands on the current
branch as it is (direct mode — name it, and say so when it is a base
branch).
- **The execution style**: batch or step-by-step (see *Execution modes*).
- A checklist (todolist) of the plan's tasks, in order.
@@ -84,6 +91,11 @@ If the arguments say `no issue` / `without issue`, skip the issue and
create a branch named `plan-<slug>` instead, where `<slug>` is the plan
title, lowercase and hyphen-separated.
If the arguments say `direct` / `no branch` / `direct commit`, skip the
issue and the branch: implement and commit on the current branch as it
is. If it is a base branch, the checklist presentation already said so —
no further confirmation is needed.
### Batch mode (default)
Implement every task in one go. Work methodically, keeping changes
@@ -126,7 +138,7 @@ instruction from me overrides them):
Extra context in the user's invocation (the message that triggered this
skill) plays the role command arguments play elsewhere: `standalone`,
`continue`, `no issue` / `without issue`, an explicit base such as
`from origin/develop`, or `step by step` / `one commit per task` for the
step-by-step execution mode. Modes combine freely, for example
"standalone step by step".
`continue`, `direct` (`no branch` / `direct commit`), `no issue` /
`without issue`, an explicit base such as `from origin/develop`, or
`step by step` / `one commit per task` for the step-by-step execution
mode. Modes combine freely, for example "standalone step by step".
+95
View File
@@ -0,0 +1,95 @@
---
name: local-ci
description: Run local CI-style checks with ./scripts/ci (lint, tests, format) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, or repairing Clojure delimiter errors.
---
# Local CI
Run the same checks CI runs, locally, for the modules you touched, with
`scripts/ci`. Each task writes a log file; the final summary says what
passed and what failed.
Full details: `mem:scripts/ci` (file: `.serena/memories/scripts/ci.md`)
## When to use
- After implementing or fixing code — verify every module you touched
before declaring the work done.
- When the user asks to run CI, lint, tests, or format checks locally.
- When you changed `common/` — validate its consumers too.
**Skip:** while exploring, planning, or reading code.
## Command reference
Run from the repo root:
```bash
./scripts/ci [OPTIONS] [MODULES...]
```
Modules: `frontend` `backend` `common` `render-wasm` `exporter` `mcp`
`plugins` `library`, or `--all` for every module.
With no task flags it runs three tasks per module, in order: **lint**,
**test**, **fmt** (format check; `--fix` formats files instead).
| Flag | Effect |
|------|--------|
| `--all` | Run every module |
| `--exclude MOD` | Skip one module (repeatable) |
| `--lint` / `--no-lint` | Run only lint / drop lint |
| `--test` / `--no-test` | Run only tests / drop tests |
| `--fmt` / `--no-fmt` | Run only format check / drop it |
| `--fix` | Format files instead of checking (other tasks unaffected) |
| `--paren-repair` | Fix delimiter errors in Clojure/CLJS files |
| `--fail-fast` | Stop at the first failure |
| `--quiet` | Suppress failure output |
| `--dry-run` | Show what would run, execute nothing |
| `--clean` | Delete the `.ci-logs/` directory |
## Reading failures
Every task writes its full output to `.ci-logs/<module>-<task>.log`. On
failure the script prints only the last 30 lines. To diagnose a failure,
**read the log file** — never re-run the command piped through filters
(repo rule: redirect to a file first, then read it). The exit code is 1
when any task failed; the summary lists each failed `module:task` and its
log path.
## Typical workflows
```bash
# Verify a module you changed: lint + tests + format check
./scripts/ci frontend
# Fast pass while iterating: lint only
./scripts/ci --lint frontend
# Lint + format check, skip the long test suite
./scripts/ci --no-test frontend
# Format the module without running the test suite
./scripts/ci --fix --no-test frontend
# Broke delimiters in Clojure/CLJS files: repair first, then lint
./scripts/ci --paren-repair frontend
./scripts/ci --lint frontend
# Changed common/ — validate its consumers too
./scripts/ci frontend backend exporter
# Preview what would run, without running it
./scripts/ci --dry-run --all
```
## Gotchas
- Run from the repo root.
- Test tasks are long-running (backend runs `clojure -M:dev:test`); give
the bash call a generous timeout (1020 minutes) instead of letting it
time out mid-run.
- `mcp` has no lint task — it shows as skipped, not failed.
- `--paren-repair` only fixes delimiters; run lint afterwards to catch
what remains. See `mem:scripts/paren-repair`.
- What to run and how to read test results: `mem:testing`.
+1 -1
View File
@@ -1,5 +1,5 @@
---
description: Execute a ready plan — task checklist, your confirmation, then all tasks with one commit (default) or step by step with a commit and a pause per task; creates issue + branch when on a base branch — loads and follows the implement-plan skill
description: Execute a ready plan — task checklist, your confirmation, then all tasks with one commit (default) or step by step with a commit and a pause per task; creates issue + branch when on a base branch, or commits on the current branch with "direct" — loads and follows the implement-plan skill
agent: build
---
+4
View File
@@ -78,6 +78,10 @@ module. You can read it from `mem:<MODULE>/core`
workspaces (root, modules, member packages). Keeps the shared pnpm store
at `<repo>/.pnpm-store` unless `--store`; ignores `external/` and
`.opencode/`. Usage and reinstall steps: `mem:workflow/updating-pnpm`.
- `scripts/ci` — CI orchestration script: runs lint, tests, and format
checks per module (`frontend backend common render-wasm exporter mcp
plugins library`). Logs go to `.ci-logs/`; read the log file on failure.
See `mem:scripts/ci`.
# Dependency graph
+61
View File
@@ -0,0 +1,61 @@
# CI (scripts/ci)
`scripts/ci` runs CI-style checks — lint, tests, format — for one or more
monorepo modules and prints a per-task summary. It is the local equivalent
of CI; use it to verify changes before declaring work done.
## When to use
- After implementing or fixing code in a module: run its checks before
finishing (AGENTS.md: run the applicable lint and format checks).
- When `common/` changed: validate its consumers too (frontend, backend,
exporter; see the dependency graph in `mem:critical-info`).
- To fix formatting across a module (`--fix`) or repair delimiters
(`--paren-repair`) before linting.
## How to use (CLI)
Run from the repo root:
```bash
./scripts/ci MODULE... # lint + test + fmt per module
./scripts/ci --all --no-test # lint + fmt on all modules
./scripts/ci --lint frontend # lint only
./scripts/ci --fix --no-test frontend # format files, skip tests
./scripts/ci --paren-repair --all # fix delimiters in all Clojure modules
./scripts/ci --dry-run --all # preview what would run
```
Modules: `frontend backend common render-wasm exporter mcp plugins library`.
Flags:
- Default tasks: `lint`, `test`, `fmt` (format check; `--fix` formats
instead).
- `--lint` / `--test` / `--fmt` run one task only; `--no-lint` /
`--no-test` / `--no-fmt` drop one task from the default set.
- `--paren-repair` runs only the delimiter repair — it wraps
`scripts/paren-repair` over each module's Clojure/CLJS sources; see
`mem:scripts/paren-repair`.
- `--all` selects every module; `--exclude MOD` drops one (repeatable).
- `--fail-fast` stops at the first failure; `--quiet` suppresses failure
output; `--dry-run` prints commands without running; `--clean` removes
the log directory.
## Logs and exit codes
- Full output of every task: `.ci-logs/<module>-<task>.log`.
- On failure the script prints the last 30 lines; the final summary lists
every failed `module:task` with its log path.
- Exit code 0 when all selected tasks passed, 1 otherwise.
- Diagnose failures by reading the log file — never pipe test output
through filters (AGENTS.md hard rule).
## Notes
- `mcp` has no lint task (shows as skipped). `render-wasm` uses `./lint`,
`./test`, and `cargo fmt`.
- Test tasks are long-running (backend: `clojure -M:dev:test`); use a
generous timeout when calling it from an agent shell.
- Skill entry point: `.agents/skills/local-ci/SKILL.md`.
- Testing principles and output discipline: `mem:testing`.
+1 -1
View File
@@ -145,6 +145,6 @@ precision while maintaining a strong focus on maintainability and performance.
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `mem:scripts/ci`.
- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`.
+34 -1
View File
@@ -2,6 +2,12 @@
## 2.19.0 (Unreleased)
### :rocket: Epics and highlights
- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237))
- Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807))
- Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958))
### :bug: Bugs fixed
- Fix copying text from Penpot to the clipboard not working on MS Windows [#11303](https://github.com/penpot/penpot/issues/11303) (PR: [#11305](https://github.com/penpot/penpot/pull/11305))
@@ -11,6 +17,20 @@
- Fix overlay shifting left when shown with top-center alignment in viewer prototype (by @filipsajdak) [#9048](https://github.com/penpot/penpot/issues/9048) (PR: [#10454](https://github.com/penpot/penpot/pull/10454))
- Fix internal error when clicking the Copy button on the Access Token page (by @0xTHAC0) [#8496](https://github.com/penpot/penpot/issues/8496) (PR: [#11156](https://github.com/penpot/penpot/pull/11156))
- Fix `disable-registration` flag not preventing non-users from creating accounts in the share prototypes page (by @0xTHAC0) [#5164](https://github.com/penpot/penpot/issues/5164) (PR: [#11199](https://github.com/penpot/penpot/pull/11199))
- Fix "Cannot assign to read only property 'toString'" error during text resize (by @makesomethingshit) [#10168](https://github.com/penpot/penpot/issues/10168) (PR: [#11521](https://github.com/penpot/penpot/pull/11521))
- Fix plugin postMessage channel broadcasting messages to all plugins without origin validation [#10968](https://github.com/penpot/penpot/issues/10968) (PR: [#10970](https://github.com/penpot/penpot/pull/10970))
- Fix MCP plugin page navigation while connected crashing the workspace (by @makesomethingshit) [#11001](https://github.com/penpot/penpot/issues/11001) (PR: [#11521](https://github.com/penpot/penpot/pull/11521))
- Fix shortcut search never matching on key combination, only on action label [#11003](https://github.com/penpot/penpot/issues/11003) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
- Fix Shift + special character key shortcut capturing the shifted character instead of the physical key [#11004](https://github.com/penpot/penpot/issues/11004) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
- Fix reassigning the "Paste" shortcut not updating the UI or taking effect in the workspace [#11005](https://github.com/penpot/penpot/issues/11005) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
- Fix font-size dropdown clipping multi-digit values in Firefox (by @0xTHAC0) [#11008](https://github.com/penpot/penpot/issues/11008) (PR: [#11162](https://github.com/penpot/penpot/pull/11162), [#11500](https://github.com/penpot/penpot/pull/11500))
- Fix exporting shortcuts producing an invalid "toggle-fullscreen" entry that breaks re-import [#11032](https://github.com/penpot/penpot/issues/11032) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
- Fix plugin API missing permission checks in tokens, shapes, variants, flows, layouts, and user identity [#11137](https://github.com/penpot/penpot/issues/11137) (PR: [#11139](https://github.com/penpot/penpot/pull/11139))
- Fix library summary Redis cache keys omitting the tenant [#11407](https://github.com/penpot/penpot/issues/11407) (PR: [#11408](https://github.com/penpot/penpot/pull/11408))
- Fix active theme name in the inspect tab displaying an id instead of the name [#11437](https://github.com/penpot/penpot/issues/11437) (PR: [#11439](https://github.com/penpot/penpot/pull/11439))
- Fix triple-click not selecting the full line in text editor v3 [#11483](https://github.com/penpot/penpot/issues/11483) (PR: [#11493](https://github.com/penpot/penpot/pull/11493))
- Fix pasted text losing formatting on last lines after resizing and adding new lines from the top [#11501](https://github.com/penpot/penpot/issues/11501) (PR: [#11503](https://github.com/penpot/penpot/pull/11503))
- Fix variant property dropdown appearing empty and throwing an internal error when the component has no sibling variants [#11524](https://github.com/penpot/penpot/issues/11524) (PR: [#11499](https://github.com/penpot/penpot/pull/11499))
### :sparkles: New features & Enhancements
@@ -19,9 +39,22 @@
- Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807))
- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237))
- Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958))
- Add support for internal libraries and file sync for Design Tokens [#9334](https://github.com/penpot/penpot/issues/9334)
- Warn self-hosted users when their Penpot version is outdated and surface what they're missing [#10497](https://github.com/penpot/penpot/issues/10497) (PR: [#11411](https://github.com/penpot/penpot/pull/11411))
- Add dedicated RPC methods for plugin registry operations with permission validation [#10952](https://github.com/penpot/penpot/issues/10952) (PR: [#10957](https://github.com/penpot/penpot/pull/10957))
- Document MCP and internal resolver environment variables (by @ShreyashAgare26) [#11318](https://github.com/penpot/penpot/issues/11318) (PR: [#11572](https://github.com/penpot/penpot/pull/11572))
- Add tokens source indicator to assets tab [#11365](https://github.com/penpot/penpot/issues/11365) (PR: [#11439](https://github.com/penpot/penpot/pull/11439))
- Export multiple fills to SVG [#11466](https://github.com/penpot/penpot/issues/11466) (PR: [#11467](https://github.com/penpot/penpot/pull/11467))
- Add Penpot-specific board size presets (file thumbnail, template cover, plugin icon/cover) [#11561](https://github.com/penpot/penpot/issues/11561) (PR: [#11565](https://github.com/penpot/penpot/pull/11565))
## 2.18.0 (Unreleased)
### :rocket: Epics and highlights
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
### :bug: Bugs fixed
- Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392))
@@ -360,7 +393,7 @@
### :rocket: Epics and highlights
- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113))
- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR: [#9113](https://github.com/penpot/penpot/pull/9113))
- Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358)
### :sparkles: New features & Enhancements
+2
View File
@@ -58,6 +58,7 @@
:objects-storage-fs-directory "assets"
:auth-token-cookie-name "auth-token"
:auth-token-cookie-max-age-absolute (ct/duration {:days 30})
:assets-path "/internal/assets/"
:smtp-default-reply-to "Penpot <no-reply@example.com>"
@@ -206,6 +207,7 @@
[:auth-token-cookie-name {:optional true} :string]
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
[:auth-token-cookie-max-age-absolute {:optional true} ::ct/duration]
[:registration-domain-whitelist {:optional true} [::sm/set :string]]
[:email-verify-threshold {:optional true} ::ct/duration]
+28 -13
View File
@@ -36,6 +36,9 @@
;; Default age for automatic session renewal
(def default-renewal-max-age (ct/duration {:hours 6}))
;; Default absolute maximum session duration
(def default-cookie-max-age-absolute (ct/duration {:days 30}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PROTOCOLS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -169,15 +172,19 @@
(defn- assign-token
[cfg session]
(let [claims {:iss "authentication"
:aud "penpot"
:sid (:id session)
:iat (:modified-at session)
:uid (:profile-id session)
:sso-provider-id (:sso-provider-id session)
:sso-session-id (:sso-session-id session)}
header {:kid 1 :ver 1}
token (tokens/generate cfg claims header)]
(let [absolute-max-age (cf/get :auth-token-cookie-max-age-absolute default-cookie-max-age-absolute)
claims {:iss "authentication"
:aud "penpot"
:sid (:id session)
:iat (:modified-at session)
:uid (:profile-id session)
:sso-provider-id (:sso-provider-id session)
:sso-session-id (:sso-session-id session)}
claims (if (:created-at session)
(assoc claims :exp (ct/plus (:created-at session) absolute-max-age))
claims)
header {:kid 1 :ver 1}
token (tokens/generate cfg claims header)]
(assoc session :token token)))
(defn create-fn
@@ -353,15 +360,23 @@
or (updated_at is null and
created_at < ?::timestamptz)")
(def ^:private
sql:delete-expired-v2
"DELETE FROM http_session_v2
WHERE created_at < ?::timestamptz")
(defn- collect-expired-tasks
[{:keys [::db/conn ::tasks/max-age]}]
(let [threshold (ct/minus (ct/now) max-age)
result (-> (db/exec-one! conn [sql:delete-expired threshold threshold])
(db/get-update-count))]
result-legacy (-> (db/exec-one! conn [sql:delete-expired threshold threshold])
(db/get-update-count))
result-v2 (-> (db/exec-one! conn [sql:delete-expired-v2 threshold])
(db/get-update-count))]
(l/dbg :task "gc"
:hint "clean http sessions"
:deleted result)
result))
:deleted-legacy result-legacy
:deleted-v2 result-v2)
(+ result-legacy result-v2)))
(defmethod ig/init-key ::tasks/gc
[_ {:keys [::tasks/max-age] :as cfg}]
@@ -277,6 +277,70 @@
(t/is (= (:id session) (:sid claims)))
(t/is (= (:id profile) (:uid claims)))))
(t/deftest session-token-contains-exp-claim
(let [cfg th/*system*
manager (session/inmemory-manager)
profile (th/create-profile* 1)
session (->> (session/create-session manager {:profile-id (:id profile)
:user-agent "user agent"})
(#'session/assign-token cfg))
claims (tokens/decode cfg (:token session))
exp (:exp claims)]
(t/is (some? exp) "session token should contain :exp claim")
(t/is (ct/inst? exp) "exp should be an instant")))
(t/deftest session-token-exp-based-on-created-at
(let [cfg th/*system*
manager (session/inmemory-manager)
profile (th/create-profile* 1)
session (->> (session/create-session manager {:profile-id (:id profile)
:user-agent "user agent"})
(#'session/assign-token cfg))
claims (tokens/decode cfg (:token session))
expected-exp (ct/plus (:created-at session) (ct/duration {:days 30}))]
(t/is (some? (:exp claims)) "session token should contain :exp claim")
(t/is (= (inst-ms (:exp claims))
(inst-ms expected-exp))
"exp should equal created-at + 30 days")))
(t/deftest session-token-past-exp-is-rejected
(let [cfg th/*system*
manager (session/inmemory-manager)
profile (th/create-profile* 1)
session (->> (session/create-session manager {:profile-id (:id profile)
:user-agent "user agent"})
(#'session/assign-token cfg))
claims (tokens/decode cfg (:token session))
;; Manually create a token with exp in the past
past-claims (assoc claims :exp (ct/minus (ct/now) (ct/duration {:days 1})))
header {:kid 1 :ver 1}
past-token (tokens/generate cfg past-claims header)]
(t/is (nil? (session/decode-token cfg past-token))
"token with exp in the past should be rejected")))
(t/deftest session-renewal-preserves-original-exp
(let [cfg th/*system*
manager (session/inmemory-manager)
profile (th/create-profile* 1)
handler (-> (fn [req] req)
(#'session/wrap-authz {::session/manager manager})
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
:cookie (partial session/decode-token cfg)}))
session (->> (session/create-session manager {:profile-id (:id profile)
:user-agent "user agent"})
(#'session/assign-token cfg))
original-exp (:exp (tokens/decode cfg (:token session)))
;; Force renewal by setting modified-at to 7 hours ago
old-session (assoc session :modified-at (ct/minus (ct/now) (ct/duration {:hours 7})))
response (handler (make-dummy-request {:cookies {"auth-token" (:token old-session)}}))
{:keys [token claims]} (get response ::http/auth-data)
new-exp (:exp claims)]
(t/is (some? original-exp) "original token should have :exp")
(t/is (some? new-exp) "renewed token should have :exp")
(t/is (= (inst-ms original-exp)
(inst-ms new-exp))
"renewed token should preserve original :exp, not extend it")))
(t/deftest parse-request-illegal-argument-exception
;; clojure.data.json raises IllegalArgumentException (case
;; fall-through) on several kinds of malformed input. The
+87 -44
View File
@@ -6,6 +6,7 @@
(ns app.common.files.changes
(:require
#?(:cljs [app.common.files.validate :as val])
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
@@ -428,7 +429,14 @@
[:set-base-font-size
[:map {:title "ModBaseFontSize"}
[:type [:= :set-base-font-size]]
[:base-font-size :string]]]])
[:base-font-size :string]]]
[:validate-shapes
[:map {:title "ValidateShapesChange"}
[:type [:= :validate-shapes]]
[:page-id ::sm/uuid]
[:shape-ids [:vector ::sm/uuid]]
[:context :string]]]])
(def schema:changes
[:sequential {:gen/max 5 :gen/min 1} schema:change])
@@ -464,7 +472,7 @@
to the processor backend."
nil)
(defmulti process-change (fn [_ change] (:type change)))
(defmulti process-change (fn [_ change _] (:type change)))
(defmulti process-operation (fn [_ op] (:type op)))
;; Changes Processing Impl
@@ -496,22 +504,25 @@
(defn process-changes
([data items]
(process-changes data items true))
(process-changes data items true {}))
([data items verify?]
(process-changes data items verify? {}))
([data items verify? libraries]
;; When verify? false we spec the schema validation. Currently used
;; to make just 1 validation even if the changes are applied twice
(when verify?
(check-changes items))
(binding [*touched-changes* (volatile! #{})]
(let [result (reduce #(or (process-change %1 %2) %1) data items)]
(let [result (reduce #(or (process-change %1 %2 libraries) %1) data items)]
(reduce process-touched-change result @*touched-changes*)))))
;; --- Comment Threads
(defmethod process-change :set-comment-thread-position
[data {:keys [page-id comment-thread-id position frame-id]}]
[data {:keys [page-id comment-thread-id position frame-id]} _]
(d/update-in-when data [:pages-index page-id]
(fn [page]
(if (and position frame-id)
@@ -524,7 +535,7 @@
;; --- Guides
(defmethod process-change :set-guide
[data {:keys [page-id id params]}]
[data {:keys [page-id id params]} _]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
@@ -540,7 +551,7 @@
;; --- Flows
(defmethod process-change :set-flow
[data {:keys [page-id id params]}]
[data {:keys [page-id id params]} _]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
@@ -556,7 +567,7 @@
;; --- Grids
(defmethod process-change :set-default-grid
[data {:keys [page-id grid-type params]}]
[data {:keys [page-id grid-type params]} _]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
@@ -593,7 +604,7 @@
(update state :media-refs into xform media-refs)))
(defmethod process-change :add-obj
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]}]
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]} _]
;; NOTE: we only perform hard validation on backend
#?(:clj (validate-shape obj page-id))
@@ -628,7 +639,7 @@
objects))
(defmethod process-change :mod-obj
[data {:keys [page-id component-id] :as change}]
[data {:keys [page-id component-id] :as change} _]
(if page-id
(d/update-in-when data [:pages-index page-id :objects] process-operations change)
(d/update-in-when data [:components component-id :objects] process-operations change)))
@@ -658,19 +669,19 @@
objects))
(defmethod process-change :reorder-children
[data {:keys [page-id component-id] :as change}]
[data {:keys [page-id component-id] :as change} _]
(if page-id
(d/update-in-when data [:pages-index page-id :objects] process-children-reordering change)
(d/update-in-when data [:components component-id :objects] process-children-reordering change)))
(defmethod process-change :del-obj
[data {:keys [page-id component-id id ignore-touched]}]
[data {:keys [page-id component-id id ignore-touched]} _]
(if page-id
(d/update-in-when data [:pages-index page-id] ctst/delete-shape id ignore-touched)
(d/update-in-when data [:components component-id] ctst/delete-shape id ignore-touched)))
(defmethod process-change :fix-obj
[data {:keys [page-id component-id id] :as params}]
[data {:keys [page-id component-id id] :as params} _]
(letfn [(fix-container [container]
(case (:fix params :broken-children)
:broken-children (ctst/fix-broken-children container id)
@@ -682,7 +693,7 @@
(d/update-in-when data [:components component-id] fix-container))))
(defmethod process-change :reg-objects
[data {:keys [page-id component-id shapes]}]
[data {:keys [page-id component-id shapes]} _]
;; FIXME: Improve performance
(letfn [(reg-objects [objects]
(let [lookup (d/getf objects)
@@ -734,7 +745,7 @@
(defmethod process-change :mov-objects
;; FIXME: ignore-touched is no longer used, so we can consider it deprecated
[data {:keys [parent-id shapes index page-id component-id #_ignore-touched after-shape allow-altering-copies syncing]}]
[data {:keys [parent-id shapes index page-id component-id #_ignore-touched after-shape allow-altering-copies syncing]} _]
(letfn [(calculate-invalid-targets [objects shape-id]
(let [reduce-fn #(into %1 (calculate-invalid-targets objects %2))]
(->> (get-in objects [shape-id :shapes])
@@ -849,7 +860,7 @@
(d/update-in-when data [:components component-id :objects] move-objects))))
(defmethod process-change :add-page
[data {:keys [id name page]}]
[data {:keys [id name page]} _]
(when (and id name page)
(ex/raise :type :conflict
:hint "id+name or page should be provided, never both"))
@@ -859,7 +870,7 @@
(ctpl/add-page data page)))
(defmethod process-change :mod-page
[data {:keys [id] :as params}]
[data {:keys [id] :as params} _]
(d/update-in-when data [:pages-index id]
(fn [page]
(let [name (get params :name)
@@ -889,7 +900,7 @@
(dissoc :pixel-grid-opacity))))))
(defmethod process-change :set-plugin-data
[data {:keys [object-type object-id page-id namespace key value]}]
[data {:keys [object-type object-id page-id namespace key value]} _]
(letfn [(update-fn [data]
(if (some? value)
(assoc-in data [:plugin-data namespace key] value)
@@ -915,83 +926,83 @@
(d/update-in-when data [:components object-id] update-fn))))
(defmethod process-change :del-page
[data {:keys [id]}]
[data {:keys [id]} _]
(ctpl/delete-page data id))
(defmethod process-change :mov-page
[data {:keys [id index]}]
[data {:keys [id index]} _]
(update data :pages d/insert-at-index index [id]))
(defmethod process-change :add-color
[data {:keys [color]}]
[data {:keys [color]} _]
(ctl/add-color data color))
(defmethod process-change :mod-color
[data {:keys [color]}]
[data {:keys [color]} _]
(ctl/set-color data color))
(defmethod process-change :del-color
[data {:keys [id]}]
[data {:keys [id]} _]
(ctl/delete-color data id))
;; -- Media
(defmethod process-change :add-media
[data {:keys [object]}]
[data {:keys [object]} _]
(update data :media assoc (:id object) object))
(defmethod process-change :mod-media
[data {:keys [object]}]
[data {:keys [object]} _]
(d/update-in-when data [:media (:id object)] merge object))
(defmethod process-change :del-media
[data {:keys [id]}]
[data {:keys [id]} _]
(d/update-when data :media dissoc id))
;; -- Components
(defmethod process-change :add-component
[data params]
[data params _]
(ctkl/add-component data params))
(defmethod process-change :mod-component
[data params]
[data params _]
(ctkl/mod-component data params))
(defmethod process-change :del-component
[data {:keys [id skip-undelete? delta]}]
[data {:keys [id skip-undelete? delta]} _]
(ctf/delete-component data id skip-undelete? delta))
(defmethod process-change :restore-component
[data {:keys [id page-id]}]
[data {:keys [id page-id]} _]
(ctf/restore-component data id page-id))
(defmethod process-change :purge-component
[data {:keys [id]}]
[data {:keys [id]} _]
(ctf/purge-component data id))
;; -- Typography
(defmethod process-change :add-typography
[data {:keys [typography]}]
[data {:keys [typography]} _]
(ctyl/add-typography data typography))
(defmethod process-change :mod-typography
[data {:keys [typography]}]
[data {:keys [typography]} _]
(ctyl/update-typography data (:id typography) merge typography))
(defmethod process-change :del-typography
[data {:keys [id]}]
[data {:keys [id]} _]
(ctyl/delete-typography data id))
;; -- Design Tokens
(defmethod process-change :set-tokens-lib
[data {:keys [tokens-lib]}]
[data {:keys [tokens-lib]} _]
(assoc data :tokens-lib tokens-lib))
(defmethod process-change :set-token
[data {:keys [set-id token-id attrs]}]
[data {:keys [set-id token-id attrs]} _]
(update data :tokens-lib
(fn [lib]
(let [lib' (ctob/ensure-tokens-lib lib)]
@@ -1008,7 +1019,7 @@
(ctob/make-token (merge prev-token attrs)))))))))
(defmethod process-change :set-token-set
[data {:keys [id attrs]}]
[data {:keys [id attrs]} _]
(update data :tokens-lib
(fn [lib]
(let [lib' (ctob/ensure-tokens-lib lib)]
@@ -1023,7 +1034,7 @@
(ctob/update-set lib' id (fn [_] (ctob/make-token-set attrs))))))))
(defmethod process-change :set-token-theme
[data {:keys [id attrs]}]
[data {:keys [id attrs]} _]
(update data :tokens-lib
(fn [lib]
(let [lib' (ctob/ensure-tokens-lib lib)]
@@ -1041,35 +1052,67 @@
(ctob/make-token-theme (merge prev-token-theme attrs)))))))))
(defmethod process-change :set-active-token-themes
[data {:keys [theme-paths]}]
[data {:keys [theme-paths]} _]
(update data :tokens-lib #(-> % (ctob/ensure-tokens-lib)
(ctob/set-active-themes theme-paths))))
(defmethod process-change :rename-token-set-group
[data {:keys [set-group-path set-group-fname]}]
[data {:keys [set-group-path set-group-fname]} _]
(update data :tokens-lib (fn [lib]
(-> lib
(ctob/ensure-tokens-lib)
(ctob/rename-set-group set-group-path set-group-fname)))))
(defmethod process-change :move-token-set
[data {:keys [from-path to-path before-path before-group] :as changes}]
[data {:keys [from-path to-path before-path before-group] :as changes} _]
(update data :tokens-lib #(-> %
(ctob/ensure-tokens-lib)
(ctob/move-set from-path to-path before-path before-group))))
(defmethod process-change :move-token-set-group
[data {:keys [from-path to-path before-path before-group]}]
[data {:keys [from-path to-path before-path before-group]} _]
(update data :tokens-lib #(-> %
(ctob/ensure-tokens-lib)
(ctob/move-set-group from-path to-path before-path before-group))))
;; === Design Tokens configuration
;; --- Design Tokens configuration
(defmethod process-change :set-base-font-size
[data {:keys [base-font-size]}]
[data {:keys [base-font-size]} _]
(ctf/set-base-font-size data base-font-size))
;; --- Validate Shapes
#?(:clj
(defmethod process-change :validate-shapes
[data _ _]
data))
#?(:cljs
(defmethod process-change :validate-shapes
[data {:keys [page-id shape-ids context]} libraries]
(if libraries
(println "Validating shapes: \n"
" page-id:" (str page-id) "\n"
" shape-ids:" (str shape-ids) "\n"
" context:" context)
(let [file {:data data :id uuid/zero}
errors (reduce (fn [acc shape-id]
(if-let [page (ctpl/get-page data page-id)]
(let [page-errors (val/validate-shape shape-id file page libraries)]
(if (seq page-errors)
(into acc page-errors)
acc))
acc))
[]
shape-ids)]
(when (seq errors)
(ex/raise :type :validation
:code :referential-integrity
:hint (str "error on validating shapes: " context)
:details errors))
data))
data))
;; === Operations
@@ -1203,7 +1203,6 @@
[changes]
(::page-id (meta changes)))
(defn set-text-content
[changes id content prev-content]
(assert-page-id! changes)
@@ -1224,3 +1223,12 @@
(-> changes
(update :redo-changes conj redo-change)
(update :undo-changes conj undo-change))))
;; Validate Shapes
(defn validate-shapes
[changes page-id shape-ids context]
(update changes :redo-changes conj {:type :validate-shapes
:page-id page-id
:shape-ids (vec shape-ids)
:context context}))
+12 -3
View File
@@ -10,7 +10,6 @@
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh]
[app.common.files.variant :as cfv]
[app.common.path-names :as cpn]
[app.common.schema :as sm]
[app.common.types.component :as ctk]
@@ -569,7 +568,17 @@
objects (:objects page)
file-data (:data file)
first-child (get objects (first shapes))
prop-names (cfv/extract-properties-names first-child file-data)]
extract-properties-names
(fn [shape]
;; Get the names of the properties of the shape's component
(->> shape
(#(ctkl/get-component file-data (:component-id %) true))
:variant-properties
(map :name)))
prop-names (extract-properties-names first-child)]
(run! (fn [child-id]
(when-let [child (get objects child-id)]
(if (not (ctk/is-variant? child))
@@ -583,7 +592,7 @@
(str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child))
child file page
:variant-id shape-id))
(when (not= prop-names (cfv/extract-properties-names child file-data))
(when (not= prop-names (extract-properties-names child))
(report-error :invalid-variant-properties
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
child file page
+37 -35
View File
@@ -6,12 +6,15 @@
(ns app.common.files.variant
(:require
[app.common.data.macros :as dm]
[app.common.types.component :as ctc]
[app.common.types.components-list :as ctcl]
[app.common.types.components-list :as ctkl]
[app.common.types.variant :as ctv]))
(defn find-variant-components
"Find a list of the components that belongs to this variant-id"
"Find the components that belong to the variant container identified by `variant-id`,
preserving the order defined by the container's shapes.
Example return:
(<component1> <component2> ...)"
([data variant-id]
(let [page-id (->> data
:components
@@ -22,22 +25,24 @@
objects (dm/get-in data [:pages-index page-id :objects])]
(find-variant-components data objects variant-id)))
([data objects variant-id]
(assert (or (uuid? variant-id) (nil? variant-id)))
;; We can't simply filter components, because we need to maintain the order
(->> (dm/get-in objects [variant-id :shapes])
(map #(dm/get-in objects [% :component-id]))
(map #(ctcl/get-component data % true))
reverse)))
(defn extract-properties-names
[shape data]
(->> shape
(#(ctcl/get-component data (:component-id %) true))
:variant-properties
(map :name)))
(let [container (get objects variant-id)]
(if (ctv/variant-container? container)
(->> (:shapes container)
(map #(dm/get-in objects [% :component-id]))
(map #(ctkl/get-component data % true))
reverse)
[]))))
(defn extract-properties-values
"Get a map of properties associated to their possible values"
"Get a map of variant property names to their distinct possible values,
collected from all components that belong to the variant container.
Example return:
[{:name 'Property 1' :value ('Value1' 'Value2')}]"
[data objects variant-id]
(assert (or (uuid? variant-id) (nil? variant-id)))
(->> (find-variant-components data objects variant-id)
(mapcat :variant-properties)
(group-by :name)
@@ -47,9 +52,13 @@
:value (->> v (map :value) distinct)}
mdata))))))
(defn get-variant-mains
[component data]
(assert (ctv/valid-variant-component? component) "expected valid component variant")
(defn- get-variant-mains
"Return the ids of the main instance shapes of the variant this component belongs to,
in the order they appear in the container.
Example return:
[<main-shape-a-id> <main-shape-b-id>]"
[data component]
(when-let [variant-id (:variant-id component)]
(let [page-id (:main-instance-page component)
objects (-> (dm/get-in data [:pages-index page-id])
@@ -57,27 +66,20 @@
(dm/get-in objects [variant-id :shapes]))))
(defn is-secondary-variant?
[component data]
(let [shapes (get-variant-mains component data)]
"Return true if the component is a secondary variant in its variant container.
The primary variant is the last one in the container's children list.
Return false if the component is the primary variant or if it's not part of a variant."
[data component]
(let [shapes (get-variant-mains data component)]
(and (seq shapes)
(not= (:main-instance-id component) (last shapes)))))
(defn get-primary-variant
"Return the main instance of the primary variant (the last one) in the variant container."
[data component]
(let [page-id (:main-instance-page component)
objects (-> (dm/get-in data [:pages-index page-id])
(get :objects))
variant-id (:variant-id component)]
(->> (dm/get-in objects [variant-id :shapes])
(let [page-id (:main-instance-page component)
objects (-> (dm/get-in data [:pages-index page-id])
(get :objects))]
(->> (get-variant-mains data component)
peek
(get objects))))
(defn get-primary-component
[data component-id]
(when-let [component (ctcl/get-component data component-id)]
(if (ctc/is-variant? component)
(->> component
(get-primary-variant data)
:component-id
(ctcl/get-component data))
component)))
+31 -5
View File
@@ -290,8 +290,11 @@
duplicated-parent?
(->> ids-map vals (some #(= % (:parent-id first-shape))))
grid-parent?
(and (ctsl/grid-layout? objects (:parent-id first-shape)) (not duplicated-parent?))
changes
(if (and (ctsl/grid-layout? objects (:parent-id first-shape)) (not duplicated-parent?))
(if grid-parent?
(let [target-cell (-> position meta :cell)
[row column]
@@ -313,7 +316,19 @@
changes
(reduce #(pcb/add-object %1 %2 {:ignore-touched true})
changes
(rest new-shapes))]
(rest new-shapes))
ids-to-validate (cond-> [(:id first-shape)]
grid-parent?
(conj (:parent-id first-shape)))
changes (if (seq ids-to-validate)
(pcb/validate-shapes changes
(:id page)
ids-to-validate
(str "generate-instantiate-component: " component-id
" under parent-id" (or parent-id " root")))
changes)]
[new-shape changes])))
@@ -3123,7 +3138,6 @@
;; we calculate a new one because the components will have created new shapes.
ids-map (into {} (map #(vector % (uuid/next))) all-ids)
;; If there is an alt-duplication we change to root
;; For variants so the copy is made as a child of root
;; This is because inside a variant-container can't be a copy
@@ -3135,7 +3149,6 @@
(assoc :parent-id uuid/zero :frame-id uuid/zero)))
shapes)
changes (-> changes
(pcb/with-page page)
(pcb/with-objects all-objects)
@@ -3165,7 +3178,20 @@
(comp
(filter #(= :add-obj (:type %)))
(map #(vector (:old-id %) (-> % :obj :id))))
(:redo-changes changes))]
(:redo-changes changes))
copied-components
(ctn/get-all-instance-roots (:objects page) ids)
ids-to-validate
(map #(get ids-map % %) copied-components)
changes (if (seq ids-to-validate)
(pcb/validate-shapes changes
(:id page)
ids-to-validate
(cond-> (str "generate-duplicate-changes: " ids)))
changes)]
(-> changes
(generate-duplicate-flows shapes page ids-map)
+29 -5
View File
@@ -75,7 +75,7 @@
(reduce check-shape changes mod-obj-changes)))
(defn generate-update-shapes
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation?]}]
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation? extra-context]}]
(let [changes (reduce
(fn [changes id]
(let [opts {:attrs attrs
@@ -96,7 +96,19 @@
(pcb/reorder-grid-children ids))
(not ignore-touched)
(generate-unapply-tokens objects changed-sub-attr))]
(generate-unapply-tokens objects changed-sub-attr))
page-id (pcb/get-page-id changes)
modified-components (ctn/get-all-instance-roots objects ids)
changes (if (and page-id (seq modified-components))
(pcb/validate-shapes changes
page-id
modified-components
(cond-> (str "generate-update-shapes: " ids " " attrs)
(some? extra-context)
(str " \n -> from " extra-context)))
changes)]
changes))
(defn- generate-update-shape-flags
@@ -248,8 +260,8 @@
page-id (pcb/get-page-id changes)
page (or (pcb/get-page changes)
(ctpl/get-page data page-id))
ids (cfh/clean-loops objects ids)
in-component-copy?
(fn [shape-id]
;; Look for shapes that are inside a component copy, but are
@@ -258,7 +270,7 @@
;; If we want to specifically allow altering the copies, this is
;; a special case, like a component swap, in which case we want
;; to delete the old shape
(let [shape (get objects shape-id)]
(let [shape (get objects shape-id)]
(and (ctn/has-any-copy-parent? objects shape)
(not allow-altering-copies))))
@@ -437,7 +449,19 @@
(into []
(remove #(and (ctsi/has-destination %)
(id-to-delete? (:destination %))))
interactions))))))]
interactions))))))
modified-components (ctn/get-all-instance-roots objects (disj all-parents uuid/zero))
;; There is no need to validate deleted objects. Probably also no need to validate hidden or unmasked objects,
;; but we may think of it
changes (if (seq modified-components)
(pcb/validate-shapes changes
page-id
modified-components
(str "generate-delete-shapes: " ids))
changes)]
[all-parents changes])))
@@ -172,10 +172,10 @@
new-props (- min-props
(+ (count props)
(if add-name? 1 0)))
props (ctv/add-new-props props (repeat new-props ""))]
props (ctv/add-new-properties props (repeat new-props ""))]
(if add-name?
(ctv/add-new-prop props (:name component))
(ctv/add-new-property props (:name component))
props)))
(defn- create-new-properties-from-non-variant
@@ -216,6 +216,41 @@
:else
(get-instance-root objects (get objects (:parent-id shape)))))
(defn get-all-instance-roots
"Given a list of shape ids and an objects tree, returns a set with the ids of
all instance roots that are at, above or below any of the shapes identified by
the given list. An instance root is a shape that has :component-root set to
true (checked by ctk/instance-root?). There is at most one instance root in
any subtree rooted at an instance root, so the downward search stops at the
first instance root found in each branch. Uses a visited set to avoid
reprocessing the same shapes."
[objects shape-ids]
(let [visited (atom #{})
result (atom #{})]
(letfn [(search-up [shape-id]
(when-not (contains? @visited shape-id)
(swap! visited conj shape-id)
(let [shape (get objects shape-id)]
(when-not (nil? shape)
(if (ctk/instance-root? shape)
(swap! result conj (:id shape))
(when-not (cfh/root? shape)
(when-let [parent-id (:parent-id shape)]
(search-up parent-id))))))))
(search-down [shape-id]
(when-not (contains? @visited shape-id)
(swap! visited conj shape-id)
(let [shape (get objects shape-id)]
(when-not (nil? shape)
(if (ctk/instance-root? shape)
(swap! result conj (:id shape))
(doseq [child-id (:shapes shape)]
(search-down child-id)))))))]
(doseq [shape-id shape-ids]
(search-up shape-id)
(search-down shape-id))
@result)))
(defn find-component-main
"If the shape is a component main instance or is inside one, return that instance.
Uses an iterative loop with cycle detection to prevent stack overflow on circular
+266 -93
View File
@@ -27,6 +27,9 @@
[:variant-id {:optional true} ::sm/uuid]
[:variant-properties {:optional true} [:vector schema:variant-property]]])
(def valid-variant-component?
(sm/check-fn schema:variant-component))
(def schema:variant-shape
"The root shape of the main instance of a variant component"
[:map
@@ -34,14 +37,17 @@
[:variant-name {:optional true} :string]
[:variant-error {:optional true} :string]])
(def valid-variant-shape?
(sm/check-fn schema:variant-shape))
(def schema:variant-container
"Is a board that contains all variant components of a variant set,
for grouping them visually in the workspace"
[:map
[:is-variant-container {:optional true} :boolean]])
(def valid-variant-component?
(sm/check-fn schema:variant-component))
(def valid-variant-container?
(sm/check-fn schema:variant-container))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -50,17 +56,41 @@
(def property-max-length 60)
(def value-prefix "Value ")
(defn variant-component?
[component]
(some? (:variant-id component)))
(defn variant-shape?
[shape]
(some? (:variant-id shape)))
(defn variant-container?
[shape]
(some? (:is-variant-container shape)))
(defn properties-to-name
"Transform the properties into a name, with the values separated by comma"
"Transform the properties into a name, with the values separated by comma, excluding the empty ones.
Example:
[{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}] -> 'Button, Primary'"
[properties]
(assert (or (sequential? properties) (nil? properties)))
(->> properties
(map :value)
(remove str/empty?)
(str/join ", ")))
(defn next-property-number
"Returns the next property number, to avoid duplicates on the property names"
"Returns the next property number, to avoid duplicates on the property names.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 3' :value 'y'}] -> 4"
[properties]
(assert (or (sequential? properties) (nil? properties)))
(let [numbers (keep
#(some->> (:name %) (re-find property-regex) second d/parse-integer)
properties)
@@ -69,38 +99,70 @@
0)]
(inc (max max-num (count properties)))))
(defn add-new-prop
"Adds a new property with generated name and provided value to the existing props list."
[props value]
(conj props {:name (str property-prefix (next-property-number props))
:value value}))
(defn add-new-property
"Adds a new property with generated name and provided value to the existing properties list.
(defn add-new-props
"Adds new properties with generated names and provided values to the existing props list."
[props values]
(let [next-prop-num (next-property-number props)
Example:
[{:name 'Property 1' :value 'x'}] 'y' -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]"
[properties value]
(assert (or (sequential? properties) (nil? properties)))
(assert (or (string? value) (nil? value)))
(conj properties {:name (str property-prefix (next-property-number properties))
:value value}))
(defn add-new-properties
"Adds new properties with generated names and provided values to the existing properties list.
Example:
[{:name 'Property 1' :value 'x'}] ['a' 'b'] -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'a'}
{:name 'Property 3' :value 'b'}]"
[properties values]
(assert (or (sequential? properties) (nil? properties)))
(assert (or (sequential? values) (nil? values)))
(let [next-prop-num (next-property-number properties)
xf (map-indexed (fn [i v]
{:name (str property-prefix (+ next-prop-num i))
:value v}))]
(into props xf values)))
(into properties xf values)))
(defn path-to-properties
"From a list of properties and a name with path, assign each token of the
path as value of a different property"
path as value of a different property. It can add blank properties if
necessary, until the min-properties number is reached.
Example with min-properties=4:
'Button / Primary / Hover' -> [{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}
{:name 'Property 3' :value 'Hover'}
{:name 'Property 4' :value ''}]"
([path properties]
(path-to-properties path properties 0))
([path properties min-props]
([path properties min-properties]
(assert (or (string? path) (nil? path)))
(assert (or (sequential? properties) (nil? properties)))
(assert (int? min-properties))
(let [cpath (cpn/split-path path)
total-props (max (count cpath) min-props)
total-properties (max (count cpath) min-properties)
assigned (mapv #(assoc % :value (nth cpath %2 "")) properties (range))
;; Add empty strings to the end of cpath to reach the minimum number of properties
cpath (take total-props (concat cpath (repeat "")))
cpath (take total-properties (concat cpath (repeat "")))
remaining (drop (count properties) cpath)]
(add-new-props assigned remaining))))
(add-new-properties assigned remaining))))
(defn properties-map->formula
"Transforms a map of properties to a formula of properties omitting the empty ones"
"Transforms a map of properties to a formula of properties omitting the empty ones.
Example:
[{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}] -> 'Property 1=Button, Property 2=Primary'"
[properties]
(assert (or (sequential? properties) (nil? properties)))
(->> properties
(keep (fn [{:keys [name value]}]
(when (not (str/blank? value))
@@ -108,9 +170,15 @@
(str/join ", ")))
(defn properties-formula->map
"Transforms a formula of properties to a map of properties"
[s]
(->> (str/split s ",")
"Transforms a formula of properties to a map of properties.
Example:
'Property 1=Button, Property 2=Primary' -> [{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}]"
[formula]
(assert (or (string? formula) (nil? formula)))
(->> (str/split formula ",")
(mapv #(str/split % "=" 2))
(filter (fn [[_ v]] (not (str/blank? v))))
(mapv (fn [[k v]]
@@ -118,9 +186,15 @@
:value (str/trim v)}))))
(defn valid-properties-formula?
"Checks if a formula is valid"
[s]
(->> (str/split s ",")
"Checks if a formula is valid.
Example:
'Property 1=Button, Property 2=Primary' -> true
'Property 1=Button, Property 2' -> false"
[formula]
(assert (or (string? formula) (nil? formula)))
(->> (str/split formula ",")
(mapv #(str/split % "=" 2))
(every? #(and (= 2 (count %))
(not (str/blank? (first %)))
@@ -128,22 +202,47 @@
(< (count (second %)) property-max-length)))))
(defn find-properties-to-remove
"Compares two property maps to find which properties should be removed"
[prev-props upd-props]
(let [upd-names (set (map :name upd-props))]
(filterv #(not (contains? upd-names (:name %))) prev-props)))
"Compares two property maps to find which properties should be removed.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]
[{:name 'Property 1' :value 'x'}] -> [{:name 'Property 2' :value 'y'}]"
[prev-properties upd-properties]
(assert (or (sequential? prev-properties) (nil? prev-properties)))
(assert (or (sequential? upd-properties) (nil? upd-properties)))
(let [upd-names (set (map :name upd-properties))]
(filterv #(not (contains? upd-names (:name %))) prev-properties)))
(defn find-properties-to-update
"Compares two property maps to find which properties should be updated"
[prev-props upd-props]
"Compares two property maps to find which properties should be updated.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]
[{:name 'Property 1' :value 'new-x'}
{:name 'Property 2' :value 'y'}] -> [{:name 'Property 1' :value 'new-x'}]"
[prev-properties upd-properties]
(assert (or (sequential? prev-properties) (nil? prev-properties)))
(assert (or (sequential? upd-properties) (nil? upd-properties)))
(filterv #(some (fn [prop] (and (= (:name %) (:name prop))
(not= (:value %) (:value prop)))) prev-props) upd-props))
(not= (:value %) (:value prop)))) prev-properties) upd-properties))
(defn find-properties-to-add
"Compares two property maps to find which properties should be added"
[prev-props upd-props]
(let [prev-names (set (map :name prev-props))]
(filterv #(not (contains? prev-names (:name %))) upd-props)))
"Compares two property maps to find which properties should be added.
Example:
[{:name 'Property 1' :value 'x'}]
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}] -> [{:name 'Property 2' :value 'y'}]"
[prev-properties upd-properties]
(assert (or (sequential? prev-properties) (nil? prev-properties)))
(assert (or (sequential? upd-properties) (nil? upd-properties)))
(let [prev-names (set (map :name prev-properties))]
(filterv #(not (contains? prev-names (:name %))) upd-properties)))
(defn- split-base-name-and-number
"Extract the number in parentheses from an item, if present, and return both the base name and the number"
@@ -165,8 +264,15 @@
(defn update-number-in-repeated-item
"Add, keep or update a number in parentheses for a given item, if necessary, depending on the items
already present in a list, to avoid repetitions"
already present in a list, to avoid repetitions.
Example:
['Property'] 'Property' -> 'Property (1)'
['Property' 'Property (1)'] 'Property' -> 'Property (2)'"
[items item]
(assert (or (sequential? items) (nil? items)))
(assert (or (string? item) (nil? item)))
(let [names (group-numbers-by-base-name items)
[base num] (split-base-name-and-number item)
nums-taken (get names base #{})]
@@ -176,25 +282,46 @@
(str base (when (pos? n) (str " (" n ")")))))))
(defn update-number-in-repeated-prop-names
"Add, keep or update a number for each prop name depending on the previous ones"
[props]
(->> props
"Add, keep or update a number for each prop name depending on the previous ones.
Example:
[{:name 'Property' :value 'x'}
{:name 'Property' :value 'y'}] -> [{:name 'Property' :value 'x'}
{:name 'Property (1)' :value 'y'}]"
[properties]
(assert (or (sequential? properties) (nil? properties)))
(->> properties
(reduce (fn [acc prop]
(conj acc {:name (update-number-in-repeated-item (mapv :name acc) (:name prop))
:value (:value prop)}))
[])))
(defn find-index-for-property-name
"Finds the index of a name in a property map"
[props name]
"Finds the index of a name in a property map.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}] 'Property 2' -> 1"
[properties name]
(assert (or (sequential? properties) (nil? properties)))
(assert (or (string? name) (nil? name)))
(some (fn [[idx prop]]
(when (= (:name prop) name)
idx))
(map-indexed vector props)))
(map-indexed vector properties)))
(defn remove-prefix
"Removes the given prefix (with or without a trailing ' / ') from the beginning of the name"
"Removes the given prefix (with or without a trailing ' / ') from the beginning of the name.
Example:
'Button / Primary' 'Button' -> 'Primary'
'Button / Primary' 'Other' -> 'Button / Primary'"
[name prefix]
(assert (or (string? name) (nil? name)))
(assert (or (string? prefix) (nil? prefix)))
(let [long-name (str prefix " / ")]
(cond
(str/starts-with? name long-name)
@@ -210,22 +337,22 @@
(map :name))
(defn- matching-indices
[props1 props2]
(let [names-in-p2 (into #{} xf:map-name props2)
[properties1 properties2]
(let [names-in-p2 (into #{} xf:map-name properties2)
xform (comp
(map-indexed (fn [index {:keys [name]}]
(when (contains? names-in-p2 name)
index)))
(filter some?))]
(into #{} xform props1)))
(into #{} xform properties1)))
(defn- find-index-by-name
"Returns the index of the first item in props with the given name, or nil if not found."
[name props]
"Returns the index of the first item in properties with the given name, or nil if not found."
[name properties]
(some (fn [[idx item]]
(when (= (:name item) name)
idx))
(map-indexed vector props)))
(map-indexed vector properties)))
(defn- next-valid-position
"Returns the first non-negative integer not present in the used-pos set."
@@ -236,42 +363,64 @@
p)))
(defn- find-position
"Returns the index of the property with the given name in `props`,
"Returns the index of the property with the given name in `properties`,
or the next available index not in `used-pos` if not found."
[name props used-pos]
(or (find-index-by-name name props)
[name properties used-pos]
(or (find-index-by-name name properties)
(next-valid-position used-pos)))
(defn merge-properties
"Merges props2 into props1 with the following rules:
- For each property p2 in props2:
"Merges properties2 into properties1 with the following rules:
- For each property p2 in properties2:
- Skip it if its value is empty.
- If props1 contains a property with the same name, update its value with that of p2.
- Otherwise, assign p2's value to the first unused property in props1. A property is considered used if:
- Its name exists in both props1 and props2, or
- If properties1 contains a property with the same name, update its value with that of p2.
- Otherwise, assign p2's value to the first unused property in properties1. A property is considered used if:
- Its name exists in both properties1 and properties2, or
- Its value has already been updated during the merge.
- If no unused properties are available in props1, append a new property with a default name and p2's value."
[props1 props2]
(let [props2 (remove #(str/empty? (:value %)) props2)]
- If no unused properties are available in properties1, append a new property with a default name and p2's value.
Example:
[{:name 'Property 1' :value 'a'}
{:name 'Property 2' :value 'b'}]
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}
{:name 'Property 3' :value 'z'}] -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}
{:name 'Property 3' :value 'z'}]"
[properties1 properties2]
(assert (or (sequential? properties1) (nil? properties1)))
(assert (or (sequential? properties2) (nil? properties2)))
(let [properties2 (remove #(str/empty? (:value %)) properties2)]
(-> (reduce
(fn [{:keys [props used-pos]} prop]
(let [pos (find-position (:name prop) props used-pos)
(fn [{:keys [properties used-pos]} prop]
(let [pos (find-position (:name prop) properties used-pos)
used-pos (conj used-pos pos)]
(if (< pos (count props))
{:props (assoc-in (vec props) [pos :value] (:value prop)) :used-pos used-pos}
{:props (add-new-prop props (:value prop)) :used-pos used-pos})))
{:props (vec props1) :used-pos (matching-indices props1 props2)}
props2)
:props)))
(if (< pos (count properties))
{:properties (assoc-in (vec properties) [pos :value] (:value prop)) :used-pos used-pos}
{:properties (add-new-property properties (:value prop)) :used-pos used-pos})))
{:properties (vec properties1) :used-pos (matching-indices properties1 properties2)}
properties2)
:properties)))
(defn compare-properties
"Compares vectors of properties keeping the value if it is the same for all
or setting a custom value where their values do not coincide"
([props-list]
(compare-properties props-list nil))
or setting a custom value where their values do not coincide.
([props-list distinct-mark]
(let [grouped (group-by :name (apply concat props-list))
Example:
[[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'z'}]] -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value nil}]"
([properties-list]
(compare-properties properties-list nil))
([properties-list distinct-mark]
(assert (or (sequential? properties-list) (nil? properties-list)))
(assert (or (string? distinct-mark) (nil? distinct-mark)))
(let [grouped (group-by :name (apply concat properties-list))
check-values (fn [values]
(let [vals (map :value values)]
(if (apply = vals)
@@ -281,33 +430,37 @@
{:name name :value (check-values values)})
grouped))))
(defn same-variant?
"Determines if all elements belong to the same variant"
[components]
(let [variant-ids (distinct (map :variant-id components))
not-blank? (complement str/blank?)]
(and
(= 1 (count variant-ids))
(not-blank? (first variant-ids)))))
(defn properties-distance
"Computes a weighted distance between two property lists `properties1` and `properties2`.
Latter properties weight less that previous ones.
(defn distance
"Computes a weighted distance between two property lists `props1` and `props2`.
Latter properties weight less that previous ones"
[props1 props2]
(let [total-num-props (count props1)
Example:
[{:name 'type' :value 'primary'}
{:name 'status' :value 'default'}]
[{:name 'type' :value 'primary'}
{:name 'status' :value 'hover'}] -> 1.0"
[properties1 properties2]
(assert (or (sequential? properties1) (nil? properties1)))
(assert (or (sequential? properties2) (nil? properties2)))
(let [total-num-properties (count properties1)
xform (map-indexed
(fn [idx [p1 p2]]
(if (not= p1 p2)
(math/pow 2 (- total-num-props idx))
(math/pow 2 (- total-num-properties idx))
0)))]
(transduce
xform
+
(map vector props1 props2))))
(map vector properties1 properties2))))
(defn variant-name-to-name
"Transforms a variant-name (its properties values) into a standard name:
the real name of the shape joined by the properties values separated by '/'"
the real name of the shape joined by the properties values separated by '/'.
Example:
{:name 'Button' :variant-name 'Primary, Hover'} -> 'Button / Primary / Hover'"
[variant]
(cpn/merge-path-item (:name variant) (str/replace (:variant-name variant) #", " " / ")))
@@ -317,8 +470,13 @@
["true" "false"]])
(defn find-boolean-pair
"Given a vector, return a map that contains the boolean equivalency if the values match
with any of the boolean pairs. Returns nil if none match."
"Given a collection, return a map that contains the boolean equivalency if the values match
with any of the boolean pairs. Returns nil if none match.
Example:
['on' 'off'] -> {'on' true 'off' false}
['foo' 'bar'] -> nil"
[[a b :as v]]
(let [a' (-> a str/trim str/lower)
b' (-> b str/trim str/lower)]
@@ -330,3 +488,18 @@
(= a' f)) {b true a false}
:else nil))
boolean-pairs))))
(defn same-variant?
"Determines if all elements belong to the same variant.
Example:
[{:variant-id 'abc'} {:variant-id 'abc'}] -> true
[{:variant-id 'abc'} {:variant-id 'def'}] -> false"
[components]
(assert (or (sequential? components) (nil? components)))
(let [variant-ids (distinct (map :variant-id components))
not-blank? (complement str/blank?)]
(and
(= 1 (count variant-ids))
(not-blank? (first variant-ids)))))
@@ -0,0 +1,165 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files.variant-test
(:require
[app.common.files.variant :as fv]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.variants :as thv]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
;; ============================================================
;; find-variant-components
;; ============================================================
(t/deftest find-variant-components-empty
(let [file (thf/sample-file :file1)
data (:data file)
page (thf/current-page file)
objects (:objects page)]
(t/is (= (fv/find-variant-components data (uuid/next))
[]))
(t/is (= (fv/find-variant-components data objects (uuid/next))
[]))))
(t/deftest find-variant-components-non-variant
(let [file (-> (thf/sample-file :file1)
(tho/add-simple-component :c01 :m01 :s01))
data (:data file)
page (thf/current-page file)
objects (:objects page)]
(t/is (= (fv/find-variant-components data (thi/id :m01))
[]))
(t/is (= (fv/find-variant-components data objects (thi/id :m01))
[]))))
(t/deftest find-variant-components-normal
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
page (thf/current-page file)
objects (:objects page)
result (fv/find-variant-components data objects (thi/id :v01))]
(t/is (= (count result) 2))
(t/is (every? #(contains? % :id) result))
(t/is (every? #(contains? % :variant-id) result))))
(t/deftest find-variant-components-single-variant
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
page (thf/current-page file)
objects (:objects page)
result (fv/find-variant-components data objects (thi/id :v01))]
;; Verify the order is maintained (reversed from shapes order)
(t/is (= (:variant-id (first result)) (thi/id :v01)))
(t/is (= (:variant-id (second result)) (thi/id :v01)))))
;; ============================================================
;; extract-properties-values
;; ============================================================
(t/deftest extract-properties-values-empty
(let [file (thf/sample-file :file1)
data (:data file)
page (thf/current-page file)
objects (:objects page)]
(t/is (= (fv/extract-properties-values data objects (uuid/next))
[]))))
(t/deftest extract-properties-values-non-variant
(let [file (-> (thf/sample-file :file1)
(tho/add-simple-component :c01 :m01 :s01))
data (:data file)
page (thf/current-page file)
objects (:objects page)]
(t/is (= (fv/extract-properties-values data objects (thi/id :m01))
[]))))
(t/deftest extract-properties-values-normal
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
page (thf/current-page file)
objects (:objects page)
result (fv/extract-properties-values data objects (thi/id :v01))]
(t/is (seq result))
(t/is (every? #(contains? % :name) result))
(t/is (every? #(contains? % :value) result))
(t/is (= (:name (first result)) "Property 1"))
(t/is (= (set (:value (first result))) #{"Value1" "Value2"}))))
(t/deftest extract-properties-values-two-properties
(let [file (-> (thf/sample-file :file1)
(thv/add-variant-two-properties :v01 :c01 :m01 :c02 :m02))
data (:data file)
page (thf/current-page file)
objects (:objects page)
result (fv/extract-properties-values data objects (thi/id :v01))]
(t/is (= (count result) 2))
(t/is (= (set (map :name result)) #{"Property 1" "Property 2"}))))
;; ============================================================
;; is-secondary-variant?
;; ============================================================
(t/deftest is-secondary-variant-primary
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
component (thc/get-component file :c01)]
(t/is (not (fv/is-secondary-variant? data component)))))
(t/deftest is-secondary-variant-secondary
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
component (thc/get-component file :c02)]
(t/is (fv/is-secondary-variant? data component))))
(t/deftest is-secondary-variant-not-variant
(let [file (-> (thf/sample-file :file1)
(tho/add-simple-component :c01 :m01 :s01))
data (:data file)
component (thc/get-component file :c01)]
(t/is (not (fv/is-secondary-variant? data component)))))
(t/deftest is-secondary-variant-no-shapes
(let [file (thf/sample-file :file1)
data (:data file)
component {:id :comp :variant-id (thi/id :file1) :main-instance-page (thi/id :file1)}]
(t/is (not (fv/is-secondary-variant? data component)))))
;; ============================================================
;; get-primary-variant
;; ============================================================
(t/deftest get-primary-variant-nil
(let [file (thf/sample-file :file1)
data (:data file)]
(t/is (nil? (fv/get-primary-variant data nil)))))
(t/deftest get-primary-variant-empty
(let [file (thf/sample-file :file1)
data (:data file)
component {:id :comp :variant-id (thi/id :file1) :main-instance-page (thi/id :file1)}]
(t/is (nil? (fv/get-primary-variant data component)))))
(t/deftest get-primary-variant-normal
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
component (thc/get-component file :c01)
result (fv/get-primary-variant data component)]
(t/is (some? result))
(t/is (contains? result :id))
(t/is (contains? result :component-id))))
+4
View File
@@ -23,6 +23,7 @@
[common-tests.files-migrations-test]
[common-tests.files.shapes-builder-test]
[common-tests.files.validate-test]
[common-tests.files.variant-test]
[common-tests.geom-align-test]
[common-tests.geom-bounds-layout-nil-test]
[common-tests.geom-bounds-map-test]
@@ -87,6 +88,7 @@
[common-tests.types.token-test]
[common-tests.types.tokens-lib-test]
[common-tests.types.tokens-status-test]
[common-tests.types.variant-test]
[common-tests.undo-stack-test]
[common-tests.uuid-test]))
@@ -102,6 +104,7 @@
'common-tests.files-migrations-0026-test
'common-tests.files-migrations-test
'common-tests.files.validate-test
'common-tests.files.variant-test
'common-tests.geom-align-test
'common-tests.geom-bounds-layout-nil-test
'common-tests.geom-bounds-map-test
@@ -142,6 +145,7 @@
'common-tests.logic.token-test
'common-tests.logic.variants-switch-test
'common-tests.math-test
'common-tests.types.variant-test
'common-tests.media-test
'common-tests.path-names-test
'common-tests.record-test
+244 -11
View File
@@ -7,10 +7,238 @@
(ns common-tests.types.variant-test
(:require
[app.common.types.variant :as ctv]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/deftest variant-component
(t/is (not (ctv/variant-component? nil)))
(t/is (not (ctv/variant-component? {})))
(t/is (ctv/variant-component? {:variant-id (uuid/next)})))
(t/deftest variant-distance01
(t/deftest variant-shape
(t/is (not (ctv/variant-shape? nil)))
(t/is (not (ctv/variant-shape? {})))
(t/is (ctv/variant-shape? {:variant-id (uuid/next)})))
(t/deftest variant-container
(t/is (not (ctv/variant-container? nil)))
(t/is (not (ctv/variant-container? {})))
(t/is (ctv/variant-container? {:is-variant-container true})))
(t/deftest properties-to-name-test
(t/is (= "" (ctv/properties-to-name [])))
(t/is (= "" (ctv/properties-to-name nil)))
(t/is (= "Button, Primary" (ctv/properties-to-name [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}])))
(t/is (= "Button" (ctv/properties-to-name [{:name "Property 1" :value "Button"}
{:name "Property 2" :value ""}]))))
(t/deftest next-property-number-test
(t/is (= 1 (ctv/next-property-number [])))
(t/is (= 1 (ctv/next-property-number nil)))
(t/is (= 2 (ctv/next-property-number [{:name "Property 1" :value "x"}])))
(t/is (= 4 (ctv/next-property-number [{:name "Property 3" :value "x"}])))
(t/is (= 3 (ctv/next-property-number [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]))))
(t/deftest add-new-property-test
(t/is (= [{:name "Property 1" :value "x"}]
(ctv/add-new-property [] "x")))
(t/is (= [{:name "Property 1" :value "x"}]
(ctv/add-new-property nil "x")))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/add-new-property [{:name "Property 1" :value "x"}] "y"))))
(t/deftest add-new-properties-test
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "b"}]
(ctv/add-new-properties [] ["a" "b"])))
(t/is (= '({:name "Property 2" :value "b"} {:name "Property 1" :value "a"})
(ctv/add-new-properties nil ["a" "b"])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "a"} {:name "Property 3" :value "b"}]
(ctv/add-new-properties [{:name "Property 1" :value "x"}] ["a" "b"]))))
(t/deftest path-to-properties-test
(t/is (= [] (ctv/path-to-properties "" [])))
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "b"}]
(ctv/path-to-properties "a / b" nil)))
(t/is (= [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}
{:name "Property 3" :value "Hover"}]
(ctv/path-to-properties "Button / Primary / Hover" [])))
(t/is (= [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}
{:name "Property 3" :value "Hover"}
{:name "Property 4" :value ""}]
(ctv/path-to-properties "Button / Primary / Hover" [] 4)))
(t/is (= [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}]
(ctv/path-to-properties "Button / Primary" [{:name "Property 1" :value "old"}
{:name "Property 2" :value "old2"}]))))
(t/deftest properties-map->formula-test
(t/is (= "" (ctv/properties-map->formula [])))
(t/is (= "" (ctv/properties-map->formula nil)))
(t/is (= "Property 1=Button, Property 2=Primary"
(ctv/properties-map->formula [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}])))
(t/is (= "Property 1=Button"
(ctv/properties-map->formula [{:name "Property 1" :value "Button"}
{:name "Property 2" :value ""}]))))
(t/deftest properties-formula->map-test
(t/is (= [] (ctv/properties-formula->map "")))
(t/is (= [] (ctv/properties-formula->map nil)))
(t/is (= [{:name "Property 1" :value "Button"} {:name "Property 2" :value "Primary"}]
(ctv/properties-formula->map "Property 1=Button, Property 2=Primary")))
(t/is (= [{:name "Property 1" :value "Button"}]
(ctv/properties-formula->map "Property 1=Button, Property 2="))))
(t/deftest valid-properties-formula?-test
(t/is (= true (ctv/valid-properties-formula? "Property 1=Button, Property 2=Primary")))
(t/is (= false (ctv/valid-properties-formula? "")))
(t/is (= true (ctv/valid-properties-formula? nil)))
(t/is (= false (ctv/valid-properties-formula? "Property 1=Button, Property 2"))))
(t/deftest find-properties-to-remove-test
(t/is (= [] (ctv/find-properties-to-remove [] [])))
(t/is (= [] (ctv/find-properties-to-remove nil nil)))
(t/is (= [{:name "Property 3" :value "z"}]
(ctv/find-properties-to-remove [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}
{:name "Property 3" :value "z"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/find-properties-to-remove [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 3" :value "z"}]))))
(t/deftest find-properties-to-update-test
(t/is (= [] (ctv/find-properties-to-update [] [])))
(t/is (= [] (ctv/find-properties-to-update nil nil)))
(t/is (= [{:name "Property 1" :value "new-x"}]
(ctv/find-properties-to-update [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "new-x"}
{:name "Property 2" :value "y"}])))
(t/is (= [{:name "Property 1" :value "new-x"} {:name "Property 2" :value "new-y"}]
(ctv/find-properties-to-update [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "new-x"}
{:name "Property 2" :value "new-y"}]))))
(t/deftest find-properties-to-add-test
(t/is (= [] (ctv/find-properties-to-add [] [])))
(t/is (= [] (ctv/find-properties-to-add nil nil)))
(t/is (= [{:name "Property 3" :value "z"}]
(ctv/find-properties-to-add [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}
{:name "Property 3" :value "z"}])))
(t/is (= [{:name "Property 2" :value "y"}]
(ctv/find-properties-to-add [{:name "Property 1" :value "x"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]))))
(t/deftest update-number-in-repeated-item-test
(t/is (= "Property" (ctv/update-number-in-repeated-item [] "Property")))
(t/is (= "Property" (ctv/update-number-in-repeated-item nil "Property")))
(t/is (= "Property (1)" (ctv/update-number-in-repeated-item ["Property"] "Property")))
(t/is (= "Property (2)" (ctv/update-number-in-repeated-item ["Property" "Property (1)"] "Property")))
(t/is (= "Property" (ctv/update-number-in-repeated-item ["Other"] "Property"))))
(t/deftest update-number-in-repeated-prop-names-test
(t/is (= [] (ctv/update-number-in-repeated-prop-names [])))
(t/is (= [] (ctv/update-number-in-repeated-prop-names nil)))
(t/is (= [{:name "Property" :value "x"}]
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}])))
(t/is (= [{:name "Property" :value "x"} {:name "Property (1)" :value "y"}]
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}
{:name "Property" :value "y"}])))
(t/is (= [{:name "Property" :value "x"} {:name "Property (1)" :value "y"} {:name "Property (2)" :value "z"}]
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}
{:name "Property" :value "y"}
{:name "Property" :value "z"}]))))
(t/deftest find-index-for-property-name-test
(t/is (= nil (ctv/find-index-for-property-name [] "Property 1")))
(t/is (= nil (ctv/find-index-for-property-name nil "Property 1")))
(t/is (= 0 (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}] "Property 1")))
(t/is (= 1 (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}] "Property 2")))
(t/is (= nil (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}] "Property 3"))))
(t/deftest remove-prefix-test
(t/is (= "name" (ctv/remove-prefix "name" "")))
(t/is (= "name" (ctv/remove-prefix "name" nil)))
(t/is (= "Primary" (ctv/remove-prefix "Button / Primary" "Button")))
(t/is (= "Primary" (ctv/remove-prefix "Button / Primary" "Button / ")))
(t/is (= "Button / Primary" (ctv/remove-prefix "Button / Primary" "Other"))))
(t/deftest merge-properties-test
(t/is (= [] (ctv/merge-properties [] [])))
(t/is (= [] (ctv/merge-properties nil nil)))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/merge-properties [{:name "Property 1" :value "a"}
{:name "Property 2" :value "b"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"} {:name "Property 3" :value "z"}]
(ctv/merge-properties [{:name "Property 1" :value "a"}
{:name "Property 2" :value "b"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}
{:name "Property 3" :value "z"}])))
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "y"}]
(ctv/merge-properties [{:name "Property 1" :value "a"}
{:name "Property 2" :value "b"}]
[{:name "Property 2" :value "y"}]))))
(t/deftest compare-properties-test
(t/is (= [] (ctv/compare-properties [])))
(t/is (= [] (ctv/compare-properties nil)))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/compare-properties [[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value nil}]
(ctv/compare-properties [[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "z"}]])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "*"}]
(ctv/compare-properties [[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "z"}]]
"*"))))
(t/deftest variant-name-to-name-test
(t/is (= "Button / Primary / Hover" (ctv/variant-name-to-name {:name "Button" :variant-name "Primary, Hover"})))
(t/is (= "Button" (ctv/variant-name-to-name {:name "Button" :variant-name ""})))
(t/is (= "Button" (ctv/variant-name-to-name {:name "Button" :variant-name nil})))
(t/is (= "" (ctv/variant-name-to-name {:name "" :variant-name ""})))
(t/is (= nil (ctv/variant-name-to-name {:name nil :variant-name nil}))))
(t/deftest find-boolean-pair-test
(t/is (= {"on" true "off" false} (ctv/find-boolean-pair ["on" "off"])))
(t/is (= {"yes" true "no" false} (ctv/find-boolean-pair ["yes" "no"])))
(t/is (= {"true" true "false" false} (ctv/find-boolean-pair ["true" "false"])))
(t/is (= {"on" true "off" false} (ctv/find-boolean-pair ["off" "on"])))
(t/is (= {"ON" true "OFF" false} (ctv/find-boolean-pair ["ON" "OFF"])))
(t/is (= nil (ctv/find-boolean-pair ["foo" "bar"])))
(t/is (= nil (ctv/find-boolean-pair nil)))
(t/is (= nil (ctv/find-boolean-pair ["on"]))))
(t/deftest same-variant?-test
(t/is (= false (ctv/same-variant? [])))
(t/is (= false (ctv/same-variant? nil)))
(t/is (= true (ctv/same-variant? [{:variant-id "abc"}])))
(t/is (= true (ctv/same-variant? [{:variant-id "abc"} {:variant-id "abc"}])))
(t/is (= false (ctv/same-variant? [{:variant-id "abc"} {:variant-id "def"}])))
(t/is (= false (ctv/same-variant? [{:variant-id ""} {:variant-id ""}]))))
(t/deftest properties-distance01
;;c1: primary, default, rounded, blue, dark
;;c2: primary, hover, squared, blue, dark
;;c3: primary, default, squared, blue, light
@@ -35,12 +263,11 @@
{:name "borders" :value "rounded"}
{:name "color" :value "blue"}
{:name "theme" :value "light"}]
dist2 (ctv/distance target props2)
dist3 (ctv/distance target props3)]
dist2 (ctv/properties-distance target props2)
dist3 (ctv/properties-distance target props3)]
(t/is (< dist3 dist2))))
(t/deftest variant-distance02
(t/deftest properties-distance02
;;c1: primary, default, rounded, blue, dark
;;c2: primary, hover, squared, red, dark
;;c3: secondary, hover, rounded, blue, dark
@@ -65,11 +292,11 @@
{:name "borders" :value "rounded"}
{:name "color" :value "blue"}
{:name "theme" :value "dark"}]
dist2 (ctv/distance target props2)
dist3 (ctv/distance target props3)]
dist2 (ctv/properties-distance target props2)
dist3 (ctv/properties-distance target props3)]
(t/is (< dist2 dist3))))
(t/deftest variant-distance03
(t/deftest properties-distance03
;;c1: primary, default, rounded, blue, dark
;;c2: secondary, default, rounded, blue, light
;;c3: secondary, hover, squared, blue, dark
@@ -101,12 +328,18 @@
{:name "borders" :value "rounded"}
{:name "color" :value "blue"}
{:name "theme" :value "dark"}]
dist2 (ctv/distance target props2)
dist3 (ctv/distance target props3)
dist4 (ctv/distance target props4)]
dist2 (ctv/properties-distance target props2)
dist3 (ctv/properties-distance target props3)
dist4 (ctv/properties-distance target props4)]
(t/is (< dist2 dist4))
(t/is (< dist4 dist3))))
(t/deftest properties-distance04
(t/is (= 0 (ctv/properties-distance [] [])))
(t/is (= 0 (ctv/properties-distance nil nil)))
(t/is (= 0 (ctv/properties-distance [{:name "a" :value "x"}] [{:name "a" :value "x"}])))
(t/is (= 2.0 (ctv/properties-distance [{:name "a" :value "x"} {:name "b" :value "y"}] [{:name "a" :value "x"} {:name "b" :value "z"}]))))
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Comments
order: 4
order: 6
desc: Learn how to import and export files in Penpot, the free, open-source design tool. Discover file formats, backups, sharing, and library management.
---
@@ -0,0 +1,35 @@
---
title: Enterprise plan
order: 2
desc: Learn how the Enterprise plan works in Penpot. Discover its features and how to use it within your organization.
---
<h1 id="enterprise">Enterprise plan</h1>
<p class="main-paragraph">Penpot Enterprise gives organizations the tools to govern how design work happens across their teams: from creating a structured org and managing members, to applying fine-grained permissions and configuration through the Admin Console.</p>
<h3 id="what-is-penpot-enterprise">What is Penpot Enterprise?
</h3>
<p>Penpot Enterprise is the plan that unlocks organizational governance features. While Penpot remains free and unlimited as an open-source platform, Enterprise adds a layer of control on top: the ability to create Organizations, manage teams under it, and apply configuration settings that define what members can and cannot do.</p>
<p>The organization owner is the user who creates the organization. They have exclusive access to the Admin Console and are responsible for configuring Modules.</p>
<p>The key concepts you'll work with:</p>
<ul>
<li><strong><a href="/user-guide/account-teams/organizations/">Organization</a>:</strong> the top-level structure that groups one or more Teams under a shared governance layer.</li>
<li><strong><a href="/user-guide/account-teams/organizations/#the-admin-console">Admin Console</a>:</strong> the back-office interface where the org owner manages settings, teams and members.</li>
<li><strong><a href="/user-guide/account-teams/organizations/#modules-and-controls">Modules</a>:</strong> the paid, configurable capabilities applied to an organization. Each Module consists of individual Controls (specific settings or restrictions).</li>
</ul>
<h3 id="subscribing-to-enterprise">Subscribing to Enterprise
<a class="direct-link" href="#subscribing-to-enterprise">#</a>
</h3>
<p>To create an organization, you first need to upgrade to the <a href="https://penpot.app/pricing" target="_blank">Enterprise plan</a>. Click "Create Organization" to begin. Once the subscription process is complete, you'll be redirected to the Admin Console to finish creating your organization.</p>
<h3 id="frequently-asked-questions"> Frequently asked questions
<a class="direct-link" href="#frequently-asked-questions">#</a>
</h3>
<h4>Can I have multiple organizations?</h4>
<p>Yes. You can create more than one organization under a single Enterprise subscription and manage them from the Admin Console.</p>
<h4>What happens if I cancel my Enterprise subscription?</h4>
<p>Your organizations are deleted but not their teams. Governance settings no longer apply to any of them.</p>
+13 -1
View File
@@ -13,7 +13,19 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<p>Access your account settings and manage personal access tokens</p>
</a>
</li>
<li>
<li>
<a href="/user-guide/account-teams/enterprise-plan/">
<h2>Enterprise plan →</h2>
<p>Learn how the Enterprise plan works and what it includes</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/organizations/">
<h2>Organizations →</h2>
<p>Create and manage organizations, the Admin Console, and SSO</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/teams">
<h2>Teams →</h2>
<p>Create and manage your teams</p>
@@ -0,0 +1,227 @@
---
title: Organizations
order: 3
desc: Learn how Organizations work in Penpot Enterprise, creating one, the Admin Console, membership, Modules and Controls, and Single Sign-On (SSO).
---
<h1 id="organizations">Organizations</h1>
<p class="main-paragraph">An organization is the governance layer Penpot Enterprise adds on top of your teams. This section covers how to create and manage one: the Admin Console, membership, and the Modules and Controls used to configure it, including Single Sign-On (SSO).</p>
<h3 id="creating-an-organization">Creating an organization
<a class="direct-link" href="#creating-an-organization">#</a>
</h3>
<p>Creating an organization is as easy as giving it a name.</p>
<p>Once the organization is created, you'll be taken to the Admin Console. At this point, the organization has one member: you, the owner.</p>
<h3 id="the-admin-console">The Admin Console
<a class="direct-link" href="#the-admin-console">#</a>
</h3>
<p>The Admin Console is the admin interface for your organization. Only the organization owner can access it.</p>
<p>You can reach the Admin Console directly at <code>/admin-console</code>, or from any team dashboard in Penpot by opening the organization navigation menu and clicking the Admin Console link. If you own more than one organization, you can switch between them from within the Admin Console.</p>
<figure>
<a href="/img/enterprise/enterprise-access-admin-console.webp" target="_blank">
<img alt="Accessing the Admin Console" src="/img/enterprise/enterprise-access-admin-console.webp"/>
</a>
</figure>
<p>The Admin Console includes:</p>
<ol>
<li><strong>Switch organization menu: </strong> To navigate between the organizations you own.</li>
<li><strong>Organization settings: </strong> To change basic settings, such as renaming it or deleting it.</li>
<li><strong>A "Go to files" button </strong> that returns you to a team dashboard.</li>
<li><strong>Your user avatar</strong>, that expands into a full user menu.</li>
<li><strong>Module: </strong> The control itself to configure.</li>
</ol>
<figure>
<a href="/img/enterprise/enterprise-admin-console.webp" target="_blank">
<img alt="Admin Console" src="/img/enterprise/enterprise-admin-console.webp"/>
</a>
</figure>
<h3 id="organizations-and-teams">Organizations and teams
<a class="direct-link" href="#organizations-and-teams">#</a>
</h3>
<p>An organization groups one or more Teams under a shared governance structure. Teams continue to work just as they do in standard Penpot, with the added layer that the org owner can apply configuration that affects all members across the organization's teams.</p>
<h4>How teams relate to organizations</h4>
<p>Teams inside an organization inherit the governance settings applied at the org level via the Admin Console. The structure is:</p>
<figure>
<img alt="organization-hierarchy" src="/img/enterprise/enterprise-organization-hierarchy-w.webp"/>
</figure>
<p>Team members work within projects and files as usual. What changes under Enterprise is the org owner's ability to restrict or govern that work from the Admin Console.</p>
<p>Within each team, the <a href="https://help.penpot.app/user-guide/account-teams/teams/#teams-members" target="_blank">standard Penpot roles</a> apply.</p>
<h3 id="managing-organization-membership">Managing organization membership
<a class="direct-link" href="#managing-organization-membership">#</a>
</h3>
<p>At launch, an organization has a single member: the owner. Additional members are brought in by being part of a team added to the organization, being invited to teams within the organization, or being directly invited to the organization by the owner.</p>
<p>The Admin Console provides a unified view of all members across teams within the organization.</p>
<figure>
<a href="/img/enterprise/enterprise-membership-w.webp" target="_blank">
<img alt="Admin-Console Members page" src="/img/enterprise/enterprise-membership-w.webp"/>
</a>
</figure>
<h3 id="modules-and-controls">Modules and Controls
<a class="direct-link" href="#modules-and-controls">#</a>
</h3>
<p>Modules are the configurable governance capabilities available to Enterprise organizations. Each Module is made up of one or more Controls: the individual settings that define who can do what, and where.</p>
<p>Modules are configured from the Admin Console and apply organization-wide. </p>
<table cellspacing="0" cellpadding="1" border="1" width="100%">
<thead>
<tr>
<th valign="top" class="cellrowborder">Modules</th>
<th valign="top" class="cellrowborder" style="text-align: center;">What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td>Single Sign-On (SSO)</td>
<td>Requires all org members to authenticate through your corporate identity provider before accessing the organization's teams and files.</td>
</tr>
<tr>
<td>Advanced permissions</td>
<td>Defines who can create, view, edit, administer, or share teams, projects, and files. Also controls who can invite new members to teams.</td>
</tr>
</tbody>
</table>
<h3 id="module-advanced-permissions">Module: Advanced permissions
<a class="direct-link" href="#module-advanced-permissions">#</a>
</h3>
<p>Advanced Permissions gives the organization owner fine-grained control over what members can do across all teams in the organization. Rather than relying on the default Penpot team roles alone, this module lets you restrict or open up specific actions at the organization level.</p>
<h4>What it controls</h4>
<p>The Advanced Permissions module is made up of individual Controls. Each Control governs a specific action, and each has a set of options to choose from. The selected option becomes the rule for the entire organization.</p>
<h4>How to configure it</h4>
<p>Advanced Permissions is configured from the Admin Console. Changes apply to all teams within the organization immediately.</p>
<ol>
<li>Open the Admin Console.</li>
<li>Select Advanced Permissions from the left sidebar.</li>
<li>For each Control, select the option that fits your governance policy.</li>
<li>Changes take effect right away. There is no publish or save step.</li>
</ol>
<h4>How it relates to team roles</h4>
<p>Advanced Permissions works on top of the standard Penpot team roles (Viewer, Editor, Admin, Owner). It does not replace them. Think of it as a ceiling: even if a member's team role would normally allow an action, an Advanced Permissions Control can prevent it organization-wide.</p>
<p>For example, if “New team members” is set to "Organization members only," a team owner who would normally be able to invite anyone will find that option restricted to people who are already part of the organization.</p>
<h4>Default behavior</h4>
<p>When an organization is first created, all controls are configured with the most permissive setting; the same setting is used by all teams that are not part of any organization. No behavior changes until you actively configure a Control.</p>
<h3 id="module-single-sign-on">Module: Single Sign-On (SSO)
<a class="direct-link" href="#module-single-sign-on">#</a>
</h3>
<p>Single Sign-On lets you require all members of your organization to authenticate through your corporate identity provider (IdP) before accessing any of the organization's teams and files.</p>
<p>SSO applies to teams and files only. The Admin Console is always accessible without SSO, so you can always reach your configuration to adjust or deactivate it, even if your own directory entry changes.</p>
<h4>Configuring your identity provider</h4>
<p>Before setting up SSO in Penpot, you need to register Penpot as an application in your identity provider. The steps vary by provider, but you will always need to set the following <strong>callback URL</strong> in your IdP configuration:</p>
<p><strong><code>https://&lt;your-penpot-domain&gt;/api/auth/oidc/callback</code></strong></p>
<p>Your IdP will then give you a <strong>Client ID</strong> and <strong>Client Secret</strong> to use in Penpot. Once you have those:</p>
<ol>
<li>Open the Admin Console.</li>
<li>Select <strong>SSO Config</strong>.</li>
<li>Choose your provider and fill in the fields.</li>
</ol>
<figure>
<a href="/img/enterprise/enterprise-module-sso.webp" target="_blank">
<img alt="SSO configuration" src="/img/enterprise/enterprise-module-sso.webp"/>
</a>
</figure>
<h5>Generic authentication (OpenID Connect)</h5>
<p>Use this option for any identity provider that supports the OIDC protocol, such as Okta, Keycloak, or Auth0.</p>
<ul>
<li><strong>Issuer / Authority URL:</strong> base URL of your OIDC provider, used to autodiscover endpoints</li>
<li><strong>Client ID:</strong> client identifier assigned by your provider</li>
<li><strong>Client Secret:</strong> client secret assigned by your provider</li>
</ul>
<h5>Azure Active Directory (OpenID Connect)</h5>
<ul>
<li><strong>Issuer / Authority URL:</strong> <code>https://login.microsoftonline.com/&lt;your-tenant-id&gt;/v2.0/</code></li>
<li><strong>Client ID:</strong> Application (client) ID from your Azure app registration</li>
<li><strong>Client Secret:</strong> client secret value from your Azure app registration</li>
</ul>
<h5>Google (OAuth)</h5>
<ul>
<li><strong>Client ID:</strong> client identifier from your Google Cloud OAuth 2.0 credentials</li>
<li><strong>Client Secret:</strong> client secret from your Google Cloud OAuth 2.0 credentials</li>
</ul>
<p>When all fields are filled, click <strong>Activate SSO</strong>. Penpot will run a test connection against your IdP. If the connection fails, your draft is kept and no changes are applied.</p>
<p>If the test passes, a confirmation dialog will appear. It will warn you that members not in your directory will lose access to the organization's teams once SSO is active. Review your member list if needed, then confirm. SSO becomes active immediately.</p>
<h4>What happens to existing sessions</h4>
<p>When SSO is activated, any member who is currently inside one of the organization's teams is cut off immediately and sent through the SSO login. This does not log them out of Penpot entirely. They can still reach teams that do not belong to your organization without re-authenticating.</p>
<p>SSO sessions last 4 hours. When a session expires, members are routed through the SSO login again. If they are still in the directory, they are signed back in immediately.</p>
<h4>Editing an active configuration</h4>
<p>While SSO is active, you can edit any field. As soon as you make a change, <strong>Apply changes</strong> and <strong>Discard changes</strong> buttons appear. Discarding restores every field to the current live configuration. Applying runs the same test connection as the initial setup, without a confirmation dialog. If the connection fails, your live configuration is not touched. Changes may take up to 5 minutes to apply for members who are currently working in a file.</p>
<h4>Deactivating SSO</h4>
<p>Click <strong>Deactivate SSO</strong> and confirm. The configuration is preserved as a draft so you can reactivate it later without re-entering your credentials. Members are notified by email the first time SSO is activated. If you deactivate and reactivate within 24 hours, the notification is not re-sent.</p>
<h4>For your members</h4>
<p>Members do not need to do anything to prepare. When they next try to access the organization's teams, they will be asked to authenticate through your IdP. If they are already signed in through that provider, the step is skipped automatically.</p>
<p>Org membership still requires an invitation from you. Being in the directory alone does not grant access to Penpot or to your organization.</p>
<p>If a member is not in your directory, they remain an org member but cannot enter the organization's teams until they are added. A single email is sent to all current members and pending invitees when SSO is first activated, explaining what changed and who to contact if they cannot get in.</p>
<h3 id="frequently-asked-questions"> Frequently asked questions
<a class="direct-link" href="#frequently-asked-questions">#</a>
</h3>
<h4>Can a team belong to more than one organization?</h4>
<p>No. A team belongs to a single organization.</p>
<h4>Can non-owners access the Admin Console?</h4>
<p>No. Access to the Admin Console is currently exclusive to the organization owner.</p>
<h4>What is the organization name used for?</h4>
<p>It's the human-readable name used to identify your organization in the UI, in emails, and in URLs. It is not your official billing name. You can change it at any time without affecting navigation or functionality.</p>
<h4>Do Advanced Permissions replace the standard Penpot team roles?</h4>
<p>No. Advanced Permissions work on top of the existing roles (Viewer, Editor, Admin, Owner). They add an organization-wide ceiling on what any role can do, but they do not change how roles work within a team.</p>
<h4>What is the default behavior when I first create an organization?</h4>
<p>All Controls are set to their most permissive option. Nothing changes until you actively configure a Control.</p>
<h4>What happens to existing team admins if I change a Control that restricts their permissions?</h4>
<p>The restriction applies immediately. An admin who could previously perform an action will no longer be able to do so as soon as the Control is changed, with no grace period.</p>
<h4>Is this the same as the authentication providers in Penpot's self-hosting configuration?</h4>
<p>No. Penpot's self-hosted configuration lets server administrators enable login methods (Google, GitHub, GitLab, OIDC) at the instance level, so users can sign in to Penpot itself with those providers. That is a server-level setting managed by whoever runs the infrastructure.</p>
<p>The SSO module in Enterprise is different in scope and purpose. It is configured by you, the organization owner, from the Admin Console, and it governs access to your organization's teams and files specifically. It does not change how users log in to Penpot as a platform, only whether they need to pass through your corporate identity provider to reach your organization's content.</p>
<h4>Does SSO affect the Admin Console?</h4>
<p>No. The Admin Console is always accessible without SSO, regardless of your configuration. This ensures you can always reach your settings to adjust or deactivate SSO, even if something changes on the directory side.</p>
<h4>What happens if my identity provider goes down while SSO is active?</h4>
<p>Current sessions continue until they expire. The next time a member tries to authenticate through your IdP and the provider is unreachable, the login will fail. There is no automatic bypass. Since the Admin Console is outside SSO, you can still reach your configuration to deactivate SSO if needed.</p>
<h4>Can I use the same identity provider for more than one organization?</h4>
<p>Yes. Two different organizations, and the Penpot instance itself, can share the same IdP. A successful SSO login never grants org membership on its own, so there is no risk of cross-organization access. Membership always requires an explicit invitation.</p>
<h4>What if a member is not in my directory?</h4>
<p>They remain an org member and keep their Penpot account, but they cannot enter the organization's teams until they are added to the directory. We send them an email when SSO is first activated explaining the situation and telling them to contact you.</p>
<h4>Does accepting an invitation automatically give someone access to my org's teams?</h4>
<p>Only if they are also in your directory. An invitee can register and accept the invitation, but if they are not in the directory, they become an org member without being able to enter the teams. Acceptance is never blocked on that basis.</p>
@@ -1,6 +1,6 @@
---
title: Projects and Files
order: 3
order: 5
desc: Learn how to organize your work in Penpot. Create, manage and organize projects and files, work with drafts, and handle deleted items.
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Teams
order: 2
order: 4
desc: Manage teams and roles with Penpot's collaboration features! Learn how to manage teams, roles (Viewer, Editor, Admin, Owner), send invites and use webhooks.
---
+8 -6
View File
@@ -103,12 +103,14 @@
(wasm.api/process-objects shapes)
(wasm.api/request-render "sync-wasm-structural-changes"))))))
(defn- apply-changes-localy
(defn- apply-changes-locally
[{:keys [file-id redo-changes ignore-wasm?] :as commit} pending]
(ptk/reify ::apply-changes-localy
(ptk/reify ::apply-changes-locally
ptk/UpdateEvent
(update [_ state]
(let [undo-changes
(let [libraries (dsh/lookup-libraries state)
undo-changes
(if pending
(->> pending
(map :undo-changes)
@@ -126,8 +128,8 @@
apply-changes
(fn [fdata]
(let [fdata (cpc/process-changes fdata undo-changes false)
fdata (cpc/process-changes fdata redo-changes false)
(let [fdata (cpc/process-changes fdata undo-changes false libraries)
fdata (cpc/process-changes fdata redo-changes false libraries)
pids (into #{} xf:map-page-id redo-changes)]
(reduce #(ctst/update-object-indices %1 %2) fdata pids)))]
@@ -200,7 +202,7 @@
(let [pending (when-not local?
(get-pending-commits state))]
(rx/concat
(rx/of (apply-changes-localy commit pending))
(rx/of (apply-changes-locally commit pending))
(if pending
(rx/concat
(->> (rx/from (reverse pending))
+16 -12
View File
@@ -1207,26 +1207,30 @@
(ptk/reify ::show-component-in-assets
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)
fdata (dsh/lookup-file-data state file-id)
component (cfv/get-primary-component fdata component-id)
cpath (:path component)
cpath (cpn/split-path cpath)
paths (map (fn [i] (cpn/join-path (take (inc i) cpath)))
(range (count cpath)))]
(let [file-id (:current-file-id state)
fdata (dsh/lookup-file-data state file-id)
component (ctkl/get-component fdata component-id)
primary-variant (cfv/get-primary-variant fdata component)
primary-component (ctkl/get-component fdata (:component-id primary-variant))
cpath (:path primary-component)
cpath (cpn/split-path cpath)
paths (map (fn [i] (cpn/join-path (take (inc i) cpath)))
(range (count cpath)))]
(rx/concat
(rx/from (map #(set-assets-group-open file-id :components % true) paths))
(rx/of (dcm/go-to-workspace :layout :assets)
(set-assets-section-open file-id :library true)
(set-assets-section-open file-id :components true)
(select-single-asset file-id (:id component) :components)))))
(select-single-asset file-id (:id primary-component) :components)))))
ptk/EffectEvent
(effect [_ state _]
(let [file-id (:current-file-id state)
fdata (dsh/lookup-file-data state file-id)
component (cfv/get-primary-component fdata component-id)
wrapper-id (str "component-shape-id-" (:id component))]
(let [file-id (:current-file-id state)
fdata (dsh/lookup-file-data state file-id)
component (ctkl/get-component fdata component-id)
primary-variant (cfv/get-primary-variant fdata component)
primary-component (ctkl/get-component fdata (:component-id primary-variant))
wrapper-id (str "component-shape-id-" (:id primary-component))]
(tm/schedule-on-idle #(dom/scroll-into-view-if-needed! (dom/get-element wrapper-id)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -703,9 +703,9 @@
#_:clj-kondo/ignore
(defn set-wasm-modifiers
[modif-tree & {:keys [ignore-constraints ignore-snap-pixel
[modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis
subtree-ids-by-id selection-rect-cache]
:or {ignore-constraints false ignore-snap-pixel false}
:or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil}
:as params}]
(let [modif-tree (without-nil-ids modif-tree)]
(ptk/reify ::set-wasm-modifiers
@@ -756,7 +756,7 @@
root-modifiers
:else
(let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
(let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis)]
(if (seq propagated) propagated root-modifiers)))]
(when wasm-ready?
(wasm.api/set-modifiers modifiers))
@@ -831,10 +831,8 @@
;; primaries and descendants would snap back to their
;; pre-drag positions on drop.
;;
;; Skipped when `snap-pixel?` is on: WASM applies
;; per-shape pixel correction (different scale/translate
;; per descendant) which we can't replicate cheaply on
;; the CLJS side.
;; Only without `snap-pixel?`: the delta that lands
;; the shape on the pixel grid is known to WASM alone.
(reduce
(fn [acc [id data]]
(let [t (:transform data)
@@ -864,7 +862,7 @@
geometry-entries))
:else
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)))
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis)))
ignore-tree
(calculate-ignore-tree-wasm transforms objects)
@@ -193,7 +193,8 @@
layout-initializer (get-layout-initializer type from-frame? calculate-params?)]
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes [id] layout-initializer {:with-objects? true})
(dwsh/update-shapes [id] layout-initializer {:with-objects? true
:extra-context (str "create-layout-from-id: " id)})
(dwsh/update-shapes (dm/get-prop parent :shapes) #(dissoc % :constraints-h :constraints-v))
(ptk/data-event :layout/update {:ids [id]})
(dwu/commit-undo-transaction undo-id))))))
@@ -189,7 +189,7 @@
{:as props
:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
ignore-touched undo-group with-objects? changed-sub-attr translation?
skip-component-sync?]
skip-component-sync? extra-context]
:or {reg-objects? false
save-undo? true
stack-undo? false
@@ -222,7 +222,8 @@
:ignore-tree ignore-tree
:ignore-touched ignore-touched
:with-objects? with-objects?
:translation? translation?})
:translation? translation?
:extra-context extra-context})
(cond-> undo-group
(pcb/set-undo-group undo-group))
(pcb/set-translation? translation?)
@@ -592,12 +592,14 @@
(rx/merge
(->> angle-stream
(rx/sample mconst/rotation-sample-time)
(rx/map #(dwm/set-wasm-modifiers (rotation-modifiers % shapes group-center)))
(rx/map #(dwm/set-wasm-modifiers (rotation-modifiers % shapes group-center)
:ignore-snap-pixel true))
(rx/take-until stopper))
(->> angle-stream
(rx/take-until stopper)
(rx/last)
(rx/map #(dwm/apply-wasm-modifiers (rotation-modifiers % shapes group-center)))))
(rx/map #(dwm/apply-wasm-modifiers (rotation-modifiers % shapes group-center)
:ignore-snap-pixel true))))
(rx/of (finish-transform)))
@@ -638,7 +640,9 @@
modif-tree
(dwm/build-modif-tree ids objects get-modifier)]
(rx/of (dwm/apply-wasm-modifiers modif-tree :ignore-touched (:ignore-touched options))))
(rx/of (dwm/apply-wasm-modifiers modif-tree
:ignore-touched (:ignore-touched options)
:ignore-snap-pixel true)))
(let [page-id (or (:page-id options)
(:current-page-id state))
@@ -764,7 +764,7 @@
(remove #(= (:id %) component-id))
(filter #(= (dm/get-in % [:variant-properties pos :value]) val))
(reverse))
nearest-comp (apply min-key #(ctv/distance target-props (:variant-properties %)) valid-comps)
nearest-comp (apply min-key #(ctv/properties-distance target-props (:variant-properties %)) valid-comps)
shape-parents (cfh/get-parents-with-self current-page-objects (:parent-id shape))
nearest-comp-children (cfh/get-children-with-self component-page-objects (:main-instance-id nearest-comp))
comps-nesting-loop? (seq? (cfh/components-nesting-loop? nearest-comp-children shape-parents))
@@ -55,7 +55,7 @@
graphics 0
typographies (count (:typographies data))
components (count (->> (ctkl/components-seq data)
(remove #(cfv/is-secondary-variant? % data))))
(remove #(cfv/is-secondary-variant? data %))))
empty? (and (zero? components)
(zero? graphics)
(zero? colors)
@@ -327,7 +327,7 @@
(mf/with-memo [filters library]
(as-> (into [] (ctkl/components-seq library)) $
(cmm/apply-filters $ filters)
(remove #(cfv/is-secondary-variant? % library) $)))
(remove #(cfv/is-secondary-variant? library %) $)))
filtered-typographies
(mf/with-memo [filters typographies]
@@ -710,7 +710,7 @@
:components
vals
(remove #(true? (:deleted %)))
(remove #(cfv/is-secondary-variant? % current-lib-data))
(remove #(cfv/is-secondary-variant? current-lib-data %))
(map #(assoc % :full-name (cpn/merge-path-item-with-dot (:path %) (:name %)))))
count-variants (fn [component]
+1 -1
View File
@@ -1033,7 +1033,7 @@
components (->> data
:components
(remove (comp :deleted second))
(remove (comp #(cfv/is-secondary-variant? % data) second))
(remove (comp #(cfv/is-secondary-variant? data %) second))
(map first)
(map #(lib-component-proxy plugin-id file-id %)))]
(apply array components)))}
+24 -2
View File
@@ -2122,13 +2122,34 @@
(h/call wasm/internal-module "_set_structure_modifiers"))))
;; Axes the pixel grid rounds, as `propagate_modifiers` expects them.
(def ^:private pixel-precision
{:disabled 0
:both 1
:only-x 2
:only-y 3})
(defn- pixel-precision-mode
"Encodes the pixel grid snapping for the renderer. `snap-ignore-axis`
names the axis to leave alone (`:x`, `:y` or nil)."
[snap-pixel? snap-ignore-axis]
(pixel-precision
(cond
(not snap-pixel?) :disabled
(= :x snap-ignore-axis) :only-y
(= :y snap-ignore-axis) :only-x
:else :both)))
(defn propagate-modifiers
"Propagates geometry modifiers through the WASM shape tree.
Rounds the resulting geometry to the pixel grid when `snap-pixel?` is set,
skipping the axis named by `snap-ignore-axis` (`:x`, `:y` or nil).
Always returns a vector. When the context is not ready (lost / mid-reload)
or `entries` is empty, returns `[]` so callers never receive `nil` (which
would trip `set-modifiers`' vector assert)."
[entries pixel-precision]
[entries snap-pixel? snap-ignore-axis]
(if-not (and (initialized?) (not ^boolean (empty? entries)))
[]
(let [heapf32 (mem/get-heap-f32)
@@ -2146,7 +2167,8 @@
offset
entries)
(let [offset (-> (h/call wasm/internal-module "_propagate_modifiers" pixel-precision)
(let [precision (pixel-precision-mode snap-pixel? snap-ignore-axis)
offset (-> (h/call wasm/internal-module "_propagate_modifiers" precision)
(mem/->offset-32))
length (aget heapu32 offset)
max-offset (+ offset 1 (* length MODIFIER-U32-SIZE))
@@ -58,7 +58,7 @@
This effectively tells the caller \"apply exactly the transform that
was requested\", which is what the real WASM engine does for simple
moves / resizes without constraints."
[entries _pixel-precision]
[entries _snap-pixel? _snap-ignore-axis]
(track! :propagate-modifiers)
(when (d/not-empty? entries)
(into []
@@ -46,7 +46,7 @@
the real implementations."
[]
(set! wasm.api/propagate-modifiers
(fn [entries _pixel-precision]
(fn [entries _snap-pixel? _snap-ignore-axis]
(swap! captured-geometry-entries into entries)
(into []
(map (fn [[id data]] [id (:transform data)]))
@@ -0,0 +1,104 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.wasm-pixel-snap-test
"Covers which pixel-grid snapping options reach the WASM renderer.
The rounding happens in Rust, so these tests assert on the arguments
crossing the bridge: which axis an axis-locked drag leaves alone, and
that rotation does not snap."
(:require
[app.common.geom.point :as gpt]
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.types.modifiers :as ctm]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.transforms :as dwt]
[app.render-wasm.api :as wasm.api]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]
[frontend-tests.helpers.wasm :as thw]))
(def ^:private captured-snap-options
"One entry per `wasm.api/propagate-modifiers` call during a test."
(atom []))
(defn- install-capturing-spy!
"Records the snap options of every propagation. Must run after
`thw/setup-wasm-mocks!` so teardown restores the real implementation."
[]
(set! wasm.api/propagate-modifiers
(fn [entries snap-pixel? snap-ignore-axis]
(swap! captured-snap-options conj
{:snap-pixel? snap-pixel? :snap-ignore-axis snap-ignore-axis})
(into []
(map (fn [[id data]] [id (:transform data)]))
entries))))
(defn- enable-pixel-grid
[]
(fn [state]
(update state :workspace-layout conj :snap-pixel-grid)))
(t/use-fixtures :each
{:before (fn []
(cthi/reset-idmap!)
(reset! captured-snap-options [])
(thw/setup-wasm-mocks!)
(install-capturing-spy!))
:after (fn []
(thw/teardown-wasm-mocks!))})
(t/deftest axis-locked-move-tells-the-renderer-which-axis-to-ignore
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
store (ths/setup-store file)
rect (cths/get-shape file :rect1)
modif-tree (dwm/create-modif-tree [(:id rect)]
(ctm/move-modifiers (gpt/point 5.2 0)))
events [(enable-pixel-grid)
(dwm/apply-wasm-modifiers modif-tree :snap-ignore-axis :y)]]
(ths/run-store
store done events
(fn [_new-state]
(t/is (= [{:snap-pixel? true :snap-ignore-axis :y}]
@captured-snap-options)))))))
(t/deftest move-without-axis-lock-snaps-both-axes
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
store (ths/setup-store file)
rect (cths/get-shape file :rect1)
modif-tree (dwm/create-modif-tree [(:id rect)]
(ctm/move-modifiers (gpt/point 5.2 3.7)))
events [(enable-pixel-grid)
(dwm/apply-wasm-modifiers modif-tree)]]
(ths/run-store
store done events
(fn [_new-state]
(t/is (= [{:snap-pixel? true :snap-ignore-axis nil}]
@captured-snap-options)))))))
(t/deftest rotation-does-not-snap-to-the-pixel-grid
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
store (ths/setup-store file)
rect (cths/get-shape file :rect1)
events [(enable-pixel-grid)
(dwt/increase-rotation #{(:id rect)} 15)]]
(ths/run-store
store done events
(fn [_new-state]
(t/is (= [{:snap-pixel? false :snap-ignore-axis nil}]
@captured-snap-options)))))))
+2
View File
@@ -45,6 +45,7 @@
[frontend-tests.logic.sidebar-transform-coalescing-test]
[frontend-tests.logic.update-position-test]
[frontend-tests.logic.wasm-modifiers-nil-id-test]
[frontend-tests.logic.wasm-pixel-snap-test]
[frontend-tests.main-errors-test]
[frontend-tests.plugins.comments-test]
[frontend-tests.plugins.context-shapes-test]
@@ -154,6 +155,7 @@
'frontend-tests.logic.sidebar-transform-coalescing-test
'frontend-tests.logic.update-position-test
'frontend-tests.logic.wasm-modifiers-nil-id-test
'frontend-tests.logic.wasm-pixel-snap-test
'frontend-tests.plugins.comments-test
'frontend-tests.plugins.context-shapes-test
'frontend-tests.plugins.file-test
+14
View File
@@ -209,6 +209,20 @@ pub(super) fn add_group(
}
}
/// SVG-raw leaf with markup (same form `get-static-markup` uploads to WASM).
pub(super) fn add_svg_raw(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
content: &str,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_svg_raw_content(content.to_string());
shape.set_selrect(l, t, r, b);
}
/// Adds a single-line text shape using the embedded default font.
pub(super) fn add_solid_text(
pool: &mut ShapesPool,
+8
View File
@@ -194,6 +194,14 @@ fn render_leaf(
{
if matches!(element.shape_type, Type::Text(_)) {
render_text_fill(builder, shared, element)?;
} else if matches!(element.shape_type, Type::SVGRaw(_)) {
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_svg(element)?;
canvas.restore();
} else {
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
+30
View File
@@ -125,6 +125,36 @@ fn exports_a_group_with_two_rects_and_group_opacity() {
insta::assert_snapshot!(svg);
}
#[test]
fn loads_svg_raw_dom_like_wasm_upload() {
// Production paints svg-raw via Dom::render after set_shape_svg_raw_content.
// Native SkSVGCanvas does not serialize those draws, so the export string
// stays empty here; we assert Dom parse + that export does not panic.
let mut pool = ShapesPool::new();
let id = uid(1);
add_svg_raw(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 307.0, 243.0),
concat!(
r#"<svg xmlns="http://www.w3.org/2000/svg">"#,
r#"<text x="10" y="24" fill="black">HOLA</text>"#,
r#"</svg>"#,
),
);
let resources = crate::render::RenderResources::try_new_headless().expect("headless");
let font_manager = skia::FontMgr::from(resources.fonts.font_provider().clone());
{
let shape = pool.get_mut(&id).unwrap();
shape.update_svg_raw_content(font_manager);
assert!(shape.svg.is_some(), "Dom must parse like WASM upload");
}
let _svg = render(&pool, id);
}
#[test]
fn exports_a_clipped_frame_with_overflowing_child() {
let mut pool = ShapesPool::new();
+326 -27
View File
@@ -12,8 +12,8 @@ use common::GetBounds;
use crate::error::Result;
use crate::shapes;
use crate::shapes::{
ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, Shape, TransformEntry,
TransformEntrySource, Type,
ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, PixelPrecision, Shape,
TransformEntry, TransformEntrySource, Type,
};
use crate::state::{ShapesPoolRef, State};
use crate::uuid::Uuid;
@@ -139,36 +139,84 @@ fn calculate_bool_bounds(
Some(result)
}
fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) {
let tr = bounds.transform_matrix().unwrap_or_default();
let tr_inv = tr.invert().unwrap_or_default();
/// Which parts of the geometry a pixel-grid correction rounds: only the ones
/// the transform changes, so a move keeps its dimensions and a resize keeps
/// its anchored corner.
#[derive(PartialEq, Debug, Clone, Copy)]
struct SnapGeometry {
x: bool,
y: bool,
width: bool,
height: bool,
}
let x = bounds.min_x().round();
let y = bounds.min_y().round();
impl SnapGeometry {
/// Flags the properties that differ between the two bounds. The axis mask
/// in `precision` applies to the position only.
fn new(before: &Bounds, after: &Bounds, precision: PixelPrecision) -> Self {
SnapGeometry {
x: precision.rounds_x() && !is_close_to(before.min_x(), after.min_x()),
y: precision.rounds_y() && !is_close_to(before.min_y(), after.min_y()),
width: !is_close_to(before.width(), after.width()),
height: !is_close_to(before.height(), after.height()),
}
}
let width = bounds.width();
let height = bounds.height();
fn resized(&self) -> bool {
self.width || self.height
}
let target_width = bounds.width().round();
let target_height = bounds.height().round();
fn any(&self) -> bool {
self.x || self.y || self.resized()
}
}
let scale_width = if width > 0.1 {
f32::max(0.01, target_width / width)
/// Rounds a transform so the parts of the shape the gesture changed land on
/// the pixel grid, leaving everything else exactly where it is.
fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds, snap: SnapGeometry) {
// Target corner, taken before the size correction: that correction scales
// about the bounds center, and the translation below undoes the corner
// displacement it causes. An unsnapped axis targets its own value.
let x = if snap.x {
bounds.min_x().round()
} else {
1.0
bounds.min_x()
};
let scale_height = if height > 0.1 {
f32::max(0.01, target_height / height)
let y = if snap.y {
bounds.min_y().round()
} else {
1.0
bounds.min_y()
};
if f32::is_finite(scale_width) && f32::is_finite(scale_height) {
let mut round_transform = Matrix::scale((scale_width, scale_height));
round_transform.post_concat(&tr);
round_transform.pre_concat(&tr_inv);
transform.post_concat(&round_transform);
bounds.transform_mut(&round_transform);
if snap.resized() {
let tr = bounds.transform_matrix().unwrap_or_default();
let tr_inv = tr.invert().unwrap_or_default();
let width = bounds.width();
let height = bounds.height();
// A rounded dimension is never smaller than one pixel.
let target_width = f32::max(1.0, width.round());
let target_height = f32::max(1.0, height.round());
let scale_width = if snap.width && width > 0.1 {
f32::max(0.01, target_width / width)
} else {
1.0
};
let scale_height = if snap.height && height > 0.1 {
f32::max(0.01, target_height / height)
} else {
1.0
};
if f32::is_finite(scale_width) && f32::is_finite(scale_height) {
let mut round_transform = Matrix::scale((scale_width, scale_height));
round_transform.post_concat(&tr);
round_transform.pre_concat(&tr_inv);
transform.post_concat(&round_transform);
bounds.transform_mut(&round_transform);
}
}
let dx = x - bounds.min_x();
@@ -184,7 +232,7 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) {
#[allow(clippy::too_many_arguments)]
fn propagate_transform(
entry: TransformEntry,
pixel_precision: bool,
pixel_precision: PixelPrecision,
state: &State,
entries: &mut VecDeque<Modifier>,
bounds: &mut HashMap<Uuid, Bounds>,
@@ -286,8 +334,11 @@ fn propagate_transform(
}
}
if pixel_precision {
set_pixel_precision(&mut transform, &mut shape_bounds_after);
if pixel_precision.enabled() {
let snap = SnapGeometry::new(&shape_bounds_before, &shape_bounds_after, pixel_precision);
if snap.any() {
set_pixel_precision(&mut transform, &mut shape_bounds_after, snap);
}
}
if entry.propagate {
@@ -417,10 +468,15 @@ fn reflow_shape(
Ok(())
}
/// Propagates a set of transforms through the shape tree, returning one
/// transform per affected shape.
///
/// The transforms are relative to the committed geometry, so callers clear
/// any transform modifier of their own before propagating.
pub fn propagate_modifiers(
state: &State,
modifiers: &[TransformEntry],
pixel_precision: bool,
pixel_precision: PixelPrecision,
) -> Result<Vec<TransformEntry>> {
let mut entries: VecDeque<_> = modifiers
.iter()
@@ -567,6 +623,249 @@ mod tests {
assert_eq!(result.len(), 1);
}
#[test]
fn test_pixel_precision_move_keeps_size() {
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
let mut bounds_after = bounds.transform(&Matrix::translate((5.2, 3.7)));
let mut transform = Matrix::translate((5.2, 3.7));
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
set_pixel_precision(&mut transform, &mut bounds_after, snap);
assert!(is_close_to(bounds_after.width(), 100.5));
assert!(is_close_to(bounds_after.height(), 50.3));
assert!(is_close_to(bounds_after.min_x(), 16.0));
assert!(is_close_to(bounds_after.min_y(), 24.0));
assert!(math::is_move_only_matrix(&transform));
}
#[test]
fn test_pixel_precision_resize_rounds_size() {
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
let mut bounds_after = bounds.transform(&Matrix::scale((1.1, 1.1)));
let mut transform = Matrix::scale((1.1, 1.1));
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
set_pixel_precision(&mut transform, &mut bounds_after, snap);
assert!(is_close_to(
bounds_after.width(),
bounds_after.width().round()
));
assert!(is_close_to(
bounds_after.height(),
bounds_after.height().round()
));
assert!(is_close_to(
bounds_after.min_x(),
bounds_after.min_x().round()
));
assert!(is_close_to(
bounds_after.min_y(),
bounds_after.min_y().round()
));
}
#[test]
fn test_propagate_pixel_precision_move_only_rounds_position() {
let shape_id = Uuid::new_v4();
let mut state = State::new();
state.shapes.initialize(10);
{
let shape = state.shapes.add_shape(shape_id);
shape.set_selrect(10.4, 20.6, 110.9, 70.9);
}
let entry = TransformEntry::from_input(shape_id, Matrix::translate((5.2, 3.7)));
let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
let transform = result
.iter()
.find(|entry| entry.id == shape_id)
.map(|entry| entry.transform)
.unwrap();
let shape = state.shapes.get(&shape_id).unwrap();
let bounds = shape.bounds().transform(&transform);
assert!(is_close_to(bounds.width(), 100.5));
assert!(is_close_to(bounds.height(), 50.3));
assert!(is_close_to(bounds.min_x(), 16.0));
assert!(is_close_to(bounds.min_y(), 24.0));
}
#[test]
fn test_propagate_pixel_precision_resize_keeps_anchored_corner() {
let shape_id = Uuid::new_v4();
let mut state = State::new();
state.shapes.initialize(10);
{
let shape = state.shapes.add_shape(shape_id);
shape.set_selrect(10.4, 20.6, 110.4, 70.6);
}
// Drag the bottom-right corner in small steps: the top-left corner
// stays put on every step.
for step in 1..40 {
let delta = step as f32 * 0.05;
let mut resize = Matrix::scale(((100.0 + delta) / 100.0, (50.0 + delta) / 50.0));
resize.post_translate(Point::new(10.4, 20.6));
resize.pre_translate(Point::new(-10.4, -20.6));
let entry = TransformEntry::from_input(shape_id, resize);
let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
let transform = result
.iter()
.find(|entry| entry.id == shape_id)
.map(|entry| entry.transform)
.unwrap();
let shape = state.shapes.get(&shape_id).unwrap();
let bounds = shape.bounds().transform(&transform);
assert!(
is_close_to(bounds.min_x(), 10.4) && is_close_to(bounds.min_y(), 20.6),
"corner moved to ({}, {}) at delta {}",
bounds.min_x(),
bounds.min_y(),
delta
);
assert!(is_close_to(bounds.width(), bounds.width().round()));
assert!(is_close_to(bounds.height(), bounds.height().round()));
}
}
#[test]
fn test_pixel_precision_only_x_leaves_y_untouched() {
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
let mut bounds_after = bounds.transform(&Matrix::translate((5.2, 0.0)));
let mut transform = Matrix::translate((5.2, 0.0));
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::OnlyX);
set_pixel_precision(&mut transform, &mut bounds_after, snap);
assert!(is_close_to(bounds_after.min_x(), 16.0));
assert!(is_close_to(bounds_after.min_y(), 20.6));
}
#[test]
fn test_pixel_precision_only_y_leaves_x_untouched() {
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
let mut bounds_after = bounds.transform(&Matrix::translate((0.0, 3.7)));
let mut transform = Matrix::translate((0.0, 3.7));
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::OnlyY);
set_pixel_precision(&mut transform, &mut bounds_after, snap);
assert!(is_close_to(bounds_after.min_x(), 10.4));
assert!(is_close_to(bounds_after.min_y(), 24.0));
}
#[test]
fn test_pixel_precision_resize_never_rounds_below_one_pixel() {
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.0, 20.0, 0.4, 0.3));
let mut bounds_after = bounds.transform(&Matrix::scale((1.5, 1.5)));
let mut transform = Matrix::scale((1.5, 1.5));
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
set_pixel_precision(&mut transform, &mut bounds_after, snap);
assert!(is_close_to(bounds_after.width(), 1.0));
assert!(is_close_to(bounds_after.height(), 1.0));
}
#[test]
fn test_propagate_pixel_precision_snaps_every_frame_of_a_gesture() {
let shape_id = Uuid::new_v4();
let mut state = State::new();
state.shapes.initialize(10);
{
let shape = state.shapes.add_shape(shape_id);
shape.set_selrect(10.4, 20.6, 110.9, 70.9);
}
// One frame of a drag, as the entry point runs it: clear the
// modifiers, propagate the delta accumulated since the gesture
// started, then push the result back as the active modifier, which is
// what the renderer draws.
let frame = |state: &mut State, delta: f32| {
state.shapes.clear_transform_modifiers();
let entry = TransformEntry::from_input(shape_id, Matrix::translate((delta, delta)));
let result = propagate_modifiers(state, &[entry], PixelPrecision::Both).unwrap();
let transform = result
.iter()
.find(|entry| entry.id == shape_id)
.map(|entry| entry.transform)
.unwrap();
let bounds = state
.shapes
.get_raw(&shape_id)
.unwrap()
.bounds()
.transform(&transform);
state.set_modifiers(HashMap::from([(shape_id, transform)]));
bounds
};
// Every frame lands on the pixel grid and keeps the size.
for step in 1..40 {
let bounds = frame(&mut state, step as f32 * 0.35);
assert!(
is_close_to(bounds.min_x(), bounds.min_x().round())
&& is_close_to(bounds.min_y(), bounds.min_y().round()),
"shape landed off the pixel grid at ({}, {}) on frame {}",
bounds.min_x(),
bounds.min_y(),
step
);
assert!(is_close_to(bounds.width(), 100.5));
assert!(is_close_to(bounds.height(), 50.3));
}
}
#[test]
fn test_propagate_pixel_precision_resize_only_rounds_the_changed_dimension() {
let shape_id = Uuid::new_v4();
let mut state = State::new();
state.shapes.initialize(10);
{
let shape = state.shapes.add_shape(shape_id);
shape.set_selrect(10.4, 20.6, 110.9, 70.9);
}
// Drag the right edge: the width lands on the grid, the height and
// the top-left corner stay put.
let mut resize = Matrix::scale((103.3 / 100.5, 1.0));
resize.post_translate(Point::new(10.4, 20.6));
resize.pre_translate(Point::new(-10.4, -20.6));
let entry = TransformEntry::from_input(shape_id, resize);
let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
let transform = result
.iter()
.find(|entry| entry.id == shape_id)
.map(|entry| entry.transform)
.unwrap();
let bounds = state
.shapes
.get_raw(&shape_id)
.unwrap()
.bounds()
.transform(&transform);
assert!(is_close_to(bounds.width(), 103.0));
assert!(is_close_to(bounds.height(), 50.3));
assert!(is_close_to(bounds.min_x(), 10.4));
assert!(is_close_to(bounds.min_y(), 20.6));
}
#[test]
fn test_group_bounds() {
let parent_id = Uuid::new_v4();
+44 -3
View File
@@ -5,18 +5,59 @@ use crate::utils::{uuid_from_u32_quartet, uuid_to_u32_quartet};
use crate::uuid::Uuid;
use skia::Matrix;
/// Axes the pixel grid rounds. An axis-locked drag rounds only the axis it
/// moves along.
#[derive(PartialEq, Debug, Clone, Copy)]
pub enum PixelPrecision {
Disabled,
Both,
OnlyX,
OnlyY,
}
impl PixelPrecision {
pub fn enabled(&self) -> bool {
*self != PixelPrecision::Disabled
}
pub fn rounds_x(&self) -> bool {
matches!(self, PixelPrecision::Both | PixelPrecision::OnlyX)
}
pub fn rounds_y(&self) -> bool {
matches!(self, PixelPrecision::Both | PixelPrecision::OnlyY)
}
}
impl From<u8> for PixelPrecision {
fn from(value: u8) -> Self {
match value {
1 => PixelPrecision::Both,
2 => PixelPrecision::OnlyX,
3 => PixelPrecision::OnlyY,
_ => PixelPrecision::Disabled,
}
}
}
#[derive(PartialEq, Debug, Clone)]
pub enum Modifier {
Transform(TransformEntry, bool),
Transform(TransformEntry, PixelPrecision),
Reflow(Uuid, bool),
}
impl Modifier {
pub fn transform_propagate(id: Uuid, transform: Matrix) -> Self {
Modifier::Transform(TransformEntry::from_propagate(id, transform), false)
Modifier::Transform(
TransformEntry::from_propagate(id, transform),
PixelPrecision::Disabled,
)
}
pub fn parent(id: Uuid, transform: Matrix) -> Self {
Modifier::Transform(TransformEntry::parent(id, transform), false)
Modifier::Transform(
TransformEntry::parent(id, transform),
PixelPrecision::Disabled,
)
}
pub fn reflow(id: Uuid, force_reflow: bool) -> Self {
Modifier::Reflow(id, force_reflow)
+14
View File
@@ -403,6 +403,20 @@ impl ShapesPoolImpl {
/// gone, but if we don't touch their tiles they keep pointing at the
/// previous modified position and the tile texture cache may serve stale
/// pixels.
/// Drops the transform modifiers, keeping structure and scale-content
/// entries, so the pool serves committed geometry again. Called before
/// propagating a new set of transforms, which are relative to that
/// geometry.
pub fn clear_transform_modifiers(&mut self) {
if self.modifiers.is_empty() {
return;
}
self.clean_shape_cache();
self.modifiers = HashMap::default();
self.modifier_uuids.clear();
}
pub fn clean_all(&mut self) -> Vec<Uuid> {
self.clean_shape_cache();
+3 -2
View File
@@ -76,7 +76,7 @@ impl From<RawTransformEntry> for TransformEntry {
#[no_mangle]
#[wasm_error]
pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> Result<*mut u8> {
pub extern "C" fn propagate_modifiers(pixel_precision: u8) -> Result<*mut u8> {
let bytes = mem::bytes();
let entries: Vec<TransformEntry> = bytes
@@ -85,7 +85,8 @@ pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> Result<*mut u8>
.collect::<Result<Vec<_>>>()?;
with_state!(state, {
let result = shapes::propagate_modifiers(state, &entries, pixel_precision)?;
state.shapes.clear_transform_modifiers();
let result = shapes::propagate_modifiers(state, &entries, pixel_precision.into())?;
Ok(mem::write_vec(result))
})
}