mirror of
https://github.com/penpot/penpot.git
synced 2026-05-16 21:43:40 +00:00
Merge branch 'staging' into niwinz-main-svg-attrs-migration
This commit is contained in:
commit
2c3447890e
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
|
||||
|
||||
IMAGES=("frontend" "backend" "exporter" "storybook")
|
||||
IMAGES=("frontend" "backend" "exporter" "mcp" "storybook")
|
||||
SHORT_TAG=${TAG%.*}
|
||||
|
||||
for image in "${IMAGES[@]}"; do
|
||||
|
||||
69
.github/workflows/tests.yml
vendored
69
.github/workflows/tests.yml
vendored
@ -24,7 +24,11 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Linter"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /tmp/.m2:/root/.m2
|
||||
- /tmp/.gitlibs:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@ -84,7 +88,11 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Common Tests"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /tmp/.m2:/root/.m2
|
||||
- /tmp/.gitlibs:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@ -99,7 +107,11 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: Plugins Runtime Linter & Tests
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /tmp/.m2:/root/.m2
|
||||
- /tmp/.gitlibs:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@ -150,7 +162,11 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Frontend Tests"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /tmp/.m2:/root/.m2
|
||||
- /tmp/.gitlibs:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@ -172,7 +188,11 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Render WASM Tests"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /tmp/.m2:/root/.m2
|
||||
- /tmp/.gitlibs:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@ -197,7 +217,11 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Backend Tests"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /tmp/.m2:/root/.m2
|
||||
- /tmp/.gitlibs:/root/.gitlibs
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@ -237,7 +261,11 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Library Tests"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /tmp/.m2:/root/.m2
|
||||
- /tmp/.gitlibs:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@ -252,7 +280,11 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Build Integration Bundle"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /tmp/.m2:/root/.m2
|
||||
- /tmp/.gitlibs:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@ -273,7 +305,12 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests 1/3"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /tmp/.m2:/root/.m2
|
||||
- /tmp/.gitlibs:/root/.gitlibs
|
||||
|
||||
needs: build-integration
|
||||
|
||||
steps:
|
||||
@ -304,7 +341,12 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests 2/3"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /tmp/.m2:/root/.m2
|
||||
- /tmp/.gitlibs:/root/.gitlibs
|
||||
|
||||
needs: build-integration
|
||||
|
||||
steps:
|
||||
@ -335,7 +377,12 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests 3/3"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /tmp/.m2:/root/.m2
|
||||
- /tmp/.gitlibs:/root/.gitlibs
|
||||
|
||||
needs: build-integration
|
||||
|
||||
steps:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -86,3 +86,6 @@
|
||||
/**/.yarn/*
|
||||
/.pnpm-store
|
||||
/.vscode
|
||||
/.idea
|
||||
/.claude
|
||||
/.playwright-mcp
|
||||
|
||||
230
.opencode/skills/gh-issue-from-pr/SKILL.md
Normal file
230
.opencode/skills/gh-issue-from-pr/SKILL.md
Normal file
@ -0,0 +1,230 @@
|
||||
---
|
||||
name: gh-issue-from-pr
|
||||
description: Create a user-facing GitHub issue from a PR, separating the WHAT from the HOW, with correct milestone, project, labels, and issue type.
|
||||
---
|
||||
|
||||
# Skill: gh-issue-from-pr
|
||||
|
||||
Create a GitHub issue that captures the **WHAT** (user-facing feature or
|
||||
bug) from an existing PR that describes the **HOW** (implementation).
|
||||
Used when the project board needs an issue as the primary changelog/release unit.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Create a tracking issue from a PR for changelog purposes
|
||||
- Extract the user-facing problem/feature from a PR's implementation details
|
||||
- Assign milestone, project, labels, and issue type to a new issue derived from a PR
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
- Permission to create issues and edit PRs in the target repository
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Understand the PR
|
||||
|
||||
```bash
|
||||
gh pr view <PR_NUMBER> --repo penpot/penpot \
|
||||
--json title,body,author,labels,baseRefName,mergedAt,state,milestone
|
||||
```
|
||||
|
||||
Identify:
|
||||
|
||||
- **WHAT** — user-facing problem or feature. Goes into the issue.
|
||||
Describe symptoms and impact, not internal mechanisms.
|
||||
- **HOW** — implementation details. These belong in the PR, not the issue.
|
||||
|
||||
### 2. Determine metadata
|
||||
|
||||
| Field | Source | Rule |
|
||||
|-------|--------|------|
|
||||
| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. |
|
||||
| **Labels** | PR labels | Copy user-facing labels (`bug`, `enhancement`, `community contribution`). Skip workflow labels (`backport candidate`, `team-qa`). |
|
||||
| **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `gh pr view <PR_NUMBER> --json milestone --jq '.milestone.title'` If the PR has no milestone, create the issue without one. |
|
||||
| **Project** | Always `Main` | Penpot uses the `Main` project (number 8) for all issues. |
|
||||
| **Body** | PR's user-facing section | Extract steps to reproduce or feature description. Omit internal details. Use templates below. |
|
||||
| **Issue Type** | PR labels / title | Map: `bug` label or `:bug:` title → `Bug`. `enhancement` label or `:sparkles:` title → `Enhancement`. Feature/epic → `Feature`. Default → `Task`. |
|
||||
|
||||
### 3. Write the issue body
|
||||
|
||||
**Bug template:**
|
||||
|
||||
```markdown
|
||||
### Description
|
||||
|
||||
<what breaks, what the user experiences>
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
1. <step 1>
|
||||
2. <step 2>
|
||||
|
||||
### Expected behavior
|
||||
|
||||
<what should happen instead>
|
||||
|
||||
### Affected versions
|
||||
|
||||
<version>
|
||||
```
|
||||
|
||||
**Enhancement template:**
|
||||
|
||||
```markdown
|
||||
### Description
|
||||
|
||||
<what the user can now do that they couldn't before>
|
||||
|
||||
### Use case
|
||||
|
||||
<why this is useful, who benefits>
|
||||
|
||||
### Affected versions
|
||||
|
||||
<version>
|
||||
```
|
||||
|
||||
### 4. Create the issue
|
||||
|
||||
Write the body to a temp file to avoid shell quoting issues:
|
||||
|
||||
```bash
|
||||
cat > /tmp/issue-body.md << 'ISSUE_BODY'
|
||||
<body content here>
|
||||
ISSUE_BODY
|
||||
```
|
||||
|
||||
Create:
|
||||
|
||||
```bash
|
||||
gh issue create \
|
||||
--repo penpot/penpot \
|
||||
--title "<Title>" \
|
||||
--label "<label1>" \
|
||||
--label "<label2>" \
|
||||
--milestone "<milestone>" \
|
||||
--project "Main" \
|
||||
--body-file /tmp/issue-body.md
|
||||
```
|
||||
|
||||
Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
|
||||
|
||||
### 5. Assign to the PR author
|
||||
|
||||
Assign the issue to the PR author so they're responsible for it:
|
||||
|
||||
```bash
|
||||
AUTHOR=$(gh pr view <PR_NUMBER> --repo penpot/penpot --json author --jq '.author.login')
|
||||
gh issue edit <ISSUE_NUMBER> --repo penpot/penpot --add-assignee "$AUTHOR"
|
||||
```
|
||||
|
||||
### 6. Set the Issue Type
|
||||
|
||||
`gh issue create` can't set the Issue Type directly. Use GraphQL.
|
||||
|
||||
Get the issue's GraphQL node ID:
|
||||
|
||||
```bash
|
||||
ISSUE_ID=$(gh api graphql -f query='
|
||||
query { repository(owner: "penpot", name: "penpot") {
|
||||
issue(number: <ISSUE_NUMBER>) { id }
|
||||
}}' --jq '.data.repository.issue.id')
|
||||
```
|
||||
|
||||
Issue Type IDs for the Penpot repo:
|
||||
|
||||
| Type | ID |
|
||||
|------|----|
|
||||
| Bug | `IT_kwDOAcyBPM4AX5Nb` |
|
||||
| Enhancement | `IT_kwDOAcyBPM4B_IQN` |
|
||||
| Feature | `IT_kwDOAcyBPM4AX5Nf` |
|
||||
| Task | `IT_kwDOAcyBPM4AX5NY` |
|
||||
| Question | `IT_kwDOAcyBPM4B_IQj` |
|
||||
| Docs | `IT_kwDOAcyBPM4B_IQz` |
|
||||
|
||||
Set it:
|
||||
|
||||
```bash
|
||||
gh api graphql -f query='
|
||||
mutation {
|
||||
updateIssue(input: {
|
||||
id: "'"$ISSUE_ID"'"
|
||||
issueTypeId: "<TYPE_ID>"
|
||||
}) {
|
||||
issue { number issueType { name } }
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 7. Verify
|
||||
|
||||
```bash
|
||||
gh issue view <ISSUE_NUMBER> --repo penpot/penpot \
|
||||
--json title,milestone,projectItems,labels \
|
||||
--jq '{title, milestone: .milestone.title, projects: [.projectItems[].title], labels: [.labels[].name]}'
|
||||
|
||||
gh api graphql -f query='
|
||||
query { repository(owner: "penpot", name: "penpot") {
|
||||
issue(number: <ISSUE_NUMBER>) { issueType { name } }
|
||||
}}' --jq '.data.repository.issue.issueType.name'
|
||||
```
|
||||
|
||||
### 8. Link the PR to the issue
|
||||
|
||||
Append `Fixes #<ISSUE_NUMBER>` to the PR body:
|
||||
|
||||
```bash
|
||||
gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md
|
||||
printf "\n\nFixes #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md
|
||||
gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md
|
||||
|
||||
# Verify
|
||||
gh pr view <PR_NUMBER> --repo penpot/penpot --json body \
|
||||
--jq '.body | test("Fixes #<ISSUE_NUMBER>")'
|
||||
```
|
||||
|
||||
**Note:** If the PR is already merged, `Fixes` won't auto-close the issue
|
||||
— it only creates the "Development" sidebar link. This is the desired
|
||||
behavior since the issue is a tracking artifact.
|
||||
|
||||
### 9. Clean up
|
||||
|
||||
```bash
|
||||
rm -f /tmp/issue-body.md /tmp/pr-body.md
|
||||
```
|
||||
|
||||
## Label rules
|
||||
|
||||
| PR has | Issue gets |
|
||||
|--------|-----------|
|
||||
| `bug` | `bug` |
|
||||
| `enhancement` | `enhancement` |
|
||||
| `community contribution` | `community contribution` |
|
||||
| `backport candidate` | *(skip — workflow label)* |
|
||||
| `team-qa` | *(skip — workflow label)* |
|
||||
| No user-facing label | Infer from title: `:bug:` → `bug`, `:sparkles:` → `enhancement` |
|
||||
|
||||
## Issue Type mapping
|
||||
|
||||
| PR label(s) / title prefix | Issue Type |
|
||||
|----------------------------|-----------|
|
||||
| `bug` or `:bug:` | Bug |
|
||||
| `enhancement` or `:sparkles:` or `:tada:` | Enhancement |
|
||||
| Feature / epic | Feature |
|
||||
| Documentation | Docs |
|
||||
| None of the above | Task |
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Issue = WHAT, PR = HOW.** Never put implementation details in the
|
||||
issue body. The issue is for users, QA, and changelog readers.
|
||||
- **Copy the milestone from the PR.** Don't guess based on branch names.
|
||||
If the PR has no milestone, create the issue without one.
|
||||
- **Set Issue Type via GraphQL** — `gh issue create` can't set it.
|
||||
- **Link via PR body** — `Fixes #<NUMBER>` creates the "Development"
|
||||
sidebar link automatically.
|
||||
- **One issue per PR** — even if a PR fixes multiple things, create a
|
||||
single issue that summarizes the overall change.
|
||||
- **Community attribution:** if the PR has the `community contribution`
|
||||
label or the author is not a core team member, add the label to the issue.
|
||||
201
.opencode/skills/update-changelog/SKILL.md
Normal file
201
.opencode/skills/update-changelog/SKILL.md
Normal file
@ -0,0 +1,201 @@
|
||||
---
|
||||
name: update-changelog
|
||||
description: Update the project CHANGES.md with issues from a given GitHub milestone, with correct categorization and references.
|
||||
---
|
||||
|
||||
# Skill: update-changelog
|
||||
|
||||
Update `CHANGES.md` with entries for all issues and PRs in a given GitHub
|
||||
milestone. Each entry references the user-facing issue (not the PR) as the
|
||||
primary link, with the fix PR on a sub-line.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before a new release, to populate the changelog with all fixed issues
|
||||
- When new issues are added to an existing milestone and the changelog needs
|
||||
to be refreshed
|
||||
- To ensure every entry follows the correct format for the changelog
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
- Read access to the penpot/penpot repository
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine the target version
|
||||
|
||||
The version is typically a semver string like `2.15.3`. Confirm with the user
|
||||
if not specified.
|
||||
|
||||
### 2. Fetch all issues and PRs in the milestone
|
||||
|
||||
Find the milestone number:
|
||||
|
||||
```bash
|
||||
gh api repos/penpot/penpot/milestones --paginate \
|
||||
--jq '.[] | select(.title=="<VERSION>") | {number: .number, title: .title, open_issues: .open_issues, closed_issues: .closed_issues}'
|
||||
```
|
||||
|
||||
Then fetch all items:
|
||||
|
||||
```bash
|
||||
MILESTONE_NUMBER=<NUMBER>
|
||||
gh api "repos/penpot/penpot/issues?milestone=$MILESTONE_NUMBER&state=all&per_page=100" \
|
||||
--jq '.[] | {number: .number, title: .title, state: .state, labels: [.labels[].name], pull_request: .pull_request != null}'
|
||||
```
|
||||
|
||||
### 3. Identify issue ↔ PR relationships
|
||||
|
||||
For each item, determine the relationship:
|
||||
|
||||
- **Issue** (`pull_request: false`): This is the user-facing issue. It
|
||||
becomes the primary link in the changelog.
|
||||
- **PR** (`pull_request: true`): Check if it has `Fixes #<NUMBER>` in its
|
||||
body to find which issue it closes.
|
||||
|
||||
To find the linked issue for a PR:
|
||||
|
||||
```bash
|
||||
gh pr view <PR_NUMBER> --repo penpot/penpot \
|
||||
--json body,closingIssuesReferences --jq '{closingIssues: [.closingIssuesReferences[].number]}'
|
||||
```
|
||||
|
||||
**Only closed issues are included.** An issue must have `state: "closed"` to
|
||||
appear in the changelog. Open/unresolved issues are omitted, even if they are
|
||||
tracked in the milestone.
|
||||
|
||||
**Pairing rules:**
|
||||
|
||||
| Pattern | Changelog format |
|
||||
|---------|-----------------|
|
||||
| Closed issue + one or more PRs fix it | Primary link = issue, sub-line with PRs comma-separated |
|
||||
| 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 sub-line. |
|
||||
|
||||
### 4. Categorize entries
|
||||
|
||||
Check the labels on each issue/PR:
|
||||
|
||||
```bash
|
||||
gh issue view <NUMBER> --repo penpot/penpot --json labels --jq '[.labels[].name]'
|
||||
```
|
||||
|
||||
| Label / Title prefix | Changelog section |
|
||||
|----------------------|-------------------|
|
||||
| `bug` label or `:bug:` title prefix | `### :bug: Bugs fixed` |
|
||||
| `enhancement` label or `:sparkles:` prefix | `### :sparkles: New features & Enhancements` |
|
||||
| No label | Infer from title convention, default to bug fix |
|
||||
|
||||
**Community contribution attribution:** If the issue or its fix PR has the
|
||||
`community contribution` label, add an attribution `(by @<github_username>)`
|
||||
on the changelog entry line, **before** the GitHub issue/PR references.
|
||||
Fetch the author:
|
||||
|
||||
```bash
|
||||
gh issue view <NUMBER> --repo penpot/penpot --json author --jq '.author.login'
|
||||
```
|
||||
|
||||
Placement in the entry line:
|
||||
```markdown
|
||||
- Fix description of the bug (by @username) [Github #<ISSUE>](...)
|
||||
(PR: [#<PR>](...))
|
||||
```
|
||||
|
||||
### 5. Read the current CHANGES.md
|
||||
|
||||
Read the top of `CHANGES.md` to understand the existing format and find the
|
||||
insertion point (newest version goes at the top, after the `# CHANGELOG`
|
||||
header).
|
||||
|
||||
Key format rules from the existing file:
|
||||
|
||||
```markdown
|
||||
## <VERSION>
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix description of the bug [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
|
||||
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- Fix another bug (by @contributor) [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
|
||||
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add new feature description [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
|
||||
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
```
|
||||
|
||||
Format details:
|
||||
- Entries start with `- ` followed by a short description in imperative mood
|
||||
- Primary link is **always the issue** (user-facing artifact)
|
||||
- PR references are on an indented sub-line: ` (PR: [#<N>](<url>))`
|
||||
If an issue has multiple fix PRs, they are comma-separated on one line:
|
||||
` (PR: [#<N>](<url>), [#<M>](<url>))`
|
||||
- The description should describe the fix/feature from the user's perspective
|
||||
- Community contributions get `(by @<username>)` **before** the GitHub link
|
||||
- Sections are separated by a blank line between the last entry and the next
|
||||
section title
|
||||
- Only include a section if there are entries for it
|
||||
|
||||
### 6. Build the description text
|
||||
|
||||
Derive the description from the issue title, not the PR title. Strip leading
|
||||
emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`) and focus on the
|
||||
user-facing behavior.
|
||||
|
||||
Examples:
|
||||
|
||||
| Issue title | Changelog description |
|
||||
|-------------|----------------------|
|
||||
| `Plugin API token methods fail with schema validation error on PRO` | `Fix Plugin API token methods failing with schema validation error on PRO` |
|
||||
| `Comment content is not sanitized before rendering, enabling stored XSS` | `Sanitize comment content on rendering` |
|
||||
| `Custom uploaded font family names are not sanitized` | `Sanitize font family names on custom uploaded fonts` |
|
||||
|
||||
### 7. Insert the section into CHANGES.md
|
||||
|
||||
Insert the new version section right after the `# CHANGELOG` header (before
|
||||
the previous version entry). Use the `edit` tool with enough context to make
|
||||
a unique match.
|
||||
|
||||
### 8. Verify
|
||||
|
||||
Read the top of `CHANGES.md` and confirm:
|
||||
- The version header is correct
|
||||
- Every entry has a GitHub link
|
||||
- Entries with a fix PR have the PR sub-line
|
||||
- The section ordering is correct (newest first)
|
||||
- Formatting matches the surrounding entries
|
||||
|
||||
## Version section template
|
||||
|
||||
```markdown
|
||||
## <VERSION>
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- <fix description> [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
|
||||
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> (by @contributor) [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
|
||||
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Issue = changelog unit.** The primary link always points to the
|
||||
user-facing issue, not the implementation PR.
|
||||
- **PR = implementation detail.** Reference the PR on a sub-line so readers
|
||||
can find the code changes.
|
||||
- **Latest version first.** New sections are inserted at the top of the
|
||||
changelog, below the `# CHANGELOG` header.
|
||||
- **User-facing descriptions.** Write from the user's perspective — describe
|
||||
what broke and what was fixed, not internal implementation details.
|
||||
- **Community attribution.** When the issue or fix PR has the
|
||||
`community contribution` label, add `(by @<username>)` on the entry line
|
||||
between the description and the GitHub link.
|
||||
- **Only closed issues.** An issue must have `state: "closed"` to appear in
|
||||
the changelog. Open unresolved issues are omitted.
|
||||
- **Multiple PRs per issue.** If multiple PRs fix the same issue, list them
|
||||
comma-separated on the same sub-line: `(PR: [#A](url), [#B](url))`.
|
||||
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
|
||||
before making edits, don't rely on cached data.
|
||||
97
CHANGES.md
97
CHANGES.md
@ -10,7 +10,6 @@
|
||||
|
||||
- Enhance readability of applied tokens in plugins API [Taiga #13714](https://tree.taiga.io/project/penpot/issue/13714)
|
||||
- Add "Delete group" option to the assets panel context menu for components, colors and typographies (by @FairyPigDev) [Github #9141](https://github.com/penpot/penpot/issues/9141)
|
||||
- Add `Alt+click` on a layer's disclosure arrow to recursively expand the entire subtree in the Layers sidebar (by @MilosM348) [Github #9179](https://github.com/penpot/penpot/pull/9179)
|
||||
- Show alpha percentage next to library color values to distinguish colors that differ only in opacity (by @rockchris099) [Github #6328](https://github.com/penpot/penpot/issues/6328)
|
||||
- Add "Clear artboard guides" option to right-click context menu for frames (by @eureka0928) [Github #6987](https://github.com/penpot/penpot/issues/6987)
|
||||
- Add loader feedback while importing and exporting files (by @moorsecopers99) [Github #9024](https://github.com/penpot/penpot/pull/9024)
|
||||
@ -20,7 +19,7 @@
|
||||
- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320)
|
||||
- Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313)
|
||||
- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8474)
|
||||
- Copy and paste entire rows in existing table (by @bittoby) [Github #8498](https://github.com/penpot/penpot/pull/8498)
|
||||
- Copy and paste entire rows in existing table (by @bittoby) [Github #8498](https://github.com/penpot/penpot/pull/84r98)
|
||||
- Rename token group [Taiga #13137](https://tree.taiga.io/project/penpot/us/13137)
|
||||
- Duplicate token group [Taiga #10653](https://tree.taiga.io/project/penpot/us/10653)
|
||||
- Copy token name from contextual menu [Taiga #13568](https://tree.taiga.io/project/penpot/issue/13568)
|
||||
@ -62,6 +61,7 @@
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix render-wasm atlas corruption when dragging large shapes after a zoom or pan change (stale multi-zoom-level pixels no longer appear at the old shape position).
|
||||
- Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361)
|
||||
- Add token name on broken token pill on sidebar [Taiga #13527](https://tree.taiga.io/project/penpot/issue/13527)
|
||||
- Fix tooltip activated when tab change [Taiga #13627](https://tree.taiga.io/project/penpot/issue/13627)
|
||||
@ -123,34 +123,83 @@
|
||||
- Fix plugin parse-point returning plain map instead of Point record (by @FairyPigDev) [Github #9129](https://github.com/penpot/penpot/pull/9129)
|
||||
- Fix `:heigth` typo in clipboard frame-same-size? (by @iot2edge) [Github #9250](https://github.com/penpot/penpot/pull/9250)
|
||||
- Fix Settings and Notifications "Update Settings" button enabled state when form has no changes (by @moorsecopers99) [Github #9090](https://github.com/penpot/penpot/issues/9090)
|
||||
- Fix library updates reappear after being applied and the file is reloaded [Taiga #14040](https://tree.taiga.io/project/penpot/issue/14040)
|
||||
- Fix dependency libraries remaining visible in UI after unlinking main library [Taiga #14020](https://tree.taiga.io/project/penpot/issue/14020)
|
||||
|
||||
## 2.15.0 (Unreleased)
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174)
|
||||
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909)
|
||||
- Improve team name validation [Github #9176](https://github.com/penpot/penpot/pull/9176)
|
||||
## 2.15.3
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix Plugin API token methods failing with schema validation error on PRO [Github #9641](https://github.com/penpot/penpot/issues/9641)
|
||||
(PR: [#9632](https://github.com/penpot/penpot/pull/9632))
|
||||
- Sanitize comment content on rendering [Github #9642](https://github.com/penpot/penpot/issues/9642)
|
||||
(PR: [#9605](https://github.com/penpot/penpot/pull/9605))
|
||||
- Sanitize font family names on custom uploaded fonts [Github #9643](https://github.com/penpot/penpot/issues/9643)
|
||||
(PR: [#9601](https://github.com/penpot/penpot/pull/9601))
|
||||
|
||||
## 2.15.2
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix mcp related internal config for docker images [Github #9565](https://github.com/penpot/penpot/pull/9565)
|
||||
|
||||
|
||||
## 2.15.1
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add support for chunked uploading of fonts [Github #9560](https://github.com/penpot/penpot/issues/9560)
|
||||
|
||||
|
||||
## 2.15.0
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174)
|
||||
(PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321))
|
||||
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #9516](https://github.com/penpot/penpot/issues/9516)
|
||||
(PR: [#8909](https://github.com/penpot/penpot/pull/8909))
|
||||
- Add anonymous telemetry event collection [Github #9467](https://github.com/penpot/penpot/issues/9467)
|
||||
(PR: [#9065](https://github.com/penpot/penpot/pull/9065), [#9483](https://github.com/penpot/penpot/pull/9483))
|
||||
- Improve team name validation [Github #9517](https://github.com/penpot/penpot/issues/9517)
|
||||
(PR: [#9176](https://github.com/penpot/penpot/pull/9176))
|
||||
- Enhance readability of applied tokens in plugins API [Github #9175](https://github.com/penpot/penpot/issues/9175)
|
||||
(PR: [#8607](https://github.com/penpot/penpot/pull/8607))
|
||||
- Encourage use of flex/grid layouts in designs generated via MCP [Github #9081](https://github.com/penpot/penpot/issues/9081)
|
||||
(PR: [#9084](https://github.com/penpot/penpot/pull/9084))
|
||||
- Improve MCP server logging, adding Loki support [Github #9415](https://github.com/penpot/penpot/issues/9415)
|
||||
(PR: [#9425](https://github.com/penpot/penpot/pull/9425))
|
||||
- Add security headers to Nginx on Docker images [Github #9519](https://github.com/penpot/penpot/issues/9519)
|
||||
(PR: [#9473](https://github.com/penpot/penpot/pull/9473))
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix MCP integrations URL copy action to match the URL displayed in settings [Github #9238](https://github.com/penpot/penpot/issues/9238)
|
||||
- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162)
|
||||
- Harden Nginx responses with standard security headers and hide upstream `X-Powered-By` headers
|
||||
- Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9435](https://github.com/penpot/penpot/pull/9435)
|
||||
- Fix MCP "active in another tab" notification not clearing (by @Dexterity104) [Github #9321](https://github.com/penpot/penpot/pull/9321)
|
||||
- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9322](https://github.com/penpot/penpot/pull/9322)
|
||||
- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9400] (https://github.com/penpot/penpot/pull/9400)
|
||||
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
|
||||
- Fix SSRF in media URL import and restrict unauthenticated asset access to public buckets only [Github #9390](https://github.com/penpot/penpot/pull/9390)
|
||||
- Fix text edition mode not exited when changing selection, blocking token application [Github #9346](https://github.com/penpot/penpot/issues/9346)
|
||||
- Use base64 envelope for Uint8Array task results to avoid JSON expansion (by @opcode81) [Github #9431](https://github.com/penpot/penpot/pull/9431)
|
||||
- Fix empty warning on login [Github #9056](https://github.com/penpot/penpot/pull/9056)
|
||||
- Fix layer hierarchy to match old and new SCSS [Github #9126](https://github.com/penpot/penpot/pull/9126)
|
||||
- Fix multiple selection on shapes with token applied to stroke color [Github #9110](https://github.com/penpot/penpot/pull/9110)
|
||||
- Fix onboarding modals appearing behind libraries and templates panel [Github #9178](https://github.com/penpot/penpot/pull/9178)
|
||||
(PR: [#9355](https://github.com/penpot/penpot/pull/9355))
|
||||
- Reduce memory usage of MCP server when handling images (by @opcode81) [Github #9420](https://github.com/penpot/penpot/issues/9420)
|
||||
(PR: [#9431](https://github.com/penpot/penpot/pull/9431))
|
||||
- Fix Plugin API token methods rejecting JS array of strings (by @boskodev790) [Github #9162](https://github.com/penpot/penpot/issues/9162)
|
||||
(PR: [#9166](https://github.com/penpot/penpot/pull/9166))
|
||||
- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296)
|
||||
(PR: [#9126](https://github.com/penpot/penpot/pull/9126), [#9233](https://github.com/penpot/penpot/pull/9233))
|
||||
- Fix empty warning on login [Github #9520](https://github.com/penpot/penpot/issues/9520)
|
||||
(PR: [#9056](https://github.com/penpot/penpot/pull/9056))
|
||||
- Fix maximum call stack size exceeded in SSE read-stream [Github #9470](https://github.com/penpot/penpot/issues/9470)
|
||||
(PR: [#9484](https://github.com/penpot/penpot/pull/9484))
|
||||
- Fix incorrect handling of version restore operation [Github #9515](https://github.com/penpot/penpot/issues/9515)
|
||||
(PR: [#9041](https://github.com/penpot/penpot/pull/9041))
|
||||
- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9518](https://github.com/penpot/penpot/issues/9518)
|
||||
(PR: [#9400](https://github.com/penpot/penpot/pull/9400))
|
||||
- Fix MCP integrations URL copy action to match the URL displayed in settings [Github #9238](https://github.com/penpot/penpot/issues/9238)
|
||||
(PR: [#9239](https://github.com/penpot/penpot/pull/9239))
|
||||
- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9496](https://github.com/penpot/penpot/issues/9496)
|
||||
(PR: [#9322](https://github.com/penpot/penpot/pull/9322))
|
||||
- Fix multiple selection on shapes with token applied to stroke color [Github #9522](https://github.com/penpot/penpot/issues/9522)
|
||||
(PR: [#9110](https://github.com/penpot/penpot/pull/9110))
|
||||
- Fix onboarding modals appearing behind libraries and templates panel [Github #9521](https://github.com/penpot/penpot/issues/9521)
|
||||
(PR: [#9178](https://github.com/penpot/penpot/pull/9178))
|
||||
- Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9430](https://github.com/penpot/penpot/issues/9430)
|
||||
(PR: [#9435](https://github.com/penpot/penpot/pull/9435))
|
||||
|
||||
## 2.14.5
|
||||
|
||||
@ -166,7 +215,6 @@
|
||||
- Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122)
|
||||
- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927)
|
||||
|
||||
|
||||
## 2.14.3
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
@ -196,7 +244,6 @@
|
||||
- Fix typo `:podition` in swap-shapes grid cell
|
||||
- Fix multiple selection on shapes with token applied to stroke color
|
||||
|
||||
|
||||
## 2.14.2
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
:deps
|
||||
{penpot/common {:local/root "../common"}
|
||||
org.clojure/clojure {:mvn/version "1.12.4"}
|
||||
org.clojure/clojure {:mvn/version "1.12.5"}
|
||||
org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.7-4"}
|
||||
@ -17,7 +17,7 @@
|
||||
|
||||
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
|
||||
|
||||
io.lettuce/lettuce-core {:mvn/version "6.8.1.RELEASE"}
|
||||
io.lettuce/lettuce-core {:mvn/version "7.5.1.RELEASE"}
|
||||
;; Minimal dependencies required by lettuce, we need to include them
|
||||
;; explicitly because clojure dependency management does not support
|
||||
;; yet the BOM format.
|
||||
@ -28,18 +28,18 @@
|
||||
com.google.guava/guava {:mvn/version "33.4.8-jre"}
|
||||
|
||||
funcool/yetti
|
||||
{:git/tag "v11.9"
|
||||
:git/sha "5fad7a9"
|
||||
{:git/tag "v11.10"
|
||||
:git/sha "88701f4"
|
||||
:git/url "https://github.com/funcool/yetti.git"
|
||||
:exclusions [org.slf4j/slf4j-api]}
|
||||
|
||||
com.github.seancorfield/next.jdbc
|
||||
{:mvn/version "1.3.1070"}
|
||||
{:mvn/version "1.3.1093"}
|
||||
|
||||
metosin/reitit-core {:mvn/version "0.9.1"}
|
||||
nrepl/nrepl {:mvn/version "1.4.0"}
|
||||
nrepl/nrepl {:mvn/version "1.7.0"}
|
||||
|
||||
org.postgresql/postgresql {:mvn/version "42.7.9"}
|
||||
org.postgresql/postgresql {:mvn/version "42.7.11"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
|
||||
|
||||
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
|
||||
@ -49,7 +49,7 @@
|
||||
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
||||
buddy/buddy-sign {:mvn/version "3.6.1-359"}
|
||||
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.3"}
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
||||
|
||||
org.jsoup/jsoup {:mvn/version "1.21.2"}
|
||||
org.im4java/im4java
|
||||
@ -57,7 +57,8 @@
|
||||
:git/sha "e2b3e16"
|
||||
:git/url "https://github.com/penpot/im4java"}
|
||||
|
||||
org.lz4/lz4-java {:mvn/version "1.8.0"}
|
||||
at.yawk.lz4/lz4-java
|
||||
{:mvn/version "1.11.0"}
|
||||
|
||||
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
|
||||
|
||||
@ -66,17 +67,17 @@
|
||||
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.41.21"}}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.44.4"}}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
{:dev
|
||||
{:extra-deps
|
||||
{com.bhauman/rebel-readline {:mvn/version "RELEASE"}
|
||||
{com.bhauman/rebel-readline {:mvn/version "0.1.5"}
|
||||
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
|
||||
org.clojure/data.csv {:mvn/version "RELEASE"}
|
||||
com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"}
|
||||
mockery/mockery {:mvn/version "RELEASE"}}
|
||||
org.clojure/data.csv {:mvn/version "1.1.1"}
|
||||
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
|
||||
mockery/mockery {:mvn/version "0.1.4"}}
|
||||
:extra-paths ["test" "dev"]}
|
||||
|
||||
:build
|
||||
@ -92,7 +93,7 @@
|
||||
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}}
|
||||
|
||||
:outdated
|
||||
{:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"}}
|
||||
{:extra-deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}}
|
||||
:main-opts ["-m" "antq.core"]}
|
||||
|
||||
:jmx-remote
|
||||
|
||||
@ -440,11 +440,28 @@
|
||||
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(let [ids (db/create-array conn "uuid" ids)
|
||||
sql (str "SELECT flr.* FROM file_library_rel AS flr "
|
||||
" JOIN file AS l ON (flr.library_file_id = l.id) "
|
||||
" WHERE flr.file_id = ANY(?) AND l.deleted_at IS NULL")]
|
||||
sql (str "SELECT flr.*,"
|
||||
" fls.synced_at"
|
||||
" FROM file_library_rel AS flr"
|
||||
" JOIN file AS l"
|
||||
" ON flr.library_file_id = l.id"
|
||||
" LEFT JOIN file_library_sync AS fls"
|
||||
" ON fls.file_id = flr.file_id"
|
||||
" AND fls.library_file_id = flr.library_file_id"
|
||||
" WHERE flr.file_id = ANY(?)"
|
||||
" AND l.deleted_at IS NULL;")]
|
||||
(db/exec! conn [sql ids])))))
|
||||
|
||||
(def ^:private sql:upsert-file-library-sync
|
||||
"INSERT INTO file_library_sync (file_id, library_file_id, synced_at)
|
||||
VALUES (?::uuid, ?::uuid, ?::timestamptz)
|
||||
ON CONFLICT (file_id, library_file_id)
|
||||
DO UPDATE SET synced_at = EXCLUDED.synced_at;")
|
||||
|
||||
(defn upsert-file-library-sync!
|
||||
[conn {:keys [file-id library-file-id synced-at]}]
|
||||
(db/exec-one! conn [sql:upsert-file-library-sync file-id library-file-id synced-at]))
|
||||
|
||||
(def ^:private sql:get-libraries
|
||||
"WITH RECURSIVE libs AS (
|
||||
SELECT fl.id
|
||||
@ -799,32 +816,41 @@
|
||||
|
||||
(def ^:private sql:get-file-libraries
|
||||
"WITH RECURSIVE libs AS (
|
||||
SELECT fl.*, flr.synced_at
|
||||
FROM file AS fl
|
||||
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
|
||||
WHERE flr.file_id = ?::uuid
|
||||
UNION
|
||||
SELECT fl.*, flr.synced_at
|
||||
FROM file AS fl
|
||||
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
|
||||
JOIN libs AS l ON (flr.file_id = l.id)
|
||||
)
|
||||
SELECT l.id,
|
||||
l.features,
|
||||
l.project_id,
|
||||
p.team_id,
|
||||
l.created_at,
|
||||
l.modified_at,
|
||||
l.deleted_at,
|
||||
l.name,
|
||||
l.revn,
|
||||
l.vern,
|
||||
l.synced_at,
|
||||
l.is_shared,
|
||||
l.version
|
||||
FROM libs AS l
|
||||
INNER JOIN project AS p ON (p.id = l.project_id)
|
||||
WHERE l.deleted_at IS NULL;")
|
||||
SELECT fl.*
|
||||
FROM file AS fl
|
||||
JOIN file_library_rel AS flr
|
||||
ON flr.library_file_id = fl.id
|
||||
WHERE flr.file_id = ?::uuid
|
||||
|
||||
UNION
|
||||
|
||||
SELECT fl.*
|
||||
FROM file AS fl
|
||||
JOIN file_library_rel AS flr
|
||||
ON flr.library_file_id = fl.id
|
||||
JOIN libs AS l
|
||||
ON flr.file_id = l.id
|
||||
)
|
||||
SELECT l.id,
|
||||
l.features,
|
||||
l.project_id,
|
||||
p.team_id,
|
||||
l.created_at,
|
||||
l.modified_at,
|
||||
l.deleted_at,
|
||||
l.name,
|
||||
l.revn,
|
||||
l.vern,
|
||||
l.is_shared,
|
||||
l.version,
|
||||
fls.synced_at
|
||||
FROM libs AS l
|
||||
JOIN project AS p
|
||||
ON p.id = l.project_id
|
||||
LEFT JOIN file_library_sync AS fls
|
||||
ON fls.file_id = ?::uuid
|
||||
AND fls.library_file_id = l.id
|
||||
WHERE l.deleted_at IS NULL;")
|
||||
|
||||
(defn get-file-libraries
|
||||
[conn file-id]
|
||||
@ -834,7 +860,7 @@
|
||||
;; completly useless
|
||||
(map #(assoc % :is-indirect false))
|
||||
(map decode-row-features))
|
||||
(db/exec! conn [sql:get-file-libraries file-id])))
|
||||
(db/exec! conn [sql:get-file-libraries file-id file-id])))
|
||||
|
||||
(defn get-resolved-file-libraries
|
||||
"Get all file libraries including itself. Returns an instance of
|
||||
|
||||
@ -573,7 +573,6 @@
|
||||
;; Insert all file relations
|
||||
(doseq [{:keys [library-file-id] :as rel} rels]
|
||||
(let [rel (-> rel
|
||||
(assoc :synced-at timestamp)
|
||||
(update :file-id bfc/lookup-index)
|
||||
(update :library-file-id bfc/lookup-index))]
|
||||
|
||||
@ -583,7 +582,12 @@
|
||||
:file-id (:file-id rel)
|
||||
:lib-id (:library-file-id rel)
|
||||
::l/sync? true)
|
||||
(db/insert! conn :file-library-rel rel))
|
||||
(let [rel-params (dissoc rel :synced-at)]
|
||||
(db/insert! conn :file-library-rel rel-params)
|
||||
(bfc/upsert-file-library-sync! conn {:file-id (:file-id rel-params)
|
||||
:library-file-id (:library-file-id rel-params)
|
||||
:synced-at (or (:synced-at rel)
|
||||
timestamp)})))
|
||||
|
||||
(l/warn :hint "ignoring file library link"
|
||||
:file-id (:file-id rel)
|
||||
|
||||
@ -314,10 +314,10 @@
|
||||
(doseq [rel (read-obj cfg :file-rels file-id)]
|
||||
(let [rel (-> rel
|
||||
(update :file-id bfc/lookup-index)
|
||||
(update :library-file-id bfc/lookup-index)
|
||||
(assoc :synced-at timestamp))]
|
||||
(update :library-file-id bfc/lookup-index))]
|
||||
(db/insert! conn :file-library-rel rel
|
||||
::db/return-keys false)))
|
||||
::db/return-keys false)
|
||||
(bfc/upsert-file-library-sync! conn (assoc rel :synced-at timestamp))))
|
||||
|
||||
(doseq [media (read-seq cfg :file-media-object file-id)]
|
||||
(let [media (-> media
|
||||
|
||||
@ -824,10 +824,10 @@
|
||||
:file-id (str file-id)
|
||||
:lib-id (str libr-id)
|
||||
::l/sync? true)
|
||||
(db/insert! conn :file-library-rel
|
||||
{:synced-at timestamp
|
||||
:file-id file-id
|
||||
:library-file-id libr-id})))))
|
||||
(let [rel-params {:file-id file-id
|
||||
:library-file-id libr-id}]
|
||||
(db/insert! conn :file-library-rel rel-params)
|
||||
(bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp)))))))
|
||||
|
||||
(defn- import-storage-objects
|
||||
[{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}]
|
||||
|
||||
@ -72,6 +72,7 @@
|
||||
:telemetry-uri "https://telemetry.penpot.app/"
|
||||
|
||||
:media-max-file-size (* 1024 1024 30) ; 30MiB
|
||||
:font-max-file-size (* 1024 1024 30) ; 30MiB
|
||||
|
||||
:ldap-user-query "(|(uid=:username)(mail=:username))"
|
||||
:ldap-attrs-username "uid"
|
||||
@ -120,6 +121,7 @@
|
||||
[:auto-file-snapshot-timeout {:optional true} ::ct/duration]
|
||||
|
||||
[:media-max-file-size {:optional true} ::sm/int]
|
||||
[:font-max-file-size {:optional true} ::sm/int]
|
||||
[:deletion-delay {:optional true} ::ct/duration]
|
||||
[:file-clean-delay {:optional true} ::ct/duration]
|
||||
[:telemetry-enabled {:optional true} ::sm/boolean]
|
||||
|
||||
@ -61,21 +61,15 @@
|
||||
::mdef/help "A total number of bytes processed by update-file."
|
||||
::mdef/type :counter}
|
||||
|
||||
:rpc-mutation-timing
|
||||
{::mdef/name "penpot_rpc_mutation_timing"
|
||||
::mdef/help "RPC mutation method call timing."
|
||||
:rpc-main-timing
|
||||
{::mdef/name "penpot_rpc_main_timing"
|
||||
::mdef/help "RPC command method call timing for main"
|
||||
::mdef/labels ["name"]
|
||||
::mdef/type :histogram}
|
||||
|
||||
:rpc-command-timing
|
||||
{::mdef/name "penpot_rpc_command_timing"
|
||||
::mdef/help "RPC command method call timing."
|
||||
::mdef/labels ["name"]
|
||||
::mdef/type :histogram}
|
||||
|
||||
:rpc-query-timing
|
||||
{::mdef/name "penpot_rpc_query_timing"
|
||||
::mdef/help "RPC query method call timing."
|
||||
:rpc-management-timing
|
||||
{::mdef/name "penpot_rpc_management_timing"
|
||||
::mdef/help "RPC command method call timing for management."
|
||||
::mdef/labels ["name"]
|
||||
::mdef/type :histogram}
|
||||
|
||||
|
||||
@ -38,9 +38,6 @@
|
||||
org.im4java.core.ConvertCmd
|
||||
org.im4java.core.IMOperation))
|
||||
|
||||
(def default-max-file-size
|
||||
(* 1024 1024 10)) ; 10 MiB
|
||||
|
||||
(def schema:upload
|
||||
[:map {:title "Upload"}
|
||||
[:filename :string]
|
||||
@ -79,6 +76,20 @@
|
||||
max-size)))
|
||||
upload))
|
||||
|
||||
(defn validate-font-size!
|
||||
"Validates that the font file `upload` does not exceed the configured
|
||||
`:font-max-file-size` limit. Accepts the same map shape as
|
||||
`validate-media-size!` — requires a `:size` key in bytes."
|
||||
[upload]
|
||||
(let [max-size (cf/get :font-max-file-size)]
|
||||
(when (> (:size upload) max-size)
|
||||
(ex/raise :type :restriction
|
||||
:code :font-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
|
||||
(:size upload)
|
||||
max-size)))
|
||||
upload))
|
||||
|
||||
(defmulti process :cmd)
|
||||
(defmulti process-error class)
|
||||
|
||||
@ -296,9 +307,7 @@
|
||||
[{:keys [::http/client]} uri]
|
||||
(letfn [(parse-and-validate [{:keys [status headers] :as response}]
|
||||
(let [size (some-> (get headers "content-length") d/parse-integer)
|
||||
mtype (get headers "content-type")
|
||||
format (cm/mtype->format mtype)
|
||||
max-size (cf/get :media-max-file-size default-max-file-size)]
|
||||
mtype (get headers "content-type")]
|
||||
|
||||
(when-not (<= 200 status 299)
|
||||
(ex/raise :type :validation
|
||||
@ -310,19 +319,9 @@
|
||||
:code :unknown-size
|
||||
:hint "seems like the url points to resource with unknown size"))
|
||||
|
||||
(when (> size max-size)
|
||||
(ex/raise :type :validation
|
||||
:code :file-too-large
|
||||
:hint (str/ffmt "the file size % is greater than the maximum %"
|
||||
size
|
||||
default-max-file-size)))
|
||||
|
||||
(when (nil? format)
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "seems like the url points to an invalid media object"))
|
||||
|
||||
{:size size :mtype mtype :format format}))]
|
||||
(-> {:size size :mtype mtype}
|
||||
(validate-media-type!)
|
||||
(validate-media-size!))))]
|
||||
|
||||
(let [{:keys [body] :as response}
|
||||
(try
|
||||
|
||||
@ -481,7 +481,10 @@
|
||||
:fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}
|
||||
|
||||
{:name "0148-add-variant-name-team-font-variant"
|
||||
:fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}
|
||||
|
||||
{:name "0149-mod-file-library-rel-synced-at"
|
||||
:fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
CREATE TABLE file_library_sync (
|
||||
file_id uuid NOT NULL,
|
||||
library_file_id uuid NOT NULL,
|
||||
synced_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
|
||||
PRIMARY KEY (file_id, library_file_id)
|
||||
);
|
||||
|
||||
INSERT INTO file_library_sync (file_id, library_file_id, synced_at)
|
||||
SELECT file_id, library_file_id, synced_at
|
||||
FROM file_library_rel;
|
||||
|
||||
-- DEPRECATED: the `synced_at` column on `file_library_rel` is deprecated
|
||||
-- and will be removed in a future migration. It's kept temporarily
|
||||
-- for backward compatibility while data is migrated to `file_library_sync`.
|
||||
COMMENT ON COLUMN file_library_rel.synced_at IS
|
||||
'DEPRECATED: will be removed in a future migration; kept temporarily for backward compatibility';
|
||||
|
||||
|
||||
@ -1064,7 +1064,10 @@
|
||||
|
||||
(defn link-file-to-library
|
||||
[conn {:keys [file-id library-id] :as params}]
|
||||
(db/exec-one! conn [sql:link-file-to-library file-id library-id]))
|
||||
(db/exec-one! conn [sql:link-file-to-library file-id library-id])
|
||||
(bfc/upsert-file-library-sync! conn {:file-id file-id
|
||||
:library-file-id library-id
|
||||
:synced-at (ct/now)}))
|
||||
|
||||
(def ^:private
|
||||
schema:link-file-to-library
|
||||
@ -1118,11 +1121,9 @@
|
||||
|
||||
(defn update-sync
|
||||
[conn {:keys [file-id library-id] :as params}]
|
||||
(db/update! conn :file-library-rel
|
||||
{:synced-at (ct/now)}
|
||||
{:file-id file-id
|
||||
:library-file-id library-id}
|
||||
{::db/return-keys true}))
|
||||
(bfc/upsert-file-library-sync! conn {:file-id file-id
|
||||
:library-file-id library-id
|
||||
:synced-at (ct/now)}))
|
||||
|
||||
(def ^:private schema:update-file-library-sync-status
|
||||
[:map {:title "update-file-library-sync-status"}
|
||||
|
||||
@ -9,9 +9,11 @@
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cmedia]
|
||||
[app.common.logging :as l]
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.font :as types.font]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as-alias sql]
|
||||
@ -23,6 +25,7 @@
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.media :refer [assemble-chunks]]
|
||||
[app.rpc.commands.projects :as projects]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
@ -31,6 +34,8 @@
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.services :as sv]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io])
|
||||
(:import
|
||||
java.io.InputStream
|
||||
@ -91,33 +96,92 @@
|
||||
(declare create-font-variant)
|
||||
|
||||
(def ^:private schema:create-font-variant
|
||||
[:map {:title "create-font-variant"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:data [:map-of ::sm/text [:or ::sm/bytes
|
||||
[::sm/vec ::sm/bytes]]]]
|
||||
[:font-id ::sm/uuid]
|
||||
[:font-family ::sm/text]
|
||||
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
||||
[:font-style [::sm/one-of {:format "string"} valid-style]]
|
||||
[:variant-name {:optional true} [:maybe ::sm/text]]])
|
||||
[:and
|
||||
[:map {:title "create-font-variant"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:font-id ::sm/uuid]
|
||||
[:font-family types.font/schema:font-family]
|
||||
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
||||
[:font-style [::sm/one-of {:format "string"} valid-style]]
|
||||
[:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]]
|
||||
[:uploads {:optional true} [:map-of ::sm/text ::sm/uuid]]]
|
||||
[:fn {:error/message "one of :data or :uploads is required"}
|
||||
(fn [{:keys [data uploads]}]
|
||||
(or (seq data) (seq uploads)))]])
|
||||
|
||||
;; FIXME: IMPORTANT: refactor this, we should not hold a whole db
|
||||
;; connection around the font creation
|
||||
|
||||
(defn- prepare-font-data-from-uploads
|
||||
"Assembles each chunked-upload session in `uploads` (a `{mtype →
|
||||
session-id}` map) into a temp file, validates the media type and
|
||||
size of every entry, and returns a `{mtype → path}` data map."
|
||||
[cfg {:keys [uploads] :as params}]
|
||||
(let [data (reduce-kv
|
||||
(fn [acc mtype session-id]
|
||||
(let [assembled (assemble-chunks cfg session-id)]
|
||||
(-> {:mtype mtype :size (:size assembled)}
|
||||
(media/validate-media-type! cm/font-types)
|
||||
(media/validate-font-size!))
|
||||
(assoc acc mtype (:path assembled))))
|
||||
{}
|
||||
uploads)]
|
||||
|
||||
(-> params
|
||||
(assoc :data data)
|
||||
(dissoc :uploads))))
|
||||
|
||||
(defn- prepare-font-data-from-legacy
|
||||
"Validates the media type and size of every entry in the legacy
|
||||
`:data` map (a `{mtype → bytes | [bytes]}` map). Normalises every
|
||||
entry to a tempfile. Returns params with a normalised
|
||||
`{mtype → path}` data map."
|
||||
[{:keys [data] :as params}]
|
||||
(let [data (reduce-kv
|
||||
(fn [acc mtype content]
|
||||
(let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "")
|
||||
chunks (if (vector? content) content [content])
|
||||
streams (map io/input-stream chunks)
|
||||
streams (Collections/enumeration streams)]
|
||||
|
||||
;; Generate the tempfile from all chunks
|
||||
(with-open [^OutputStream output (io/output-stream tmp)
|
||||
^InputStream input (SequenceInputStream. streams)]
|
||||
(io/copy input output))
|
||||
|
||||
;; Validate
|
||||
(-> {:mtype mtype :size (fs/size tmp)}
|
||||
(media/validate-media-type! cm/font-types)
|
||||
(media/validate-font-size!))
|
||||
|
||||
(assoc acc mtype tmp)))
|
||||
{}
|
||||
data)]
|
||||
(assoc params :data data)))
|
||||
|
||||
(sv/defmethod ::create-font-variant
|
||||
"Upload a font variant. Font data may be provided either as a
|
||||
Transit-encoded `:data` map (keyed by mime-type) for small fonts, or
|
||||
as an `:uploads` map (keyed by mime-type, values are upload-session
|
||||
UUIDs from the chunked-upload API) for large fonts. Exactly one of
|
||||
the two must be present."
|
||||
{::doc/added "1.18"
|
||||
::doc/changes ["2.16" "Add :uploads param for chunked upload support"]
|
||||
::climit/id [[:process-font/by-profile ::rpc/profile-id]
|
||||
[:process-font/global]]
|
||||
::webhooks/event? true
|
||||
::sm/params schema:create-font-variant}
|
||||
[cfg {:keys [::rpc/profile-id team-id] :as params}]
|
||||
[cfg {:keys [::rpc/profile-id team-id uploads] :as params}]
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id})
|
||||
(create-font-variant cfg (assoc params :profile-id profile-id)))))
|
||||
(let [params (if (some? uploads)
|
||||
(prepare-font-data-from-uploads cfg params)
|
||||
(prepare-font-data-from-legacy params))]
|
||||
(create-font-variant cfg (assoc params :profile-id profile-id))))))
|
||||
|
||||
(defn create-font-variant
|
||||
[{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}]
|
||||
@ -132,23 +196,6 @@
|
||||
:hint "invalid font upload, unable to generate missing font assets"))
|
||||
data))
|
||||
|
||||
(process-chunks [chunks]
|
||||
(let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "")
|
||||
streams (map io/input-stream chunks)
|
||||
streams (Collections/enumeration streams)]
|
||||
(with-open [^OutputStream output (io/output-stream tmp)
|
||||
^InputStream input (SequenceInputStream. streams)]
|
||||
(io/copy input output))
|
||||
tmp))
|
||||
|
||||
(join-chunks [data]
|
||||
(reduce-kv (fn [data mtype content]
|
||||
(if (vector? content)
|
||||
(assoc data mtype (process-chunks content))
|
||||
data))
|
||||
data
|
||||
data))
|
||||
|
||||
(prepare-font [data mtype]
|
||||
(when-let [resource (get data mtype)]
|
||||
|
||||
@ -191,11 +238,38 @@
|
||||
:otf-file-id (:id otf)
|
||||
:ttf-file-id (:id ttf)}))]
|
||||
|
||||
(let [data (join-chunks data)
|
||||
data (generate-missing data)
|
||||
assets (persist-fonts-files! data)
|
||||
result (insert-font-variant! assets)]
|
||||
(vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys))))))
|
||||
(let [tpoint (ct/tpoint)
|
||||
mtypes (vec (keys data))
|
||||
total-size (reduce-kv (fn [acc _ content]
|
||||
(+ acc (if (bytes? content)
|
||||
(alength ^bytes content)
|
||||
(fs/size content))))
|
||||
0
|
||||
data)]
|
||||
|
||||
(l/dbg :hint "create-font-variant"
|
||||
:step "init"
|
||||
:font-family (:font-family params)
|
||||
:font-weight (:font-weight params)
|
||||
:font-style (:font-style params)
|
||||
:mtypes (str/join mtypes ",")
|
||||
:size total-size)
|
||||
|
||||
(let [data (generate-missing data)
|
||||
assets (persist-fonts-files! data)
|
||||
result (insert-font-variant! assets)
|
||||
elapsed (tpoint)]
|
||||
|
||||
(l/dbg :hint "create-font-variant"
|
||||
:step "end"
|
||||
:font-family (:font-family params)
|
||||
:font-weight (:font-weight params)
|
||||
:font-style (:font-style params)
|
||||
:mtypes (str/join mtypes ",")
|
||||
:size total-size
|
||||
:elapsed (ct/format-duration elapsed))
|
||||
|
||||
(vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys)))))))
|
||||
|
||||
;; --- UPDATE FONT FAMILY
|
||||
|
||||
@ -204,7 +278,7 @@
|
||||
[:map {:title "update-font"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]])
|
||||
[:name types.font/schema:font-family]])
|
||||
|
||||
(sv/defmethod ::update-font
|
||||
{::doc/added "1.18"
|
||||
@ -326,7 +400,7 @@
|
||||
[v mtype]
|
||||
(str (:font-family v) "-" (:font-weight v)
|
||||
(when-not (= "normal" (:font-style v)) (str "-" (:font-style v)))
|
||||
(cmedia/mtype->extension mtype)))
|
||||
(cm/mtype->extension mtype)))
|
||||
|
||||
(def ^:private schema:download-font
|
||||
[:map {:title "download-font"}
|
||||
|
||||
@ -72,10 +72,14 @@
|
||||
(doseq [params (sequence (comp
|
||||
(map #(bfc/remap-id % :file-id))
|
||||
(map #(bfc/remap-id % :library-file-id))
|
||||
(map #(assoc % :synced-at timestamp))
|
||||
(map #(assoc % :created-at timestamp)))
|
||||
flibs)]
|
||||
(db/insert! conn :file-library-rel params ::db/return-keys false))
|
||||
(let [rel-params (dissoc params :synced-at)]
|
||||
(db/insert! conn :file-library-rel rel-params ::db/return-keys false)
|
||||
(bfc/upsert-file-library-sync! conn {:file-id (:file-id rel-params)
|
||||
:library-file-id (:library-file-id rel-params)
|
||||
:synced-at (or (:synced-at params)
|
||||
timestamp)})))
|
||||
|
||||
(doseq [params (sequence (comp
|
||||
(map #(bfc/remap-id % :id))
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
@ -58,8 +59,8 @@
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
;; We get the minimal file for proper checking if
|
||||
;; file is not already deleted
|
||||
(let [_ (files/get-minimal-file conn file-id)
|
||||
mobj (create-file-media-object cfg params)]
|
||||
(let [_ (files/get-minimal-file conn file-id)
|
||||
mobj (create-file-media-object cfg params)]
|
||||
|
||||
(db/update! conn :file
|
||||
{:modified-at (ct/now)
|
||||
@ -149,20 +150,49 @@
|
||||
|
||||
(defn- create-file-media-object
|
||||
[{:keys [::sto/storage ::db/conn] :as cfg}
|
||||
{:keys [id file-id is-local name content]}]
|
||||
(let [result (process-image content)
|
||||
image (sto/put-object! storage (::image result))
|
||||
thumb (when-let [params (::thumb result)]
|
||||
(sto/put-object! storage params))]
|
||||
{:keys [id file-id is-local name content from-url? from-chunks?]}]
|
||||
|
||||
(db/exec-one! conn [sql:create-file-media-object
|
||||
(or id (uuid/next))
|
||||
file-id is-local name
|
||||
(:id image)
|
||||
(:id thumb)
|
||||
(:width result)
|
||||
(:height result)
|
||||
(:mtype result)])))
|
||||
(let [tpoint (ct/tpoint)
|
||||
id (or id (uuid/next))
|
||||
origin (cond
|
||||
from-url?
|
||||
"url"
|
||||
from-chunks?
|
||||
"chunks"
|
||||
:else
|
||||
"direct")]
|
||||
|
||||
(l/dbg :hint "create file-media-object"
|
||||
:step "init"
|
||||
:id (str id)
|
||||
:mtype (:mtype content)
|
||||
:size (:size content)
|
||||
:path (str (:path content))
|
||||
:origin origin)
|
||||
|
||||
(let [result (process-image content)
|
||||
image (sto/put-object! storage (::image result))
|
||||
thumb (when-let [params (::thumb result)]
|
||||
(sto/put-object! storage params))
|
||||
elapsed (tpoint)]
|
||||
|
||||
(l/dbg :hint "create file-media-object"
|
||||
:step "end"
|
||||
:id (str id)
|
||||
:mtype (:mtype content)
|
||||
:size (:size content)
|
||||
:path (str (:path content))
|
||||
:origin origin
|
||||
:elapsed (ct/format-duration elapsed))
|
||||
|
||||
(db/exec-one! conn [sql:create-file-media-object
|
||||
id
|
||||
file-id is-local name
|
||||
(:id image)
|
||||
(:id thumb)
|
||||
(:width result)
|
||||
(:height result)
|
||||
(:mtype result)]))))
|
||||
|
||||
;; --- Create File Media Object (from URL)
|
||||
|
||||
@ -198,6 +228,7 @@
|
||||
[cfg {:keys [url name] :as params}]
|
||||
(let [content (media/download-image cfg url)
|
||||
params (-> params
|
||||
(assoc :from-url? true)
|
||||
(assoc :content content)
|
||||
(assoc :name (d/nilv name "unknown")))]
|
||||
|
||||
@ -305,7 +336,14 @@
|
||||
:hint "chunk index is out of range for this session"
|
||||
:session-id session-id
|
||||
:total-chunks (:total-chunks session)
|
||||
:index index)))
|
||||
:index index))
|
||||
|
||||
|
||||
(l/trc :hint "upload-chunk"
|
||||
:session-id session-id
|
||||
:chunk (str index "/" (:total-chunks session))
|
||||
:size (:size content)
|
||||
:path (:path content)))
|
||||
|
||||
(let [storage (sto/resolve cfg)
|
||||
data (sto/content (:path content))]
|
||||
@ -399,14 +437,15 @@
|
||||
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [{:keys [path size]} (assemble-chunks cfg session-id)
|
||||
content {:filename "upload"
|
||||
:size size
|
||||
:path path
|
||||
:mtype mtype}
|
||||
_ (media/validate-media-type! content)
|
||||
(let [content (assemble-chunks cfg session-id)
|
||||
content (-> content
|
||||
(assoc :filename (str "upload:" name))
|
||||
(assoc :mtype mtype)
|
||||
(media/validate-media-type!)
|
||||
(media/validate-media-size!))
|
||||
mobj (create-file-media-object cfg (assoc params
|
||||
:id (or id (uuid/next))
|
||||
:id id
|
||||
:from-chunks? true
|
||||
:content content))]
|
||||
|
||||
(db/update! conn :file
|
||||
|
||||
@ -265,6 +265,7 @@
|
||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||
;; Validate incoming mime type
|
||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||
(media/validate-media-size! file)
|
||||
(update-profile-photo cfg (assoc params :profile-id profile-id)))
|
||||
|
||||
(defn update-profile-photo
|
||||
|
||||
@ -918,6 +918,7 @@
|
||||
;; Validate incoming mime type
|
||||
|
||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||
(media/validate-media-size! file)
|
||||
(update-team-photo cfg (assoc params :profile-id profile-id)))
|
||||
|
||||
(defn update-team-photo
|
||||
|
||||
@ -918,6 +918,72 @@
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (th/ex-of-type? error :not-found)))))
|
||||
|
||||
(t/deftest link-file-to-library-creates-sync-row
|
||||
(let [profile (th/create-profile* 1)
|
||||
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
|
||||
:profile-id (:id profile)
|
||||
:is-shared true})
|
||||
file2 (th/create-file* 2 {:project-id (:default-project-id profile)
|
||||
:profile-id (:id profile)})
|
||||
data {::th/type :link-file-to-library
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file2)
|
||||
:library-id (:id file1)}
|
||||
out (th/command! data)
|
||||
rel (th/db-get :file-library-rel {:file-id (:id file2)
|
||||
:library-file-id (:id file1)})
|
||||
sync (th/db-get :file-library-sync {:file-id (:id file2)
|
||||
:library-file-id (:id file1)})]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (some? rel))
|
||||
(t/is (some? sync))
|
||||
(t/is (some? (:synced-at sync)))))
|
||||
|
||||
(t/deftest update-file-library-sync-status-updates-sync-row
|
||||
(let [profile (th/create-profile* 1)
|
||||
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
|
||||
:profile-id (:id profile)
|
||||
:is-shared true})
|
||||
file2 (th/create-file* 2 {:project-id (:default-project-id profile)
|
||||
:profile-id (:id profile)})
|
||||
_ (th/link-file-to-library* {:file-id (:id file2)
|
||||
:library-id (:id file1)})
|
||||
before (th/db-get :file-library-sync {:file-id (:id file2)
|
||||
:library-file-id (:id file1)})
|
||||
_ (th/sleep 10)
|
||||
data {::th/type :update-file-library-sync-status
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file2)
|
||||
:library-id (:id file1)}
|
||||
out (th/command! data)
|
||||
after (th/db-get :file-library-sync {:file-id (:id file2)
|
||||
:library-file-id (:id file1)})]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (some? before))
|
||||
(t/is (some? after))
|
||||
(t/is (pos? (compare (:synced-at after) (:synced-at before))))))
|
||||
|
||||
(t/deftest update-file-library-sync-status-without-link-creates-sync-row
|
||||
(let [profile (th/create-profile* 1)
|
||||
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
|
||||
:profile-id (:id profile)
|
||||
:is-shared true})
|
||||
file2 (th/create-file* 2 {:project-id (:default-project-id profile)
|
||||
:profile-id (:id profile)})
|
||||
data {::th/type :update-file-library-sync-status
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file2)
|
||||
:library-id (:id file1)}
|
||||
out (th/command! data)
|
||||
sync (th/db-get :file-library-sync {:file-id (:id file2)
|
||||
:library-file-id (:id file1)})]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (some? sync))
|
||||
(t/is (some? (:synced-at sync)))))
|
||||
|
||||
|
||||
(t/deftest deletion
|
||||
(let [profile1 (th/create-profile* 1)
|
||||
|
||||
@ -17,7 +17,9 @@
|
||||
[clojure.test :as t]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io]
|
||||
[mockery.core :refer [with-mocks]]))
|
||||
[mockery.core :refer [with-mocks]])
|
||||
(:import
|
||||
java.io.RandomAccessFile))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
@ -327,3 +329,596 @@
|
||||
(let [error (:error out)
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Helpers for chunked-upload font tests
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(defn- split-bytes-into-chunks
|
||||
"Splits `data` (byte array) into chunks of at most `chunk-size` bytes.
|
||||
Returns a vector of byte arrays."
|
||||
[^bytes data chunk-size]
|
||||
(let [length (alength data)]
|
||||
(loop [offset 0 chunks []]
|
||||
(if (>= offset length)
|
||||
chunks
|
||||
(let [remaining (- length offset)
|
||||
size (min chunk-size remaining)
|
||||
buf (byte-array size)]
|
||||
(System/arraycopy data offset buf 0 size)
|
||||
(recur (+ offset size) (conj chunks buf)))))))
|
||||
|
||||
(defn- make-chunk-mfile
|
||||
"Writes `data` (byte array) to a tempfile and returns a map
|
||||
compatible with the upload-chunk :content parameter."
|
||||
[^bytes data mtype]
|
||||
(let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-")]
|
||||
(io/write* tmp data)
|
||||
{:filename "chunk"
|
||||
:path tmp
|
||||
:mtype mtype
|
||||
:size (alength data)}))
|
||||
|
||||
(defn- create-upload-session!
|
||||
"Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID."
|
||||
[prof total-chunks]
|
||||
(let [out (th/command! {::th/type :create-upload-session
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks total-chunks})]
|
||||
(t/is (nil? (:error out)))
|
||||
(:session-id (:result out))))
|
||||
|
||||
(defn- upload-font-chunked!
|
||||
"Splits `font-bytes` into chunks of `chunk-size` bytes, creates an upload
|
||||
session, uploads all chunks, and returns the session-id UUID."
|
||||
[prof ^bytes font-bytes mtype chunk-size]
|
||||
(let [chunks (split-bytes-into-chunks font-bytes chunk-size)
|
||||
session-id (create-upload-session! prof (count chunks))]
|
||||
(doseq [[idx chunk-data] (map-indexed vector chunks)]
|
||||
(let [mfile (make-chunk-mfile chunk-data mtype)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index idx
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))))
|
||||
session-id))
|
||||
|
||||
(defn- assert-font-variant-result
|
||||
"Checks that a successful create-font-variant result has valid UUIDs and
|
||||
the expected scalar fields matching `params`."
|
||||
[params result]
|
||||
(t/is (uuid? (:id result)))
|
||||
(t/is (uuid? (:ttf-file-id result)))
|
||||
(t/is (uuid? (:otf-file-id result)))
|
||||
(t/is (uuid? (:woff1-file-id result)))
|
||||
(t/are [k] (= (get params k) (get result k))
|
||||
:team-id
|
||||
:font-id
|
||||
:font-family
|
||||
:font-weight
|
||||
:font-style))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Path 1 – Normal (direct :data bytes)
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(t/deftest create-font-variant-normal-ttf
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 10)
|
||||
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "chunked-test"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"font/ttf" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (= 1 (:call-count @mock)))
|
||||
(t/is (nil? (:error out)))
|
||||
(assert-font-variant-result params (:result out)))))
|
||||
|
||||
(t/deftest create-font-variant-normal-otf
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 11)
|
||||
data (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "chunked-test"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"font/otf" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (= 1 (:call-count @mock)))
|
||||
(t/is (nil? (:error out)))
|
||||
(assert-font-variant-result params (:result out)))))
|
||||
|
||||
(t/deftest create-font-variant-normal-woff
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 12)
|
||||
data (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "chunked-test"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"font/woff" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (= 1 (:call-count @mock)))
|
||||
(t/is (nil? (:error out)))
|
||||
(assert-font-variant-result params (:result out)))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Path 2 – Legacy chunking (:data with vector of byte-arrays per mtype)
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(t/deftest create-font-variant-legacy-chunked-ttf
|
||||
"Upload a TTF via the legacy :data path where each mtype value is a
|
||||
vector of byte-array chunks (4 MiB each) instead of a single byte-array."
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 20)
|
||||
full-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
|
||||
;; Simulate 4 MiB legacy chunks – font is small so a single chunk suffices
|
||||
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "legacy-chunked"
|
||||
:font-weight 700
|
||||
:font-style "italic"
|
||||
:data {"font/ttf" (vec chunks)}}
|
||||
out (th/command! params)]
|
||||
(t/is (= 1 (:call-count @mock)))
|
||||
(t/is (nil? (:error out)))
|
||||
(assert-font-variant-result params (:result out)))))
|
||||
|
||||
(t/deftest create-font-variant-legacy-chunked-woff
|
||||
"Upload a WOFF via the legacy :data path with multiple sub-4 KiB chunks
|
||||
to exercise the SequenceInputStream concatenation path."
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 21)
|
||||
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
|
||||
;; Split into small chunks to exercise the SequenceInputStream path
|
||||
chunks (split-bytes-into-chunks full-bytes 512)
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "legacy-chunked-woff"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"font/woff" (vec chunks)}}
|
||||
out (th/command! params)]
|
||||
(t/is (= 1 (:call-count @mock)))
|
||||
(t/is (nil? (:error out)))
|
||||
(assert-font-variant-result params (:result out)))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Path 3 – New standardized chunked upload (:uploads map)
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(t/deftest create-font-variant-chunked-upload-ttf
|
||||
"Upload a TTF via the new :uploads path (chunked-upload API)."
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 30)
|
||||
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
|
||||
session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "new-chunked"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"font/ttf" session-id}}
|
||||
out (th/command! params)]
|
||||
;; quotes/check! is called at least once (for the font-variant quota) plus
|
||||
;; once during session creation — assert it fired at least once.
|
||||
(t/is (>= (:call-count @mock) 1))
|
||||
(t/is (nil? (:error out)))
|
||||
(assert-font-variant-result params (:result out)))))
|
||||
|
||||
(t/deftest create-font-variant-chunked-upload-otf
|
||||
"Upload an OTF via the new :uploads path."
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 31)
|
||||
font-bytes (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*))
|
||||
session-id (upload-font-chunked! prof font-bytes "font/otf" (* 4 1024 1024))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "new-chunked-otf"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"font/otf" session-id}}
|
||||
out (th/command! params)]
|
||||
(t/is (>= (:call-count @mock) 1))
|
||||
(t/is (nil? (:error out)))
|
||||
(assert-font-variant-result params (:result out)))))
|
||||
|
||||
(t/deftest create-font-variant-chunked-upload-woff
|
||||
"Upload a WOFF via the new :uploads path."
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 32)
|
||||
font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
|
||||
session-id (upload-font-chunked! prof font-bytes "font/woff" (* 4 1024 1024))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "new-chunked-woff"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"font/woff" session-id}}
|
||||
out (th/command! params)]
|
||||
(t/is (>= (:call-count @mock) 1))
|
||||
(t/is (nil? (:error out)))
|
||||
(assert-font-variant-result params (:result out)))))
|
||||
|
||||
(t/deftest create-font-variant-chunked-upload-multi-chunk
|
||||
"Upload a WOFF split into many small chunks to exercise multi-chunk assembly."
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 33)
|
||||
font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
|
||||
;; Use a chunk-size smaller than 4 MiB to force multiple chunks while
|
||||
;; staying within the 20-chunk-per-session quota limit (29836 / 2000 = ~15 chunks).
|
||||
session-id (upload-font-chunked! prof font-bytes "font/woff" 2000)
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "multi-chunk-woff"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"font/woff" session-id}}
|
||||
out (th/command! params)]
|
||||
(t/is (>= (:call-count @mock) 1))
|
||||
(t/is (nil? (:error out)))
|
||||
(assert-font-variant-result params (:result out)))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Error cases
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(t/deftest create-font-variant-missing-data-and-uploads
|
||||
"Neither :data nor :uploads is present — schema validation must reject it."
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 40)
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "bad"
|
||||
:font-weight 400
|
||||
:font-style "normal"}
|
||||
out (th/command! params)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))))
|
||||
|
||||
(t/deftest create-font-variant-chunked-upload-missing-chunks
|
||||
"When only some chunks are uploaded the assembly step must fail."
|
||||
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 41)
|
||||
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
|
||||
;; 5000-byte chunks → 68640/5000 = 14 chunks; declare 15 but only upload 13
|
||||
chunks (split-bytes-into-chunks font-bytes 5000)
|
||||
;; Declare one extra chunk so assembly will fail (not all chunks present)
|
||||
session-id (create-upload-session! prof (inc (count chunks)))]
|
||||
|
||||
;; Upload all real chunks except the last one (omit it so the session is incomplete)
|
||||
(doseq [[idx chunk-data] (map-indexed vector (butlast chunks))]
|
||||
(let [mfile (make-chunk-mfile chunk-data "font/ttf")
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index idx
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(let [out (th/command! {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "missing-chunks"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"font/ttf" session-id}})]
|
||||
(t/is (some? (:error out)))))))
|
||||
|
||||
(t/deftest create-font-variant-chunked-upload-invalid-session
|
||||
"Passing a non-existent session-id must fail at assembly time."
|
||||
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 42)
|
||||
out (th/command! {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "bad-session"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"font/ttf" (uuid/next)}})]
|
||||
(t/is (some? (:error out))))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Font size validation tests
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(t/deftest create-font-variant-size-exceeded-normal
|
||||
"Direct :data upload exceeding font-max-file-size must be rejected."
|
||||
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 50)
|
||||
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "size-exceeded"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"font/ttf" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :restriction (-> out :error ex-data :type)))
|
||||
(t/is (= :font-max-file-size-reached (-> out :error ex-data :code)))))))
|
||||
|
||||
(t/deftest create-font-variant-size-exceeded-legacy-chunked
|
||||
"Legacy :data chunk-vector upload exceeding font-max-file-size must be rejected."
|
||||
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 51)
|
||||
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
|
||||
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "size-exceeded-legacy"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"font/woff" (vec chunks)}}
|
||||
out (th/command! params)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :restriction (-> out :error ex-data :type)))
|
||||
(t/is (= :font-max-file-size-reached (-> out :error ex-data :code)))))))
|
||||
|
||||
(t/deftest create-font-variant-size-exceeded-chunked-upload
|
||||
"New :uploads path exceeding font-max-file-size must be rejected after assembly."
|
||||
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 52)
|
||||
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
|
||||
session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))]
|
||||
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)]
|
||||
(let [out (th/command! {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "size-exceeded-chunked"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"font/ttf" session-id}})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :restriction (-> out :error ex-data :type)))
|
||||
(t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))))
|
||||
|
||||
(t/deftest create-font-variant-size-within-limit
|
||||
"Upload exactly at the limit must succeed."
|
||||
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 53)
|
||||
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
|
||||
font-size (alength ^bytes font-bytes)]
|
||||
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size font-size)]
|
||||
(let [params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "size-at-limit"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"font/ttf" font-bytes}}
|
||||
out (th/command! params)]
|
||||
(t/is (nil? (:error out)))
|
||||
(assert-font-variant-result params (:result out)))))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Font media-type validation tests
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(t/deftest create-font-variant-invalid-type-normal
|
||||
"Direct :data upload with a disallowed mtype must be rejected."
|
||||
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 60)
|
||||
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "invalid-type"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"application/octet-stream" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
|
||||
|
||||
(t/deftest create-font-variant-invalid-type-legacy-chunked
|
||||
"Legacy :data chunk-vector upload with a disallowed mtype must be rejected."
|
||||
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 61)
|
||||
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
|
||||
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "invalid-type-legacy"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"image/png" (vec chunks)}}
|
||||
out (th/command! params)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
|
||||
|
||||
(t/deftest create-font-variant-invalid-type-chunked-upload
|
||||
"New :uploads path with a disallowed mtype must be rejected after assembly."
|
||||
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 62)
|
||||
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
|
||||
;; Upload the bytes under a valid session but lie about the mtype
|
||||
;; when calling create-font-variant.
|
||||
session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))
|
||||
out (th/command! {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "invalid-type-chunked"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"image/jpeg" session-id}})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
|
||||
|
||||
;; --- Font family name validation / XSS prevention
|
||||
|
||||
(t/deftest create-font-variant-with-invalid-family
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 100)
|
||||
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))]
|
||||
|
||||
;; name with < should fail
|
||||
(let [params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id :font-id font-id
|
||||
:font-family "evil<script>alert(1)</script>"
|
||||
:font-weight 400 :font-style "normal"
|
||||
:data {"font/ttf" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; name with ' should fail
|
||||
(let [params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id :font-id font-id
|
||||
:font-family "evil'name"
|
||||
:font-weight 400 :font-style "normal"
|
||||
:data {"font/ttf" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation)))
|
||||
|
||||
;; name with } should fail
|
||||
(let [params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id :font-id font-id
|
||||
:font-family "evil}name"
|
||||
:font-weight 400 :font-style "normal"
|
||||
:data {"font/ttf" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation)))
|
||||
|
||||
;; valid name should succeed
|
||||
(let [params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id :font-id (uuid/custom 10 101)
|
||||
:font-family "Source Sans Pro"
|
||||
:font-weight 400 :font-style "normal"
|
||||
:data {"font/ttf" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (th/success? out))))))
|
||||
|
||||
(t/deftest update-font-with-invalid-family
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 102)
|
||||
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))]
|
||||
|
||||
;; Create a valid font first
|
||||
(let [params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id :font-id font-id
|
||||
:font-family "ValidFont"
|
||||
:font-weight 400 :font-style "normal"
|
||||
:data {"font/ttf" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (th/success? out)))
|
||||
|
||||
;; rename with < should fail
|
||||
(let [params {::th/type :update-font
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id :id font-id
|
||||
:name "evil<script>x</script>"}
|
||||
out (th/command! params)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; rename with ' should fail
|
||||
(let [params {::th/type :update-font
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id :id font-id
|
||||
:name "evil'name"}
|
||||
out (th/command! params)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation)))
|
||||
|
||||
;; valid rename should succeed
|
||||
(let [params {::th/type :update-font
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id :id font-id
|
||||
:name "Valid Font Name"}
|
||||
out (th/command! params)]
|
||||
(t/is (th/success? out))))))
|
||||
|
||||
@ -1,23 +1,23 @@
|
||||
{:deps
|
||||
{org.clojure/clojure {:mvn/version "1.12.4"}
|
||||
org.clojure/data.json {:mvn/version "2.5.1"}
|
||||
{org.clojure/clojure {:mvn/version "1.12.5"}
|
||||
org.clojure/data.json {:mvn/version "2.5.2"}
|
||||
org.clojure/tools.cli {:mvn/version "1.1.230"}
|
||||
org.clojure/test.check {:mvn/version "1.1.1"}
|
||||
org.clojure/data.fressian {:mvn/version "1.1.0"}
|
||||
org.clojure/data.fressian {:mvn/version "1.1.1"}
|
||||
org.clojure/clojurescript {:mvn/version "1.12.42"}
|
||||
|
||||
org.apache.commons/commons-pool2 {:mvn/version "2.12.1"}
|
||||
|
||||
;; Logging
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-web {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.3"}
|
||||
org.slf4j/slf4j-api {:mvn/version "2.0.17"}
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.26.0"}
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.26.0"}
|
||||
org.apache.logging.log4j/log4j-web {:mvn/version "2.26.0"}
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.0"}
|
||||
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.0"}
|
||||
org.slf4j/slf4j-api {:mvn/version "2.0.18"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"}
|
||||
|
||||
selmer/selmer {:mvn/version "1.12.70"}
|
||||
selmer/selmer {:mvn/version "1.13.1"}
|
||||
criterium/criterium {:mvn/version "0.4.6"}
|
||||
|
||||
metosin/jsonista {:mvn/version "0.3.13"}
|
||||
@ -55,12 +55,12 @@
|
||||
:aliases
|
||||
{:dev
|
||||
{:extra-deps
|
||||
{org.clojure/tools.namespace {:mvn/version "RELEASE"}
|
||||
{org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||
thheller/shadow-cljs {:mvn/version "3.2.0"}
|
||||
com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"}
|
||||
com.bhauman/rebel-readline {:mvn/version "RELEASE"}
|
||||
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
|
||||
com.bhauman/rebel-readline {:mvn/version "0.1.5"}
|
||||
criterium/criterium {:mvn/version "0.4.6"}
|
||||
mockery/mockery {:mvn/version "RELEASE"}}
|
||||
mockery/mockery {:mvn/version "0.1.4"}}
|
||||
:extra-paths ["test" "dev"]}
|
||||
|
||||
:build
|
||||
|
||||
@ -13,8 +13,7 @@
|
||||
#{"font/ttf"
|
||||
"font/woff"
|
||||
"font/woff2"
|
||||
"font/otf"
|
||||
"font/opentype"})
|
||||
"font/otf"})
|
||||
|
||||
(def image-types
|
||||
#{"image/jpeg"
|
||||
|
||||
21
common/src/app/common/types/font.cljc
Normal file
21
common/src/app/common/types/font.cljc
Normal file
@ -0,0 +1,21 @@
|
||||
;; 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 app.common.types.font
|
||||
(:require
|
||||
[app.common.schema :as sm]))
|
||||
|
||||
(def ^:private font-family-re
|
||||
;; \p{L} (Unicode letter) works in Java regex natively, but in JavaScript it
|
||||
;; requires the "u" flag which ClojureScript regex literals don't support.
|
||||
#?(:clj #"[\p{L}\d _.-]+"
|
||||
:cljs (js/RegExp. "[\\p{L}\\d _.-]+" "u")))
|
||||
|
||||
(def schema:font-family
|
||||
[:and
|
||||
[::sm/text {:max 250}]
|
||||
[:fn {:error/code "errors.font-family-invalid-chars"}
|
||||
(fn [s] (boolean (re-matches font-family-re s)))]])
|
||||
41
common/test/common_tests/types/font_test.cljc
Normal file
41
common/test/common_tests/types/font_test.cljc
Normal file
@ -0,0 +1,41 @@
|
||||
;; 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.types.font-test
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.font :as ctf]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/deftest font-family-schema-valid
|
||||
(t/is (sm/validate ctf/schema:font-family "Source Sans Pro"))
|
||||
(t/is (sm/validate ctf/schema:font-family "Roboto"))
|
||||
(t/is (sm/validate ctf/schema:font-family "Open Sans 300"))
|
||||
(t/is (sm/validate ctf/schema:font-family "Font-Name_v2"))
|
||||
(t/is (sm/validate ctf/schema:font-family "Noto Sans CJK SC"))
|
||||
(t/is (sm/validate ctf/schema:font-family "A"))
|
||||
;; hyphens, underscores and dots are allowed
|
||||
(t/is (sm/validate ctf/schema:font-family "Fira-Code"))
|
||||
(t/is (sm/validate ctf/schema:font-family "font_name"))
|
||||
(t/is (sm/validate ctf/schema:font-family "Soucre Sans Pro 3.0"))
|
||||
;; Unicode letters are allowed
|
||||
(t/is (sm/validate ctf/schema:font-family "思源黑体"))
|
||||
(t/is (sm/validate ctf/schema:font-family "العربية")))
|
||||
|
||||
(t/deftest font-family-schema-invalid
|
||||
;; HTML injection characters
|
||||
(t/is (not (sm/validate ctf/schema:font-family "evil<script>")))
|
||||
(t/is (not (sm/validate ctf/schema:font-family "<test>name")))
|
||||
;; CSS injection characters
|
||||
(t/is (not (sm/validate ctf/schema:font-family "evil'name")))
|
||||
(t/is (not (sm/validate ctf/schema:font-family "evil\"name")))
|
||||
(t/is (not (sm/validate ctf/schema:font-family "evil}name")))
|
||||
(t/is (not (sm/validate ctf/schema:font-family "evil;name")))
|
||||
(t/is (not (sm/validate ctf/schema:font-family "evil\\name")))
|
||||
;; empty string
|
||||
(t/is (not (sm/validate ctf/schema:font-family "")))
|
||||
;; too long
|
||||
(t/is (not (sm/validate ctf/schema:font-family (apply str (repeat 251 "a"))))))
|
||||
@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \
|
||||
LC_ALL='C.UTF-8' \
|
||||
JAVA_HOME="/opt/jdk" \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
NODE_VERSION=v22.22.0 \
|
||||
NODE_VERSION=v24.15.0 \
|
||||
TZ=Etc/UTC
|
||||
|
||||
RUN set -ex; \
|
||||
@ -46,12 +46,12 @@ RUN set -eux; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
aarch64|arm64) \
|
||||
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
|
||||
ESUM='cc1b459dc442d7422b46a3b5fe52acaea54879fa7913e29a05650cef54687f5f'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_aarch64.tar.gz'; \
|
||||
;; \
|
||||
amd64|x86_64) \
|
||||
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
|
||||
ESUM='7d6663ea8d4298df65de065e32f9f449745ff607d30ba5d13777cb92e9d4613d'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_x64.tar.gz'; \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported arch: ${ARCH}"; \
|
||||
@ -68,7 +68,7 @@ RUN set -eux; \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--strip-debug \
|
||||
--add-modules java.base,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \
|
||||
--add-modules java.base,jdk.net,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \
|
||||
--output /opt/jre;
|
||||
|
||||
FROM ubuntu:24.04 AS image
|
||||
|
||||
@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
|
||||
|
||||
ENV LANG=en_US.UTF-8 \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
NODE_VERSION=v22.22.0 \
|
||||
NODE_VERSION=v24.15.0 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
PATH=/opt/node/bin:/opt/imagick/bin:$PATH \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM nginxinc/nginx-unprivileged:1.29.1
|
||||
FROM nginxinc/nginx-unprivileged:1.30.0
|
||||
LABEL maintainer="Penpot <docker@penpot.app>"
|
||||
|
||||
USER root
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM nginxinc/nginx-unprivileged:1.29.1
|
||||
FROM nginxinc/nginx-unprivileged:1.30.0
|
||||
LABEL maintainer="Penpot <docker@penpot.app>"
|
||||
|
||||
USER root
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
# WARNING: if you're exposing Penpot to the internet, you should remove the flags
|
||||
# 'disable-secure-session-cookies' and 'disable-email-verification'
|
||||
x-flags: &penpot-flags
|
||||
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies
|
||||
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp
|
||||
|
||||
x-uri: &penpot-public-uri
|
||||
PENPOT_PUBLIC_URI: http://localhost:9001
|
||||
@ -78,7 +78,7 @@ services:
|
||||
# - "443:443"
|
||||
|
||||
penpot-frontend:
|
||||
image: "penpotapp/frontend:${PENPOT_VERSION:-latest}"
|
||||
image: "penpotapp/frontend:${PENPOT_VERSION:-2.15}"
|
||||
restart: always
|
||||
ports:
|
||||
- 9001:8080
|
||||
@ -108,7 +108,7 @@ services:
|
||||
<< : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
|
||||
|
||||
penpot-backend:
|
||||
image: "penpotapp/backend:${PENPOT_VERSION:-latest}"
|
||||
image: "penpotapp/backend:${PENPOT_VERSION:-2.15}"
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
@ -176,8 +176,14 @@ services:
|
||||
PENPOT_SMTP_TLS: false
|
||||
PENPOT_SMTP_SSL: false
|
||||
|
||||
penpot-mcp:
|
||||
image: "penpotapp/mcp:${PENPOT_VERSION:-2.15}"
|
||||
restart: always
|
||||
networks:
|
||||
- penpot
|
||||
|
||||
penpot-exporter:
|
||||
image: "penpotapp/exporter:${PENPOT_VERSION:-latest}"
|
||||
image: "penpotapp/exporter:${PENPOT_VERSION:-2.15}"
|
||||
restart: always
|
||||
|
||||
depends_on:
|
||||
|
||||
@ -43,9 +43,10 @@ update_oidc_name /var/www/app/js/config.js
|
||||
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060}
|
||||
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061}
|
||||
export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000}
|
||||
export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp}
|
||||
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_HTTP_SERVER_MAX_BODY_SIZE" \
|
||||
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_MCP_URI_WS,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \
|
||||
< /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)"
|
||||
|
||||
@ -142,17 +142,17 @@ http {
|
||||
location /mcp/ws {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_pass $PENPOT_MCP_URI:4402;
|
||||
proxy_pass $PENPOT_MCP_URI_WS;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp/stream {
|
||||
proxy_pass $PENPOT_MCP_URI:4401/mcp;
|
||||
proxy_pass $PENPOT_MCP_URI/mcp;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp/sse {
|
||||
proxy_pass $PENPOT_MCP_URI:4401/sse;
|
||||
proxy_pass $PENPOT_MCP_URI/sse;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
|
||||
@ -425,6 +425,12 @@ In a high-availability (HA) scenario, managing the state outside of replicas is
|
||||
- Valkey: Penpot only needs one Valkey instance to function correctly. Due to the nature of the data it manages, replication isn't even essential.
|
||||
- User media storage: This should not be configured with local storage but rather with centralized storage, such as Kubernetes PVC or S3.
|
||||
|
||||
|
||||
__Since version 2.15.0__
|
||||
|
||||
Starting with version 2.15, we have introduced the MCP server. Due to architectural constraints, using the MCP server requires running only a single instance of Penpot.
|
||||
If the MCP server is not installed, then Penpot can scale normally and multiple application instances may be deployed without restrictions.
|
||||
|
||||
## Backend
|
||||
|
||||
This section enumerates the backend only configuration variables.
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
:deps
|
||||
{penpot/common {:local/root "../common"}
|
||||
org.clojure/clojure {:mvn/version "1.12.2"}
|
||||
binaryage/devtools {:mvn/version "RELEASE"}
|
||||
binaryage/devtools {:mvn/version "1.0.7"}
|
||||
metosin/reitit-core {:mvn/version "0.9.1"}
|
||||
}
|
||||
:aliases
|
||||
{:outdated
|
||||
{:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"}
|
||||
{:extra-deps {com.github.liquidz/antq {:mvn/version"2.11.1276"}
|
||||
;; org.slf4j/slf4j-nop {:mvn/version "RELEASE"}
|
||||
}
|
||||
:main-opts ["-m" "antq.core"]}
|
||||
|
||||
@ -11,22 +11,22 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"archiver": "^8.0.0",
|
||||
"cookies": "^0.9.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"generic-pool": "^3.9.0",
|
||||
"inflation": "^2.1.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"playwright": "^1.57.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"playwright": "^1.60.0",
|
||||
"raw-body": "^3.0.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"svgo": "penpot/svgo#v3.1",
|
||||
"undici": "^7.16.0",
|
||||
"undici": "^8.2.0",
|
||||
"xml-js": "^1.6.11",
|
||||
"xregexp": "^5.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ws": "^8.18.3"
|
||||
"ws": "^8.20.1"
|
||||
},
|
||||
"scripts": {
|
||||
"clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target",
|
||||
|
||||
532
exporter/pnpm-lock.yaml
generated
532
exporter/pnpm-lock.yaml
generated
@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
archiver:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
cookies:
|
||||
specifier: ^0.9.1
|
||||
version: 0.9.1
|
||||
@ -24,11 +24,11 @@ importers:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
ioredis:
|
||||
specifier: ^5.8.2
|
||||
version: 5.8.2
|
||||
specifier: ^5.10.1
|
||||
version: 5.10.1
|
||||
playwright:
|
||||
specifier: ^1.57.0
|
||||
version: 1.57.0
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
raw-body:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
@ -39,8 +39,8 @@ importers:
|
||||
specifier: penpot/svgo#v3.1
|
||||
version: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180
|
||||
undici:
|
||||
specifier: ^7.16.0
|
||||
version: 7.16.0
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
xml-js:
|
||||
specifier: ^1.6.11
|
||||
version: 1.6.11
|
||||
@ -49,8 +49,8 @@ importers:
|
||||
version: 5.1.2
|
||||
devDependencies:
|
||||
ws:
|
||||
specifier: ^8.18.3
|
||||
version: 8.18.3
|
||||
specifier: ^8.20.1
|
||||
version: 8.20.1
|
||||
|
||||
packages:
|
||||
|
||||
@ -58,16 +58,8 @@ packages:
|
||||
resolution: {integrity: sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@ioredis/commands@1.4.0':
|
||||
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
'@ioredis/commands@1.5.1':
|
||||
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
|
||||
|
||||
'@trysound/sax@0.2.0':
|
||||
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
|
||||
@ -77,43 +69,24 @@ packages:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
archiver-utils@5.0.2:
|
||||
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
archiver@7.0.1:
|
||||
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
|
||||
engines: {node: '>= 14'}
|
||||
archiver@8.0.0:
|
||||
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
async@3.2.6:
|
||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||
|
||||
b4a@1.7.3:
|
||||
resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==}
|
||||
b4a@1.8.1:
|
||||
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
|
||||
peerDependencies:
|
||||
react-native-b4a: '*'
|
||||
peerDependenciesMeta:
|
||||
react-native-b4a:
|
||||
optional: true
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
balanced-match@4.0.4:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
bare-events@2.8.2:
|
||||
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
|
||||
@ -123,14 +96,48 @@ packages:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
|
||||
bare-fs@4.7.1:
|
||||
resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==}
|
||||
engines: {bare: '>=1.16.0'}
|
||||
peerDependencies:
|
||||
bare-buffer: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-buffer:
|
||||
optional: true
|
||||
|
||||
bare-os@3.9.1:
|
||||
resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==}
|
||||
engines: {bare: '>=1.14.0'}
|
||||
|
||||
bare-path@3.0.0:
|
||||
resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==}
|
||||
|
||||
bare-stream@2.13.1:
|
||||
resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
bare-buffer: '*'
|
||||
bare-events: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
bare-buffer:
|
||||
optional: true
|
||||
bare-events:
|
||||
optional: true
|
||||
|
||||
bare-url@2.4.3:
|
||||
resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
buffer-crc32@1.0.0:
|
||||
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
||||
@ -150,16 +157,9 @@ packages:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
compress-commons@6.0.2:
|
||||
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
|
||||
engines: {node: '>= 14'}
|
||||
compress-commons@7.0.1:
|
||||
resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cookies@0.9.1:
|
||||
resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==}
|
||||
@ -176,13 +176,9 @@ packages:
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crc32-stream@6.0.0:
|
||||
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
crc32-stream@7.0.1:
|
||||
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
css-select@5.2.2:
|
||||
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||
@ -236,15 +232,6 @@ packages:
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
@ -263,10 +250,6 @@ packages:
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@ -276,13 +259,6 @@ packages:
|
||||
resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
hasBin: true
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@ -301,27 +277,17 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ioredis@5.8.2:
|
||||
resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==}
|
||||
ioredis@5.10.1:
|
||||
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-stream@2.0.1:
|
||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
is-stream@4.0.1:
|
||||
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
keygrip@1.1.0:
|
||||
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@ -340,26 +306,15 @@ packages:
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
mdn-data@2.0.28:
|
||||
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
|
||||
|
||||
mdn-data@2.12.2:
|
||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||
|
||||
minimatch@5.1.6:
|
||||
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
minimatch@9.0.5:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minipass@7.1.2:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
minimatch@10.2.5:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
@ -371,24 +326,13 @@ packages:
|
||||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-scurry@1.11.1:
|
||||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||
engines: {node: '>=16 || 14 >=14.18'}
|
||||
|
||||
playwright-core@1.57.0:
|
||||
resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
|
||||
playwright-core@1.60.0:
|
||||
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.57.0:
|
||||
resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==}
|
||||
playwright@1.60.0:
|
||||
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
@ -410,8 +354,9 @@ packages:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
readdir-glob@1.1.3:
|
||||
resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==}
|
||||
readdir-glob@3.0.0:
|
||||
resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
redis-errors@1.2.0:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
@ -436,18 +381,6 @@ packages:
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shebang-regex@3.0.0:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
signal-exit@4.1.0:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -466,16 +399,8 @@ packages:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
streamx@2.23.0:
|
||||
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string-width@5.1.2:
|
||||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||
engines: {node: '>=12'}
|
||||
streamx@2.25.0:
|
||||
resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
@ -483,24 +408,19 @@ packages:
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180:
|
||||
resolution: {tarball: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180}
|
||||
version: 4.0.0
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
tar-stream@3.1.7:
|
||||
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
|
||||
tar-stream@3.2.0:
|
||||
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
|
||||
|
||||
text-decoder@1.2.3:
|
||||
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
|
||||
teex@1.0.1:
|
||||
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
|
||||
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
@ -510,9 +430,9 @@ packages:
|
||||
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
|
||||
engines: {node: '>=0.6.x'}
|
||||
|
||||
undici@7.16.0:
|
||||
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
undici@8.2.0:
|
||||
resolution: {integrity: sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
@ -521,21 +441,8 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
ws@8.20.1:
|
||||
resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
@ -553,9 +460,9 @@ packages:
|
||||
xregexp@5.1.2:
|
||||
resolution: {integrity: sha512-6hGgEMCGhqCTFEJbqmWrNIPqfpdirdGWkqshu7fFZddmTSfgv5Sn9D2SaKloR79s5VUiUlpwzg3CM3G6D3VIlw==}
|
||||
|
||||
zip-stream@6.0.1:
|
||||
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
||||
engines: {node: '>= 14'}
|
||||
zip-stream@7.0.5:
|
||||
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
snapshots:
|
||||
|
||||
@ -563,19 +470,7 @@ snapshots:
|
||||
dependencies:
|
||||
core-js-pure: 3.47.0
|
||||
|
||||
'@ioredis/commands@1.4.0': {}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
string-width-cjs: string-width@4.2.3
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi-cjs: strip-ansi@6.0.1
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
'@ioredis/commands@1.5.1': {}
|
||||
|
||||
'@trysound/sax@0.2.0': {}
|
||||
|
||||
@ -583,54 +478,67 @@ snapshots:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
archiver@8.0.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
archiver-utils@5.0.2:
|
||||
dependencies:
|
||||
glob: 10.5.0
|
||||
graceful-fs: 4.2.11
|
||||
is-stream: 2.0.1
|
||||
lazystream: 1.0.1
|
||||
lodash: 4.17.21
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
|
||||
archiver@7.0.1:
|
||||
dependencies:
|
||||
archiver-utils: 5.0.2
|
||||
async: 3.2.6
|
||||
buffer-crc32: 1.0.0
|
||||
is-stream: 4.0.1
|
||||
lazystream: 1.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
readdir-glob: 1.1.3
|
||||
tar-stream: 3.1.7
|
||||
zip-stream: 6.0.1
|
||||
readdir-glob: 3.0.0
|
||||
tar-stream: 3.2.0
|
||||
zip-stream: 7.0.5
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
async@3.2.6: {}
|
||||
|
||||
b4a@1.7.3: {}
|
||||
b4a@1.8.1: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
bare-events@2.8.2: {}
|
||||
|
||||
bare-fs@4.7.1:
|
||||
dependencies:
|
||||
bare-events: 2.8.2
|
||||
bare-path: 3.0.0
|
||||
bare-stream: 2.13.1(bare-events@2.8.2)
|
||||
bare-url: 2.4.3
|
||||
fast-fifo: 1.3.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
bare-os@3.9.1: {}
|
||||
|
||||
bare-path@3.0.0:
|
||||
dependencies:
|
||||
bare-os: 3.9.1
|
||||
|
||||
bare-stream@2.13.1(bare-events@2.8.2):
|
||||
dependencies:
|
||||
streamx: 2.25.0
|
||||
teex: 1.0.1
|
||||
optionalDependencies:
|
||||
bare-events: 2.8.2
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
bare-url@2.4.3:
|
||||
dependencies:
|
||||
bare-path: 3.0.0
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
brace-expansion@5.0.6:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
balanced-match: 4.0.4
|
||||
|
||||
buffer-crc32@1.0.0: {}
|
||||
|
||||
@ -645,17 +553,11 @@ snapshots:
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
compress-commons@6.0.2:
|
||||
compress-commons@7.0.1:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
crc32-stream: 6.0.0
|
||||
is-stream: 2.0.1
|
||||
crc32-stream: 7.0.1
|
||||
is-stream: 4.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
|
||||
@ -670,17 +572,11 @@ snapshots:
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crc32-stream@6.0.0:
|
||||
crc32-stream@7.0.1:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
readable-stream: 4.7.0
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
css-select@5.2.2:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
@ -733,12 +629,6 @@ snapshots:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
@ -753,27 +643,11 @@ snapshots:
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
generic-pool@3.9.0: {}
|
||||
|
||||
glob@10.5.0:
|
||||
dependencies:
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 3.4.3
|
||||
minimatch: 9.0.5
|
||||
minipass: 7.1.2
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@ -792,9 +666,9 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ioredis@5.8.2:
|
||||
ioredis@5.10.1:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.4.0
|
||||
'@ioredis/commands': 1.5.1
|
||||
cluster-key-slot: 1.1.2
|
||||
debug: 4.4.3
|
||||
denque: 2.1.0
|
||||
@ -806,20 +680,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
is-stream@4.0.1: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jackspeak@3.4.3:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
optionalDependencies:
|
||||
'@pkgjs/parseargs': 0.11.0
|
||||
|
||||
keygrip@1.1.0:
|
||||
dependencies:
|
||||
tsscmp: 1.0.6
|
||||
@ -834,21 +698,13 @@ snapshots:
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
mdn-data@2.0.28: {}
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
|
||||
minimatch@5.1.6:
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimatch@9.0.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minipass@7.1.2: {}
|
||||
brace-expansion: 5.0.6
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
@ -858,20 +714,11 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
playwright-core@1.60.0: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
path-scurry@1.11.1:
|
||||
playwright@1.60.0:
|
||||
dependencies:
|
||||
lru-cache: 10.4.3
|
||||
minipass: 7.1.2
|
||||
|
||||
playwright-core@1.57.0: {}
|
||||
|
||||
playwright@1.57.0:
|
||||
dependencies:
|
||||
playwright-core: 1.57.0
|
||||
playwright-core: 1.60.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
@ -904,9 +751,9 @@ snapshots:
|
||||
process: 0.11.10
|
||||
string_decoder: 1.3.0
|
||||
|
||||
readdir-glob@1.1.3:
|
||||
readdir-glob@3.0.0:
|
||||
dependencies:
|
||||
minimatch: 5.1.6
|
||||
minimatch: 10.2.5
|
||||
|
||||
redis-errors@1.2.0: {}
|
||||
|
||||
@ -924,14 +771,6 @@ snapshots:
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
@ -945,27 +784,15 @@ snapshots:
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
streamx@2.23.0:
|
||||
streamx@2.25.0:
|
||||
dependencies:
|
||||
events-universal: 1.0.1
|
||||
fast-fifo: 1.3.2
|
||||
text-decoder: 1.2.3
|
||||
text-decoder: 1.2.7
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string-width@5.1.2:
|
||||
dependencies:
|
||||
eastasianwidth: 0.2.0
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
@ -974,14 +801,6 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180:
|
||||
dependencies:
|
||||
'@trysound/sax': 0.2.0
|
||||
@ -990,18 +809,27 @@ snapshots:
|
||||
csso: 5.0.5
|
||||
lodash: 4.17.21
|
||||
|
||||
tar-stream@3.1.7:
|
||||
tar-stream@3.2.0:
|
||||
dependencies:
|
||||
b4a: 1.7.3
|
||||
b4a: 1.8.1
|
||||
bare-fs: 4.7.1
|
||||
fast-fifo: 1.3.2
|
||||
streamx: 2.23.0
|
||||
streamx: 2.25.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
teex@1.0.1:
|
||||
dependencies:
|
||||
streamx: 2.25.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
text-decoder@1.2.3:
|
||||
text-decoder@1.2.7:
|
||||
dependencies:
|
||||
b4a: 1.7.3
|
||||
b4a: 1.8.1
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
@ -1009,29 +837,13 @@ snapshots:
|
||||
|
||||
tsscmp@1.0.6: {}
|
||||
|
||||
undici@7.16.0: {}
|
||||
undici@8.2.0: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
ws@8.18.3: {}
|
||||
ws@8.20.1: {}
|
||||
|
||||
xml-js@1.6.11:
|
||||
dependencies:
|
||||
@ -1041,8 +853,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/runtime-corejs3': 7.28.4
|
||||
|
||||
zip-stream@6.0.1:
|
||||
zip-stream@7.0.5:
|
||||
dependencies:
|
||||
archiver-utils: 5.0.2
|
||||
compress-commons: 6.0.2
|
||||
compress-commons: 7.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
(defn- handle-single-export
|
||||
[{:keys [:request/auth-token] :as exchange} {:keys [export name skip-children is-wasm] :as params}]
|
||||
(let [resource (rsc/create (:type export) (or name (:name export)))
|
||||
export (assoc export :skip-children skip-children :is-wasm is-wasm)]
|
||||
export (assoc export :skip-children skip-children :is-wasm (boolean is-wasm))]
|
||||
|
||||
(->> (rd/render export
|
||||
(fn [{:keys [path] :as object}]
|
||||
@ -112,7 +112,7 @@
|
||||
(rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))
|
||||
|
||||
proc (->> exports
|
||||
(map (fn [export] (rd/render (assoc export :is-wasm is-wasm) append)))
|
||||
(map (fn [export] (rd/render (assoc export :is-wasm (boolean is-wasm)) append)))
|
||||
(p/all)
|
||||
(p/mcat (fn [_] (rsc/close-zip zip)))
|
||||
(p/fmap (constantly resource))
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
"@penpot/text-editor": "workspace:./text-editor",
|
||||
"@penpot/tokenscript": "workspace:./packages/tokenscript",
|
||||
"@penpot/ui": "workspace:./packages/ui",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@storybook/addon-docs": "10.3.5",
|
||||
"@storybook/addon-themes": "10.3.5",
|
||||
"@storybook/addon-vitest": "10.3.5",
|
||||
@ -90,7 +90,7 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"opentype.js": "^1.3.4",
|
||||
"p-limit": "^7.3.0",
|
||||
"playwright": "1.59.1",
|
||||
"playwright": "1.60.0",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-clean": "^1.2.2",
|
||||
"postcss-modules": "^6.0.1",
|
||||
|
||||
408
frontend/pnpm-lock.yaml
generated
408
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -27,14 +27,6 @@ body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
&.cursor-drag-scrub {
|
||||
cursor: ew-resize !important;
|
||||
|
||||
* {
|
||||
cursor: ew-resize !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
|
||||
@ -378,14 +378,6 @@
|
||||
border: $s-1 solid var(--input-border-color);
|
||||
color: var(--input-foreground-color);
|
||||
|
||||
&:not(:focus-within) {
|
||||
cursor: ew-resize;
|
||||
|
||||
input {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
}
|
||||
|
||||
span,
|
||||
label {
|
||||
@extend %input-label;
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
[app.util.object :as obj]
|
||||
[app.util.perf :as perf]
|
||||
[app.util.storage :as storage]
|
||||
[app.util.timers :as timers]
|
||||
[beicon.v2.core :as rx]
|
||||
[beicon.v2.operators :as rxo]
|
||||
[cuerdas.core :as str]
|
||||
@ -216,7 +217,7 @@
|
||||
(rx/create
|
||||
(fn [subs]
|
||||
(let [start (perf/now)]
|
||||
(js/requestAnimationFrame
|
||||
(timers/raf
|
||||
#(.postTask js/scheduler
|
||||
(fn []
|
||||
(let [time (- (perf/now) start)]
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.uploads :as uploads]
|
||||
[app.main.fonts :as fonts]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
@ -24,24 +25,14 @@
|
||||
[cuerdas.core :as str]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(def ^:const default-chunk-size
|
||||
(* 1024 1024 4)) ;; 4MiB
|
||||
|
||||
(defn- chunk-array
|
||||
[data chunk-size]
|
||||
(let [total-size (alength data)]
|
||||
(loop [offset 0
|
||||
chunks []]
|
||||
(if (< offset total-size)
|
||||
(let [end (min (+ offset chunk-size) total-size)
|
||||
chunk (.subarray ^js data offset end)]
|
||||
(recur end (conj chunks chunk)))
|
||||
chunks))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; General purpose events & IMPL
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private font-upload-chunk-size
|
||||
"Size in bytes of each chunk when uploading font files (10 MiB)."
|
||||
(* 1024 1024 10))
|
||||
|
||||
(defn fonts-fetched
|
||||
[fonts]
|
||||
(letfn [;; Prepare font to the internal font database format.
|
||||
@ -94,9 +85,44 @@
|
||||
(->> (rp/cmd! :get-font-variants {:team-id team-id})
|
||||
(rx/map fonts-fetched)))))
|
||||
|
||||
(defn upload-font-variant
|
||||
"Uploads a single font variant item using the chunked upload API.
|
||||
|
||||
For each mime-type in `data`, creates a Blob and uploads it via the
|
||||
session-based chunked upload. Once all sessions are created, calls
|
||||
`create-font-variant` with the resulting `:uploads` map so the server
|
||||
can assemble the chunks and materialise the final font-variant record.
|
||||
|
||||
Returns an observable that emits the created font-variant."
|
||||
[{:keys [data team-id font-id font-family font-weight font-style] :as _item}]
|
||||
;; Upload each mtype as a separate chunked session in parallel, collect
|
||||
;; all [mtype session-id] pairs, then call create-font-variant with :uploads.
|
||||
(->> (rx/from (seq data))
|
||||
(rx/mapcat (fn [[mtype buffer]]
|
||||
(let [blob (js/Blob. #js [buffer] #js {:type mtype})]
|
||||
(->> (uploads/upload-blob-chunked blob :chunk-size font-upload-chunk-size)
|
||||
(rx/map (fn [{:keys [session-id]}]
|
||||
[mtype session-id]))))))
|
||||
(rx/reduce (fn [acc [mtype session-id]]
|
||||
(assoc acc mtype session-id))
|
||||
{})
|
||||
(rx/mapcat (fn [uploads]
|
||||
(rp/cmd! :create-font-variant
|
||||
{:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family font-family
|
||||
:font-weight font-weight
|
||||
:font-style font-style
|
||||
:uploads uploads})))))
|
||||
|
||||
(defn process-upload
|
||||
"Given a seq of blobs and the team id, creates a ready-to-use fonts
|
||||
map with temporal ID's associated to each font entry."
|
||||
map with temporal ID's associated to each font entry.
|
||||
|
||||
Each font entry's `:data` is a map of `{mtype -> ArrayBuffer}`. The
|
||||
raw `ArrayBuffer` is kept as-is so that `upload-font-variant` can
|
||||
wrap it in a `Blob` and hand it directly to `upload-blob-chunked`
|
||||
without any intermediate client-side chunking."
|
||||
[blobs team-id]
|
||||
(letfn [(prepare [{:keys [font type name data] :as params}]
|
||||
(if font
|
||||
@ -134,7 +160,7 @@
|
||||
(not= hhea-ascender os2-ascent)
|
||||
(not= hhea-descender os2-descent))))
|
||||
data (js/Uint8Array. data)]
|
||||
{:content {:data (chunk-array data default-chunk-size)
|
||||
{:content {:data data
|
||||
:name name
|
||||
:type type}
|
||||
:font-family (or family "")
|
||||
@ -152,7 +178,7 @@
|
||||
(str/trim))
|
||||
family-name (if (str/blank? raw-family-name) base-name raw-family-name)
|
||||
data (js/Uint8Array. data)]
|
||||
{:content {:data (chunk-array data default-chunk-size)
|
||||
{:content {:data data
|
||||
:name name
|
||||
:type type}
|
||||
:font-family family-name
|
||||
|
||||
@ -24,10 +24,6 @@
|
||||
[app.main.repo :as rp]
|
||||
[beicon.v2.core :as rx]))
|
||||
|
||||
;; Size of each upload chunk in bytes. Reads the penpotUploadChunkSize global
|
||||
;; variable at startup; defaults to 25 MiB (overridden in production).
|
||||
(def ^:private chunk-size cf/upload-chunk-size)
|
||||
|
||||
(def ^:private max-parallel-chunk-uploads
|
||||
"Maximum number of chunk upload requests that may be in-flight at the
|
||||
same time within a single chunked upload session."
|
||||
@ -44,8 +40,11 @@
|
||||
Returns an observable that emits exactly one map:
|
||||
`{:session-id <uuid>}`
|
||||
|
||||
The caller is responsible for the final step (assemble / import)."
|
||||
[blob]
|
||||
The caller is responsible for the final step (assemble / import).
|
||||
|
||||
The optional `opts` map accepts:
|
||||
`:chunk-size` – size in bytes of each chunk (default: `cf/upload-chunk-size`, 25 MiB)."
|
||||
[blob & {:keys [chunk-size] :or {chunk-size cf/upload-chunk-size}}]
|
||||
(let [total-size (.-size blob)
|
||||
total-chunks (js/Math.ceil (/ total-size chunk-size))]
|
||||
(->> (rp/cmd! :create-upload-session
|
||||
|
||||
@ -1578,6 +1578,7 @@
|
||||
(dm/export dwv/initialize-viewport)
|
||||
(dm/export dwv/update-viewport-position)
|
||||
(dm/export dwv/update-viewport-size)
|
||||
(dm/export dwv/sync-wasm-workspace-viewport)
|
||||
(dm/export dwv/start-panning)
|
||||
(dm/export dwv/finish-panning)
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
[app.config :as cf]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.exports.wasm :as wasm.exports]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.persistence :as-alias dps]
|
||||
@ -1131,10 +1132,11 @@
|
||||
:enabled true
|
||||
:name ""}
|
||||
|
||||
params {:exports [export]
|
||||
:profile-id (:profile-id state)
|
||||
:cmd :export-shapes
|
||||
:wait true}]
|
||||
;; Create a deferred promise immediately, before any async operations.
|
||||
;; Registering the clipboard write NOW preserves the user-gesture security
|
||||
;; context; the actual blob is supplied asynchronously once the export finishes.
|
||||
deferred (p/deferred)
|
||||
write-promise (clipboard/to-clipboard-promise "image/png" deferred)]
|
||||
|
||||
(rx/concat
|
||||
;; Ensure current state persisted before exporting.
|
||||
@ -1144,21 +1146,34 @@
|
||||
(rx/first)
|
||||
(rx/timeout 400 (rx/empty)))
|
||||
|
||||
;; Exporting itself can time its time, better to notify that we are busy.
|
||||
;; Exporting itself can take its time, better to notify that we are busy.
|
||||
(rx/of (ntf/info (tr "workspace.clipboard.copying")))
|
||||
|
||||
;; Call exporter to get image URI, then fetch and copy blob.
|
||||
(->> (rp/cmd! :export params)
|
||||
;; Call exporter to get image URI, then fetch blob and resolve the deferred.
|
||||
(->> (if (and (features/active-feature? state "render-wasm/v1")
|
||||
(contains? cf/flags :wasm-export))
|
||||
(rx/of {:uri (wasm.exports/export-image-uri export)})
|
||||
(rp/cmd! :export
|
||||
{:exports [export]
|
||||
:profile-id (:profile-id state)
|
||||
:cmd :export-shapes
|
||||
:wait true}))
|
||||
|
||||
(rx/mapcat (fn [{:keys [uri]}]
|
||||
(http/send! {:method :get
|
||||
:uri uri
|
||||
:response-type :blob})))
|
||||
(rx/map :body)
|
||||
(rx/tap (fn [blob]
|
||||
(clipboard/to-clipboard-promise "image/png" (p/resolved blob))))
|
||||
(rx/mapcat (fn [blob]
|
||||
;; Resolve the deferred with the fetched blob; the browser
|
||||
;; will now complete the clipboard write it started earlier.
|
||||
(p/resolve! deferred blob)
|
||||
(rx/from write-promise)))
|
||||
(rx/map (fn [_]
|
||||
(ntf/success (tr "workspace.clipboard.image-copied"))))
|
||||
(rx/catch (fn [e]
|
||||
(js/console.error "clipboard blocked:" e)
|
||||
(ntf/error (tr "workspace.clipboard.image-copy-failed"))
|
||||
(rx/empty)))))))))
|
||||
(js/console.error "clipboard error:" e)
|
||||
;; Reject the deferred in case the error occurred before the
|
||||
;; blob was fetched, so the pending clipboard write is cancelled.
|
||||
(p/reject! deferred e)
|
||||
(rx/of (ntf/error (tr "workspace.clipboard.image-copy-failed")))))))))))
|
||||
|
||||
@ -1558,6 +1558,27 @@
|
||||
:variants-count variants-count
|
||||
:library-used-in (:used-in library-usage)}))))))))))
|
||||
|
||||
(defn cleanup-unlinked-libraries
|
||||
"Remove libraries from state that are no longer linked to the given file.
|
||||
This is used after unlinking a library to clean up transitive dependencies."
|
||||
[file-id libraries]
|
||||
(ptk/reify ::cleanup-unlinked-libraries
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [linked-ids (into #{} (map :id) libraries)]
|
||||
(update state :files
|
||||
(fn [files]
|
||||
(reduce-kv
|
||||
(fn [acc id file]
|
||||
(if (and (= (:library-of file) file-id)
|
||||
(not (contains? linked-ids id))
|
||||
(not= id file-id))
|
||||
(dissoc acc id)
|
||||
acc))
|
||||
files
|
||||
files)))))))
|
||||
|
||||
|
||||
(defn unlink-file-from-library
|
||||
[file-id library-id]
|
||||
(ptk/reify ::detach-library
|
||||
@ -1573,7 +1594,11 @@
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(let [params {:file-id file-id
|
||||
:library-id library-id}]
|
||||
(->> (rp/cmd! :unlink-file-from-library params)
|
||||
(rx/ignore))))))
|
||||
;; Unlink the library, then fetch the current list of linked libraries
|
||||
;; and remove any that are no longer linked (e.g., transitive dependencies)
|
||||
(->> (rp/cmd! :unlink-file-from-library {:file-id file-id :library-id library-id})
|
||||
(rx/mapcat (fn [_]
|
||||
(rp/cmd! :get-file-libraries {:file-id file-id})))
|
||||
(rx/map (partial cleanup-unlinked-libraries file-id))))))
|
||||
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
[app.main.data.workspace.pages :as-alias dwpg]
|
||||
[app.main.data.workspace.specialized-panel :as-alias dwsp]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
[app.main.data.workspace.viewport-wasm :as dwvw]
|
||||
[app.main.data.workspace.zoom :as dwz]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.router :as rt]
|
||||
@ -602,6 +603,10 @@
|
||||
(assoc :workspace-pre-focus (:workspace-local state)))
|
||||
state))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state stream]
|
||||
(let [stopper (rx/filter #(or (= ::toggle-focus-mode (ptk/type %))
|
||||
|
||||
@ -36,6 +36,7 @@
|
||||
[app.main.fonts :as fonts]
|
||||
[app.main.router :as rt]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.text-editor :as wasm.text-editor]
|
||||
[app.util.text-editor :as ted]
|
||||
[app.util.text.content.styles :as styles]
|
||||
[app.util.timers :as ts]
|
||||
@ -465,13 +466,23 @@
|
||||
(when-not (some? (get-in state [:workspace-editor-state id]))
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
wasm? (features/active-feature? state "render-wasm/v1")
|
||||
update-node? (fn [node]
|
||||
(or (txt/is-text-node? node)
|
||||
(txt/is-paragraph-node? node)))
|
||||
shape-ids (cond
|
||||
(cfh/text-shape? shape) [id]
|
||||
(cfh/group-shape? shape) (cfh/get-children-ids objects id))]
|
||||
(rx/of (dwsh/update-shapes shape-ids #(txt/update-text-content % update-node? d/txt-merge attrs))))))))
|
||||
(cfh/group-shape? shape) (cfh/get-children-ids objects id))
|
||||
;; Keep WASM editor cache in sync with merged :content so a following
|
||||
;; `apply-styles-to-selection` in `update-attrs` does not read stale
|
||||
;; `shape-text-contents` and overwrite per-run fills (e.g. line-height).
|
||||
merge-shape
|
||||
(fn [sh]
|
||||
(let [updated-shape (txt/update-text-content sh update-node? d/txt-merge attrs)]
|
||||
(when wasm?
|
||||
(wasm.text-editor/cache-shape-text-content! (:id updated-shape) (:content updated-shape)))
|
||||
updated-shape))]
|
||||
(rx/of (dwsh/update-shapes shape-ids merge-shape)))))))
|
||||
|
||||
(defn migrate-node
|
||||
[node]
|
||||
@ -851,11 +862,13 @@
|
||||
(effect [_ state _]
|
||||
(when (features/active-feature? state "text-editor/v2")
|
||||
(when-let [instance (:workspace-editor state)]
|
||||
(let [attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
|
||||
(styles/get-styles-from-style-declaration))
|
||||
overriden-attrs (merge attrs-to-override attrs)
|
||||
styles (styles/attrs->styles overriden-attrs)]
|
||||
(editor.v2/applyStylesToSelection instance styles)))))))
|
||||
(when (seq attrs)
|
||||
;; DOM `getCurrentStyle` reflects one resolved style (e.g. caret color). Merging
|
||||
;; it with sidebar `attrs` and applying to the whole selection collapses mixed
|
||||
;; fills/fonts when the user only changes one property (e.g. line-height).
|
||||
;; Apply only the explicit attributes from this action.
|
||||
(let [styles (styles/attrs->styles attrs)]
|
||||
(editor.v2/applyStylesToSelection instance styles))))))))
|
||||
|
||||
(defn update-all-attrs
|
||||
[ids attrs]
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.util.timers :as timers]
|
||||
[app.util.webapi :as wapi]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
@ -58,7 +59,7 @@
|
||||
(rx/create
|
||||
(fn [subs]
|
||||
(let [req-id
|
||||
(js/requestAnimationFrame
|
||||
(timers/raf
|
||||
(fn [_]
|
||||
(try
|
||||
(let [objects (dsh/lookup-page-objects @st/state file-id page-id)]
|
||||
@ -83,7 +84,7 @@
|
||||
(rx/error! subs "Frame not found")))
|
||||
(catch :default err
|
||||
(rx/error! subs err)))))]
|
||||
#(js/cancelAnimationFrame req-id)))))
|
||||
#(timers/cancel-af! req-id)))))
|
||||
|
||||
(defn render-thumbnail
|
||||
"Renders a component thumbnail via WASM and updates the UI immediately.
|
||||
|
||||
@ -16,13 +16,19 @@
|
||||
[app.common.math :as mth]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.viewport-wasm :as dwvw]
|
||||
[app.util.mouse :as mse]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn- render-context-lost?
|
||||
[state]
|
||||
(true? (get-in state [:render-state :lost])))
|
||||
(defn sync-wasm-workspace-viewport
|
||||
"Effect-only: pushes the current workspace zoom/view box to WASM after other
|
||||
events (e.g. `update-viewport-size`) have updated the store."
|
||||
[]
|
||||
(ptk/reify ::sync-wasm-workspace-viewport
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state))))
|
||||
|
||||
(defn initialize-viewport
|
||||
[{:keys [width height] :as size}]
|
||||
@ -86,7 +92,11 @@
|
||||
(update [_ state]
|
||||
(update state :workspace-local
|
||||
(fn [local]
|
||||
(setup state local)))))))
|
||||
(setup state local))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state)))))
|
||||
|
||||
(defn calculate-centered-viewbox
|
||||
"Updates the viewbox coordinates for a given center position"
|
||||
@ -105,9 +115,13 @@
|
||||
(ptk/reify ::update-viewport-position-center
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (render-context-lost? state)
|
||||
(if (dwvw/render-context-lost? state)
|
||||
state
|
||||
(update state :workspace-local calculate-centered-viewbox position)))))
|
||||
(update state :workspace-local calculate-centered-viewbox position)))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state))))
|
||||
|
||||
(defn update-viewport-position
|
||||
[{:keys [x y] :or {x identity y identity}}]
|
||||
@ -124,13 +138,17 @@
|
||||
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (render-context-lost? state)
|
||||
(if (dwvw/render-context-lost? state)
|
||||
state
|
||||
(update-in state [:workspace-local :vbox]
|
||||
(fn [vbox]
|
||||
(-> vbox
|
||||
(update :x x)
|
||||
(update :y y))))))))
|
||||
(update :y y))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state))))
|
||||
|
||||
(defn update-viewport-size
|
||||
[resize-type {:keys [width height] :as size}]
|
||||
@ -174,16 +192,27 @@
|
||||
(assoc-in [:vbox :width] vbox-width')
|
||||
(assoc-in [:vbox :height] vbox-height')))))))))
|
||||
|
||||
(defn- activate-panning []
|
||||
(ptk/reify ::activate-panning
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(-> state
|
||||
(assoc-in [:workspace-local :panning] true)))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-view-interaction-start! state))))
|
||||
|
||||
(defn start-panning []
|
||||
(ptk/reify ::start-panning
|
||||
ptk/WatchEvent
|
||||
(watch [_ state stream]
|
||||
(let [stopper (->> stream (rx/filter (ptk/type? ::finish-panning)))
|
||||
zoom (get-in state [:workspace-local :zoom])]
|
||||
(when (and (not (render-context-lost? state))
|
||||
(when (and (not (dwvw/render-context-lost? state))
|
||||
(not (get-in state [:workspace-local :panning])))
|
||||
(rx/concat
|
||||
(rx/of #(-> % (assoc-in [:workspace-local :panning] true)))
|
||||
(rx/of (activate-panning))
|
||||
(->> stream
|
||||
(rx/filter mse/pointer-event?)
|
||||
(rx/filter #(some? (mse/get-pointer-movement %)))
|
||||
@ -200,4 +229,8 @@
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(-> state
|
||||
(update :workspace-local dissoc :panning)))))
|
||||
(update :workspace-local dissoc :panning)))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-view-interaction-end! state))))
|
||||
|
||||
30
frontend/src/app/main/data/workspace/viewport_wasm.cljs
Normal file
30
frontend/src/app/main/data/workspace/viewport_wasm.cljs
Normal file
@ -0,0 +1,30 @@
|
||||
;; 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 app.main.data.workspace.viewport-wasm
|
||||
(:require
|
||||
[app.main.features :as features]
|
||||
[app.render-wasm.api :as wasm.api]))
|
||||
|
||||
(defn render-context-lost?
|
||||
[state]
|
||||
(true? (get-in state [:render-state :lost])))
|
||||
|
||||
(defn maybe-sync-workspace-local-viewport!
|
||||
"When `render-wasm/v1` is active, pushes workspace zoom and vbox into WASM."
|
||||
[state]
|
||||
(when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state)))
|
||||
(wasm.api/sync-workspace-local-viewport! state)))
|
||||
|
||||
(defn maybe-view-interaction-start!
|
||||
[state]
|
||||
(when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state)))
|
||||
(wasm.api/view-interaction-start!)))
|
||||
|
||||
(defn maybe-view-interaction-end!
|
||||
[state]
|
||||
(when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state)))
|
||||
(wasm.api/view-interaction-end!)))
|
||||
@ -16,15 +16,12 @@
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.viewport-wasm :as dwvw]
|
||||
[app.main.streams :as ms]
|
||||
[app.util.mouse :as mse]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn- render-context-lost?
|
||||
[state]
|
||||
(true? (get-in state [:render-state :lost])))
|
||||
|
||||
(defn impl-update-zoom
|
||||
[{:keys [vbox] :as local} center zoom]
|
||||
(let [new-zoom (if (fn? zoom) (zoom (:zoom local)) zoom)
|
||||
@ -47,11 +44,15 @@
|
||||
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (render-context-lost? state)
|
||||
(if (dwvw/render-context-lost? state)
|
||||
state
|
||||
(let [center (if (= center ::auto) @ms/mouse-position center)]
|
||||
(update state :workspace-local
|
||||
#(impl-update-zoom % center (fn [z] (min (* z 1.3) 200))))))))))
|
||||
#(impl-update-zoom % center (fn [z] (min (* z 1.3) 200)))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state)))))
|
||||
|
||||
(defn decrease-zoom
|
||||
([]
|
||||
@ -62,11 +63,15 @@
|
||||
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (render-context-lost? state)
|
||||
(if (dwvw/render-context-lost? state)
|
||||
state
|
||||
(let [center (if (= center ::auto) @ms/mouse-position center)]
|
||||
(update state :workspace-local
|
||||
#(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01))))))))))
|
||||
#(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01)))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state)))))
|
||||
|
||||
(defn set-zoom
|
||||
([scale]
|
||||
@ -77,7 +82,7 @@
|
||||
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (render-context-lost? state)
|
||||
(if (dwvw/render-context-lost? state)
|
||||
state
|
||||
(let [vp (dm/get-in state [:workspace-local :vbox])
|
||||
x (+ (:x vp) (/ (:width vp) 2))
|
||||
@ -86,22 +91,30 @@
|
||||
(update state :workspace-local
|
||||
#(impl-update-zoom % center (fn [z] (-> (* z scale)
|
||||
(max 0.01)
|
||||
(min 200)))))))))))
|
||||
(min 200))))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state)))))
|
||||
|
||||
(def reset-zoom
|
||||
(ptk/reify ::reset-zoom
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (render-context-lost? state)
|
||||
(if (dwvw/render-context-lost? state)
|
||||
state
|
||||
(update state :workspace-local
|
||||
#(impl-update-zoom % nil 1))))))
|
||||
#(impl-update-zoom % nil 1))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state))))
|
||||
|
||||
(def zoom-to-fit-all
|
||||
(ptk/reify ::zoom-to-fit-all
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (render-context-lost? state)
|
||||
(if (dwvw/render-context-lost? state)
|
||||
state
|
||||
(let [page-id (:current-page-id state)
|
||||
objects (dsh/lookup-page-objects state page-id)
|
||||
@ -116,13 +129,17 @@
|
||||
(-> local
|
||||
(assoc :zoom zoom)
|
||||
(assoc :zoom-inverse (/ 1 zoom))
|
||||
(update :vbox merge srect)))))))))))
|
||||
(update :vbox merge srect)))))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state))))
|
||||
|
||||
(def zoom-to-selected-shape
|
||||
(ptk/reify ::zoom-to-selected-shape
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (render-context-lost? state)
|
||||
(if (dwvw/render-context-lost? state)
|
||||
state
|
||||
(let [selected (dsh/lookup-selected state)]
|
||||
(if (empty? selected)
|
||||
@ -139,14 +156,18 @@
|
||||
(-> local
|
||||
(assoc :zoom zoom)
|
||||
(assoc :zoom-inverse (/ 1 zoom))
|
||||
(update :vbox merge srect))))))))))))
|
||||
(update :vbox merge srect))))))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state))))
|
||||
|
||||
(defn fit-to-shapes
|
||||
[ids]
|
||||
(ptk/reify ::fit-to-shapes
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (or (render-context-lost? state) (empty? ids))
|
||||
(if (or (dwvw/render-context-lost? state) (empty? ids))
|
||||
state
|
||||
(let [page-id (:current-page-id state)
|
||||
objects (dsh/lookup-page-objects state page-id)
|
||||
@ -164,16 +185,21 @@
|
||||
(-> local
|
||||
(assoc :zoom zoom)
|
||||
(assoc :zoom-inverse (/ 1 zoom))
|
||||
(update :vbox merge srect))))))))))
|
||||
(update :vbox merge srect))))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state))))
|
||||
|
||||
(defn start-zooming [pt]
|
||||
(ptk/reify ::start-zooming
|
||||
ptk/WatchEvent
|
||||
(watch [_ state stream]
|
||||
(let [stopper (->> stream (rx/filter (ptk/type? ::finish-zooming)))]
|
||||
(when (and (not (render-context-lost? state))
|
||||
(when (and (not (dwvw/render-context-lost? state))
|
||||
(not (get-in state [:workspace-local :zooming])))
|
||||
(rx/concat
|
||||
(rx/of (fn [s] (dwvw/maybe-view-interaction-start! s) s))
|
||||
(rx/of #(-> % (assoc-in [:workspace-local :zooming] true)))
|
||||
(->> stream
|
||||
(rx/filter mse/pointer-event?)
|
||||
@ -189,4 +215,7 @@
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(-> state
|
||||
(update :workspace-local dissoc :zooming)))))
|
||||
(update :workspace-local dissoc :zooming)))
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-view-interaction-end! state))))
|
||||
|
||||
@ -95,7 +95,7 @@
|
||||
([text]
|
||||
(-> (dom/create-element "span")
|
||||
(dom/set-data! "type" "text")
|
||||
(dom/set-html! (if (empty? text) zero-width-space text)))))
|
||||
(dom/set-html! (if (empty? text) zero-width-space (dom/escape-html text))))))
|
||||
|
||||
(defn- create-mention-node
|
||||
"Creates a mention node"
|
||||
@ -334,7 +334,7 @@
|
||||
after-span (create-text-node (dm/str " " suffix))
|
||||
sel (wapi/get-selection)]
|
||||
|
||||
(dom/set-html! span-node (if (empty? prefix) zero-width-space prefix))
|
||||
(dom/set-html! span-node (if (empty? prefix) zero-width-space (dom/escape-html prefix)))
|
||||
(dom/insert-after! node span-node mention-span)
|
||||
(dom/insert-after! node mention-span after-span)
|
||||
(wapi/set-cursor-after! after-span)
|
||||
@ -351,7 +351,7 @@
|
||||
(let [node-text (dom/get-text span-node)
|
||||
at-symbol (if (blank-content? node-text) "@" " @")]
|
||||
|
||||
(dom/set-html! span-node (str/concat node-text at-symbol))
|
||||
(dom/set-html! span-node (str/concat (dom/escape-html node-text) at-symbol))
|
||||
(wapi/set-cursor-after! span-node))))))
|
||||
|
||||
handle-key-down
|
||||
@ -399,7 +399,7 @@
|
||||
|
||||
(when span-node
|
||||
(let [txt (.-textContent span-node)]
|
||||
(dom/set-html! span-node (dm/str (subs txt 0 offset) "\n" zero-width-space (subs txt offset)))
|
||||
(dom/set-html! span-node (dm/str (dom/escape-html (subs txt 0 offset)) "\n" zero-width-space (dom/escape-html (subs txt offset))))
|
||||
(wapi/set-cursor! span-node (inc offset))
|
||||
(handle-input)))))
|
||||
|
||||
|
||||
@ -63,11 +63,6 @@
|
||||
;; Last value input by the user we need to store to save on unmount
|
||||
last-value* (mf/use-var value)
|
||||
|
||||
;; Drag scrubbing state
|
||||
drag-state* (mf/use-ref :idle)
|
||||
drag-start-x* (mf/use-ref 0)
|
||||
drag-start-val* (mf/use-ref 0)
|
||||
|
||||
parse-value
|
||||
(mf/use-fn
|
||||
(mf/deps min-value max-value value nillable? default integer?)
|
||||
@ -219,83 +214,18 @@
|
||||
(dom/blur! node)))))
|
||||
|
||||
handle-focus
|
||||
(mf/use-callback
|
||||
(mf/use-fn
|
||||
(mf/deps on-focus select-on-focus?)
|
||||
(fn [event]
|
||||
(when-not (= :dragging (mf/ref-val drag-state*))
|
||||
(reset! last-value* (parse-value))
|
||||
(let [target (dom/get-target event)]
|
||||
(when on-focus
|
||||
(mf/set-ref-val! dirty-ref true)
|
||||
(on-focus event))
|
||||
|
||||
(when select-on-focus?
|
||||
(dom/select-text! target)
|
||||
;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect
|
||||
(.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))))
|
||||
|
||||
on-scrub-pointer-down
|
||||
(mf/use-fn
|
||||
(mf/deps value value-str min-value max-value default)
|
||||
(fn [event]
|
||||
(let [disabled? (unchecked-get props "disabled")
|
||||
node (mf/ref-val ref)
|
||||
is-focused (and (some? node) (dom/active? node))]
|
||||
(when-not (or disabled? is-focused (= :multiple value-str))
|
||||
(let [client-x (.-clientX event)
|
||||
start-val (or value default 0)]
|
||||
(mf/set-ref-val! drag-state* :maybe-dragging)
|
||||
(mf/set-ref-val! drag-start-x* client-x)
|
||||
(mf/set-ref-val! drag-start-val* start-val)
|
||||
(dom/capture-pointer event))))))
|
||||
|
||||
on-scrub-pointer-move
|
||||
(mf/use-fn
|
||||
(mf/deps apply-value update-input step-value min-value max-value)
|
||||
(fn [event]
|
||||
(let [state (mf/ref-val drag-state*)]
|
||||
(when (or (= state :maybe-dragging) (= state :dragging))
|
||||
(let [client-x (.-clientX event)
|
||||
start-x (mf/ref-val drag-start-x*)
|
||||
delta-x (- client-x start-x)]
|
||||
(when (and (= state :maybe-dragging)
|
||||
(>= (js/Math.abs delta-x) 3))
|
||||
(mf/set-ref-val! drag-state* :dragging)
|
||||
(dom/add-class! (dom/get-body) "cursor-drag-scrub"))
|
||||
(when (= (mf/ref-val drag-state*) :dragging)
|
||||
(let [effective-step (cond
|
||||
(.-shiftKey event) (* step-value 10)
|
||||
(.-ctrlKey event) (* step-value 0.1)
|
||||
:else step-value)
|
||||
steps (js/Math.round (/ delta-x 1))
|
||||
new-val (+ (mf/ref-val drag-start-val*)
|
||||
(* steps effective-step))
|
||||
new-val (cond-> new-val
|
||||
(d/num? min-value) (mth/max min-value)
|
||||
(d/num? max-value) (mth/min max-value))]
|
||||
(update-input new-val)
|
||||
(apply-value event new-val))))))))
|
||||
|
||||
on-scrub-pointer-up
|
||||
(mf/use-fn
|
||||
(mf/deps ref)
|
||||
(fn [event]
|
||||
(let [state (mf/ref-val drag-state*)]
|
||||
(when (= state :maybe-dragging)
|
||||
(mf/set-ref-val! drag-state* :idle)
|
||||
(dom/release-pointer event)
|
||||
(when-let [node (mf/ref-val ref)]
|
||||
(dom/focus! node)))
|
||||
(when (= state :dragging)
|
||||
(mf/set-ref-val! drag-state* :idle)
|
||||
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")
|
||||
(dom/release-pointer event)))))
|
||||
|
||||
on-scrub-lost-pointer-capture
|
||||
(mf/use-fn
|
||||
(fn [_event]
|
||||
(mf/set-ref-val! drag-state* :idle)
|
||||
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")))
|
||||
(reset! last-value* (parse-value))
|
||||
(let [target (dom/get-target event)]
|
||||
(when on-focus
|
||||
(mf/set-ref-val! dirty-ref true)
|
||||
(on-focus event))
|
||||
(when select-on-focus?
|
||||
(dom/select-text! target)
|
||||
;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect
|
||||
(.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))
|
||||
|
||||
props (-> (obj/clone props)
|
||||
(obj/unset! "selectOnFocus")
|
||||
@ -310,11 +240,7 @@
|
||||
(obj/set! "title" title)
|
||||
(obj/set! "onKeyDown" handle-key-down)
|
||||
(obj/set! "onBlur" handle-blur)
|
||||
(obj/set! "onFocus" handle-focus)
|
||||
(obj/set! "onPointerDown" on-scrub-pointer-down)
|
||||
(obj/set! "onPointerMove" on-scrub-pointer-move)
|
||||
(obj/set! "onPointerUp" on-scrub-pointer-up)
|
||||
(obj/set! "onLostPointerCapture" on-scrub-lost-pointer-capture))]
|
||||
(obj/set! "onFocus" handle-focus))]
|
||||
|
||||
(mf/with-effect [value]
|
||||
(when-let [input-node (mf/ref-val ref)]
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.font :as ctf]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.data.fonts :as df]
|
||||
@ -109,7 +111,7 @@
|
||||
(mf/use-fn
|
||||
(fn [{:keys [id] :as item}]
|
||||
(swap! uploading* conj id)
|
||||
(->> (rp/cmd! :create-font-variant item)
|
||||
(->> (df/upload-font-variant item)
|
||||
(rx/delay-at-least 2000)
|
||||
(rx/subs! (fn [font]
|
||||
(swap! fonts* dissoc id)
|
||||
@ -139,7 +141,8 @@
|
||||
(dom/get-data "id")
|
||||
(uuid/parse))
|
||||
name (dom/get-value target)]
|
||||
(when-not (str/blank? name)
|
||||
(when (and (not (str/blank? name))
|
||||
(sm/validate ctf/schema:font-family name))
|
||||
(swap! fonts* df/rename-and-regroup id name installed-fonts)))))
|
||||
|
||||
on-change-name
|
||||
@ -320,7 +323,9 @@
|
||||
(fn [_]
|
||||
(reset! edition* false)
|
||||
(when-not (str/blank? font-family)
|
||||
(st/emit! (df/update-font {:id font-id :name font-family})))))
|
||||
(if (sm/validate ctf/schema:font-family font-family)
|
||||
(st/emit! (df/update-font {:id font-id :name font-family}))
|
||||
(st/emit! (ntf/error (tr "errors.font-family-invalid-chars")))))))
|
||||
|
||||
on-key-down
|
||||
(mf/use-fn
|
||||
|
||||
@ -531,7 +531,6 @@
|
||||
(when (and (= state :maybe-dragging)
|
||||
(>= (js/Math.abs delta-x) 3))
|
||||
(mf/set-ref-val! drag-state* :dragging)
|
||||
(dom/add-class! (dom/get-body) "cursor-drag-scrub")
|
||||
(when (fn? on-change-start)
|
||||
(on-change-start)))
|
||||
(when (= (mf/ref-val drag-state*) :dragging)
|
||||
@ -559,7 +558,6 @@
|
||||
(dom/focus! node)))
|
||||
(when (= state :dragging)
|
||||
(mf/set-ref-val! drag-state* :idle)
|
||||
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")
|
||||
(dom/release-pointer event)
|
||||
(when (fn? on-change-end)
|
||||
(on-change-end)))))))
|
||||
@ -571,7 +569,6 @@
|
||||
(when-not is-token-applied?
|
||||
(let [was-dragging (= :dragging (mf/ref-val drag-state*))]
|
||||
(mf/set-ref-val! drag-state* :idle)
|
||||
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")
|
||||
(when (and was-dragging (fn? on-change-end))
|
||||
(on-change-end))))))
|
||||
|
||||
|
||||
@ -325,13 +325,16 @@
|
||||
(let [size-px (cond (= size "l") icon-size-l
|
||||
(= size "s") icon-size-s
|
||||
:else icon-size-m)
|
||||
|
||||
offset (if (or (= size "s") (= size "m"))
|
||||
(/ (- icon-size-m size-px) 2)
|
||||
0)
|
||||
props (mf/spread-props props
|
||||
{:class [class (stl/css :icon)]
|
||||
:width size-px
|
||||
:height size-px})]
|
||||
|
||||
:width (max icon-size-m size-px)
|
||||
:height (max icon-size-m size-px)})]
|
||||
[:> :svg props
|
||||
[:use {:href (dm/str "#icon-" icon-id)
|
||||
:x offset
|
||||
:y offset
|
||||
:width size-px
|
||||
:height size-px}]]))
|
||||
|
||||
@ -34,12 +34,14 @@
|
||||
|
||||
on-accept
|
||||
(mf/use-fn
|
||||
(mf/deps on-accept)
|
||||
(fn [e]
|
||||
(when (fn? on-accept)
|
||||
(on-accept e))))
|
||||
|
||||
on-cancel
|
||||
(mf/use-fn
|
||||
(mf/deps on-cancel)
|
||||
(fn [e]
|
||||
(when on-cancel (on-cancel e))))]
|
||||
|
||||
|
||||
@ -322,7 +322,7 @@
|
||||
(let [trigger-el (mf/ref-val trigger-ref)
|
||||
tooltip-el (mf/ref-val tooltip-ref)]
|
||||
(when (and trigger-el tooltip-el)
|
||||
(js/requestAnimationFrame
|
||||
(ts/raf
|
||||
(fn []
|
||||
(let [origin-brect (dom/get-bounding-rect trigger-el)
|
||||
tooltip-brect (dom/get-bounding-rect tooltip-el)
|
||||
|
||||
@ -77,7 +77,8 @@
|
||||
(mf/deps vport)
|
||||
(fn [resize-type size]
|
||||
(when (and vport (not= size vport))
|
||||
(st/emit! (dw/update-viewport-size resize-type size)))))
|
||||
(st/emit! (dw/update-viewport-size resize-type size)
|
||||
(dw/sync-wasm-workspace-viewport)))))
|
||||
|
||||
on-resize-palette
|
||||
(mf/use-fn
|
||||
|
||||
@ -376,7 +376,12 @@
|
||||
;; Initialize colorpicker state
|
||||
(mf/with-effect []
|
||||
(st/emit! (dc/initialize-colorpicker on-change active-fill-tab))
|
||||
(partial st/emit! (dc/finalize-colorpicker)))
|
||||
;; Always deactivate picking mode on unmount so that :picking-color? never
|
||||
;; stays true if the modal closes for any reason other than the normal
|
||||
;; pointer-up path (e.g. ESC, navigation, programmatic hide).
|
||||
(fn []
|
||||
(st/emit! (dc/stop-picker)
|
||||
(dc/finalize-colorpicker))))
|
||||
|
||||
;; Update colorpicker with external color changes
|
||||
(mf/with-effect [data]
|
||||
|
||||
@ -60,10 +60,11 @@
|
||||
{::mf/wrap [mf/memo]
|
||||
::mf/wrap-props false}
|
||||
[props]
|
||||
(let [objects (obj/get props "objects")
|
||||
active-frames (obj/get props "active-frames")
|
||||
shapes (cfh/get-immediate-children objects)
|
||||
vbox (mf/use-ctx ctx/current-vbox)
|
||||
(let [objects (obj/get props "objects")
|
||||
active-frames (obj/get props "active-frames")
|
||||
disable-thumbnails (obj/get props "disable-thumbnails")
|
||||
shapes (cfh/get-immediate-children objects)
|
||||
vbox (mf/use-ctx ctx/current-vbox)
|
||||
|
||||
frame-overlap? (mf/with-memo [vbox objects]
|
||||
#(make-is-frame-overlap vbox objects))
|
||||
@ -84,13 +85,16 @@
|
||||
|
||||
[:g.frame-children
|
||||
(for [shape shapes]
|
||||
[:g.ws-shape-wrapper {:key (dm/str (dm/get-prop shape :id))}
|
||||
(if ^boolean (cfh/frame-shape? shape)
|
||||
[:& root-frame-wrapper
|
||||
{:shape shape
|
||||
:objects objects
|
||||
:thumbnail? (not (contains? active-frames (dm/get-prop shape :id)))}]
|
||||
[:& shape-wrapper {:shape shape}])])]]]))
|
||||
(let [thumbnail?
|
||||
(and (not disable-thumbnails)
|
||||
(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
|
||||
{:shape shape
|
||||
:objects objects
|
||||
:thumbnail? thumbnail?}]
|
||||
[:& shape-wrapper {:shape shape}])]))]]]))
|
||||
|
||||
(mf/defc shape-wrapper
|
||||
{::mf/wrap [#(mf/memo' % common/check-shape-props)]
|
||||
|
||||
@ -290,8 +290,8 @@
|
||||
:data-testid "text-editor-container"
|
||||
:style {:width "var(--editor-container-width)"
|
||||
:height "var(--editor-container-height)"
|
||||
:min-width "1px"
|
||||
:min-height "1px"}}
|
||||
:min-width "var(--editor-container-min-width, 1px)"
|
||||
:min-height "var(--editor-container-min-height, 1px)"}}
|
||||
;; We hide the editor when is blurred because otherwise the
|
||||
;; selection won't let us see the underlying text. Use opacity
|
||||
;; because display or visibility won't allow to recover focus
|
||||
@ -381,7 +381,7 @@
|
||||
|
||||
render-wasm? (mf/use-memo #(features/active-feature? @st/state "render-wasm/v1"))
|
||||
|
||||
[{:keys [x y width height]} transform]
|
||||
[{:keys [x y width height selrect-width selrect-height]} transform]
|
||||
(if render-wasm?
|
||||
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
|
||||
selrect-transform (mf/deref refs/workspace-selrect)
|
||||
@ -403,7 +403,8 @@
|
||||
"bottom" (+ y (- selrect-height height))
|
||||
"center" (+ y (/ (- selrect-height height) 2))
|
||||
y)]
|
||||
[(assoc selrect :y y :width overlay-width :height max-height) transform])
|
||||
[(assoc selrect :y y :width overlay-width :height max-height
|
||||
:selrect-width selrect-width :selrect-height selrect-height) transform])
|
||||
|
||||
(let [bounds (gst/shape->rect shape)
|
||||
x (mth/min (dm/get-prop bounds :x)
|
||||
@ -422,6 +423,8 @@
|
||||
(obj/merge!
|
||||
#js {"--editor-container-width" "auto"
|
||||
"--editor-container-height" "auto"
|
||||
"--editor-container-min-width" (dm/str selrect-width "px")
|
||||
"--editor-container-min-height" (dm/str selrect-height "px")
|
||||
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")
|
||||
:display "flex"})
|
||||
|
||||
|
||||
@ -90,7 +90,7 @@
|
||||
(d/without-nils))
|
||||
prev-color (d/seek (partial get groups) prev-colors)
|
||||
color-operations-old (get groups old-color)
|
||||
color-operations-prev (get groups prev-colors)
|
||||
color-operations-prev (get groups prev-color)
|
||||
color-operations (or color-operations-prev color-operations-old)
|
||||
old-color (or prev-color old-color)]
|
||||
[color-operations old-color]))
|
||||
@ -115,11 +115,17 @@
|
||||
;; TODO: Review if this is still necessary.
|
||||
prev-colors-ref (mf/use-ref nil)
|
||||
|
||||
;; Always keep this ref pointing to the latest groups so that on-change
|
||||
;; (which may be captured stale by the colorpicker's rx subscription) can
|
||||
;; still read the current groups and find the correct color operations.
|
||||
groups-ref (mf/use-ref nil)
|
||||
_ (mf/set-ref-val! groups-ref groups)
|
||||
|
||||
on-change
|
||||
(mf/use-fn
|
||||
(mf/deps groups)
|
||||
(fn [old-color new-color from-picker?]
|
||||
(let [prev-colors (mf/ref-val prev-colors-ref)
|
||||
(let [groups (mf/ref-val groups-ref)
|
||||
prev-colors (mf/ref-val prev-colors-ref)
|
||||
[color-operations old-color] (retrieve-color-operations groups old-color prev-colors)]
|
||||
|
||||
;; TODO: Review if this is still necessary.
|
||||
|
||||
@ -196,11 +196,6 @@
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:focus-within) {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
block-size: $sz-32;
|
||||
inline-size: px2rem(60);
|
||||
padding-inline-start: var(--sp-xs);
|
||||
@ -260,11 +255,6 @@
|
||||
margin: var(--sp-xxs) 0;
|
||||
padding: 0 0 0 px2rem(6);
|
||||
color: var(--color-foreground-primary);
|
||||
cursor: ew-resize;
|
||||
|
||||
&:focus {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
|
||||
@ -96,14 +96,6 @@
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-24;
|
||||
padding: 0 deprecated.$s-4 0 deprecated.$s-8;
|
||||
|
||||
svg {
|
||||
@extend %button-icon-small;
|
||||
|
||||
color: transparent;
|
||||
fill: none;
|
||||
stroke: var(--icon-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
(ns app.main.ui.workspace.tokens.management.forms.controls.floating-dropdown
|
||||
(:require
|
||||
[app.util.dom :as dom]
|
||||
[app.util.timers :as timers]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn use-floating-dropdown [is-open input-wrapper-ref outer-wrapper-ref dropdown-ref]
|
||||
@ -48,7 +49,7 @@
|
||||
(when is-open
|
||||
(let [recalculate
|
||||
(fn []
|
||||
(js/requestAnimationFrame
|
||||
(timers/raf
|
||||
(fn []
|
||||
(let [input-node (mf/ref-val input-wrapper-ref)]
|
||||
(calculate-position input-node)))))
|
||||
|
||||
@ -410,7 +410,9 @@
|
||||
;; Render root shape
|
||||
[:& shapes/root-shape {:key (str page-id)
|
||||
:objects base-objects
|
||||
:active-frames @active-frames}]]]]
|
||||
:active-frames @active-frames
|
||||
;; disable thumbnails when previewing a version
|
||||
:disable-thumbnails (some? preview-id)}]]]]
|
||||
|
||||
[:svg.viewport-controls
|
||||
{:xmlns "http://www.w3.org/2000/svg"
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
[app.util.globals :as ug]
|
||||
[app.util.keyboard :as kbd]
|
||||
[app.util.object :as obj]
|
||||
[app.util.timers :as timers]
|
||||
[beicon.v2.core :as rx]
|
||||
[goog.events :as events]
|
||||
[rumext.v2 :as mf]))
|
||||
@ -53,26 +54,22 @@
|
||||
new-canvas))))))))
|
||||
|
||||
(defn process-pointer-move
|
||||
[viewport-node canvas canvas-image-data zoom-view-context client-x client-y]
|
||||
[viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y]
|
||||
(when-let [image-data (mf/ref-val canvas-image-data)]
|
||||
(when-let [zoom-view-node (dom/get-element "picker-detail")]
|
||||
(when-not (mf/ref-val zoom-view-context)
|
||||
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
|
||||
(let [canvas-width 260
|
||||
(let [canvas-width 260
|
||||
canvas-height 140
|
||||
{brx :left bry :top} (dom/get-bounding-rect viewport-node)
|
||||
|
||||
x (mth/floor (- client-x brx))
|
||||
y (mth/floor (- client-y bry))
|
||||
|
||||
zoom-context (mf/ref-val zoom-view-context)
|
||||
offset (* (+ (* y (unchecked-get image-data "width")) x) 4)
|
||||
rgba (unchecked-get image-data "data")
|
||||
img-width (unchecked-get image-data "width")
|
||||
img-height (unchecked-get image-data "height")
|
||||
|
||||
r (obj/get rgba (+ 0 offset))
|
||||
g (obj/get rgba (+ 1 offset))
|
||||
b (obj/get rgba (+ 2 offset))
|
||||
a (obj/get rgba (+ 3 offset))
|
||||
zoom-context (mf/ref-val zoom-view-context)
|
||||
|
||||
sx (- x 32)
|
||||
sy (if (cfg/check-browser? :safari) y (- y 17))
|
||||
@ -82,13 +79,27 @@
|
||||
dy 0
|
||||
dw canvas-width
|
||||
dh canvas-height]
|
||||
|
||||
(when (obj/get zoom-context "imageSmoothingEnabled")
|
||||
(obj/set! zoom-context "imageSmoothingEnabled" false))
|
||||
(.clearRect zoom-context 0 0 canvas-width canvas-height)
|
||||
(.drawImage zoom-context canvas sx sy sw sh dx dy dw dh)
|
||||
(js/requestAnimationFrame
|
||||
(fn []
|
||||
(st/emit! (dwc/pick-color [r g b a]))))))))
|
||||
|
||||
;; Only pick color when cursor is within canvas bounds to avoid garbage pixels
|
||||
(when (and (>= x 0) (< x img-width) (>= y 0) (< y img-height))
|
||||
(let [offset (* (+ (* y img-width) x) 4)
|
||||
rgba (unchecked-get image-data "data")
|
||||
r (obj/get rgba (+ 0 offset))
|
||||
g (obj/get rgba (+ 1 offset))
|
||||
b (obj/get rgba (+ 2 offset))
|
||||
a (obj/get rgba (+ 3 offset))
|
||||
color [r g b a]]
|
||||
;; Store latest color synchronously so the click handler always reads
|
||||
;; the correct pixel even before the rAF fires (fixes race condition)
|
||||
(mf/set-ref-val! last-picked-color color)
|
||||
(timers/raf
|
||||
(fn []
|
||||
(st/emit! (dwc/pick-color color))))))))))
|
||||
|
||||
|
||||
(mf/defc pixel-overlay
|
||||
@ -103,8 +114,14 @@
|
||||
canvas-context (.getContext canvas "2d" #js {:willReadFrequently true})
|
||||
canvas-image-data (mf/use-ref nil)
|
||||
zoom-view-context (mf/use-ref nil)
|
||||
canvas-ready (mf/use-state false)
|
||||
initial-mouse-pos (mf/use-state {:x 0 :y 0})
|
||||
;; Holds the last successfully picked [r g b a] synchronously so that
|
||||
;; the pointer-down handler always has the current pixel, regardless of
|
||||
;; whether the rAF-deferred store update has fired yet.
|
||||
last-picked-color (mf/use-ref nil)
|
||||
;; Use a ref (not state) so tracking the cursor doesn't cause re-renders.
|
||||
;; Updated by both on-mouse-enter and a document-level pointermove listener
|
||||
;; so that the position is always current when the canvas first becomes ready.
|
||||
initial-mouse-pos (mf/use-ref {:x 0 :y 0})
|
||||
update-str (rx/subject)
|
||||
|
||||
handle-keydown
|
||||
@ -121,8 +138,15 @@
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(dom/stop-propagation event)
|
||||
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
|
||||
(dwc/pick-color-select true (kbd/shift? event)))))
|
||||
;; Emit pick-color synchronously with the latest pixel colour before
|
||||
;; pick-color-select, so the colorpicker effect never sees a stale value.
|
||||
(let [color (mf/ref-val last-picked-color)]
|
||||
(if (some? color)
|
||||
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
|
||||
(dwc/pick-color color)
|
||||
(dwc/pick-color-select true (kbd/shift? event)))
|
||||
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
|
||||
(dwc/pick-color-select true (kbd/shift? event)))))))
|
||||
|
||||
handle-pointer-up-picker
|
||||
(mf/use-callback
|
||||
@ -143,16 +167,20 @@
|
||||
:result "image-bitmap"}]
|
||||
(->> (fonts/render-font-styles-cached fonts)
|
||||
(rx/map (fn [styles]
|
||||
(assoc result
|
||||
:styles styles)))
|
||||
(assoc result :styles styles)))
|
||||
(rx/mapcat thr/render-node)
|
||||
(rx/subs! (fn [image-bitmap]
|
||||
(.drawImage canvas-context image-bitmap 0 0)
|
||||
(let [width (unchecked-get canvas "width")
|
||||
height (unchecked-get canvas "height")
|
||||
image-data (.getImageData canvas-context 0 0 width height)]
|
||||
image-data (.getImageData canvas-context 0 0 width height)
|
||||
;; Read current mouse position from ref so the zoom
|
||||
;; is populated immediately even without a mouse-move.
|
||||
{mx :x my :y} (mf/ref-val initial-mouse-pos)]
|
||||
(mf/set-ref-val! canvas-image-data image-data)
|
||||
(reset! canvas-ready true))))))))
|
||||
(process-pointer-move viewport-node canvas canvas-image-data
|
||||
zoom-view-context last-picked-color
|
||||
mx my))))))))
|
||||
|
||||
handle-svg-change
|
||||
(mf/use-callback
|
||||
@ -163,19 +191,28 @@
|
||||
(mf/use-callback
|
||||
(mf/deps viewport-node)
|
||||
(fn [event]
|
||||
(let [x (.-clientX event)
|
||||
y (.-clientY event)]
|
||||
(reset! initial-mouse-pos {:x x
|
||||
:y y}))))
|
||||
(mf/set-ref-val! initial-mouse-pos
|
||||
{:x (.-clientX event)
|
||||
:y (.-clientY event)})))
|
||||
|
||||
handle-pointer-move-picker
|
||||
(mf/use-callback
|
||||
(mf/deps viewport-node)
|
||||
(fn [event]
|
||||
(process-pointer-move viewport-node canvas canvas-image-data zoom-view-context (.-clientX event) (.-clientY event))))]
|
||||
(process-pointer-move viewport-node canvas canvas-image-data zoom-view-context
|
||||
last-picked-color (.-clientX event) (.-clientY event))))]
|
||||
|
||||
(when (obj/get canvas-context "imageSmoothingEnabled")
|
||||
(obj/set! canvas-context "imageSmoothingEnabled" false))
|
||||
|
||||
;; Move focus to the overlay div on mount so the eyedropper button loses
|
||||
;; :focus styling immediately. Without this, prevent-default on pointer-down
|
||||
;; keeps focus on the button and it looks "selected" even after picking.
|
||||
(mf/use-effect
|
||||
(fn []
|
||||
(when-let [node (dom/get-element "pixel-overlay")]
|
||||
(.focus node))))
|
||||
|
||||
(mf/use-effect
|
||||
(fn []
|
||||
(let [listener (events/listen ug/document "keydown" handle-keydown)]
|
||||
@ -202,12 +239,17 @@
|
||||
;; Disconnect on unmount
|
||||
#(.disconnect observer))))
|
||||
|
||||
;; Track the cursor position at document level so initial-mouse-pos is always
|
||||
;; current when the canvas first becomes ready — even when the picker is opened
|
||||
;; via the "i" shortcut and the cursor hasn't entered/moved over the overlay yet.
|
||||
(mf/use-effect
|
||||
(mf/deps viewport-node @canvas-ready)
|
||||
(fn []
|
||||
(when canvas-ready
|
||||
(let [{:keys [x y]} @initial-mouse-pos]
|
||||
(process-pointer-move viewport-node canvas canvas-image-data zoom-view-context x y)))))
|
||||
(let [listener (events/listen ug/document "pointermove"
|
||||
(fn [e]
|
||||
(mf/set-ref-val! initial-mouse-pos
|
||||
{:x (.-clientX e)
|
||||
:y (.-clientY e)})))]
|
||||
#(events/unlistenByKey listener))))
|
||||
|
||||
[:div {:id "pixel-overlay"
|
||||
:tab-index 0
|
||||
@ -218,12 +260,13 @@
|
||||
:on-mouse-enter handle-mouse-enter}]))
|
||||
|
||||
|
||||
(defn process-pointer-move-wasm [viewport-node canvas canvas-image-data zoom-view-context client-x client-y]
|
||||
(defn process-pointer-move-wasm
|
||||
[viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y]
|
||||
(when-let [image-data (mf/ref-val canvas-image-data)]
|
||||
(when-let [zoom-view-node (dom/get-element "picker-detail")]
|
||||
(when-not (mf/ref-val zoom-view-context)
|
||||
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
|
||||
(let [zoom-view-width 260
|
||||
(let [zoom-view-width 260
|
||||
zoom-view-height 140
|
||||
{brx :left bry :top} (dom/get-bounding-rect viewport-node)
|
||||
x (mth/floor (- client-x brx))
|
||||
@ -232,31 +275,39 @@
|
||||
canvas-x (* x wasm.api/dpr)
|
||||
canvas-y (* y wasm.api/dpr)
|
||||
|
||||
zoom-context (mf/ref-val zoom-view-context)
|
||||
;; the image-data we have is an array of pixels, starting from the
|
||||
;; bottom-left corner; so we need to calculate the offset accordingly
|
||||
inverted-y (- (.-height image-data) canvas-y)
|
||||
offset (* (+ (* inverted-y (.-width image-data)) canvas-x) 4)
|
||||
rgba (.-data image-data)
|
||||
img-width (.-width image-data)
|
||||
img-height (.-height image-data)
|
||||
|
||||
r (obj/get rgba (+ 0 offset))
|
||||
g (obj/get rgba (+ 1 offset))
|
||||
b (obj/get rgba (+ 2 offset))
|
||||
a (obj/get rgba (+ 3 offset))
|
||||
zoom-context (mf/ref-val zoom-view-context)
|
||||
|
||||
sx (- canvas-x 32)
|
||||
sy (if (cfg/check-browser? :safari) canvas-y (- canvas-y 17))
|
||||
sw 65
|
||||
sh 35]
|
||||
|
||||
(when (obj/get zoom-context "imageSmoothingEnabled")
|
||||
(obj/set! zoom-context "imageSmoothingEnabled" false))
|
||||
(.clearRect zoom-context 0 0 zoom-view-width zoom-view-height)
|
||||
(.drawImage zoom-context canvas sx sy sw sh 0 0 zoom-view-width zoom-view-height)
|
||||
;; FIXME: this is throttled to avoid getting stuck in an inifinite react
|
||||
;; update loop. We should fix the global state instead.
|
||||
(js/requestAnimationFrame
|
||||
(fn []
|
||||
(st/emit! (dwc/pick-color [r g b a]))))))))
|
||||
|
||||
;; Only pick color when cursor is within canvas bounds to avoid garbage pixels
|
||||
(when (and (>= canvas-x 0) (< canvas-x img-width) (>= canvas-y 0) (< canvas-y img-height))
|
||||
(let [;; image-data pixels start from the bottom-left corner; invert y accordingly
|
||||
inverted-y (- img-height canvas-y)
|
||||
offset (* (+ (* inverted-y img-width) canvas-x) 4)
|
||||
rgba (.-data image-data)
|
||||
r (obj/get rgba (+ 0 offset))
|
||||
g (obj/get rgba (+ 1 offset))
|
||||
b (obj/get rgba (+ 2 offset))
|
||||
a (obj/get rgba (+ 3 offset))
|
||||
color [r g b a]]
|
||||
;; Store latest color synchronously so the click handler always reads
|
||||
;; the correct pixel even before the rAF fires (fixes race condition)
|
||||
(mf/set-ref-val! last-picked-color color)
|
||||
;; rAF throttles state updates to avoid an infinite React re-render loop
|
||||
(timers/raf
|
||||
(fn []
|
||||
(st/emit! (dwc/pick-color color))))))))))
|
||||
|
||||
(mf/defc pixel-overlay-wasm*
|
||||
{::mf/wrap-props false}
|
||||
@ -266,7 +317,14 @@
|
||||
canvas-context (mf/use-ref nil)
|
||||
canvas-image-data (mf/use-ref nil)
|
||||
zoom-view-context (mf/use-ref nil)
|
||||
initial-mouse-pos (mf/use-state {:x 0 :y 0})
|
||||
;; Holds the last successfully picked [r g b a] synchronously so that
|
||||
;; the pointer-down handler always has the current pixel, regardless of
|
||||
;; whether the rAF-deferred store update has fired yet.
|
||||
last-picked-color (mf/use-ref nil)
|
||||
;; Use a ref (not state) so tracking the cursor doesn't cause re-renders.
|
||||
;; Updated by both on-mouse-enter and a document-level pointermove listener
|
||||
;; so that the position is always current when the canvas first becomes ready.
|
||||
initial-mouse-pos (mf/use-ref {:x 0 :y 0})
|
||||
update-str (rx/subject)
|
||||
|
||||
handle-keydown
|
||||
@ -283,8 +341,15 @@
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(dom/stop-propagation event)
|
||||
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
|
||||
(dwc/pick-color-select true (kbd/shift? event)))))
|
||||
;; Emit pick-color synchronously with the latest pixel colour before
|
||||
;; pick-color-select, so the colorpicker effect never sees a stale value.
|
||||
(let [color (mf/ref-val last-picked-color)]
|
||||
(if (some? color)
|
||||
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
|
||||
(dwc/pick-color color)
|
||||
(dwc/pick-color-select true (kbd/shift? event)))
|
||||
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
|
||||
(dwc/pick-color-select true (kbd/shift? event)))))))
|
||||
|
||||
handle-pointer-up-picker
|
||||
(mf/use-callback
|
||||
@ -300,12 +365,18 @@
|
||||
(mf/deps canvas-context)
|
||||
(fn []
|
||||
(when-let [canvas-context (mf/ref-val canvas-context)]
|
||||
(let [width (.-width canvas)
|
||||
(let [width (.-width canvas)
|
||||
height (.-height canvas)
|
||||
buffer (js/Uint8ClampedArray. (* width height 4))
|
||||
_ (.readPixels canvas-context 0 0 width height (.-RGBA canvas-context) (.-UNSIGNED_BYTE canvas-context) buffer)
|
||||
image-data (js/ImageData. buffer width height)]
|
||||
(mf/set-ref-val! canvas-image-data image-data)))))
|
||||
buffer (js/Uint8ClampedArray. (* width height 4))
|
||||
_ (.readPixels canvas-context 0 0 width height (.-RGBA canvas-context) (.-UNSIGNED_BYTE canvas-context) buffer)
|
||||
image-data (js/ImageData. buffer width height)
|
||||
;; Read current mouse position from ref so the zoom
|
||||
;; is populated immediately even without a mouse-move.
|
||||
{mx :x my :y} (mf/ref-val initial-mouse-pos)]
|
||||
(mf/set-ref-val! canvas-image-data image-data)
|
||||
(process-pointer-move-wasm viewport-node canvas canvas-image-data
|
||||
zoom-view-context last-picked-color
|
||||
mx my)))))
|
||||
|
||||
handle-canvas-changed
|
||||
(mf/use-callback
|
||||
@ -316,25 +387,33 @@
|
||||
(mf/use-callback
|
||||
(mf/deps viewport-node)
|
||||
(fn [event]
|
||||
(let [x (.-clientX event)
|
||||
y (.-clientY event)]
|
||||
(reset! initial-mouse-pos {:x x
|
||||
:y y}))))
|
||||
(mf/set-ref-val! initial-mouse-pos
|
||||
{:x (.-clientX event)
|
||||
:y (.-clientY event)})))
|
||||
|
||||
handle-pointer-move-picker
|
||||
(mf/use-callback
|
||||
(mf/deps viewport-node)
|
||||
(fn [event]
|
||||
(process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context (.-clientX event) (.-clientY event))))]
|
||||
(process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context last-picked-color (.-clientX event) (.-clientY event))))]
|
||||
|
||||
(mf/use-effect
|
||||
(mf/deps canvas)
|
||||
(fn []
|
||||
(let [context (.getContext canvas "webgl2" #js {:willReadFrequently true, :preserveDrawingBuffer true})]
|
||||
(let [context (.getContext canvas "webgl2" #js {:willReadFrequently true :preserveDrawingBuffer true})]
|
||||
(mf/set-ref-val! canvas-context context))))
|
||||
|
||||
;; Move focus to the overlay div on mount so the eyedropper button loses
|
||||
;; :focus styling immediately. Without this, prevent-default on pointer-down
|
||||
;; keeps focus on the button and it looks "selected" even after picking.
|
||||
(mf/use-effect
|
||||
(fn []
|
||||
(when-let [node (dom/get-element "pixel-overlay")]
|
||||
(.focus node))))
|
||||
|
||||
(mf/use-effect
|
||||
(fn []
|
||||
(let [listener (events/listen ug/document "keydown" handle-keydown)]
|
||||
(let [listener (events/listen ug/document "keydown" handle-keydown)]
|
||||
#(events/unlistenByKey listener))))
|
||||
|
||||
(mf/use-effect
|
||||
@ -350,12 +429,17 @@
|
||||
(fn []
|
||||
(.removeEventListener ug/document "penpot:wasm:render" handle-canvas-changed)))
|
||||
|
||||
;; Track the cursor position at document level so initial-mouse-pos is always
|
||||
;; current when the canvas first becomes ready — even when the picker is opened
|
||||
;; via the "i" shortcut and the cursor hasn't entered/moved over the overlay yet.
|
||||
(mf/use-effect
|
||||
(mf/deps viewport-node canvas canvas-image-data zoom-view-context)
|
||||
(fn []
|
||||
(when (some? canvas)
|
||||
(let [{:keys [x y]} @initial-mouse-pos]
|
||||
(process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context x y)))))
|
||||
(let [listener (events/listen ug/document "pointermove"
|
||||
(fn [e]
|
||||
(mf/set-ref-val! initial-mouse-pos
|
||||
{:x (.-clientX e)
|
||||
:y (.-clientY e)})))]
|
||||
#(events/unlistenByKey listener))))
|
||||
|
||||
[:div {:id "pixel-overlay"
|
||||
:tab-index 0
|
||||
|
||||
@ -436,7 +436,8 @@
|
||||
|
||||
(mf/with-effect [vport]
|
||||
(when (and @canvas-init? @initialized?)
|
||||
(wasm.api/resize-viewbox (:width vport) (:height vport))))
|
||||
(wasm.api/resize-viewbox (:width vport) (:height vport))
|
||||
(wasm.api/set-view-box zoom vbox)))
|
||||
|
||||
(mf/with-effect [@canvas-init? preview-blend]
|
||||
(when (and @canvas-init? preview-blend)
|
||||
@ -467,10 +468,6 @@
|
||||
(wasm.api/clear-focus-mode)
|
||||
(wasm.api/set-focus-mode focus)))))
|
||||
|
||||
(mf/with-effect [vbox zoom]
|
||||
(when (and @canvas-init? @initialized?)
|
||||
(wasm.api/set-view-box zoom vbox)))
|
||||
|
||||
(mf/with-effect [background]
|
||||
(when (and @canvas-init? @initialized?)
|
||||
(wasm.api/set-canvas-background background)))
|
||||
|
||||
@ -53,6 +53,7 @@
|
||||
[app.util.i18n :refer [tr]]
|
||||
[app.util.modules :as mod]
|
||||
[app.util.text.content :as tc]
|
||||
[app.util.timers :as timers]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]
|
||||
@ -208,6 +209,8 @@
|
||||
|
||||
(def ^:const DEBOUNCE_DELAY_MS 100)
|
||||
|
||||
(defonce ^:private view-interaction-active? (atom false))
|
||||
|
||||
;; Time budget (ms) per chunk of shape processing before yielding to browser
|
||||
(def ^:private ^:const CHUNK_TIME_BUDGET_MS 8)
|
||||
;; Threshold below which we use synchronous processing (no chunking overhead)
|
||||
@ -396,7 +399,7 @@
|
||||
(when-not @pending-render
|
||||
(reset! pending-render true)
|
||||
(let [frame-id
|
||||
(js/requestAnimationFrame
|
||||
(timers/raf
|
||||
(fn [ts]
|
||||
(reset! pending-render false)
|
||||
(set! wasm/internal-frame-id nil)
|
||||
@ -1140,14 +1143,26 @@
|
||||
(= result 1))
|
||||
false))
|
||||
|
||||
(defn view-interaction-start!
|
||||
[]
|
||||
(when-not @view-interaction-active?
|
||||
(h/call wasm/internal-module "_set_view_start")
|
||||
(reset! view-interaction-active? true)))
|
||||
|
||||
(defn view-interaction-end!
|
||||
[]
|
||||
(when @view-interaction-active?
|
||||
(perf/begin-measure "render-finish")
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(perf/end-measure "render-finish")
|
||||
(reset! view-interaction-active? false)))
|
||||
|
||||
(def render-finish
|
||||
(letfn [(do-render []
|
||||
;; Check if context is still initialized before executing
|
||||
;; to prevent errors when navigating quickly
|
||||
(when (and wasm/context-initialized? (not @wasm/context-lost?))
|
||||
(perf/begin-measure "render-finish")
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(perf/end-measure "render-finish")
|
||||
(view-interaction-end!)
|
||||
;; Use async _render: visible tiles render synchronously
|
||||
;; (no yield), interest-area tiles render progressively
|
||||
;; via rAF. _set_view_end already rebuilt the tile
|
||||
@ -1161,7 +1176,7 @@
|
||||
(defn set-view-box
|
||||
[zoom vbox]
|
||||
(perf/begin-measure "set-view-box")
|
||||
(h/call wasm/internal-module "_set_view_start")
|
||||
(view-interaction-start!)
|
||||
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
|
||||
(perf/end-measure "set-view-box")
|
||||
|
||||
@ -1170,6 +1185,16 @@
|
||||
(render-finish)
|
||||
(perf/end-measure "render-from-cache"))
|
||||
|
||||
(defn sync-workspace-local-viewport!
|
||||
"Pushes `[:workspace-local :zoom]` and `:vbox` into WASM."
|
||||
[state]
|
||||
(when (and wasm/context-initialized?
|
||||
(not @wasm/context-lost?))
|
||||
(let [zoom (get-in state [:workspace-local :zoom])
|
||||
vbox (get-in state [:workspace-local :vbox])]
|
||||
(when (and zoom vbox)
|
||||
(set-view-box zoom vbox)))))
|
||||
|
||||
(defn- ensure-text-content
|
||||
"Guarantee that the shape always sends a valid text tree to WASM. When the
|
||||
content is nil (freshly created text) we fall back to
|
||||
@ -1338,6 +1363,7 @@
|
||||
;; Rebuild the tile index so _render knows which shapes
|
||||
;; map to which tiles after a page switch.
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(reset! view-interaction-active? false)
|
||||
|
||||
;; Text layouts must run after _end_loading (they
|
||||
;; depend on state that is only correct when loading
|
||||
@ -1396,6 +1422,7 @@
|
||||
;; Rebuild the tile index so _render knows which shapes
|
||||
;; map to which tiles after a page switch.
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(reset! view-interaction-active? false)
|
||||
(process-pending shapes thumbnails full
|
||||
(fn []
|
||||
(if render-callback
|
||||
@ -1676,7 +1703,7 @@
|
||||
[]
|
||||
(p/create
|
||||
(fn [resolve _reject]
|
||||
(js/requestAnimationFrame (fn [] (resolve nil))))))
|
||||
(timers/raf (fn [] (resolve nil))))))
|
||||
|
||||
(def ^:private default-context-options
|
||||
#js {:antialias false
|
||||
@ -1846,7 +1873,7 @@
|
||||
|
||||
;; Cancel any pending animation frame to prevent race conditions.
|
||||
(when wasm/internal-frame-id
|
||||
(js/cancelAnimationFrame wasm/internal-frame-id))
|
||||
(timers/cancel-af! wasm/internal-frame-id))
|
||||
|
||||
;; Reset render flags to prevent new renders from being scheduled.
|
||||
(reset! pending-render false)
|
||||
|
||||
@ -331,6 +331,18 @@
|
||||
([document ^js text]
|
||||
(.createTextNode document text)))
|
||||
|
||||
(defn escape-html
|
||||
"Escapes special HTML characters in a string so that it can be safely used
|
||||
as innerHTML without risk of XSS."
|
||||
[^js text]
|
||||
(when (some? text)
|
||||
(-> text
|
||||
(str/replace "&" "&")
|
||||
(str/replace "<" "<")
|
||||
(str/replace ">" ">")
|
||||
(str/replace "\"" """)
|
||||
(str/replace "'" "'"))))
|
||||
|
||||
(defn set-html!
|
||||
[^js el html]
|
||||
(when (some? el)
|
||||
|
||||
@ -65,11 +65,20 @@
|
||||
#(.requestAnimationFrame js/globalThis %)
|
||||
#(js/setTimeout % 16)))
|
||||
|
||||
(def ^:private cancel-animation-frame
|
||||
(if (and (exists? js/globalThis)
|
||||
(exists? (.-cancelAnimationFrame js/globalThis)))
|
||||
#(.cancelAnimationFrame js/globalThis %)
|
||||
#(js/clearTimeout %)))
|
||||
|
||||
(defn raf
|
||||
[f]
|
||||
(^function request-animation-frame f))
|
||||
|
||||
(defn cancel-af!
|
||||
[frame-id]
|
||||
(^function cancel-animation-frame frame-id))
|
||||
|
||||
(defn idle-then-raf
|
||||
[f]
|
||||
(schedule-on-idle #(^function raf f)))
|
||||
|
||||
|
||||
@ -165,6 +165,15 @@
|
||||
(js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_base64")
|
||||
""))))
|
||||
|
||||
(defn ^:export wasmSurfaceConsole
|
||||
"Logs the render-wasm surface id as an image in the JS console."
|
||||
[id]
|
||||
(let [module wasm/internal-module
|
||||
f (when module (unchecked-get module "_debug_surface_console"))]
|
||||
(if (fn? f)
|
||||
(wasm.h/call module "_debug_surface_console" id)
|
||||
(js/console.warn "[debug] render-wasm module not ready or missing _debug_surface_console"))))
|
||||
|
||||
(defn ^:export wasmCacheConsole
|
||||
"Logs the current render-wasm cache surface as an image in the JS console."
|
||||
[]
|
||||
|
||||
@ -1544,6 +1544,10 @@ msgstr "Invalid text"
|
||||
msgid "errors.team-name-invalid-chars"
|
||||
msgstr "The team name can't contain any of the following characters:'.', ':' or '/'"
|
||||
|
||||
#: common/src/app/common/types/font.cljc
|
||||
msgid "errors.font-family-invalid-chars"
|
||||
msgstr "The font family name can only contain letters, numbers, spaces, hyphens, underscores, and dots."
|
||||
|
||||
#: src/app/main/ui/static.cljs:74
|
||||
msgid "errors.invite-invalid"
|
||||
msgstr "Invite invalid"
|
||||
|
||||
@ -9,12 +9,12 @@
|
||||
|
||||
:aliases
|
||||
{:outdated
|
||||
{:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"}}
|
||||
{:extra-deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}}
|
||||
:main-opts ["-m" "antq.core"]}
|
||||
|
||||
:jvm-repl
|
||||
{:extra-deps
|
||||
{com.bhauman/rebel-readline {:mvn/version "RELEASE"}}
|
||||
{com.bhauman/rebel-readline {:mvn/version "0.1.5"}}
|
||||
:main-opts ["-m" "rebel-readline.main"]
|
||||
:jvm-opts ["--sun-misc-unsafe-memory-access=allow"]}
|
||||
|
||||
@ -22,9 +22,9 @@
|
||||
{:extra-paths ["dev"]
|
||||
:extra-deps
|
||||
{thheller/shadow-cljs {:mvn/version "3.2.1"}
|
||||
com.bhauman/rebel-readline {:mvn/version "RELEASE"}
|
||||
org.clojure/tools.namespace {:mvn/version "RELEASE"}
|
||||
criterium/criterium {:mvn/version "RELEASE"}}}
|
||||
com.bhauman/rebel-readline {:mvn/version "0.1.5"}
|
||||
org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||
criterium/criterium {:mvn/version "0.4.6"}}}
|
||||
|
||||
:shadow-cljs
|
||||
{:main-opts ["-m" "shadow.cljs.devtools.cli"]
|
||||
|
||||
@ -1,61 +1,73 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::error::{Error, Result, CRITICAL_ERROR};
|
||||
use crate::{error::Result, performance};
|
||||
|
||||
pub const LAYOUT_ALIGN: usize = 4;
|
||||
|
||||
pub static BUFFERU8: Mutex<Option<Vec<u8>>> = Mutex::new(None);
|
||||
pub static BUFFER_ERROR: Mutex<u8> = Mutex::new(0x00);
|
||||
// Please, read about the #[allow(static_mut_refs)]
|
||||
//
|
||||
// If we don't put this allow, the compiler shows a warning like this:
|
||||
//
|
||||
// shared references to mutable statics are dangerous; it's undefined behavior
|
||||
// if the static is mutated or if a mutable reference is created for it while
|
||||
// the shared reference lives
|
||||
//
|
||||
// https://doc.rust-lang.org/edition-guide/rust-2024/static-mut-references.html
|
||||
//
|
||||
// But this isn't a problem in a single-threaded environment like WebAssembly
|
||||
// because access/modification is always sequential, not parallel.
|
||||
pub static mut BUFFERU8: Option<Vec<u8>> = None;
|
||||
pub static mut BUFFER_ERROR: u8 = 0x00;
|
||||
|
||||
pub fn clear_error_code() {
|
||||
let mut guard = BUFFER_ERROR.lock().unwrap();
|
||||
*guard = 0x00;
|
||||
unsafe {
|
||||
BUFFER_ERROR = 0x00;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the error buffer from a byte. Used by #[wasm_error] when E: Into<u8>.
|
||||
pub fn set_error_code(code: u8) {
|
||||
let mut guard = BUFFER_ERROR.lock().unwrap();
|
||||
*guard = code;
|
||||
unsafe {
|
||||
BUFFER_ERROR = code;
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn read_error_code() -> u8 {
|
||||
if let Ok(guard) = BUFFER_ERROR.lock() {
|
||||
*guard
|
||||
} else {
|
||||
CRITICAL_ERROR
|
||||
}
|
||||
unsafe { BUFFER_ERROR }
|
||||
}
|
||||
|
||||
pub fn write_bytes(mut bytes: Vec<u8>) -> *mut u8 {
|
||||
let mut guard = BUFFERU8.lock().unwrap();
|
||||
unsafe {
|
||||
performance::begin_measure!("write_bytes");
|
||||
#[allow(static_mut_refs)]
|
||||
if BUFFERU8.is_some() {
|
||||
panic!("Bytes already allocated");
|
||||
}
|
||||
|
||||
if guard.is_some() {
|
||||
panic!("Bytes already allocated");
|
||||
let ptr = bytes.as_mut_ptr();
|
||||
BUFFERU8 = Some(bytes);
|
||||
performance::end_measure!("write_bytes");
|
||||
ptr
|
||||
}
|
||||
|
||||
let ptr = bytes.as_mut_ptr();
|
||||
|
||||
*guard = Some(bytes);
|
||||
ptr
|
||||
}
|
||||
|
||||
pub fn bytes() -> Vec<u8> {
|
||||
let mut guard = BUFFERU8.lock().unwrap();
|
||||
guard.take().expect("Buffer is not initialized")
|
||||
unsafe {
|
||||
#[allow(static_mut_refs)]
|
||||
BUFFERU8.take().expect("Buffer is not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bytes_or_empty() -> Vec<u8> {
|
||||
let mut guard = BUFFERU8.lock().unwrap();
|
||||
guard.take().unwrap_or_default()
|
||||
unsafe {
|
||||
#[allow(static_mut_refs)]
|
||||
BUFFERU8.take().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn free_bytes() -> Result<()> {
|
||||
let mut guard = BUFFERU8
|
||||
.lock()
|
||||
.map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?;
|
||||
*guard = None;
|
||||
std::mem::drop(guard);
|
||||
unsafe {
|
||||
BUFFERU8 = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -381,7 +381,7 @@ pub(crate) struct RenderState {
|
||||
/// interactive backdrop exactly once per gesture (first rAF) so we don't
|
||||
/// repeatedly overwrite tiles that have already been updated.
|
||||
pub interactive_target_seeded: bool,
|
||||
/// GPU crops from `Backbuffer` keyed by shape id. Filled on full-frame completion; during
|
||||
/// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during
|
||||
/// drag, entries for the moved top-level selection are ensured here
|
||||
pub backbuffer_crop_cache: HashMap<Uuid, InteractiveDragCrop>,
|
||||
}
|
||||
@ -389,9 +389,6 @@ pub(crate) struct RenderState {
|
||||
pub struct InteractiveDragCrop {
|
||||
pub src_doc_bounds: Rect,
|
||||
pub src_selrect: Rect,
|
||||
/// True if the captured crop bounds were fully inside the viewport at capture time.
|
||||
/// Used to avoid serving partial/offscreen crops during interactive drag.
|
||||
pub fits_viewport_at_capture: bool,
|
||||
/// Viewbox origin (doc-space) at capture time.
|
||||
pub capture_vb_left: f32,
|
||||
pub capture_vb_top: f32,
|
||||
@ -401,6 +398,49 @@ pub struct InteractiveDragCrop {
|
||||
pub image: skia::Image,
|
||||
}
|
||||
|
||||
/// Chooses a window inside the full workspace-pixel crop `[0, out_w) × [0, out_h)` with each side
|
||||
/// at most `max_side_px` (**without scaling**): centered on the projection of
|
||||
/// `viewport_doc ∩ src_doc_bounds`, or on the full crop if that intersection is empty.
|
||||
/// `max_side_px` should match [`Surfaces::max_texture_dimension_px`] (same budget as the atlas).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drag_crop_snapshot_window_px(
|
||||
max_side_px: i32,
|
||||
out_w: i32,
|
||||
out_h: i32,
|
||||
viewport_doc: Rect,
|
||||
vb_left: f32,
|
||||
vb_top: f32,
|
||||
scale: f32,
|
||||
src_left_px: i32,
|
||||
src_top_px: i32,
|
||||
src_doc_bounds: Rect,
|
||||
) -> (i32, i32, i32, i32) {
|
||||
let cap = max_side_px.max(1);
|
||||
if out_w <= cap && out_h <= cap {
|
||||
return (0, 0, out_w, out_h);
|
||||
}
|
||||
let win_w = out_w.min(cap);
|
||||
let win_h = out_h.min(cap);
|
||||
|
||||
let mut vis = viewport_doc;
|
||||
let has_vis = vis.intersect(src_doc_bounds);
|
||||
let (cx, cy) = if !has_vis || vis.is_empty() {
|
||||
(out_w as f32 * 0.5, out_h as f32 * 0.5)
|
||||
} else {
|
||||
let lx0 = (vis.left - vb_left) * scale - src_left_px as f32;
|
||||
let ly0 = (vis.top - vb_top) * scale - src_top_px as f32;
|
||||
let lx1 = (vis.right - vb_left) * scale - src_left_px as f32;
|
||||
let ly1 = (vis.bottom - vb_top) * scale - src_top_px as f32;
|
||||
((lx0 + lx1) * 0.5, (ly0 + ly1) * 0.5)
|
||||
};
|
||||
|
||||
let mut ox = (cx - win_w as f32 * 0.5).round() as i32;
|
||||
let mut oy = (cy - win_h as f32 * 0.5).round() as i32;
|
||||
ox = ox.clamp(0, out_w - win_w);
|
||||
oy = oy.clamp(0, out_h - win_h);
|
||||
(ox, oy, win_w, win_h)
|
||||
}
|
||||
|
||||
pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize {
|
||||
// First we retrieve the extended area of the viewport that we could render.
|
||||
let TileRect(isx, isy, iex, iey) =
|
||||
@ -458,10 +498,7 @@ impl RenderState {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(crop) = self.backbuffer_crop_cache.get(&node_id) else {
|
||||
return false;
|
||||
};
|
||||
if !crop.fits_viewport_at_capture {
|
||||
if !self.backbuffer_crop_cache.contains_key(&node_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -804,10 +841,27 @@ impl RenderState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) {
|
||||
self.surfaces.flush(SurfaceId::Backbuffer);
|
||||
}
|
||||
|
||||
pub fn flush_and_submit(&mut self) {
|
||||
self.surfaces.flush_and_submit(SurfaceId::Target);
|
||||
}
|
||||
|
||||
/// Copy the clean (no UI overlay) Backbuffer to Target, draw UI/debug overlays
|
||||
/// on top of Target, then present. Backbuffer is left clean so it can be reused
|
||||
/// as-is across interactive-transform frames without stale overlay pixels.
|
||||
pub fn present_frame(&mut self, tree: ShapesPoolRef) {
|
||||
self.surfaces.copy_backbuffer_to_target();
|
||||
if self.options.is_debug_visible() {
|
||||
debug::render(self);
|
||||
}
|
||||
ui::render(self, tree);
|
||||
debug::render_wasm_label(self);
|
||||
self.surfaces.flush_and_submit(SurfaceId::Target);
|
||||
}
|
||||
|
||||
pub fn reset_canvas(&mut self) {
|
||||
self.surfaces.reset(self.background_color);
|
||||
}
|
||||
@ -816,7 +870,7 @@ impl RenderState {
|
||||
/// This is currently not being used, but it's set there for testing purposes on
|
||||
/// upcoming tasks
|
||||
pub fn render_loading_overlay(&mut self) {
|
||||
let canvas = self.surfaces.canvas(SurfaceId::Target);
|
||||
let canvas = self.surfaces.canvas(SurfaceId::Backbuffer);
|
||||
let skia::ISize { width, height } = canvas.base_layer_size();
|
||||
|
||||
canvas.save();
|
||||
@ -863,8 +917,11 @@ impl RenderState {
|
||||
// the interaction ends.
|
||||
if self.options.is_interactive_transform() {
|
||||
let tile_rect = self.get_current_aligned_tile_bounds()?;
|
||||
self.surfaces
|
||||
.draw_current_tile_direct_target_only(&tile_rect, self.background_color);
|
||||
self.surfaces.draw_current_tile_direct(
|
||||
&tile_rect,
|
||||
self.background_color,
|
||||
surfaces::DrawOnCache::No,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@ -879,10 +936,12 @@ impl RenderState {
|
||||
// In fast mode the viewport is moving (pan/zoom) so Cache surface
|
||||
// positions would be wrong — only save to the tile HashMap.
|
||||
let tile_rect = self.get_current_aligned_tile_bounds()?;
|
||||
|
||||
let current_tile = *self
|
||||
.current_tile
|
||||
.as_ref()
|
||||
.ok_or(Error::CriticalError("Current tile not found".to_string()))?;
|
||||
|
||||
self.surfaces.cache_current_tile_texture(
|
||||
&self.tile_viewbox,
|
||||
¤t_tile,
|
||||
@ -1644,10 +1703,12 @@ impl RenderState {
|
||||
self.backbuffer_crop_cache.clear();
|
||||
|
||||
// Collect candidate shapes that are "recortable" and visible in the current viewport.
|
||||
|
||||
// This is intentionally conservative; we only cache shapes that do not overlap with
|
||||
// ANY other candidate to guarantee the pixels under their bounds belong exclusively
|
||||
// to that shape in Backbuffer.
|
||||
let viewport = self.viewbox.area;
|
||||
let scale = self.get_scale();
|
||||
let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect)
|
||||
|
||||
let root_ids: Vec<Uuid> = match tree.get(&Uuid::nil()) {
|
||||
@ -1678,23 +1739,57 @@ impl RenderState {
|
||||
}
|
||||
|
||||
// Filter out any candidate that overlaps with any other candidate.
|
||||
let mut non_overlapping: Vec<(Uuid, Rect, Rect)> = Vec::new();
|
||||
'outer: for (i, (id, bounds, selrect)) in candidates.iter().enumerate() {
|
||||
for (j, (_id2, bounds2, _sel2)) in candidates.iter().enumerate() {
|
||||
if i == j {
|
||||
continue;
|
||||
// Sort by left edge so the inner loop can break early once no further
|
||||
// x-overlap is possible, reducing comparisons from O(N²) to O(N log N)
|
||||
// in typical layouts where shapes are spread out.
|
||||
candidates.sort_unstable_by(|a, b| {
|
||||
a.1.left
|
||||
.partial_cmp(&b.1.left)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
let n = candidates.len();
|
||||
let mut is_overlapping = vec![false; n];
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
if candidates[j].1.left >= candidates[i].1.right {
|
||||
break; // sorted: no further x-overlap possible for i
|
||||
}
|
||||
if bounds.intersects(*bounds2) {
|
||||
continue 'outer;
|
||||
if is_overlapping[i] && is_overlapping[j] {
|
||||
continue; // both already excluded, skip check
|
||||
}
|
||||
if candidates[i].1.intersects(candidates[j].1) {
|
||||
is_overlapping[i] = true;
|
||||
is_overlapping[j] = true;
|
||||
}
|
||||
}
|
||||
non_overlapping.push((*id, *bounds, *selrect));
|
||||
}
|
||||
let non_overlapping: Vec<(Uuid, Rect, Rect)> = candidates
|
||||
.iter()
|
||||
.zip(is_overlapping.iter())
|
||||
.filter_map(|((id, bounds, selrect), ov)| {
|
||||
if !ov {
|
||||
Some((*id, *bounds, *selrect))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Snapshot from Backbuffer for each accepted shape.
|
||||
let scale = self.get_scale();
|
||||
let vb_left = self.viewbox.area.left;
|
||||
let vb_top = self.viewbox.area.top;
|
||||
let (bb_w, bb_h) = self.surfaces.surface_size(SurfaceId::Backbuffer);
|
||||
let max_snap_px = self.surfaces.max_texture_dimension_px();
|
||||
|
||||
// Snapshot the atlas once for the whole pass so that all shapes sharing
|
||||
// the tile/atlas fallback path reuse the same GPU image rather than each
|
||||
// triggering a separate `image_snapshot` flush.
|
||||
let atlas_snap = self.surfaces.atlas_snapshot_for_drag_crop();
|
||||
|
||||
// Scratch surface reused across all shapes that need the tile/atlas
|
||||
// fallback — avoids one WebGL texture allocation per shape.
|
||||
// Created lazily on first use and grown if a later shape needs more space.
|
||||
let mut scratch_surface: Option<skia::Surface> = None;
|
||||
|
||||
for (id, doc_bounds, selrect) in non_overlapping {
|
||||
let left = ((doc_bounds.left - vb_left) * scale).floor() as i32;
|
||||
let top = ((doc_bounds.top - vb_top) * scale).floor() as i32;
|
||||
@ -1704,12 +1799,6 @@ impl RenderState {
|
||||
continue;
|
||||
}
|
||||
let src_irect = skia::IRect::new(left, top, right, bottom);
|
||||
let Some(image) = self
|
||||
.surfaces
|
||||
.snapshot_rect(SurfaceId::Backbuffer, src_irect)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let src_doc_bounds = Rect::new(
|
||||
src_irect.left as f32 / scale + vb_left,
|
||||
@ -1717,20 +1806,92 @@ impl RenderState {
|
||||
src_irect.right as f32 / scale + vb_left,
|
||||
src_irect.bottom as f32 / scale + vb_top,
|
||||
);
|
||||
let fits_viewport_at_capture = doc_bounds.left >= viewport.left
|
||||
&& doc_bounds.top >= viewport.top
|
||||
&& doc_bounds.right <= viewport.right
|
||||
&& doc_bounds.bottom <= viewport.bottom;
|
||||
|
||||
let full_w = src_irect.width();
|
||||
let full_h = src_irect.height();
|
||||
let (win_ox, win_oy, win_w, win_h) = drag_crop_snapshot_window_px(
|
||||
max_snap_px,
|
||||
full_w,
|
||||
full_h,
|
||||
viewport,
|
||||
vb_left,
|
||||
vb_top,
|
||||
scale,
|
||||
src_irect.left,
|
||||
src_irect.top,
|
||||
src_doc_bounds,
|
||||
);
|
||||
let window_irect = skia::IRect::new(
|
||||
src_irect.left + win_ox,
|
||||
src_irect.top + win_oy,
|
||||
src_irect.left + win_ox + win_w,
|
||||
src_irect.top + win_oy + win_h,
|
||||
);
|
||||
|
||||
let src_doc_window = Rect::new(
|
||||
window_irect.left as f32 / scale + vb_left,
|
||||
window_irect.top as f32 / scale + vb_top,
|
||||
window_irect.right as f32 / scale + vb_left,
|
||||
window_irect.bottom as f32 / scale + vb_top,
|
||||
);
|
||||
|
||||
let in_backbuffer = window_irect.left >= 0
|
||||
&& window_irect.top >= 0
|
||||
&& window_irect.right <= bb_w
|
||||
&& window_irect.bottom <= bb_h;
|
||||
|
||||
let backbuffer_snap = if in_backbuffer {
|
||||
self.surfaces
|
||||
.snapshot_rect(SurfaceId::Backbuffer, window_irect)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let image = if let Some(img) = backbuffer_snap {
|
||||
img
|
||||
} else {
|
||||
// Ensure the scratch surface is large enough for this window.
|
||||
// Grow (reallocate) only when necessary so that the common case
|
||||
// of similarly-sized shapes pays zero extra allocation cost.
|
||||
let needs_alloc = scratch_surface
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.width() < win_w || s.height() < win_h);
|
||||
if needs_alloc {
|
||||
scratch_surface = get_gpu_state()
|
||||
.create_surface_with_isize(
|
||||
"drag_crop_scratch".to_string(),
|
||||
skia::ISize::new(win_w, win_h),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
let Some(scratch) = scratch_surface.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let Some(img) = self.surfaces.try_snapshot_doc_rect_from_tiles_and_atlas(
|
||||
scratch,
|
||||
atlas_snap.as_ref(),
|
||||
src_doc_window,
|
||||
window_irect,
|
||||
win_w,
|
||||
win_h,
|
||||
vb_left,
|
||||
vb_top,
|
||||
scale,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
img
|
||||
};
|
||||
|
||||
self.backbuffer_crop_cache.insert(
|
||||
id,
|
||||
InteractiveDragCrop {
|
||||
src_doc_bounds,
|
||||
src_doc_bounds: src_doc_window,
|
||||
src_selrect: selrect,
|
||||
fits_viewport_at_capture,
|
||||
capture_vb_left: vb_left,
|
||||
capture_vb_top: vb_top,
|
||||
capture_src_left: src_irect.left,
|
||||
capture_src_top: src_irect.top,
|
||||
capture_src_left: window_irect.left,
|
||||
capture_src_top: window_irect.top,
|
||||
image,
|
||||
},
|
||||
);
|
||||
@ -1749,16 +1910,9 @@ impl RenderState {
|
||||
// and drawing from it avoids mixing a partially-updated Cache surface with missing tiles.
|
||||
if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() {
|
||||
self.surfaces
|
||||
.draw_atlas_to_target(self.viewbox, self.options.dpr, bg_color);
|
||||
.draw_atlas_to_backbuffer(self.viewbox, self.options.dpr, bg_color);
|
||||
|
||||
if self.options.is_debug_visible() {
|
||||
debug::render(self);
|
||||
}
|
||||
|
||||
ui::render(self, shapes);
|
||||
debug::render_wasm_label(self);
|
||||
|
||||
self.flush_and_submit();
|
||||
self.present_frame(shapes);
|
||||
performance::end_measure!("render_from_cache");
|
||||
performance::end_timed_log!("render_from_cache", _start);
|
||||
return;
|
||||
@ -1817,19 +1971,13 @@ impl RenderState {
|
||||
if !cache_covers {
|
||||
// Early return only if atlas exists; otherwise keep cache path.
|
||||
if self.surfaces.has_atlas() {
|
||||
self.surfaces.draw_atlas_to_target(
|
||||
self.surfaces.draw_atlas_to_backbuffer(
|
||||
self.viewbox,
|
||||
self.options.dpr,
|
||||
bg_color,
|
||||
);
|
||||
|
||||
if self.options.is_debug_visible() {
|
||||
debug::render(self);
|
||||
}
|
||||
|
||||
ui::render(self, shapes);
|
||||
debug::render_wasm_label(self);
|
||||
self.flush_and_submit();
|
||||
self.present_frame(shapes);
|
||||
performance::end_measure!("render_from_cache");
|
||||
performance::end_timed_log!("render_from_cache", _start);
|
||||
return;
|
||||
@ -1839,7 +1987,7 @@ impl RenderState {
|
||||
|
||||
// Setup canvas transform
|
||||
{
|
||||
let canvas = self.surfaces.canvas(SurfaceId::Target);
|
||||
let canvas = self.surfaces.canvas(SurfaceId::Backbuffer);
|
||||
canvas.save();
|
||||
canvas.scale((navigate_zoom, navigate_zoom));
|
||||
canvas.translate((translate_x, translate_y));
|
||||
@ -1847,10 +1995,10 @@ impl RenderState {
|
||||
}
|
||||
|
||||
// Draw directly from cache surface, avoiding snapshot overhead
|
||||
self.surfaces.draw_cache_to_target();
|
||||
self.surfaces.draw_cache_to_backbuffer();
|
||||
|
||||
// Restore canvas state
|
||||
self.surfaces.canvas(SurfaceId::Target).restore();
|
||||
self.surfaces.canvas(SurfaceId::Backbuffer).restore();
|
||||
|
||||
// During pure pan (same zoom), draw tiles from the HashMap
|
||||
// on top of the scaled Cache surface. Cached tile textures
|
||||
@ -1882,14 +2030,7 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
if self.options.is_debug_visible() {
|
||||
debug::render(self);
|
||||
}
|
||||
|
||||
ui::render(self, shapes);
|
||||
debug::render_wasm_label(self);
|
||||
|
||||
self.flush_and_submit();
|
||||
self.present_frame(shapes);
|
||||
}
|
||||
|
||||
performance::end_measure!("render_from_cache");
|
||||
@ -1957,7 +2098,6 @@ impl RenderState {
|
||||
if !self.interactive_target_seeded {
|
||||
// Seed from the last presented frame; this is stable even when
|
||||
// fast_mode skips cache updates and regardless of atlas coverage.
|
||||
self.surfaces.seed_target_from_backbuffer();
|
||||
self.interactive_target_seeded = true;
|
||||
}
|
||||
} else {
|
||||
@ -2017,6 +2157,7 @@ impl RenderState {
|
||||
self.nested_shadows.clear();
|
||||
// reorder by distance to the center.
|
||||
self.current_tile = None;
|
||||
|
||||
self.render_in_progress = true;
|
||||
|
||||
self.apply_drawing_to_render_canvas(None, SurfaceId::Current);
|
||||
@ -2080,38 +2221,28 @@ impl RenderState {
|
||||
timestamp: i32,
|
||||
) -> Result<()> {
|
||||
performance::begin_measure!("process_animation_frame");
|
||||
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
|
||||
|
||||
if self.render_in_progress {
|
||||
if tree.len() != 0 {
|
||||
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
|
||||
}
|
||||
|
||||
// In a pure viewport interaction (pan/zoom), render_from_cache
|
||||
// owns the Target surface — skip flush so we don't present
|
||||
// stale tile positions. The rAF still populates the Cache
|
||||
// surface and tile HashMap so render_from_cache progressively
|
||||
// shows more complete content.
|
||||
//
|
||||
// During interactive shape transforms (drag/resize/rotate) we
|
||||
// still need to flush every rAF so the user sees the updated
|
||||
// shape position — render_from_cache is not in the loop here.
|
||||
if !self.options.is_viewport_interaction() {
|
||||
self.flush_and_submit();
|
||||
}
|
||||
|
||||
if self.render_in_progress {
|
||||
self.cancel_animation_frame();
|
||||
self.render_request_id = Some(wapi::request_animation_frame!());
|
||||
} else {
|
||||
// A full-quality frame is now complete. Refresh Backbuffer and regenerate
|
||||
// the per-shape crop cache so interactive drags can reuse pixels.
|
||||
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
|
||||
self.surfaces.copy_target_to_backbuffer();
|
||||
self.rebuild_backbuffer_crop_cache(tree);
|
||||
}
|
||||
wapi::notify_tiles_render_complete!();
|
||||
performance::end_measure!("render");
|
||||
// Partial frame: just flush GPU work. The display shows the last
|
||||
// fully submitted frame; no need to copy or draw UI overlays here.
|
||||
self.flush();
|
||||
self.cancel_animation_frame();
|
||||
self.render_request_id = Some(wapi::request_animation_frame!());
|
||||
} else {
|
||||
// A full-quality frame is now complete. Rebuild the per-shape crop
|
||||
// cache from the clean Backbuffer (no UI overlay yet) so that
|
||||
// interactive drag backgrounds don't include the grid overlay.
|
||||
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
|
||||
self.rebuild_backbuffer_crop_cache(tree);
|
||||
}
|
||||
// present_frame: copy clean Backbuffer → Target, draw UI/debug
|
||||
// overlays on Target only, then flush. Backbuffer stays overlay-free.
|
||||
self.present_frame(tree);
|
||||
wapi::notify_tiles_render_complete!();
|
||||
performance::end_measure!("render");
|
||||
}
|
||||
|
||||
performance::end_measure!("process_animation_frame");
|
||||
Ok(())
|
||||
}
|
||||
@ -2122,10 +2253,8 @@ impl RenderState {
|
||||
tree: ShapesPoolRef,
|
||||
timestamp: i32,
|
||||
) -> Result<()> {
|
||||
if tree.len() != 0 {
|
||||
self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
|
||||
}
|
||||
self.flush_and_submit();
|
||||
self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
|
||||
self.present_frame(tree);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -3054,7 +3183,10 @@ impl RenderState {
|
||||
|
||||
let x = (doc_left + translation.0) * scale;
|
||||
let y = (doc_top + translation.1) * scale;
|
||||
canvas.draw_image(crop_image, (x, y), Some(&skia::Paint::default()));
|
||||
let bw = crop_image.width() as f32;
|
||||
let bh = crop_image.height() as f32;
|
||||
let dst = skia::Rect::from_xywh(x, y, bw, bh);
|
||||
canvas.draw_image_rect(crop_image, None, dst, &skia::Paint::default());
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
@ -3298,6 +3430,7 @@ impl RenderState {
|
||||
return Ok(());
|
||||
}
|
||||
performance::end_measure!("render_shape_tree::uncached");
|
||||
|
||||
let tile_rect = self.get_current_tile_bounds()?;
|
||||
// Composite if the walker did work in this PAF (`!is_empty`) OR
|
||||
// the tile has unfinished work from a previous PAF
|
||||
@ -3307,8 +3440,11 @@ impl RenderState {
|
||||
if self.options.is_interactive_transform() {
|
||||
// During drag, avoid snapshot-based caching. Draw Current directly
|
||||
// into Target (and Cache) to reduce stalls.
|
||||
self.surfaces
|
||||
.draw_current_tile_direct(&tile_rect, self.background_color);
|
||||
self.surfaces.draw_current_tile_direct(
|
||||
&tile_rect,
|
||||
self.background_color,
|
||||
surfaces::DrawOnCache::Yes,
|
||||
);
|
||||
} else {
|
||||
self.apply_render_to_final_canvas(tile_rect)?;
|
||||
}
|
||||
@ -3321,25 +3457,6 @@ impl RenderState {
|
||||
tile_rect,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.surfaces.apply_mut(SurfaceId::Target as u32, |s| {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(self.background_color);
|
||||
s.canvas().draw_rect(tile_rect, &paint);
|
||||
});
|
||||
// Keep Cache surface coherent for render_from_cache.
|
||||
if !self.options.is_fast_mode() {
|
||||
if !self.cache_cleared_this_render {
|
||||
self.surfaces.clear_cache(self.background_color);
|
||||
self.cache_cleared_this_render = true;
|
||||
}
|
||||
let aligned_rect = self.get_aligned_tile_bounds(current_tile);
|
||||
self.surfaces.apply_mut(SurfaceId::Cache as u32, |s| {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(self.background_color);
|
||||
s.canvas().draw_rect(aligned_rect, &paint);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3412,7 +3529,6 @@ impl RenderState {
|
||||
}
|
||||
|
||||
self.render_in_progress = false;
|
||||
|
||||
self.surfaces.gc();
|
||||
|
||||
// Mark cache as valid for render_from_cache.
|
||||
@ -3427,13 +3543,6 @@ impl RenderState {
|
||||
self.cached_viewbox = self.viewbox;
|
||||
}
|
||||
|
||||
if self.options.is_debug_visible() {
|
||||
debug::render(self);
|
||||
}
|
||||
|
||||
ui::render(self, tree);
|
||||
debug::render_wasm_label(self);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -3497,6 +3606,25 @@ impl RenderState {
|
||||
|
||||
let mut result = HashSet::<tiles::Tile>::with_capacity(old_tiles.len());
|
||||
|
||||
// When the shape has an active modifier (i.e. is being moved/resized),
|
||||
// clear its OLD doc-space extent from the atlas using the raw
|
||||
// (pre-modifier) shape. The per-tile clearing done later via
|
||||
// `clear_tile_in_atlas` only covers tiles tracked in `atlas_tile_doc_rects`
|
||||
// at the current zoom level. However, the atlas may also contain stale
|
||||
// pixels from previous zoom levels (tiles are larger / smaller in doc
|
||||
// space at different zoom scales) that were never re-tracked after a zoom
|
||||
// change. Clearing the full raw extrect here removes all such residual
|
||||
// content without growing the atlas.
|
||||
//
|
||||
// We intentionally skip this when there is NO modifier so that plain
|
||||
// zoom / pan tile-index rebuilds do NOT invalidate valid atlas content.
|
||||
if tree.get_modifier(&shape.id).is_some() {
|
||||
if let Some(raw_shape) = tree.get_raw(&shape.id) {
|
||||
let old_extrect = raw_shape.extrect(tree, 1.0);
|
||||
self.surfaces.clear_doc_rect_in_atlas_clipped(old_extrect);
|
||||
}
|
||||
}
|
||||
|
||||
// First, remove the shape from all tiles where it was previously located
|
||||
for tile in old_tiles {
|
||||
self.tiles.remove_shape_at(tile, shape.id);
|
||||
|
||||
@ -187,8 +187,52 @@ pub fn render_debug_shape(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn trap() {
|
||||
run_script!("debugger");
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum SurfaceBackendKind {
|
||||
BackendTexture, // GPU Framebuffer (Texture)
|
||||
BackendRenderTarget, // GPU Framebuffer (Renderbuffer)
|
||||
Raster, // CPU
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn classify_surface_backend(surface: &mut skia::Surface) -> SurfaceBackendKind {
|
||||
if skia::gpu::surfaces::get_backend_texture(
|
||||
surface,
|
||||
skia_safe::surface::BackendHandleAccess::FlushRead,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
return SurfaceBackendKind::BackendTexture;
|
||||
}
|
||||
|
||||
if skia::gpu::surfaces::get_backend_render_target(
|
||||
surface,
|
||||
skia_safe::surface::BackendHandleAccess::FlushRead,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
return SurfaceBackendKind::BackendRenderTarget;
|
||||
}
|
||||
|
||||
if surface.peek_pixels().is_some() {
|
||||
return SurfaceBackendKind::Raster;
|
||||
}
|
||||
|
||||
SurfaceBackendKind::Unknown
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) {
|
||||
let base64_image = render_state
|
||||
.surfaces
|
||||
@ -198,6 +242,8 @@ pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) {
|
||||
run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')"));
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) {
|
||||
let base64_image = render_state
|
||||
.surfaces
|
||||
@ -258,3 +304,11 @@ pub extern "C" fn debug_atlas_base64() -> Result<()> {
|
||||
console_debug_surface_base64(get_render_state(), SurfaceId::Atlas);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub extern "C" fn debug_surface_console(id: SurfaceId) -> Result<()> {
|
||||
console_debug_surface(get_render_state(), id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -96,14 +96,6 @@ impl RenderOptions {
|
||||
self.interactive_transform = enabled;
|
||||
}
|
||||
|
||||
/// True only when the viewport is the one being moved (pan/zoom)
|
||||
/// and the dedicated `render_from_cache` path owns Target
|
||||
/// presentation. In this mode `process_animation_frame` must not
|
||||
/// flush to avoid presenting stale tile positions.
|
||||
pub fn is_viewport_interaction(&self) -> bool {
|
||||
self.fast_mode && !self.interactive_transform
|
||||
}
|
||||
|
||||
pub fn is_text_editor_v3(&self) -> bool {
|
||||
self.flags & TEXT_EDITOR_V3 == TEXT_EDITOR_V3
|
||||
}
|
||||
|
||||
@ -5,7 +5,10 @@ use crate::{get_gpu_state, performance};
|
||||
|
||||
use skia_safe::{self as skia, IRect, Paint, RRect};
|
||||
|
||||
use super::{gpu_state::GpuState, tiles::Tile, tiles::TileViewbox, tiles::TILE_SIZE};
|
||||
use super::{
|
||||
gpu_state::GpuState,
|
||||
tiles::{self, Tile, TileViewbox, TILE_SIZE},
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@ -26,6 +29,12 @@ const TILE_SIZE_MULTIPLIER: i32 = 2;
|
||||
const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096;
|
||||
const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum DrawOnCache {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
#[repr(u32)]
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
pub enum SurfaceId {
|
||||
@ -177,6 +186,11 @@ impl Surfaces {
|
||||
self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn max_texture_dimension_px(&self) -> i32 {
|
||||
self.max_atlas_texture_size
|
||||
}
|
||||
|
||||
/// Sets the document-space bounds used to clamp atlas updates.
|
||||
/// Pass `None` to disable clamping.
|
||||
pub fn set_atlas_doc_bounds(&mut self, bounds: Option<skia::Rect>) {
|
||||
@ -396,6 +410,58 @@ impl Surfaces {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clears a doc-space rect from the atlas **without** growing it.
|
||||
///
|
||||
/// Unlike [`clear_doc_rect_in_atlas`], this method clips `doc_rect` to the
|
||||
/// current atlas bounds and skips silently if there is no overlap. Use this
|
||||
/// when evicting stale shape content (e.g. before a drag re-render) where
|
||||
/// growing the atlas to accommodate an out-of-range rect would be wasteful.
|
||||
pub fn clear_doc_rect_in_atlas_clipped(&mut self, doc_rect: skia::Rect) {
|
||||
if !self.has_atlas() || doc_rect.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let atlas_scale = self.atlas_scale.max(0.01);
|
||||
let atlas_doc_right = self.atlas_origin.x + (self.atlas_size.width as f32) / atlas_scale;
|
||||
let atlas_doc_bottom = self.atlas_origin.y + (self.atlas_size.height as f32) / atlas_scale;
|
||||
|
||||
// Intersect with current atlas bounds in doc space.
|
||||
let mut clipped = doc_rect;
|
||||
let atlas_bounds = skia::Rect::from_ltrb(
|
||||
self.atlas_origin.x,
|
||||
self.atlas_origin.y,
|
||||
atlas_doc_right,
|
||||
atlas_doc_bottom,
|
||||
);
|
||||
if !clipped.intersect(atlas_bounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply atlas_doc_bounds clamping.
|
||||
if let Some(bounds) = self.atlas_doc_bounds {
|
||||
if !clipped.intersect(bounds) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if clipped.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dst = skia::Rect::from_xywh(
|
||||
(clipped.left - self.atlas_origin.x) * atlas_scale,
|
||||
(clipped.top - self.atlas_origin.y) * atlas_scale,
|
||||
clipped.width() * atlas_scale,
|
||||
clipped.height() * atlas_scale,
|
||||
);
|
||||
|
||||
let canvas = self.atlas.canvas();
|
||||
canvas.save();
|
||||
canvas.clip_rect(dst, None, true);
|
||||
canvas.clear(skia::Color::TRANSPARENT);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
pub fn clear_tiles(&mut self) {
|
||||
self.tiles.clear();
|
||||
}
|
||||
@ -404,16 +470,21 @@ impl Surfaces {
|
||||
self.atlas_size.width > 0 && self.atlas_size.height > 0
|
||||
}
|
||||
|
||||
/// Draw the persistent atlas onto the target using the current viewbox transform.
|
||||
/// Draw the persistent atlas onto the backbuffer using the current viewbox transform.
|
||||
/// Intended for fast pan/zoom-out previews (avoids per-tile composition).
|
||||
/// Clears Target to `background` first so atlas-uncovered regions don't
|
||||
/// Clears Backbuffer to `background` first so atlas-uncovered regions don't
|
||||
/// show stale content when the atlas only partially covers the viewport.
|
||||
pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) {
|
||||
pub fn draw_atlas_to_backbuffer(
|
||||
&mut self,
|
||||
viewbox: Viewbox,
|
||||
dpr: f32,
|
||||
background: skia::Color,
|
||||
) {
|
||||
if !self.has_atlas() {
|
||||
return;
|
||||
}
|
||||
|
||||
let canvas = self.target.canvas();
|
||||
let canvas = self.backbuffer.canvas();
|
||||
canvas.save();
|
||||
canvas.reset_matrix();
|
||||
let size = canvas.base_layer_size();
|
||||
@ -545,6 +616,12 @@ impl Surfaces {
|
||||
self.dirty_surfaces = 0;
|
||||
}
|
||||
|
||||
pub fn flush(&mut self, id: SurfaceId) {
|
||||
let gpu_state = get_gpu_state();
|
||||
let surface = self.get_mut(id);
|
||||
gpu_state.context.flush_surface(surface);
|
||||
}
|
||||
|
||||
pub fn flush_and_submit(&mut self, id: SurfaceId) {
|
||||
let gpu_state = get_gpu_state();
|
||||
let surface = self.get_mut(id);
|
||||
@ -562,12 +639,12 @@ impl Surfaces {
|
||||
);
|
||||
}
|
||||
|
||||
/// Draws the cache surface directly to the target canvas.
|
||||
/// Draws the cache surface directly to the backbuffer canvas.
|
||||
/// This avoids creating an intermediate snapshot, reducing GPU stalls.
|
||||
pub fn draw_cache_to_target(&mut self) {
|
||||
pub fn draw_cache_to_backbuffer(&mut self) {
|
||||
let sampling_options = self.sampling_options;
|
||||
self.cache.clone().draw(
|
||||
self.target.canvas(),
|
||||
self.cache.draw(
|
||||
self.backbuffer.canvas(),
|
||||
(0.0, 0.0),
|
||||
sampling_options,
|
||||
Some(&skia::Paint::default()),
|
||||
@ -663,7 +740,7 @@ impl Surfaces {
|
||||
});
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[inline(always)]
|
||||
pub fn get_mut(&mut self, id: SurfaceId) -> &mut skia::Surface {
|
||||
match id {
|
||||
SurfaceId::Target => &mut self.target,
|
||||
@ -683,6 +760,7 @@ impl Surfaces {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn get(&self, id: SurfaceId) -> &skia::Surface {
|
||||
match id {
|
||||
SurfaceId::Target => &self.target,
|
||||
@ -707,23 +785,23 @@ impl Surfaces {
|
||||
(s.width(), s.height())
|
||||
}
|
||||
|
||||
/// Copy the current `Target` contents into the persistent `Backbuffer`.
|
||||
/// Copy the current `Backbuffer` contents into `Target`.
|
||||
/// This is a GPU→GPU copy via Skia (no ReadPixels).
|
||||
pub fn copy_target_to_backbuffer(&mut self) {
|
||||
pub fn copy_backbuffer_to_target(&mut self) {
|
||||
let sampling_options = self.sampling_options;
|
||||
self.target.clone().draw(
|
||||
self.backbuffer.canvas(),
|
||||
self.backbuffer.draw(
|
||||
self.target.canvas(),
|
||||
(0.0, 0.0),
|
||||
sampling_options,
|
||||
Some(&skia::Paint::default()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed `Target` from `Backbuffer` (last presented frame).
|
||||
pub fn seed_target_from_backbuffer(&mut self) {
|
||||
/// Seed `Backbuffer` from `Target` (last presented frame).
|
||||
pub fn seed_backbuffer_from_target(&mut self) {
|
||||
let sampling_options = self.sampling_options;
|
||||
self.backbuffer.clone().draw(
|
||||
self.target.canvas(),
|
||||
self.target.draw(
|
||||
self.backbuffer.canvas(),
|
||||
(0.0, 0.0),
|
||||
sampling_options,
|
||||
Some(&skia::Paint::default()),
|
||||
@ -749,7 +827,6 @@ impl Surfaces {
|
||||
.target
|
||||
.new_surface_with_dimensions(dim)
|
||||
.ok_or(Error::CriticalError("Failed to create surface".to_string()))?;
|
||||
// The rest are tile size surfaces
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -984,6 +1061,118 @@ impl Surfaces {
|
||||
self.tiles.has(tile)
|
||||
}
|
||||
|
||||
/// Returns a snapshot of the atlas together with its scale and origin, so the
|
||||
/// caller can take it **once** per `rebuild_backbuffer_crop_cache` and share it
|
||||
/// across all shapes that need the tile/atlas fallback path — avoiding an
|
||||
/// `image_snapshot` (and potential GPU flush) per shape.
|
||||
pub fn atlas_snapshot_for_drag_crop(&mut self) -> Option<(skia::Image, f32, skia::Point)> {
|
||||
if !self.has_atlas() {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
self.atlas.image_snapshot(),
|
||||
self.atlas_scale.max(0.01),
|
||||
self.atlas_origin,
|
||||
))
|
||||
}
|
||||
|
||||
/// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into
|
||||
/// `scratch`, then returns the sub-region `[0, out_w) × [0, out_h)` as an image.
|
||||
///
|
||||
/// `scratch` must be at least `out_w × out_h` pixels — the caller is responsible
|
||||
/// for allocating (and **reusing across shapes**) a surface large enough to hold
|
||||
/// the largest window needed in one `rebuild_backbuffer_crop_cache` pass.
|
||||
///
|
||||
/// `atlas_snap` is a pre-snapshotted view of the persistent atlas produced by
|
||||
/// [`Surfaces::atlas_snapshot_for_drag_crop`]; pass `None` when no atlas exists.
|
||||
///
|
||||
/// For each tile cell intersecting `src_doc_bounds`: draws from
|
||||
/// [`TileTextureCache`] when present; otherwise samples the atlas.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn try_snapshot_doc_rect_from_tiles_and_atlas(
|
||||
&mut self,
|
||||
scratch: &mut skia::Surface,
|
||||
atlas_snap: Option<&(skia::Image, f32, skia::Point)>,
|
||||
src_doc_bounds: skia::Rect,
|
||||
src_irect: IRect,
|
||||
out_w: i32,
|
||||
out_h: i32,
|
||||
vb_left: f32,
|
||||
vb_top: f32,
|
||||
scale: f32,
|
||||
) -> Option<skia::Image> {
|
||||
if out_w <= 0 || out_h <= 0 || src_doc_bounds.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let canvas = scratch.canvas();
|
||||
canvas.clear(skia::Color::TRANSPARENT);
|
||||
|
||||
let tile_size = tiles::get_tile_size(scale);
|
||||
let tr = tiles::get_tiles_for_rect(src_doc_bounds, tile_size);
|
||||
let ix0 = src_irect.left as f32;
|
||||
let iy0 = src_irect.top as f32;
|
||||
let paint = skia::Paint::default();
|
||||
|
||||
for ty in tr.y1()..=tr.y2() {
|
||||
for tx in tr.x1()..=tr.x2() {
|
||||
let tile = Tile(tx, ty);
|
||||
let tile_doc = tiles::get_tile_rect(tile, scale);
|
||||
let mut clip_doc = tile_doc;
|
||||
if !clip_doc.intersect(src_doc_bounds) || clip_doc.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dst = skia::Rect::from_ltrb(
|
||||
(clip_doc.left - vb_left) * scale - ix0,
|
||||
(clip_doc.top - vb_top) * scale - iy0,
|
||||
(clip_doc.right - vb_left) * scale - ix0,
|
||||
(clip_doc.bottom - vb_top) * scale - iy0,
|
||||
);
|
||||
|
||||
if let Some(tile_image) = self.tiles.get(tile) {
|
||||
let iw = tile_image.width() as f32;
|
||||
let ih = tile_image.height() as f32;
|
||||
let td_w = tile_doc.width().max(1e-6);
|
||||
let td_h = tile_doc.height().max(1e-6);
|
||||
|
||||
let src = skia::Rect::from_ltrb(
|
||||
((clip_doc.left - tile_doc.left) / td_w) * iw,
|
||||
((clip_doc.top - tile_doc.top) / td_h) * ih,
|
||||
((clip_doc.right - tile_doc.left) / td_w) * iw,
|
||||
((clip_doc.bottom - tile_doc.top) / td_h) * ih,
|
||||
);
|
||||
|
||||
canvas.draw_image_rect(
|
||||
tile_image,
|
||||
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
|
||||
dst,
|
||||
&paint,
|
||||
);
|
||||
} else {
|
||||
let snap = atlas_snap?;
|
||||
let (atlas, a_scale, atlas_origin) = (&snap.0, snap.1, snap.2);
|
||||
let sx = (clip_doc.left - atlas_origin.x) * a_scale;
|
||||
let sy = (clip_doc.top - atlas_origin.y) * a_scale;
|
||||
let sw = clip_doc.width() * a_scale;
|
||||
let sh = clip_doc.height() * a_scale;
|
||||
if sw <= 0.0 || sh <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let src = skia::Rect::from_xywh(sx, sy, sw, sh);
|
||||
canvas.draw_image_rect(
|
||||
atlas,
|
||||
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
|
||||
dst,
|
||||
&paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scratch.image_snapshot_with_bounds(IRect::new(0, 0, out_w, out_h))
|
||||
}
|
||||
|
||||
pub fn remove_cached_tile_surface(&mut self, tile: Tile) {
|
||||
let gpu_state = get_gpu_state();
|
||||
// Mark tile as invalid
|
||||
@ -1000,17 +1189,17 @@ impl Surfaces {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(color);
|
||||
|
||||
self.target.canvas().draw_rect(rect, &paint);
|
||||
self.backbuffer.canvas().draw_rect(rect, &paint);
|
||||
|
||||
self.target
|
||||
self.backbuffer
|
||||
.canvas()
|
||||
.draw_image_rect(&image, None, rect, &skia::Paint::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a cached tile texture to the Cache surface at the given
|
||||
/// Draws a cached tile texture to the Cache self.backbuffer at the given
|
||||
/// cache-aligned rect. This keeps the Cache surface in sync with
|
||||
/// Target so that `render_from_cache` (used during pan) has the
|
||||
/// Backbuffer so that `render_from_cache` (used during pan) has the
|
||||
/// full scene including tiles served from the texture cache.
|
||||
pub fn draw_cached_tile_to_cache(
|
||||
&mut self,
|
||||
@ -1031,53 +1220,14 @@ impl Surfaces {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the current tile directly to the target and cache surfaces without
|
||||
/// Draws the current tile directly to the backbuffer and cache surfaces without
|
||||
/// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't
|
||||
/// populate the tile texture cache (suitable for one-shot renders like tests).
|
||||
pub fn draw_current_tile_direct(&mut self, tile_rect: &skia::Rect, color: skia::Color) {
|
||||
let sampling_options = self.sampling_options;
|
||||
let src_rect = IRect::from_xywh(
|
||||
self.margins.width,
|
||||
self.margins.height,
|
||||
self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width,
|
||||
self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height,
|
||||
);
|
||||
let src_rect_f = skia::Rect::from(src_rect);
|
||||
|
||||
// Draw background
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(color);
|
||||
self.target.canvas().draw_rect(tile_rect, &paint);
|
||||
|
||||
// Draw current surface directly to target (no snapshot)
|
||||
self.current.clone().draw(
|
||||
self.target.canvas(),
|
||||
(
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
),
|
||||
sampling_options,
|
||||
None,
|
||||
);
|
||||
|
||||
// Also draw to cache for render_from_cache
|
||||
self.current.clone().draw(
|
||||
self.cache.canvas(),
|
||||
(
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
),
|
||||
sampling_options,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Same as `draw_current_tile_direct` but draws only into Target.
|
||||
/// Useful during interactive transforms to reduce extra GPU work.
|
||||
pub fn draw_current_tile_direct_target_only(
|
||||
pub fn draw_current_tile_direct(
|
||||
&mut self,
|
||||
tile_rect: &skia::Rect,
|
||||
color: skia::Color,
|
||||
draw_on_cache: DrawOnCache,
|
||||
) {
|
||||
let sampling_options = self.sampling_options;
|
||||
let src_rect = IRect::from_xywh(
|
||||
@ -1088,12 +1238,15 @@ impl Surfaces {
|
||||
);
|
||||
let src_rect_f = skia::Rect::from(src_rect);
|
||||
|
||||
let backbuffer_canvas = self.backbuffer.canvas();
|
||||
// Draw background
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(color);
|
||||
self.target.canvas().draw_rect(tile_rect, &paint);
|
||||
backbuffer_canvas.draw_rect(tile_rect, &paint);
|
||||
|
||||
self.current.clone().draw(
|
||||
self.target.canvas(),
|
||||
// Draw current surface directly to target (no snapshot)
|
||||
self.current.draw(
|
||||
backbuffer_canvas,
|
||||
(
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
@ -1101,6 +1254,19 @@ impl Surfaces {
|
||||
sampling_options,
|
||||
None,
|
||||
);
|
||||
|
||||
// Also draw to cache for render_from_cache
|
||||
if draw_on_cache == DrawOnCache::Yes {
|
||||
self.current.draw(
|
||||
self.cache.canvas(),
|
||||
(
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
),
|
||||
sampling_options,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full cache reset: clears both the tile texture cache and the cache canvas.
|
||||
|
||||
@ -6,21 +6,15 @@ use crate::shapes::{Layout, Type};
|
||||
|
||||
pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
|
||||
let canvas = render_state.surfaces.canvas(SurfaceId::UI);
|
||||
let viewbox = render_state.viewbox;
|
||||
let zoom = viewbox.zoom * render_state.options.dpr;
|
||||
let show_grid_id = render_state.show_grid;
|
||||
|
||||
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
|
||||
canvas.save();
|
||||
|
||||
let viewbox = render_state.viewbox;
|
||||
let zoom = viewbox.zoom * render_state.options.dpr;
|
||||
|
||||
canvas.scale((zoom, zoom));
|
||||
|
||||
canvas.translate((-viewbox.area.left, -viewbox.area.top));
|
||||
|
||||
let canvas = render_state.surfaces.canvas(SurfaceId::UI);
|
||||
|
||||
let show_grid_id = render_state.show_grid;
|
||||
|
||||
if let Some(id) = show_grid_id {
|
||||
if let Some(shape) = shapes.get(&id) {
|
||||
grid_layout::render_overlay(
|
||||
@ -67,6 +61,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
|
||||
render_state.surfaces.draw_into(
|
||||
SurfaceId::UI,
|
||||
SurfaceId::Target,
|
||||
|
||||
@ -1495,6 +1495,8 @@ impl Shape {
|
||||
|
||||
// Outsets (strokes, shadows, blur, children) are translation-invariant,
|
||||
// so the cached extrect can be shifted instead of invalidated.
|
||||
// The bounds cache must always be invalidated so that callers such as
|
||||
// grid_cell_data get the updated position after a drag.
|
||||
if math::is_move_only_matrix(transform) {
|
||||
let tx = transform.translate_x();
|
||||
let ty = transform.translate_y();
|
||||
@ -1506,6 +1508,7 @@ impl Shape {
|
||||
rect.height(),
|
||||
);
|
||||
}
|
||||
self.invalidate_bounds();
|
||||
} else {
|
||||
self.invalidate_extrect();
|
||||
self.invalidate_bounds();
|
||||
|
||||
@ -167,6 +167,7 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn propagate_transform(
|
||||
entry: TransformEntry,
|
||||
pixel_precision: bool,
|
||||
@ -175,6 +176,7 @@ fn propagate_transform(
|
||||
bounds: &mut HashMap<Uuid, Bounds>,
|
||||
modifiers: &mut HashMap<Uuid, Matrix>,
|
||||
reflown: &mut HashSet<Uuid>,
|
||||
reflowed_shapes: &mut HashSet<Uuid>,
|
||||
) -> Result<()> {
|
||||
let Some(shape) = state.shapes.get(&entry.id) else {
|
||||
return Ok(());
|
||||
@ -190,7 +192,11 @@ fn propagate_transform(
|
||||
if !is_close_to(shape_bounds_before.width(), shape_bounds_after.width())
|
||||
|| !is_close_to(shape_bounds_before.height(), shape_bounds_after.height())
|
||||
{
|
||||
if let Type::Text(text_content) = &mut shape.shape_type.clone() {
|
||||
if let Type::Text(text_content) = &shape.shape_type {
|
||||
let width_changed =
|
||||
!is_close_to(shape_bounds_before.width(), shape_bounds_after.width());
|
||||
let height_changed =
|
||||
!is_close_to(shape_bounds_before.height(), shape_bounds_after.height());
|
||||
let resized_selrect = math::Rect::from_xywh(
|
||||
shape.selrect.left(),
|
||||
shape.selrect.top(),
|
||||
@ -199,12 +205,15 @@ fn propagate_transform(
|
||||
);
|
||||
match text_content.grow_type() {
|
||||
GrowType::AutoHeight => {
|
||||
// For auto-height, always update layout when width changes
|
||||
// because the new width affects how text wraps
|
||||
let width_changed =
|
||||
!is_close_to(shape_bounds_before.width(), shape_bounds_after.width());
|
||||
if width_changed || text_content.needs_update_layout() {
|
||||
text_content.update_layout(resized_selrect);
|
||||
let height_before = text_content.size.height;
|
||||
let new_height = if width_changed {
|
||||
let mut clone = text_content.clone();
|
||||
clone.update_layout(resized_selrect);
|
||||
clone.size.height
|
||||
} else {
|
||||
height_before
|
||||
};
|
||||
if !is_close_to(height_before, new_height) && reflowed_shapes.insert(shape.id) {
|
||||
entries.push_back(Modifier::reflow(shape.id, false));
|
||||
|
||||
if let Some(parent_id) = shape.parent_id {
|
||||
@ -215,31 +224,28 @@ fn propagate_transform(
|
||||
}
|
||||
}
|
||||
}
|
||||
let height = text_content.size.height;
|
||||
let resize_transform = math::resize_matrix(
|
||||
&shape_bounds_after,
|
||||
&shape_bounds_after,
|
||||
shape_bounds_after.width(),
|
||||
height,
|
||||
new_height,
|
||||
);
|
||||
shape_bounds_after = shape_bounds_after.transform(&resize_transform);
|
||||
transform.post_concat(&resize_transform);
|
||||
}
|
||||
GrowType::AutoWidth => {
|
||||
// For auto-width, always update layout when height changes
|
||||
// because the new height affects how text flows
|
||||
let height_changed =
|
||||
!is_close_to(shape_bounds_before.height(), shape_bounds_after.height());
|
||||
if height_changed || text_content.needs_update_layout() {
|
||||
text_content.update_layout(resized_selrect);
|
||||
}
|
||||
let width = text_content.width();
|
||||
let height = text_content.size.height;
|
||||
let (new_width, new_height) = if height_changed {
|
||||
let mut clone = text_content.clone();
|
||||
clone.update_layout(resized_selrect);
|
||||
(clone.width(), clone.size.height)
|
||||
} else {
|
||||
(text_content.width(), text_content.size.height)
|
||||
};
|
||||
let resize_transform = math::resize_matrix(
|
||||
&shape_bounds_after,
|
||||
&shape_bounds_after,
|
||||
width,
|
||||
height,
|
||||
new_width,
|
||||
new_height,
|
||||
);
|
||||
shape_bounds_after = shape_bounds_after.transform(&resize_transform);
|
||||
transform.post_concat(&resize_transform);
|
||||
@ -404,6 +410,7 @@ pub fn propagate_modifiers(
|
||||
// In order for loop to eventualy finish, we limit the flex reflow to just
|
||||
// one (the reflown set).
|
||||
while !entries.is_empty() {
|
||||
let mut reflowed_shapes = HashSet::<Uuid>::new();
|
||||
while let Some(modifier) = entries.pop_front() {
|
||||
match modifier {
|
||||
Modifier::Transform(entry, pixel) => propagate_transform(
|
||||
@ -414,6 +421,7 @@ pub fn propagate_modifiers(
|
||||
&mut bounds,
|
||||
&mut modifiers,
|
||||
&mut reflown,
|
||||
&mut reflowed_shapes,
|
||||
)?,
|
||||
Modifier::Reflow(id, force_reflow) => {
|
||||
if force_reflow {
|
||||
|
||||
@ -333,13 +333,26 @@ pub fn calculate_normalized_line_height(
|
||||
normalized_line_height
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextContent {
|
||||
pub paragraphs: Vec<Paragraph>,
|
||||
pub bounds: Rect,
|
||||
pub grow_type: GrowType,
|
||||
pub size: TextContentSize,
|
||||
pub layout: TextContentLayout,
|
||||
content_version: u64,
|
||||
layout_version: u64,
|
||||
layout_width: Option<f32>,
|
||||
}
|
||||
|
||||
impl PartialEq for TextContent {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.paragraphs == other.paragraphs
|
||||
&& self.bounds == other.bounds
|
||||
&& self.grow_type == other.grow_type
|
||||
&& self.size == other.size
|
||||
&& self.layout == other.layout
|
||||
}
|
||||
}
|
||||
|
||||
impl TextContent {
|
||||
@ -350,6 +363,9 @@ impl TextContent {
|
||||
grow_type,
|
||||
size: TextContentSize::default(),
|
||||
layout: TextContentLayout::new(),
|
||||
content_version: 0,
|
||||
layout_version: 0,
|
||||
layout_width: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -362,6 +378,9 @@ impl TextContent {
|
||||
grow_type,
|
||||
size: TextContentSize::new_with_size(bounds.width(), bounds.height()),
|
||||
layout: TextContentLayout::new(),
|
||||
content_version: 0,
|
||||
layout_version: 0,
|
||||
layout_width: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -385,6 +404,7 @@ impl TextContent {
|
||||
|
||||
pub fn add_paragraph(&mut self, paragraph: Paragraph) {
|
||||
self.paragraphs.push(paragraph);
|
||||
self.content_version = self.content_version.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn paragraphs(&self) -> &[Paragraph] {
|
||||
@ -392,6 +412,7 @@ impl TextContent {
|
||||
}
|
||||
|
||||
pub fn paragraphs_mut(&mut self) -> &mut Vec<Paragraph> {
|
||||
self.content_version = self.content_version.wrapping_add(1);
|
||||
&mut self.paragraphs
|
||||
}
|
||||
|
||||
@ -408,7 +429,10 @@ impl TextContent {
|
||||
}
|
||||
|
||||
pub fn set_grow_type(&mut self, grow_type: GrowType) {
|
||||
self.grow_type = grow_type;
|
||||
if self.grow_type != grow_type {
|
||||
self.grow_type = grow_type;
|
||||
self.content_version = self.content_version.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a tight text rect from laid-out Skia paragraphs using glyph
|
||||
@ -891,19 +915,31 @@ impl TextContent {
|
||||
}
|
||||
|
||||
pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize {
|
||||
if !self.layout.needs_update()
|
||||
&& self.layout_version == self.content_version
|
||||
&& self
|
||||
.layout_width
|
||||
.is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON)
|
||||
{
|
||||
return self.size;
|
||||
}
|
||||
|
||||
self.size.set_size(selrect.width(), selrect.height());
|
||||
|
||||
match self.grow_type() {
|
||||
GrowType::AutoHeight => {
|
||||
let result = self.text_layout_auto_height();
|
||||
self.layout_width = Some(result.2.width);
|
||||
self.set_layout_from_result(result, selrect.width(), selrect.height());
|
||||
}
|
||||
GrowType::AutoWidth => {
|
||||
let result = self.text_layout_auto_width();
|
||||
self.layout_width = Some(result.2.width);
|
||||
self.set_layout_from_result(result, selrect.width(), selrect.height());
|
||||
}
|
||||
GrowType::Fixed => {
|
||||
let result = self.text_layout_fixed();
|
||||
self.layout_width = Some(result.2.width);
|
||||
self.set_layout_from_result(result, selrect.width(), selrect.height());
|
||||
}
|
||||
}
|
||||
@ -915,6 +951,7 @@ impl TextContent {
|
||||
self.size.max_width = placeholder_width;
|
||||
}
|
||||
|
||||
self.layout_version = self.content_version;
|
||||
self.size
|
||||
}
|
||||
|
||||
@ -1048,6 +1085,9 @@ impl Default for TextContent {
|
||||
grow_type: GrowType::Fixed,
|
||||
size: TextContentSize::default(),
|
||||
layout: TextContentLayout::new(),
|
||||
content_version: 0,
|
||||
layout_version: 0,
|
||||
layout_width: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,15 +9,22 @@ use macros::wasm_error;
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn alloc_bytes(len: usize) -> Result<*mut u8> {
|
||||
let mut guard = BUFFERU8
|
||||
.lock()
|
||||
.map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?;
|
||||
|
||||
if guard.is_some() {
|
||||
return Err(Error::CriticalError("Bytes already allocated".to_string()));
|
||||
}
|
||||
|
||||
unsafe {
|
||||
// If we don't put this allow, the compiler shows a warning like this:
|
||||
//
|
||||
// shared references to mutable statics are dangerous; it's undefined behavior
|
||||
// if the static is mutated or if a mutable reference is created for it while
|
||||
// the shared reference lives
|
||||
//
|
||||
// https://doc.rust-lang.org/edition-guide/rust-2024/static-mut-references.html
|
||||
//
|
||||
// But this isn't a problem in a single-threaded environment like WebAssembly
|
||||
// because access/modification is always sequential, not parallel.
|
||||
#[allow(static_mut_refs)]
|
||||
if BUFFERU8.is_some() {
|
||||
return Err(Error::CriticalError("Bytes already allocated".to_string()));
|
||||
}
|
||||
|
||||
let layout = Layout::from_size_align_unchecked(len, LAYOUT_ALIGN);
|
||||
let ptr = alloc(layout);
|
||||
if ptr.is_null() {
|
||||
@ -25,7 +32,7 @@ pub extern "C" fn alloc_bytes(len: usize) -> Result<*mut u8> {
|
||||
}
|
||||
// TODO: Maybe this could be removed.
|
||||
ptr::write_bytes(ptr, 0, len);
|
||||
*guard = Some(Vec::from_raw_parts(ptr, len, len));
|
||||
BUFFERU8 = Some(Vec::from_raw_parts(ptr, len, len));
|
||||
Ok(ptr)
|
||||
}
|
||||
}
|
||||
|
||||
208
scripts/check-commit
Executable file
208
scripts/check-commit
Executable file
@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check commit messages against Penpot's commit guidelines.
|
||||
|
||||
Validates commit messages using the rules defined in:
|
||||
- .github/workflows/commit-checker.yml (regex pattern)
|
||||
- CONTRIBUTING.md (formatting rules, subject length, DCO)
|
||||
|
||||
By default, checks HEAD. Use --commit to specify a different commit.
|
||||
|
||||
Usage:
|
||||
./scripts/check-commit
|
||||
./scripts/check-commit --commit HEAD~1
|
||||
./scripts/check-commit -c abc1234
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# ── Emoji list ───────────────────────────────────────────────────────────────
|
||||
# Combined from commit-checker.yml AND CONTRIBUTING.md
|
||||
VALID_EMOJIS = (
|
||||
"lipstick|globe_with_meridians|wrench|books|"
|
||||
"arrow_up|arrow_down|zap|ambulance|construction|"
|
||||
"boom|fire|whale|bug|sparkles|paperclip|tada|"
|
||||
"recycle|rewind|construction_worker|rocket"
|
||||
)
|
||||
|
||||
# ── Regex from .github/workflows/commit-checker.yml ──────────────────────────
|
||||
# Matches:
|
||||
# 1) ":emoji: <Capitalized subject without trailing dot>"
|
||||
# 2) "Merge|Revert|Reapply ... without trailing dot"
|
||||
COMMIT_PATTERN = re.compile(
|
||||
r"^((:(" + VALID_EMOJIS + r"):\s[A-Z].*[^.]))$"
|
||||
)
|
||||
|
||||
MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def run_git(args):
|
||||
"""Run a git command and return (returncode, stdout, stderr)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git"] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except FileNotFoundError:
|
||||
print("ERROR: git not found. Is it installed?", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_commit_message(commit_ref):
|
||||
"""Return the full commit message for *commit_ref*."""
|
||||
rc, out, err = run_git(["log", "--format=%B", "-n", "1", commit_ref])
|
||||
if rc != 0:
|
||||
print(f"ERROR: could not read commit {commit_ref}: {err.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not out.strip():
|
||||
print(f"ERROR: commit {commit_ref} has no message", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return out.rstrip("\n")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Validators
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def check_regex(message):
|
||||
"""Check the commit message against the CI regex pattern."""
|
||||
# Normalise: strip trailing newlines for single-line matching
|
||||
first_line = message.split("\n")[0]
|
||||
|
||||
if MERGE_PATTERN.match(first_line):
|
||||
return True, None
|
||||
|
||||
if COMMIT_PATTERN.match(first_line):
|
||||
return True, None
|
||||
|
||||
return False, (
|
||||
"Commit subject must match one of:\n"
|
||||
" :emoji: <Capitalized subject without trailing dot>\n"
|
||||
" Merge|Revert|Reapply <rest without trailing dot>\n"
|
||||
f"Got: {first_line!r}"
|
||||
)
|
||||
|
||||
|
||||
def check_subject_length(message):
|
||||
"""Subject line must be ≤ 90 characters."""
|
||||
first_line = message.split("\n")[0]
|
||||
if len(first_line) > 90:
|
||||
return False, (
|
||||
f"Subject line exceeds 90 characters ({len(first_line)} chars):\n"
|
||||
f" {first_line}"
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
def check_subject_no_trailing_dot(message):
|
||||
"""Subject line must not end with a period ('.')."""
|
||||
first_line = message.split("\n")[0]
|
||||
if first_line.endswith("."):
|
||||
return False, (
|
||||
"Subject line must not end with a period:\n"
|
||||
f" {first_line}"
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
def check_subject_capitalized(message):
|
||||
"""Subject must be capitalized, but only if it's a regular commit (not Merge/Revert/Reapply)."""
|
||||
first_line = message.split("\n")[0]
|
||||
|
||||
# Skip check for Merge/Revert/Reapply commits
|
||||
if MERGE_PATTERN.match(first_line):
|
||||
return True, None
|
||||
|
||||
# Strip emoji prefix before checking capitalization
|
||||
emoji_match = re.match(r"^:([a-z_]+):\s+(.*)", first_line)
|
||||
if emoji_match:
|
||||
rest = emoji_match.group(2)
|
||||
else:
|
||||
rest = first_line
|
||||
|
||||
if rest and not rest[0].isupper():
|
||||
return False, (
|
||||
"Subject line must start with a capital letter "
|
||||
"(after the emoji prefix):\n"
|
||||
f" {first_line}"
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
def check_body_blank_line(message):
|
||||
"""If a body exists, there must be a blank line between subject and body."""
|
||||
lines = message.split("\n")
|
||||
if len(lines) >= 3 and lines[1] != "":
|
||||
return False, (
|
||||
"A blank line must separate the subject from the body."
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
def check_signed_off_by(message):
|
||||
"""Check for the DCO Signed-off-by line (required for code changes)."""
|
||||
if "Signed-off-by:" not in message:
|
||||
return False, (
|
||||
"Missing 'Signed-off-by:' line in the commit footer.\n"
|
||||
" Add it with 'git commit -s' or append it manually:\n"
|
||||
" Signed-off-by: Your Real Name <your.email@example.com>"
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check a commit message against Penpot commit guidelines."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--commit",
|
||||
default="HEAD",
|
||||
help="Commit to check (default: HEAD)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
commit_ref = args.commit
|
||||
message = get_commit_message(commit_ref)
|
||||
|
||||
print(f"Checking commit {commit_ref} ...\n")
|
||||
|
||||
validators = [
|
||||
("Regex pattern", check_regex),
|
||||
("Subject ≤ 90 chars", check_subject_length),
|
||||
("No trailing period in subject", check_subject_no_trailing_dot),
|
||||
("Subject capitalized", check_subject_capitalized),
|
||||
("Blank line after subject", check_body_blank_line),
|
||||
]
|
||||
|
||||
all_ok = True
|
||||
|
||||
for name, validator in validators:
|
||||
ok, error_msg = validator(message)
|
||||
status = "✓" if ok else "✗"
|
||||
print(f" [{status}] {name}")
|
||||
if not ok:
|
||||
all_ok = False
|
||||
print(f" {error_msg}", file=sys.stderr)
|
||||
|
||||
print()
|
||||
if all_ok:
|
||||
print("All checks passed.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("Some checks FAILED. See messages above.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user