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

This commit is contained in:
Andrey Antukh 2026-05-27 13:37:02 +02:00
commit 3858993a57
112 changed files with 4765 additions and 1264 deletions

1
.gitignore vendored
View File

@ -95,3 +95,4 @@
/.idea
/.claude
/.playwright-mcp
/tools/__pycache__

View File

@ -45,10 +45,17 @@ python3 tools/gh.py issues "2.16.0" --state all
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
```
**Exclusion rules:**
**Exclusion rules (issue-level):**
- `no changelog` label — Chore/refactor work that doesn't need a changelog entry
- `release blocker` label — Blocked issues not yet ready for changelog
- `Task` issue type — Internal chores are not user-facing; filter these out after fetching
**Exclusion rules (PR-level):**
In addition to issue-level exclusions, PRs with these labels should be
excluded regardless of their linked issue's labels:
- `release blocker` — PR is part of a pending release blocker batch
- `no issue required` — Trivial fix not tracked as an issue
The script outputs JSON with each entry containing `number`, `title`, `state`,
`issue_type`, `labels`, and `closing_prs` (the PRs that fix each issue).
@ -63,6 +70,10 @@ python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --c
This returns a filtered JSON array with only the missing issues.
> **Note:** The `--compare` flag checks **issues** only (via issue number
> references in the changelog). To find merged **PRs** not yet referenced,
> use the milestone PR cross-reference described in step 10 below.
### 4. Fetch additional PR details when needed
When you need more context for specific PRs (e.g. to find the PR author for
@ -80,9 +91,20 @@ python3 tools/gh.py prs --file prs.txt
cat prs.txt | python3 tools/gh.py prs --stdin
```
The `prs` command also supports listing all PRs in a milestone in one call:
```bash
# All merged PRs in a milestone (default)
python3 tools/gh.py prs --milestone "2.16.0"
# All states (merged, open, closed)
python3 tools/gh.py prs --milestone "2.16.0" --state all
```
The `prs` command returns JSON with `number`, `title`, `body`, `state`,
`merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in
batches of 50 via GraphQL to stay within API limits.
batches of 50 via GraphQL to stay within API limits (milestone mode uses
paginated GraphQL on the milestone's `pullRequests` connection).
### 5. Categorize entries — strictly by issue type, never by labels or emoji
@ -134,6 +156,37 @@ tracked in the milestone.
| PR exists with no linked issue | If a corresponding closed issue exists in the same milestone, link the issue. Otherwise, skip the entry (the issue must be the changelog unit). |
| Closed issue with no fix PR in milestone | Link the issue directly, without a PR reference. |
> **False-positive associations:** A PR may incorrectly claim to close an issue
> from a different context (e.g., a very old PR referencing a modern issue, or a
> cross-project reference). If the PR title and issue title are clearly unrelated,
> or the PR was created years before the issue, treat it as a data glitch and
> skip it. PR [#3](https://github.com/penpot/penpot/pull/3) (ancient License PR
> claiming to close a plugin API issue) is a known example.
### 5a. ⚠️ Verify PR merge status before writing
A closed issue may list closing PRs that were **closed without merging**
(e.g., a community PR that was superseded by another). The changelog must
only reference **merged** PRs. Verify before writing:
```bash
# Collect all PR numbers from the candidate entries and check them
python3 tools/gh.py prs <ALL_PR_NUMBERS> | python3 -c "
import json, sys
for pr in json.load(sys.stdin):
if pr['state'] != 'MERGED':
print(f'WARNING: #{pr[\"number\"]} is {pr[\"state\"]} (not merged)')
"
```
If a closing PR is closed-unmerged, find the actual merged PR that
superseded it:
1. Check the issue's closing PRs list for other PRs (there may be multiple)
2. Look for other PRs with similar titles or descriptions referencing the same issue
3. Inspect the closed PR's conversation timeline for a pointer to the replacement
Replace the reference in the changelog entry with the correct merged PR number.
### 6. Read the current CHANGES.md
Read the top of `CHANGES.md` to understand the existing format and find the
@ -198,6 +251,72 @@ Read the top of `CHANGES.md` and confirm:
- The section ordering is correct (newest first)
- Formatting matches the surrounding entries
### 10. Cross-reference milestone PRs against the changelog
Issues can be fixed by PRs that aren't in the milestone, and merged PRs in
the milestone may not close any tracked issue. After writing, run a full
cross-reference to catch gaps:
```bash
# List all merged PRs in the milestone
python3 tools/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
# Extract PR numbers from the changelog section
python3 -c "
import json, re
with open('CHANGES.md') as f:
content = f.read()
# Extract the version section (adjust regex to match the actual version)
match = re.search(r'## <MILESTONE> \(Unreleased\)\n(.*?)(?:\n## |\Z)', content, re.DOTALL)
section = match.group(1)
# Collect all PR numbers referenced
changelog_prs = set()
for m in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)', section):
changelog_prs.add(int(m))
# Collect all milestone PRs (filtered)
with open('/tmp/milestone-prs.json') as f:
milestone_prs = json.load(f)
milestone_merged = {pr['number'] for pr in milestone_prs}
# PRs in milestone but not in changelog
missing = sorted(milestone_merged - changelog_prs)
print(f'Milestone merged PRs: {len(milestone_merged)}')
print(f'Changelog referenced PRs: {len(changelog_prs)}')
print(f'PRs in milestone but NOT in changelog: {len(missing)}')
for num in missing:
pr = next(p for p in milestone_prs if p['number'] == num)
print(f' #{num} {pr[\"title\"][:80]}')
"
```
For each missing PR found, decide whether it should be added to the
changelog or is legitimately excluded (check its labels).
Also verify that no closed-unmerged PRs remain in the changelog:
```bash
python3 tools/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
import json, sys
data = json.load(sys.stdin)
closed = [p for p in data if p['state'] == 'CLOSED']
if closed:
print('WARNING: CLOSED (unmerged) PRs in milestone:')
for p in closed:
print(f' #{p[\"number\"]} {p[\"title\"][:80]}')
"
```
**Post-edit audit checklist:**
- ✅ All referenced PRs are merged (no closed-unmerged artifacts)
- ✅ Every merged milestone PR is either in the changelog or excluded by label
- ✅ PR and issue counts are internally consistent
- ✅ No false-positive PR-to-issue associations
## Version section template
```markdown
@ -244,3 +363,17 @@ Read the top of `CHANGES.md` and confirm:
- **Use `tools/gh.py`.** Prefer the helper script over raw `gh api` calls for
milestone issue listing and PR detail fetching. It handles GraphQL
pagination, batching, and label filtering automatically.
- **Verify PR merge status.** Not all closing PRs are merged — community PRs
can be superseded and closed without merging. Always check that every PR
referenced in the changelog has `state: MERGED`.
- **PR-level exclusions apply.** A PR can carry its own exclusion labels
(`release blocker`, `no issue required`) independent of its linked issue's
labels. Check both.
- **Cross-reference milestone PRs, not just issues.** The `--compare` flag on
the `issues` command only compares issue numbers. Merged PRs not linked to
any milestone issue can be missed. Use `python3 tools/gh.py prs --milestone`
for a full PR cross-reference.
- **False-positive PR-to-issue associations.** A PR may claim to close an
issue from a different project or context. If the PR title and issue title
are clearly unrelated, or the PR predates the issue by years, treat it as a
data glitch and skip it.

View File

