Merge remote-tracking branch 'origin/staging' into develop
1
.gitignore
vendored
@ -95,3 +95,4 @@
|
||||
/.idea
|
||||
/.claude
|
||||
/.playwright-mcp
|
||||
/tools/__pycache__
|
||||
|
||||
@ -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.
|
||||
|
||||
30
CHANGES.md
@ -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
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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]}]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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))
|
||||
|
||||
43
backend/test/backend_tests/db_test.clj
Normal 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))))))
|
||||
@ -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
|
||||
|
||||
@ -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"]))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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"
|
||||
|
||||
449
common/test/common_tests/files/validate_test.cljc
Normal 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)))))))))
|
||||
@ -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 ")"))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
listen_addresses = '*'
|
||||
max_connections = 50
|
||||
max_connections = 160
|
||||
shared_buffers = 256MB
|
||||
temp_buffers = 18MB
|
||||
work_mem = 18MB
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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;
|
||||
|
||||
BIN
docs/img/customfonts-download.webp
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
docs/img/design-tokens/41-design-tab-token-dropdown.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/img/layers/pages-separator.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
docs/img/libraries/libraries-import-tokens-button.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/img/libraries/libraries-import-tokens-confirm.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/img/objects/board-copy-as-image.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/img/workspace-basics/history-preview-banner.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
docs/img/workspace-basics/history-preview-menu.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/img/workspace-basics/pixel-grid-color.webp
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
docs/img/workspace-basics/ruler-guide-pill-edit.webp
Normal file
|
After Width: | Height: | Size: 118 KiB |
@ -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__
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 team’s 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>
|
||||
|
||||
@ -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 don’t 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 you’re working with guides (if you’re 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
236
frontend/playwright/data/workspace/get-file-14214_main.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
243
frontend/playwright/data/workspace/get-file-14214_shared.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
[]
|
||||
308
frontend/playwright/data/workspace/get-file-rename-page.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
80
frontend/playwright/ui/specs/numeric-input.spec.js
Normal 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");
|
||||
});
|
||||
127
frontend/playwright/ui/specs/rename-page.spec.js
Normal 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}`));
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))))))))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)))))
|
||||
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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!
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)))
|
||||
|
||||
[:*
|
||||
|
||||
@ -867,6 +867,7 @@
|
||||
|
||||
.modal-invitation-content {
|
||||
overflow: auto;
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.invitation-list p {
|
||||
|
||||
@ -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*)
|
||||
|
||||
@ -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"]]]]]]
|
||||
|
||||
@ -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"))))))]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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}])]])])]])))
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}])]))
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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))))
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
@ -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: {}
|
||||
|
||||