@ -82,11 +82,11 @@
- Add clear artboard guides to frame context menu (by @eureka0928) [#6987](https://github.com/penpot/penpot/issues/6987) (PR: [#8936](https://github.com/penpot/penpot/pull/8936))
- Add loader feedback while importing and exporting files (by @moorsecopers99) [#9020](https://github.com/penpot/penpot/issues/9020) (PR: [#9024](https://github.com/penpot/penpot/pull/9024))
- Allow duplicating color and typography styles (by @MkDev11) [#2912](https://github.com/penpot/penpot/issues/2912) (PR: [#8449](https://github.com/penpot/penpot/pull/8449))
- Add woff2 support on user uploaded fonts (by @Nivl) [#3521](https://github.com/penpot/penpot/issues/3521) (PR: [#8248](https://github.com/penpot/penpot/pull/8248))
- Import Tokens from linked library (by @dfelinto) [#9635](https://github.com/penpot/penpot/issues/9635) (PR: [#8391](https://github.com/penpot/penpot/pull/8391))
- Option to download custom fonts (by @dfelinto) [#9672](https://github.com/penpot/penpot/issues/9672) (PR: [#8320](https://github.com/penpot/penpot/pull/8320))
- Add copy as image to workspace context menu (by @dfelinto) [#9607](https://github.com/penpot/penpot/issues/9607) (PR: [#8313](https://github.com/penpot/penpot/pull/8313))
- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [#2569](https://github.com/penpot/penpot/issues/2569) (PR: [#8474](https://github.com/penpot/penpot/pull/8474))
- Add woff2 support on user uploaded fonts (by @Nivl) [#3521](https://github.com/penpot/penpot/issues/3521) (PR: [#8367](https://github.com/penpot/penpot/pull/8367))
- Import Tokens from linked library (by @dfelinto) [#9635](https://github.com/penpot/penpot/issues/9635) (PR: [#8439](https://github.com/penpot/penpot/pull/8439))
- Option to download custom fonts (by @dfelinto) [#9672](https://github.com/penpot/penpot/issues/9672) (PR: [#8335](https://github.com/penpot/penpot/pull/8335))
- Add copy as image to workspace context menu (by @dfelinto) [#9607](https://github.com/penpot/penpot/issues/9607) (PR: [#8364](https://github.com/penpot/penpot/pull/8364), [#9586](https://github.com/penpot/penpot/pull/9586))
- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [#2569](https://github.com/penpot/penpot/issues/2569) (PR: [#8506](https://github.com/penpot/penpot/pull/8506))
- Copy and paste entire rows in existing table (by @bittoby) [#5969](https://github.com/penpot/penpot/issues/5969) (PR: [#8498](https://github.com/penpot/penpot/pull/8498))
- Rename token group [#9637](https://github.com/penpot/penpot/issues/9637) (PR: [#8275](https://github.com/penpot/penpot/pull/8275))
- Duplicate token group [#9638](https://github.com/penpot/penpot/issues/9638) (PR: [#8886](https://github.com/penpot/penpot/pull/8886))
@ -94,7 +94,7 @@
- Add drag-to-change for numeric inputs in workspace sidebar (by @RenzoMXD) [#2466](https://github.com/penpot/penpot/issues/2466) (PR: [#8536](https://github.com/penpot/penpot/pull/8536))
- Add CSS linter [#9636](https://github.com/penpot/penpot/issues/9636) (PR: [#8592](https://github.com/penpot/penpot/pull/8592))
- Add per-group add button for typographies (by @eureka0928) [#5275](https://github.com/penpot/penpot/issues/5275) (PR: [#8895](https://github.com/penpot/penpot/pull/8895))
- Add Find & Replace for text content and layer names (by @statxc) [#7108](https://github.com/penpot/penpot/issues/7108) (PR: [#8899](https://github.com/penpot/penpot/pull/8899))
- Add Find & Replace for text content and layer names (by @statxc) [#7108](https://github.com/penpot/penpot/issues/7108) (PR: [#8899](https://github.com/penpot/penpot/pull/8899), [#9687](https://github.com/penpot/penpot/pull/9687))
- Use page name for multi-export ZIP/PDF downloads (by @Dexterity104) [#8773](https://github.com/penpot/penpot/issues/8773) (PR: [#8874](https://github.com/penpot/penpot/pull/8874))
- Make links in comments clickable (by @eureka0928) [#1602](https://github.com/penpot/penpot/issues/1602) (PR: [#8894](https://github.com/penpot/penpot/pull/8894))
- Add visibility toggle for strokes (by @eureka0928) [#7438](https://github.com/penpot/penpot/issues/7438) (PR: [#8913](https://github.com/penpot/penpot/pull/8913))
@ -115,7 +115,7 @@
- Add HEX/HSB/HSL support to color picker with persistent model switcher (by @edwin-rivera-dev) [#9133](https://github.com/penpot/penpot/issues/9133) (PR: [#9134](https://github.com/penpot/penpot/pull/9134))
- Show specific invitation-link error messages (by @niwinz) [#9220](https://github.com/penpot/penpot/issues/9220) (PR: [#9223](https://github.com/penpot/penpot/pull/9223))
- Show detailed file import error messages (by @jsdevninja) [#8212](https://github.com/penpot/penpot/issues/8212) (PR: [#9004](https://github.com/penpot/penpot/pull/9004))
- Add read-only preview mode for saved versions (by @wdeveloper16) [#7622](https://github.com/penpot/penpot/issues/7622) (PR: [#8976](https://github.com/penpot/penpot/pull/8976))
- Add read-only preview mode for saved versions (by @wdeveloper16) [#7622](https://github.com/penpot/penpot/issues/7622) (PR: [#8976](https://github.com/penpot/penpot/pull/8976), [#9514](https://github.com/penpot/penpot/pull/9514))
- Add clipboard read/write permissions to the plugin system (by @wdeveloper16) [#6980](https://github.com/penpot/penpot/issues/6980) (PR: [#9053](https://github.com/penpot/penpot/pull/9053))
- Update auth hero illustration on login screen [#9532](https://github.com/penpot/penpot/issues/9532) (PR: [#9552](https://github.com/penpot/penpot/pull/9552))
- Update Open Graph link preview metadata [#9555](https://github.com/penpot/penpot/issues/9555) (PR: [#9557](https://github.com/penpot/penpot/pull/9557))
@ -123,6 +123,9 @@
- Preserve Inkscape labels when pasting SVGs (by @jeffrey701) [#7869](https://github.com/penpot/penpot/issues/7869) (PR: [#9252](https://github.com/penpot/penpot/pull/9252))
- Add Alt+click to expand layer subtree (by @MilosM348) [#7736](https://github.com/penpot/penpot/issues/7736) (PR: [#9179](https://github.com/penpot/penpot/pull/9179))
- Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068))
- Clarify self-hosted OIDC configuration for containerized (by @sancfc) [#9764](https://github.com/penpot/penpot/issues/9764) (PR: [#9758](https://github.com/penpot/penpot/pull/9758))
- Update User Guide with 2.16 features (by @myfunnyandy) [#9767](https://github.com/penpot/penpot/issues/9767) (PR: [#9768](https://github.com/penpot/penpot/pull/9768))
- Improve file validation performance and fix orphan shape detection [#9790](https://github.com/penpot/penpot/issues/9790) (PR: [#9789](https://github.com/penpot/penpot/pull/9789))
### :bug: Bugs fixed
@ -132,7 +135,7 @@
- Add natural sorting on token names [#8635](https://github.com/penpot/penpot/issues/8635) (PR: [#8672](https://github.com/penpot/penpot/pull/8672))
- Fix warnings for unsupported token $type (by @Dexterity104) [#8790](https://github.com/penpot/penpot/issues/8790) (PR: [#8873](https://github.com/penpot/penpot/pull/8873))
- Apply styles to selection (by @AzazelN28) [#9661](https://github.com/penpot/penpot/issues/9661) (PR: [#8625](https://github.com/penpot/penpot/pull/8625))
- Fix Alt/Option to draw shapes from center point (by @offreal) [#8360](https://github.com/penpot/penpot/issues/8360) (PR: [#8361](https://github.com/penpot/penpot/pull/8361))
- Fix Alt/Option to draw shapes from center point (by @offreal) [#8360](https://github.com/penpot/penpot/issues/8360) (PR: [#8381](https://github.com/penpot/penpot/pull/8381))
- Fix library update button freezing [#9330](https://github.com/penpot/penpot/issues/9330) (PR: [#9513](https://github.com/penpot/penpot/pull/9513))
- Fix typo in subscription settings success key (by @jack-stormentswe) [#9203](https://github.com/penpot/penpot/issues/9203) (PR: [#9204](https://github.com/penpot/penpot/pull/9204))
- Add token name on broken token pill on sidebar [#9534](https://github.com/penpot/penpot/issues/9534) (PR: [#8527](https://github.com/penpot/penpot/pull/8527))
@ -200,8 +203,16 @@
- Fix resize cursor appearing on login and register buttons [#9505](https://github.com/penpot/penpot/issues/9505) (PR: [#9590](https://github.com/penpot/penpot/pull/9590))
- Fix version restore restoring first previewed version instead of selected one [#9588](https://github.com/penpot/penpot/issues/9588) (PR: [#9626](https://github.com/penpot/penpot/pull/9626))
- Fix incorrect error message when applying tokens while editing text [#9620](https://github.com/penpot/penpot/issues/9620) (PR: [#9708](https://github.com/penpot/penpot/pull/9708))
- Fix standalone tokens ordering separated from token groups [#9733](https://github.com/penpot/penpot/issues/9733) (PR: [#9736](https://github.com/penpot/penpot/pull/9736))
- Fix delete invitation modal readability in light theme [#9737](https://github.com/penpot/penpot/issues/9737) (PR: [#9747](https://github.com/penpot/penpot/pull/9747))
- Fix team invitation not automatically accepted after account validation [#9776](https://github.com/penpot/penpot/issues/9776) (PR: [#9782](https://github.com/penpot/penpot/pull/9782))
## 2.15.4 (Unreleased)
## 2.15.4
### :sparkles: New features & Enhancements
- Add rate limiting and concurrency safety for file snapshot operations [#9723](https://github.com/penpot/penpot/issues/9723) (PR: [#9722](https://github.com/penpot/penpot/pull/9722))
### :bug: Bugs fixed
@ -210,6 +221,7 @@
- Fix API doc endpoint returning HTML as text/plain [#9680](https://github.com/penpot/penpot/issues/9680) (PR: [#9681](https://github.com/penpot/penpot/pull/9681))
- Fix unexpected error when opening the export dialog [#9721](https://github.com/penpot/penpot/issues/9721) (PR: [#9704](https://github.com/penpot/penpot/pull/9704))
## 2.15.3
### :bug: Bugs fixed

View File

@ -19,7 +19,7 @@
{:permits 40}
:root/by-profile
{:permits 10}
{:permits 10 :queue 30 :timeout 30000}
:file-thumbnail-ops/global
{:permits 20}
@ -27,4 +27,16 @@
{:permits 2}
:submit-audit-events/by-profile
{:permits 1 :queue 3}}
{:permits 1 :queue 3}
:restore-file-snapshot/global
{:permits 3}
:restore-file-snapshot/by-profile
{:permits 1 :queue 2 :timeout 60000}
:create-file-snapshot/global
{:permits 3}
:create-file-snapshot/by-profile
{:permits 1 :queue 2 :timeout 60000}}

View File

@ -27,7 +27,9 @@
[next.jdbc.transaction])
(:import
com.zaxxer.hikari.HikariConfig
com.zaxxer.hikari.HikariConfigMXBean
com.zaxxer.hikari.HikariDataSource
com.zaxxer.hikari.HikariPoolMXBean
com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory
io.whitfin.siphash.SipHasher
io.whitfin.siphash.SipHasherContainer
@ -67,9 +69,8 @@
(def defaults
{::name :main
::min-size 0
::max-size 60
::connection-timeout 10000
::connection-timeout 30000
::validation-timeout 10000
::idle-timeout 120000 ; 2min
::max-lifetime 1800000 ; 30m
@ -82,7 +83,7 @@
(defmethod ig/init-key ::pool
[_ cfg]
(let [{:keys [::uri ::read-only] :as cfg}
(merge defaults cfg)]
(merge defaults (d/without-nils cfg))]
(when uri
(l/info :hint "initialize connection pool"
:name (d/name (::name cfg))
@ -90,7 +91,8 @@
:read-only read-only
:credentials (and (contains? cfg ::username)
(contains? cfg ::password))
:min-size (::min-size cfg)
:min-size (or (::min-size cfg)
(::max-size cfg))
:max-size (::max-size cfg))
(create-pool cfg))))
@ -111,7 +113,9 @@
[{:keys [::uri] :as cfg}]
;; (app.common.pprint/pprint cfg)
(let [config (HikariConfig.)]
(let [config (HikariConfig.)
max-size (::max-size cfg)
min-size (or (::min-size cfg) max-size)]
(doto config
(.setJdbcUrl (str "jdbc:" uri))
(.setPoolName (d/name (::name cfg)))
@ -121,8 +125,8 @@
(.setValidationTimeout (::validation-timeout cfg))
(.setIdleTimeout (::idle-timeout cfg))
(.setMaxLifetime (::max-lifetime cfg))
(.setMinimumIdle (::min-size cfg))
(.setMaximumPoolSize (::max-size cfg))
(.setMinimumIdle min-size)
(.setMaximumPoolSize max-size)
(.setConnectionInitSql initsql)
(.setInitializationFailTimeout -1))
@ -180,6 +184,20 @@
:code :invalid-connection
:hint "invalid connection provided")))
(defn pool-stats
"Given a HikariDataSource instance, returns a map with current pool
statistics: active/idle connections, threads awaiting connection,
total connections, maximum pool size, and minimum idle connections."
[^HikariDataSource pool]
(let [^HikariPoolMXBean pool-mxbean (.getHikariPoolMXBean pool)
^HikariConfigMXBean cfg-mxbean (.getHikariConfigMXBean pool)]
{:active-connections (.getActiveConnections pool-mxbean)
:idle-connections (.getIdleConnections pool-mxbean)
:threads-awaiting-connection (.getThreadsAwaitingConnection pool-mxbean)
:total-connections (.getTotalConnections pool-mxbean)
:maximum-pool-size (.getMaximumPoolSize cfg-mxbean)
:minimum-idle (.getMinimumIdle cfg-mxbean)}))
(defn create-pool
[cfg]
(let [dsc (create-datasource-config cfg)]

View File

@ -66,11 +66,6 @@
LEFT JOIN file_data AS fd ON (fd.file_id = f.id AND fd.id = f.id)
WHERE f.id = ?")
(defn- get-minimal-file
[cfg id & {:as opts}]
(-> (db/get-with-sql cfg [sql:get-minimal-file id] opts)
(d/update-when :metadata fdata/decode-metadata)))
(def ^:private sql:get-snapshot-without-data
(str "WITH snapshots AS (" sql:snapshots ")"
"SELECT c.id,
@ -112,7 +107,7 @@
THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz)
END"))
(defn get-snapshot-data
(defn get-snapshot
"Get a fully decoded snapshot for read-only preview or restoration.
Returns the snapshot map with decoded :data field."
[cfg file-id snapshot-id]
@ -320,79 +315,87 @@
(defn restore!
[{:keys [::db/conn] :as cfg} file-id snapshot-id]
(let [file (get-minimal-file conn file-id {::db/for-update true})
vern (rand-int Integer/MAX_VALUE)
(let [lock-sql (str sql:get-minimal-file " FOR UPDATE OF f SKIP LOCKED")
row (db/exec-one! conn [lock-sql file-id])]
storage
(sto/resolve cfg {::db/reuse-conn true})
(when-not row
(ex/raise :type :conflict
:code :file-locked
:hint "the file is currently locked by another operation, retry later"))
snapshot
(get-snapshot-data cfg file-id snapshot-id)]
(let [file (d/update-when row :metadata fdata/decode-metadata)
vern (rand-int Integer/MAX_VALUE)
(when-not snapshot
(ex/raise :type :not-found
:code :snapshot-not-found
:hint "unable to find snapshot with the provided label"
:snapshot-id snapshot-id
:file-id file-id))
storage
(sto/resolve cfg {::db/reuse-conn true})
(when-not (:data snapshot)
(ex/raise :type :internal
:code :snapshot-without-data
:hint "snapshot has no data"
:label (:label snapshot)
:file-id file-id))
snapshot
(get-snapshot cfg file-id snapshot-id)]
(let [;; If the snapshot has applied migrations stored, we reuse
;; them, if not, we take a safest set of migrations as
;; starting point. This is because, at the time of
;; implementing snapshots, migrations were not taken into
;; account so we need to make this backward compatible in
;; some way.
migrations
(or (:migrations snapshot)
(fmg/generate-migrations-from-version 67))
(when-not snapshot
(ex/raise :type :not-found
:code :snapshot-not-found
:hint "unable to find snapshot with the provided label"
:snapshot-id snapshot-id
:file-id file-id))
file
(-> file
(update :revn inc)
(assoc :migrations migrations)
(assoc :data (:data snapshot))
(assoc :vern vern)
(assoc :version (:version snapshot))
(assoc :has-media-trimmed false)
(assoc :modified-at (:modified-at snapshot))
(assoc :features (:features snapshot)))]
(when-not (:data snapshot)
(ex/raise :type :internal
:code :snapshot-without-data
:hint "snapshot has no data"
:label (:label snapshot)
:file-id file-id))
(l/dbg :hint "restoring snapshot"
:file-id (str file-id)
:label (:label snapshot)
:snapshot-id (str (:id snapshot)))
(let [;; If the snapshot has applied migrations stored, we reuse
;; them, if not, we take a safest set of migrations as
;; starting point. This is because, at the time of
;; implementing snapshots, migrations were not taken into
;; account so we need to make this backward compatible in
;; some way.
migrations
(or (:migrations snapshot)
(fmg/generate-migrations-from-version 67))
;; In the same way, on reseting the file data, we need to restore
;; the applied migrations on the moment of taking the snapshot
(bfc/update-file! cfg file ::bfc/reset-migrations? true)
file
(-> file
(update :revn inc)
(assoc :migrations migrations)
(assoc :data (:data snapshot))
(assoc :vern vern)
(assoc :version (:version snapshot))
(assoc :has-media-trimmed false)
(assoc :modified-at (:modified-at snapshot))
(assoc :features (:features snapshot)))]
;; FIXME: this should be separated functions, we should not have
;; inline sql here.
(l/dbg :hint "restoring snapshot"
:file-id (str file-id)
:label (:label snapshot)
:snapshot-id (str (:id snapshot)))
;; clean object thumbnails
(let [sql (str "update file_tagged_object_thumbnail "
" set deleted_at = now() "
" where file_id=? returning media_id")
res (db/exec! conn [sql file-id])]
(doseq [media-id (into #{} (keep :media-id) res)]
(sto/touch-object! storage media-id)))
;; In the same way, on reseting the file data, we need to restore
;; the applied migrations on the moment of taking the snapshot
(bfc/update-file! cfg file ::bfc/reset-migrations? true)
;; clean file thumbnails
(let [sql (str "update file_thumbnail "
" set deleted_at = now() "
" where file_id=? returning media_id")
res (db/exec! conn [sql file-id])]
(doseq [media-id (into #{} (keep :media-id) res)]
(sto/touch-object! storage media-id)))
;; FIXME: this should be separated functions, we should not have
;; inline sql here.
vern)))
;; clean object thumbnails
(let [sql (str "update file_tagged_object_thumbnail "
" set deleted_at = now() "
" where file_id=? returning media_id")
res (db/exec! conn [sql file-id])]
(doseq [media-id (into #{} (keep :media-id) res)]
(sto/touch-object! storage media-id)))
;; clean file thumbnails
(let [sql (str "update file_thumbnail "
" set deleted_at = now() "
" where file_id=? returning media_id")
res (db/exec! conn [sql file-id])]
(doseq [media-id (into #{} (keep :media-id) res)]
(sto/touch-object! storage media-id)))
vern))))
(defn delete!
[cfg & {:keys [id file-id deleted-at]}]

View File

@ -154,8 +154,8 @@
::db/username (cf/get :database-username)
::db/password (cf/get :database-password)
::db/read-only (cf/get :database-readonly false)
::db/min-size (cf/get :database-min-pool-size 0)
::db/max-size (cf/get :database-max-pool-size 60)
::db/min-size (cf/get :database-min-pool-size)
::db/max-size (cf/get :database-max-pool-size)
::mtx/metrics (ig/ref ::mtx/metrics)}
;; Default netty IO pool (shared between several services)

View File

@ -18,6 +18,7 @@
[app.main :as-alias main]
[app.msgbus :as mbus]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
@ -54,7 +55,7 @@
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}]
(let [perms (bfc/get-file-permissions conn profile-id file-id)]
(files/check-read-permissions! perms)
(let [snapshot (fsnap/get-snapshot-data cfg file-id id)]
(let [snapshot (fsnap/get-snapshot cfg file-id id)]
(when-not snapshot
(ex/raise :type :not-found
:code :snapshot-not-found
@ -81,9 +82,10 @@
(sv/defmethod ::create-file-snapshot
{::doc/added "1.20"
::sm/params schema:create-file-snapshot
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id label]}]
(files/check-edition-permissions! conn profile-id file-id)
::climit/id [[:create-file-snapshot/by-profile ::rpc/profile-id]
[:create-file-snapshot/global]]}
[cfg {:keys [::rpc/profile-id file-id label]}]
(files/check-edition-permissions! cfg profile-id file-id)
(let [file (bfc/get-file cfg file-id :realize? true)
project (db/get-by-id cfg :project (:project-id file))]
@ -95,10 +97,10 @@
(quotes/check! {::quotes/id ::quotes/snapshots-per-file}
{::quotes/id ::quotes/snapshots-per-team}))
(fsnap/create! cfg file
{:label label
:profile-id profile-id
:created-by "user"})))
(db/tx-run! cfg fsnap/create! file
{:label label
:profile-id profile-id
:created-by "user"})))
(def ^:private schema:restore-file-snapshot
[:map {:title "restore-file-snapshot"}
@ -108,29 +110,43 @@
(sv/defmethod ::restore-file-snapshot
{::doc/added "1.20"
::sm/params schema:restore-file-snapshot
::db/transaction true}
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id id] :as params}]
(files/check-edition-permissions! conn profile-id file-id)
::climit/id [[:restore-file-snapshot/by-profile ::rpc/profile-id]
[:restore-file-snapshot/global]]}
[{:keys [::db/pool ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id id] :as params}]
;; Check permissions and read current file state (short-lived, outside restore transaction)
(files/check-edition-permissions! pool profile-id file-id)
(let [file (bfc/get-file cfg file-id)
team (teams/get-team conn
team (teams/get-team pool
:profile-id profile-id
:file-id file-id)
delay (ldel/get-deletion-delay team)]
delay (ldel/get-deletion-delay team)
file-revn (:revn file)]
;; Create backup snapshot of the current state (committed immediately
;; independently of the restore outcome)
(fsnap/create! cfg file
{:profile-id profile-id
:deleted-at (ct/in-future delay)
:created-by "system"})
(let [vern (fsnap/restore! cfg file-id id)]
;; Send to the clients a notification to reload the file
(mbus/pub! msgbus
:topic (:id file)
:message {:type :file-restored
:session-id session-id
:file-id (:id file)
:vern vern})
nil)))
;; Restore snapshot inside its own transaction; the revn check
;; ensures no data is lost if the file was edited concurrently
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [current (bfc/get-minimal-file conn file-id {::db/for-update true})]
(when (not= (:revn current) file-revn)
(ex/raise :type :conflict
:code :file-modified
:hint "the file was modified during the restore process, please retry")))
(let [vern (fsnap/restore! cfg file-id id)]
(mbus/pub! msgbus
:topic (:id file)
:message {:type :file-restored
:session-id session-id
:file-id (:id file)
:vern vern})
nil)))))
(def ^:private schema:update-file-snapshot
[:map {:title "update-file-snapshot"}

View File

@ -101,6 +101,17 @@
:mod-typography
:del-typography})
(def ^:private token-change-types
#{:set-tokens-lib
:set-token
:set-token-set
:set-token-theme
:set-active-token-themes
:rename-token-set-group
:move-token-set
:move-token-set-group
:set-base-font-size})
(def ^:private file-change-types
#{:add-obj
:mod-obj
@ -111,6 +122,7 @@
(defn- library-change?
[{:keys [type] :as change}]
(or (contains? library-change-types type)
(contains? token-change-types type)
(contains? file-change-types type)))
;; If features are specified from params and the final feature
@ -367,19 +379,16 @@
(l/error :hint "file schema validation error" :cause cause))))
(defn- soft-validate-file!
[file libs]
[file libs changes]
(try
(val/validate-file! file libs)
(val/validate-file-affected! file libs changes)
(catch Throwable cause
(l/error :hint "file validation error"
:cause cause))))
(defn- process-changes-and-validate
[cfg file changes skip-validate]
(let [;; WARNING: this ruins performance; maybe we need to find
;; some other way to do general validation
libs
(let [libs
(when (and (or (contains? cf/flags :file-validation)
(contains? cf/flags :soft-file-validation))
(not skip-validate))
@ -407,14 +416,14 @@
(binding [pmap/*tracked* nil]
(when (contains? cf/flags :soft-file-validation)
(soft-validate-file! file libs))
(soft-validate-file! file libs changes))
(when (contains? cf/flags :soft-file-schema-validation)
(soft-validate-file-schema! file))
(when (and (contains? cf/flags :file-validation)
(not skip-validate))
(val/validate-file! file libs))
(val/validate-file-affected! file libs changes))
(when (and (contains? cf/flags :file-schema-validation)
(not skip-validate))

View File

@ -0,0 +1,43 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns backend-tests.db-test
(:require
[app.db :as db]
[backend-tests.helpers :as th]
[clojure.test :as t])
(:import
com.zaxxer.hikari.HikariConfig
com.zaxxer.hikari.HikariDataSource
java.sql.Connection))
(t/use-fixtures :once th/state-init)
(t/deftest pool-stats-returns-expected-keys
(let [stats (db/pool-stats th/*pool*)]
(t/testing "all expected keys are present"
(t/is (contains? stats :active-connections))
(t/is (contains? stats :idle-connections))
(t/is (contains? stats :threads-awaiting-connection))
(t/is (contains? stats :total-connections))
(t/is (contains? stats :maximum-pool-size))
(t/is (contains? stats :minimum-idle)))
(t/testing "values are non-negative integers"
(t/is (>= (:active-connections stats) 0))
(t/is (>= (:idle-connections stats) 0))
(t/is (>= (:threads-awaiting-connection stats) 0))
(t/is (>= (:total-connections stats) 0))
(t/is (>= (:maximum-pool-size stats) 0))
(t/is (>= (:minimum-idle stats) 0)))
(t/testing "total connections equals active + idle"
(t/is (= (:total-connections stats)
(+ (:active-connections stats)
(:idle-connections stats)))))
(t/testing "maximum pool size is reasonable"
(t/is (pos? (:maximum-pool-size stats))))))

View File

@ -38,31 +38,61 @@
(dissoc component :objects))
component)))))
(defn normalize-component-root
"Some old files have shapes with an explicit :component-root false. This is semantically
equivalent to the attribute being absent (instance-root? only treats true as root), but
breaks the subcopy-head? predicate, which expects nil. Remove the explicit false so the
downstream fixers can recognize these shapes as nested copy heads."
[file-data]
(try
(ctf/update-all-shapes
file-data
(fn [shape]
(if (false? (:component-root shape))
(do
(log/warn :msg "Normalizing :component-root false on shape"
:shape-id (:id shape)
:shape-name (:name shape)
:file-id (:id file-data))
{:result :update :updated-shape (dissoc shape :component-root)})
{:result :keep})))
(catch #?(:clj Throwable :cljs :default) e
(log/error :msg "Failed to normalize :component-root on shapes"
:file-id (:id file-data)
:cause e)
file-data)))
(defn fix-missing-swap-slots
"Locate shapes that have been swapped (i.e. their shape-ref does not point to the near match) but
they don't have a swap slot. In this case, add one pointing to the near match."
[file-data libraries]
(ctf/update-all-shapes
file-data
(fn [shape]
(if (ctk/subcopy-head? shape)
(let [container (:container (meta shape))
file {:id (:id file-data) :data file-data}
near-match (ctf/find-near-match file container libraries shape :include-deleted? true :with-context? false)]
(if (and (some? near-match)
(not= (:shape-ref shape) (:id near-match))
(nil? (ctk/get-swap-slot shape)))
(let [updated-shape (ctk/set-swap-slot shape (:id near-match))]
(log/warn :msg "Adding missing swap slot to shape"
:shape-id (:id shape)
:shape-name (:name shape)
:swap-slot (:id near-match)
:file-id (:id file)
:container-id (:id container)
:container-type (:type container))
{:result :update :updated-shape updated-shape})
{:result :keep}))
{:result :keep}))))
(try
(ctf/update-all-shapes
file-data
(fn [shape]
(if (ctk/subcopy-head? shape)
(let [container (:container (meta shape))
file {:id (:id file-data) :data file-data}
near-match (ctf/find-near-match file container libraries shape :include-deleted? true :with-context? false)]
(if (and (some? near-match)
(not= (:shape-ref shape) (:id near-match))
(nil? (ctk/get-swap-slot shape)))
(let [updated-shape (ctk/set-swap-slot shape (:id near-match))]
(log/warn :msg "Adding missing swap slot to shape"
:shape-id (:id shape)
:shape-name (:name shape)
:swap-slot (:id near-match)
:file-id (:id file)
:container-id (:id container)
:container-type (:type container))
{:result :update :updated-shape updated-shape})
{:result :keep}))
{:result :keep})))
(catch #?(:clj Throwable :cljs :default) e
(log/error :msg "Failed to fix missing swap slots on shapes"
:file-id (:id file-data)
:cause e)
file-data)))
(defn sync-component-id-with-ref-shape
"Ensure that all copies heads have the same component id and file as the referenced shape.
@ -70,40 +100,46 @@
[file-data libraries]
(letfn [(sync-one-iteration
[file-data libraries]
(ctf/update-all-shapes
file-data
(fn [shape]
(if (and (ctk/subcopy-head? shape) (nil? (ctk/get-swap-slot shape)))
(let [container (:container (meta shape))
file {:id (:id file-data) :data file-data}
ref-shape (ctf/find-ref-shape file container libraries shape {:include-deleted? true :with-context? true})]
(if (and (some? ref-shape)
(or (not= (:component-id shape) (:component-id ref-shape))
(not= (:component-file shape) (:component-file ref-shape))))
(let [shape' (cond-> shape
(some? (:component-id ref-shape))
(assoc :component-id (:component-id ref-shape))
(try
(ctf/update-all-shapes
file-data
(fn [shape]
(if (and (ctk/subcopy-head? shape) (nil? (ctk/get-swap-slot shape)))
(let [container (:container (meta shape))
file {:id (:id file-data) :data file-data}
ref-shape (ctf/find-ref-shape file container libraries shape {:include-deleted? true :with-context? true})]
(if (and (some? ref-shape)
(or (not= (:component-id shape) (:component-id ref-shape))
(not= (:component-file shape) (:component-file ref-shape))))
(let [shape' (cond-> shape
(some? (:component-id ref-shape))
(assoc :component-id (:component-id ref-shape))
(nil? (:component-id ref-shape))
(dissoc :component-id)
(nil? (:component-id ref-shape))
(dissoc :component-id)
(some? (:component-file ref-shape))
(assoc :component-file (:component-file ref-shape))
(some? (:component-file ref-shape))
(assoc :component-file (:component-file ref-shape))
(nil? (:component-file ref-shape))
(dissoc :component-file))]
(log/warn :msg "Syncing component id and file with ref shape"
:shape-id (:id shape)
:shape-name (:name shape)
:component-id (:component-id shape')
:component-file (:component-file shape')
:ref-shape-id (:id ref-shape)
:file-id (:id file)
:container-id (:id container)
:container-type (:type container))
{:result :update :updated-shape shape'})
{:result :keep}))
{:result :keep}))))]
(nil? (:component-file ref-shape))
(dissoc :component-file))]
(log/warn :msg "Syncing component id and file with ref shape"
:shape-id (:id shape)
:shape-name (:name shape)
:component-id (:component-id shape')
:component-file (:component-file shape')
:ref-shape-id (:id ref-shape)
:file-id (:id file)
:container-id (:id container)
:container-type (:type container))
{:result :update :updated-shape shape'})
{:result :keep}))
{:result :keep})))
(catch #?(:clj Throwable :cljs :default) e
(log/error :msg "Failed to sync component id and file with ref shape"
:file-id (:id file-data)
:cause e)
file-data)))]
;; If a copy inside a main is updated, we need to repeat the process for the change to be
;; propagated to all copies.
(loop [current-data file-data

View File

@ -1812,6 +1812,34 @@
(ctob/fix-conflicting-token-names)
(ctob/fix-missing-sets-in-themes))))
(defmethod migrate-data "0021-fix-shape-svg-attrs"
[data _]
(some-> cfeat/*new* (swap! conj "fdata/shape-data-type"))
(letfn [(update-object [object]
(-> object
(d/update-when :svg-attrs csvg/attrs->props)
(d/update-when :svg-viewbox grc/make-rect)))
(update-container [container]
(d/update-when container :objects d/update-vals update-object))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
;; Re-run the 0019 and 0020 fixers after normalizing :component-root.
;; Migrations 0019 and 0020 missed shapes with an explicit :component-root
;; false because subcopy-head? expects nil. Normalize first, then re-run.
(defmethod migrate-data "0022-normalize-component-root-and-resync"
[data _]
(let [libraries (if (:libs data)
(deref (:libs data))
{})]
(-> data
(cfcp/normalize-component-root)
(cfcp/fix-missing-swap-slots libraries)
(cfcp/sync-component-id-with-ref-shape libraries))))
(def available-migrations
(into (d/ordered-set)
["legacy-2"
@ -1890,4 +1918,6 @@
"0018-remove-unneeded-objects-from-components"
"0019-fix-missing-swap-slots"
"0020-sync-component-id-with-near-main"
"0021-repair-bad-tokens"]))
"0021-repair-bad-tokens"
"0021-fix-shape-svg-attrs"
"0022-normalize-component-root-and-resync"]))

View File

@ -644,9 +644,10 @@
(fn [shape]
;; Set the desired swap slot
(let [slot (:swap-slot args)]
(when (some? slot)
(log/debug :hint (str " -> set swap-slot to " slot))
(ctk/set-swap-slot shape slot))))]
(if (some? slot)
(do (log/debug :hint (str " -> set swap-slot to " slot))
(ctk/set-swap-slot shape slot))
shape)))]
(log/dbg :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)

View File

@ -17,7 +17,6 @@
[app.common.types.components-list :as ctkl]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.pages-list :as ctpl]
[app.common.types.shape-tree :as ctst]
[app.common.types.variant :as ctv]
[app.common.uuid :as uuid]
@ -94,6 +93,52 @@
(def ^:dynamic ^:private *errors* nil)
;; Per-page volatile map used to memoize `ctf/find-ref-shape` calls during a
;; single validation pass. Keys are shape-ids; values are the returned ref-shape
;; (or `nil` when the shape has no ref). The cache is reset once per page so
;; that stale results never cross page boundaries.
(def ^:dynamic ^:private *ref-shape-cache* nil)
;; Per-page pre-computed map from parent-id to the set of its children ids.
;; Enables O(1) containment checks in `check-parent-children` instead of the
;; default O(k) linear `(some #(= shape-id %) (:shapes parent))` scan.
;; Bound as a plain immutable map (not a volatile) since it is read-only during
;; a page's validation pass.
(def ^:dynamic ^:private *children-sets* nil)
(defn- build-children-sets
"Return a {parent-id → #{child-ids}} map built from `objects` in a single
`reduce-kv` pass. Only shapes that have at least one child get an entry."
[objects]
(reduce-kv (fn [m _ shape]
(if-let [kids (not-empty (:shapes shape))]
(assoc m (:id shape) (set kids))
m))
{}
objects))
(defn- find-ref-shape*
"Cached wrapper around `ctf/find-ref-shape` with `:include-deleted? true`.
When `*ref-shape-cache*` is bound, each shape-id is resolved at most once
per validation pass regardless of how many check functions request it.
Cache miss detection uses `contains?` rather than a sentinel default
(e.g. `(get cache id ::miss)` + `identical?`). The reason: in
ClojureScript, `identical?` on namespace-qualified keywords is not
reliable across compilation units because keyword interning is not
guaranteed — a sentinel retrieved from `get` may not be `===` to the
same literal written in code, causing every lookup to appear as a miss
and breaking the whole optimisation."
[file page libraries shape]
(if-let [cache *ref-shape-cache*]
(let [id (:id shape)]
(if (contains? @cache id)
(get @cache id)
(let [result (ctf/find-ref-shape file page libraries shape :include-deleted? true)]
(vswap! cache assoc id result)
result)))
(ctf/find-ref-shape file page libraries shape :include-deleted? true)))
(defn- library-exists?
[file libraries shape]
(or (= (:component-file shape) (:id file))
@ -149,15 +194,32 @@
shape file page)
(do
(when-not (cfh/root? shape)
(when-not (some #(= shape-id %) (:shapes parent))
;; Fast path: O(1) set lookup when `*children-sets*` is pre-computed
;; for the current page. Falls back to an O(k) linear scan via
;; `some` when the var is unbound (e.g. in isolated single-shape
;; validation calls outside a full page pass).
(when-not (if-let [cs *children-sets*]
(contains? (get cs (:id parent)) shape-id)
(some #(= shape-id %) (:shapes parent)))
(report-error :child-not-in-parent
(str/ffmt "Shape % not in parent's children list" shape-id)
shape file page)))
(when-not (= (count shapes) (count (distinct shapes)))
(report-error :duplicated-children
(str/ffmt "Shape % has duplicated children" shape-id)
shape file page))
;; Single-pass duplicate detection: walk the children list once,
;; accumulating seen IDs into a set and short-circuiting on the
;; first duplicate. This replaces the previous two-pass
;; `(not= (count shapes) (count (distinct shapes)))` approach
;; which allocated an intermediate sequence and scanned twice.
(let [dup? (reduce (fn [seen id]
(if (contains? seen id)
(reduced true)
(conj seen id)))
#{}
shapes)]
(when (true? dup?)
(report-error :duplicated-children
(str/ffmt "Shape % has duplicated children" shape-id)
shape file page)))
(doseq [child-id shapes]
(let [child (ctst/get-shape page child-id)]
@ -292,7 +354,7 @@
[shape file page libraries]
(let [library-exists (library-exists? file libraries shape)
ref-shape (when library-exists
(ctf/find-ref-shape file page libraries shape :include-deleted? true))]
(find-ref-shape* file page libraries shape))]
(when (and library-exists (nil? ref-shape))
(report-error :ref-shape-not-found
(str/ffmt "Referenced shape % not found in near component" (:shape-ref shape))
@ -309,7 +371,7 @@
(defn- check-ref-is-not-head
"Validate that the referenced shape is not a nested copy root."
[shape file page libraries]
(let [ref-shape (ctf/find-ref-shape file page libraries shape :include-deleted? true)]
(let [ref-shape (find-ref-shape* file page libraries shape)]
(when (and (some? ref-shape)
(ctk/instance-head? ref-shape))
(report-error :ref-shape-is-head
@ -319,7 +381,7 @@
(defn- check-ref-is-head
"Validate that the referenced shape is a nested copy root."
[shape file page libraries]
(let [ref-shape (ctf/find-ref-shape file page libraries shape :include-deleted? true)]
(let [ref-shape (find-ref-shape* file page libraries shape)]
(when (and (some? ref-shape)
(not (ctk/instance-head? ref-shape)))
(report-error :ref-shape-is-not-head
@ -333,7 +395,7 @@
the same as in the referenced shape in the near main."
[shape file page libraries]
(when (nil? (ctk/get-swap-slot shape))
(when-let [ref-shape (ctf/find-ref-shape file page libraries shape :include-deleted? true)]
(when-let [ref-shape (find-ref-shape* file page libraries shape)]
(when (or (not= (:component-id shape) (:component-id ref-shape))
(not= (:component-file shape) (:component-file ref-shape)))
(report-error :component-id-mismatch
@ -351,12 +413,21 @@
shape file page)))
(defn- has-duplicate-swap-slot?
"Returns true if any two children of `shape` share the same swap slot.
Uses a single pass with early exit on the first duplicate, avoiding
the intermediate `frequencies` map allocation."
[shape container]
(let [shapes (map #(get (:objects container) %) (:shapes shape))
slots (->> (map #(ctk/get-swap-slot %) shapes)
(remove nil?))
counts (frequencies slots)]
(some (fn [[_ count]] (> count 1)) counts)))
(let [objects (:objects container)
result (reduce (fn [seen child-id]
(let [slot (ctk/get-swap-slot (get objects child-id))]
(if (nil? slot)
seen
(if (contains? seen slot)
(reduced true)
(conj seen slot)))))
#{}
(:shapes shape))]
(true? result)))
(defn- check-duplicate-swap-slot
"Validate that the children of this shape does not have duplicated slots."
@ -370,14 +441,16 @@
"Validate that the shape has swap-slot if it's a subinstance head and the ref shape is not the
matching shape by position in the near main."
[shape file page libraries]
(let [near-match (ctf/find-near-match file page libraries shape :include-deleted? true :with-context? false)]
(when (and (some? near-match)
(not= (:shape-ref shape) (:id near-match))
(nil? (ctk/get-swap-slot shape)))
(report-error :missing-slot
"Shape has been swapped, should have swap slot"
shape file page
:swap-slot (or (ctk/get-swap-slot near-match) (:id near-match))))))
;; Guard first: if the shape already has a swap slot the invariant is satisfied
;; and we can avoid the expensive `find-near-match` call entirely.
(when (nil? (ctk/get-swap-slot shape))
(let [near-match (ctf/find-near-match file page libraries shape :include-deleted? true :with-context? false)]
(when (and (some? near-match)
(not= (:shape-ref shape) (:id near-match)))
(report-error :missing-slot
"Shape has been swapped, should have swap slot"
shape file page
:swap-slot (or (ctk/get-swap-slot near-match) (:id near-match)))))))
(defn- check-valid-touched
"Validate that the text touched flags are coherent."
@ -491,26 +564,28 @@
-all its children should be variants with variant-id equals to the shape-id
-all the components should have the same properties"
[shape file page]
(let [shape-id (:id shape)
shapes (:shapes shape)
children (map #(ctst/get-shape page %) shapes)
prop-names (cfv/extract-properties-names (first children) (:data file))]
(doseq [child children]
(when child
(if (not (ctk/is-variant? child))
(report-error :not-a-variant
(str/ffmt "Shape % should be a variant" (:id child))
child file page)
(do
(when (not= (:variant-id child) shape-id)
(report-error :invalid-variant-id
(str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child))
child file page))
(when (not= prop-names (cfv/extract-properties-names child (:data file)))
(report-error :invalid-variant-properties
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
child file page))))))))
(let [shape-id (:id shape)
shapes (:shapes shape)
objects (:objects page)
file-data (:data file)
first-child (get objects (first shapes))
prop-names (cfv/extract-properties-names first-child file-data)]
(run! (fn [child-id]
(when-let [child (get objects child-id)]
(if (not (ctk/is-variant? child))
(report-error :not-a-variant
(str/ffmt "Shape % should be a variant" (:id child))
child file page)
(do
(when (not= (:variant-id child) shape-id)
(report-error :invalid-variant-id
(str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child))
child file page))
(when (not= prop-names (cfv/extract-properties-names child file-data))
(report-error :invalid-variant-properties
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
child file page))))))
shapes)))
(defn- check-variant
"Shape is a variant, so
-it should be a main component
@ -615,18 +690,26 @@
shape file page)
(check-shape-copy-not-root shape file page libraries))
(if (ctn/inside-component-main? (:objects page) shape)
(if-not (#{:main-top :main-nested :main-any} context)
(report-error :not-head-main-not-allowed
"Non-root main only allowed inside a main component"
shape file page)
(check-shape-main-not-root shape file page libraries))
;; Short-circuit `inside-component-main?` when the propagated
;; `context` already classifies this sub-tree as belonging to a
;; main component. `inside-component-main?` performs an O(depth)
;; upward ancestor walk; skipping it for every non-head shape that
;; sits inside a known-main context avoids redundant tree traversals
;; that would otherwise dominate validation time on deep hierarchies.
(let [in-main? (or (#{:main-top :main-nested :main-any} context)
(ctn/inside-component-main? (:objects page) shape))]
(if in-main?
(if-not (#{:main-top :main-nested :main-any} context)
(report-error :not-head-main-not-allowed
"Non-root main only allowed inside a main component"
shape file page)
(check-shape-main-not-root shape file page libraries))
(if (#{:main-top :main-nested :main-any} context)
(report-error :not-component-not-allowed
"Not compoments are not allowed inside a main"
shape file page)
(check-shape-not-component shape file page libraries))))))))
(if (#{:main-top :main-nested :main-any} context)
(report-error :not-component-not-allowed
"Not compoments are not allowed inside a main"
shape file page)
(check-shape-not-component shape file page libraries)))))))))
(defn check-component-duplicate-swap-slot
[component file]
@ -638,11 +721,13 @@
(defn check-ref-cycles
[component file]
(let [cycles-ids (->> component
:objects
vals
(filter #(= (:id %) (:shape-ref %)))
(map :id))]
(let [cycles-ids (-> (reduce-kv (fn [acc id shape]
(if (= id (:shape-ref shape))
(conj! acc id)
acc))
(transient [])
(:objects component))
(persistent!))]
(when (seq cycles-ids)
(report-error :shape-ref-cycle
@ -703,10 +788,25 @@
(check-variant-component component file)))
(defn- get-orphan-shapes
[{:keys [objects] :as page}]
(let [xf (comp (map #(contains? objects (:parent-id %)))
(map :id))]
(into [] xf (vals objects))))
"Return the ids of shapes whose parent does not exist in the objects
map (i.e. shapes unreachable from the root traversal). The root
shape itself is excluded since it is always validated separately.
Implemented with `reduce-kv` rather than a `map`/`filter` pipeline.
The previous implementation mapped over `objects` and returned a
lazy seq that could contain `nil` entries (for shapes that were
*not* orphans), meaning `check-shape` was called with `nil` IDs and
orphaned shapes were silently skipped. The `reduce-kv` approach
builds a plain vector of IDs and never yields `nil` entries."
[{:keys [objects] :as _page}]
(persistent!
(reduce-kv (fn [result id shape]
(if (and (not (cfh/root? shape))
(not (contains? objects (:parent-id shape))))
(conj! result id)
result))
(transient [])
objects)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC API: VALIDATION FUNCTIONS
@ -720,20 +820,39 @@
(when (contains? features "components/v2")
(binding [*errors* (volatile! [])]
(doseq [page (filter :id (ctpl/pages-seq data))]
(check-shape uuid/zero file page libraries)
(->> (get-orphan-shapes page)
(run! #(check-shape % file page libraries))))
;; `reduce-kv` is used throughout this function (and `validate-file-affected`)
;; instead of `(run! f (vals m))` or `(doseq [[k v] m])` because persistent
;; maps implement `IKVReduce`, which drives iteration internally without
;; first materialising an intermediate sequence of key-value pairs.
(reduce-kv
(fn [_ _ page]
(when (some? page)
;; Both performance caches are scoped to a single page: ref-shape
;; lookups and parent→children sets are page-local and must never
;; carry over to the next page. A fresh volatile is created for
;; each page so stale entries cannot corrupt subsequent pages.
(binding [*ref-shape-cache* (volatile! {})
*children-sets* (build-children-sets (:objects page))]
(check-shape uuid/zero file page libraries)
(run! #(check-shape % file page libraries)
(get-orphan-shapes page)))))
nil
(:pages-index data))
(->> (vals (:components data))
(run! #(check-component % file)))
(reduce-kv
(fn [_ _ component]
(check-component component file))
nil
(:components data))
(-> *errors* deref not-empty))))
(defn validate-shape
"Validate a shape and all its children. Returns a list of errors."
[shape-id file page libraries]
(binding [*errors* (volatile! [])]
(binding [*errors* (volatile! [])
*ref-shape-cache* (volatile! {})
*children-sets* (build-children-sets (:objects page))]
(check-shape shape-id file page libraries)
(deref *errors*)))
@ -744,6 +863,107 @@
(check-component component file)
(deref *errors*)))
(defn- extract-affected-ids
"Single reduce pass over a changes batch.
Returns {:page-ids #{uuid …} :component-ids #{uuid …}}.
Only entities that need re-validation after applying `changes` are
included. Deleted entities and pure file-level changes (colors,
tokens, typography…) produce no entries because there is nothing left
to check."
[changes]
(loop [changes (seq changes)
page-ids #{}
component-ids #{}]
(if-let [change (first changes)]
(let [{:keys [type page-id component-id id]} change
page-ids
(case type
;; Shape-level ops are scoped to either a page or a component
(:add-obj :mod-obj :del-obj :fix-obj :mov-objects :reorder-children :reg-objects)
(cond-> page-ids
page-id (conj page-id))
;; A new or modified page needs a full page sweep
(:add-page :mod-page)
(conj page-ids id)
;; restore-component resurrects a deleted component (touches the
;; component definition) and places its main instance on a page
;; (touches that page's shape tree)
:restore-component
(conj page-ids page-id)
;; Otherwise don't change the ids
page-ids)
component-ids
(case type
;; Shape-level ops are scoped to either a page or a component
(:add-obj :mod-obj :del-obj :fix-obj :mov-objects :reorder-children :reg-objects)
(cond-> component-ids
component-id (conj component-id))
;; A new, modified, restored component needs component-level checking
(:add-component :mod-component :restore-component)
(conj component-ids id)
;; Otherwise don't change the ids
component-ids)]
(recur (rest changes) page-ids component-ids))
;; Return result of accumulated ids
{:page-ids page-ids :component-ids component-ids})))
(defn validate-file-affected
"Validate only the pages and components touched by `changes`.
Semantics are identical to `validate-file` but the work is bounded
to the entities that were actually mutated, making it safe and cheap
to call on every incremental file update.
Returns a list of errors or `nil`."
[{:keys [data features] :as file} libraries changes]
(when (contains? features "components/v2")
(let [{:keys [page-ids component-ids]} (extract-affected-ids changes)]
(binding [*errors* (volatile! [])]
(reduce-kv
(fn [_ page-id page]
(when (and (some? page) (contains? page-ids page-id))
;; Both performance caches are scoped to a single page: ref-shape
;; lookups and parent→children sets are page-local and must never
;; carry over to the next page. A fresh volatile is created for
;; each page so stale entries cannot corrupt subsequent pages.
(binding [*ref-shape-cache* (volatile! {})
*children-sets* (build-children-sets (:objects page))]
(check-shape uuid/zero file page libraries)
(run! #(check-shape % file page libraries)
(get-orphan-shapes page)))))
nil
(:pages-index data))
(reduce-kv
(fn [_ id component]
(when (contains? component-ids id)
(check-component component file)))
nil
(:components data))
(-> *errors* deref not-empty)))))
(defn validate-file-affected!
"Like `validate-file-affected` but raises on the first non-empty
error list instead of returning it."
[file libraries changes]
(when-let [errors (validate-file-affected file libraries changes)]
(ex/raise :type :validation
:code :referential-integrity
:hint "error on validating file referential integrity"
:file-id (:id file)
:details errors)))
(defn validate-file-schema!
"Validates the file itself, without external dependencies, it
performs the schema checking and some semantical validation of the

View File

@ -2098,6 +2098,8 @@
For :selrect we compare width/height only;
for :points we normalise each vector so the first point is the
origin before comparing.
For :content on path shapes we compare the bounding-box width/height
of the path segments, again ignoring absolute position.
Comparisons use `mth/close?` (and `gpt/close?` for points) rather than
exact `=` because `previous-shape` here may carry sub-pixel drift from
@ -2118,8 +2120,17 @@
a (normalize-pts (get shape :points))
b (normalize-pts (get origin-shape :points))]
(and (= (count a) (count b))
(every? identity (map gpt/close? a b)))))))
(every? identity (map gpt/close? a b)))))
(and (= attr :content)
(cfh/path-shape? shape)
(let [ca (:content shape)
cb (:content origin-shape)]
(and (some? ca) (some? cb)
(let [selrect-a (segment/content->selrect ca)
selrect-b (segment/content->selrect cb)]
(and (some? selrect-a) (some? selrect-b)
(mth/close? (:width selrect-a) (:width selrect-b))
(mth/close? (:height selrect-a) (:height selrect-b)))))))))
(defn update-attrs-on-switch
"Copy attributes that have changed in the shape previous to the switch

View File

@ -269,10 +269,19 @@
[group]
(str/starts-with? (name group) "swap-slot-"))
(def ^:private xf:normal-touched
"Transducer that removes swap-slot touched groups."
(remove swap-slot?))
(defn normal-touched-groups
"Gets all touched groups that are not swap slots."
"Gets all touched groups that are not swap slots.
Returns an empty set immediately when `:touched` is nil or empty,
avoiding an unnecessary `into #{}` allocation for the common case."
[shape]
(into #{} (remove swap-slot? (:touched shape))))
(let [touched (:touched shape)]
(if (empty? touched)
#{}
(into #{} xf:normal-touched touched))))
(defn group->swap-slot
[group]

View File

@ -888,7 +888,7 @@
(t/is (not (d/editable-collection? "hello")))
(t/is (not (d/editable-collection? 42))))
(t/deftest num-predicate
(t/deftest num-predicate-2
(t/is (d/num? 1))
(t/is (d/num? 0))
(t/is (d/num? -3.14))

View File

@ -114,6 +114,84 @@
(t/is (= expected-diff diff)))))
(t/deftest test-normalize-component-root
(t/testing "nil file should return nil"
(let [file nil
file' (ctf/update-file-data file cfcp/normalize-component-root)]
(t/is (nil? file'))))
(t/testing "empty file should not need any action"
(let [file (thf/sample-file :file1)
file' (ctf/update-file-data file cfcp/normalize-component-root)]
(t/is (empty? (d/map-diff file file')))))
(t/testing "shape with :component-root true should not be modified"
(let [file
(-> (thf/sample-file :file1)
(tho/add-simple-component :component1 :main1-root :main1-child)
(thc/instantiate-component :component1 :copy1-root))
file' (ctf/update-file-data file cfcp/normalize-component-root)]
(t/is (empty? (d/map-diff file file')))))
(t/testing "shape with :component-root false should have it removed"
(let [file
(-> (thf/sample-file :file1)
(tho/add-nested-component-with-copy :component1 :main1-root :main1-child
:component2 :main2-root :nested-head
:copy2-root)
(ths/update-shape :nested-head :component-root false))
file' (ctf/update-file-data file cfcp/normalize-component-root)
shape' (ths/get-shape file' :nested-head)]
(t/is (not (contains? shape' :component-root))))))
(t/deftest test-migration-0022-fixes-component-root-false-mismatch
(t/testing "a nested copy with :component-root false and a stale :component-id is fixed by the full migration sequence"
(let [file
;; Reproduce the legacy corruption: a nested copy head has
;; :component-root false (instead of absent) AND a stale
;; component-id. Migrations 0019/0020 alone don't catch it
;; because subcopy-head? expects nil. After normalize-component-root,
;; sync-component-id-with-ref-shape can repair it.
(-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested-head)
(thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head])
(ths/update-shape :copy2-nested-head :component-root false)
(ths/update-shape :copy2-nested-head :component-id (thi/new-id! :wrong-id)))
;; Run the old sequence (0019/0020 without normalization): the bad shape is skipped.
old-sequence
(-> file
(ctf/update-file-data #(cfcp/fix-missing-swap-slots % {}))
(ctf/update-file-data #(cfcp/sync-component-id-with-ref-shape % {})))
old-shape (ths/get-shape old-sequence :copy2-nested-head)
;; Run the new sequence (0022): normalize first, then 0019/0020 logic.
new-sequence
(-> file
(ctf/update-file-data cfcp/normalize-component-root)
(ctf/update-file-data #(cfcp/fix-missing-swap-slots % {}))
(ctf/update-file-data #(cfcp/sync-component-id-with-ref-shape % {})))
new-shape (ths/get-shape new-sequence :copy2-nested-head)]
;; Confirm the old sequence fails to repair (this guards against a future
;; subcopy-head? change accidentally fixing it and masking the regression).
(t/is (= false (:component-root old-shape)))
(t/is (= (thi/id :wrong-id) (:component-id old-shape)))
;; Confirm the new sequence repairs both the explicit false and the bad ref.
(t/is (not (contains? new-shape :component-root)))
(t/is (= (thi/id :component1) (:component-id new-shape))))))
(t/deftest test-fix-missing-swap-slots
(t/testing "nil file should return nil"

View File

@ -0,0 +1,449 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.files.validate-test
"Exhaustive tests for the change-scoped partial validation functions in
app.common.files.validate:
- validate-file-affected returns nil or list of errors
- validate-file-affected! same but raises on non-empty errors
The tests verify the scoping logic implemented by extract-affected-ids
(tested indirectly) by injecting controlled broken states into specific
pages / components and confirming that only the expected entities are
validated."
(:require
[app.common.files.validate :as cfv]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.types.file :as ctf]
[app.common.types.pages-list :as ctpl]
[app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
;; ----------------------------------------------------------------
;; Test-local helpers
;; ----------------------------------------------------------------
(defn- inject-broken-child
"Add a reference to a non-existent shape ID to the root shape's
children list on the page identified by `page-label`.
This causes `check-parent-children` to report a :child-not-found
error whenever that page is validated."
[file page-label]
(let [page-id (thi/id page-label)
missing-id (uuid/next)]
(ctf/update-file-data
file
(fn [file-data]
(ctpl/update-page
file-data
page-id
(fn [page]
(let [root (ctst/get-shape page uuid/zero)]
(ctst/set-shape page (update root :shapes conj missing-id)))))))))
(defn- inject-broken-component
"Add a deleted component with `:objects nil` to the file.
This causes `check-component` to report a
:component-nil-objects-not-allowed error whenever that component is
validated. The component id is registered under `comp-label` in the
ids-map so callers can look it up with `(thi/id comp-label)`."
[file comp-label]
(let [comp-id (thi/new-id! comp-label)]
(ctf/update-file-data
file
(fn [file-data]
(assoc-in file-data [:components comp-id]
{:id comp-id
:name "broken-component"
:objects nil
:deleted true
:main-instance-id (uuid/next)
:main-instance-page (uuid/next)})))))
;; ----------------------------------------------------------------
;; 1. Feature gate
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-no-feature
(t/testing "returns nil when file does not have the components/v2 feature"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(assoc :features #{}))
page-id (thi/id :page1)
changes [{:type :add-obj :page-id page-id :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected file {} changes))))))
;; ----------------------------------------------------------------
;; 2. Empty changes nothing to validate
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-empty-changes
(t/testing "returns nil when the changes list is empty"
(let [file (thf/sample-file :file1 :page-label :page1)]
(t/is (nil? (cfv/validate-file-affected file {} []))))))
;; ----------------------------------------------------------------
;; 3. Page-level scoping add-obj / mod-obj / fix-obj key: :page-id
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-page-scoping-misses-untouched-page
(t/testing "add-obj on page1 does not validate broken page2"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
;; The change only touches page1
changes [{:type :add-obj :page-id page1-id :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "mod-obj on page1 does not validate broken page2"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
changes [{:type :mod-obj :page-id page1-id :id uuid/zero
:operations [{:type :set :attr :name :val "root"}]}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "fix-obj on page1 does not validate broken page2"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
changes [{:type :fix-obj :page-id page1-id :id (uuid/next)
:operations []}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes))))))
(t/deftest validate-file-affected-page-scoping-catches-error-on-touched-page
(t/testing "add-obj on broken page1 surfaces the error"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :add-obj :page-id page1-id :id (uuid/next)}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "mod-obj on broken page1 surfaces the error"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :mod-obj :page-id page1-id :id uuid/zero
:operations [{:type :set :attr :name :val "root"}]}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "reg-objects on broken page1 surfaces the error"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :reg-objects :page-id page1-id :shapes []}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "mov-objects on broken page1 surfaces the error"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :mov-objects :page-id page1-id :parent-id uuid/zero :shapes []}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors))))))
;; ----------------------------------------------------------------
;; 4. add-page / mod-page scoped by :id (not :page-id)
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-add-page-scoped-by-id
(t/testing "add-page with :id=page2 validates page2 (broken → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page2-id (thi/id :page2)
changes [{:type :add-page :id page2-id}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "add-page with :id=page1 does not validate broken page2"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
changes [{:type :add-page :id page1-id}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "mod-page with :id=page2 validates page2 (broken → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page2-id (thi/id :page2)
changes [{:type :mod-page :id page2-id}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors))))))
;; ----------------------------------------------------------------
;; 5. del-obj shape-level op scoped by :page-id
;; del-page / del-component / mov-page / purge-component no-ops
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-del-obj-scopes-page
(t/testing "del-obj scopes its :page-id just like add-obj / mod-obj"
;; del-obj is in the same shape-level ops bucket and scopes by :page-id.
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :del-obj :page-id page1-id :id (uuid/next)}]]
;; page1 has an error and del-obj touches page1 → error surfaced
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "del-obj on page1 does not validate broken page2"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
changes [{:type :del-obj :page-id page1-id :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes))))))
(t/deftest validate-file-affected-noop-change-types
(t/testing "del-page produces no affected entries"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :del-page :id page1-id}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "del-component produces no affected entries"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
changes [{:type :del-component :id comp-id}]]
(t/is (nil? (cfv/validate-file-affected file {} changes)))))
(t/testing "mov-page produces no affected entries"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :mov-page :id page1-id}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "purge-component produces no affected entries"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
changes [{:type :purge-component :id comp-id}]]
(t/is (nil? (cfv/validate-file-affected file {} changes)))))
(t/testing "add-color (library change) produces no affected entries"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
changes [{:type :add-color :id (uuid/next) :color {}}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "mod-color (library change) produces no affected entries"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
changes [{:type :mod-color :id (uuid/next) :color {}}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes))))))
;; ----------------------------------------------------------------
;; 6. add-component / mod-component scoped by :id (component-id)
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-add-component-scoped-by-id
(t/testing "add-component :id=comp1 validates comp1 (broken → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
changes [{:type :add-component :id comp-id}]]
(let [errors (cfv/validate-file-affected file {} changes)]
(t/is (seq errors))
(t/is (some #(= :component-nil-objects-not-allowed (:code %)) errors)))))
(t/testing "add-component :id=other-id does not validate broken comp1"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
_ (thi/id :comp1)
changes [{:type :add-component :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected file {} changes)))))
(t/testing "mod-component :id=comp1 validates comp1 (broken → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
changes [{:type :mod-component :id comp-id}]]
(let [errors (cfv/validate-file-affected file {} changes)]
(t/is (seq errors))
(t/is (some #(= :component-nil-objects-not-allowed (:code %)) errors))))))
;; ----------------------------------------------------------------
;; 7. restore-component scopes BOTH :id (component) and :page-id
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-restore-component-scopes-page
(t/testing "restore-component touches its :page-id (broken page → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :restore-component :id (uuid/next) :page-id page1-id}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "restore-component does not validate a page it does not reference"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
changes [{:type :restore-component :id (uuid/next) :page-id page1-id}]]
;; page2 has an error but the change only touches page1
(t/is (nil? (cfv/validate-file-affected file' {} changes))))))
(t/deftest validate-file-affected-restore-component-scopes-component
(t/testing "restore-component touches its component :id (broken component → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
page1-id (thi/id :page1)
changes [{:type :restore-component :id comp-id :page-id page1-id}]]
(let [errors (cfv/validate-file-affected file {} changes)]
(t/is (seq errors))
(t/is (some #(= :component-nil-objects-not-allowed (:code %)) errors))))))
;; ----------------------------------------------------------------
;; 8. Mixed changes union of affected entities
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-mixed-changes-union
(t/testing "two changes on different pages: both pages are validated"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
;; page2 is broken; page1 is clean
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
page2-id (thi/id :page2)
;; Both pages are touched
changes [{:type :add-obj :page-id page1-id :id (uuid/next)}
{:type :mod-obj :page-id page2-id :id uuid/zero
:operations []}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
;; page2 is validated → error surfaced
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "del-page (true no-op) mixed with add-obj on page1: only page1 is validated"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
;; page2 is broken; page1 is clean
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
page2-id (thi/id :page2)
;; del-page on page2 is a no-op; add-obj on page1 scopes page1 only
changes [{:type :del-page :id page2-id}
{:type :add-obj :page-id page1-id :id (uuid/next)}]]
;; page2's error is NOT surfaced because del-page produces no scope
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "duplicate page-ids in changes are deduplicated (page validated once)"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
;; Three changes all touching the same page
changes [{:type :add-obj :page-id page1-id :id (uuid/next)}
{:type :mod-obj :page-id page1-id :id uuid/zero :operations []}
{:type :del-obj :page-id page1-id :id (uuid/next)}]]
;; del-obj excluded, add-obj + mod-obj both scope page1; error surfaced once
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
;; There should be exactly one :child-not-found (not duplicated)
(t/is (= 1 (count (filter #(= :child-not-found (:code %)) errors))))))))
;; ----------------------------------------------------------------
;; 9. reorder-children and component-context changes
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-reorder-children
(t/testing "reorder-children on broken page surfaces the error"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :reorder-children :page-id page1-id :id uuid/zero :shapes []}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors))))))
(t/deftest validate-file-affected-obj-in-component
(t/testing "add-obj with :component-id (not :page-id) scopes a component"
;; When a shape change carries :component-id instead of :page-id it
;; means the change happened inside a deleted component's object tree.
;; extract-affected-ids routes it to :component-ids.
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
changes [{:type :add-obj :component-id comp-id :id (uuid/next)}]]
(let [errors (cfv/validate-file-affected file {} changes)]
(t/is (seq errors))
(t/is (some #(= :component-nil-objects-not-allowed (:code %)) errors)))))
(t/testing "add-obj with :component-id does not scope an unrelated component"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
_ (thi/id :comp1)
changes [{:type :add-obj :component-id (uuid/next) :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected file {} changes))))))
;; ----------------------------------------------------------------
;; 10. validate-file-affected! raises vs returns nil
;; ----------------------------------------------------------------
(t/deftest validate-file-affected!-returns-nil-on-clean-file
(t/testing "returns nil when the file has no errors in the touched page"
(let [file (thf/sample-file :file1 :page-label :page1)
page1-id (thi/id :page1)
changes [{:type :add-obj :page-id page1-id :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected! file {} changes))))))
(t/deftest validate-file-affected!-returns-nil-empty-changes
(t/testing "returns nil when there are no changes (no pages validated)"
(let [file (thf/sample-file :file1 :page-label :page1)]
(t/is (nil? (cfv/validate-file-affected! file {} []))))))
(t/deftest validate-file-affected!-raises-on-error
(t/testing "raises an exception when the touched page has validation errors"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :mod-obj :page-id page1-id :id uuid/zero
:operations []}]]
(t/is (thrown? #?(:clj Exception :cljs js/Error)
(cfv/validate-file-affected! file' {} changes)))))
(t/testing "raised exception is of :validation type with :referential-integrity code"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :mod-obj :page-id page1-id :id uuid/zero
:operations []}]]
(try
(cfv/validate-file-affected! file' {} changes)
(t/is false "expected exception to be thrown")
(catch #?(:clj clojure.lang.ExceptionInfo :cljs cljs.core.ExceptionInfo) e
(let [data (ex-data e)]
(t/is (= :validation (:type data)))
(t/is (= :referential-integrity (:code data)))
(t/is (seq (:details data)))))))))

View File

@ -2970,3 +2970,68 @@
;; its :selrect.y is internally inconsistent and renders incorrectly.
(t/is (= post-btn-rel-y post-btn-selrect-rel-y)
":y and :selrect.y must agree after switch")))
(t/deftest test-switch-does-not-override-path-content-when-only-repositioned
;; Regression: when a path shape inside a variant has :geometry-group touched
;; (e.g. because auto-layout repositioned it after the copy's parent was
;; resized), switching variants must NOT copy the old variant's path position
;; to the new variant. The path should stay at the new variant's default position.
;;
;; Root cause: equal-geometry? did not handle the :content attr for path shapes,
;; so switch-path-change-value was always invoked and placed the new path at the
;; pre-switch absolute position instead of the target master's default position.
(let [;; A small closed triangle path whose bounding box is 24x14 px,
;; anchored at absolute position (x0, y0).
triangle (fn [x0 y0]
[{:command :move-to :params {:x x0 :y y0}}
{:command :line-to :params {:x (+ x0 24) :y y0}}
{:command :line-to :params {:x (+ x0 12) :y (+ y0 14)}}
{:command :close-path}])
;; V1 has the path at y=10; V2 has the same-shape path at y=30.
file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02
{:variant1-params {:width 100 :height 100}
:variant2-params {:width 100 :height 100}})
(ths/add-sample-shape :path1 :type :path
:parent-label :m01
:content (triangle 0 10))
(ths/add-sample-shape :path2 :type :path
:parent-label :m02
:content (triangle 0 30))
(thc/instantiate-component :c01 :copy01))
;; Simulate auto-layout repositioning the path inside the copy by
;; moving it to y=50. This touches :geometry-group on the copy's path.
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
copy-path (->> (cfh/get-children-with-self (:objects page) (:id copy01))
(filter #(= :path (:type %)))
first)
changes (cls/generate-update-shapes
(pcb/empty-changes nil (:id page))
#{(:id copy-path)}
#(gsh/absolute-move % (gpt/point (:x %) 50))
(:objects page) {})
file (thf/apply-changes file changes)
;; Switch copy01 from V1 (c01) to V2 (c02).
file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true})
page' (thf/current-page file')
copy01' (ths/get-shape file' :copy01)
copy-path' (->> (cfh/get-children-with-self (:objects page') (:id copy01'))
(filter #(= :path (:type %)))
first)
;; Expected: V2's path sits at y=30 (its master default), not y=50
;; (the pre-switch repositioned position).
m02 (ths/get-shape file :m02)
path2 (ths/get-shape file :path2)
target-rel-y (- (-> path2 :selrect :y) (-> m02 :selrect :y))
actual-rel-y (- (-> copy-path' :selrect :y) (-> copy01' :selrect :y))]
(t/is (some? copy-path') "path should exist in switched copy")
(t/is (= target-rel-y actual-rel-y)
(str "path :selrect.y should match target master layout (expected "
target-rel-y " got " actual-rel-y ")"))))

View File

@ -20,6 +20,7 @@
[common-tests.files-changes-test]
[common-tests.files-migrations-test]
[common-tests.files.shapes-builder-test]
[common-tests.files.validate-test]
[common-tests.geom-align-test]
[common-tests.geom-bounds-map-test]
[common-tests.geom-flex-layout-test]
@ -56,6 +57,7 @@
[common-tests.logic.swap-and-reset-test]
[common-tests.logic.swap-as-override-test]
[common-tests.logic.token-test]
[common-tests.logic.variants-switch-test]
[common-tests.media-test]
[common-tests.path-names-test]
[common-tests.record-test]
@ -91,6 +93,7 @@
'common-tests.files-changes-test
'common-tests.files-builder-test
'common-tests.files-migrations-test
'common-tests.files.validate-test
'common-tests.geom-align-test
'common-tests.geom-bounds-map-test
'common-tests.geom-flex-layout-test
@ -127,6 +130,7 @@
'common-tests.logic.swap-and-reset-test
'common-tests.logic.swap-as-override-test
'common-tests.logic.token-test
'common-tests.logic.variants-switch-test
'common-tests.media-test
'common-tests.path-names-test
'common-tests.record-test

View File

@ -1,5 +1,5 @@
listen_addresses = '*'
max_connections = 50
max_connections = 160
shared_buffers = 256MB
temp_buffers = 18MB
work_mem = 18MB

View File

@ -89,6 +89,7 @@ services:
depends_on:
- penpot-backend
- penpot-exporter
- penpot-mcp
networks:
- penpot
@ -106,7 +107,8 @@ services:
environment:
<< : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
# Set to "true" on hosts where IPv6 is disabled at kernel boot level.
# PENPOT_DISABLE_IPV6_LISTEN: "true"
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-2.15}"
restart: always

View File

@ -46,7 +46,11 @@ export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000}
export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp:4401}
export PENPOT_MCP_URI_WS=${PENPOT_MCP_URI_WS:-http://penpot-mcp:4402}
export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_MCP_URI_WS,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \
export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::]:8080 default_server;"}
if [ "${PENPOT_DISABLE_IPV6_LISTEN}" = "true" ]; then
export PENPOT_IPV6_LISTEN_DIRECTIVE=""
fi
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_MCP_URI_WS,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf
PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)"

View File

@ -73,7 +73,7 @@ http {
server {
listen 8080 default_server;
listen [::]:8080 default_server;
${PENPOT_IPV6_LISTEN_DIRECTIVE}
server_name _;
client_max_body_size $PENPOT_HTTP_SERVER_MAX_BODY_SIZE;

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@ -171,13 +171,14 @@ PENPOT_OIDC_CLIENT_ID: <client-id>
# Mainly used for auto discovery the openid endpoints
PENPOT_OIDC_BASE_URI: <uri>
PENPOT_OIDC_CLIENT_SECRET: <client-id>
PENPOT_OIDC_CLIENT_SECRET: <client-secret>
# Optional backend variables, used mainly if you want override; they are
# autodiscovered using the standard openid-connect mechanism.
PENPOT_OIDC_AUTH_URI: <uri>
PENPOT_OIDC_TOKEN_URI: <uri>
PENPOT_OIDC_USER_URI: <uri>
PENPOT_OIDC_JWKS_URI: <uri>
# Optional list of roles that users are required to have. If no role
# is provided, roles checking disabled.
@ -187,6 +188,27 @@ PENPOT_OIDC_ROLES: "role1 role2"
# not provided, the roles checking will be disabled.
PENPOT_OIDC_ROLES_ATTR:
```
<p class="advice">
For self-hosted and containerized deployments, the autodiscovered OIDC endpoints are
not always enough. Some providers expose browser-facing endpoints through a public
hostname while the Penpot backend must reach the same provider through an
internal/container-resolvable hostname. In that case, explicitly set the OIDC endpoint
overrides above so the browser can use the public authorization endpoint while the
backend uses reachable token, userinfo, and JWKS endpoints.
</p>
If the backend needs to contact the OIDC provider through a hostname not already allowed
by SSRF protection, add it to:
```bash
# Backend
# Space separated list of allowed hosts
PENPOT_SSRF_ALLOWED_HOSTS: "<internal-provider-host> <public-provider-host>"
```
This is commonly required when the provider is reachable from the browser via a public
URL but from the backend via a different internal hostname.
<br />
__Since version 1.6.0__

View File

@ -9,6 +9,7 @@ desc: Learn how to import and export files in Penpot, the free, open-source desi
<h2 id="comment-workspace">At the workspace</h2>
<p>At the workspace, activate the comment tool by clicking the comment icon in the navbar or pressing the <kbd>C</kbd> key. <a href="/user-guide/designing/workspace-basics/#comments">More about comments at the Workspace</a></p>
<p>URLs in comments are detected automatically and open in a new browser tab when clicked.</p>
<h2 id="comment-viewmode">At the View mode</h2>
<p>You can activate comments at the View mode by pressing the comments icon at the top navbar. <a href="/user-guide/prototyping-testing/testing-view-mode/#viewmode-comments">More about comments at the View mode</a>.</p>

View File

@ -36,8 +36,10 @@ desc: Use Penpot's asset libraries for reusable design elements! Learn to create
<source src="/img/libraries/add-color.mp4" type="video/mp4">
</video>
</figure>
<p>Semi-transparent colors in the library show their opacity percentage in the asset name.</p>
<h3>Typographies</h3>
<p>All typography styles created from the text properties (at the right sidebar) are automatically stored at the library. You can also click the “+” to create a new typography style from scratch.</p>
<p>Each typography group has its own add button to create a style directly in that group. When editing a typography style, use the delete and duplicate buttons in the typography dialog.</p>
<figure>
<video title="Add typography" muted="" playsinline="" controls="" width="auto" poster="/img/libraries/add-typography.webp" height="auto">
<source src="/img/libraries/add-typography.mp4" type="video/mp4">
@ -49,8 +51,8 @@ desc: Use Penpot's asset libraries for reusable design elements! Learn to create
<p>Press left click over any asset of the library to show the options menu. Some options are available only for certain assets.</p>
<ul>
<li><strong>Components:</strong> Rename, duplicate, delete, group.</li>
<li><strong>Colors:</strong> Rename, edit, delete.</li>
<li><strong>Typographies:</strong> Rename, edit, delete.</li>
<li><strong>Colors:</strong> Rename, edit, duplicate, delete.</li>
<li><strong>Typographies:</strong> Rename, edit, duplicate, delete.</li>
</ul>
<figure>
<video title="Asset options" muted="" playsinline="" controls="" width="auto" poster="/img/libraries/asset-options.webp" height="auto">
@ -126,6 +128,9 @@ desc: Use Penpot's asset libraries for reusable design elements! Learn to create
<h3>Ungroup assets</h3>
<p>You can ungroup the assets the same ways you can group them, via the menu option ("Ungroup" in this case) or renaming them.</p>
<h3>Delete a group</h3>
<p><strong>Delete group</strong> removes every asset inside that group from the library. Penpot asks you to confirm how many assets will be deleted. This option is available in the context menu for component, color and typography groups.</p>
<h3>Drag assets to groups</h3>
<p>One very direct way to move assets between groups at the libraries is by dragging them.</p>
<figure>

View File

@ -134,6 +134,24 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
</ul>
<p>Tokens can be applied to multiple selected elements, but not to groups.</p>
<h3 id="design-tokens-design-tab">Applying tokens from the Design tab</h3>
<p>Besides applying tokens from the Tokens panel, you can bind tokens from numeric and color fields in the Design sidebar.</p>
<p>Token selection is available for:</p>
<ul>
<li><strong>Size and position:</strong> width, height, x, y</li>
<li><strong>Rotation and border radius</strong></li>
<li><strong>Layout spacing:</strong> gaps, paddings, margins, min and max widths and heights</li>
<li><strong>Typography:</strong> letter spacing and line height</li>
<li><strong>Stroke width</strong></li>
<li><strong>Shadows:</strong> x, y, blur, spread</li>
<li><strong>Blur</strong> amount</li>
</ul>
<p>Click the token control on a field to open a list of applicable tokens from your active sets and themes. Select a token to apply it; a token pill shows the bound token. Use the pill menu to detach the token when you need a custom value again.</p>
<p class="advice">When a numeric field has a token applied, drag-to-change is disabled for that field. Edit the token or detach it first.</p>
<figure>
<img src="/img/design-tokens/41-design-tab-token-dropdown.webp" alt="Token list on a numeric field in the Design sidebar">
</figure>
<h3 id="design-tokens-radius">Border radius</h3>
<p>Border radius tokens allow you to define specific values for border-radius properties, offering flexibility in how you style the corners of elements.</p>
<figure>
@ -654,6 +672,7 @@ ExtraBold Italic
<h2 id="design-tokens-import-export">Importing and Exporting Tokens</h2>
<p>You can export Tokens from Penpot and import them from your computer to a Penpot file. Tokens can be imported from the <strong>Tools</strong> option at the bottom of the <strong>Tokens</strong> tab.</p>
<p>You can also <a href="/user-guide/design-systems/libraries/#import-tokens-from-library">import tokens from a connected shared library</a> using the Libraries modal. That import uses the library's full token definition (sets and themes included) and replaces the tokens already in your file.</p>
<p>The <strong>Import</strong> functionality allows you to upload and replace the global token set using a single file or a folder with multiple files in it.</p>
<p>These features support JSON files formatted according to specific guidelines and preserve the ability to undo changes if needed.</p>
<figure>

View File

@ -70,3 +70,23 @@ desc: Use Penpot's libraries for reusable design elements! Learn to create, mana
<figure>
<img src="/img/libraries/libraries-open.webp" alt="Open libraries">
</figure>
<h4 id="import-tokens-from-library">Import tokens from a connected library</h4>
<p>If a shared library you have connected includes design tokens, you can copy them into the current file from the <strong>Libraries</strong> modal.</p>
<p>The <strong>Import tokens</strong> button appears only next to connected libraries that define tokens. Libraries without tokens do not show this control.</p>
<ol>
<li>Open the <strong>Libraries</strong> panel from the assets tab.</li>
<li>Under <strong>Libraries in this file</strong>, find the connected library that includes tokens and click <strong>Import tokens</strong> (beside the disconnect control).</li>
<li>Review the confirmation dialog. Importing brings in the library's full token data, including sets and themes when the library defines them.</li>
<li>Click <strong>Import tokens</strong> to apply, or <strong>Cancel</strong> to go back without changes.</li>
</ol>
<p class="advice">Importing tokens from a library replaces all tokens, sets and themes in the current file. Export your tokens first (from the Tokens tab → Tools → Export) if you need to keep a backup.</p>
<p>This is separate from connecting the library for components, colors and typographies. You can use shared assets without importing tokens, and import tokens only when you want the file's token setup to match the library.</p>
<figure>
<img src="/img/libraries/libraries-import-tokens-button.webp" alt="Import tokens button in the Libraries modal">
<figcaption>The Import tokens button on a connected library that includes tokens.</figcaption>
</figure>
<figure>
<img src="/img/libraries/libraries-import-tokens-confirm.webp" alt="Import tokens from library confirmation dialog">
<figcaption>Confirm before importing. All current tokens, sets and themes in this file will be replaced.</figcaption>
</figure>

View File

@ -53,9 +53,10 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien
<ol>
<li><strong>Eyedropper</strong> - Allows you to pick any color of the layers at the viewport.</li>
<li><strong>Color profiles</strong> - Select between RGB, the Harmony Wheel or HSV.</li>
<li><strong>Color model</strong> - On the HSBA tab, switch between HEX, RGB, HSB and HSL input. Your choice is remembered across sessions.</li>
<li><strong>Color type</strong> - Solid, gradient, or image.</li>
<li><strong>Sliders</strong> - Easily manage settings like brightness, saturation or opacity.</li>
<li><strong>Values</strong> - Set precise color values of red(R), green(G), blue(B) and transparency(A).</li>
<li><strong>Values</strong> - Set precise color values. Available formats depend on the selected color model.</li>
<li><strong>Libraries</strong> - Switch between recent colors and libraries.</li>
<li><strong>Color palette</strong> - A quick launcher of the palette with the selected library.</li>
</ol>
@ -85,6 +86,7 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien
<li>Using the color palette <strong>launcher</strong> at the color picker (see previous section).</li>
</ol>
<p>Use the <strong>menu</strong> to easily switch between libraries.</p>
<p>Use the <strong>search</strong> field to filter colors in the active library by name.</p>
<p>Switch between big and small <strong>thumbnails sizes</strong>.</p>
<h3>Applying color from the palette</h3>
@ -112,6 +114,7 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien
<li><strong>Width</strong> (in pixels)</li>
<li><strong>Position</strong> - center, outside, inside</li>
<li><strong>Style</strong> - solid, dotted, dashed, mixed</li>
<li><strong>Visibility</strong> - show or hide the stroke without removing it</li>
</ul>
<p>You can add as many strokes as you want to the same layer.</p>
<figure>

View File

@ -144,7 +144,7 @@ desc: Master responsive web design with Penpot's flexible and grid layouts! Lear
<li>From the option at the selection menu (right click button).</li>
<li>Pressing <kbd>Ctrl/⌘</kbd> + <kbd>Shift</kbd> + <kbd>A</kbd>.</li>
</ul>
<figure><img src="/img/layouts-add.webp" alt="Adding Layouts" /></figure>
<figure><img src="/img/flexible-layouts/layouts-add.webp" alt="Adding Layouts" /></figure>
<h3 id="layouts-grid-terminology">Grid layout basic terminology</h3>
<ul>

View File

@ -22,7 +22,7 @@ are shown by default at the <a href="/user-guide/prototyping-testing/testing-vie
<p>Then, with the board tool selected, you have two options:</p>
<ul>
<li><strong>Select a board size upfront</strong>. You can choose one of the provided presets with the most common resolution for devices and standard print sizes</li>
<li><strong>Select a board size upfront</strong>. You can choose one of the provided presets with the most common resolution for devices and standard print sizes. Use the search field to filter presets by name.</li>
<li><strong>Click-and-drag</strong> to draw a frame of approximate size, then immediately edit its width/height values to be precise.</li>
</ul>
<figure>
@ -119,10 +119,17 @@ Penpot allows you to decide if the fill of an artboard will be shown in exports,
<h4>Board guides</h4>
<p>You can set guides on boards that will assist with aligning layers.</p>
<p>Read more about <a href="/user-guide/designing/workspace-basics/#guides">guides</a>.</p>
<p>Right-click a board and choose <strong>Clear artboard guides</strong> to remove all guides on that board.</p>
<figure>
<img src="/img/objects/board-guides.webp" alt="board guides">
</figure>
<h4>Copy as image</h4>
<p><strong>Copy as image</strong> copies a board as a bitmap to the system clipboard. Select a board, open the context menu, choose <strong>Copy/Paste as…</strong>, then <strong>Copy as image</strong>. Paste the result into other applications like any image.</p>
<figure>
<img src="/img/objects/board-copy-as-image.webp" alt="Copy as image option for a board">
</figure>
<h4>Prototyping boards</h4>
<p>You can connect boards with other boards to create rich interactions.</p>
<p>Read more about <a href="/user-guide/prototyping-testing/prototyping/">prototyping</a>.</p>
@ -212,6 +219,9 @@ You can choose to edit individual nodes or create new ones. Press <kbd>Esc</kbd>
</video>
</figure>
<h3 id="paste-to-replace">Paste to replace</h3>
<p><strong>Paste to replace</strong> replaces the selected shape with the clipboard contents, keeping the selection. Use the context menu or press <kbd>Ctrl/⌘</kbd> + <kbd>Shift/⇧</kbd> + <kbd>V</kbd>.</p>
<h3 id="duplicating-layers">Duplicate</h3>
<p>There are several ways to duplicate a layer:</p>
<ol>

View File

@ -64,7 +64,7 @@ desc: Penpot's guide on custom fonts! Upload, manage, and use custom fonts in Pe
<ol>
<li>Press “Add custom font”.</li>
<li>Inspect your local files to select one or more fonts that you want to upload. <strong>You can upload fonts with
the following formats: TTF, OTF and WOFF</strong>. Only one format will be needed.</li>
the following formats: TTF, OTF, WOFF and WOFF2</strong>. Only one format will be needed.</li>
<li>Change the font name if needed. The font name is the name that will be shown in the font list at the workspace.
It is also what Penpot uses to group fonts in families. You can always edit it later.</li>
<li>Once ready, press upload. That's it. The font will be available at the font list of this teams files.</li>
@ -77,7 +77,14 @@ It is also what Penpot uses to group fonts in families. You can always edit it l
<p><a href="/img/customfonts-families.png" target="_blank"><img src="/img/customfonts-families.png" alt="local fonts" /></a></p>
<h3 id="customfonts-edit">Edit custom fonts</h3>
<p>At the right side of a font family of the custom fonts list you can find a menu that allows you to edit the name of a font family and delete it.</p>
<p>At the right side of a font family of the custom fonts list you can find a menu that allows you to edit the name of a font family, download it, and delete it.</p>
<h3 id="customfonts-download">Download custom fonts</h3>
<p>Team members with edit permissions (editor, admin, or owner) can download a custom font family from the team <strong>Fonts</strong> section. Right-click a font family and choose <strong>Download</strong>. A single variant downloads as one file; families with multiple variants download as a ZIP.</p>
<p>Viewers can use custom fonts in files but cannot upload, edit, or download them from the dashboard.</p>
<figure>
<img src="/img/customfonts-download.webp" alt="Download option in the custom font family menu">
</figure>
<h3 id="customfonts-using">Using custom fonts</h3>
<p>Custom fonts are added to the fonts catalog of a team and can be used at the workspace from the font list at the design sidebar.</p>

View File

@ -43,6 +43,14 @@ desc: Master Penpot's workspace basics! Learn interface navigation, zoom tools,
</video>
</figure>
<h4 id="page-separators">Page separators</h4>
<p>You can add visual dividers in the page list to group pages without extra structure. Create an empty page, then rename it to <code>---</code>. The page appears as a horizontal line in the list.</p>
<p>A page only becomes a separator if its name is <code>---</code> and the page has no content on the canvas. A page with content that is named <code>---</code> stays a normal page. Separators cannot be opened or selected; you can reorder or delete them like other pages.</p>
<p>If you rename the active page to <code>---</code>, Penpot navigates to another page in the file. Double-click a separator to rename it; clearing the name and confirming removes the separator page.</p>
<figure>
<img src="/img/layers/pages-separator.webp" alt="Page separator in the pages list">
</figure>
<h3>Layers</h3>
<p><strong>Layers:</strong> Layers are the different objects that you can place at the design viewport. At the layers panel you can see all the layers of a file page. Drag the layers to arrange them to different positions.</p>
<figure>
@ -53,9 +61,10 @@ desc: Master Penpot's workspace basics! Learn interface navigation, zoom tools,
<h4>Navigate layers using the keyboard</h4>
<ul>
<li>Select a layer and press top/bottom arrows while pressing <kbd>Ctrl/⌘</kbd> to move them in the layers list.</li>
<li>Press <kbd>tab</kbd> to change the layer selection to the next layer.</li>
<li>Press <kbd>tab</kbd> + <kbd>Shift/⇧</kbd> to change the layer selection to the previous layer.</li>
<li>If the layer contains other layers, press <kbd>Enter</kbd> to select the first layer inside the group and <kbd>Enter</kbd>+ <kbd>Shift/⇧</kbd> to move a level up.</li>
<li>Press <kbd>Tab</kbd> to change the layer selection to the next layer.</li>
<li>Press <kbd>Tab</kbd> + <kbd>Shift/⇧</kbd> to change the layer selection to the previous layer.</li>
<li>While renaming a layer in the Layers panel, press <kbd>Tab</kbd> to save the name and start renaming the next layer below, or <kbd>Shift/⇧</kbd> + <kbd>Tab</kbd> for the layer above (siblings in the same group).</li>
<li>If the layer contains other layers, press <kbd>Enter</kbd> to select the first layer inside the group and <kbd>Enter</kbd> + <kbd>Shift/⇧</kbd> to move a level up.</li>
</ul>
<p>Layers are displayed from the bottom to the top of the layer stack, with layers above on the stack being shown on top in the image.</p>
@ -69,17 +78,32 @@ desc: Master Penpot's workspace basics! Learn interface navigation, zoom tools,
</video>
</figure>
<h4 id="find-and-replace">Find and replace</h4>
<p>Open <strong>Find and Replace</strong> from the main menu (Edit) or press <kbd>Ctrl/⌘</kbd> + <kbd>H</kbd>. The search bar at the top of the Layers panel expands with a replace field.</p>
<ul>
<li><strong>Search on canvas:</strong> finds text inside text layers on the current page.</li>
<li><strong>Search layers:</strong> finds layer names on the current page.</li>
</ul>
<p>Search is not case sensitive and does not support regular expressions. Layer type filters still apply, so you can narrow matches before replacing.</p>
<p>Use <strong>Replace</strong> for the current match or <strong>Replace all</strong> for every match on the page. Arrow buttons move between matches.</p>
<p>You can also open <strong>Find</strong> from the main menu or press <kbd>Ctrl/⌘</kbd> + <kbd>F</kbd> to search without the replace field.</p>
<h3>Collapse groups and boards</h3>
<p>Groups and boards can have their contents expanded and collapsed. Click on the arrow at the
right side to toggle the visibility of their contents. </p>
<p>To collapse all the layers, and just display the boards,
press <kbd>Shift/⇧</kbd> + left click over the right arrow of a group or a board to collapse them all.</p>
<p>To expand the entire subtree of a collapsed group or board, press <kbd>Alt/⌥</kbd> + click its disclosure arrow.</p>
<figure>
<video title="Collapse layer groups" muted="" playsinline="" controls="" width="auto" poster="/img/layers/layers-collapse.webp" height="auto">
<source src="/img/layers/layers-collapse.mp4" type="video/mp4">
</video>
</figure>
<h2 id="design-sidebar-inputs">Numeric inputs in the Design sidebar</h2>
<p>Many numeric fields in the Design sidebar support scrubbing: click and drag on the field to change the value. Hold <kbd>Shift/⇧</kbd> to change the value ten times faster.</p>
<p>Drag-to-change is not available when a <a href="/user-guide/design-systems/design-tokens/#design-tokens-design-tab">design token</a> is applied to that field.</p>
<h2 id="focus-mode">Focus mode</h2>
<p>Focus mode zooms into the elements of a page you want to work with in a specific moment, and hides the rest so that they dont get in the way. When the page has many elements, focus mode can also improve performance.</p>
<p>To activate focus mode:</p>
@ -136,6 +160,7 @@ press <kbd>Shift/⇧</kbd> + left click over the right arrow of a group or a boa
<li>Write your comment in the text box.</li>
<li>Press Post to leave the comment or Cancel to not do it.</li>
</ol>
<p>URLs in comments are detected automatically and open in a new browser tab when clicked.</p>
<h3>How to reply a comment</h3>
<ol>
<li>Open a comment by clicking at its bubble (a circled number).</li>
@ -199,6 +224,18 @@ press <kbd>Shift/⇧</kbd> + left click over the right arrow of a group or a boa
<img src="/img/workspace-basics/history-pin.webp" alt="Pin versions" />
</figure>
<h4 id="preview-version">Preview a saved version</h4>
<p>Open the version menu in the History tab and choose <strong>Preview version</strong>. The workspace opens in view-only mode: a banner shows the version name, and the Pages panel displays a <strong>View only</strong> badge.</p>
<p>You can inspect the design but not edit it. Use <strong>Exit</strong> in the banner to leave preview, or <strong>Restore</strong> to revert the file to that version.</p>
<figure>
<img src="/img/workspace-basics/history-preview-menu.webp" alt="Preview version option in the version menu">
<figcaption>Open the version menu to preview a saved version.</figcaption>
</figure>
<figure>
<img src="/img/workspace-basics/history-preview-banner.webp" alt="Version preview banner with Exit and Restore">
<figcaption>View-only preview. Exit returns to the current file; Restore applies this version.</figcaption>
</figure>
<h3>Actions panel</h3>
<p>At the Actions panel, you have the layer type (rectangle, text, image...) and type of change (New, Modified, Deleted...). If you press the item, it will be reverted to its state before that specific action was performed.</p>
<figure>
@ -247,8 +284,14 @@ press <kbd>Shift/⇧</kbd> + left click over the right arrow of a group or a boa
<video title="Ruler guides" muted="" playsinline="" controls="" width="auto" poster="/img/workspace-basics/ruler-guides.webp" height="auto">
<source src="/img/workspace-basics/ruler-guides.mp4" type="video/mp4">
</video>
</figure><p>To <strong>delete ruler guides</strong> drag the guide to the ruler or select the guide and press delete / supr.</p>
</figure>
<p>To set an exact position, double-click the guide pill on the ruler and type a value.</p>
<p>Right-click a ruler guide to change its color from the preset swatches.</p>
<p>To <strong>delete ruler guides</strong> drag the guide to the ruler or select the guide and press delete / supr.</p>
<p>To <strong>show/hide ruler guides</strong> use the same shortcut as for rulers: <kbd>Shift/CMD + Ctrl + R</kbd></p>
<figure>
<img src="/img/workspace-basics/ruler-guide-pill-edit.webp" alt="Editing a ruler guide position from the guide pill">
</figure>
<h2 id="guides">Guides</h2>
<p>Guides are design aids that are used to help you to align content to a
@ -343,9 +386,13 @@ geometric structure. In Penpot there are three types of guides:
<h2 id="snap-to-pixel">Snap to pixel</h2>
<p>Layers automatically snap to the pixel grid. If you need a different kind of precision like working at subpixel level using measures with decimals you can disable this option anytime from the main menu.</p>
<p>When the pixel grid is visible, you can customize its color from the main menu (View).</p>
<figure>
<img src="/img/workspace-basics/workspace-snap.webp" alt="Snap to pixel setting" />
</figure>
<figure>
<img src="/img/workspace-basics/pixel-grid-color.webp" alt="Pixel grid color setting in the main menu">
</figure>
<h2 id="nudge-amount">Nudge amount</h2>
<p>Set your chosen distance to move layers using the keyboard. This is a must if youre working with guides (if youre not, you should ;)). Being able to adjust the movement to your baseline grid (8px? 5px?) is a huge timesaver that will improve your quality of life while designing.</p>

View File

@ -112,6 +112,21 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
<td style="text-align: center;"><kbd>Ctrl</kbd><kbd>V</kbd></td>
<td style="text-align: center;"><kbd>⌘</kbd><kbd>V</kbd></td>
</tr>
<tr>
<td>Paste to replace</td>
<td style="text-align: center;"><kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>V</kbd></td>
<td style="text-align: center;"><kbd>⌘</kbd><kbd>⇧</kbd><kbd>V</kbd></td>
</tr>
<tr>
<td>Find</td>
<td style="text-align: center;"><kbd>Ctrl</kbd><kbd>F</kbd></td>
<td style="text-align: center;"><kbd>⌘</kbd><kbd>F</kbd></td>
</tr>
<tr>
<td>Find and replace</td>
<td style="text-align: center;"><kbd>Ctrl</kbd><kbd>H</kbd></td>
<td style="text-align: center;"><kbd>⌘</kbd><kbd>H</kbd></td>
</tr>
<tr>
<td>Redo</td>
<td style="text-align: center;"><kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>Z</kbd></td>
@ -423,6 +438,90 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
<td style="text-align: center;"><kbd>G</kbd> and then <kbd>H</kbd></td>
<td style="text-align: center;"><kbd>G</kbd> and then <kbd>H</kbd></td>
</tr>
<tr>
<td>Go to view mode</td>
<td style="text-align: center;"><kbd>G</kbd> and then <kbd>V</kbd></td>
<td style="text-align: center;"><kbd>G</kbd> and then <kbd>V</kbd></td>
</tr>
<tr>
<td>Go to view mode comments</td>
<td style="text-align: center;"><kbd>G</kbd> and then <kbd>C</kbd></td>
<td style="text-align: center;"><kbd>G</kbd> and then <kbd>C</kbd></td>
</tr>
</tbody>
</table>
<h3 id="layers-panel">Layers panel</h3>
<p>Keyboard shortcuts and mouse actions for the Layers sidebar. Layer order in the panel is bottom-to-top; <kbd>Tab</kbd> and <kbd>Shift</kbd> + <kbd>Tab</kbd> move between siblings of the current selection (wrapping at the ends). Use <kbd>Ctrl</kbd> + <kbd>↑</kbd> / <kbd>↓</kbd> to change stacking order in the list (see <a href="#modify-layers">Modify layers</a>).</p>
<table cellspacing="0" cellpadding="1" border="1" width="100%">
<thead>
<tr>
<th valign="top" class="cellrowborder"><strong>Action</strong></th>
<th valign="top" class="cellrowborder" style="text-align: center;"><strong>Linux and Windows</strong></th>
<th style="text-align: center;"><strong>macOS</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>Select next layer (sibling)</td>
<td style="text-align: center;"><kbd>Tab</kbd></td>
<td style="text-align: center;"><kbd>Tab</kbd></td>
</tr>
<tr>
<td>Select previous layer (sibling)</td>
<td style="text-align: center;"><kbd>Shift</kbd><kbd>Tab</kbd></td>
<td style="text-align: center;"><kbd>⇧</kbd><kbd>Tab</kbd></td>
</tr>
<tr>
<td>Select parent layer</td>
<td style="text-align: center;"><kbd>Shift</kbd><kbd>Enter</kbd></td>
<td style="text-align: center;"><kbd>⇧</kbd><kbd>Enter</kbd></td>
</tr>
<tr>
<td>Select children (group, frame, or boolean; single selection)</td>
<td style="text-align: center;"><kbd>Enter</kbd></td>
<td style="text-align: center;"><kbd>Enter</kbd></td>
</tr>
<tr>
<td>Rename next layer (while renaming in the panel)</td>
<td style="text-align: center;"><kbd>Tab</kbd></td>
<td style="text-align: center;"><kbd>Tab</kbd></td>
</tr>
<tr>
<td>Rename previous layer (while renaming in the panel)</td>
<td style="text-align: center;"><kbd>Shift</kbd><kbd>Tab</kbd></td>
<td style="text-align: center;"><kbd>⇧</kbd><kbd>Tab</kbd></td>
</tr>
<tr>
<td>Toggle expand or collapse (group or board)</td>
<td style="text-align: center;">Click disclosure arrow</td>
<td style="text-align: center;">Click disclosure arrow</td>
</tr>
<tr>
<td>Expand entire subtree (when collapsed)</td>
<td style="text-align: center;"><kbd>Alt</kbd> + click disclosure arrow</td>
<td style="text-align: center;"><kbd>⌥</kbd> + click disclosure arrow</td>
</tr>
<tr>
<td>Collapse all layers in the sidebar (when expanded)</td>
<td style="text-align: center;"><kbd>Shift</kbd> + click disclosure arrow</td>
<td style="text-align: center;"><kbd>⇧</kbd> + click disclosure arrow</td>
</tr>
<tr>
<td>Add layer to selection</td>
<td style="text-align: center;"><kbd>Ctrl</kbd> + click layer</td>
<td style="text-align: center;"><kbd>⌘</kbd> + click layer</td>
</tr>
<tr>
<td>Range select layers</td>
<td style="text-align: center;"><kbd>Shift</kbd> + click layer</td>
<td style="text-align: center;"><kbd>⇧</kbd> + click layer</td>
</tr>
<tr>
<td>Zoom to layer</td>
<td style="text-align: center;">Double-click layer icon</td>
<td style="text-align: center;">Double-click layer icon</td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,236 @@
{
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "import component",
"~:revn": 6,
"~:modified-at": "~m1730384055106",
"~:vern": 0,
"~:id": "~u3622460c-3408-81e2-8005-2fd0e55888b7",
"~:is-shared": false,
"~:version": 56,
"~:project-id": "~u3622460c-3408-81e2-8005-2fc9059741e0",
"~:created-at": "~m1730103423332",
"~:data": {
"~:pages": [
"~u3622460c-3408-81e2-8005-2fd0e55888b8"
],
"~:pages-index": {
"~u3622460c-3408-81e2-8005-2fd0e55888b8": {
"~:options": {},
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{"~#point": {"~:x": 0.0, "~:y": 0.0}},
{"~#point": {"~:x": 0.01, "~:y": 0.0}},
{"~#point": {"~:x": 0.01, "~:y": 0.01}},
{"~#point": {"~:x": 0.0, "~:y": 0.01}}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:selrect": {
"~#rect": {
"~:x": 0, "~:y": 0,
"~:width": 0.01, "~:height": 0.01,
"~:x1": 0, "~:y1": 0,
"~:x2": 0.01, "~:y2": 0.01
}
},
"~:fills": [{"~:fill-color": "#FFFFFF", "~:fill-opacity": 1}],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~u2e0995e6-d90f-80ed-8005-2fd17ece8809"
]
}
},
"~u2e0995e6-d90f-80ed-8005-2fd17ece8809": {
"~#shape": {
"~:y": 221,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:hide-in-viewer": true,
"~:name": "Rectangle",
"~:width": 237,
"~:type": "~:frame",
"~:points": [
{"~#point": {"~:x": 234, "~:y": 221}},
{"~#point": {"~:x": 471, "~:y": 221}},
{"~#point": {"~:x": 471, "~:y": 382}},
{"~#point": {"~:x": 234, "~:y": 382}}
],
"~:component-root": true,
"~:shape-ref": "~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a",
"~:show-content": true,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u2e0995e6-d90f-80ed-8005-2fd17ece8809",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:component-id": "~u2e0995e6-d90f-80ed-8005-2fd0c20489ce",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 234,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 234, "~:y": 221,
"~:width": 237, "~:height": 161,
"~:x1": 234, "~:y1": 221,
"~:x2": 471, "~:y2": 382
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": 161,
"~:component-file": "~u3622460c-3408-81e2-8005-2fc938010233",
"~:flip-y": null,
"~:shapes": [
"~u2e0995e6-d90f-80ed-8005-2fd17ece880a"
]
}
},
"~u2e0995e6-d90f-80ed-8005-2fd17ece880a": {
"~#shape": {
"~:y": 221,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Rectangle",
"~:width": 237,
"~:type": "~:rect",
"~:points": [
{"~#point": {"~:x": 234, "~:y": 221}},
{"~#point": {"~:x": 471, "~:y": 221}},
{"~#point": {"~:x": 471, "~:y": 382}},
{"~#point": {"~:x": 234, "~:y": 382}}
],
"~:shape-ref": "~u2e0995e6-d90f-80ed-8005-2fd0bd35e183",
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:constraints-v": "~:scale",
"~:constraints-h": "~:scale",
"~:id": "~u2e0995e6-d90f-80ed-8005-2fd17ece880a",
"~:parent-id": "~u2e0995e6-d90f-80ed-8005-2fd17ece8809",
"~:frame-id": "~u2e0995e6-d90f-80ed-8005-2fd17ece8809",
"~:strokes": [],
"~:x": 234,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 234, "~:y": 221,
"~:width": 237, "~:height": 161,
"~:x1": 234, "~:y1": 221,
"~:x2": 471, "~:y2": 382
}
},
"~:fills": [{"~:fill-color": "#B1B2B5", "~:fill-opacity": 1}],
"~:flip-x": null,
"~:ry": 0,
"~:height": 161,
"~:flip-y": null
}
}
},
"~:id": "~u3622460c-3408-81e2-8005-2fd0e55888b8",
"~:name": "Page 1",
"~:background": "#e8e9ea"
}
},
"~:id": "~u3622460c-3408-81e2-8005-2fd0e55888b7",
"~:options": {
"~:components-v2": true
},
"~:components": {}
}
}

View File

@ -0,0 +1,243 @@
{
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": true,
"~:comment-thread-seqn": 0,
"~:name": "published component",
"~:revn": 3,
"~:modified-at": "~m1730103592325",
"~:vern": 0,
"~:id": "~u3622460c-3408-81e2-8005-2fc938010233",
"~:is-shared": true,
"~:version": 56,
"~:project-id": "~u3622460c-3408-81e2-8005-2fc9059741e0",
"~:created-at": "~m1730101410834",
"~:data": {
"~:pages": [
"~u3622460c-3408-81e2-8005-2fc938010234"
],
"~:pages-index": {
"~u3622460c-3408-81e2-8005-2fc938010234": {
"~:options": {},
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{"~#point": {"~:x": 0.0, "~:y": 0.0}},
{"~#point": {"~:x": 0.01, "~:y": 0.0}},
{"~#point": {"~:x": 0.01, "~:y": 0.01}},
{"~#point": {"~:x": 0.0, "~:y": 0.01}}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:selrect": {
"~#rect": {
"~:x": 0, "~:y": 0,
"~:width": 0.01, "~:height": 0.01,
"~:x1": 0, "~:y1": 0,
"~:x2": 0.01, "~:y2": 0.01
}
},
"~:fills": [{"~:fill-color": "#FFFFFF", "~:fill-opacity": 1}],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a"
]
}
},
"~u2e0995e6-d90f-80ed-8005-2fd0bd35e183": {
"~#shape": {
"~:y": 214,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Rectangle",
"~:width": 237,
"~:type": "~:rect",
"~:points": [
{"~#point": {"~:x": 238, "~:y": 214}},
{"~#point": {"~:x": 475, "~:y": 214}},
{"~#point": {"~:x": 475, "~:y": 375}},
{"~#point": {"~:x": 238, "~:y": 375}}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:constraints-v": "~:scale",
"~:constraints-h": "~:scale",
"~:id": "~u2e0995e6-d90f-80ed-8005-2fd0bd35e183",
"~:parent-id": "~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a",
"~:frame-id": "~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a",
"~:strokes": [],
"~:x": 238,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 238, "~:y": 214,
"~:width": 237, "~:height": 161,
"~:x1": 238, "~:y1": 214,
"~:x2": 475, "~:y2": 375
}
},
"~:fills": [{"~:fill-color": "#3656b4", "~:fill-opacity": 1}],
"~:flip-x": null,
"~:ry": 0,
"~:height": 161,
"~:flip-y": null
}
},
"~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a": {
"~#shape": {
"~:y": 214,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:hide-in-viewer": true,
"~:name": "Rectangle",
"~:width": 237,
"~:type": "~:frame",
"~:points": [
{"~#point": {"~:x": 238, "~:y": 214}},
{"~#point": {"~:x": 475, "~:y": 214}},
{"~#point": {"~:x": 475, "~:y": 375}},
{"~#point": {"~:x": 238, "~:y": 375}}
],
"~:component-root": true,
"~:show-content": true,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:component-id": "~u2e0995e6-d90f-80ed-8005-2fd0c20489ce",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 238,
"~:main-instance": true,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 238, "~:y": 214,
"~:width": 237, "~:height": 161,
"~:x1": 238, "~:y1": 214,
"~:x2": 475, "~:y2": 375
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": 161,
"~:component-file": "~u3622460c-3408-81e2-8005-2fc938010233",
"~:flip-y": null,
"~:shapes": [
"~u2e0995e6-d90f-80ed-8005-2fd0bd35e183"
]
}
}
},
"~:id": "~u3622460c-3408-81e2-8005-2fc938010234",
"~:name": "Page 1"
}
},
"~:id": "~u3622460c-3408-81e2-8005-2fc938010233",
"~:options": {
"~:components-v2": true
},
"~:components": {
"~u2e0995e6-d90f-80ed-8005-2fd0c20489ce": {
"~:id": "~u2e0995e6-d90f-80ed-8005-2fd0c20489ce",
"~:name": "Rectangle",
"~:path": "",
"~:modified-at": "~m1730103592327",
"~:main-instance-id": "~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a",
"~:main-instance-page": "~u3622460c-3408-81e2-8005-2fc938010234"
}
}
}
}

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1,308 @@
{
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/shape-data-type"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "Rename Page Test File",
"~:revn": 1,
"~:modified-at": "~m1713873823633",
"~:id": "~uaaaaaaaa-0000-0000-0000-000000000001",
"~:is-shared": false,
"~:version": 46,
"~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
"~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d",
"~:created-at": "~m1713536343369",
"~:data": {
"~:pages": [
"~ubbbbbbbb-0000-0000-0000-000000000001",
"~ucccccccc-0000-0000-0000-000000000002",
"~udddddddd-0000-0000-0000-000000000003"
],
"~:pages-index": {
"~ubbbbbbbb-0000-0000-0000-000000000001": {
"~:options": {},
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{"~#point": {"~:x": 0, "~:y": 0}},
{"~#point": {"~:x": 0.01, "~:y": 0}},
{"~#point": {"~:x": 0.01, "~:y": 0.01}},
{"~#point": {"~:x": 0, "~:y": 0.01}}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [{"~:fill-color": "#FFFFFF", "~:fill-opacity": 1}],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": ["~ueeeeeee0-0000-0000-0000-000000000001"]
}
},
"~ueeeeeee0-0000-0000-0000-000000000001": {
"~#shape": {
"~:y": 100,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Rectangle",
"~:width": 200,
"~:type": "~:rect",
"~:points": [
{"~#point": {"~:x": 100, "~:y": 100}},
{"~#point": {"~:x": 300, "~:y": 100}},
{"~#point": {"~:x": 300, "~:y": 300}},
{"~#point": {"~:x": 100, "~:y": 300}}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~ueeeeeee0-0000-0000-0000-000000000001",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 100,
"~:proportion": 1.0,
"~:selrect": {
"~#rect": {
"~:x": 100,
"~:y": 100,
"~:width": 200,
"~:height": 200,
"~:x1": 100,
"~:y1": 100,
"~:x2": 300,
"~:y2": 300
}
},
"~:fills": [{"~:fill-color": "#B1B2B5", "~:fill-opacity": 1}],
"~:flip-x": null,
"~:ry": 0,
"~:height": 200,
"~:flip-y": null
}
}
},
"~:id": "~ubbbbbbbb-0000-0000-0000-000000000001",
"~:name": "Page 1"
},
"~ucccccccc-0000-0000-0000-000000000002": {
"~:options": {},
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{"~#point": {"~:x": 0, "~:y": 0}},
{"~#point": {"~:x": 0.01, "~:y": 0}},
{"~#point": {"~:x": 0.01, "~:y": 0.01}},
{"~#point": {"~:x": 0, "~:y": 0.01}}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [{"~:fill-color": "#FFFFFF", "~:fill-opacity": 1}],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": []
}
}
},
"~:id": "~ucccccccc-0000-0000-0000-000000000002",
"~:name": "Page 2"
},
"~udddddddd-0000-0000-0000-000000000003": {
"~:options": {},
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{"~#point": {"~:x": 0, "~:y": 0}},
{"~#point": {"~:x": 0.01, "~:y": 0}},
{"~#point": {"~:x": 0.01, "~:y": 0.01}},
{"~#point": {"~:x": 0, "~:y": 0.01}}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [{"~:fill-color": "#FFFFFF", "~:fill-opacity": 1}],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": []
}
}
},
"~:id": "~udddddddd-0000-0000-0000-000000000003",
"~:name": "Page 3"
}
},
"~:id": "~uaaaaaaaa-0000-0000-0000-000000000001",
"~:options": {
"~:components-v2": true
},
"~:recent-colors": []
}
}

View File

@ -0,0 +1,224 @@
{
"~#set": [
{
"~:name": "published component",
"~:revn": 3,
"~:modified-at": "~m1730103592325",
"~:vern": 0,
"~:thumbnail-uri": "http://localhost:3449/assets/by-id/878a76a2-6235-4541-ba8a-d4f17cf5253c",
"~:id": "~u3622460c-3408-81e2-8005-2fc938010233",
"~:is-shared": true,
"~:project-id": "~u3622460c-3408-81e2-8005-2fc9059741e0",
"~:created-at": "~m1730101410834",
"~:library-summary": {
"~:components": {
"~:count": 1,
"~:sample": [
{
"~:id": "~u2e0995e6-d90f-80ed-8005-2fd0c20489ce",
"~:name": "Rectangle",
"~:path": "",
"~:modified-at": "~m1730103592327",
"~:main-instance-id": "~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a",
"~:main-instance-page": "~u3622460c-3408-81e2-8005-2fc938010234",
"~:objects": {
"~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a": {
"~#shape": {
"~:y": 214,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:hide-in-viewer": true,
"~:name": "Rectangle",
"~:width": 237,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 238,
"~:y": 214
}
},
{
"~#point": {
"~:x": 475,
"~:y": 214
}
},
{
"~#point": {
"~:x": 475,
"~:y": 375
}
},
{
"~#point": {
"~:x": 238,
"~:y": 375
}
}
],
"~:component-root": true,
"~:show-content": true,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:component-id": "~u2e0995e6-d90f-80ed-8005-2fd0c20489ce",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 238,
"~:main-instance": true,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 238,
"~:y": 214,
"~:width": 237,
"~:height": 161,
"~:x1": 238,
"~:y1": 214,
"~:x2": 475,
"~:y2": 375
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": 161,
"~:component-file": "~u3622460c-3408-81e2-8005-2fc938010233",
"~:flip-y": null,
"~:shapes": [
"~u2e0995e6-d90f-80ed-8005-2fd0bd35e183"
]
}
},
"~u2e0995e6-d90f-80ed-8005-2fd0bd35e183": {
"~#shape": {
"~:y": 214,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Rectangle",
"~:width": 237,
"~:type": "~:rect",
"~:points": [
{
"~#point": {
"~:x": 238,
"~:y": 214
}
},
{
"~#point": {
"~:x": 475,
"~:y": 214
}
},
{
"~#point": {
"~:x": 475,
"~:y": 375
}
},
{
"~#point": {
"~:x": 238,
"~:y": 375
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:constraints-v": "~:scale",
"~:constraints-h": "~:scale",
"~:id": "~u2e0995e6-d90f-80ed-8005-2fd0bd35e183",
"~:parent-id": "~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a",
"~:frame-id": "~u2e0995e6-d90f-80ed-8005-2fd0c2033a5a",
"~:strokes": [],
"~:x": 238,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 238,
"~:y": 214,
"~:width": 237,
"~:height": 161,
"~:x1": 238,
"~:y1": 214,
"~:x2": 475,
"~:y2": 375
}
},
"~:fills": [
{
"~:fill-color": "#3656b4",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:ry": 0,
"~:height": 161,
"~:flip-y": null
}
}
}
}
]
},
"~:media": {
"~:count": 0,
"~:sample": []
},
"~:colors": {
"~:count": 0,
"~:sample": []
},
"~:typographies": {
"~:count": 0,
"~:sample": []
}
}
}
]
}

View File

@ -0,0 +1,80 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-token-input"]);
});
test("BUG 14226: Numeric inputs in the design panel reject values with leading whitespace", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
// Select first shape
await page.getByTestId("layer-item").getByRole("button").first().click();
await workspacePage.layers.getByTestId("layer-row").nth(0).click();
// Check if measures section is visible
const measuresSection = workspacePage.rightSidebar.getByRole("region", {
name: "shape-measures-section",
});
await expect(measuresSection).toBeVisible();
// Width
const widthInput = measuresSection.getByRole("textbox", {
name: "Width",
exact: true,
});
await expect(widthInput).toHaveValue("360");
await widthInput.fill("100");
await widthInput.press("Enter");
await expect(widthInput).toHaveValue("100");
await widthInput.fill(" 100");
await widthInput.press("Enter");
await expect(widthInput).toHaveValue("100");
await widthInput.fill(" 100 ");
await widthInput.press("Enter");
await expect(widthInput).toHaveValue("100");
await widthInput.fill("100 ");
await widthInput.press("Enter");
await expect(widthInput).toHaveValue("100");
await widthInput.fill("98+2");
await widthInput.press("Enter");
await expect(widthInput).toHaveValue("100");
await widthInput.fill("98 + 2");
await widthInput.press("Enter");
await expect(widthInput).toHaveValue("100");
await widthInput.fill(" 98 + 2 ");
await widthInput.press("Enter");
await expect(widthInput).toHaveValue("100");
await widthInput.fill(" 98+2 ");
await widthInput.press("Enter");
await expect(widthInput).toHaveValue("100");
await widthInput.fill(" asdasdasdasd ");
await widthInput.press("Enter");
await expect(widthInput).toHaveValue("100");
});

View File

@ -0,0 +1,127 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
// UUIDs matching the fixture file `workspace/get-file-rename-page.json`
const FILE_ID = "aaaaaaaa-0000-0000-0000-000000000001";
const PAGE1_ID = "bbbbbbbb-0000-0000-0000-000000000001"; // non-empty (has a rectangle)
const PAGE2_ID = "cccccccc-0000-0000-0000-000000000002"; // empty
const PAGE3_ID = "dddddddd-0000-0000-0000-000000000003"; // empty
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
});
/**
* Returns a locator for the interactive body of a page item in the sitemap
* sidebar, identified by its page UUID.
*/
function getPageItem(page, pageId) {
return page.getByTestId(`page-${pageId}`);
}
/**
* Returns the visible name span inside a page item (not in edit mode).
*/
function getPageNameSpan(page, pageId) {
return getPageItem(page, pageId).getByTestId("page-name");
}
/**
* Double-clicks a page item to enter rename mode, types the new name and
* confirms with Enter.
*/
async function renamePage(page, pageId, newName) {
const item = getPageItem(page, pageId);
await item.dblclick();
const input = item.locator("input");
await expect(input).toBeVisible();
await input.selectText();
await input.fill(newName);
await page.keyboard.press("Enter");
}
async function setupWorkspace(workspacePage) {
await workspacePage.setupEmptyFile();
// Override the file mock with the 3-page fixture.
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-rename-page.json",
);
// Mock update-file so rename commits don't fail.
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-create-rect.json",
);
// The base WorkspacePage uses `this.pageName` (getByTestId("page-name")) as a
// readiness signal inside goToWorkspace. With 3 pages in the sidebar there are
// 3 matching elements, which violates Playwright's strict mode. Narrow the
// locator to the first occurrence so the internal readiness check can pass.
workspacePage.pageName = workspacePage.page
.getByTestId("page-name")
.first();
await workspacePage.goToWorkspace({
fileId: FILE_ID,
pageId: PAGE1_ID,
pageName: "Page 1",
});
}
test("User renames a non-empty page to '---' — page is renamed, URL does not change", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
await setupWorkspace(workspacePage);
// Confirm we are on Page 1.
await expect(getPageNameSpan(page, PAGE1_ID)).toHaveText("Page 1");
const urlBefore = page.url();
expect(urlBefore).toContain(`page-id=${PAGE1_ID}`);
// Rename the non-empty page to "---".
await renamePage(page, PAGE1_ID, "---");
// The page name should have changed to "---".
await expect(getPageNameSpan(page, PAGE1_ID)).toHaveText("---");
// The URL must still point to the same page (no navigation triggered).
await expect(page).toHaveURL(new RegExp(`page-id=${PAGE1_ID}`));
// The page must NOT be rendered as a separator (it still has shapes).
await expect(
getPageItem(page, PAGE1_ID).getByTestId("page-separator"),
).not.toBeVisible();
});
test("User renames an empty page to '---' — page becomes a separator and URL changes", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
await setupWorkspace(workspacePage);
// Navigate to the second page (empty) by clicking it in the sitemap.
const page2Item = getPageItem(page, PAGE2_ID);
await page2Item.click();
// Wait until the URL reflects the navigation to Page 2.
await expect(page).toHaveURL(new RegExp(`page-id=${PAGE2_ID}`));
// Rename the empty page to "---".
await renamePage(page, PAGE2_ID, "---");
// Since the renamed page is empty, it must now be shown as a separator.
await expect(
getPageItem(page, PAGE2_ID).getByTestId("page-separator"),
).toBeVisible();
// The application must have navigated away from the separator page to
// a different page (Page 1 or Page 3 depending on fallback logic).
await expect(page).not.toHaveURL(new RegExp(`page-id=${PAGE2_ID}`));
// The current URL must still be a workspace URL pointing to the same file.
await expect(page).toHaveURL(new RegExp(`file-id=${FILE_ID}`));
});

View File

@ -1200,6 +1200,123 @@ test("BUG: 14200, Tokens in sets are applied when clicking on Save during creati
await expect(borderTokenPill).not.toBeVisible();
});
test("Check token application across sets", async ({ page }) => {
// Setup the workspace with token features enabled
const {
workspacePage,
tokensSidebar,
tokenContextMenuForToken,
tokenThemesSetsSidebar,
tokenSetGroupItems,
tokensUpdateCreateModal,
} = await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
const changeSetInput = async (sidebar, setName, finalKey = "Enter") => {
const setInput = sidebar.locator("input:focus");
await expect(setInput).toBeVisible();
await setInput.fill(setName);
await setInput.press(finalKey);
};
const createSet = async (sidebar, setName, finalKey = "Enter") => {
const tokensTabButton = sidebar
.getByRole("button", { name: "Add set" })
.click();
await changeSetInput(sidebar, setName, (finalKey = "Enter"));
};
const createTokenInSet = async (tokenName, tokenValue) => {
await unfoldTokenType(tokensSidebar, "Border radius");
// Create border token
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: `Add Token: Border radius` })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(tokenName);
const valueField = tokensUpdateCreateModal.getByRole("combobox", {
name: "Value",
});
await valueField.fill(tokenValue);
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
};
// Select rectangle layer
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Rectangle" })
.first()
.click();
// Go to tokens tab
await page.getByRole("tab", { name: "Tokens" }).click();
// Create nested token set, select it and activate it
await createSet(tokenThemesSetsSidebar, "device/desktop");
const desktopSetButton = tokenThemesSetsSidebar.getByRole("button", {
name: "desktop",
});
await desktopSetButton.click();
await desktopSetButton.getByRole("checkbox").click();
// Create token in nested set
await unfoldTokenType(tokensSidebar, "Border radius");
// Create border token
await createTokenInSet("border-radius", "20");
// Check "border" token is not applied while creating.
const borderRadiusSection = page.getByRole("region", {
name: "Border radius section",
});
await expect(borderRadiusSection).toBeVisible();
// Check if token pill is visible on design tab on right sidebar
const borderTokenPill = borderRadiusSection.getByRole("button", {
name: "border-radius",
exact: true,
});
await expect(borderTokenPill).not.toBeVisible();
// Apply token to shape
await tokensSidebar
.getByRole("button", { name: "border-radius", exact: true })
.click();
await expect(borderTokenPill).toBeVisible();
await expect(borderTokenPill).toHaveText("20");
//Create new set, select it and activate it
await createSet(tokenThemesSetsSidebar, "device/mobile");
const mobileSetButton = tokenThemesSetsSidebar.getByRole("button", {
name: "mobile",
});
await mobileSetButton.click();
await mobileSetButton.getByRole("checkbox").click();
//Create the same token in new set with different value
await createTokenInSet("border-radius", "30");
//Check token is applied and value updated.
await expect(borderRadiusSection).toBeVisible();
await expect(borderTokenPill).toBeVisible();
await expect(borderTokenPill).toHaveText("30");
});
test("BUG: 14191, Apply tokens from different set", async ({ page }) => {
const {
workspacePage,
@ -1656,3 +1773,36 @@ test.describe("Numeric Input and Token Integration Tests", () => {
await expect(brokenPill).toHaveCount(2);
});
});
test("BUG: 14234, Numeric input token filtering must be case sensitive", async ({
page,
}) => {
const { workspacePage, tokensSidebar } = await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
await unfoldTokenType(tokensSidebar, "dimensions");
await createToken(page, "Dimensions", "Dim-up", "Value", "20");
await createToken(page, "Dimensions", "dim-up", "Value", "10");
const measuresSection = page.getByRole("region", {
name: "shape-measures-section",
});
await expect(measuresSection).toBeVisible();
const widthInput = measuresSection.getByRole("textbox", {
name: "Width",
});
await widthInput.click();
await widthInput.type("{Dim");
await expect(
measuresSection.getByText("Dim-up", { exact: true }),
).toBeVisible();
await expect(
measuresSection.getByText("dim-up", { exact: true }),
).not.toBeVisible();
});

View File

@ -2076,3 +2076,27 @@ test.describe("Tokens tab - delete", () => {
await expect(colorToken).not.toBeVisible();
});
});
test("BUG: 1425 Token is not highlighted in red when value references a token in a disabled set", async ({
page,
}) => {
const { tokensSidebar, tokenContextMenuForToken, tokenThemesSetsSidebar } =
await setupTokensFileRender(page);
await expect(tokensSidebar).toBeVisible();
await unfoldTokenType(tokensSidebar, "Border radius");
await createToken(page, "Border radius", "base-radius", "Value", "20");
await createToken(page, "Border radius", "ref-base", "Value", "{base-radius}");
const refTokenPill = tokensSidebar.getByRole("button", {
name: "ref-base",
});
await expect(refTokenPill).toBeVisible();
const CoreSetCheckbox = tokenThemesSetsSidebar.getByRole('button', { name: 'core' }).getByRole('checkbox');
await CoreSetCheckbox.click();
const brokenTokenPill = tokensSidebar.getByRole('button', { name: 'Missing reference ref-base' });
await expect(brokenTokenPill).toBeVisible();
});

View File

@ -651,6 +651,108 @@ test.describe("Remapping a single token", () => {
});
test.describe("Remapping group of tokens", () => {
test("User renames a group, remaps and undoes - tokens remain applied", async ({
page,
}) => {
const { tokensSidebar } = await setupTokensFileRender(page);
const workspacePage = new WasmWorkspacePage(page);
const rightSidebar = workspacePage.rightSidebar;
// Create multiple tokens in a group. Use a name not present in the mock
// file to avoid conflicts with pre-existing token groups.
await createToken(
page,
"Color",
"brand.primary",
"Value",
"#0000FF",
);
await createToken(
page,
"Color",
"brand.secondary",
"Value",
"#0055FF",
);
const brandNode = tokensSidebar.getByRole("button", {
name: "brand",
exact: true,
});
await expect(brandNode).toBeVisible();
// Apply the token to a shape so that references exist and the
// remapping modal is triggered on rename
await page.getByRole("tab", { name: "Layers" }).click();
await page
.getByTestId("layer-row")
.filter({ hasText: "Rectangle" })
.first()
.click();
await page.getByRole("tab", { name: "Tokens" }).click();
const brandPrimaryToken = tokensSidebar.getByRole("button", {
name: "primary",
});
await brandPrimaryToken.click();
// Rename the group
await brandNode.click({ button: "right" });
const renameNodeButton = page.getByRole("button", {
name: "Rename",
exact: true,
});
await expect(renameNodeButton).toBeVisible();
await renameNodeButton.click();
const tokenRenameNodeModal = page.getByTestId("token-rename-node-modal");
await expect(tokenRenameNodeModal).toBeVisible();
const nameField = tokenRenameNodeModal.getByRole("textbox", {
name: "Name",
});
await nameField.fill("brandy");
const submitButton = tokenRenameNodeModal.getByRole("button", {
name: "Rename",
});
await submitButton.click();
// Confirm remapping
const remappingModal = page.getByTestId("token-remapping-modal");
await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens",
});
await confirmButton.click();
// Verify the group was renamed and the token is still applied
const brandyNode = tokensSidebar.getByRole("button", {
name: "brandy",
exact: true,
});
await expect(brandyNode).toBeVisible();
const fillSection = rightSidebar.getByRole("region", {
name: "Fill section",
});
await expect(fillSection).toBeVisible();
await expect(
fillSection.getByLabel("brandy.primary", { exact: true }),
).toBeVisible();
// Undo the rename and remap as a single operation
await page.keyboard.press("ControlOrMeta+z");
// The original group name and token application should be fully restored
await expect(brandNode).toBeVisible();
await expect(
fillSection.getByLabel("brand.primary", { exact: true }),
).toBeVisible();
});
test("User renames a group - and remaps", async ({ page }) => {
const { tokensSidebar } = await setupTokensFileRender(page);
const workspacePage = new WasmWorkspacePage(page);

View File

@ -110,3 +110,51 @@ test("Bug 10113 - Empty library modal for non-empty library", async ({
workspace.page.getByText("Publish empty library"),
).not.toBeVisible();
});
test("BUG 14214 - Updates tab refreshes after syncing a freshly linked library", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile(page);
await workspace.mockRPC(
new RegExp(`get\\-file\\?id=${mainFileId}`),
"workspace/get-file-14214_main.json",
);
await workspace.mockRPC(
new RegExp(`get\\-file\\?id=${sharedFileId}`),
"workspace/get-file-14214_shared.json",
);
await workspace.mockRPC(
"get-file-libraries?file-id=*",
"workspace/get-file-libraries-14214.json",
);
await workspace.mockRPC(
"get-team-shared-files?team-id=*",
"workspace/get-team-shared-files-14214.json",
);
await workspace.mockRPC(
"link-file-to-library",
"workspace/link-file-to-library.json",
);
await workspace.goToWorkspace({ fileId: mainFileId, pageId: mainPageId });
// Open the library modal
await workspace.clickAssets();
await workspace.openLibrariesModal();
await workspace.librariesModal
.getByRole("button", { name: "Connect library" })
.click();
// Switch to the Updates tab — the library should appear as needing update.
await workspace.librariesModal.getByRole("tab", { name: "Updates" }).click();
const updatesPanel = workspace.librariesModal.getByRole("tabpanel", {
name: "UPDATES",
});
await updatesPanel.getByRole("button", { name: "Update" }).click();
await expect(
updatesPanel.getByText("There are no Shared Libraries that need update"),
).toBeVisible();
});

View File

@ -195,16 +195,19 @@
(defn login-from-token
"Used mainly as flow continuation after token validation."
[{:keys [profile] :as tdata}]
[{:keys [profile invitation-token] :as tdata}]
(ptk/reify ::login-from-token
ptk/WatchEvent
(watch [_ _ _]
(->> (dp/on-fetch-profile-success profile)
(rx/map (fn [profile]
(logged-in (with-meta profile {::ev/source "login-with-token"}))))
;; NOTE: we need this to be asynchronous because the effect
;; should be called before proceed with the login process
(rx/observe-on :async)))))
(let [profile (cond-> profile
invitation-token
(assoc :invitation-token invitation-token)
:always
(with-meta {::ev/source "login-with-token"}))]
(logged-in profile))))))))
(defn login-from-register
"Event used mainly for mark current session as logged-in in after the

View File

@ -18,7 +18,9 @@
[app.main.data.helpers :as dsh]
[app.main.features :as features]
[app.main.worker :as mw]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.shape :as wasm.shape]
[app.render-wasm.wasm :as wasm]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@ -76,6 +78,31 @@
(def ^:private xf:map-page-id
(map :page-id))
(def ^:private wasm-structural-change-types
#{:add-obj :mov-objects})
(defn- redo-changes-need-wasm-object-sync?
[redo-changes]
(some #(contains? wasm-structural-change-types (:type %)) redo-changes))
(defn- sync-wasm-structural-changes
[{:keys [redo-changes]}]
(ptk/reify ::sync-wasm-structural-changes
ptk/EffectEvent
(effect [_ state _]
(when (and wasm/context-initialized?
(not @wasm/context-lost?))
(let [objects (dsh/lookup-page-objects state)]
(doseq [{:keys [type id parent-id]} redo-changes
:when (contains? wasm-structural-change-types type)
:let [shape-id (case type
:add-obj id
:mov-objects parent-id)
shape (get objects shape-id)]
:when shape]
(wasm.api/process-object shape))
(wasm.api/request-render "sync-wasm-structural-changes"))))))
(defn- apply-changes-localy
[{:keys [file-id redo-changes ignore-wasm?] :as commit} pending]
(ptk/reify ::apply-changes-localy
@ -118,7 +145,16 @@
state)
;; wasm renderer deactivated
(update-in state [:files file-id :data] apply-changes))))))
(update-in state [:files file-id :data] apply-changes))))
ptk/WatchEvent
(watch [_ state _]
;; `:add-obj` / `:mov-objects` are not tracked via `*shape-changes*`.
;; Emit only for structural commits, after file data is in state.
(when (and (not ignore-wasm?)
(features/active-feature? state "render-wasm/v1")
(redo-changes-need-wasm-object-sync? redo-changes))
(rx/of (sync-wasm-structural-changes {:redo-changes redo-changes}))))))
(defn commit
"Create a commit event instance"

View File

@ -9,6 +9,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.features :as cfeat]
[app.common.files.changes :as cpc]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.schema :as sm]
@ -20,9 +21,11 @@
[app.main.data.common :as dcm]
[app.main.data.event :as ev]
[app.main.data.fonts :as df]
[app.main.data.helpers :as dsh]
[app.main.features :as features]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.render-wasm.api :as wasm.api]
[app.util.globals :as ug]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@ -171,6 +174,88 @@
(declare go-to-frame-by-index)
(declare go-to-frame-auto)
;; Applies to the viewer the changes passed as parameters
;; will not save the data but just modify the data localy
(defn- apply-changes-viewer
[changes]
(ptk/reify ::apply-changes-viewer
ptk/UpdateEvent
(update [_ state]
(let [file (-> (dm/get-in state [:viewer :file])
(update :data cpc/process-changes changes false))
pages
(->> (dm/get-in file [:data :pages])
(map (fn [page-id]
(let [data (get-in file [:data :pages-index page-id])]
[page-id (assoc data
:frames (ctt/get-viewer-frames (:objects data))
:all-frames (ctt/get-viewer-frames (:objects data) {:all-frames? true}))])))
(into {}))]
(-> state
(assoc-in [:viewer :file] file)
(assoc-in [:viewer :pages] pages))))))
(defn- generate-update-position-data-changes
[shapes page-id]
(reduce
(fn [result shape]
(conj result
{:type :mod-obj
:id (:id shape)
:page-id page-id
:operations
[{:type :set
:attr :position-data
:val (wasm.api/calculate-position-data shape)
:ignore-touched true
:ignore-geometry true}]}))
[]
shapes))
(defn update-page-position-data
[file-id page-id]
(ptk/reify ::update-page-position-data
ptk/WatchEvent
(watch [_ state _]
(if (features/active-feature? state "render-wasm/v1")
(let [objects (dsh/lookup-page-objects state file-id page-id)
shapes
(reduce-kv
(fn [result _ shape]
(cond-> result
(and (cfh/text-shape? shape) (nil? (:position-data shape)))
(conj shape)))
[]
objects)
;; Creates a stream from the async callback. This stream will only
;; emit one single value after the objects have finished loading
;; in the wasm memory.
set-objects-stream
(rx/create
(fn [subs]
(wasm.api/init-canvas-context (js/OffscreenCanvas. 0 0))
(wasm.api/set-objects-callback shapes #(rx/push! subs :done))
nil))]
(if (d/not-empty? shapes)
(->> (rx/from @wasm.api/module)
(rx/mapcat (constantly set-objects-stream))
(rx/mapcat
(fn []
(let [changes (generate-update-position-data-changes shapes page-id)
_ (wasm.api/clear-canvas)]
(if (d/not-empty? changes)
(rx/of (apply-changes-viewer changes))
(rx/empty))))))
(rx/empty)))
;; Render wasm disabled, we do nothing
(rx/empty)))))
(defn bundle-fetched
[{:keys [project file team share-links libraries users permissions thumbnails] :as bundle}]
(let [pages (->> (dm/get-in file [:data :pages])
@ -205,13 +290,17 @@
(let [route (:route state)
qparams (:query-params route)
index (some-> (rt/get-query-param qparams :index) parse-long)
frame-id (some-> (:frame-id qparams) uuid/parse)]
frame-id (some-> (:frame-id qparams) uuid/parse)
page-id (some-> (rt/get-query-param qparams :page-id) uuid/parse)
file-id (some-> (rt/get-query-param qparams :file-id) uuid/parse)]
(rx/merge
(rx/of (case (:zoom qparams)
"fit" zoom-to-fit
"fill" zoom-to-fill
nil))
(rx/of
(update-page-position-data file-id page-id)
(cond
(some? frame-id) (go-to-frame frame-id)
(some? index) (go-to-frame-by-index index)

View File

@ -204,36 +204,40 @@
(rx/take-until stopper-s))))))
(defn check-file-position-data
[file-id]
(ptk/reify ::fix-position-data
ptk/WatchEvent
(watch [it state _]
(let [file (dsh/lookup-file state file-id)
changes
(->> file :data :pages
(mapcat
(fn [page-id]
(->> (dsh/lookup-page-objects state file-id page-id)
(vals)
(filter cfh/text-shape?)
(filter #(nil? (:position-data %)))
(map (fn [shape]
{:type :mod-obj
:id (:id shape)
:page-id page-id
:operations
[{:type :set
:attr :position-data
:val (wasm.api/calculate-position-data shape)
:ignore-touched true
:ignore-geometry true}]})))))
(into []))]
(rx/of (dch/commit-changes
{:redo-changes changes :undo-changes []
:save-undo? false
:origin it
:tags #{:position-data}}))))))
(defn update-page-position-data
([]
(update-page-position-data nil nil))
([file-id page-id]
(ptk/reify ::update-page-position-data
ptk/WatchEvent
(watch [it state _]
(let [file-id (or file-id (:current-file-id state))
page-id (or page-id (:current-page-id state))
changes
(reduce-kv
(fn [result _ shape]
(if (and (cfh/text-shape? shape)
(nil? (:position-data shape)))
(conj result
{:type :mod-obj
:id (:id shape)
:page-id page-id
:operations
[{:type :set
:attr :position-data
:val (wasm.api/calculate-position-data shape)
:ignore-touched true
:ignore-geometry true}]})
result))
[]
(dsh/lookup-page-objects state file-id page-id))]
(if (d/not-empty? changes)
(rx/of (dch/commit-changes
{:redo-changes changes :undo-changes []
:save-undo? false
:origin it
:tags #{:position-data}}))
(rx/empty)))))))
(defn- workspace-initialized
[file-id]
@ -306,19 +310,6 @@
(rx/map bundle-fetched)
(rx/take-until stopper-s))))))
;; FIXME: this need docstring
(defn- process-wasm-object
[id]
(ptk/reify ::process-wasm-object
ptk/EffectEvent
(effect [_ state _]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
;; Only process objects that exist in the current page
;; This prevents errors when processing changes from other pages
(when shape
(wasm.api/process-object shape))))))
(defn initialize-file
[team-id file-id]
(assert (uuid? team-id) "expected valud uuid for `team-id`")
@ -444,18 +435,6 @@
(rx/take 1)
(rx/map #(dwcm/navigate-to-comment-id comment-id))))
(->> stream
(rx/filter dch/commit?)
(rx/filter render-wasm-ready?)
(rx/map deref)
(rx/mapcat
(fn [{:keys [redo-changes]}]
(let [added (->> redo-changes
(filter #(= (:type %) :add-obj))
(map :id))]
(->> (rx/from added)
(rx/map process-wasm-object))))))
(let [local-commits-s
(->> stream
(rx/filter dch/commit?)
@ -1493,18 +1472,47 @@
(update [_ state]
(assoc-in state [:workspace-global :clipboard-style] style))))
(defn open-layers-search
(defn- layers-search-config
[mode]
(ptk/reify ::open-layers-search
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-local :layers-panel-search] mode))))
{:open? true
:mode mode
:scope (if (= mode :find-and-replace) :canvas :layers)
:find-replace-mode? (= mode :find-and-replace)})
(def clear-layers-search
(ptk/reify ::clear-layers-search
(defn- layers-search-active?
[current target]
(and (:open? current false)
(= (:scope current) (:scope target))
(= (:find-replace-mode? current) (:find-replace-mode? target))))
(defn open-layers-search
([mode] (open-layers-search mode nil))
([mode options]
(let [force? (boolean (:force? options))]
(ptk/reify ::open-layers-search
ptk/UpdateEvent
(update [_ state]
(let [target (layers-search-config mode)
current (get-in state [:workspace-local :layers-search])]
(if (and (not force?)
(layers-search-active? current target))
(update state :workspace-local dissoc :layers-search)
(assoc-in state [:workspace-local :layers-search] target))))))))
(def close-layers-search
(ptk/reify ::close-layers-search
ptk/UpdateEvent
(update [_ state]
(update state :workspace-local dissoc :layers-panel-search))))
(update state :workspace-local dissoc :layers-search))))
(defn update-layers-search-scope
[scope]
(ptk/reify ::update-layers-search-scope
ptk/UpdateEvent
(update [_ state]
(if (get-in state [:workspace-local :layers-search])
(assoc-in state [:workspace-local :layers-search :scope] scope)
state))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Exports
@ -1565,6 +1573,8 @@
;; Highlight
(dm/export dwh/highlight-shape)
(dm/export dwh/dehighlight-shape)
(dm/export dwh/set-search-match-highlight)
(dm/export dwh/clear-search-match-highlight)
;; Shape flags
(dm/export dwsh/update-shape-flags)

View File

@ -27,3 +27,29 @@
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :highlighted] disj id))))
(defn set-search-match-highlight
"Highlight the active find/replace match on canvas and sidebar."
[current-id match-ids]
(dm/assert! (uuid? current-id))
(let [match-ids (set match-ids)]
(ptk/reify ::set-search-match-highlight
ptk/UpdateEvent
(update [_ state]
(let [highlighted (-> (get-in state [:workspace-local :highlighted] #{})
(set/difference match-ids)
(conj current-id))]
(-> state
(assoc-in [:workspace-local :search-match-highlight] current-id)
(assoc-in [:workspace-local :highlighted] highlighted)))))))
(defn clear-search-match-highlight
[match-ids]
(let [match-ids (set match-ids)]
(ptk/reify ::clear-search-match-highlight
ptk/UpdateEvent
(update [_ state]
(-> state
(update-in [:workspace-local :highlighted]
#(set/difference (or % #{}) match-ids))
(update :workspace-local dissoc :search-match-highlight))))))

View File

@ -1162,7 +1162,7 @@
(update [_ state]
(if (and (not= library-id (:current-file-id state))
(nil? asset-id))
(d/assoc-in-when state [:files library-id :synced-at] (ct/now))
(d/update-in-when state [:files library-id] assoc :synced-at (ct/now))
state))
ptk/WatchEvent

View File

@ -329,6 +329,9 @@
ptk/WatchEvent
(watch [it state _]
(let [page (dsh/lookup-page state id)
objects (:objects page)
empty-page? (and (= 1 (count objects))
(= uuid/zero (first (keys objects))))
changes (-> (pcb/empty-changes it)
(pcb/with-page page)
(pcb/mod-page page {:name name}))
@ -342,7 +345,11 @@
separator? (= "---" (str/trim name))]
(rx/concat
(rx/of (dch/commit-changes changes))
;; Go to other page only if page is empty (only has the root shape)
;; and the separator page is being renamed, otherwise user can rename
;; any page to separator and be forced to go to another page
(when (and separator?
empty-page?
(= id (:current-page-id state))
(some? fallback-page-id))
(rx/of (dcm/go-to-workspace :page-id fallback-page-id))))))))

View File

@ -47,10 +47,12 @@
(-> item
(assoc :name (extract-name href))
(assoc :url href))))))
(rx/filter (fn [item]
(or (contains? item :content)
(let [url (:url item)]
(or (str/starts-with? url "http://")
(str/starts-with? url "https://"))))))
(rx/mapcat (fn [item]
;; TODO: :create-file-media-object-from-url is
;; deprecated and this should be resolved in
;; frontend
(->> (rp/cmd! (if (contains? item :content)
:upload-file-media-object
:create-file-media-object-from-url)

View File

@ -1206,18 +1206,24 @@
[ids search replacement]
(ptk/reify ::replace-text-in-shapes
ptk/WatchEvent
(watch [_ _ _]
(let [undo-group (uuid/next)]
(rx/of
(dwsh/update-shapes
ids
(fn [shape]
(if (and (= :text (:type shape)) (some? (:content shape)))
(let [new-content (txt/replace-text-in-content (:content shape) search replacement)
new-name (txt/generate-shape-name (txt/content->text new-content))]
(-> shape (assoc :content new-content) (assoc :name new-name)))
shape))
{:attrs #{:content :name} :undo-group undo-group}))))))
(watch [_ state _]
(let [undo-group (uuid/next)
update-event
(dwsh/update-shapes
ids
(fn [shape]
(if (and (= :text (:type shape)) (some? (:content shape)))
(let [new-content (txt/replace-text-in-content (:content shape) search replacement)
new-name (txt/generate-shape-name (txt/content->text new-content))]
(-> shape (assoc :content new-content) (assoc :name new-name)))
shape))
{:attrs #{:content :name} :undo-group undo-group})]
(rx/concat
(rx/of update-event)
(if (features/active-feature? state "render-wasm/v1")
(->> (rx/from ids)
(rx/map #(dwwt/resize-wasm-text-debounce % {:undo-group undo-group})))
(rx/empty)))))))
;; -- Text Editor v3

View File

@ -603,7 +603,7 @@
(merge (meta it))))))))))
(defn bulk-update-tokens
[set-id token-ids type old-path new-path]
[set-id token-ids type old-path new-path & {:keys [undo-group]}]
(dm/assert! (uuid? set-id))
(dm/assert! (every? uuid? token-ids))
(ptk/reify ::bulk-update-tokens
@ -624,7 +624,9 @@
(-> (pcb/empty-changes it)
(pcb/with-library-data data))
token-ids)]
token-ids)
changes (cond-> changes (some? undo-group) (assoc :undo-group undo-group))]
(toggle-token-path (str (name type) "." old-path))
(toggle-token-path (str (name type) "." new-path))
(rx/of (dch/commit-changes changes)
@ -681,7 +683,11 @@
token-id)]
(let [tokens (vals (ctob/get-tokens tokens-lib (ctob/get-id token-set)))
unames (map :name tokens) ;; TODO: add function duplicate-token in tokens-lib
suffix (tr "workspace.tokens.duplicate-suffix")
;; "copy" is intentionally not translated here. Token names are validated
;; against a restricted set of allowed characters (currently English-compatible),
;; so translating this suffix could introduce invalid characters and break
;; token name validation.
suffix "copy"
copy-name (cfh/generate-unique-name (:name token) unames :suffix suffix)
new-token (-> token
(ctob/reid (uuid/next))

View File

@ -98,7 +98,7 @@
references in other tokens. Updates to copy shapes are committed with
`:ignore-touched true` so the rename does not flip sync groups into the
:touched set and silently break later main→copy propagation."
[file-data old-token-name new-token-name]
[file-data old-token-name new-token-name & {:keys [undo-group]}]
(let [scan-results (scan-workspace-token-references file-data old-token-name)
tokens-lib (:tokens-lib file-data)
sets (ctob/get-sets tokens-lib)
@ -108,47 +108,61 @@
(vals (ctob/get-tokens tokens-lib (ctob/get-id set)))))
sets)
;; Group applied token references by container
refs-by-container (group-by :container (:applied-tokens scan-results))
;; Use apply-token logic to update shapes for both direct and alias references
shape-changes
(reduce-kv
(fn [changes container refs]
(let [shape-ids (map :shape-id refs)
;; Find the correct token to apply (new or alias)
token (or (some #(when (= (:name (:token %)) new-token-name) %) tokens-with-sets)
(some #(when (= (:name (:token %)) old-token-name) %) tokens-with-sets))
attributes (set (map :attribute refs))]
(if token
(-> (pcb/with-container changes container)
(pcb/update-shapes shape-ids
(fn [shape]
(update shape :applied-tokens
#(merge % (cft/attributes-map attributes (:token token)))))
{:ignore-touched true}))
;; Create a new independent changes so we can call `with-file-data` after `with-container`
;; otherwise it causes probelms looking up for objects
(let [container-changes
(-> (pcb/empty-changes)
(pcb/with-container container)
(pcb/with-file-data file-data)
(pcb/update-shapes shape-ids
(fn [shape]
(update shape :applied-tokens
#(merge % (cft/attributes-map attributes (:token token)))))
{:ignore-touched true}))]
(pcb/concat-changes changes container-changes))
changes)))
(-> (pcb/empty-changes)
(pcb/with-file-data file-data)
(pcb/with-library-data file-data))
refs-by-container)]
refs-by-container)
(reduce
(fn [changes ref]
(let [source-token-id (:source-token-id ref)]
(when-let [{:keys [token set]} (some #(when (= (:id (:token %)) source-token-id) %) tokens-with-sets)]
(let [old-value (:value token)
new-value (cto/update-token-value-references old-value old-token-name new-token-name)]
(pcb/set-token changes (ctob/get-id set) (:id token)
(assoc token :value new-value))))))
shape-changes
(:token-aliases scan-results))))
;; Create changes for updating token alias references
token-changes
(reduce
(fn [changes ref]
(let [source-token-id (:source-token-id ref)]
(when-let [{:keys [token set]} (some #(when (= (:id (:token %)) source-token-id) %) tokens-with-sets)]
(let [old-value (:value token)
new-value (cto/update-token-value-references old-value old-token-name new-token-name)]
(pcb/set-token changes (ctob/get-id set) (:id token)
(assoc token :value new-value))))))
shape-changes
(:token-aliases scan-results))]
(cond-> token-changes
(some? undo-group)
(assoc :undo-group undo-group))))
(defn remap-tokens
"Main function to remap all token references when a token name changes"
[old-token-name new-token-name]
[old-token-name new-token-name & {:keys [undo-group]}]
(ptk/reify ::remap-tokens
ptk/WatchEvent
(watch [_ state _]
(let [file-data (dh/lookup-file-data state)
token-changes (build-remap-changes file-data old-token-name new-token-name)]
token-changes (build-remap-changes file-data old-token-name new-token-name :undo-group undo-group)]
(log/info :hint "token-remapping"
:old-name old-token-name
:new-name new-token-name)
@ -156,13 +170,13 @@
(defn bulk-remap-tokens
"Helper function to remap a batch of tokens, used for node renaming"
[tokens-in-path new-tokens]
[tokens-in-path new-tokens & {:keys [undo-group]}]
(ptk/reify ::bulk-remap-tokens
ptk/WatchEvent
(watch [_ _ _]
(rx/concat
(map (fn [old-token new-token]
(remap-tokens (:name old-token) (:name new-token)))
(remap-tokens (:name old-token) (:name new-token) :undo-group undo-group))
tokens-in-path
new-tokens)))))

View File

@ -189,7 +189,7 @@
[:? [:& auth-page {:route route}]]
:auth-verify-token
[:? [:& verify-token-page* {:route route}]]
[:? [:> verify-token-page* {:route route}]]
:nitrate-entry
[:> nitrate-entry/nitrate-entry-page* {:profile profile}]

View File

@ -25,19 +25,11 @@
(defmulti handle-token (fn [token] (:iss token)))
(defmethod handle-token :verify-email
[{:keys [invitation-token] :as data}]
[data]
(cf/external-notify-register-success (:profile-id data))
(let [msg (tr "dashboard.notifications.email-verified-successfully")]
(ts/schedule 1000 #(st/emit! (ntf/success msg)))
;; If the verify-email JWE carries an :invitation-token, it means
;; the user registered via a team-invitation flow but had to verify
;; their email first. Log them in and then redirect to
;; :auth-verify-token with the invitation token, which will accept
;; the invitation as a logged-in user.
(if invitation-token
(st/emit! (da/login-from-token data)
(rt/nav :auth-verify-token {:token invitation-token}))
(st/emit! (da/login-from-token data)))))
(st/emit! (da/login-from-token data))))
(defmethod handle-token :change-email
[_data]
@ -86,7 +78,7 @@
;; :invalid-token -> corrupted / unknown / fallback
bad-token-reason (mf/use-state nil)]
(mf/with-effect []
(mf/with-effect [token]
(dom/set-html-title (tr "title.default"))
(->> (rp/cmd! :verify-token {:token token})
(rx/subs!

View File

@ -13,7 +13,8 @@
[rumext.v2 :as mf]))
(mf/defc search-bar*
[{:keys [id class value placeholder icon-id auto-focus on-change on-clear on-submit children]}]
[{:keys [id class value placeholder icon-id auto-focus input-ref
on-change on-clear on-submit on-key-down children]}]
(let [handle-change
(mf/use-fn
(mf/deps on-change)
@ -31,8 +32,11 @@
handle-key-down
(mf/use-fn
(mf/deps on-submit)
(mf/deps on-submit on-key-down)
(fn [event]
(when (fn? on-key-down)
(on-key-down event))
(let [enter? (kbd/enter? event)
esc? (kbd/esc? event)
node (dom/get-target event)]
@ -53,6 +57,7 @@
:size "s"
:class (stl/css :icon)}])
[:input {:id id
:ref input-ref
:class (stl/css :search-input)
:on-change handle-change
:value value

View File

@ -607,14 +607,14 @@
(mf/defc team-members-page*
[{:keys [team profile]}]
(mf/with-effect [team]
(mf/with-effect [(:id team)]
(dom/set-html-title
(tr "title.team-members"
(if (:is-default team)
(tr "dashboard.your-penpot")
(:name team)))))
(mf/with-effect [team]
(mf/with-effect [(:id team)]
(st/emit! (dtm/fetch-members)))
[:*
@ -1159,7 +1159,7 @@
(tr "dashboard.your-penpot")
(:name team)))))
(mf/with-effect [(:id team) (:members team)]
(mf/with-effect [(:id team)]
(st/emit! (dtm/fetch-invitations)))
[:*

View File

@ -867,6 +867,7 @@
.modal-invitation-content {
overflow: auto;
color: var(--color-foreground-secondary);
}
.invitation-list p {

View File

@ -68,7 +68,9 @@
(defn- get-option-by-name
[options name]
(let [options (if (delay? options) (deref options) options)]
(d/seek #(= name (get % :name)) options)))
(d/seek #(and (= :token (get % :type))
(= name (get % :name)))
options)))
(defn- get-token-op
[tokens name]
@ -289,33 +291,35 @@
(mf/use-fn
(mf/deps on-change update-input value nillable min max)
(fn [raw-value]
(if-let [parsed (parse-value raw-value (mf/ref-val last-value*) min max nillable)]
(when-not (= parsed (mf/ref-val last-value*))
(mf/set-ref-val! last-value* parsed)
(reset! token-applied-name* nil)
(when (fn? on-change)
(on-change parsed))
(mf/set-ref-val! raw-value* (fmt/format-number parsed))
(update-input (fmt/format-number parsed)))
(if (and nillable (empty? raw-value))
(let [raw-value (str/trim (str raw-value))]
(if-let [parsed (parse-value raw-value (mf/ref-val last-value*) min max nillable)]
(do
(mf/set-ref-val! last-value* nil)
(mf/set-ref-val! raw-value* "")
(reset! token-applied-name* nil)
(update-input "")
(when (fn? on-change)
(on-change nil)))
(when-not (= parsed (mf/ref-val last-value*))
(mf/set-ref-val! last-value* parsed)
(reset! token-applied-name* nil)
(when (fn? on-change)
(on-change parsed)))
(let [fallback-value (or (mf/ref-val last-value*) default)]
(mf/set-ref-val! raw-value* fallback-value)
(mf/set-ref-val! last-value* fallback-value)
(reset! token-applied-name* nil)
(update-input (fmt/format-number fallback-value))
(mf/set-ref-val! raw-value* (fmt/format-number parsed))
(update-input (fmt/format-number parsed)))
(when (and (fn? on-change) (not= fallback-value (str value)))
(on-change fallback-value)))))))
(if (and nillable (empty? raw-value))
(do
(mf/set-ref-val! last-value* nil)
(mf/set-ref-val! raw-value* "")
(reset! token-applied-name* nil)
(update-input "")
(when (fn? on-change)
(on-change nil)))
(let [fallback-value (or (mf/ref-val last-value*) default)]
(mf/set-ref-val! raw-value* fallback-value)
(mf/set-ref-val! last-value* fallback-value)
(reset! token-applied-name* nil)
(update-input (fmt/format-number fallback-value))
(when (and (fn? on-change) (not= fallback-value (str value)))
(on-change fallback-value))))))))
apply-token
(mf/use-fn
@ -340,6 +344,7 @@
(mf/deps apply-token)
(fn [id value name]
(mf/set-ref-val! token-selection-in-progress* true)
(mf/set-ref-val! dirty-ref false)
(reset! selected-id* id)
(reset! focused-id* nil)
(reset! is-open* false)
@ -430,8 +435,13 @@
(let [name (clean-token-name (mf/ref-val raw-value*))
token (get-option-by-name options name)]
(if token
(apply-token (:resolved-value token) name)
(apply-value (mf/ref-val last-value*)))))
(do
(apply-token (:resolved-value token) name)
(mf/set-ref-val! dirty-ref false)
(reset! filter-id* "")
(handle-blur event))
(apply-value (mf/ref-val last-value*))))
(reset! is-open* false))
enter?
(if is-open
@ -463,9 +473,13 @@
(dom/prevent-default event)
(handle-focus-change options focused-id* new-index (mf/ref-val nodes-ref)))
(let [parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable)
(let [parsed (parse-value (str/trim (mf/ref-val raw-value*)) (mf/ref-val last-value*) min max nillable)
current-value (or parsed default)
new-val (increment current-value step min max)]
eff-step (cond
(kbd/shift? event) (* step 10)
(kbd/alt? event) (* step 0.1)
:else step)
new-val (increment current-value eff-step min max)]
(dom/prevent-default event)
(update-input (fmt/format-number new-val))
(apply-value (dm/str new-val))))
@ -476,9 +490,13 @@
(dom/prevent-default event)
(handle-focus-change options focused-id* new-index (mf/ref-val nodes-ref)))
(let [parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable)
(let [parsed (parse-value (str/trim (mf/ref-val raw-value*)) (mf/ref-val last-value*) min max nillable)
current-value (or parsed default)
new-val (decrement current-value step min max)]
eff-step (cond
(kbd/shift? event) (* step 10)
(kbd/alt? event) (* step 0.1)
:else step)
new-val (decrement current-value eff-step min max)]
(dom/prevent-default event)
(update-input (fmt/format-number new-val))
(apply-value (dm/str new-val))))))))
@ -505,7 +523,7 @@
(let [inc? (->> (dom/get-delta-position event)
:y
(neg?))
parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable)
parsed (parse-value (str/trim (mf/ref-val raw-value*)) (mf/ref-val last-value*) min max nillable)
current-value (or parsed default)
new-val (if inc?
(increment current-value step min max)
@ -524,7 +542,7 @@
has-token (some? (deref token-applied-name*))]
(when-not (or is-focused has-token)
(let [client-x (.-clientX event)
parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable)
parsed (parse-value (str/trim (mf/ref-val raw-value*)) (mf/ref-val last-value*) min max nillable)
start-val (or parsed default 0)]
(mf/set-ref-val! drag-state* :maybe-dragging)
(mf/set-ref-val! drag-start-x* client-x)
@ -703,7 +721,9 @@
token-props
(when (and token-applied-name (not= :multiple token-applied-name))
(let [token (get-option-by-name dropdown-options token-applied-name)
id (get token :id)
id (or (get token :id)
(some-> (get token-applied :id)
(dm/str)))
label (or (get token :name) applied-token-name)
token-value (or (get token :resolved-value)
(or (mf/ref-val last-value*)

View File

@ -77,7 +77,7 @@
[:> c/navigation-bullets*
{:slide slide
:navigate navigate
:total 4}]
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
@ -118,7 +118,7 @@
[:> c/navigation-bullets*
{:slide slide
:navigate navigate
:total 4}]
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]

View File

@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.notifications :as ntf]
[app.main.data.profile :as du]
[app.main.refs :as refs]
@ -95,7 +96,11 @@
handle-render-change
(mf/use-fn
(fn [enabled?]
(st/emit! (du/update-profile-props {:renderer (if enabled? :wasm :svg)})
(st/emit! (ev/event {::ev/name (if enabled?
"enable-webgl-rendering"
"disable-webgl-rendering")
::ev/origin "settings"})
(du/update-profile-props {:renderer (if enabled? :wasm :svg)})
(ntf/success (tr (if enabled?
"webgl.toast.webgl-render-enabled"
"webgl.toast.webgl-render-disabled"))))))]

View File

@ -474,7 +474,7 @@
(mf/deps subscription-editors nitrate-license)
(fn [subscription-type current-subscription]
(st/emit! (ev/event {::ev/name "open-subscription-modal"
::ev/origin "settings:in-app"}))
::ev/origin "settings"}))
(if (= subscription-type "nitrate")
(st/emit! (dnt/show-nitrate-popup :nitrate-dialog {:nitrate-license nitrate-license}))
(st/emit!
@ -537,7 +537,7 @@
(st/emit!
(ev/event {::ev/name "open-subscription-modal"
::ev/origin "settings:from-pricing-page"})
::ev/origin "settings"})
(modal/show :management-dialog
{:subscription-type (if (= params-subscription "subscription-to-penpot-unlimited")
"unlimited"

View File

@ -229,8 +229,7 @@
(mf/deps profile)
(fn []
(let [team-id (:default-team-id profile)]
(st/emit! (rt/assign-exception nil)
(dcm/go-to-dashboard-recent :team-id team-id)))))
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))
on-success
(mf/use-fn

View File

@ -63,21 +63,21 @@
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "explore-help-center-click"
::ev/origin "workspace-menu:in-app"}))
::ev/origin "workspace:menu"}))
(dom/open-new-window "https://help.penpot.app")))
nav-to-community
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "explore-community-click"
::ev/origin "workspace-menu:in-app"}))
::ev/origin "workspace:menu"}))
(dom/open-new-window "https://community.penpot.app")))
nav-to-youtube
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "explore-tutorials-click"
::ev/origin "workspace-menu:in-app"}))
::ev/origin "workspace:menu"}))
(dom/open-new-window "https://www.youtube.com/c/Penpot")))
nav-to-templates
@ -91,14 +91,14 @@
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "explore-github-repository-click"
::ev/origin "workspace-menu:in-app"}))
::ev/origin "workspace:menu"}))
(dom/open-new-window "https://github.com/penpot/penpot")))
nav-to-terms
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "explore-terms-service-click"
::ev/origin "workspace-menu:in-app"}))
::ev/origin "workspace:menu"}))
(dom/open-new-window "https://penpot.app/terms")))
nav-to-feedback
@ -119,7 +119,7 @@
(st/emit!
(-> (dw/toggle-layout-flag :shortcuts)
(vary-meta assoc ::ev/origin "workspace-header")))))
(vary-meta assoc ::ev/origin "workspace:menu")))))
show-release-notes
(mf/use-fn
@ -347,7 +347,7 @@
(r/set-resize-type! :bottom)
(st/emit! (dw/remove-layout-flag :textpalette)
(-> (dw/toggle-layout-flag :colorpalette)
(vary-meta assoc ::ev/origin "workspace-menu")))))
(vary-meta assoc ::ev/origin "workspace:menu")))))
toggle-text-palette
(mf/use-fn
@ -355,7 +355,7 @@
(r/set-resize-type! :bottom)
(st/emit! (dw/remove-layout-flag :colorpalette)
(-> (dw/toggle-layout-flag :textpalette)
(vary-meta assoc ::ev/origin "workspace-menu")))))]
(vary-meta assoc ::ev/origin "workspace:menu")))))]
[:> dropdown-menu* {:show true
:class (stl/css :base-menu :sub-menu :pos-3)
@ -474,10 +474,10 @@
#(st/emit! (dw/select-all)))
find
(mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find))))
(mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find {:force? true}))))
find-and-replace
(mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find-and-replace))))
(mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find-and-replace {:force? true}))))
undo
(mf/use-fn
@ -629,7 +629,7 @@
(mf/deps file)
(fn [_]
(st/emit! (-> (fexp/open-export-dialog [file])
(with-meta {::ev/origin "workspace"})))))
(with-meta {::ev/origin "workspace:menu"})))))
on-export-file-key-down
(mf/use-fn
@ -809,7 +809,7 @@
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "manage-mpc-option"
::ev/origin "workspace-menu"}))
::ev/origin "workspace:menu"}))
(dom/open-new-window "/#/settings/integrations")))
on-nav-to-integrations-key-down
@ -824,10 +824,10 @@
(if mcp-connected?
(st/emit! (mcp/user-disconnect-mcp)
(ev/event {::ev/name "disconnect-mcp-plugin"
::ev/origin "workspace-menu"}))
::ev/origin "workspace:menu"}))
(st/emit! (mcp/connect-mcp)
(ev/event {::ev/name "connect-mcp-plugin"
::ev/origin "workspace-menu"})))))
::ev/origin "workspace:menu"})))))
on-toggle-mcp-plugin-key-down
(mf/use-fn
@ -910,7 +910,7 @@
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "explore-pricing-click"
::ev/origin "workspace-menu"}))
::ev/origin "workspace:menu"}))
(dom/open-new-window "https://penpot.app/pricing")))
toggle-flag
@ -922,7 +922,7 @@
(keyword))]
(st/emit!
(-> (dw/toggle-layout-flag flag)
(vary-meta assoc ::ev/origin "workspace-menu")))
(vary-meta assoc ::ev/origin "workspace:menu")))
(reset! show-menu* false)
(reset! selected-sub-menu* nil))))
@ -939,7 +939,11 @@
(dom/stop-propagation event)
(let [renderer (or (-> profile :props :renderer) :svg)
next-renderer (if (= renderer :wasm) :svg :wasm)]
(st/emit! (du/update-profile-props {:renderer next-renderer})
(st/emit! (ev/event {::ev/name (if (= next-renderer :wasm)
"enable-webgl-rendering"
"disable-webgl-rendering")
::ev/origin "workspace:menu"})
(du/update-profile-props {:renderer next-renderer})
(ntf/success (tr (if (= next-renderer :wasm)
"webgl.toast.webgl-render-enabled"
"webgl.toast.webgl-render-disabled")))))))
@ -973,7 +977,7 @@
:icon i/menu}]
[:> dropdown-menu* {:show show-menu?
:id "workspace-menu"
:id "workspace:menu"
:on-close close-menu
:class (stl/css :base-menu :menu)}
[:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item)

View File

@ -87,7 +87,7 @@
(for [shape shapes]
(let [thumbnail?
(and (not disable-thumbnails)
(contains? active-frames (dm/get-prop shape :id)))]
(not (contains? active-frames (dm/get-prop shape :id))))]
[:g.ws-shape-wrapper {:key (dm/str (dm/get-prop shape :id))}
(if ^boolean (cfh/frame-shape? shape)
[:& root-frame-wrapper

View File

@ -119,7 +119,7 @@
(mf/defc left-sidebar*
{::mf/memo true}
[{:keys [layout file tokens-lib active-tokens resolved-active-tokens]}]
[{:keys [layout file tokens-lib active-tokens resolved-active-tokens resolved-active-tokens-not-forced]}]
(let [options-mode (mf/deref refs/options-mode-global)
project (mf/deref refs/project)
file-id (get file :id)
@ -223,7 +223,8 @@
[:> tokens-sidebar-tab*
{:tokens-lib tokens-lib
:active-tokens active-tokens
:resolved-active-tokens resolved-active-tokens}]
:resolved-active-tokens resolved-active-tokens
:resolved-active-tokens-not-forced resolved-active-tokens-not-forced}]
:layers
[:> layers-content*
@ -399,6 +400,8 @@
resolved-active-tokens-force-set
(sd/use-resolved-tokens* active-tokens-force-set)]
;; TODO: This props should be passed though context to avoid prop drilling.
[:*
(if (:collapse-left-sidebar layout)
[:> collapsed-button*]
@ -407,6 +410,7 @@
:page-id page-id
:tokens-lib tokens-lib
:active-tokens active-tokens-force-set
:resolved-active-tokens-not-forced resolved-active-tokens
:resolved-active-tokens (if tokenscript?
tokenscript-resolved-active-tokens-force-set
resolved-active-tokens-force-set)}])

View File

@ -48,7 +48,10 @@
(let [{:keys [enter leave]} @sidebar-hover-queue
enter (set/difference enter leave)
leave (set/difference leave enter)]
leave (set/difference leave enter)
search-match (get-in @st/state [:workspace-local :search-match-highlight])
leave (cond-> leave
(some? search-match) (disj search-match))]
(reset! sidebar-hover-queue {:enter #{} :leave #{}})
(reset! sidebar-hover-pending? false)

View File

@ -19,7 +19,9 @@
[app.main.store :as st]
[app.main.ui.components.search-bar :refer [search-bar*]]
[app.main.ui.components.title-bar :refer [title-bar*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.hooks :as hooks]
[app.main.ui.notifications.badge :refer [badge-notification]]
@ -30,6 +32,7 @@
[app.util.keyboard :as kbd]
[app.util.rxops :refer [throttle-fn]]
[app.util.shape-icon :as usi]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[goog.events :as events]
@ -120,30 +123,28 @@
[:> hooks/sortable-container* {}
(for [obj shapes]
(if (cfh/frame-shape? obj)
[:> frame-wrapper*
{:item obj
:rename-id rename-id
:selected selected
:highlighted highlighted
:index (unchecked-get obj "__$__counter")
:objects objects
:key (dm/str (get obj :id))
:is-sortable true
:is-filtered is-filtered
:parent-size parent-size
:depth -1}]
[:> layer-item*
{:item obj
:rename-id rename-id
:selected selected
:highlighted highlighted
:index (unchecked-get obj "__$__counter")
:objects objects
:key (dm/str (get obj :id))
:is-sortable true
:is-filtered is-filtered
:depth -1
:parent-size parent-size}]))]]))
[:> frame-wrapper* {:item obj
:rename-id rename-id
:selected selected
:highlighted highlighted
:index (unchecked-get obj "__$__counter")
:objects objects
:key (dm/str (get obj :id))
:is-sortable true
:is-filtered is-filtered
:parent-size parent-size
:depth -1}]
[:> layer-item* {:item obj
:rename-id rename-id
:selected selected
:highlighted highlighted
:index (unchecked-get obj "__$__counter")
:objects objects
:key (dm/str (get obj :id))
:is-sortable true
:is-filtered is-filtered
:depth -1
:parent-size parent-size}]))]]))
(mf/defc layers-tree-wrapper*
{::mf/private true}
@ -175,21 +176,22 @@
{::mf/wrap [mf/memo #(mf/throttle % 300)]
::mf/private true}
[{:keys [objects parent-size]}]
(let [selected (use-selected-shapes)
root (get objects uuid/zero)]
(let [selected (use-selected-shapes)
highlighted (mf/deref ref:highlighted-shapes)
root (get objects uuid/zero)]
[:ul {:class (stl/css :element-list)}
(for [[index id] (d/enumerate (:shapes root))]
(when-let [obj (get objects id)]
[:> layer-item*
{:item obj
:selected selected
:index index
:objects objects
:key id
:is-sortable false
:is-filtered true
:depth -1
:parent-size parent-size}]))]))
[:> layer-item* {:item obj
:selected selected
:highlighted highlighted
:index index
:objects objects
:key id
:is-sortable false
:is-filtered true
:depth -1
:parent-size parent-size}]))]))
(defn calc-reparented-objects
[objects]
@ -208,8 +210,8 @@
;; --- Layers Toolbox
(def ^:private ref:layers-panel-search
(l/derived (l/key :layers-panel-search) refs/workspace-local))
(def ^:private ref:layers-search
(l/derived (l/key :layers-search) refs/workspace-local))
;; FIXME: optimize
(defn- match-filters?
@ -242,6 +244,21 @@
(false? (:masked-group shape))))
(and (contains? filters :mask) (true? (:masked-group shape))))))))
(mf/defc radio-button*
{::mf/private true}
[{:keys [name checked text on-change]}]
[:label {:class (stl/css-case :radio-label true
:selected checked)}
[:span {:class (stl/css-case :radio-icon true
:checked checked)}]
[:input {:type "radio"
:name name
:class (stl/css :radio-input)
:checked checked
:on-change on-change}]
[:span {:class (stl/css :radio-text)}
text]])
(defn use-search
[page objects]
(let [state* (mf/use-state
@ -254,7 +271,7 @@
:filters #{}
:num-items 100
:current-match-idx 0}))
layers-search-request (mf/deref ref:layers-panel-search)
layers-search (mf/deref ref:layers-search)
state (deref state*)
current-filters (:filters state)
current-items (:num-items state)
@ -265,13 +282,18 @@
find-replace-mode? (:find-replace-mode? state)
search-scope (:search-scope state)
current-match-idx (:current-match-idx state)
search-input-ref (mf/use-ref nil)
clear-search-text
(mf/use-fn
#(swap! state* assoc :search-text "" :num-items 100 :current-match-idx 0))
#(swap! state* assoc
:search-text ""
:num-items 100
:current-match-idx 0))
toggle-filters
(mf/use-fn #(swap! state* update :show-menu not))
(mf/use-fn
#(swap! state* update :show-menu not))
on-toggle-filters-click
(mf/use-fn
@ -280,51 +302,92 @@
(toggle-filters)))
hide-menu
(mf/use-fn #(swap! state* assoc :show-menu false))
(mf/use-fn
#(swap! state* assoc :show-menu false))
on-key-down
(mf/use-fn (fn [event] (when (kbd/esc? event) (hide-menu))))
(mf/use-fn
(fn [event]
(when (kbd/esc? event)
(hide-menu))))
update-search-text
(mf/use-fn
(fn [value _event]
(swap! state* assoc :search-text value :num-items 100 :current-match-idx 0)))
(fn [value]
(swap! state* assoc
:search-text value
:num-items 100
:current-match-idx 0)))
update-replace-text
(mf/use-fn (fn [value _event] (swap! state* assoc :replace-text value)))
(mf/use-fn
(fn [event]
(let [value (dom/get-target-val event)]
(swap! state* assoc :replace-text value))))
clear-replace-text
(mf/use-fn #(swap! state* assoc :replace-text ""))
f-key? (kbd/is-key-ignore-case? "f")
h-key? (kbd/is-key-ignore-case? "h")
handle-find-shortcut-keydown
(mf/use-fn
(fn [event]
(when (kbd/mod? event)
(cond
(f-key? event)
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(st/emit! (dw/open-layers-search :find)))
(h-key? event)
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(st/emit! (dw/open-layers-search :find-and-replace)))))))
set-search-scope
(mf/use-fn
(fn [scope]
(swap! state* assoc :search-scope scope :num-items 100 :current-match-idx 0)))
(swap! state* assoc
:search-scope scope
:num-items 100
:current-match-idx 0)
(st/emit! (dw/update-layers-search-scope scope))))
toggle-mode
(mf/use-fn
(mf/deps find-replace-mode?)
(fn []
(let [mode (if find-replace-mode? :find :find-and-replace)]
(st/emit! (dw/open-layers-search mode {:force? true})))))
toggle-search
(mf/use-fn
(mf/deps show-search?)
(fn [event]
(let [node (dom/get-current-target event)]
(dom/blur! node)
(swap! state* (fn [state]
(-> state
(assoc :search-text "" :replace-text "" :filters #{})
(assoc :show-menu false :find-replace-mode? false)
(assoc :search-scope :layers :num-items 100 :current-match-idx 0)
(update :show-search not)))))))
(if show-search?
(st/emit! dw/close-layers-search)
(st/emit! (dw/open-layers-search :find {:force? true}))))))
remove-filter
(mf/use-fn
(fn [event]
(let [fkey (-> (dom/get-current-target event) (dom/get-data "filter") (keyword))]
(let [fkey (-> (dom/get-current-target event)
(dom/get-data "filter")
(keyword))]
(swap! state* (fn [state]
(-> state (update :filters disj fkey) (assoc :num-items 100)))))))
(-> state
(update :filters disj fkey)
(assoc :num-items 100)))))))
add-filter
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(let [key (-> (dom/get-current-target event) (dom/get-data "filter") (keyword))]
(let [key (-> (dom/get-current-target event)
(dom/get-data "filter") (keyword))]
(swap! state* (fn [state]
(-> state
(update :filters conj key)
@ -374,7 +437,8 @@
(fn [_]
(when (pos? text-match-count)
(swap! state* update :current-match-idx
(fn [idx] (mod (inc idx) text-match-count))))))
(fn [idx]
(mod (inc idx) text-match-count))))))
navigate-prev
(mf/use-fn
@ -382,7 +446,8 @@
(fn [_]
(when (pos? text-match-count)
(swap! state* update :current-match-idx
(fn [idx] (mod (+ (dec idx) text-match-count) text-match-count))))))
(fn [idx]
(mod (+ (dec idx) text-match-count) text-match-count))))))
handle-replace
(mf/use-fn
@ -403,6 +468,24 @@
(st/emit! (dwt/replace-text-in-shapes text-match-ids current-search replace-text))
(st/emit! (dwt/replace-layer-names-in-shapes text-match-ids current-search replace-text))))))
on-replace-keydown
(mf/use-fn
(mf/deps handle-replace)
(fn [event]
(when (or (kbd/enter? event) (kbd/space? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(handle-replace event))))
on-replace-all-keydown
(mf/use-fn
(mf/deps handle-replace-all)
(fn [event]
(when (or (kbd/enter? event) (kbd/space? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(handle-replace-all event))))
filtered-objects
(mf/with-memo [active? filtered-objects-all current-items]
(when active?
@ -424,201 +507,242 @@
(events/unlistenByKey key1)
(events/unlistenByKey key2))))
(mf/with-effect [layers-search-request]
(when (some? layers-search-request)
(let [replace-mode? (= layers-search-request :find-and-replace)]
(mf/with-effect [layers-search]
(if-let [{:keys [open? find-replace-mode? scope]} layers-search]
(when open?
(swap! state* (fn [s]
(-> s
(assoc :show-search true :find-replace-mode? replace-mode?)
(assoc :search-scope (if replace-mode? :canvas :layers))
(assoc :search-text "" :replace-text "" :current-match-idx 0)))))
(st/emit! dw/clear-layers-search)))
(let [mode-changed? (not= (:find-replace-mode? s) find-replace-mode?)
opening? (not (:show-search s))]
(-> s
(assoc :show-search true
:find-replace-mode? find-replace-mode?
:search-scope scope)
(cond-> (or opening? mode-changed?)
(assoc :search-text "" :replace-text "" :current-match-idx 0)))))))
(swap! state* (fn [state]
(-> state
(assoc :search-text ""
:replace-text ""
:filters #{})
(assoc :show-menu false
:find-replace-mode? false)
(assoc :search-scope :layers
:num-items 100
:current-match-idx 0)
(assoc :show-search false))))))
(mf/with-effect [(get layers-search :scope)]
(when (and layers-search (:open? layers-search))
(swap! state* assoc :search-scope (:scope layers-search))))
(mf/with-effect [layers-search show-search?]
(when (and layers-search (:open? layers-search) show-search?)
(ts/raf
(fn []
(when-let [node (mf/ref-val search-input-ref)]
(dom/focus! node))))))
(mf/with-effect [find-replace-mode? show-search? safe-match-idx text-match-ids]
(let [match-ids text-match-ids]
(when (and find-replace-mode? show-search? (seq match-ids))
(let [current-id (nth match-ids safe-match-idx)]
(st/emit! (dw/set-search-match-highlight current-id match-ids))))
(fn []
(when (seq match-ids)
(st/emit! (dw/clear-search-match-highlight match-ids))))))
[filtered-objects
handle-show-more
#(mf/html
(if show-search?
[:*
[:div {:class (stl/css :tool-window-bar :search)}
[:> search-bar* {:on-change update-search-text
[:div {:class (stl/css :tool-window-bar)}
[:> search-bar* {:input-ref search-input-ref
:class (stl/css :search-item)
:on-change update-search-text
:value current-search
:on-clear clear-search-text
:on-key-down handle-find-shortcut-keydown
:placeholder (tr "workspace.sidebar.layers.search")}
[:button {:on-click on-toggle-filters-click
:class (stl/css-case :filter-button true :opened show-menu? :active active?)}
[:> icon* {:icon-id i/filter}]]]
[:> icon-button* {:variant "secondary"
:class (stl/css :filter-button)
:aria-pressed show-menu?
:aria-label (tr "workspace.sidebar.layers.filter")
:on-click on-toggle-filters-click
:icon i/filter}]]
[:> icon-button* {:variant "ghost"
:aria-pressed find-replace-mode?
:aria-label (tr "workspace.sidebar.layers.search-and-replace")
:on-click toggle-mode
:icon i/menu}]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click toggle-search
:icon i/close}]]
[:div {:class (stl/css :search-scope-row)}
[:label {:class (stl/css-case :scope-option true :scope-selected (= :canvas search-scope))}
[:span {:class (stl/css-case :scope-radio true :scope-radio-checked (= :canvas search-scope))}]
[:input {:type "radio" :name "search-scope" :class (stl/css :scope-radio-input)
:checked (= :canvas search-scope)
:on-change (fn [_] (set-search-scope :canvas))}]
[:span {:class (stl/css :scope-label)}
(tr "workspace.sidebar.layers.search-scope-canvas")]]
[:label {:class (stl/css-case :scope-option true :scope-selected (= :layers search-scope))}
[:span {:class (stl/css-case :scope-radio true :scope-radio-checked (= :layers search-scope))}]
[:input {:type "radio" :name "search-scope" :class (stl/css :scope-radio-input)
:checked (= :layers search-scope)
:on-change (fn [_] (set-search-scope :layers))}]
[:span {:class (stl/css :scope-label)}
(tr "workspace.sidebar.layers.search-scope-layers")]]]
[:div {:class (stl/css :replace-wrapper)}
(when ^boolean find-replace-mode?
[:div {:class (stl/css :replace-row)}
[:> input* {:type "text"
:placeholder (tr "workspace.sidebar.layers.replace-placeholder")
:on-key-down handle-find-shortcut-keydown
:on-change update-replace-text}]
(when ^boolean find-replace-mode?
[:*
[:div {:class (stl/css :tool-window-bar :replace-row)}
[:div {:class (stl/css :replace-input-wrapper)}
[:input {:class (stl/css :replace-input)
:value replace-text
:placeholder (tr "workspace.sidebar.layers.replace-placeholder")
:on-change (fn [event]
(update-replace-text (dom/get-target-val event) event))}]
(when (not= "" replace-text)
[:button {:class (stl/css :clear-icon) :on-click clear-replace-text}
[:> icon* {:icon-id i/delete-text :size "s"}]])]
(when (d/not-empty? current-search)
(if (pos? text-match-count)
[:div {:class (stl/css :match-navigation)}
[:span {:class (stl/css :match-count)}
[:div {:class (stl/css :replace-match-navigation)}
[:span {:class (stl/css :replace-match-count)}
(dm/str (inc safe-match-idx) " / " text-match-count)]
[:> icon-button* {:variant "ghost" :aria-label (tr "labels.previous")
:on-click navigate-prev :icon i/arrow-up}]
[:> icon-button* {:variant "ghost" :aria-label (tr "labels.next")
:on-click navigate-next :icon i/arrow-down}]]
[:span {:class (stl/css :no-matches)}
(tr "workspace.sidebar.layers.no-matches")]))]
[:div {:class (stl/css :replace-match-count)}
(tr "workspace.sidebar.layers.no-matches")]))])
[:div {:class (stl/css :replace-scope-row)}
[:> radio-button* {:name "search-scope"
:checked (= :canvas search-scope)
:text (tr "workspace.sidebar.layers.search-scope-canvas")
:on-change (partial set-search-scope :canvas)}]
[:> radio-button* {:name "search-scope"
:checked (= :layers search-scope)
:text (tr "workspace.sidebar.layers.search-scope-layers")
:on-change (partial set-search-scope :layers)}]]
(when ^boolean find-replace-mode?
[:div {:class (stl/css :replace-actions-row)}
[:button {:class (stl/css :replace-button)
:on-click handle-replace
:disabled (or (zero? text-match-count) (str/empty? current-search))}
[:> button* {:variant "secondary"
:class (stl/css :replace-actions-button)
:on-click handle-replace
:on-key-down on-replace-keydown
:disabled (or (zero? text-match-count) (str/empty? current-search))}
(tr "workspace.sidebar.layers.replace")]
[:button {:class (stl/css :replace-button)
:on-click handle-replace-all
:disabled (or (zero? text-match-count) (str/empty? current-search))}
(tr "workspace.sidebar.layers.replace-all")]]])
[:> button* {:variant "secondary"
:class (stl/css :replace-actions-button)
:on-click handle-replace-all
:on-key-down on-replace-all-keydown
:disabled (or (zero? text-match-count) (str/empty? current-search))}
(tr "workspace.sidebar.layers.replace-all")]])
[:div {:class (stl/css :active-filters)}
(for [fkey current-filters]
(let [fname (d/name fkey)
[:div {:class (stl/css :active-filters)}
(for [fkey current-filters]
(let [fname (d/name fkey)
name (case fkey
:frame (tr "workspace.sidebar.layers.frames")
:group (tr "workspace.sidebar.layers.groups")
:mask (tr "workspace.sidebar.layers.masks")
:component (tr "workspace.sidebar.layers.components")
:text (tr "workspace.sidebar.layers.texts")
:image (tr "workspace.sidebar.layers.images")
:shape (tr "workspace.sidebar.layers.shapes")
(tr fkey))
filter-icon (usi/get-shape-icon-by-type fkey)]
name (case fkey
:frame (tr "workspace.sidebar.layers.frames")
:group (tr "workspace.sidebar.layers.groups")
:mask (tr "workspace.sidebar.layers.masks")
:component (tr "workspace.sidebar.layers.components")
:text (tr "workspace.sidebar.layers.texts")
:image (tr "workspace.sidebar.layers.images")
:shape (tr "workspace.sidebar.layers.shapes")
(tr fkey))
filter-icon (usi/get-shape-icon-by-type fkey)]
[:button {:class (stl/css :layer-filter)
:key fname
:data-filter fname
:on-click remove-filter}
[:> icon* {:icon-id filter-icon :size "s" :class (stl/css :layer-filter-icon)}]
[:span {:class (stl/css :layer-filter-name)}
name]
[:> icon* {:icon-id i/close-small :class (stl/css :layer-filter-close)}]]))]
[:button {:class (stl/css :layer-filter)
:key fname
:data-filter fname
:on-click remove-filter}
[:> icon* {:icon-id filter-icon :size "s" :class (stl/css :layer-filter-icon)}]
[:span {:class (stl/css :layer-filter-name)}
name]
[:> icon* {:icon-id i/close-small :class (stl/css :layer-filter-close)}]]))]
(when ^boolean show-menu?
[:ul {:class (stl/css :filters-container)}
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :frame))
:data-filter "frame"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/board :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.frames")]]
(when ^boolean show-menu?
[:ul {:class (stl/css :filters-container)}
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :frame))
:data-filter "frame"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/board :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.frames")]]
(when (contains? current-filters :frame)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
(when (contains? current-filters :frame)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :group))
:data-filter "group"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/group :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.groups")]]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :group))
:data-filter "group"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/group :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.groups")]]
(when (contains? current-filters :group)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
(when (contains? current-filters :group)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :mask))
:data-filter "mask"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/mask :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.masks")]]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :mask))
:data-filter "mask"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/mask :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.masks")]]
(when (contains? current-filters :mask)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
(when (contains? current-filters :mask)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :component))
:data-filter "component"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/component :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.components")]]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :component))
:data-filter "component"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/component :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.components")]]
(when (contains? current-filters :component)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
(when (contains? current-filters :component)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :text))
:data-filter "text"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/text :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.texts")]]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :text))
:data-filter "text"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/text :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.texts")]]
(when (contains? current-filters :text)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
(when (contains? current-filters :text)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :image))
:data-filter "image"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/img :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.images")]]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :image))
:data-filter "image"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/img :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.images")]]
(when (contains? current-filters :image)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
(when (contains? current-filters :image)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :shape))
:data-filter "shape"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/path :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.shapes")]]
[:li {:class (stl/css-case :filter-menu-item true
:selected (contains? current-filters :shape))
:data-filter "shape"
:on-click add-filter}
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
[:> icon* {:icon-id i/path :size "s" :class (stl/css :filter-menu-item-icon)}]
[:span {:class (stl/css :filter-menu-item-name)}
(tr "workspace.sidebar.layers.shapes")]]
(when (contains? current-filters :shape)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]])]
(when (contains? current-filters :shape)
[:> icon* {:icon-id i/tick :size "s" :class (stl/css :filter-menu-item-tick)}])]])]]
[:div {:class (stl/css :tool-window-bar)}
[:> title-bar* {:collapsable false
:class (stl/css :tool-window-bar-title)
:title (:name page)
:on-btn-click toggle-search
:btn-icon "search"
:btn-title (tr "labels.search")}]]))]))
(defn- on-scroll
[event]
(let [children (dom/get-elements-by-class "sticky-children")
@ -699,19 +823,24 @@
(mf/use-fn
#(st/emit! (dw/toggle-focus-mode)))]
[:div#layers {:class (stl/css :layers) :data-testid "layer-tree"}
[:div {:id "layers"
:class (stl/css :layers)
:data-testid "layer-tree"}
(if (d/not-empty? focus)
[:div {:class (stl/css :tool-window-bar)}
[:button {:class (stl/css :focus-title)
:on-click toogle-focus-mode}
[:span {:class (stl/css :back-button)}
[:> icon* {:icon-id i/arrow}]]
[:span {:class (stl/css :focus-back-button)}
[:> icon* {:icon-id i/arrow-left}]]
[:div {:class (stl/css :focus-name)}
(or title (tr "workspace.sidebar.layers"))]
[:div {:class (stl/css :focus-mode-tag-wrapper)}
[:& badge-notification {:content (tr "workspace.focus.focus-mode") :size :small :is-focus true}]]]]
[:& badge-notification {:content (tr "workspace.focus.focus-mode")
:size :small
:is-focus true}]]]]
(filter-component))
@ -724,11 +853,11 @@
:key (dm/str page-id)
:parent-size size-parent}]
[:div {:ref lazy-load-ref}]]
[:div {:on-scroll on-scroll
:class (stl/css :tool-window-content)
:data-scroll-container true
:style {:display (when (some? filtered-objects) "none")}}
[:> layers-tree-wrapper* {:objects filtered-objects
:key (dm/str page-id)
:is-filtered true

View File

@ -5,144 +5,100 @@
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/borders.scss" as *;
@use "ds/mixins.scss" as *;
@use "ds/sizes.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/typography.scss" as t;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
.element-list {
display: grid;
position: relative;
}
.tool-window-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: deprecated.$s-32;
min-height: deprecated.$s-32;
margin: deprecated.$s-8 0 deprecated.$s-4 deprecated.$s-8;
padding-right: deprecated.$s-12;
&.search {
padding: 0 deprecated.$s-12 0 deprecated.$s-8;
gap: deprecated.$s-4;
.filter-button {
@include deprecated.flex-center;
@include deprecated.button-style;
height: deprecated.$s-32;
width: deprecated.$s-32;
margin: 0;
border: deprecated.$s-1 solid var(--color-background-tertiary);
border-radius: deprecated.$br-8 deprecated.$br-2 deprecated.$br-2 deprecated.$br-8;
background-color: var(--color-background-tertiary);
svg {
height: deprecated.$s-16;
width: deprecated.$s-16;
stroke: var(--icon-foreground);
}
&:focus {
border: deprecated.$s-1 solid var(--input-border-color-focus);
outline: 0;
background-color: var(--input-background-color-active);
color: var(--input-foreground-color-active);
svg {
background-color: var(--input-background-color-active);
}
}
&:hover {
border: deprecated.$s-1 solid var(--input-border-color-hover);
background-color: var(--input-background-color-hover);
svg {
background-color: var(--input-background-color-hover);
stroke: var(--button-foreground-hover);
}
}
&.opened {
@extend %button-icon-selected;
}
}
}
gap: var(--sp-xs);
margin: var(--sp-m) var(--sp-m) 0 var(--sp-m);
}
.page-name {
@include deprecated.uppercase-title-typography;
padding: 0 deprecated.$s-12;
color: var(--title-foreground-color);
.tool-window-bar-title {
margin-block-end: var(--sp-s);
}
.icon-search {
@extend %button-tertiary;
.tool-window-content {
--calculated-height: calc(#{px2rem(136)} + var(--height, #{$sz-200}));
height: deprecated.$s-32;
width: deprecated.$s-28;
border-radius: deprecated.$br-8;
margin-right: deprecated.$s-8;
padding: 0;
display: flex;
flex-direction: column;
block-size: calc(100vh - var(--calculated-height));
inline-size: calc(var(--left-sidebar-width) + var(--depth) * var(--layer-indentation-size));
overflow: auto;
scrollbar-gutter: stable;
}
svg {
@extend %button-icon;
stroke: var(--icon-foreground);
}
.filter-button {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.focus-title {
@include deprecated.button-style;
border: none;
background: none;
block-size: var(--sp-xxxl);
inline-size: 100%;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
width: 100%;
margin-block-end: var(--sp-s);
padding: 0;
}
.back-button {
@include deprecated.flex-center;
height: deprecated.$s-32;
width: deprecated.$s-24;
padding: 0 deprecated.$s-4 0 deprecated.$s-8;
svg {
@extend %button-icon-small;
stroke: var(--icon-foreground);
transform: rotate(180deg);
}
.focus-back-button {
display: flex;
align-items: center;
justify-content: center;
block-size: var(--sp-xxxl);
padding-inline-start: var(--sp-s);
color: var(--icon-foreground);
}
.focus-name {
@include deprecated.text-ellipsis;
@include deprecated.body-small-typography;
@include t.use-typography("body-small");
@include text-ellipsis;
padding-left: deprecated.$s-4;
padding-inline-end: var(--sp-xs);
color: var(--title-foreground-color);
}
.focus-mode-tag-wrapper {
@include deprecated.flex-center;
height: 100%;
margin-right: deprecated.$s-12;
display: flex;
align-items: center;
justify-content: center;
block-size: 100%;
margin-inline-end: var(--sp-m);
}
.active-filters {
@include deprecated.flex-row;
display: flex;
align-items: center;
gap: var(--sp-xs);
flex-wrap: wrap;
margin: 0 deprecated.$s-12;
margin: 0 var(--sp-m);
}
.layer-filter {
@extend %button-tag;
gap: deprecated.$s-6;
height: deprecated.$s-24;
margin: deprecated.$s-2 0;
border-radius: deprecated.$br-6;
gap: px2rem(6);
block-size: var(--sp-xxl);
margin: var(--sp-xxs) 0;
border-radius: $br-6;
background-color: var(--pill-background-color);
cursor: pointer;
}
.layer-filter-icon,
@ -151,9 +107,11 @@
}
.layer-filter-name {
@include deprecated.flex-center;
@include deprecated.body-small-typography;
@include t.use-typography("body-small");
display: flex;
align-items: center;
justify-content: center;
color: var(--pill-foreground-color);
}
@ -161,34 +119,117 @@
position: relative;
}
.replace-wrapper {
display: flex;
flex-direction: column;
padding: var(--sp-xs) var(--sp-m);
gap: var(--sp-xs);
}
.replace-row {
display: flex;
flex-direction: row;
gap: var(--sp-xs);
}
.replace-scope-row {
display: flex;
gap: var(--sp-l);
align-items: center;
block-size: $sz-32;
}
.replace-actions-row {
display: flex;
gap: var(--sp-xs);
}
.replace-actions-button {
justify-content: center;
flex: 1 1 0;
}
.replace-match-navigation {
display: flex;
align-items: center;
gap: var(--sp-xs);
flex-shrink: 0;
}
.replace-match-count {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
white-space: nowrap;
display: flex;
align-items: center;
padding: 0 var(--sp-xs);
}
.radio-label {
display: flex;
align-items: center;
gap: px2rem(6);
&.selected {
.radio-text {
color: var(--color-foreground-primary);
}
}
}
.radio-icon {
block-size: var(--sp-m);
inline-size: var(--sp-m);
border: $b-1 solid var(--color-foreground-secondary);
border-radius: 50%;
background-color: transparent;
flex-shrink: 0;
&.checked {
border-color: var(--color-accent-primary);
background-color: var(--color-accent-primary);
box-shadow: inset 0 0 0 var(--sp-xxs) var(--color-background-primary);
}
}
.radio-input {
display: none;
}
.radio-text {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
}
.filters-container {
@extend %menu-dropdown;
position: absolute;
left: deprecated.$s-20;
width: deprecated.$s-192;
inline-size: $sz-192;
.filter-menu-item {
@include deprecated.body-small-typography;
@include t.use-typography("body-small");
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: deprecated.$s-6;
border-radius: deprecated.$br-8;
inline-size: 100%;
padding: px2rem(6);
border-radius: $br-8;
.filter-menu-item-name-wrapper {
display: flex;
align-items: center;
gap: deprecated.$s-8;
gap: var(--sp-s);
.filter-menu-item-icon {
color: var(--menu-foreground-color);
}
.filter-menu-item-name {
padding-top: deprecated.$s-2;
padding-block-start: var(--sp-xxs);
color: var(--menu-foreground-color);
}
}
@ -234,173 +275,3 @@
}
}
}
.tool-window-content {
--calculated-height: calc(#{deprecated.$s-136} + var(--height, #{deprecated.$s-200}));
display: flex;
flex-direction: column;
height: calc(100vh - var(--calculated-height));
width: calc(var(--left-sidebar-width) + var(--depth) * var(--layer-indentation-size));
overflow: auto;
scrollbar-gutter: stable;
}
.replace-row {
padding: 0 deprecated.$s-12;
gap: deprecated.$s-4;
}
.search-scope-row {
display: flex;
gap: deprecated.$s-16;
padding: deprecated.$s-4 deprecated.$s-12 deprecated.$s-8;
align-items: center;
}
.scope-option {
display: flex;
align-items: center;
gap: deprecated.$s-6;
cursor: pointer;
}
.scope-radio {
width: deprecated.$s-12;
height: deprecated.$s-12;
border: deprecated.$s-1 solid var(--color-foreground-secondary);
border-radius: 50%;
background-color: transparent;
flex-shrink: 0;
}
.scope-radio-checked {
border-color: var(--color-accent-primary);
background-color: var(--color-accent-primary);
box-shadow: inset 0 0 0 deprecated.$s-2 var(--color-background-primary);
}
.scope-radio-input {
display: none;
}
.scope-label {
@include deprecated.body-small-typography;
color: var(--color-foreground-secondary);
cursor: pointer;
}
.scope-selected .scope-label {
color: var(--color-foreground-primary);
}
.replace-actions-row {
display: flex;
gap: deprecated.$s-4;
padding: 0 deprecated.$s-12 deprecated.$s-8;
}
.replace-input-wrapper {
@include deprecated.flex-center;
flex: 1;
height: deprecated.$s-32;
border: deprecated.$s-1 solid var(--search-bar-input-border-color);
border-radius: deprecated.$br-8;
background-color: var(--search-bar-input-background-color);
&:hover {
border: deprecated.$s-1 solid var(--input-border-color-hover);
background-color: var(--input-background-color-hover);
.replace-input {
background-color: var(--input-background-color-hover);
}
}
&:focus-within {
background-color: var(--input-background-color-active);
color: var(--input-foreground-color-active);
border: deprecated.$s-1 solid var(--input-border-color-focus);
.replace-input {
background-color: var(--input-background-color-active);
}
}
}
.replace-input {
width: 100%;
height: 100%;
margin: 0 deprecated.$s-8;
border: 0;
background-color: var(--input-background-color);
font-size: deprecated.$fs-12;
color: var(--input-foreground-color);
border-radius: deprecated.$br-8;
&:focus {
outline: none;
}
}
.replace-button {
@include deprecated.body-small-typography;
@include deprecated.button-style;
flex: 1;
height: deprecated.$s-28;
padding: 0 deprecated.$s-8;
border: deprecated.$s-1 solid var(--color-background-tertiary);
border-radius: deprecated.$br-8;
background-color: var(--color-background-tertiary);
color: var(--color-foreground-primary);
white-space: nowrap;
text-transform: uppercase;
&:hover:not(:disabled) {
border: deprecated.$s-1 solid var(--input-border-color-hover);
background-color: var(--input-background-color-hover);
}
&:disabled {
opacity: 0.4;
cursor: default;
}
}
.match-navigation {
display: flex;
align-items: center;
gap: deprecated.$s-2;
flex-shrink: 0;
}
.match-count {
@include deprecated.body-small-typography;
color: var(--color-foreground-secondary);
white-space: nowrap;
}
.no-matches {
@include deprecated.body-small-typography;
color: var(--color-foreground-secondary);
white-space: nowrap;
flex-shrink: 0;
}
.clear-icon {
@extend %button-tag;
flex: 0 0 deprecated.$s-32;
height: 100%;
color: var(--color-icon-default);
}
.element-list {
display: grid;
position: relative;
}

View File

@ -111,6 +111,8 @@
(tr "shortcuts.duplicate")
(tr "shortcuts.escape")
(tr "shortcuts.export-shapes")
(tr "shortcuts.find")
(tr "shortcuts.find-and-replace")
(tr "shortcuts.fit-all")
(tr "shortcuts.flip-horizontal")
(tr "shortcuts.flip-vertical")
@ -160,6 +162,7 @@
(tr "shortcuts.open-viewer")
(tr "shortcuts.open-workspace")
(tr "shortcuts.paste")
(tr "shortcuts.paste-replace")
(tr "shortcuts.prev-frame")
(tr "shortcuts.redo")
(tr "shortcuts.rename")

View File

@ -224,6 +224,7 @@
:aria-label (tr "modals.delete-page.title")
:on-click on-delete
:icon-size "s"
:class (stl/css :page-delete-button)
:icon-class (stl/css :page-delete-button-icon)
:icon i/delete}])]])])]])))

View File

@ -55,8 +55,12 @@
margin-bottom: deprecated.$s-12;
}
.page-delete-button {
--delete-button-display: none;
}
.page-delete-button-icon {
color: transparent;
display: var(--delete-button-display);
}
.page-element {
@ -97,8 +101,6 @@
.page-icon {
@include deprecated.flex-center;
height: deprecated.$s-32;
width: deprecated.$s-24;
padding: 0 deprecated.$s-4 0 deprecated.$s-8;
}
@ -136,12 +138,6 @@
color: var(--layer-row-foreground-color-drag);
background-color: var(--layer-row-background-color-drag);
.page-actions button {
svg {
stroke: var(--layer-row-foreground-color-drag);
}
}
.page-icon svg {
stroke: var(--layer-row-foreground-color-drag);
}
@ -156,12 +152,6 @@
box-shadow: deprecated.$s-16 deprecated.$s-0 deprecated.$s-0 deprecated.$s-0
var(--layer-row-background-color-selected);
.page-actions button {
svg {
stroke: var(--layer-row-foreground-color-selected);
}
}
.page-icon svg {
stroke: var(--layer-row-foreground-color-selected);
}
@ -179,9 +169,7 @@
.page-actions button {
opacity: deprecated.$op-10;
svg {
stroke: var(--layer-row-foreground-color-hover);
}
--delete-button-display: initial;
}
.page-icon svg {
@ -218,12 +206,6 @@
background-color: var(--layer-row-background-color-hidden);
opacity: deprecated.$op-7;
.page-actions button {
svg {
stroke: var(--layer-row-foreground-color-hidden);
}
}
.page-icon svg {
stroke: var(--layer-row-foreground-color-hidden);
}

View File

@ -5,6 +5,7 @@
[app.common.path-names :as cpn]
[app.common.types.shape.layout :as ctsl]
[app.common.types.tokens-lib :as ctob]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.helpers :as dh]
[app.main.data.modal :as modal]
@ -84,7 +85,7 @@
(mf/defc tokens-section*
{::mf/private true}
[{:keys [tokens-lib active-tokens resolved-active-tokens]}]
[{:keys [tokens-lib active-tokens resolved-active-tokens resolved-active-tokens-not-forced]}]
(let [objects (mf/deref refs/workspace-page-objects)
selected (mf/deref refs/selected-shapes)
@ -220,10 +221,11 @@
new-tokens (map (fn [token]
(let [new-token-path (ctob/rename-path node token new-node-name)]
(assoc token :name new-token-path)))
tokens-in-path)]
tokens-in-path)
undo-group (uuid/next)]
(st/emit!
(dwtl/bulk-update-tokens selected-token-set-id tokens-in-path-ids type old-path new-node-path)
(remap/bulk-remap-tokens tokens-in-path new-tokens)
(dwtl/bulk-update-tokens selected-token-set-id tokens-in-path-ids type old-path new-node-path :undo-group undo-group)
(remap/bulk-remap-tokens tokens-in-path new-tokens :undo-group undo-group)
(dwtp/propagate-workspace-tokens)
(modal/hide)))))
@ -318,6 +320,7 @@
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens resolved-active-tokens
:active-theme-tokens-not-forced resolved-active-tokens-not-forced
:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]))
@ -328,4 +331,5 @@
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens resolved-active-tokens
:active-theme-tokens-not-forced resolved-active-tokens-not-forced
:selected-token-set-id selected-token-set-id}])]))

View File

@ -39,19 +39,26 @@
(not-empty)))))
(defn- extract-partial-brace-text
"Returns the substring after the last '{' in s. If the resulting
substring ends with '}', that trailing brace is removed.
Returns nil if no '{' is found or s is nil."
[s]
(when-let [start (str/last-index-of s "{")]
(subs s (inc start))))
(let [partial (subs s (inc start))]
(if (and (seq partial)
(> (count partial) 0)
(= "}" (subs partial (dec (count partial)))))
(subs partial 0 (dec (count partial)))
partial))))
(defn- filter-token-groups-by-name
[tokens filter-text]
(let [lc-filter (str/lower filter-text)]
(into {}
(keep (fn [[group tokens]]
(let [filtered (filter #(str/includes? (str/lower (:name %)) lc-filter) tokens)]
(when (seq filtered)
[group filtered]))))
tokens)))
(into {}
(keep (fn [[group tokens]]
(let [filtered (filter #(str/includes? (:name %) filter-text) tokens)]
(when (seq filtered)
[group filtered]))))
tokens))
(defn- sort-groups-and-tokens
"Sorts the tokens inside the groups alphabetically.

View File

@ -71,7 +71,7 @@
(mf/defc token-group*
{::mf/schema schema:token-group}
[{:keys [type tokens selected-shapes is-selected-inside-layout active-theme-tokens selected-token-set-id tokens-lib selected-ids]}]
[{:keys [type tokens selected-shapes is-selected-inside-layout active-theme-tokens selected-token-set-id tokens-lib selected-ids active-theme-tokens-not-forced]}]
(let [{:keys [modal title]}
(get dwta/token-properties type)
@ -191,6 +191,7 @@
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:active-theme-tokens-not-forced active-theme-tokens-not-forced
:selected-token-set-id selected-token-set-id
:tokens-lib tokens-lib
:on-token-pill-click on-token-pill-click

View File

@ -175,7 +175,7 @@
(mf/defc token-pill*
{::mf/wrap [mf/memo]}
[{:keys [on-click token on-context-menu selected-shapes is-selected-inside-layout active-theme-tokens]}]
[{:keys [on-click token on-context-menu selected-shapes is-selected-inside-layout active-theme-tokens active-theme-tokens-not-forced]}]
(let [{:keys [name value type]} token
resolved-token (get active-theme-tokens (:name token))
@ -216,7 +216,7 @@
(if (contains? cf/flags :tokenscript)
(seq (:errors resolved-token))
(and is-reference?
(not (contains-reference-value? value active-theme-tokens))))
(not (contains-reference-value? value active-theme-tokens-not-forced))))
no-valid-value (seq errors)

View File

@ -11,8 +11,8 @@
[app.common.path-names :as cpn]
[app.common.types.tokens-lib :as ctob]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as ctx]
[app.main.ui.ds.layers.layer-button :refer [layer-button*]]
[app.main.ui.workspace.tokens.management.token-pill :refer [token-pill*]]
[rumext.v2 :as mf]))
@ -44,18 +44,36 @@
on-token-pill-click
on-pill-context-menu
on-node-context-menu]}]
(let [full-path (str (name type) "." (:path node))
is-folder-expanded (not (contains? (set (or folded-token-paths [])) full-path))
swap-folder-expanded (mf/use-fn
(mf/deps full-path)
(fn []
(st/emit! (dwtl/toggle-token-path full-path))))
(let [full-path (str (name type) "." (:path node))
is-folder-expanded (not (contains? (set (or folded-token-paths [])) full-path))
children (:children node)
sorted-children
(mf/with-memo [children]
(let [[leafs groups]
(reduce (fn [[l g] item]
(if (:leaf item)
[(conj l item) g]
[l (conj g item)]))
[[] []]
children)
sorted-leafs (d/natural-sort-by :name leafs)
sorted-groups (d/natural-sort-by :name groups)]
(concat sorted-leafs sorted-groups)))
swap-folder-expanded
(mf/use-fn
(mf/deps full-path)
(fn []
(st/emit! (dwtl/toggle-token-path full-path))))
node-context-menu-prep
(mf/use-fn
(mf/deps on-node-context-menu node)
(fn [event]
(when on-node-context-menu
(on-node-context-menu event node))))]
node-context-menu-prep (mf/use-fn
(mf/deps on-node-context-menu node)
(fn [event]
(when on-node-context-menu
(on-node-context-menu event node))))]
[:li {:class (stl/css :folder-node)}
[:> layer-button* {:label (:name node)
:expanded is-folder-expanded
@ -65,36 +83,34 @@
:on-toggle-expand swap-folder-expanded
:on-context-menu node-context-menu-prep}]
(when is-folder-expanded
(let [children (:children node)]
[:div {:class (stl/css :folder-children-wrapper)
:id (str "folder-children-" (:path node))}
(when (seq children)
(let [sorted-children (d/natural-sort-by :name children)]
(for [child sorted-children]
(if (not (:leaf child))
[:ul {:class (stl/css :node-parent)
:key (:path child)}
[:> folder-node* {:type type
:node child
:folded-token-paths folded-token-paths
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-token-pill-click on-token-pill-click
:on-pill-context-menu on-pill-context-menu
:on-node-context-menu on-node-context-menu
:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]]
(let [id (:id (:leaf child))
token (ctob/get-token tokens-lib selected-token-set-id id)]
[:> token-pill*
{:key id
:token token
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-click on-token-pill-click
:on-context-menu on-pill-context-menu}])))))]))]))
[:div {:class (stl/css :folder-children-wrapper)
:id (str "folder-children-" (:path node))}
(when (seq children)
(for [child sorted-children]
(if (not (:leaf child))
[:ul {:class (stl/css :node-parent)
:key (:path child)}
[:> folder-node* {:type type
:node child
:folded-token-paths folded-token-paths
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-token-pill-click on-token-pill-click
:on-pill-context-menu on-pill-context-menu
:on-node-context-menu on-node-context-menu
:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]]
(let [id (:id (:leaf child))
token (ctob/get-token tokens-lib selected-token-set-id id)]
[:> token-pill*
{:key id
:token token
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-click on-token-pill-click
:on-context-menu on-pill-context-menu}]))))])]))
(def ^:private schema:token-tree
[:map
@ -118,25 +134,36 @@
selected-shapes
is-selected-inside-layout
active-theme-tokens
active-theme-tokens-not-forced
tokens-lib
selected-token-set-id
on-token-pill-click
on-pill-context-menu
on-node-context-menu]}]
(let [separator "."
tree (mf/use-memo
(mf/deps tokens)
(fn []
(->> (cpn/build-tree-root tokens separator)
(d/natural-sort-by :name))))
can-edit? (:can-edit (deref refs/permissions))
raw-tree (mf/with-memo [tokens]
(cpn/build-tree-root tokens separator))
permissions (mf/use-ctx ctx/permissions)
can-edit? (:can-edit permissions)
on-node-context-menu (mf/use-fn
(mf/deps can-edit? on-node-context-menu)
(fn [event node]
(when can-edit?
(on-node-context-menu event node))))]
(on-node-context-menu event node))))
ordered-nodes (mf/with-memo [raw-tree]
(let [[leafs groups]
(reduce (fn [[l g] item]
(if (:leaf item)
[(conj l item) g]
[l (conj g item)]))
[[] []]
raw-tree)
sorted-leafs (d/natural-sort-by :name leafs)
sorted-groups (d/natural-sort-by :name groups)]
(concat sorted-leafs sorted-groups)))]
[:div {:class (stl/css :token-tree-wrapper)}
(for [node tree]
(for [node ordered-nodes]
(if (:leaf node)
(let [token (ctob/get-token tokens-lib selected-token-set-id (get-in node [:leaf :id]))]
[:> token-pill*
@ -145,6 +172,7 @@
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:active-theme-tokens-not-forced active-theme-tokens-not-forced
:on-click on-token-pill-click
:on-context-menu on-pill-context-menu}])
;; Render segment folder

View File

@ -460,7 +460,8 @@
(wasm.api/initialize-viewport base-objects zoom vbox
:background background
:on-shapes-ready
#(st/emit! (dw/check-file-position-data file-id)))
(fn []
(st/emit! (dw/update-page-position-data))))
(reset! initialized? true))
(when (and (some? vern) (not= vern (mf/ref-val last-vern-ref)))

View File

@ -15,6 +15,7 @@
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[app.common.math :as mth]
[app.common.types.color :as clr]
[app.common.types.fills :as types.fills]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.types.path :as path]
@ -311,7 +312,7 @@
zoom (:zoom local)
vbox (:vbox local)
canvas wasm/canvas
background (get page :background)]
background (get page :background clr/canvas)]
{:canvas canvas
:base-objects (cpf/focus-objects objects focus)
:zoom zoom
@ -438,6 +439,19 @@
(aget buffer 2)
(aget buffer 3)))))
(defn has-shape
[id]
(when wasm/context-initialized?
(let [buffer (uuid/get-u32 id)
result
(h/call wasm/internal-module "_has_shape"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))]
(= result 1))))
(defn set-shape-text-content
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
[shape-id content]
@ -767,16 +781,10 @@
(defn set-shape-svg-attrs
[attrs]
(let [style (:style attrs)
;; Filter to only supported attributes
allowed-keys #{:fill :fillRule :fill-rule :strokeLinecap :stroke-linecap :strokeLinejoin :stroke-linejoin}
attrs (-> attrs
(dissoc :style)
(merge style)
(select-keys allowed-keys))
fill-rule (-> (or (:fill-rule attrs) (:fillRule attrs)) sr/translate-fill-rule)
stroke-linecap (-> (or (:stroke-linecap attrs) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
stroke-linejoin (-> (or (:stroke-linejoin attrs) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
fill-none (= "none" (-> attrs :fill))]
fill-rule (-> (or (:fillRule style) (:fillRule attrs)) sr/translate-fill-rule)
stroke-linecap (-> (or (:strokeLinecap style) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
stroke-linejoin (-> (or (:strokeLinejoin style) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
fill-none (= "none" (or (:fill style) (:fill attrs)))]
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
(defn set-shape-path-content
@ -1415,6 +1423,53 @@
noop-fn)))))))]
(process-next-chunk 0 [] []))))))
;; This is a version of process-pending that doesn't have sideffects
;; with like request render or update layout.
(defn- process-pending-no-sideffects
[thumbnails full on-complete]
(let [pending-thumbnails
(d/index-by :key :callback thumbnails)
pending-full
(d/index-by :key :callback full)]
(if (or (seq pending-thumbnails) (seq pending-full))
(->> (rx/concat
(->> (rx/from (vals pending-thumbnails))
(rx/merge-map (fn [callback] (if (fn? callback) (callback) (rx/empty))))
(rx/reduce conj [])
(rx/catch #(rx/empty)))
(->> (rx/from (vals pending-full))
(rx/mapcat (fn [callback] (if (fn? callback) (callback) (rx/empty))))
(rx/reduce conj [])
(rx/catch #(rx/empty))))
(rx/subs!
noop-fn
noop-fn
(fn []
(when (fn? on-complete) (on-complete)))))
;; No pending images — complete immediately.
(when on-complete (on-complete)))))
(defn set-objects-callback
"Sets the shapes and when the async operations are done calls the callback. Won't
interact with the rendering pipeline, this call is only to set the model (used currently
in the viewer)."
[shapes set-objects-cb]
(let [total-shapes (count shapes)
{:keys [thumbnails full]}
(loop [index 0 thumbnails-acc (transient []) full-acc (transient [])]
(if (< index total-shapes)
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(reduce conj! thumbnails-acc thumbnails)
(reduce conj! full-acc full)))
{:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc)}))]
(process-pending-no-sideffects thumbnails full set-objects-cb)))
(defn- set-objects-sync
"Synchronously process all shapes (for small shape counts)."
[shapes render-callback on-shapes-ready]

View File

@ -8,6 +8,7 @@
(:require
[app.common.data :as d]
[app.common.types.text :as txt]
[app.util.dom :as dom]
[app.util.text.content.styles :as styles]))
(defn is-text-node
@ -60,7 +61,15 @@
(defn get-paragraph-styles
[element]
(get-attrs-from-styles element (d/concat-set txt/paragraph-attrs txt/text-node-attrs) (d/merge txt/default-paragraph-attrs txt/default-text-attrs)))
(let [styles (get-attrs-from-styles element
(d/concat-set txt/paragraph-attrs txt/text-node-attrs)
(d/merge txt/default-paragraph-attrs txt/default-text-attrs))
;; Recover real font-size from data attribute, which to_dom/get-paragraph-styles may have
;; changed to "0" ("0" trick to avoid it interfering with height calculation in the browser).
saved-font-size (dom/get-data element "saved-font-size")]
(cond-> styles
(some? saved-font-size)
(assoc :font-size saved-font-size))))
(defn get-root-styles
[element]

View File

@ -133,7 +133,11 @@
(create-element
"div"
{:id (or (:key paragraph) (create-random-key))
:data {:itype "paragraph"}
:data {:itype "paragraph"
;; Save the real font size to be restored later in from-dom/get-paragraph-styles,
;; because the function get-paragraph-styles here sets it to "0" in the css properties,
;; to avoid the browser affecting the height calculation.
:saved-font-size (:font-size paragraph)}
:style (get-paragraph-styles paragraph)}
(mapv #(create-text-span % paragraph) (:children paragraph))))

View File

@ -4751,6 +4751,14 @@ msgstr "Cancel"
msgid "shortcuts.export-shapes"
msgstr "Export shapes"
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs
msgid "shortcuts.find"
msgstr "Find"
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs
msgid "shortcuts.find-and-replace"
msgstr "Find and replace"
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:114
msgid "shortcuts.fit-all"
msgstr "Zoom to fit all"
@ -4955,6 +4963,10 @@ msgstr " or "
msgid "shortcuts.paste"
msgstr "Paste"
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs
msgid "shortcuts.paste-replace"
msgstr "Paste and replace"
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:111
#, unused
msgid "shortcuts.paste-props"
@ -8296,7 +8308,10 @@ msgstr "Masks"
#: src/app/main/ui/workspace/sidebar/layers.cljs:293
msgid "workspace.sidebar.layers.search"
msgstr "Search layers"
msgstr "Find…"
msgid "workspace.sidebar.layers.search-and-replace"
msgstr "Find and replace"
#: src/app/main/ui/workspace/sidebar/layers.cljs:316, src/app/main/ui/workspace/sidebar/layers.cljs:410
msgid "workspace.sidebar.layers.shapes"
@ -8328,11 +8343,11 @@ msgstr "No matches"
#: src/app/main/ui/workspace/sidebar/layers.cljs
msgid "workspace.sidebar.layers.search-scope-layers"
msgstr "Search layers"
msgstr "Layer names"
#: src/app/main/ui/workspace/sidebar/layers.cljs
msgid "workspace.sidebar.layers.search-scope-canvas"
msgstr "Search on canvas"
msgstr "Text content"
#: src/app/main/ui/inspect/attributes/svg.cljs:56, src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:101
msgid "workspace.sidebar.options.svg-attrs.title"

View File

@ -6001,6 +6001,14 @@ msgstr "Rehacer"
msgid "workspace.header.menu.select-all"
msgstr "Seleccionar todo"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.find"
msgstr "Buscar"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.find-and-replace"
msgstr "Buscar y reemplazar"
#: src/app/main/ui/workspace/main_menu.cljs:423
msgid "workspace.header.menu.show-artboard-names"
msgstr "Mostrar nombres de tableros"
@ -8092,6 +8100,9 @@ msgstr "Máscaras"
msgid "workspace.sidebar.layers.search"
msgstr "Buscar capas"
msgid "workspace.sidebar.layers.search-and-replace"
msgstr "Buscar y reemplazar"
#: src/app/main/ui/workspace/sidebar/layers.cljs:316, src/app/main/ui/workspace/sidebar/layers.cljs:410
msgid "workspace.sidebar.layers.shapes"
msgstr "Formas"

View File

@ -16,10 +16,10 @@
"fmt": "./scripts/fmt"
},
"devDependencies": {
"@github/copilot": "^1.0.44",
"@github/copilot": "^1.0.54",
"@types/node": "^25.6.2",
"esbuild": "^0.28.0",
"nrepl-client": "^0.3.0",
"opencode-ai": "^1.15.4"
"opencode-ai": "^1.15.11"
}
}

196
pnpm-lock.yaml generated
View File

@ -9,8 +9,8 @@ importers:
.:
devDependencies:
'@github/copilot':
specifier: ^1.0.44
version: 1.0.44
specifier: ^1.0.54
version: 1.0.54
'@types/node':
specifier: ^25.6.2
version: 25.6.2
@ -21,8 +21,8 @@ importers:
specifier: ^0.3.0
version: 0.3.0
opencode-ai:
specifier: ^1.15.4
version: 1.15.4
specifier: ^1.15.11
version: 1.15.11
packages:
@ -182,44 +182,60 @@ packages:
cpu: [x64]
os: [win32]
'@github/copilot-darwin-arm64@1.0.44':
resolution: {integrity: sha512-9NqA5sT2spmNsehxhs51GhXRZIZga5nq+WcMl4LG2QrUPJRDwvHf1bDKqETJUBbYvBY8jONGuTKMRofkMI68YQ==}
'@github/copilot-darwin-arm64@1.0.54':
resolution: {integrity: sha512-ZRiKkxCvDccdGSNB/gmge4UkqMsWWZNIOr0pcim4/x2YUdHbh9cex9RZRjEMXijtUkBTzW5DP/cACuoAqTCyEg==}
cpu: [arm64]
os: [darwin]
hasBin: true
'@github/copilot-darwin-x64@1.0.44':
resolution: {integrity: sha512-QPD8KtXx07SIKILGBl4JDhPyL2Qo0FMmaTYVxR6nkyHkHnFPsUZD6VWGR+T/KMLkcUXFM85Xc1ba9Y27s4nRrQ==}
'@github/copilot-darwin-x64@1.0.54':
resolution: {integrity: sha512-DGqs8x5r4y+SebMco890lNsPrqe6L4v2hCmV1IQ1pvYPvD1o1NMVSZPAQhkdvUeR5bqusOg8+0ugIZOQGTFpFQ==}
cpu: [x64]
os: [darwin]
hasBin: true
'@github/copilot-linux-arm64@1.0.44':
resolution: {integrity: sha512-Z8ScIUP433xS18f68NP9jM9zW320Xzpi2wf7Nig/VyfrwupBy25UTezydQMT0KQHLWTEleHOPcYnASY3HgJXnQ==}
'@github/copilot-linux-arm64@1.0.54':
resolution: {integrity: sha512-waVKu6RuG8YBvCoGrOgtsOxmnfLaUywvbqZXRgvMya1m4akRkMi5r9B2UDr3+egjChp+FIUJVbGIoXN6ZST0rQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
hasBin: true
'@github/copilot-linux-x64@1.0.44':
resolution: {integrity: sha512-KUl6lvJt0HNKaXSx0T0bIWJ3rvrGwgZYMlkDfqMbuMnZatEQJbjPwxmL/IDfp/c0DyKd7K+ajl17wHYcN/hJIQ==}
'@github/copilot-linux-x64@1.0.54':
resolution: {integrity: sha512-u/ltZa+HDIuhMivkIwkkuylRdEMk5Lp0XjE9w/OityW+BPKjZ+VKAmJ1/1Xm/uUx1IUlZaE3TJnka52wVNOD0A==}
cpu: [x64]
os: [linux]
libc: [glibc]
hasBin: true
'@github/copilot-win32-arm64@1.0.44':
resolution: {integrity: sha512-JVJxZJwAc95ZfapgOXjNFwSqrWlvC3heo128L+CDkdZ6lwpD1dTGMHT/6rMMEeo3xjZmMm8tiynfwsHLDgTtvQ==}
'@github/copilot-linuxmusl-arm64@1.0.54':
resolution: {integrity: sha512-21LLjoQnD57Y1fvO56G1FGVbkt/ffZNDpHqVe2NW7C4r78Gn0hOTqwp+xWRUMpdmxrGZyKeFjX8jK6qox2uF5w==}
cpu: [arm64]
os: [linux]
libc: [musl]
hasBin: true
'@github/copilot-linuxmusl-x64@1.0.54':
resolution: {integrity: sha512-sbeATKa9vaIetsY1vhQJO0PN/5FgoK48wkGBWCy4BpO8ER/kGYczT22qv6n96gBYrVmC2IZuTFTM4GFpC3bbBw==}
cpu: [x64]
os: [linux]
libc: [musl]
hasBin: true
'@github/copilot-win32-arm64@1.0.54':
resolution: {integrity: sha512-muOX8qrJSi56BWQejkH0TgXpZYRO8Y9k1qIfMuRojZyLyATn1P4lIKb67ZqDCXJLkcPfVJ5eJYsSAeGwU3Qpww==}
cpu: [arm64]
os: [win32]
hasBin: true
'@github/copilot-win32-x64@1.0.44':
resolution: {integrity: sha512-Yj3KQ/DqwS50PwRtyQITX2mWIVZeJeX+y0faVSMwUUzG1qxmMcme7wimhKOyc4LSV11DpgVB9MSiBw2xys7iww==}
'@github/copilot-win32-x64@1.0.54':
resolution: {integrity: sha512-BheXmqrYFmfRXA0iveKkjKks/2wgK5glrEOARomzy3JCbvVMSPIE8YeK+3YysiOh2SUkWjahwJc09cxaBq4+qQ==}
cpu: [x64]
os: [win32]
hasBin: true
'@github/copilot@1.0.44':
resolution: {integrity: sha512-wr/GmNOUaJK/giJK5abyB1oTpEowgFKLi+NJnlyAymKiK/GKCaRlJqiX23H2RetM8vD2hDYUFUFm9lTCooGy0g==}
'@github/copilot@1.0.54':
resolution: {integrity: sha512-gxiWEQFWxJ3J2Rh67CxKEfER/zayB1z2qaSBUz3RZ0u1iDNJdGPry/1vOQ72X/yHmpGNm+9egucN5VMzyedsIg==}
hasBin: true
'@types/node@25.6.2':
@ -228,6 +244,10 @@ packages:
bencode@2.0.3:
resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
esbuild@0.28.0:
resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==}
engines: {node: '>=18'}
@ -236,69 +256,69 @@ packages:
nrepl-client@0.3.0:
resolution: {integrity: sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==}
opencode-ai@1.15.4:
resolution: {integrity: sha512-Z5PeJwFNUW4sFW+jYHQTnJm6858dECvWOATvnG0S66Nn46zwjaaZJEJMKQEPOW7Yog99j6k7xRKvJPPAjKDXfQ==}
opencode-ai@1.15.11:
resolution: {integrity: sha512-i3DYIATyWT3ukP+5OCyEuXvbCEq7PgBAlVA4yp01+W5BaYeoj/f0bpXdDPP5q9B/Yl8drtyEhWh0YC9UAHF06A==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
opencode-darwin-arm64@1.15.4:
resolution: {integrity: sha512-d+0sFAAhrDtjQbxRZvYSzy3g/xj/xUDRWUVBWGfkJAx0QEWc/v7cksmnYj3p3l88Gxm/rWeLCh6H32pw1/En8A==}
opencode-darwin-arm64@1.15.11:
resolution: {integrity: sha512-XuiTIkBj0YKpfT8KHNan4JaX686vROCwXQHDzsZ55g/I7rdyQXG2wdt2CsUhRDaPyQTOkhrM+VqC3uYsT+kZzw==}
cpu: [arm64]
os: [darwin]
opencode-darwin-x64-baseline@1.15.4:
resolution: {integrity: sha512-Lj9wsEPFyEOgLO6J3DsCXdSu/IAJnW/RjtD1oojAao6uHvhs5uXyj1mrsmK8GrtAfCT4JUh8W38o3YYXGjItSw==}
opencode-darwin-x64-baseline@1.15.11:
resolution: {integrity: sha512-itb1FRgGyve89/W+sQBqmTVWUoldb0TdH1Qm805c52UbY+nkEW6oE40vveHJEDDPxFHypyzSymYFcJ7wqYBisw==}
cpu: [x64]
os: [darwin]
opencode-darwin-x64@1.15.4:
resolution: {integrity: sha512-H412BUw5O+bmXfzLo6UMCWVc3DOYEM0RCI5Kt+Ynqh+Q9878bXK6mwRR7VXgGVBkkH2U4GtT1uDgY0BzSK185Q==}
opencode-darwin-x64@1.15.11:
resolution: {integrity: sha512-PimsC+uaSmVxszQ3sbrIEjDoba9jUyAwAbydEukY3EoQ7cgLFd8tn9H/8yeA0aY7bh3uiCttDCctoQfMpV+S9Q==}
cpu: [x64]
os: [darwin]
opencode-linux-arm64-musl@1.15.4:
resolution: {integrity: sha512-TO2IVSoYolGKJahf73/hRsJBGxLKOdP/akYPzI0hQQvW4oVrmQkZ3s13jU1+LXIEn4Zbj/TB18QvLzvXrnrEhA==}
opencode-linux-arm64-musl@1.15.11:
resolution: {integrity: sha512-wLaAM12H0mH93XpdXuFz4+oeNA9+CDj8WEvdL8NNrz/Sfgi/zWIo8g3UMH+lp+pfO0s7PTM84RzxGQTOvcejXg==}
cpu: [arm64]
os: [linux]
opencode-linux-arm64@1.15.4:
resolution: {integrity: sha512-V+x/u9JnPOLPEfqbePSCL0OQdin5gs1V35VsVxj19WaZDEwxlMVjOe6HjVKEY64/O6htkPxCCZohmnMU4dVBMQ==}
opencode-linux-arm64@1.15.11:
resolution: {integrity: sha512-SM+xMU8pUd5p6KD2wdIR2d0q3IRw6axKSGUqlcVrit7ZhFlTjdr3Ca0KqVv3JsUny8Bu8N8Z5b3MHvqRf4nTSQ==}
cpu: [arm64]
os: [linux]
opencode-linux-x64-baseline-musl@1.15.4:
resolution: {integrity: sha512-xOJ3aHg2+2GrT9F/KmAF0JLB1D6K3SCY/626n+fLjs/AEFvLdmE3TYhoXPEyGH2I9F4kF+4p2xk0pg2b+LVlZQ==}
opencode-linux-x64-baseline-musl@1.15.11:
resolution: {integrity: sha512-N+geBY9Zv2kfoMKYMnPxJZQ1R9xOrgA55BMa7aMtMHr2x9tkjI5mCT7ese6Igna/cnoicxo216YhkSzyY1+p9A==}
cpu: [x64]
os: [linux]
opencode-linux-x64-baseline@1.15.4:
resolution: {integrity: sha512-dTlV8tAVN8nFdPb7527GR6/BpyIVavAcXJmZ2VbS1daXu4C6k6bpmjiS/ZFKlphRZiKKiEzFrHlimao4BMchVQ==}
opencode-linux-x64-baseline@1.15.11:
resolution: {integrity: sha512-MX+eaLOkFVO5IA8jA0QLUJma3KBwRzUrzZrCFuv6+vq9U/SsD+F9Jz2Bnfw9iE9v3QwnJ+8Yf/vKCsb5LrrWvQ==}
cpu: [x64]
os: [linux]
opencode-linux-x64-musl@1.15.4:
resolution: {integrity: sha512-IbMaM6zrakdtDD55GUhlT/WeXomXmKsVqo3XQuOaGXprBg3W5alsxXh60SZpV3ftbdcMD/eiB/PYtN/ZN8Fa5w==}
opencode-linux-x64-musl@1.15.11:
resolution: {integrity: sha512-/xbhh8aDFR5E8Ggc00ZG3qXXAwWxosEfWaPXiP04/Y7kDbz8T28a1cSIWniNmY426rYdnYLXgwJzOgpD/ZhDDg==}
cpu: [x64]
os: [linux]
opencode-linux-x64@1.15.4:
resolution: {integrity: sha512-2c20aldKLfNkg6N6nABvvK1fuaCwYLo/HNeL8ikellkFMeGalCGDhkL/VQ8R8KPV3ohVZJtZwG0nkFiA8MeHCg==}
opencode-linux-x64@1.15.11:
resolution: {integrity: sha512-RuXo1XFiAqW6ypnP4V+rPDmTrdDKh8FuO+3whMpw2qIYs8eVil72QwX1Rv6W96FDCFQkyUZW9R6MK/MGbGWCUQ==}
cpu: [x64]
os: [linux]
opencode-windows-arm64@1.15.4:
resolution: {integrity: sha512-kr3nIWmYH7NC0Vgrhgjp9EmCuy5MuxjIRrSjzlfRLMaML6U/a0Hsr3AahBwI1KjT+HEhz5u6xpodZeeEDY3nPQ==}
opencode-windows-arm64@1.15.11:
resolution: {integrity: sha512-sdWtLGq1aoaCzbTQY8NR7+g56lzYREBBcT7Na81FKg4H/ZybLQHXV+lwbaw+cK/d0aPpM8EAB+TV6Wbe5nEzGw==}
cpu: [arm64]
os: [win32]
opencode-windows-x64-baseline@1.15.4:
resolution: {integrity: sha512-2/elQ163r4Q97bYJRrY09IG+bpqh0AKpfutDGCaokFdLWIWQN/cFvjzb4C+BKzLFsU9LRfoyvPhe4nXMm1+S4A==}
opencode-windows-x64-baseline@1.15.11:
resolution: {integrity: sha512-Lj9TkJIeUD++idJWIKK5z+k5hvNubAEXItdxzBLM8LlWArSd2tXCGbxbBktsM5URlhFBdAN05ghyiUtAVOcMPg==}
cpu: [x64]
os: [win32]
opencode-windows-x64@1.15.4:
resolution: {integrity: sha512-f6p40u3yLEbiq4pzBOXAwtW/NP/dL8uTurHfraPcfezA4ua5DEm4vSoSePUY0CHtubUPuDe0wRUA1s53sysjPQ==}
opencode-windows-x64@1.15.11:
resolution: {integrity: sha512-d5jJqLA+d2DmbEzVrriePxoOjLfqjKZar0OYkNgvmUcWHCHeyn1NcL5JH6T7L19s/X3qY1tEDvZZ3uWFqNMdGQ==}
cpu: [x64]
os: [win32]
@ -389,32 +409,42 @@ snapshots:
'@esbuild/win32-x64@0.28.0':
optional: true
'@github/copilot-darwin-arm64@1.0.44':
'@github/copilot-darwin-arm64@1.0.54':
optional: true
'@github/copilot-darwin-x64@1.0.44':
'@github/copilot-darwin-x64@1.0.54':
optional: true
'@github/copilot-linux-arm64@1.0.44':
'@github/copilot-linux-arm64@1.0.54':
optional: true
'@github/copilot-linux-x64@1.0.44':
'@github/copilot-linux-x64@1.0.54':
optional: true
'@github/copilot-win32-arm64@1.0.44':
'@github/copilot-linuxmusl-arm64@1.0.54':
optional: true
'@github/copilot-win32-x64@1.0.44':
'@github/copilot-linuxmusl-x64@1.0.54':
optional: true
'@github/copilot@1.0.44':
'@github/copilot-win32-arm64@1.0.54':
optional: true
'@github/copilot-win32-x64@1.0.54':
optional: true
'@github/copilot@1.0.54':
dependencies:
detect-libc: 2.1.2
optionalDependencies:
'@github/copilot-darwin-arm64': 1.0.44
'@github/copilot-darwin-x64': 1.0.44
'@github/copilot-linux-arm64': 1.0.44
'@github/copilot-linux-x64': 1.0.44
'@github/copilot-win32-arm64': 1.0.44
'@github/copilot-win32-x64': 1.0.44
'@github/copilot-darwin-arm64': 1.0.54
'@github/copilot-darwin-x64': 1.0.54
'@github/copilot-linux-arm64': 1.0.54
'@github/copilot-linux-x64': 1.0.54
'@github/copilot-linuxmusl-arm64': 1.0.54
'@github/copilot-linuxmusl-x64': 1.0.54
'@github/copilot-win32-arm64': 1.0.54
'@github/copilot-win32-x64': 1.0.54
'@types/node@25.6.2':
dependencies:
@ -422,6 +452,8 @@ snapshots:
bencode@2.0.3: {}
detect-libc@2.1.2: {}
esbuild@0.28.0:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.0
@ -456,55 +488,55 @@ snapshots:
bencode: 2.0.3
tree-kill: 1.2.2
opencode-ai@1.15.4:
opencode-ai@1.15.11:
optionalDependencies:
opencode-darwin-arm64: 1.15.4
opencode-darwin-x64: 1.15.4
opencode-darwin-x64-baseline: 1.15.4
opencode-linux-arm64: 1.15.4
opencode-linux-arm64-musl: 1.15.4
opencode-linux-x64: 1.15.4
opencode-linux-x64-baseline: 1.15.4
opencode-linux-x64-baseline-musl: 1.15.4
opencode-linux-x64-musl: 1.15.4
opencode-windows-arm64: 1.15.4
opencode-windows-x64: 1.15.4
opencode-windows-x64-baseline: 1.15.4
opencode-darwin-arm64: 1.15.11
opencode-darwin-x64: 1.15.11
opencode-darwin-x64-baseline: 1.15.11
opencode-linux-arm64: 1.15.11
opencode-linux-arm64-musl: 1.15.11
opencode-linux-x64: 1.15.11
opencode-linux-x64-baseline: 1.15.11
opencode-linux-x64-baseline-musl: 1.15.11
opencode-linux-x64-musl: 1.15.11
opencode-windows-arm64: 1.15.11
opencode-windows-x64: 1.15.11
opencode-windows-x64-baseline: 1.15.11
opencode-darwin-arm64@1.15.4:
opencode-darwin-arm64@1.15.11:
optional: true
opencode-darwin-x64-baseline@1.15.4:
opencode-darwin-x64-baseline@1.15.11:
optional: true
opencode-darwin-x64@1.15.4:
opencode-darwin-x64@1.15.11:
optional: true
opencode-linux-arm64-musl@1.15.4:
opencode-linux-arm64-musl@1.15.11:
optional: true
opencode-linux-arm64@1.15.4:
opencode-linux-arm64@1.15.11:
optional: true
opencode-linux-x64-baseline-musl@1.15.4:
opencode-linux-x64-baseline-musl@1.15.11:
optional: true
opencode-linux-x64-baseline@1.15.4:
opencode-linux-x64-baseline@1.15.11:
optional: true
opencode-linux-x64-musl@1.15.4:
opencode-linux-x64-musl@1.15.11:
optional: true
opencode-linux-x64@1.15.4:
opencode-linux-x64@1.15.11:
optional: true
opencode-windows-arm64@1.15.4:
opencode-windows-arm64@1.15.11:
optional: true
opencode-windows-x64-baseline@1.15.4:
opencode-windows-x64-baseline@1.15.11:
optional: true
opencode-windows-x64@1.15.4:
opencode-windows-x64@1.15.11:
optional: true
tree-kill@1.2.2: {}

Some files were not shown because too many files have changed in this diff Show More