Merge branch 'develop' into niwinz-performance-tests

This commit is contained in:
Andrey Antukh 2026-07-22 11:37:28 +02:00 committed by GitHub
commit 0835c51e11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
151 changed files with 9405 additions and 2494 deletions

View File

@ -62,14 +62,15 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Fmt
working-directory: ./backend
run: |
cljfmt check --parallel=true src/ test/
- name: Lint
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
clj-kondo --parallel --lint ../common/src/ src/
- name: Tests
working-directory: ./backend
@ -81,4 +82,4 @@ jobs:
run: |
mkdir -p /tmp/penpot;
clojure -M:dev:test --reporter kaocha.report/documentation
clojure -M:dev:test

3
.gitignore vendored
View File

@ -9,6 +9,7 @@
.clj-kondo
.cpcache
.lsp
.env
.nrepl-port
.nyc_output
.rebel_readline_history
@ -74,8 +75,6 @@ opencode.json
/frontend/target/
/frontend/test-results/
/frontend/.shadow-cljs
/other/
/scripts/
/nexus/
/tmp/
/vendor/**/target

299
.opencode/plugins/penpot.js Normal file
View File

@ -0,0 +1,299 @@
import { tool } from "@opencode-ai/plugin"
import path from "path"
import { spawn } from "child_process"
const penpotPsqlTool = tool({
description:
"Execute a SQL command against the Penpot database. Uses the defaults from scripts/psql.",
args: {
sql: tool.schema
.string()
.describe("SQL command to execute"),
test: tool.schema
.boolean()
.describe("Use the penpot_test database")
.optional(),
},
async execute(args, context) {
const host = process.env.PENPOT_DB_HOST || "postgres"
const user = process.env.PENPOT_DB_USER || "penpot"
const db = args.test
? "penpot_test"
: process.env.PENPOT_DB_NAME || "penpot"
const password = process.env.PENPOT_DB_PASSWORD || "penpot"
const psqlArgs = ["-h", host, "-U", user, "-d", db, "-c", args.sql]
return new Promise((resolve) => {
let stdout = ""
let stderr = ""
const proc = spawn("psql", psqlArgs, {
cwd: context.worktree,
env: { ...process.env, PGPASSWORD: password },
})
proc.stdout.on("data", (data) => {
stdout += data.toString()
})
proc.stderr.on("data", (data) => {
stderr += data.toString()
})
proc.on("error", (error) => {
resolve(`Error: ${error.message}`)
})
proc.on("close", (exitCode) => {
const output =
exitCode === 0
? stdout.trim() || "Query executed successfully"
: `Error (exit ${exitCode}): ${
(stderr || stdout).trim() || "No error output"
}`
resolve(output)
})
})
},
})
const parenRepairTool = tool({
description:
"Fix mismatched parentheses/braces in Clojure files (.clj, .cljs, .cljc) then reformat with cljfmt.",
args: {
// A string is used instead of an array so OpenCode displays it
// in the generic tool invocation.
files: tool.schema
.string()
.describe(
"Comma-separated file paths to fix, for example: frontend/src/app/config.cljs, backend/src/core.clj",
)
.optional(),
code: tool.schema
.string()
.describe("Code string to fix via stdin")
.optional(),
},
async execute(args, context) {
const script = path.join(context.worktree, "scripts/paren-repair")
const files = args.files
? args.files
.split(",")
.map((file) => file.trim())
.filter(Boolean)
: []
const paramInfo =
files.length > 0
? `files=[${files.join(", ")}]`
: args.code !== undefined
? `code=(${args.code.length} chars)`
: "none"
return new Promise((resolve) => {
const childArgs =
files.length > 0
? [script, ...files]
: [script]
const proc = spawn("bb", childArgs, {
cwd: context.worktree,
})
let stdout = ""
let stderr = ""
proc.stdout.on("data", (data) => {
stdout += data.toString()
})
proc.stderr.on("data", (data) => {
stderr += data.toString()
})
proc.on("error", (error) => {
resolve(`Error: ${error.message}`)
})
proc.on("close", (exitCode) => {
const output =
exitCode === 0
? stdout.trim() || "No changes needed"
: `Error (exit ${exitCode}): ${
(stderr || stdout).trim() || "No error output"
}`
resolve(output)
})
// Close stdin in all cases so the process cannot wait indefinitely.
if (args.code !== undefined) {
proc.stdin.end(args.code)
} else {
proc.stdin.end()
}
})
},
})
export default async function plugin() {
return {
tool: {
"paren-repair": parenRepairTool,
"penpot-psql": penpotPsqlTool,
},
}
}
// import { tool } from "@opencode-ai/plugin"
// import path from "path"
// import { spawn } from "child_process"
// function formatFiles(files) {
// if (files.length === 0) return "stdin"
// // Keep the visible tool title reasonably short.
// if (files.length <= 3) return files.join(", ")
// return `${files.slice(0, 3).join(", ")} (+${files.length - 3} more)`
// }
// const parenRepairTool = tool({
// description:
// "Fix mismatched parentheses/braces in Clojure files, then reformat with cljfmt.",
// args: {
// files: tool.schema
// .array(tool.schema.string())
// .describe("Array of file paths to fix")
// .optional(),
// code: tool.schema
// .string()
// .describe("Code string to fix via stdin")
// .optional(),
// },
// async execute(args, context) {
// const script = path.join(context.worktree, "scripts/paren-repair")
// const files = (args.files ?? []).map((file) => {
// const absolute = path.isAbsolute(file)
// ? file
// : path.resolve(context.worktree, file)
// return path.relative(context.worktree, absolute)
// })
// const targetSummary =
// files.length > 0
// ? formatFiles(files)
// : args.code !== undefined
// ? `stdin (${args.code.length} chars)`
// : "no input"
// // This updates the tool-call title immediately, while it is running.
// await context.metadata({
// title: `Paren repair: ${targetSummary}`,
// metadata: {
// files,
// codeChars: args.code?.length,
// },
// })
// const childArgs =
// args.files && args.files.length > 0
// ? [script, ...args.files]
// : [script]
// return new Promise((resolve) => {
// const proc = spawn("bb", childArgs, {
// cwd: context.worktree,
// })
// let stdout = ""
// let stderr = ""
// if (args.code !== undefined) {
// proc.stdin.end(args.code)
// }
// proc.stdout.on("data", (data) => {
// stdout += data.toString()
// })
// proc.stderr.on("data", (data) => {
// stderr += data.toString()
// })
// proc.on("close", (exitCode) => {
// const successful = exitCode === 0
// const commandOutput = successful
// ? stdout.trim() || "No changes needed"
// : `Error (exit ${exitCode}): ${(stderr || stdout).trim()}`
// const parameterOutput =
// files.length > 0
// ? `Files passed:\n${files.map((file) => `- ${file}`).join("\n")}`
// : args.code !== undefined
// ? `Input passed through stdin: ${args.code.length} characters`
// : "No files or stdin input were passed"
// resolve({
// title: `Paren repair: ${targetSummary}`,
// output: `${parameterOutput}\n\n${commandOutput}`,
// metadata: {
// files,
// codeChars: args.code?.length,
// exitCode,
// successful,
// },
// })
// })
// proc.on("error", (error) => {
// resolve({
// title: `Paren repair failed: ${targetSummary}`,
// output: [
// files.length > 0
// ? `Files passed:\n${files.map((file) => `- ${file}`).join("\n")}`
// : `Input: ${targetSummary}`,
// `Failed to start bb: ${error.message}`,
// ].join("\n\n"),
// metadata: {
// files,
// codeChars: args.code?.length,
// successful: false,
// },
// })
// })
// })
// },
// })
// export default async function plugin() {
// return {
// tool: {
// "paren-repair": parenRepairTool,
// },
// }
// }

View File

@ -1,85 +0,0 @@
---
name: backport-commit
description: Port changes from a specific Git commit to the current branch by manually applying the diff, avoiding cherry-pick when it would introduce complex conflicts.
---
# Backport Commit
Port changes from a specific Git commit to the current branch by manually
applying the diff, avoiding `git cherry-pick` when it would introduce
complex conflicts.
## When to Use
Use this skill whenever the user asks to backport a commit, especially when:
- The commit touches multiple modules or files with significant divergence
- `git cherry-pick` is explicitly ruled out ("do not use cherry-pick")
- The target commit is old enough that conflicts are likely
- The commit introduces both source changes AND new files (tests, etc.)
- You need full control over how each hunk is applied
## Workflow
### 1. Identify the target commit
```bash
# Verify the commit exists and understand what it does
git log --oneline -1 <commit-sha>
# Get the full diff (including new/deleted files)
git show <commit-sha>
# Capture the original commit message for later reuse
git log --format='%B' -1 <commit-sha>
```
### 2. Identify affected modules
From the file paths in the diff, determine which Penpot modules are affected
(frontend, backend, common, render-wasm, etc.) and read their `AGENTS.md`
files **before** making any changes. If a module has no `AGENTS.md`, skip
that step — verify with `ls <module>/AGENTS.md` first.
### 3. Read the current state of each affected file
For every file the diff touches, read the current version on disk to understand
context and ensure correct placement before editing.
### 4. Apply changes manually (the core of this approach)
Process every hunk in the diff using the appropriate tool:
| Diff action | Tool to use |
|-------------|-------------|
| Modify existing file | `edit` — use enough surrounding context in `oldString` to uniquely match the location |
| Add new file | `write` — include proper license header and namespace conventions matching project style |
| Delete file | `bash rm <path>` |
| Rename/move file | `bash mv <old> <new>`, then apply any content changes with `edit` |
> **Tip:** Group nearby hunks from the same file into a single `edit` call.
> Use separate calls when hunks are far apart to keep `oldString` short and
> unambiguous.
Repeat until **all** hunks in the diff are ported.
### 5. Validate
Run **lint**, **check-fmt**, and **tests** for every affected module (see each
module's `AGENTS.md` for the exact commands). If the formatter auto-fixes
indentation, verify the logic is still semantically correct. All checks must
pass before moving on.
### 6. Commit
Ask the `commiter` sub-agent to create a commit. Stage all relevant files
(exclude unrelated untracked files) and provide the original commit message as
a reference, adapting it as needed for the target branch context.
## Key Principles
- **Context matters** — always read files before editing; never guess
indentation or surrounding code
- **Lint + format + test** — never skip validation before committing
- **Preserve intent** — keep the original commit message meaning; the
`commiter` agent handles formatting

View File

@ -1,19 +1,19 @@
---
name: nrepl-eval
description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-eval.mjs CLI tool.
description: Evaluate Clojure code via nREPL using the standalone scripts/nrepl-eval.mjs CLI tool.
---
# nREPL Eval
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
`tools/nrepl-eval.mjs`.
`scripts/nrepl-eval.mjs`.
Full documentation: `mem:tools/nrepl-eval` (file: `.serena/memories/tools/nrepl-eval.md`)
Full documentation: `mem:scripts/nrepl-eval` (file: `.serena/memories/scripts/nrepl-eval.md`)
## Quick Reference
```bash
./tools/nrepl-eval.mjs [options] [<code>]
./scripts/nrepl-eval.mjs [options] [<code>]
```
| Flag | Description | Default |
@ -30,8 +30,8 @@ Full documentation: `mem:tools/nrepl-eval` (file: `.serena/memories/tools/nrepl-
## Examples
```bash
./tools/nrepl-eval.mjs '(+ 1 2 3)'
./tools/nrepl-eval.mjs --backend '(+ 1 2 3)'
./tools/nrepl-eval.mjs --frontend '(js/alert "hi")'
./tools/nrepl-eval.mjs -e
./scripts/nrepl-eval.mjs '(+ 1 2 3)'
./scripts/nrepl-eval.mjs --backend '(+ 1 2 3)'
./scripts/nrepl-eval.mjs --frontend '(js/alert "hi")'
./scripts/nrepl-eval.mjs -e
```

View File

@ -56,7 +56,10 @@ and only weave in Penpot context when it is clearly relevant.
- Ask clarifying questions if the intent is unclear or if critical information
is missing (e.g. target model, expected output format, tone, constraints).
Keep questions concise and grouped. Prefer to ask 14 questions at once
rather than one at a time.
rather than one at a time. **Use the `question` tool** to ask them so the
user gets a structured multi-choice UI; reserve a plain `## Clarifying
questions` markdown section for cases where the `question` tool is
unavailable or the question is genuinely open-ended.
- Rewrite the prompt using prompt-engineering best practices (see below).
- Preserve the user's original intent — do not change the underlying task.
- When the user provides Penpot project context, weave in the relevant
@ -105,10 +108,27 @@ Deliver the result in the response as two clearly separated blocks:
changes you made and why (37 bullets max). Skip the rationale if the
changes are trivial.
If you asked clarifying questions, list them in a separate **Clarifying
questions** section above the refined prompt and stop — do not produce a
refined prompt until the user answers. If the user explicitly told you to
proceed without questions (e.g. "just rewrite it"), make reasonable
If you asked clarifying questions via the `question` tool, stop and wait for
the answers before producing a refined prompt. If the `question` tool was not
available and you asked the questions in chat, list them in a separate
**Clarifying questions** section above the refined prompt and stop — do not
produce a refined prompt until the user answers. If the user explicitly told
you to proceed without questions (e.g. "just rewrite it"), make reasonable
assumptions and note them under **Assumptions made** in the rationale block.
No file persistence — the refined prompt lives entirely in the response.
## File Persistence
Always persist the refined prompt to disk so it can be re-used later, versioned
in git, and shared with other agents. The response still contains the prompt
and rationale blocks; the file is an additional artifact, not a replacement.
- Save the refined prompt (the body inside the fenced code block, **without**
the surrounding ``` fences) to `.opencode/prompts/<descriptive-name>.md`.
- Use a **kebab-case** filename that summarises the task, e.g.
`add-error-reports-management-rpc.md`, `backend-rpc-security-audit.md`. No
spaces, no uppercase, no version numbers or dates in the filename.
- If `.opencode/prompts/` does not exist, create it before writing.
- If a file with the same name already exists, overwrite it (the file is the
refined prompt, not a log).
- Only skip the file write when the user explicitly opts out (e.g. "don't save
this one", "just show it in the chat"). When in doubt, save it.

View File

@ -13,7 +13,7 @@ Fetch information from Taiga public API for the **Penpot** project
## Prerequisites
- `python3` — the `tools/taiga.py` CLI script is self-contained (stdlib only)
- `python3` — the `scripts/taiga.py` CLI script is self-contained (stdlib only)
## Quick Start
@ -21,17 +21,17 @@ The easiest way is to use the bundled Python script:
```bash
# Pass a Taiga URL directly
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714
# Or use "<type> <ref>" syntax
python3 tools/taiga.py us 14128
python3 tools/taiga.py task 13648
python3 scripts/taiga.py us 14128
python3 scripts/taiga.py task 13648
# Add --json for raw output
python3 tools/taiga.py --json issue 13714
python3 scripts/taiga.py --json issue 13714
# See full usage
python3 tools/taiga.py --help
python3 scripts/taiga.py --help
```
## URL Pattern Reference
@ -51,30 +51,30 @@ To extract the **type** and **ref** from a URL:
## Python Script Reference
The `tools/taiga.py` script wraps the Taiga API into a single convenient CLI
The `scripts/taiga.py` script wraps the Taiga API into a single convenient CLI
with sensible defaults.
### Usage
```
python3 tools/taiga.py <taiga-url>
python3 tools/taiga.py <type> <ref>
python3 tools/taiga.py [--json] <taiga-url>
python3 tools/taiga.py [--json] <type> <ref>
python3 scripts/taiga.py <taiga-url>
python3 scripts/taiga.py <type> <ref>
python3 scripts/taiga.py [--json] <taiga-url>
python3 scripts/taiga.py [--json] <type> <ref>
```
### Examples
```bash
# By URL (recommended — no need to think about type/ref)
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714
# By type and ref
python3 tools/taiga.py us 14128
python3 tools/taiga.py task 13648
python3 scripts/taiga.py us 14128
python3 scripts/taiga.py task 13648
# Raw JSON output
python3 tools/taiga.py --json issue 13714
python3 scripts/taiga.py --json issue 13714
```
### Output

View File

@ -20,7 +20,7 @@ primary link, with the fix PR inline on the same line.
- `gh` CLI authenticated (`gh auth status`)
- Python 3.8+
- `tools/gh.py` helper script available
- `scripts/gh.py` helper script available
## Workflow
@ -36,13 +36,13 @@ Use the helper script. It uses GraphQL for efficient single-pass fetching
```bash
# All closed issues (default)
python3 tools/gh.py issues "2.16.0"
python3 scripts/gh.py issues "2.16.0"
# Include open issues too
python3 tools/gh.py issues "2.16.0" --state all
python3 scripts/gh.py issues "2.16.0" --state all
# Exclude entries that should not go in the changelog
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
```
**Exclusion rules (issue-level):**
@ -68,7 +68,7 @@ If updating from an existing `CHANGES.md`, find issues in the milestone that
are NOT yet referenced in the changelog:
```bash
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
```
This returns a filtered JSON array with only the missing issues.
@ -85,23 +85,23 @@ community contribution attribution, or to read the PR body for
```bash
# One or more PR numbers
python3 tools/gh.py prs 9179 9204 9311
python3 scripts/gh.py prs 9179 9204 9311
# From a file
python3 tools/gh.py prs --file prs.txt
python3 scripts/gh.py prs --file prs.txt
# From stdin
cat prs.txt | python3 tools/gh.py prs --stdin
cat prs.txt | python3 scripts/gh.py prs --stdin
```
The `prs` command also supports listing all PRs in a milestone in one call:
```bash
# All merged PRs in a milestone (default)
python3 tools/gh.py prs --milestone "2.16.0"
python3 scripts/gh.py prs --milestone "2.16.0"
# All states (merged, open, closed)
python3 tools/gh.py prs --milestone "2.16.0" --state all
python3 scripts/gh.py prs --milestone "2.16.0" --state all
```
The `prs` command returns JSON with `number`, `title`, `body`, `state`,
@ -113,13 +113,13 @@ You can also list all PRs in a milestone in a single call:
```bash
# All merged PRs in a milestone (default)
python3 tools/gh.py prs --milestone "2.16.0"
python3 scripts/gh.py prs --milestone "2.16.0"
# All states (merged, open, closed)
python3 tools/gh.py prs --milestone "2.16.0" --state all
python3 scripts/gh.py prs --milestone "2.16.0" --state all
# Open PRs only
python3 tools/gh.py prs --milestone "2.16.0" --state open
python3 scripts/gh.py prs --milestone "2.16.0" --state open
```
The milestone path uses paginated GraphQL on the milestone's `pullRequests`
@ -147,6 +147,12 @@ belongs to.
The `gh.py` issues command already includes `issue_type` in every entry's
output. **No separate GraphQL query is needed.**
**Preserve highlighted entries:** If an entry is already featured in
`### :rocket: Epics and highlights`, keep it in that section when refreshing a
changelog version. Do not remove a highlighted entry just because issue type
categorization would otherwise place it under `### :sparkles: New features &
Enhancements`.
**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.
@ -155,7 +161,7 @@ The attribution should reference the **PR author**, not the issue author.
The `prs` subcommand includes the `author` field — use that:
```bash
python3 tools/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
python3 scripts/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
```
Placement in the entry line:
@ -190,7 +196,7 @@ only reference **merged** PRs. Verify before writing:
```bash
# Collect all PR numbers from the candidate entries and check them
python3 tools/gh.py prs <ALL_PR_NUMBERS> | python3 -c "
python3 scripts/gh.py prs <ALL_PR_NUMBERS> | python3 -c "
import json, sys
for pr in json.load(sys.stdin):
if pr['state'] != 'MERGED':
@ -265,7 +271,7 @@ section) or in the candidate set for the current milestone, check:
current milestone since the changelog was last updated (e.g., a fix
arrived late and the issue was reassigned to a future milestone)?
- Verify the issue is still in the current milestone via
`python3 tools/gh.py issues <MILESTONE> --state all`. If it's no
`python3 scripts/gh.py issues <MILESTONE> --state all`. If it's no
longer there, remove the entry from the current section. (If the
target section doesn't exist yet, the entry is simply dropped.)
@ -337,7 +343,7 @@ cross-reference to catch gaps:
```bash
# List all merged PRs in the milestone
python3 tools/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
python3 scripts/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
# Extract PR numbers from the changelog section
python3 -c "
@ -378,7 +384,7 @@ changelog or is legitimately excluded (check its labels).
Also verify that no closed-unmerged PRs remain in the changelog:
```bash
python3 tools/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
python3 scripts/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
import json, sys
data = json.load(sys.stdin)
closed = [p for p in data if p['state'] == 'CLOSED']
@ -483,13 +489,13 @@ def fmt_issue_list(nums):
# --- Fetch milestone data ---
result = subprocess.run(
["python3", "tools/gh.py", "issues", MILESTONE, "--state", "all"],
["python3", "scripts/gh.py", "issues", MILESTONE, "--state", "all"],
capture_output=True, text=True)
all_issues = json.loads(result.stdout)
issue_by_num = {i['number']: i for i in all_issues}
result = subprocess.run(
["python3", "tools/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
["python3", "scripts/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
capture_output=True, text=True)
all_prs = json.loads(result.stdout)
pr_by_num = {p['number']: p for p in all_prs}
@ -728,7 +734,7 @@ self-contained and clickable in any Markdown viewer.
reference if applicable.
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
before making edits, don't rely on cached data.
- **Use `tools/gh.py`.** Prefer the helper script over raw `gh api` calls for
- **Use `scripts/gh.py`.** Prefer the helper script over raw `gh api` calls for
milestone issue listing and PR detail fetching. It handles GraphQL
pagination, batching, and label filtering automatically.
- **Verify PR merge status.** Not all closing PRs are merged — community PRs
@ -739,7 +745,7 @@ self-contained and clickable in any Markdown viewer.
labels. Check both.
- **Cross-reference milestone PRs, not just issues.** The `--compare` flag on
the `issues` command only compares issue numbers. Merged PRs not linked to
any milestone issue can be missed. Use `python3 tools/gh.py prs --milestone`
any milestone issue can be missed. Use `python3 scripts/gh.py prs --milestone`
for a full PR cross-reference.
- **False-positive PR-to-issue associations.** A PR may claim to close an
issue from a different project or context. If the PR title and issue title

View File

@ -39,8 +39,8 @@ Backend RPC command areas without focused memories include access tokens, binfil
Database migrations live in `backend/src/app/migrations/`; pure SQL migrations are under `backend/src/app/migrations/sql/`. SQL filenames conventionally start with a sequence and verb/table description, e.g. `0026-mod-profile-table-add-is-active-field`. Applied migrations are tracked in the `migrations` table.
For interactive PostgreSQL access with correct dev defaults, use `tools/psql`; to dump
the current DDL schema, use `tools/db-schema` (see `mem:tools/psql`).
For interactive PostgreSQL access with correct dev defaults, use `scripts/psql`; to dump
the current DDL schema, use `scripts/db-schema` (see `mem:scripts/psql`).
For deeper details on transaction semantics, advisory locks, Transit vs JSON helpers, and dev/test DB URLs: `mem:backend/rpc-db-worker-subtleties`.
@ -56,14 +56,14 @@ In devenv, backend nREPL is exposed on port 6064.
### Non-interactive eval (preferred for agents)
`./tools/nrepl-eval.mjs` connects to an already-running nREPL server and evaluates code. Session state (defs, `in-ns`) persists across invocations via a stored session ID in `/tmp/penpot-nrepl-session-<host>-<port>`.
`./scripts/nrepl-eval.mjs` connects to an already-running nREPL server and evaluates code. Session state (defs, `in-ns`) persists across invocations via a stored session ID in `/tmp/penpot-nrepl-session-<host>-<port>`.
```bash
./tools/nrepl-eval.mjs '(+ 1 2)' # single expression
./tools/nrepl-eval.mjs "(require '[my.ns :as ns] :reload)" # reload after edits
./tools/nrepl-eval.mjs -e # inspect last exception (*e)
./tools/nrepl-eval.mjs --reset-session '(def x 0)' # discard session, start fresh
./tools/nrepl-eval.mjs <<'EOF' # multi-expression heredoc
./scripts/nrepl-eval.mjs '(+ 1 2)' # single expression
./scripts/nrepl-eval.mjs "(require '[my.ns :as ns] :reload)" # reload after edits
./scripts/nrepl-eval.mjs -e # inspect last exception (*e)
./scripts/nrepl-eval.mjs --reset-session '(def x 0)' # discard session, start fresh
./scripts/nrepl-eval.mjs <<'EOF' # multi-expression heredoc
(def x 10)
(+ x 20)
EOF
@ -92,12 +92,12 @@ Fixtures can populate local data for manual testing/perf work. From the backend
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
* **Linting:** `pnpm run lint` from the repository root.
* **Formatting:** `pnpm run check-fmt`. Use `pnpm run fmt` to fix. Avoid unrelated whitespace diffs.
* **Linting:** `clj-kondo --lint ../common/src/ src/`.
* **Formatting:** `cljfmt check src/ test/` to check, `cljfmt fix src/ test/` to fix. Avoid unrelated whitespace diffs.
**Before linting:** if delimiter errors are suspected (after LLM edits), run
`tools/paren-repair.bb` on the affected files first. Delimiter errors produce
misleading linter/compiler output. See `mem:tools/paren-repair`.
`scripts/paren-repair` on the affected files first. Delimiter errors produce
misleading linter/compiler output. See `mem:scripts/paren-repair`.
## Testing

View File

@ -22,23 +22,23 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
- Align `let` binding values: when a `let` form has multiple bindings spanning
several lines, align the value forms to the same column with spaces.
- If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files,
fix them with `tools/paren-repair.bb` BEFORE running lint/format checks.
See `mem:tools/paren-repair` for usage.
fix them with `scripts/paren-repair` BEFORE running lint/format checks.
See `mem:scripts/paren-repair` for usage.
- Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra.
# Project modules
This is a monorepo. Principles that apply to one module do *not* generally apply to others. Do not make assumptions.
- `frontend/`: ClojureScript + SCSS SPA/design editor.
- `backend/`: JVM Clojure HTTP/RPC server with PostgreSQL, Redis, storage, mail, and workers.Runtime services and the task-queue vs Pub/Sub topology that constrains horizontal scaling: `mem:prod-infra/core`.
- `common/`: shared CLJC data types, geometry, schemas, file/change logic, and utilities.
- `render-wasm/`: Rust -> WebAssembly Skia renderer consumed by frontend.
- `exporter/`: ClojureScript/Node headless Playwright SVG/PDF export.
- `mcp/`: TypeScript Model Context Protocol integration.
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types.
- `library/`: design library workflows.
- `docs/`: documentation site.
- `frontend/`: ClojureScript + SCSS SPA/design editor; core conventions: `mem:frontend/core`.
- `backend/`: JVM Clojure HTTP/RPC server with PostgreSQL, Redis, storage, mail, and workers; core conventions: `mem:backend/core`. Runtime services and the task-queue vs Pub/Sub topology that constrains horizontal scaling: `mem:prod-infra/core`.
- `common/`: shared CLJC data types, geometry, schemas, file/change logic, and utilities; core conventions: `mem:common/core`.
- `render-wasm/`: Rust -> WebAssembly Skia renderer consumed by frontend; core conventions: `mem:render-wasm/core`.
- `exporter/`: ClojureScript/Node headless Playwright SVG/PDF export; core conventions: `mem:exporter/core`.
- `mcp/`: TypeScript Model Context Protocol integration; core conventions: `mem:mcp/core`.
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
- `library/`: design library workflows; core conventions: `mem:library/core`.
- `docs/`: documentation site; core workflow and conventions: `mem:docs/core`.
The memory is structured in a way that you can get the critical information about the
module. You can read it from `mem:<MODULE>/core`
@ -54,18 +54,21 @@ module. You can read it from `mem:<MODULE>/core`
# Dev tools
- `tools/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
- `scripts/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases.
See `mem:tools/nrepl-eval`.
- `tools/paren-repair.bb` — Fix mismatched delimiters in Clojure/CLJS files
See `mem:scripts/nrepl-eval`.
- `scripts/paren-repair` — Fix mismatched delimiters in Clojure/CLJS files
and reformat with cljfmt. Run before lint checks when LLM edits break parens.
See `mem:tools/paren-repair`.
- `tools/psql` — PostgreSQL client wrapper with devenv defaults.
Companion: `tools/db-schema` for DDL dumps. See `mem:tools/psql`.
- `tools/taiga.py` — Fetch public issues, user stories, and tasks from the
Penpot Taiga project without authentication. See `mem:tools/taiga`.
- `tools/gh.py` — GitHub operations helper: list milestone issues, fetch PR
details, compare against CHANGES.md. Requires `gh` CLI. See `mem:tools/gh`.
See `mem:scripts/paren-repair`.
- `scripts/psql` — PostgreSQL client wrapper with devenv defaults.
Companion: `scripts/db-schema` for DDL dumps. See `mem:scripts/psql`.
- `scripts/taiga.py` — Fetch public issues, user stories, and tasks from the
Penpot Taiga project without authentication. See `mem:scripts/taiga`.
- `scripts/gh.py` — GitHub operations helper: list milestone issues, fetch PR
details, compare against CHANGES.md. Requires `gh` CLI. See `mem:scripts/gh`.
- `scripts/error-reports.mjs` — Query error reports via RPC API with token
authentication. Supports list/get operations with filtering and pagination.
See `mem:scripts/error-reports`.
# Dependency graph

View File

@ -1,4 +1,4 @@
# Docs Workflow
# Docs
`docs/`: Penpot documentation site; Eleventy.
@ -16,4 +16,4 @@ From `docs/`:
- Build: `pnpm run build`.
- Watch: `pnpm run watch`.
Documentation changes should follow the existing page structure and rendered Help Center conventions rather than inventing a new style locally.
Documentation changes should follow the existing page structure and rendered Help Center conventions rather than inventing a new style locally.

View File

@ -27,9 +27,9 @@ From `frontend/`:
- Translation formatting after i18n edits: `pnpm run translations`.
**Before linting:** if delimiter errors are suspected (after LLM edits, or
lint/compiler reports syntax errors), run `tools/paren-repair.bb` on the
lint/compiler reports syntax errors), run `scripts/paren-repair` on the
affected files first. Delimiter errors produce misleading linter output.
See `mem:tools/paren-repair`.
See `mem:scripts/paren-repair`.
## Focused memory routing

View File

@ -11,10 +11,10 @@ The latter is needed because syntax errors in parentheses give an uninformative
tool can often find the exact location of such errors.
When delimiter errors are detected (typically from lint or compiler output),
fix the affected files with `tools/paren-repair.bb`. The `clj_check_parentheses`
fix the affected files with `scripts/paren-repair`. The `clj_check_parentheses`
MCP tool can also pinpoint the error location when available, but it is not
required — standard build errors are usually enough.
See `mem:tools/paren-repair`.
See `mem:scripts/paren-repair`.
## Runtime patching with `set!`

View File

@ -1,6 +1,6 @@
# GitHub operations helper
`tools/gh.py` is a multi-purpose CLI for querying the penpot/penpot GitHub
`scripts/gh.py` is a multi-purpose CLI for querying the penpot/penpot GitHub
repository via GraphQL and REST APIs through the authenticated `gh` CLI.
## When to use
@ -23,24 +23,24 @@ List issues in a milestone, with filtering by state, labels, and project status.
```bash
# Closed issues in a milestone (default)
python3 tools/gh.py issues "2.16.0"
python3 scripts/gh.py issues "2.16.0"
# All issues in a milestone
python3 tools/gh.py issues "2.16.0" --state all
python3 scripts/gh.py issues "2.16.0" --state all
# Issues with no milestone
python3 tools/gh.py issues none
python3 tools/gh.py issues none --state open
python3 scripts/gh.py issues none
python3 scripts/gh.py issues none --state open
# Filter by label (include only)
python3 tools/gh.py issues "2.16.0" --label "bug"
python3 tools/gh.py issues "2.16.0" --label "bug,regression"
python3 scripts/gh.py issues "2.16.0" --label "bug"
python3 scripts/gh.py issues "2.16.0" --label "bug,regression"
# Exclude by label
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
# Show only issues NOT yet in CHANGES.md
python3 tools/gh.py issues "2.16.0" --compare CHANGES.md
python3 scripts/gh.py issues "2.16.0" --compare CHANGES.md
```
**Default filters** (override with flags):
@ -55,19 +55,19 @@ Fetch PR details by number or by milestone.
```bash
# Fetch specific PRs
python3 tools/gh.py prs 9179 9204 9311
python3 scripts/gh.py prs 9179 9204 9311
# Read PR numbers from file
python3 tools/gh.py prs --file prs.txt
python3 scripts/gh.py prs --file prs.txt
# Read PR numbers from stdin
cat prs.txt | python3 tools/gh.py prs --stdin
cat prs.txt | python3 scripts/gh.py prs --stdin
# All PRs in a milestone (default: merged only)
python3 tools/gh.py prs --milestone "2.16.0"
python3 scripts/gh.py prs --milestone "2.16.0"
# All PRs in a milestone (all states)
python3 tools/gh.py prs --milestone "2.16.0" --state all
python3 scripts/gh.py prs --milestone "2.16.0" --state all
```
**Output**: JSON array to stdout; progress to stderr.

View File

@ -1,7 +1,7 @@
# nREPL Eval
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
`tools/nrepl-eval.mjs` — a standalone CLI application.
`scripts/nrepl-eval.mjs` — a standalone CLI application.
Session state (defs, in-ns, etc.) persists across invocations via a stored
session ID, so you can build up state incrementally.
@ -9,9 +9,9 @@ session ID, so you can build up state incrementally.
## Usage
```bash
node tools/nrepl-eval.mjs [options] [<code>]
node scripts/nrepl-eval.mjs [options] [<code>]
# or
./tools/nrepl-eval.mjs [options] [<code>]
./scripts/nrepl-eval.mjs [options] [<code>]
```
## Options
@ -47,37 +47,37 @@ Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State
carries across calls automatically:
```bash
./tools/nrepl-eval.mjs '(def x 42)'
./tools/nrepl-eval.mjs 'x'
./scripts/nrepl-eval.mjs '(def x 42)'
./scripts/nrepl-eval.mjs 'x'
# => 42
```
Reset the session to start fresh:
```bash
./tools/nrepl-eval.mjs --reset-session '(def x 0)'
./scripts/nrepl-eval.mjs --reset-session '(def x 0)'
```
### Evaluate code
**Single expression (inline) — uses default port 6064:**
```bash
./tools/nrepl-eval.mjs '(+ 1 2 3)'
./scripts/nrepl-eval.mjs '(+ 1 2 3)'
```
**Backend nREPL (explicit):**
```bash
./tools/nrepl-eval.mjs --backend '(+ 1 2 3)'
./scripts/nrepl-eval.mjs --backend '(+ 1 2 3)'
```
**Frontend nREPL:**
```bash
./tools/nrepl-eval.mjs --frontend '(js/alert "hi")'
./scripts/nrepl-eval.mjs --frontend '(js/alert "hi")'
```
**Multiple expressions via heredoc (recommended — avoids escaping issues):**
```bash
./tools/nrepl-eval.mjs <<'EOF'
./scripts/nrepl-eval.mjs <<'EOF'
(def x 10)
(+ x 20)
EOF
@ -85,7 +85,7 @@ EOF
**Override with a different port:**
```bash
./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
./scripts/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
```
### Inspect last exception
@ -93,26 +93,48 @@ EOF
After code throws an error, retrieve the full exception details:
```bash
./tools/nrepl-eval.mjs -e
./scripts/nrepl-eval.mjs -e
```
## Common Patterns
**Require a namespace with reload:**
```bash
./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
./scripts/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
```
**Test a function:**
```bash
./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
./scripts/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
```
**Long-running operation with custom timeout:**
```bash
./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)"
./scripts/nrepl-eval.mjs -t 300000 "(long-running-fn)"
```
### Accessing Private Functions
Private functions (declared with `^:private` or `defn-`) cannot be called
directly from outside their namespace. Use the var quote syntax `#'` to
access the underlying var:
**This fails:**
```bash
./scripts/nrepl-eval.mjs "(app.rpc.commands.error-reports/build-list-query {})"
# => Syntax error: app.rpc.commands.error-reports/build-list-query is not public
```
**This works:**
```bash
./scripts/nrepl-eval.mjs "(#'app.rpc.commands.error-reports/build-list-query {})"
# => Returns the result
```
The `#'` reader macro resolves to `(var ...)`, giving you direct access to
the var regardless of its visibility modifier. The syntax is `#'` followed
by the fully qualified symbol.
## Key Principles
- **Default port is 6064** — just pass code directly, no `-p` needed when

View File

@ -0,0 +1,43 @@
# Paren-Repair
`scripts/paren-repair` fixes mismatched parentheses, brackets, and braces in
Clojure/ClojureScript files, then reformats them with cljfmt.
## When to use
- After LLM edits introduce broken delimiters — proactively run it on files
you just touched.
- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax
errors mentioning mismatched/unclosed delimiters, reader errors, or
unexpected EOF.
- Before running lint/format checks — delimiter errors make linter output
misleading. Fix them first, then lint.
## How to use (CLI)
```bash
# File mode (in-place fix + format)
bb scripts/paren-repair path/to/file.clj
# Pipe mode (stdin → fixed code to stdout)
echo '(def x 1' | bb scripts/paren-repair
# Help
bb scripts/paren-repair --help
```
`bb` must be invoked from the repo root so the path `scripts/paren-repair` resolves.
## Native Tool Available (opencode)
A native opencode tool `paren-repair` is available at `.opencode/tools/paren-repair.ts`.
The LLM can call it directly with:
- `files`: Array of file paths to fix
- `code`: Code string to fix via stdin
Example usage by the LLM:
```
paren-repair(files="src/foo.clj, src/bar.cljs")
paren-repair(code="(defn foo [x")
```

View File

@ -0,0 +1,39 @@
# Psql
`scripts/psql` is a wrapper around `psql` that connects to the Penpot PostgreSQL
database using environment variables (`PENPOT_DB_HOST`, `PENPOT_DB_USER`,
`PENPOT_DB_PASSWORD`, `PENPOT_DB_NAME`) with sensible defaults for local
development.
## When to use
- Running ad-hoc SQL queries against the Penpot database.
- Inspecting schema, migrations, or data during development or debugging.
## How to use (CLI)
```bash
# Default connection (penpot db, localhost)
scripts/psql -c "SELECT version();"
# Test database
scripts/psql --test -c "SELECT * FROM migrations;"
# Custom host/user/database
scripts/psql --host myhost --user myuser --db mydb
```
`scripts/psql` must be invoked from the repo root so the path resolves.
## Native Tool Available (opencode)
A native opencode tool `penpot-psql` is available. The LLM can call it directly
with:
- `sql`: SQL command string to execute
- `test`: Boolean flag to use the `penpot_test` database
Example usage by the LLM:
```
penpot-psql(sql="SELECT version();")
penpot-psql(sql="SELECT * FROM migrations;", test=true)
```

View File

@ -1,6 +1,6 @@
# Taiga API client
`tools/taiga.py` fetches public issues, user stories, and tasks from the
`scripts/taiga.py` fetches public issues, user stories, and tasks from the
Penpot Taiga project (id 345963) without authentication.
## When to use
@ -13,16 +13,16 @@ Penpot Taiga project (id 345963) without authentication.
```bash
# Fetch by full Taiga URL
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714
# Fetch by type and ref number
python3 tools/taiga.py issue 13714
python3 tools/taiga.py us 14128
python3 tools/taiga.py task 13648
python3 scripts/taiga.py issue 13714
python3 scripts/taiga.py us 14128
python3 scripts/taiga.py task 13648
# Output raw JSON instead of formatted summary
python3 tools/taiga.py --json issue 13714
python3 tools/taiga.py --json https://tree.taiga.io/project/penpot/us/14128
python3 scripts/taiga.py --json issue 13714
python3 scripts/taiga.py --json https://tree.taiga.io/project/penpot/us/14128
```
## Supported types

View File

@ -1,29 +0,0 @@
# Paren-Repair
`tools/paren-repair.bb` fixes mismatched parentheses, brackets, and braces in
Clojure/ClojureScript files, then reformats them with cljfmt.
## When to use
- After LLM edits introduce broken delimiters — proactively run it on files
you just touched.
- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax
errors mentioning mismatched/unclosed delimiters, reader errors, or
unexpected EOF.
- Before running lint/format checks — delimiter errors make linter output
misleading. Fix them first, then lint.
## How to use
```bash
# File mode (in-place fix + format)
bb tools/paren-repair.bb path/to/file.clj
# Pipe mode (stdin → fixed code to stdout)
echo '(def x 1' | bb tools/paren-repair.bb
# Help
bb tools/paren-repair.bb --help
```
`bb` must be invoked from the repo root so the path `tools/paren-repair.bb` resolves.

View File

@ -1,51 +0,0 @@
# PostgreSQL client wrapper
`tools/psql` is a wrapper around the `psql` command with defaults preconfigured
for the Penpot development environment.
## When to use
- Running SQL queries against the dev database (`penpot`) or test database
(`penpot_test`).
- Inspecting table structures, running migrations manually, or debugging
database state.
- Any time you need PostgreSQL access and want the correct host/user/password
without typing them each time.
## How to use
```bash
# Interactive session (penpot database)
tools/psql
# Interactive session (penpot_test database)
tools/psql --test
# Inline query (penpot)
tools/psql -c "SELECT 1"
# Inline query (penpot_test)
tools/psql --test -c "SELECT 1"
# Override defaults
tools/psql -h other-host -U other-user -d other-db
# Pipe SQL from a file
tools/psql -f some-query.sql
```
All standard `psql` flags are passed through after the wrapper's own flags.
## Defaults
| Setting | Default | Env override |
|----------|-----------|---------------------|
| Host | `postgres` | `PENPOT_DB_HOST` |
| User | `penpot` | `PENPOT_DB_USER` |
| Password | `penpot` | `PENPOT_DB_PASSWORD` |
| Database | `penpot` | `PENPOT_DB_NAME` |
## See also
`tools/db-schema` — a companion script that dumps the current DDL schema
using `pg_dump --schema-only`, with the same defaults and `--test` flag.

View File

@ -18,6 +18,10 @@ Body explaining what changed and why.
AI-assisted-by: model-name
```
**AI-assisted-by trailer rules:**
- Use only the model name, e.g. `mimo-v2.5`, `deepseek-v4-flash`
- Do NOT add prefixes like `opencode-go/` — use the bare model name
## Commit Type Emojis
`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight

View File

@ -4,10 +4,10 @@ PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<s
## Target Branch
Auto-detect the base branch with `tools/detect-target-branch`:
Auto-detect the base branch with `scripts/detect-target-branch`:
```bash
TARGET=$(tools/detect-target-branch)
TARGET=$(scripts/detect-target-branch)
```
This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails.
@ -83,7 +83,7 @@ cat > /tmp/pr-body.md << 'PR_BODY'
<body content here>
PR_BODY
TARGET=$(tools/detect-target-branch)
TARGET=$(scripts/detect-target-branch)
gh pr create \
--repo penpot/penpot \

View File

@ -1,26 +1,31 @@
# the name by which the project can be referenced within Serena
# the name by which the project can be referenced within Serena/when chatting with the LLM.
project_name: "penpot"
# list of languages for which language servers are started; choose from:
# al ansible bash clojure cpp
# cpp_ccls crystal csharp csharp_omnisharp dart
# elixir elm erlang fortran fsharp
# list of languages for which language servers are started (LSP backend only); choose from:
# ada al angular ansible bash
# bsl clojure cpp cpp_ccls crystal
# csharp csharp_omnisharp cue dart elixir
# elm erlang fortran fsharp gdscript
# go groovy haskell haxe hlsl
# java json julia kotlin lean4
# lua luau markdown matlab msl
# nix ocaml pascal perl php
# php_phpactor powershell python python_jedi python_ty
# r rego ruby ruby_solargraph rust
# scala solidity swift systemverilog terraform
# toml typescript typescript_vts vue yaml
# zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# html java json julia kotlin
# latex lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor php_phpantom powershell
# python python_jedi python_pyrefly python_ty r
# rego ruby ruby_solargraph rust scala
# scss solidity svelte swift systemverilog
# terraform toml typescript typescript_vts vue
# yaml zig
# (This list may be outdated; generated with scripts/print_language_list.py;
# For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
@ -54,12 +59,19 @@ ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
# Example:
# ignored_paths:
# - "examples/**"
# - ".worktrees/**"
# - "**/bin/**"
# - "**/obj/**"
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
@ -130,13 +142,38 @@ ignored_memory_patterns: []
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# list of additional workspace folder paths for cross-package reference support.
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# symbols and references across package boundaries, but these folders are not indexed by Serena,
# i.e. the respective symbols will not be found using Serena's symbol search tools.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
ls_additional_workspace_folders: []
# list of workspace folder paths (LSP backend only).
# These folders will be used to build up Serena's symbol index.
# Paths must be within the project root and should thus be relative to the project root.
# Furthermore, the paths should not be filtered by ignore settings.
# Default setting: The entire project root folder (".") is considered.
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
# ls_workspace_folders:
# - "./subproject1"
# - "./subproject2"
ls_workspace_folders:
- .
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
# the command runs in the project root directory and is only executed if the project is trusted
# (see trusted_project_path_patterns in the global configuration).
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
# example: activation_command: "npx nx run-many -t build"
activation_command:
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
# must be a positive number.
activation_command_timeout: 180.0

View File

@ -1,6 +1,6 @@
# AI AGENT GUIDE
## Hard rules (always apply — no exceptions)
## HARD RULES (always apply — no exceptions)
- **Never `git push`, force-push, or modify `git origin`** (or any other remote).
The user pushes from their own shell. If a push is required to surface the
@ -92,3 +92,21 @@ precision while maintaining a strong focus on maintainability and performance.
down into atomic steps.
2. Be concise and autonomous.
3. Do **not** touch unrelated modules unless the task explicitly requires it.
---
# Available Scripts & Tools
## Native opencode Tools (callable directly by the LLM)
- `paren-repair` — Fix mismatched delimiters + reformat Clojure files. Example: `paren-repair(files="src/foo.clj, src/bar.cljs")`
- `penpot-psql` — Execute SQL against the Penpot database. Example: `penpot-psql(sql="SELECT version();")`
## Scripts (from repo root via `scripts/<name>`)
- `scripts/paren-repair` — Fix mismatched delimiters in Clojure/CLJS files + reformat with cljfmt. See `mem:scripts/paren-repair`.
- `scripts/psql` — Connect to the Penpot PostgreSQL database (wraps `psql` with env-var defaults). See `mem:scripts/psql`.
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.

View File

@ -1,6 +1,6 @@
# CHANGELOG
## 2.18.0
## 2.18.0 (Unreleased)
### :bug: Bugs fixed
@ -23,9 +23,7 @@
- Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461))
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
## 2.17.0 (Unreleased)
### :boom: Breaking changes & Deprecations
## 2.17.0
### :rocket: Epics and highlights
@ -45,7 +43,7 @@
- Remove unreachable try/catch in hex->hsl (by @Dexterity104) [#9244](https://github.com/penpot/penpot/issues/9244) (PR: [#9245](https://github.com/penpot/penpot/pull/9245))
- Remove stray debug log in exporter upload-resource (by @iot2edge) [#9270](https://github.com/penpot/penpot/issues/9270) (PR: [#9272](https://github.com/penpot/penpot/pull/9272))
- Release pool connection during font variant creation (by @Dexterity104) [#9286](https://github.com/penpot/penpot/issues/9286) (PR: [#9287](https://github.com/penpot/penpot/pull/9287))
- Add autocomplete combobox to token creation and edition forms [#9899](https://github.com/penpot/penpot/issues/9899) (PR: [#9109](https://github.com/penpot/penpot/pull/9109))
- Add autocomplete combobox to token creation and edition forms [#9899](https://github.com/penpot/penpot/issues/9899) (PR: [#9109](https://github.com/penpot/penpot/pull/9109), [#8294](https://github.com/penpot/penpot/pull/8294))
- Add list view mode to color picker UI [#4420](https://github.com/penpot/penpot/issues/4420) (PR: [#9953](https://github.com/penpot/penpot/pull/9953))
- Use Clipboard API consistently across the application (by @MilosM348) [#6514](https://github.com/penpot/penpot/issues/6514) (PR: [#9188](https://github.com/penpot/penpot/pull/9188))
- Use `$` as DTCG token/group discriminator and make `$description` optional [#8342](https://github.com/penpot/penpot/issues/8342) (PR: [#9912](https://github.com/penpot/penpot/pull/9912))
@ -57,7 +55,6 @@
- Add composite typography token input to the Design sidebar [#9932](https://github.com/penpot/penpot/issues/9932) (PR: [#9128](https://github.com/penpot/penpot/pull/9128), [#9375](https://github.com/penpot/penpot/pull/9375), [#8749](https://github.com/penpot/penpot/pull/8749))
- Avoid deduplicating temporary export files to prevent stale content (by @yong2bba) [#9970](https://github.com/penpot/penpot/issues/9970) (PR: [#9959](https://github.com/penpot/penpot/pull/9959))
- Add layer blur effect [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))
- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
- Add concurrency limiter for MCP Server Plugin Communications [#9493](https://github.com/penpot/penpot/issues/9493) (PR: [#9748](https://github.com/penpot/penpot/pull/9748))
- Render guides in WebGL [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
- Add configurable resource limits to ImageMagick image processing [#10223](https://github.com/penpot/penpot/issues/10223) (PR: [#10240](https://github.com/penpot/penpot/pull/10240))
@ -65,6 +62,7 @@
- Add color variants and positioning to selection size badge (by @bittoby) [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
- Add separate internal URI for exporter to handle Docker deployments where internal and public URIs differ [#10627](https://github.com/penpot/penpot/issues/10627) (PR: [#10630](https://github.com/penpot/penpot/pull/10630))
### :bug: Bugs fixed
@ -180,6 +178,19 @@
- Fix SVG raw caching prevented by unnecessary Cow wrapping [#10488](https://github.com/penpot/penpot/issues/10488) (PR: [#10492](https://github.com/penpot/penpot/pull/10492))
- Add component reset operation to plugin API [#10561](https://github.com/penpot/penpot/issues/10561) (PR: [#10533](https://github.com/penpot/penpot/pull/10533))
- Fix blur menu alignment in Firefox [#10576](https://github.com/penpot/penpot/issues/10576) (PR: [#10575](https://github.com/penpot/penpot/pull/10575))
- Fix sidebar not showing all elements with grid layout [#10539](https://github.com/penpot/penpot/issues/10539) (PR: [#10600](https://github.com/penpot/penpot/pull/10600))
- Fix sidebar getting stuck when selecting shapes that haven't loaded yet [#10599](https://github.com/penpot/penpot/issues/10599) (PR: [#10600](https://github.com/penpot/penpot/pull/10600))
- Fix text shape bounding boxes not updating after remote fonts finish loading [#10585](https://github.com/penpot/penpot/issues/10585) (PR: [#10566](https://github.com/penpot/penpot/pull/10566))
- Fix text editor crash from Draft.js selection offset exceeding DOM node length [#10607](https://github.com/penpot/penpot/issues/10607) (PR: [#10608](https://github.com/penpot/penpot/pull/10608))
- Fix workspace crash when converting SVG-raw shape to path [#10612](https://github.com/penpot/penpot/issues/10612) (PR: [#10613](https://github.com/penpot/penpot/pull/10613))
- Fix component variant panel crash when selecting multiple copies with mismatched property counts [#10615](https://github.com/penpot/penpot/issues/10615) (PR: [#10616](https://github.com/penpot/penpot/pull/10616))
- Fix workspace crash from recursion when clicking shape in comments mode [#10620](https://github.com/penpot/penpot/issues/10620) (PR: [#10622](https://github.com/penpot/penpot/pull/10622))
- Fix Plugin API validation error when listing shared plugin data keys [#10628](https://github.com/penpot/penpot/issues/10628) (PR: [#10632](https://github.com/penpot/penpot/pull/10632))
- Fix Plugin API silently dropping plugin data written to shared library [#10629](https://github.com/penpot/penpot/issues/10629) (PR: [#10632](https://github.com/penpot/penpot/pull/10632))
- Fix workspace crash when event target is a DOM text node [#10640](https://github.com/penpot/penpot/issues/10640) (PR: [#10641](https://github.com/penpot/penpot/pull/10641))
- Fix text shape position-data to include required fills in WASM and DOM calculation paths [#10646](https://github.com/penpot/penpot/issues/10646) (PR: [#10650](https://github.com/penpot/penpot/pull/10650))
- Log expired OIDC tokens as auth failures instead of server errors [#10635](https://github.com/penpot/penpot/issues/10635) (PR: [#10636](https://github.com/penpot/penpot/pull/10636))
- Return 400 instead of 500 when ImageMagick rejects invalid uploaded images [#10642](https://github.com/penpot/penpot/issues/10642) (PR: [#10643](https://github.com/penpot/penpot/pull/10643))
## 2.16.2

View File

@ -14,9 +14,9 @@ Center](https://help.penpot.app/).
- [Reporting Bugs](#reporting-bugs)
- [Pull Requests](#pull-requests)
- [Workflow](#workflow)
- [Title format](#title-format)
- [Description](#description)
- [Branch naming](#branch-naming)
- [Format](#format)
- [Title format](#title-format)
- [Description](#description)
- [Review process](#review-process)
- [What we won't accept](#what-we-wont-accept)
- [Good first issues](#good-first-issues)
@ -73,35 +73,23 @@ Advisories](https://github.com/penpot/penpot/security/advisories)
4. **Format and lint** — run the checks described in
[Formatting and Linting](#formatting-and-linting) before submitting.
### Title format
### Format
#### Title
> **IMPORTANT:** When a PR is squash-merged, the PR title becomes the
> commit message on the main branch. Getting the title right matters.
Pull request titles **must** follow the same convention as commit subjects:
```
:emoji: <subject>
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
```
- Use the **imperative mood** (e.g. "Fix", not "Fixed").
- Capitalize the first letter of the subject.
- Do not end the subject with a period.
- Keep the subject to **70 characters** or fewer.
- Use one of the [commit type emojis](#commit-types) listed below.
Read [Creating Commits](./.serena/memories/workflow/creating-commits.md)
for more concrete information.
When a PR contains multiple unrelated commits, choose the emoji that
best represents the dominant change.
**Examples:**
```
:bug: Fix unexpected error on launching modal
:sparkles: Enable new modal for profile
:zap: Improve performance of dashboard navigation
```
> **Note:** When a PR is squash-merged, the PR title becomes the
> commit message on the main branch. Getting the title right matters.
### Description
#### Description
Every pull request should include a description that helps reviewers
understand the change quickly:
@ -114,24 +102,8 @@ understand the change quickly:
5. **Breaking changes** — call out anything that affects existing users
or requires migration steps.
### Branch naming
Use a descriptive branch name that reflects the type and scope of the
change:
```
<type>/<short-description>
```
Types: `fix`, `feat`, `refactor`, `docs`, `chore`, `perf`.
Optionally include the issue number:
```
fix/9122-email-blacklisting
feat/export-webp
refactor/layout-sizing
```
Read [Creating PRs](./.serena/memories/workflow/creating-prs.md)
for more concrete information.
### Review process
@ -151,10 +123,10 @@ refactor/layout-sizing
To save time on both sides, please avoid submitting PRs that:
- Introduce new dependencies without prior discussion.
- Change the build system or CI configuration without maintainer
approval.
- Mix unrelated changes in a single PR — keep PRs focused on one
concern.
- Change the build system or CI configuration without maintainer approval.
- Mix unrelated changes in a single PR — keep PRs focused on one concern.
- Submit AI-generated code without human review.
- Skip local syntax and formatting checks before submitting.
- Skip the [discussion step](#workflow) for non-bug-fix changes.
### Good first issues
@ -217,36 +189,8 @@ Commit messages must follow this format:
## Formatting and Linting
We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
[clj-kondo](https://github.com/clj-kondo/clj-kondo) for linting.
```bash
# Check formatting (does not modify files)
./scripts/check-fmt
# Fix formatting (modifies files in place)
./scripts/fmt
# Lint
./scripts/lint
```
For frontend SCSS, we use `stylelint` for linting and
`Prettier` for formatting:
```bash
cd frontend
# Lint SCSS
pnpm run lint:scss (does not modify files)
# Fix SCSS formatting (modifies files in place)
pnpm run fmt:scss
```
Ideally, run these as git pre-commit hooks.
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
setting this up.
Each module has its own linting and formatting commands — see the relevant one on the
[Serena Memories](./.serena/memories/)
## Changelog

View File

@ -186,7 +186,7 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:50 }}”{% if
organization %}
part of the organization “{{ organization.name|abbreviate:50 }}”{% endif %}.</div>
</td>

View File

@ -1,6 +1,6 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization.name|abbreviate:50 }}"{% endif %}.
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:50 }}"{% if organization %}, part of the organization "{{ organization.name|abbreviate:50 }}"{% endif %}.
{% if organization.sso-active %}
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes

View File

@ -971,7 +971,9 @@
(catch Throwable cause
(binding [l/*context* (errors/request->context request)]
(l/err :hint "error on process oidc callback" :cause cause)
(if (= :unable-to-retrieve-user-info (:code (ex-data cause)))
(l/wrn :hint "error on process oidc callback" :cause cause)
(l/err :hint "error on process oidc callback" :cause cause))
(redirect-with-error "unable-to-auth" (ex-message cause)))))))
(def ^:private schema:routes-params

View File

@ -184,8 +184,8 @@
:env (get-imagemagick-env)
:timeout 60)]
(when (not= 0 (:exit result))
(ex/raise :type :internal
:code :imagemagick-error
(ex/raise :type :validation
:code :invalid-image
:hint (str "ImageMagick command failed: " (:err result))
:cmd cmd
:exit (:exit result)))

View File

@ -493,7 +493,10 @@
:fn (mg/resource "app/migrations/sql/0150-mod-storage-object-table.sql")}
{:name "0151-mod-file-tagged-object-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}])
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}
{:name "0152-improve-uuid-defaults-and-drop-extension"
:fn (mg/resource "app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@ -0,0 +1,29 @@
-- Migration: Replace uuid_generate_v4() defaults with gen_random_uuid()
-- and remove uuid-ossp extension.
--
-- gen_random_uuid() is built into PostgreSQL >= 13 and requires no extension.
-- The application already generates IDs explicitly via uuid/next in all
-- code paths; this migration adds gen_random_uuid() as a safety-net default
-- instead of the extension-dependent uuid_generate_v4().
ALTER TABLE access_token ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE audit_log ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE comment ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE comment_thread ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE file ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE file_change ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE file_media_object ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE profile ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE project ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE project_profile_rel ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE scheduled_task_history ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE share_link ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE storage_object ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE task ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team_access_request ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team_font_variant ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team_invitation ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team_profile_rel ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team_project_profile_rel ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE usage_quote ALTER COLUMN id SET DEFAULT gen_random_uuid();

View File

@ -10,6 +10,7 @@
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.features.logical-deletion :as ldel]
@ -184,7 +185,8 @@
timestamp (::rpc/request-at params)]
(teams/create-project-role conn profile-id (:id project) :owner)
(db/insert! conn :team-project-profile-rel
{:project-id (:id project)
{:id (uuid/next)
:project-id (:id project)
:profile-id profile-id
:created-at timestamp
:modified-at timestamp

View File

@ -628,7 +628,7 @@
(some? (:organization-id membership)) ;; the team do belong to an organization
(not (:is-member membership))) ;; the user is not a member of the org yet
(initialize-user-in-nitrate-org cfg profile-id (:organization-id membership)))))
(db/insert! conn :team-profile-rel params options)))
(db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options)))
(defn create-team
"This is a complete team creation process, it creates the team
@ -699,7 +699,8 @@
(defn create-project-role
[conn profile-id project-id role]
(let [params {:project-id project-id
:profile-id profile-id}]
:profile-id profile-id
:id (uuid/next)}]
(->> (perms/assign-role-flags params role)
(db/insert! conn :project-profile-rel))))

View File

@ -825,11 +825,21 @@ RETURNING id, deleted_at;")
t.name,
t.photo_id,
t.created_at,
(SELECT MAX(p2.modified_at)
FROM project AS p2
WHERE p2.team_id = t.id
AND p2.deleted_at IS NULL
AND p2.is_default IS FALSE) AS last_activity_at,
(SELECT MAX(activity.modified_at)
FROM (
SELECT p2.modified_at
FROM project AS p2
WHERE p2.team_id = t.id
AND p2.deleted_at IS NULL
AND p2.is_default IS FALSE
UNION ALL
SELECT f.modified_at
FROM file AS f
JOIN project AS p ON p.id = f.project_id
WHERE p.team_id = t.id
AND p.deleted_at IS NULL
AND f.deleted_at IS NULL
) AS activity) AS last_activity_at,
owner_tpr.profile_id AS owner_profile_id,
owner_p.fullname AS owner_name,
owner_p.photo_id AS owner_photo_id,

View File

@ -7,6 +7,7 @@
(ns app.srepl.binfile
(:require
[app.binfile.v2 :as binfile.v2]
[app.common.uuid :as uuid]
[app.db :as db]
[app.main :as main]
[app.srepl.helpers :as h]
@ -30,7 +31,8 @@
(when owner
(db/insert! cfg :team-profile-rel
{:team-id (:id team)
{:id (uuid/next)
:team-id (:id team)
:profile-id (:id owner)
:is-admin true
:is-owner true

View File

@ -7,7 +7,16 @@
(ns backend-tests.auth-oidc-test
(:require
[app.auth.oidc :as oidc]
[clojure.test :as t]))
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.time :as ct]
[app.config :as cf]
[app.http.session :as session]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[clojure.test :as t]
[mockery.core :refer [with-mocks]]
[yetti.response :as-alias yres]))
(def ^:private oidc-provider
{:id "oidc"
@ -70,3 +79,442 @@
(t/is (not (#'oidc/token-endpoint-valid-client-error?
{:status 400
:body "{\"error\":\"invalid_client\"}"}))))
(t/deftest int-in-range-checks-range-correctly
(t/testing "values within range return true"
(t/is (#'oidc/int-in-range? 200 200 300))
(t/is (#'oidc/int-in-range? 250 200 300))
(t/is (#'oidc/int-in-range? 299 200 300)))
(t/testing "values outside range return false"
(t/is (not (#'oidc/int-in-range? 199 200 300)))
(t/is (not (#'oidc/int-in-range? 300 200 300)))))
(t/deftest redirect-response-builds-302-response
(let [result (#'oidc/redirect-response "https://example.com/path")]
(t/is (= 302 (::yres/status result)))
(t/is (= "https://example.com/path" (get-in result [::yres/headers "location"])))))
(t/deftest valid-info-validates-info-map
(t/testing "valid info maps pass validation"
(t/is (#'oidc/valid-info?
{:backend "oidc" :email "user@example.com" :fullname "User"
:email-verified true :props {:foo 1}})))
(t/testing "incomplete maps fail validation"
(t/is (not (#'oidc/valid-info? nil)))
(t/is (not (#'oidc/valid-info? {})))
(t/is (not (#'oidc/valid-info? {:backend "oidc"})))
(t/is (not (#'oidc/valid-info? {:backend "oidc" :email "user@example.com"})))
(t/is (not (#'oidc/valid-info? {:backend "oidc" :email "user@example.com" :fullname "User"})))
(t/is (not (#'oidc/valid-info?
{:backend "oidc" :email "user@example.com" :fullname "User" :email-verified true})))))
(t/deftest qualify-prop-key-qualifies-key
(let [provider {:type "github"}]
(t/is (= :github/email (#'oidc/qualify-prop-key provider :email)))
(t/is (= :github/full-name (#'oidc/qualify-prop-key provider :full_name)))
(t/is (= :github/my-key (#'oidc/qualify-prop-key provider :my-key)))))
(t/deftest qualify-props-qualifies-all-keys
(let [provider {:type "github"}
result (#'oidc/qualify-props provider {:email "u@e.com" :name "Test"})]
(t/is (= "u@e.com" (:github/email result)))
(t/is (= "Test" (:github/name result)))))
(t/deftest provider-has-email-verified-checks-email-verified
(let [provider {:type "github"}]
(t/testing "returns true when email_verified in props is true"
(t/is (#'oidc/provider-has-email-verified? provider {:props {:github/email-verified true}})))
(t/testing "returns false when email_verified is false or missing"
(t/is (not (#'oidc/provider-has-email-verified? provider {:props {}})))
(t/is (not (#'oidc/provider-has-email-verified? provider {:props {:github/email-verified false}}))))))
(t/deftest profile-has-provider-props-matches-provider
(t/testing "non-OIDC provider with string id checks for qualified email key"
(let [provider {:type "github" :id "github"}]
(t/is (#'oidc/profile-has-provider-props? provider {:props {:github/email "u@e.com"}}))
(t/is (not (#'oidc/profile-has-provider-props? provider {:props {}})))
(t/is (not (#'oidc/profile-has-provider-props? provider {:props nil})))))
(t/testing "OIDC provider with UUID id checks oidc/provider-id"
(let [provider {:type "oidc" :id #uuid "00000000-0000-0000-0000-000000000001"}]
(t/is (#'oidc/profile-has-provider-props?
provider {:props {:oidc/provider-id "00000000-0000-0000-0000-000000000001"}}))
(t/is (not (#'oidc/profile-has-provider-props? provider {:props {:oidc/provider-id "other"}}))))))
(t/deftest redirect-with-error-builds-error-url
(binding [cf/config {:public-uri "http://localhost:3449"}]
(t/testing "with error and hint"
(let [result (#'oidc/redirect-with-error "auth-error" "hint message")
loc (get-in result [::yres/headers "location"])]
(t/is (= 302 (::yres/status result)))
(t/is (.contains loc "http://localhost:3449/#/auth/login?"))
(t/is (.contains loc "error=auth-error"))
(t/is (.contains loc "hint=hint"))))
(t/testing "without hint omits hint param"
(let [result (#'oidc/redirect-with-error "auth-error")
loc (get-in result [::yres/headers "location"])]
(t/is (.contains loc "error=auth-error"))
(t/is (not (.contains loc "hint=")))))))
(t/deftest redirect-to-verify-token-builds-verify-url
(binding [cf/config {:public-uri "http://localhost:3449"}]
(let [result (#'oidc/redirect-to-verify-token "test-token-value")
loc (get-in result [::yres/headers "location"])]
(t/is (= 302 (::yres/status result)))
(t/is (.contains loc "http://localhost:3449/#/auth/verify-token?"))
(t/is (.contains loc "token=test-token-value")))))
(t/deftest build-redirect-uri-constructs-redirect
(binding [cf/config {:public-uri "http://localhost:3449"}]
(t/is (= "http://localhost:3449/api/auth/oidc/callback"
(#'oidc/build-redirect-uri)))))
(t/deftest fetch-user-info-returns-decoded-body-on-success
(let [cfg {}
provider {:user-uri "https://provider.example.com/userinfo"}
tdata {:token/access "test-access-token" :token/type "Bearer"}]
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200
:body "{\"email\":\"user@example.com\",\"name\":\"Test User\"}"}}]
(let [result (#'oidc/fetch-user-info cfg provider tdata)]
(t/is (:called? @http-mock))
(t/is (= 1 (:call-count @http-mock)))
(t/is (= "user@example.com" (:email result)))
(t/is (= "Test User" (:name result)))))))
(t/deftest fetch-user-info-throws-on-non-2xx
(let [cfg {}
provider {:user-uri "https://provider.example.com/userinfo"}
tdata {:token/access "test-at" :token/type "Bearer"}]
(t/testing "401 with Bad credentials"
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 401
:body "Bad credentials"}}]
(let [e (try (#'oidc/fetch-user-info cfg provider tdata) (catch Throwable t t))]
(t/is (instance? clojure.lang.ExceptionInfo e))
(t/is (= :unable-to-retrieve-user-info (:code (ex-data e))))
(t/is (= 401 (:http-status (ex-data e))))
(t/is (= "Bad credentials" (:http-body (ex-data e)))))))
(t/testing "500 server error"
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 500
:body "Internal Server Error"}}]
(let [e (try (#'oidc/fetch-user-info cfg provider tdata) (catch Throwable t t))]
(t/is (instance? clojure.lang.ExceptionInfo e))
(t/is (= :unable-to-retrieve-user-info (:code (ex-data e))))
(t/is (= 500 (:http-status (ex-data e)))))))))
(t/deftest fetch-user-info-passes-correct-request
(let [cfg {}
provider {:user-uri "https://provider.example.com/userinfo" :skip-ssrf-check? true}
tdata {:token/access "secret-token" :token/type "Bearer"}]
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200 :body "{}"}}]
(#'oidc/fetch-user-info cfg provider tdata)
(let [[_ req-opts opts] (-> @http-mock :call-args)]
(t/is (true? (:skip-ssrf-check? opts)))
(t/is (= "https://provider.example.com/userinfo" (:uri req-opts)))
(t/is (= "Bearer secret-token" (get-in req-opts [:headers "Authorization"])))
(t/is (= :get (:method req-opts)))))))
(t/deftest fetch-access-token-returns-token-data-on-success
(binding [cf/config {:public-uri "http://localhost:3449"}]
(let [cfg {}
provider {:client-id "test-client"
:client-secret "test-secret"
:token-uri "https://provider.example.com/token"}
code "auth-code-123"]
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200
:body "{\"access_token\":\"at\",\"id_token\":\"it\",\"token_type\":\"Bearer\"}"}}]
(let [result (#'oidc/fetch-access-token cfg provider code)]
(t/is (:called? @http-mock))
(t/is (= 1 (:call-count @http-mock)))
(t/is (= "at" (:token/access result)))
(t/is (= "it" (:token/id result)))
(t/is (= "Bearer" (:token/type result))))))))
(t/deftest fetch-access-token-throws-on-error
(binding [cf/config {:public-uri "http://localhost:3449"}]
(let [cfg {}
provider {:client-id "test-client"
:client-secret "test-secret"
:token-uri "https://provider.example.com/token"}
code "auth-code-123"]
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 400 :body "{\"error\":\"invalid_grant\"}"}}]
(let [e (try (#'oidc/fetch-access-token cfg provider code) (catch Throwable t t))]
(t/is (instance? clojure.lang.ExceptionInfo e))
(t/is (= :unable-to-fetch-access-token (:code (ex-data e)))))))))
;; Shared mock data for get-info tests
(def ^:private mock-tdata
{:token/access "mock-at" :token/id nil :token/type "Bearer"})
(def ^:private mock-claims
{:email "user@example.com" :name "User" :exp 1 :iss "test"})
(def ^:private mock-userinfo
{:email "user@example.com" :name "User"})
(t/deftest get-info-uses-token-source
(let [provider {:type "oidc" :user-info-source "token"}
state {}
code "code"]
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
app.auth.oidc/get-id-token-claims (constantly mock-claims)]
(let [result (#'oidc/get-info {} provider state code)]
(t/is (= "user@example.com" (:email result)))
(t/is (= "User" (:fullname result)))
(t/is (= "oidc" (:backend result)))
(t/is (= false (:email-verified result)))))))
(t/deftest get-info-uses-userinfo-source
(let [provider {:type "oidc" :user-info-source "userinfo"}
state {}
code "code"]
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
app.auth.oidc/get-id-token-claims (constantly nil)
app.auth.oidc/fetch-user-info (constantly mock-userinfo)]
(let [result (#'oidc/get-info {} provider state code)]
(t/is (= "user@example.com" (:email result)))
(t/is (= "User" (:fullname result)))
(t/is (= "oidc" (:backend result)))))))
(t/deftest get-info-auto-prefers-claims
(let [provider {:type "oidc" :user-info-source "auto"}
state {}
code "code"]
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
app.auth.oidc/get-id-token-claims (constantly mock-claims)
app.auth.oidc/fetch-user-info (fn [& _] (throw (Exception. "should not call")))]
(let [result (#'oidc/get-info {} provider state code)]
(t/is (= "user@example.com" (:email result)))
(t/is (= "User" (:fullname result)))))))
(t/deftest get-info-auto-falls-back-to-userinfo
(let [provider {:type "oidc" :user-info-source "auto"}
state {}
code "code"]
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
app.auth.oidc/get-id-token-claims (constantly nil)
app.auth.oidc/fetch-user-info (constantly mock-userinfo)]
(let [result (#'oidc/get-info {} provider state code)]
(t/is (= "user@example.com" (:email result)))
(t/is (= "User" (:fullname result)))))))
(t/deftest get-info-throws-on-incomplete-info
(let [provider {:type "oidc" :user-info-source "userinfo"}
state {}
code "code"]
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
app.auth.oidc/get-id-token-claims (constantly nil)
app.auth.oidc/fetch-user-info (constantly {:no-email nil})]
(let [e (try (#'oidc/get-info {} provider state code) (catch Throwable t t))]
(t/is (instance? clojure.lang.ExceptionInfo e))
(t/is (= :incomplete-user-info (:code (ex-data e))))))))
(t/deftest get-info-checks-roles-satisfied
(let [provider {:type "oidc" :user-info-source "token" :roles #{"member"}}
state {}
code "code"
claims (assoc mock-claims :roles ["member" "admin"])]
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
app.auth.oidc/get-id-token-claims (constantly claims)]
(let [result (#'oidc/get-info {} provider state code)]
(t/is (= "user@example.com" (:email result)))
(t/is (= "oidc" (:backend result)))))))
(t/deftest get-info-throws-on-insufficient-roles
(let [provider {:type "oidc" :user-info-source "token" :roles #{"admin"}}
state {}
code "code"
claims (assoc mock-claims :roles ["member"])]
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
app.auth.oidc/get-id-token-claims (constantly claims)]
(let [e (try (#'oidc/get-info {} provider state code) (catch Throwable t t))]
(t/is (instance? clojure.lang.ExceptionInfo e))
(t/is (= :unable-to-auth (:code (ex-data e))))))))
(t/deftest get-info-merges-state-props
(let [provider {:type "oidc" :user-info-source "token"}
state {:invitation-token "inv-123"
:external-session-id "ext-456"
:props {:utm_source "twitter"}}
code "code"]
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
app.auth.oidc/get-id-token-claims (constantly mock-claims)]
(let [result (#'oidc/get-info {} provider state code)]
(t/is (= "inv-123" (:invitation-token result)))
(t/is (= "ext-456" (:external-session-id result)))
(t/is (= "twitter" (get-in result [:props :utm_source])))))))
(t/deftest get-info-adds-sso-session-id-from-claims
(let [provider {:type "oidc" :user-info-source "token"}
state {}
code "code"
claims (assoc mock-claims :sid "sso-sid")]
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
app.auth.oidc/get-id-token-claims (constantly claims)]
(let [result (#'oidc/get-info {} provider state code)]
(t/is (= "sso-sid" (:sso-session-id result)))))))
(t/deftest get-info-adds-sso-provider-id-for-uuid-provider
(let [provider {:type "oidc" :user-info-source "token"
:id #uuid "00000000-0000-0000-0000-000000000001"}
state {}
code "code"]
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
app.auth.oidc/get-id-token-claims (constantly mock-claims)]
(let [result (#'oidc/get-info {} provider state code)]
(t/is (= #uuid "00000000-0000-0000-0000-000000000001" (:sso-provider-id result)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; callback-handler tests
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private test-token-key
(byte-array (map byte (range 32))))
(def ^:private base-cfg
{::setup/props {:tokens-key test-token-key}
::session/manager (session/inmemory-manager)
:app.email/blacklist #{"banned.com"}
:app.email/whitelist #{"allowed.com"}})
(def ^:private test-profile-id
#uuid "11111111-1111-1111-1111-111111111111")
(def ^:private test-profile
{:id test-profile-id
:is-active true
:is-blocked false
:auth-backend "oidc"
:email "user@example.com"
:props {}})
(defn- make-state-token
[cfg overrides]
(tokens/generate cfg (d/without-nils (merge {:iss "oidc" :provider "oidc"
:exp (ct/in-future {:hours 1})}
overrides))))
(defn- default-request
[cfg & {:keys [state] :or {state "dummy"}}]
{:params {:state state :code "test-code"}
:method :get
:path "/api/auth/oidc/callback"
:headers {"user-agent" "TestAgent"
"x-forwarded-for" "127.0.0.1"}
:remote-addr "127.0.0.1"})
(defn- redirect-location
"Extract the Location header from a handler response."
[result]
(get-in result [::yres/headers "location"]))
(t/deftest callback-param-error-redirects-to-login
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
request {:params {:error "access_denied"}}]
(binding [cf/config {:public-uri "http://localhost:3449"}]
(let [result (#'oidc/callback-handler cfg request)
loc (redirect-location result)]
(t/is (= 302 (::yres/status result)))
(t/is (.contains loc "error=unable-to-auth"))
(t/is (.contains loc "hint=access_denied"))))))
(t/deftest callback-no-profile-registration-disabled
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {})
request (default-request cfg :state state)]
(binding [cf/config {:public-uri "http://localhost:3449"}
cf/flags #{}]
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
:backend "oidc" :email-verified false
:props {}})
app.auth.oidc/get-profile (constantly nil)]
(let [result (#'oidc/callback-handler cfg request)
loc (redirect-location result)]
(t/is (.contains loc "error=registration-disabled")))))))
(t/deftest callback-profile-blocked
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {})
request (default-request cfg :state state)]
(binding [cf/config {:public-uri "http://localhost:3449"}
cf/flags #{:registration}]
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
:backend "oidc" :email-verified false
:props {}})
app.auth.oidc/get-profile (constantly (assoc test-profile :is-blocked true))]
(let [result (#'oidc/callback-handler cfg request)
loc (redirect-location result)]
(t/is (.contains loc "error=profile-blocked")))))))
(t/deftest callback-provider-mismatch
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {})
request (default-request cfg :state state)]
(binding [cf/config {:public-uri "http://localhost:3449"}
cf/flags #{:registration}]
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
:backend "oidc" :email-verified false
:props {}})
app.auth.oidc/get-profile (constantly (assoc test-profile :auth-backend "gitlab"))]
(let [result (#'oidc/callback-handler cfg request)
loc (redirect-location result)]
(t/is (.contains loc "error=auth-provider-not-allowed")))))))
(t/deftest callback-profile-inactive-redirects-to-register
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {})
request (default-request cfg :state state)]
(binding [cf/config {:public-uri "http://localhost:3449"}
cf/flags #{:registration}]
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
:backend "oidc" :email-verified false
:props {}})
app.auth.oidc/get-profile (constantly (assoc test-profile :is-active false))]
(let [result (#'oidc/callback-handler cfg request)
loc (redirect-location result)]
(t/is (.contains loc "http://localhost:3449/#/auth/register/validate?"))
(t/is (.contains loc "token=")))))))
(t/deftest callback-success-flow
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {})
request (default-request cfg :state state)]
(binding [cf/config {:public-uri "http://localhost:3449"}
cf/flags #{:registration}]
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
:backend "oidc" :email-verified false
:props {}})
app.auth.oidc/get-profile (constantly test-profile)
app.auth.oidc/update-profile-with-info (fn [cfg profile info] profile)
app.loggers.audit/submit (constantly nil)]
(let [result (#'oidc/callback-handler cfg request)
loc (redirect-location result)]
(t/is (.contains loc "http://localhost:3449/#/auth/verify-token?"))
(t/is (.contains loc "token=")))))))
(t/deftest callback-gracefully-handles-unable-to-retrieve-user-info
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {})
request (default-request cfg :state state)]
(binding [cf/config {:public-uri "http://localhost:3449"}]
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
app.auth.oidc/get-info (fn [& _]
(ex/raise :type :internal
:code :unable-to-retrieve-user-info
:hint "unable to retrieve user info"
:http-status 401
:http-body "Bad credentials"))]
(let [result (#'oidc/callback-handler cfg request)
loc (redirect-location result)]
(t/is (= 302 (::yres/status result)))
(t/is (.contains loc "error=unable-to-auth")))))))

View File

@ -67,8 +67,8 @@
(t/is false "should have thrown")
(catch Exception e
(let [data (ex-data e)]
;; Could be validation or imagemagick-error depending on what magick does
(t/is (contains? #{:validation :internal} (:type data)))))
(t/is (= :validation (:type data)))
(t/is (= :invalid-image (:code data)))))
(finally
(fs/delete path))))))

View File

@ -171,6 +171,62 @@
(t/is (= #{(:id team1) (:id team2)}
(->> out :result :teams (map :id) set)))))
(t/deftest get-teams-detail-last-activity-reflects-file-modifications
(let [profile (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile)})
organization-id (uuid/random)
org-summary {:id organization-id
:teams [{:id (:id team)}]}
params {::th/type :get-teams-detail
::rpc/profile-id (:id profile)
:organization-id organization-id}
call! (fn []
(with-redefs [nitrate/call (fn [_cfg method _params]
(case method
:get-org-summary org-summary
nil))]
(management-command-with-nitrate! params)))
empty-out (call!)
empty-team (-> empty-out :result first)
project (th/create-project* 1 {:profile-id (:id profile)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id project)})
file-after-create (th/db-get :file {:id (:id file)})
project-after-create (th/db-get :project {:id (:id project)})
expected-activity-create (if (.isAfter (:modified-at file-after-create)
(:modified-at project-after-create))
(:modified-at file-after-create)
(:modified-at project-after-create))
with-file-out (call!)
with-file (-> with-file-out :result first)
new-activity (ct/in-future "1h")
_ (th/db-update! :file
{:modified-at new-activity}
{:id (:id file)})
file-after-update (th/db-get :file {:id (:id file)})
updated-out (call!)
updated-team (-> updated-out :result first)]
(t/is (th/success? empty-out))
(t/is (= (:id team) (:id empty-team)))
(t/is (nil? (:last-activity-at empty-team)))
(t/is (th/success? with-file-out))
(t/is (= (:id team) (:id with-file)))
(t/is (= expected-activity-create (:last-activity-at with-file)))
(t/is (th/success? updated-out))
(t/is (= (:id team) (:id updated-team)))
(t/is (= (:modified-at file-after-update) (:last-activity-at updated-team)))
(t/is (not= (:last-activity-at with-file) (:last-activity-at updated-team)))))
(t/deftest notify-organization-deletion-prefixes-teams-and-publishes-org-deleted-event
(let [profile (th/create-profile* 1 {:is-active true})
;; One team will have files -> it will be kept and renamed.

View File

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

View File

@ -2505,15 +2505,49 @@
(pcb/concat-changes changes new-changes)))
(defn- reposition-shape
[shape origin-root dest-root]
(let [shape-pos (fn [shape]
(gpt/point (get-in shape [:selrect :x])
(get-in shape [:selrect :y])))
"Expresses the shape (belonging to the origin-root instance) in the frame of the
dest-root instance, making the geometry of both instances directly comparable —
and copyable.
origin-root-pos (shape-pos origin-root)
dest-root-pos (shape-pos dest-root)
delta (gpt/subtract dest-root-pos origin-root-pos)]
(gsh/move shape delta)))
If the dest root's geometry is NOT overridden (touched), the instance follows
the origin's transformation verbatim (including rotation and flips), so a
translation by the roots' (untransformed) position delta suffices — position is
free per-instance placement.
If the dest root's geometry IS overridden (e.g. the user rotated the copy as a
whole), the instance keeps its own placement transform, so the origin shape is
additionally transformed by the roots' relative transformation (rotation /
flips) around the dest root center — geometric changes then land expressed in
the dest instance's own frame instead of wiping its placement."
[shape origin-root dest-root]
(let [shape-pos (fn [shape]
(gpt/point (dm/get-in shape [:selrect :x])
(dm/get-in shape [:selrect :y])))
origin-root-pos (shape-pos origin-root)
dest-root-pos (shape-pos dest-root)
delta (gpt/subtract dest-root-pos origin-root-pos)
shape (gsh/move shape delta)]
(if-not (ctk/touched-group? dest-root :geometry-group)
shape
(let [origin-transform (d/nilv (:transform origin-root) (gmt/matrix))
dest-transform (d/nilv (:transform dest-root) (gmt/matrix))
rel-transform (gmt/multiply dest-transform (gmt/inverse origin-transform))]
(if ^boolean (gmt/unit? rel-transform)
shape
;; The roots differ in rotation/flips: rotate the whole (already moved)
;; shape around the dest root center by the roots' relative transform.
;; The :rotation attribute delta is fed through the :modifiers path so
;; apply-transform keeps it consistent with the resulting matrix.
(let [center (grc/rect->center (:selrect dest-root))
rel-rotation (mod (- (d/nilv (:rotation dest-root) 0)
(d/nilv (:rotation origin-root) 0))
360)]
(-> shape
(assoc-in [:modifiers :rotation] rel-rotation)
(gsh/apply-transform (gmt/transform-in center rel-transform))
(dissoc :modifiers))))))))
(defn- make-change
[container change]

View File

@ -66,7 +66,7 @@ RUN set -eux; \
FROM base AS setup-opencode
ENV OPENCODE_VERSION=1.18.1
ENV OPENCODE_VERSION=1.18.2
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
@ -218,7 +218,7 @@ ENV CLJKONDO_VERSION=2026.05.25 \
UV_TOOL_DIR=/opt/uv/tools \
UV_TOOL_BIN_DIR=/opt/utils/bin \
UV_PYTHON_INSTALL_DIR=/opt/uv/python \
SERENA_VERSION=1.5.0
SERENA_VERSION=1.6.1
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \

View File

@ -89,9 +89,9 @@ services:
- PENPOT_TENANT=${PENPOT_TENANT}
- PENPOT_TMUX_ATTACH=${PENPOT_TMUX_ATTACH}
# Agentic devenv: set to a commit/tag to update Serena on startup,
# Agentic devenv: set to a PyPI release version/tag to update Serena on startup,
# leave empty to skip update and use the version baked into the image.
- SERENA_UPDATE_VERSION=1.5.0
- SERENA_UPDATE_VERSION=1.6.1
- SHADOW_SERVER_URL=${SHADOW_SERVER_URL}
networks:

View File

@ -129,17 +129,18 @@ If you just want to try Penpot AI workflows quickly through the MCP, follow this
![MCP Server in Penpot Integrations, copy server url](/img/mcp/mcp-server-url.webp)
4. #### Add the server to your MCP client
In your MCP-aware IDE/agent (Cursor, Claude Code, etc.), add a new server pointing to that URL.
**Example (generic JSON config):**
```json
{
"mcpServers": {
"penpot": {
"url": "https://<your-penpot-domain>/mcp/stream?userToken=YOUR_MCP_KEY"
}
}
}
We recommend using the [add-mcp](https://github.com/neon-solutions/add-mcp) project for connecting your MCP client to the Penpot MCP server.
With `npx` available, call
```shell
npx -y add-mcp -g -n penpot <URL>
```
and follow the interactive setup. Alternatively, follow your client's instructions for connecting
to a remote MCP server. For Claude Desktop, we recommend adding the Penpot MCP Server as a
custom [connector](http://claude.ai/customize/connectors).
See the section **Connect your MCP client** for more details on how to connect.
5. #### Open a Penpot file and connect MCP
In Penpot, open a design file and use **File → MCP Server → Connect** to connect the plugin to your current file.
@ -216,84 +217,21 @@ You can use Penpot MCP server in two main ways:
## Connect your MCP client
Use the same client setup flow for both modes. What changes is the server URL and authentication method.
### Connection values by mode
* **Remote MCP**
* URL: `https://<your-penpot-domain>/mcp/stream?userToken=YOUR_MCP_KEY`
* Auth: MCP key in `userToken`
* URL (copy from your Penpot account overview): `https://<your-penpot-domain>/mcp/stream?userToken=YOUR_MCP_KEY`
* Configure your MCP client with `npx -y add-mcp -g -n penpot <URL>` or by following your client's instructions for connecting to a remote MCP server.
For Claude Desktop, the server can be added as a custom [connector](http://claude.ai/customize/connectors).
* **Local MCP**
* URL: `http://localhost:4401/mcp`
* Auth: none (uses your active Penpot browser session)
* Configure your MCP client with `npx -y add-mcp -g -n penpot http://localhost:4401/mcp` (adjust the port if you changed `PENPOT_MCP_SERVER_PORT`) or by following your client's instructions for connecting to an HTTP MCP server.
### Cursor
1. Open Cursor MCP/tool configuration.
2. Add a Penpot MCP server entry:
Note: For clients that do not support HTTP servers directly (like Claude Desktop), the local MCP server can be connected via [mcp-remote](https://github.com/geelen/mcp-remote) as follows:
```json
{
"mcpServers": {
"penpot": {
"url": "REMOTE_OR_LOCAL_URL",
"type": "http"
}
}
}
```
Replace `REMOTE_OR_LOCAL_URL` with the URL for your mode.
### Claude Code
1. Open MCP configuration in Claude Code.
2. Add a Penpot server with `http` transport and the URL for your mode.
3. Restart Claude Code or reload tools.
```json
{
"mcpServers": {
"penpot": {
"transport": "http",
"url": "REMOTE_OR_LOCAL_URL"
}
}
}
```
### VS Code / Copilot
1. Open external MCP server configuration in your extension/settings.
2. Add Penpot with the URL for your mode.
3. Save and reload tools.
```json
{
"mcp.servers": {
"penpot": {
"transport": "http",
"url": "REMOTE_OR_LOCAL_URL"
}
}
}
```
### Codex / OpenCode etc
1. Use your client's "Add MCP server" flow.
2. Set the URL for your mode.
3. Reload tools and verify Penpot tools are available.
```json
{
"servers": {
"penpot": {
"url": "REMOTE_OR_LOCAL_URL",
"transport": {
"type": "http"
}
"command": "npx",
"args": ["-y", "mcp-remote", "<URL>", "--allow-http"]
}
}
}
@ -326,9 +264,8 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted
<a id="connect-remote"></a>
### Connect
For client-specific setup, use the shared section **Connect your MCP client**.
For remote mode, use the URL shown in **Your account → Integrations → MCP Server**, which includes your `userToken`.
See the section **Connect your MCP client** for details on how to connect.
### Setup videos
@ -464,9 +401,7 @@ For advanced or repository-based workflows, see the [MCP README](https://github.
<a id="connect-local"></a>
### Connect
For client-specific setup, use the shared section **Connect your MCP client**.
For local mode, use `http://localhost:4401/mcp` with HTTP transport (no MCP key; authentication uses your active Penpot browser session).
See the section **Connect your MCP client** for details on how to connect.
<a id="use-local"></a>
### Use

View File

@ -8,15 +8,15 @@ desc: Deploy your free Penpot plugins! Learn about Netlify, Cloudflare, Surge &
When it comes to deploying your plugin there are several platforms to choose from. Each platform has its unique features and benefits, so the choice depends on you.
In this guide you will found some options for static sites that have free plans.
In this guide you will find some options for static sites that have free plans.
## 3.1. Building your project
The building may vary between frameworks but if you had previously configured your scripts in <code class="language-bash">package.json</code>, <code class="language-bash">npm run build</code> should work.
The resulting build should be located somewhere in the <code class="language-bash">dist/</code> folder, maybe somewhere else if you have configured so.
The resulting build should be located somewhere in the <code class="language-bash">dist/</code> folder, or somewhere else if you configured it that way.
Be wary that some framework's builders can add additional folders like <code class="language-bash">apps/project-name/</code>, <code class="language-bash">project-name/</code> or <code class="language-bash">browser/</code>.
Be wary that some framework builders can add additional folders like <code class="language-bash">apps/project-name/</code>, <code class="language-bash">project-name/</code> or <code class="language-bash">browser/</code>.
Examples:
@ -27,7 +27,7 @@ Examples:
### Create an account
You need a Netlify account if you don't already have one. You can <a target="_blank" href="https://app.netlify.com/signup">sign up</a> with Github, GItlab, BItbucket or via email and password.
You need a Netlify account if you don't already have one. You can <a target="_blank" href="https://app.netlify.com/signup">sign up</a> with GitHub, GitLab, Bitbucket or via email and password.
### CORS issues
@ -82,7 +82,7 @@ npm run build
2. Go to <a target="_blank" href="https://app.netlify.com/drop">Netlify Drop</a>.
3. Drag and drop the build folder into Netlify Sites. Dropping the whole dist may not work, you should drop the folder where the main files are located.
3. Drag and drop the build folder into Netlify Sites. Dropping the whole dist may not work; you should drop the folder where the main files are located.
4. Done!
@ -122,11 +122,11 @@ Cloudflare allows you to import an existing project from GitHub or GitLab.
![Cloudflare git installation](/img/plugins/install_cloudflare.png)
4. Configure your build settings.
3. Configure your build settings.
![Cloudflare git configuration](/img/plugins/cf_build_settings.png)
5. Save and deploy.
4. Save and deploy.
### Direct upload
@ -220,6 +220,6 @@ Success! - Published to example-plugin-penpot.surge.sh
## 3.5. Submitting to Penpot
To make your finished plugin available in our catalog, submit in on the [plugin submission page](https://penpot.app/penpothub/plugins/create-plugin). Once it becomes available any Penpot user will be able to install and use it.
To make your finished plugin available in our catalog, submit it on the [plugin submission page](https://penpot.app/penpothub/plugins/create-plugin). Once it becomes available, any Penpot user will be able to install and use it.

View File

@ -0,0 +1,628 @@
---
title: 3.02.01. Penpot file format (.penpot)
desc: Complete technical specification for the .penpot file format, including structure, schemas, and inspection methods.
---
# Penpot File Format (.penpot)
The `.penpot` file format is Penpot's native export format for design files. It's a ZIP archive containing JSON metadata and binary assets, designed to be open, inspectable, and efficient.
## Overview
The `.penpot` format (version 3) uses a ZIP container with JSON files for metadata and binary files for media assets. This approach provides several advantages:
- **Open and inspectable**: All metadata is human-readable JSON
- **Efficient**: ZIP compression reduces file size
- **Interoperable**: Standard formats enable third-party tooling
- **Versioned**: Clear versioning system for format and data evolution
## Version History
| Version | Description | Status |
|---------|-------------|--------|
| v1 | Custom binary format | Deprecated |
| v2 | SQLite-based format | Never released (internal only) |
| v3 | ZIP + JSON format | **Current** |
## File Structure
A `.penpot` file contains the following structure:
```
pencil.penpot (ZIP archive)
├── manifest.json # Root metadata
├── files/
│ ├── {file-id}.json # File metadata
│ └── {file-id}/
│ ├── pages/
│ │ ├── {page-id}.json # Page metadata
│ │ └── {page-id}/
│ │ └── {shape-id}.json # Individual shapes
│ ├── media/
│ │ └── {media-id}.json # Media references
│ ├── colors/
│ │ └── {color-id}.json # Library colors
│ ├── components/
│ │ └── {component-id}.json # Library components
│ ├── typographies/
│ │ └── {typography-id}.json # Library typographies
│ ├── tokens.json # Design tokens library
│ └── thumbnails/
│ └── {tag}/{page-id}/{frame-id}.json # Thumbnail metadata
└── objects/
├── {uuid}.json # Storage object metadata
└── {uuid}.{ext} # Binary media files (png, jpg, etc.)
```
## Manifest Specification
The `manifest.json` file is the root of the archive and contains metadata about the export.
### Schema
```json
{
"version": 1,
"type": "penpot/export-files",
"generatedBy": "penpot/2.12.0",
"refer": "penpot",
"files": [
{
"id": "uuid",
"name": "File Name",
"features": ["feature1", "feature2"]
}
],
"relations": []
}
```
### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `version` | integer | Yes | Format version (currently `1`) |
| `type` | string | Yes | Must be `"penpot/export-files"` |
| `generatedBy` | string | No | Penpot version that created the file |
| `refer` | string | No | Source system (typically `"penpot"`) |
| `files` | array | Yes | List of files in the archive |
| `files[].id` | UUID | Yes | File identifier |
| `files[].name` | string | Yes | File name |
| `files[].features` | array | Yes | Set of feature flags |
| `relations` | array | No | Library relationships `[file-id, library-id]` |
### Example
```json
{
"type": "penpot/export-files",
"version": 1,
"generatedBy": "penpot/2.12.0-RC1-99-g40c27591f",
"refer": "penpot",
"files": [
{
"id": "73b59a94-3ea3-8189-8007-3d36adc8c3e3",
"name": "Pencil | Penpot Design System",
"features": [
"fdata/path-data",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"components/v2",
"fdata/shape-data-type"
]
}
],
"relations": []
}
```
## File Metadata
Each file has a JSON file at `files/{file-id}.json` containing the file's metadata.
### Schema
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | UUID | Yes | File identifier |
| `name` | string | Yes | File name |
| `revn` | integer | Yes | Revision number |
| `vern` | integer | No | Version number |
| `createdAt` | timestamp | Yes | Creation timestamp |
| `modifiedAt` | timestamp | Yes | Last modification timestamp |
| `deletedAt` | timestamp | No | Deletion timestamp (if soft-deleted) |
| `projectId` | UUID | No | Project identifier |
| `teamId` | UUID | No | Team identifier |
| `isShared` | boolean | No | Whether file is a shared library |
| `hasMediaTrimmed` | boolean | No | Whether media has been trimmed |
| `features` | array | Yes | Set of enabled feature flags |
| `migrations` | array | No | List of applied data migrations |
| `options` | object | No | File-level options |
### Features
The `features` field is a set of strings indicating which features are enabled in the file. Common features include:
| Feature | Description |
|---------|-------------|
| `fdata/path-data` | Path data format |
| `fdata/shape-data-type` | Shape data type system |
| `design-tokens/v1` | Design tokens support |
| `variants/v1` | Component variants |
| `layout/grid` | Grid layout system |
| `components/v2` | Component system v2 |
| `plugins/runtime` | Plugin runtime support |
### Migrations
The `migrations` field lists all data migrations applied to the file. This ensures backward compatibility when the data model evolves.
### Example
```json
{
"id": "73b59a94-3ea3-8189-8007-3d36adc8c3e3",
"name": "Pencil | Penpot Design System",
"revn": 28425,
"vern": 0,
"createdAt": "2025-12-10T10:24:18.686066Z",
"modifiedAt": "2025-12-10T12:13:49.799076Z",
"teamId": "b62e1aa4-d9a7-8147-8005-2813bed4056e",
"projectId": "f23add0e-6b77-8069-8005-41b48b93a5da",
"isShared": true,
"features": [
"fdata/path-data",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"components/v2",
"fdata/shape-data-type"
],
"migrations": [
"legacy-2",
"legacy-3",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2"
],
"options": {
"componentsV2": true
}
}
```
## Pages and Shapes
### Page Structure
Each page is stored at `files/{file-id}/pages/{page-id}.json`.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | UUID | Yes | Page identifier |
| `name` | string | Yes | Page name |
| `index` | integer | No | Page order in the file |
| `options` | object | No | Page options (guides, etc.) |
| `background` | string | No | Background color (hex) |
| `flows` | object | No | Prototype flows |
| `guides` | object | No | Ruler guides |
### Shapes
Individual shapes are stored at `files/{file-id}/pages/{page-id}/{shape-id}.json`.
#### Shape Types
Penpot supports 9 shape types:
| Type | Description |
|------|-------------|
| `frame` | Container frame (artboard) |
| `group` | Group of shapes |
| `rect` | Rectangle |
| `circle` | Circle/Ellipse |
| `path` | Vector path |
| `text` | Text shape |
| `image` | Image |
| `bool` | Boolean operation |
| `svg-raw` | Raw SVG element |
#### Base Shape Attributes
All shapes share these base attributes:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | UUID | Yes | Shape identifier |
| `name` | string | Yes | Shape name |
| `type` | string | Yes | Shape type (see above) |
| `selrect` | object | Yes | Selection rectangle `{x, y, width, height}` |
| `points` | array | Yes | Array of points `[{x, y}, ...]` |
| `transform` | array | Yes | 2D transformation matrix |
| `transformInverse` | array | Yes | Inverse transformation matrix |
| `parentId` | UUID | Yes | Parent shape identifier |
| `frameId` | UUID | Yes | Containing frame identifier |
#### Geometry Attributes
Shapes with geometry (frame, rect, circle, image, svg-raw, text):
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `x` | number | Yes | X position |
| `y` | number | Yes | Y position |
| `width` | number | Yes | Width |
| `height` | number | Yes | Height |
#### Generic Attributes
Optional attributes available on all shapes:
| Field | Type | Description |
|-------|------|-------------|
| `fills` | array | Fill styles |
| `strokes` | array | Stroke styles |
| `opacity` | number | Opacity (0-1) |
| `blendMode` | string | Blend mode |
| `shadow` | array | Shadow effects |
| `blur` | object | Blur effect |
| `constraintsH` | string | Horizontal constraint |
| `constraintsV` | string | Vertical constraint |
| `r1`, `r2`, `r3`, `r4` | number | Border radius corners |
| `blocked` | boolean | Shape is locked |
| `hidden` | boolean | Shape is hidden |
| `collapsed` | boolean | Shape is collapsed |
| `componentId` | UUID | Component reference |
| `componentFile` | UUID | Component library file |
| `shapeRef` | UUID | Shape reference for components |
| `touched` | array | Modified component properties |
| `interactions` | array | Prototype interactions |
| `exports` | array | Export settings |
| `grids` | array | Grid configurations |
| `appliedTokens` | object | Applied design tokens |
| `pluginData` | object | Plugin-specific data |
#### Type-Specific Attributes
**Frame**
- `shapes`: array of child shape UUIDs
- `showContent`: boolean
- `hideInViewer`: boolean
**Group**
- `shapes`: array of child shape UUIDs
**Bool**
- `shapes`: array of child shape UUIDs
- `boolType`: string (`union`, `difference`, `exclude`, `intersection`)
- `content`: path data
**Path**
- `content`: path data (SVG path commands)
**Text**
- `content`: text content with formatting
- `positionData`: glyph position data
**Image**
- `metadata`: object with `width`, `height`, `mtype`, `id`
### Example Shape
```json
{
"id": "260aea33-4e55-808c-8007-3d4f2efe4230",
"name": "Rectangle",
"type": "rect",
"x": 100,
"y": 100,
"width": 200,
"height": 150,
"selrect": {
"x": 100,
"y": 100,
"width": 200,
"height": 150
},
"points": [
{"x": 100, "y": 100},
{"x": 300, "y": 100},
{"x": 300, "y": 250},
{"x": 100, "y": 250}
],
"transform": [1, 0, 0, 1, 0, 0],
"transformInverse": [1, 0, 0, 1, 0, 0],
"parentId": "00000000-0000-0000-0000-000000000000",
"frameId": "00000000-0000-0000-0000-000000000001",
"fills": [
{
"color": "#FF5733",
"opacity": 1
}
],
"r1": 8,
"r2": 8,
"r3": 8,
"r4": 8
}
```
## Library Assets
### Colors
Stored at `files/{file-id}/colors/{color-id}.json`.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | UUID | Yes | Color identifier |
| `name` | string | Yes | Color name |
| `path` | string | No | Path in the library tree |
| `opacity` | number | No | Opacity (0-1) |
| `color` | string | Conditional | Hex color (for plain colors) |
| `gradient` | object | Conditional | Gradient definition |
| `image` | object | Conditional | Image fill definition |
#### Plain Color Example
```json
{
"id": "abc123...",
"name": "Primary Blue",
"path": "Brand/Primary",
"color": "#0066CC",
"opacity": 1
}
```
#### Gradient Color Example
```json
{
"id": "def456...",
"name": "Sunset Gradient",
"gradient": {
"type": "linear",
"startX": 0,
"startY": 0,
"endX": 1,
"endY": 1,
"stops": [
{"color": "#FF6B6B", "offset": 0, "opacity": 1},
{"color": "#4ECDC4", "offset": 1, "opacity": 1}
]
}
}
```
### Components
Stored at `files/{file-id}/components/{component-id}.json`.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | UUID | Yes | Component identifier |
| `name` | string | Yes | Component name |
| `path` | string | Yes | Path in the library tree |
| `mainInstanceId` | UUID | Yes | Root shape of main instance |
| `mainInstancePage` | UUID | Yes | Page containing main instance |
| `modifiedAt` | timestamp | No | Last modification |
| `objects` | object | No | Captured shapes (if deleted) |
### Typographies
Stored at `files/{file-id}/typographies/{typography-id}.json`.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | UUID | Yes | Typography identifier |
| `name` | string | Yes | Typography name |
| `fontId` | string | Yes | Font identifier |
| `fontFamily` | string | Yes | Font family name |
| `fontVariantId` | string | Yes | Font variant |
| `fontSize` | string | Yes | Font size |
| `fontWeight` | string | Yes | Font weight |
| `fontStyle` | string | Yes | Font style |
| `lineHeight` | string | Yes | Line height |
| `letterSpacing` | string | Yes | Letter spacing |
| `textTransform` | string | Yes | Text transform |
### Design Tokens
Stored at `files/{file-id}/tokens.json`.
The tokens library contains:
- **Sets**: Collections of tokens organized hierarchically
- **Themes**: Named combinations of token sets
- **Active Themes**: Currently applied themes
#### Token Structure
```json
{
"sets": {
"core": {
"id": "uuid",
"name": "Core",
"tokens": {
"color": {
"primary": {
"id": "uuid",
"name": "primary",
"type": "color",
"value": "#0066CC"
}
}
}
}
},
"themes": {
"light": {
"id": "uuid",
"name": "Light",
"sets": ["core"]
}
},
"activeThemes": ["light"]
}
```
## Media and Storage Objects
### Storage Objects
Binary assets (images, fonts, etc.) are stored in the `objects/` directory.
Each storage object has:
- `objects/{uuid}.json` - Metadata
- `objects/{uuid}.{ext}` - Binary content (png, jpg, svg, etc.)
#### Metadata Schema
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | UUID | Yes | Storage object identifier |
| `size` | integer | Yes | File size in bytes |
| `contentType` | string | Yes | MIME type |
| `bucket` | string | Yes | Storage bucket |
| `hash` | string | No | Content hash (blake2b) |
#### Example
```json
{
"id": "0039433d-adc8-430d-b2c3-d884dea6e050",
"size": 575,
"contentType": "image/png",
"bucket": "file-media-object",
"hash": "blake2b:77d447db38eb5daf31acb7344a504cacc6b79aa11855a00501d9475c595053d0"
}
```
### Media References
File media references are stored at `files/{file-id}/media/{media-id}.json`.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | UUID | Yes | Media identifier |
| `name` | string | Yes | File name |
| `width` | integer | Yes | Image width |
| `height` | integer | Yes | Image height |
| `mtype` | string | Yes | MIME type |
| `mediaId` | UUID | Yes | Reference to storage object |
| `thumbnaillId` | UUID | No | Reference to thumbnail |
| `isLocal` | boolean | No | Whether media is local to file |
### Thumbnails
Page thumbnails are stored at `files/{file-id}/thumbnails/{tag}/{page-id}/{frame-id}.json`.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `fileId` | UUID | Yes | File identifier |
| `pageId` | UUID | Yes | Page identifier |
| `frameId` | UUID | Yes | Frame identifier |
| `tag` | string | Yes | Thumbnail tag |
| `mediaId` | UUID | Yes | Reference to storage object |
## Plugin Data
Plugins can store custom data on files, pages, shapes, components, colors, and typographies using the `pluginData` field.
### Structure
```json
{
"pluginData": {
"plugin-id": {
"key1": "value1",
"key2": "value2"
}
}
}
```
The plugin ID is a keyword (e.g., `"my-plugin"`), and the values are string key-value pairs.
## Inspecting a .penpot File
### List Contents
```bash
unzip -l design.penpot
```
### Extract and View
```bash
# Extract to temporary directory
unzip design.penpot -d /tmp/penpot-inspect
# View manifest
cat /tmp/penpot-inspect/manifest.json | jq .
# List all files
find /tmp/penpot-inspect -name "*.json" | head -20
# View a specific shape
cat /tmp/penpot-inspect/files/*/pages/*/*.json | jq .
```
### Browser-Based Inspector
For an interactive way to explore a `.penpot` file, try the [**Penpot file inspector**](/technical-guide/developer/data-model/penpot-file-inspector/). It runs entirely in your browser, displays a collapsible file tree, syntax-highlighted JSON, image previews, and clickable cross-references between shapes, components, colors, and media.
### Quick Inspection Script
```bash
#!/bin/bash
# Inspect .penpot file structure
FILE=$1
TMPDIR=$(mktemp -d)
unzip -q "$FILE" -d "$TMPDIR"
echo "=== Manifest ==="
cat "$TMPDIR/manifest.json" | jq '{type, version, files: [.files[] | {id, name}]}'
echo -e "\n=== Files ==="
for f in "$TMPDIR"/files/*.json; do
echo "- $(jq -r '.name' "$f")"
done
echo -e "\n=== Pages ==="
for f in "$TMPDIR"/files/*/pages/*.json; do
echo "- $(jq -r '.name' "$f")"
done
echo -e "\n=== Storage Objects ==="
ls -lh "$TMPDIR"/objects/*.{png,jpg,svg} 2>/dev/null | wc -l
echo "media files"
rm -rf "$TMPDIR"
```
## Cross-References
- [Penpot file inspector](/technical-guide/developer/data-model/penpot-file-inspector/) - Browser-based interactive inspector
- [Data Model](/technical-guide/developer/data-model/) - Conceptual data model
- [Data Guide](/technical-guide/developer/data-guide/) - Working with data structures
- [Export/Import Files](/user-guide/export-import/export-import-files/) - User guide for exporting and importing
## Source Code References
The authoritative schema definitions are in the Penpot source code:
- **Manifest**: `backend/src/app/binfile/v3.clj` (schema:manifest)
- **File**: `common/src/app/common/types/file.cljc` (schema:file)
- **Page**: `common/src/app/common/types/page.cljc` (schema:page)
- **Shape**: `common/src/app/common/types/shape.cljc` (schema:shape)
- **Component**: `common/src/app/common/types/component.cljc` (schema:component)
- **Color**: `common/src/app/common/types/color.cljc` (schema:library-color)
- **Typography**: `common/src/app/common/types/typography.cljc` (schema:typography)
- **Tokens**: `common/src/app/common/types/tokens_lib.cljc` (schema:tokens-lib)
- **Plugin Data**: `common/src/app/common/types/plugins.cljc` (schema:plugin-data)
- **Features**: `common/src/app/common/features.cljc` (schema:features)

File diff suppressed because it is too large Load Diff

View File

@ -48,21 +48,22 @@ desc: Learn how to import and export files in Penpot, the free, open-source desi
<figure><img src="/img/import-export/import-selection.webp" alt="Import penpot file" /></figure>
<h2 id="penpot-formats">Penpot file format</h2>
<p>Penpot export to a unique format that streamline the import and export of files and assets by being more efficient and interoperable.</p>
<p>Penpot exports to a unique format that streamlines the import and export of files and assets by being more efficient and interoperable.</p>
<p>Unlike other design tools, <strong>Penpot's format is built on standard languages</strong>. The exported file is essentially a ZIP archive containing binary assets (such as bitmap and vector images) alongside a readable JSON structure. By avoiding proprietary formats, Penpot empowers users with autonomy from specific tools while enabling seamless third-party integrations.</p>
<p>For a detailed look at what's inside a .penpot file, see <a href="/user-guide/export-import/penpot-file-format/">The .penpot file format</a>. Developers can also check the <a href="/technical-guide/developer/data-model/penpot-file-format/">technical specification</a>.</p>
<h3>Deprecated Penpot file formats</h3>
<p class="advice">These formats can only be exported from version 2.3 or earlier versions, but can be imported to any Penpot version.</p>
<p>There are two different deprecated Penpot file formats in which you can import/export Penpot files. A standard one and a binary one. You always have the chance to use both for any file.</p>
<h4>[Deprecated] Penpot file (.penpot).</h4>
<h4>[Deprecated] Binary file (.penpot) — v1</h4>
<p>The fast one. Binary Penpot specific.</p>
<ul>
<li>✅ Highly efficient in terms of memory and transfer time when exporting and importing.</li>
<li>❌ It can be opened only in Penpot.</li>
<li>❌ Not transparent, code difficult to explore.</li>
</ul>
<h4>[Deprecated] Standard file (.zip).</h4>
<p>The open one. A compressed file that includes SVG and JSON.</p>
<h4>[Deprecated] Standard file (.zip) — v2 (never released)</h4>
<p>The open one. A compressed file that includes SVG and JSON. This format was developed but never released to users.</p>
<ul>
<li>✅ Allows the file to be opened by other softwares (still, for those cases export to SVG seems to be the common practice).</li>
<li>✅ Allows some automations and integrations.</li>

View File

@ -13,7 +13,13 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<p>How to export and import your Penpot files</p>
</a>
</li>
<li>
<li>
<a href="/user-guide/export-import/penpot-file-format/">
<h2>The .penpot file format →</h2>
<p>Understand what's inside a .penpot file and how to inspect it</p>
</a>
</li>
<li>
<a href="/user-guide/export-import/exporting-layers/">
<h2>Exporting layers →</h2>
<p>How to export elements from your design into different file formats</p>

View File

@ -0,0 +1,155 @@
---
title: The .penpot file format
order: 2
desc: Understand the .penpot file format, what's inside, and how to inspect your design files.
---
<h1 id="penpot-file-format">The .penpot file format</h1>
<p class="main-paragraph">Penpot's native file format is designed to be open, inspectable, and efficient. Unlike proprietary formats, you can always look inside a .penpot file to understand your design data.</p>
<h2 id="what-is-penpot-format">What is a .penpot file?</h2>
<p>A <code class="language-text">.penpot</code> file is a <strong>ZIP archive</strong> containing:</p>
<ul>
<li><strong>JSON metadata</strong> — human-readable descriptions of your pages, shapes, colors, components, and more</li>
<li><strong>Binary media files</strong> — images, fonts, and other assets used in your design</li>
</ul>
<p>This means your design data is never locked in a proprietary format. You can always unzip a .penpot file and read the JSON to understand what's inside.</p>
<h2 id="whats-inside">What's inside a .penpot file?</h2>
<p>When you unzip a .penpot file, you'll find this structure:</p>
<figure>
<pre class="language-text"><code>your-design.penpot (ZIP archive)
├── manifest.json ← Table of contents
├── files/ ← Your design data
│ ├── file-metadata.json
│ └── file-data/
│ ├── pages/ ← Each page of your design
│ ├── colors/ ← Library colors
│ ├── components/ ← Library components
│ ├── typographies/ ← Library typographies
│ ├── tokens.json ← Design tokens
│ └── media/ ← Media references
└── objects/ ← Images and binary assets</code></pre>
</figure>
<h3>manifest.json</h3>
<p>The manifest is the table of contents. It tells you:</p>
<ul>
<li>Which version of the format is used</li>
<li>Which Penpot version created the file</li>
<li>What files are included</li>
<li>What features are enabled</li>
</ul>
<h3>File data</h3>
<p>The <code class="language-text">files/</code> directory contains all your design data organized by type:</p>
<ul>
<li><strong>Pages</strong> — Each page is split into individual shape files for efficiency</li>
<li><strong>Colors</strong> — Your color library with hex values, gradients, or image fills</li>
<li><strong>Components</strong> — Reusable component definitions</li>
<li><strong>Typographies</strong> — Text style definitions</li>
<li><strong>Tokens</strong> — Design tokens organized in sets and themes</li>
<li><strong>Media</strong> — References to images and other assets</li>
</ul>
<h3>Objects</h3>
<p>The <code class="language-text">objects/</code> directory contains the actual binary files (PNG, JPG, SVG) referenced by your design. Each object has a JSON metadata file alongside the binary file.</p>
<h2 id="how-to-inspect">How to inspect a .penpot file</h2>
<p>Since .penpot files are just ZIP archives, you can inspect them with standard tools.</p>
<h3>On macOS or Linux</h3>
<p><strong>1. List the contents without extracting:</strong></p>
<pre class="language-bash"><code>unzip -l your-design.penpot</code></pre>
<p><strong>2. Extract to a temporary folder:</strong></p>
<pre class="language-bash"><code>unzip your-design.penpot -d /tmp/penpot-inspect</code></pre>
<p><strong>3. View the manifest:</strong></p>
<pre class="language-bash"><code>cat /tmp/penpot-inspect/manifest.json | jq .</code></pre>
<p><strong>4. Browse the structure:</strong></p>
<pre class="language-bash"><code>tree /tmp/penpot-inspect</code></pre>
<p><strong>5. View a specific shape:</strong></p>
<pre class="language-bash"><code>cat /tmp/penpot-inspect/files/*/pages/*/*.json | jq .</code></pre>
<h3>On Windows</h3>
<p><strong>1. Right-click the .penpot file</strong> and select "Extract All..." or use 7-Zip.</p>
<p><strong>2. Browse the extracted folder</strong> to see the structure.</p>
<p><strong>3. Open any JSON file</strong> with a text editor or use a JSON viewer tool.</p>
<h3>Using AI tools</h3>
<p>You can use the <a href="/mcp/">Penpot MCP server</a> to inspect .penpot files with AI assistance. The MCP server can read and analyze your design files, making it easy to understand complex designs or automate tasks.</p>
<h2 id="versioning">Format versions</h2>
<p>The .penpot format has evolved over time:</p>
<table>
<thead>
<tr>
<th>Version</th>
<th>Description</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>v3</strong></td>
<td>ZIP + JSON format (current)</td>
<td>✅ Current</td>
</tr>
<tr>
<td>v1</td>
<td>Custom binary format</td>
<td>⚠️ Deprecated</td>
</tr>
</tbody>
</table>
<p class="advice">All current Penpot versions export in v3 format. Old v1 files can still be imported, but new exports will use v3.</p>
<h3>Version numbers inside the file</h3>
<p>Inside a .penpot file, you'll see two different version numbers:</p>
<ul>
<li><strong>Format version</strong> (in <code class="language-text">manifest.json</code>) — Tracks changes to the ZIP structure itself. Currently <code class="language-text">1</code>.</li>
<li><strong>Data version</strong> (in each file's JSON) — Tracks changes to the data model. This number increases as Penpot evolves.</li>
</ul>
<h3>Feature flags</h3>
<p>Files also include a list of <strong>feature flags</strong> that indicate which capabilities are used. For example:</p>
<ul>
<li><code class="language-text">design-tokens/v1</code> — Uses design tokens</li>
<li><code class="language-text">components/v2</code> — Uses the v2 component system</li>
<li><code class="language-text">variants/v1</code> — Uses component variants</li>
<li><code class="language-text">layout/grid</code> — Uses grid layouts</li>
</ul>
<p>These flags help Penpot know what features need to be supported when importing the file.</p>
<h2 id="for-developers">For developers</h2>
<p>If you're building tools or integrations that work with .penpot files, the <a href="/technical-guide/developer/data-model/penpot-file-format/">complete technical specification</a> provides detailed schema definitions for every JSON file in the archive.</p>
<p>Key resources:</p>
<ul>
<li><a href="/technical-guide/developer/data-model/penpot-file-format/">Technical specification</a> — Complete schema reference</li>
<li><a href="/technical-guide/developer/data-model/penpot-file-inspector/">File inspector</a> — Browser-based tool to interactively explore a .penpot file</li>
<li><a href="/mcp/">MCP Server</a> — AI-powered file inspection and manipulation</li>
<li><a href="/technical-guide/developer/data-model/">Data model</a> — Conceptual overview of Penpot's data structures</li>
</ul>

View File

@ -22,7 +22,7 @@
(def ^:private defaults
{:public-uri "http://localhost:3449"
:internal-uri nil
;; :internal-uri nil ;; internal-uri cannot be nil
:tenant "default"
:host "localhost"
:http-server-port 6061

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -0,0 +1,146 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"tokens/numeric-input",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u8b485740-3f39-8080-8008-400e7784f55a",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "New File 3",
"~:revn": 54,
"~:modified-at": "~m1784183383968",
"~:vern": 0,
"~:id": "~u814272d9-d3f8-812d-8008-54c11cbba219",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node",
"0017-fix-layout-flex-dir",
"0018-remove-unneeded-objects-from-components",
"0019-fix-missing-swap-slots",
"0020-sync-component-id-with-near-main",
"0021-fix-shape-svg-attrs",
"0022-normalize-component-root-and-resync",
"0023-repair-token-themes-with-inexistent-sets",
"0024b-fix-stroke-cap-placement"
]
},
"~:version": 67,
"~:project-id": "~u8b485740-3f39-8080-8008-400e7786d1d0",
"~:created-at": "~m1784121921262",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~u814272d9-d3f8-812d-8008-54c11cbba21a"
],
"~:pages-index": {
"~u814272d9-d3f8-812d-8008-54c11cbba21a": {
"~:objects": {
"~#penpot/objects-map/v2": {
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u9f14b915-410b-8062-8008-55a1fbbd5fa4\",\"~u9f14b915-410b-8062-8008-559878019a9e\"]]]",
"~u9f14b915-410b-8062-8008-559878019a9e": "[\"~#shape\",[\"^ \",\"~:y\",-1863.999482267452,\"~:background-blur\",[\"^ \",\"~:id\",\"~u9f14b915-410b-8062-8008-5598c30393d7\",\"~:type\",\"^1\",\"~:value\",8,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:key\",\"22sle9m74ka\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^=\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^=\",[[\"^ \",\"^?\",\"normal\",\"~:text-transform\",\"none\",\"~:font-id\",\"sourcesanspro\",\"^<\",\"1ixh2em79as\",\"~:font-size\",\"200\",\"~:font-weight\",\"900\",\"~:font-variant-id\",\"black\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.12]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"blur\"]],\"^@\",\"none\",\"~:text-align\",\"left\",\"^A\",\"sourcesanspro\",\"^<\",\"1x2xz4p3kcs\",\"^B\",\"200\",\"^C\",\"900\",\"~:text-direction\",\"ltr\",\"^3\",\"paragraph\",\"^D\",\"black\",\"^E\",\"none\",\"^F\",\"0\",\"^G\",[[\"^ \",\"^H\",\"#ffffff\",\"^I\",0.12]],\"^J\",\"sourcesanspro\"]]]],\"~:vertical-align\",\"top\"],\"~:hide-in-viewer\",false,\"~:name\",\"blur\",\"~:width\",375.0000213699236,\"^3\",\"^K\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",95.49998073772147,\"~:y\",-1863.999482267452]],[\"^S\",[\"^ \",\"~:x\",470.50000210764506,\"~:y\",-1863.999482267452]],[\"^S\",[\"^ \",\"~:x\",470.50000210764506,\"~:y\",-1623.9994721448552]],[\"^S\",[\"^ \",\"~:x\",95.49998073772147,\"~:y\",-1623.9994721448552]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u9f14b915-410b-8062-8008-559878019a9e\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:position-data\",[[\"^ \",\"~:y\",-1602.939453125,\"^>\",\"1.2\",\"^?\",\"normal\",\"~:typography-ref-id\",null,\"^@\",\"none\",\"^L\",\"left\",\"^A\",\"sourcesanspro\",\"^B\",\"200px\",\"^C\",\"900\",\"~:typography-ref-file\",null,\"^M\",\"ltr\",\"^Q\",374.6099853515625,\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0px\",\"~:x\",95.4999771118164,\"^G\",[[\"^ \",\"^H\",\"#ffffff\",\"^I\",0.12]],\"~:direction\",\"ltr\",\"^J\",\"sourcesanspro\",\"~:height\",282.1201171875,\"^K\",\"blur\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",95.49998073772146,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",95.49998073772146,\"~:y\",-1863.999482267452,\"^Q\",375.0000213699236,\"^Z\",240.0000101225969,\"~:x1\",95.49998073772146,\"~:y1\",-1863.999482267452,\"~:x2\",470.50000210764506,\"~:y2\",-1623.9994721448552]],\"~:flip-x\",null,\"^Z\",240.0000101225969,\"~:flip-y\",null]]",
"~u9f14b915-410b-8062-8008-55a1fbbd5fa4": "[\"~#shape\",[\"^ \",\"~:y\",-2043.9994670845845,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"background_1\",\"~:width\",599.9999796086561,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",-16.999998381644758,\"~:y\",-2043.9994670845845]],[\"^9\",[\"^ \",\"~:x\",582.9999812270113,\"~:y\",-2043.9994670845845]],[\"^9\",[\"^ \",\"~:x\",582.9999812270113,\"~:y\",-1443.9994873277228]],[\"^9\",[\"^ \",\"~:x\",-16.999998381644758,\"~:y\",-1443.9994873277228]]],\"~:r2\",0,\"~:proportion-lock\",true,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u9f14b915-410b-8062-8008-55a1fbbd5fa4\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",-16.999998381644787,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",-16.999998381644787,\"~:y\",-2043.9994670845845,\"^5\",599.9999796086561,\"~:height\",599.9999797568616,\"~:x1\",-16.999998381644787,\"~:y1\",-2043.9994670845845,\"~:x2\",582.9999812270113,\"~:y2\",-1443.9994873277228]],\"~:fills\",[[\"^ \",\"~:fill-opacity\",1,\"~:fill-image\",[\"^ \",\"^5\",1181,\"^G\",1181,\"~:mtype\",\"image/png\",\"^?\",\"~u814272d9-d3f8-812d-8008-55a1fb78211b\",\"~:keep-aspect-ratio\",true]]],\"~:flip-x\",null,\"^G\",599.9999797568616,\"~:flip-y\",null]]"
}
},
"~:id": "~u814272d9-d3f8-812d-8008-54c11cbba21a",
"~:name": "Page 1"
}
},
"~:id": "~u814272d9-d3f8-812d-8008-54c11cbba219",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@ -609,4 +609,22 @@ test("Renders a file with group with strokes and not 100% opacities", async ({
maxDiffPixelRatio: 0,
threshold: 0.01,
});
});
});
test("Renders background blur on text shapes", async ({ page }) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockFileMediaAsset(
"814272d9-d3f8-812d-8008-55a1fb78211b",
"render-wasm/assets/squares-background.png",
);
await workspace.mockGetFile("render-wasm/get-file-text-background-blur.json");
await workspace.goToWorkspace({
id: "814272d9-d3f8-812d-8008-54c11cbba219",
pageId: "814272d9-d3f8-812d-8008-54c11cbba21a",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -179,6 +179,15 @@
(and (wasm-export-enabled? state)
(contains? wasm-export-types (:type export))))
(defn- request-simple-export-wasm
[export]
(ptk/reify ::request-simple-export-wasm
ptk/EffectEvent
(effect [_ _ _]
(case (:type export)
:pdf (wasm.exports/export-pdf export)
(wasm.exports/export-image export)))))
(defn request-simple-export
[{:keys [export]}]
(ptk/reify ::request-simple-export
@ -191,11 +200,7 @@
ptk/WatchEvent
(watch [_ state _]
(if (use-wasm-export? state export)
(do
(case (:type export)
:pdf (wasm.exports/export-pdf export)
(wasm.exports/export-image export))
(rx/empty))
(rx/of (request-simple-export-wasm export))
(let [profile-id (:profile-id state)
params {:exports [export]
:profile-id profile-id

View File

@ -70,6 +70,9 @@
(= (:code error) :media-type-mismatch)
(tr "errors.media-type-mismatch")
(= (:code error) :invalid-image)
(tr "errors.media-type-not-allowed")
:else
(tr "errors.unexpected-error"))]
(rx/of (ntf/error msg))))

View File

@ -1207,9 +1207,20 @@
(dm/assert! (gpt/point? position))
(ptk/reify ::show-page-item-context-menu
ptk/WatchEvent
(watch [_ _ _]
(rx/of (show-context-menu
(-> params (assoc :kind :page :selected (:id page))))))))
(watch [_ state _]
(let [id (:id page)
selected (dm/get-in state [:workspace-local :selected-pages])
;; When the right-clicked page is part of a multi-selection we
;; keep it; otherwise the menu targets just that page.
multi? (and (contains? selected id) (> (count selected) 1))]
(rx/concat
(if multi?
(rx/empty)
(rx/of (dwpg/select-page id)))
(rx/of (show-context-menu
(-> params (assoc :kind :page
:selected id
:selected-pages (if multi? selected #{id}))))))))))
(defn show-track-context-menu
[{:keys [grid-id type index] :as params}]
@ -1651,3 +1662,11 @@
(dm/export dwpg/duplicate-page)
(dm/export dwpg/rename-page)
(dm/export dwpg/delete-page)
(dm/export dwpg/delete-pages)
(dm/export dwpg/select-page)
(dm/export dwpg/toggle-page-selection)
(dm/export dwpg/select-pages-range)
(dm/export dwpg/clear-page-selection)
;; Shapes
(dm/export dwsh/delete-shapes)

View File

@ -117,28 +117,54 @@
;; geometric attributes of the shapes.
(defn- check-delta
"If the shape is a component instance, check its relative position and rotation respect
the root of the component, and see if it changes after applying a transformation."
[shape root transformed-shape transformed-root]
(let [shape-delta
(when root
(gpt/point (- (gsh/left-bound shape) (gsh/left-bound root))
(- (gsh/top-bound shape) (gsh/top-bound root))))
"If the shape is a component instance, check whether the transformation is a
free change that must not mark the shape as touched.
transformed-shape-delta
(when transformed-root
(gpt/point (- (gsh/left-bound transformed-shape) (gsh/left-bound transformed-root))
(- (gsh/top-bound transformed-shape) (gsh/top-bound transformed-root))))
For the instance ROOT, position is free placement, but its rotation and flips
are inherited content: transforming the copy as a whole overrides them, so a
change there marks the root as touched.
For DESCENDANTS, position, size, rotation and flips are compared RELATIVE TO
THE ROOT: a transformation of the whole instance preserves them all and does
not mark the descendants as touched; editing an individual shape does."
[shape root transformed-shape transformed-root]
(let [is-root? (and (some? root)
(= (dm/get-prop shape :id) (dm/get-prop root :id)))
center (fn [shape]
(grc/rect->center (:selrect shape)))
rel-pos (fn [shape root]
(when root
;; vector from the root center to the shape center,
;; expressed in the root's local (untransformed) axes
(-> (gpt/subtract (center shape) (center root))
(gpt/transform (:transform-inverse root (gmt/matrix))))))
orientation (fn [shape root]
;; the shape's rotation/flip orientation taken from its
;; transform matrix, so it is reliable regardless of how the
;; transform was applied (the WASM apply-transform path does
;; not refresh the :rotation attribute). For the ROOT the
;; absolute orientation; for a DESCENDANT the orientation
;; relative to the root — invariant when the whole instance
;; is rotated or flipped as a unit.
(let [t (:transform shape (gmt/matrix))]
(if is-root?
t
(gmt/multiply (:transform-inverse root (gmt/matrix)) t))))
pos-before (rel-pos shape root)
pos-after (rel-pos transformed-shape transformed-root)
distance
(if (and shape-delta transformed-shape-delta)
(gpt/distance-vector shape-delta transformed-shape-delta)
(if (and pos-before pos-after)
(gpt/distance-vector pos-before pos-after)
(gpt/point 0 0))
rotation-delta
(if (and (some? (:rotation shape)) (some? (:rotation shape)))
(- (:rotation transformed-shape) (:rotation shape))
0)
orientation-unchanged?
(gmt/close? (orientation shape root)
(orientation transformed-shape transformed-root))
selrect (:selrect shape)
transformed-selrect (:selrect transformed-shape)]
@ -152,7 +178,7 @@
(and (and (< (:x distance) 1) (< (:y distance) 1))
(mth/close? (:width selrect) (:width transformed-selrect))
(mth/close? (:height selrect) (:height transformed-selrect))
(mth/close? rotation-delta 0))))
orientation-unchanged?)))
(defn calculate-ignore-tree
"Retrieves a map with the flag `ignore-geometry?` given a tree of modifiers"

View File

@ -135,7 +135,8 @@
ptk/UpdateEvent
(update [_ state]
(let [local (-> (:workspace-local state)
(dissoc :edition :edit-path :selected))
(dissoc :edition :edit-path :selected
:selected-pages :selected-pages-anchor))
exit? (not= :workspace (rt/lookup-name state))
state (-> state
(update :workspace-cache assoc [file-id page-id] local)
@ -395,3 +396,104 @@
(rx/of (dch/commit-changes changes)
(when (= id (:current-page-id state))
(dcm/go-to-workspace {:page-id (first pages)})))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Page selection (sitemap multi-selection)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn clear-page-selection
"Clear the sitemap multi-selection state."
[]
(ptk/reify ::clear-page-selection
ptk/UpdateEvent
(update [_ state]
(update state :workspace-local dissoc :selected-pages :selected-pages-anchor))))
(defn select-page
"Set the sitemap selection to a single page (plain click). It also
sets the anchor used by range (shift+click) selection."
[id]
(ptk/reify ::select-page
ptk/UpdateEvent
(update [_ state]
(update state :workspace-local assoc
:selected-pages (d/ordered-set id)
:selected-pages-anchor id))))
(defn toggle-page-selection
"Add or remove a page from the sitemap multi-selection (ctrl/cmd + click)."
[id]
(ptk/reify ::toggle-page-selection
ptk/UpdateEvent
(update [_ state]
(let [current-id (:current-page-id state)
selected (or (not-empty (dm/get-in state [:workspace-local :selected-pages]))
(d/ordered-set current-id))
selected (if (contains? selected id)
(disj selected id)
(conj selected id))]
(update state :workspace-local assoc
:selected-pages selected
:selected-pages-anchor id)))))
(defn select-pages-range
"Select every page between the current anchor and `id`, both included
(shift + click). The anchor is kept unchanged."
[id]
(ptk/reify ::select-pages-range
ptk/UpdateEvent
(update [_ state]
(let [current-id (:current-page-id state)
pages (-> (dsh/lookup-file-data state) :pages vec)
anchor (or (dm/get-in state [:workspace-local :selected-pages-anchor])
current-id)
a-idx (d/index-of pages anchor)
b-idx (d/index-of pages id)]
(if (and (some? a-idx) (some? b-idx))
(let [start (min a-idx b-idx)
end (inc (max a-idx b-idx))
range (subvec pages start end)]
(update state :workspace-local assoc
:selected-pages (into (d/ordered-set) range)))
state)))))
(defn delete-pages
"Delete a collection of pages in a single change (single undo entry),
always keeping at least one page in the file."
[ids]
(ptk/reify ::delete-pages
ptk/WatchEvent
(watch [it state _]
(let [file-id (:current-file-id state)
fdata (dsh/lookup-file-data state file-id)
pindex (:pages-index fdata)
pages (:pages fdata)
ids (set ids)
;; A file must keep at least one page: if every page is
;; selected, keep the first one in page order.
ids (if (>= (count ids) (count pages))
(set (rest (filter ids pages)))
ids)
;; Pages to delete, in page order.
del-ids (filter ids pages)
remaining (remove ids pages)
changes (reduce
(fn [changes id]
(let [page (-> (get pindex id)
(assoc :index (d/index-of pages id)))]
(-> changes
(delete-page-components page)
(pcb/del-page page))))
(-> (pcb/empty-changes it)
(pcb/with-library-data fdata))
del-ids)]
(if (empty? del-ids)
(rx/empty)
(rx/of (dch/commit-changes changes)
(clear-page-selection)
(when (contains? ids (:current-page-id state))
(dcm/go-to-workspace {:page-id (first remaining)}))))))))

View File

@ -253,6 +253,10 @@
(def editing-page-item
(l/derived :page-item workspace-local))
;; set of pages selected in the sitemap (multi-selection)
(def selected-pages
(l/derived :selected-pages workspace-local))
(def current-hover-ids
(l/derived :hover-ids context-menu))

View File

@ -17,6 +17,7 @@
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.dashboard.grid :refer [grid*]]
[app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]]
[app.main.ui.dashboard.subscription :refer [get-subscription-type]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
@ -57,10 +58,13 @@
(mf/defc header*
{::mf/private true}
[]
[:header {:class (stl/css :dashboard-header) :data-testid "dashboard-header"}
[{:keys [layout on-change]}]
[:header {:class (stl/css :dashboard-header)
:data-testid "dashboard-header"}
[:div#dashboard-deleted-title {:class (stl/css :dashboard-title)}
[:h1 (tr "dashboard.projects-title")]]])
[:h1 (tr "dashboard.projects-title")]]
[:div {:class (stl/css :dashboard-header-actions)}
[:> layout-toggle* {:layout layout :on-change on-change}]]])
(mf/defc project-context-menu*
{::mf/private true}
@ -87,18 +91,17 @@
:id "project-delete"
:handler on-delete-project}])]
[:> context-menu*
{:on-close on-close
:show show
:fixed (or (not= top 0) (not= left 0))
:min-width true
:top top
:left left
:options options}]))
[:> context-menu* {:on-close on-close
:show show
:fixed (or (not= top 0) (not= left 0))
:min-width true
:top top
:left left
:options options}]))
(mf/defc deleted-project-item*
{::mf/private true}
[{:keys [project files]}]
[{:keys [project files layout]}]
(let [project-files (filterv #(= (:project-id %) (:id project)) files)
empty? (empty? project-files)
@ -176,14 +179,14 @@
:type 1
:subtitle (tr "dashboard.empty-placeholder-files-subtitle")}]
[:> grid*
{:project project
:files project-files
:origin :deleted
:can-edit false
:can-restore true
:limit limit
:selected-files selected-files}])]]))
[:> grid* {:project project
:files project-files
:origin :deleted
:can-edit false
:can-restore true
:limit limit
:layout layout
:selected-files selected-files}])]]))
(mf/defc menu*
{::mf/private true}
@ -217,7 +220,15 @@
(mf/defc deleted-section*
[{:keys [team projects]}]
(let [deleted-map
(let [layout* (hooks/use-persisted-state lt/layout-key lt/default-layout)
layout (deref layout*)
on-layout-change
(mf/use-fn
(fn [value]
(reset! layout* (keyword value))))
deleted-map
(mf/deref ref:deleted-files)
projects
@ -241,7 +252,7 @@
;; Calculate deletion days based on team subscription
deletion-days
(let [profile (mf/deref refs/profile)
(let [profile (mf/deref refs/profile)
subscription-type (get-subscription-type (:subscription team))
nitrate-type (get-in profile [:subscription :type])
nitrate-active? (dnt/is-valid-license? profile)]
@ -286,13 +297,16 @@
(dd/clear-selected-files)))
[:*
[:> header* {:team team}]
[:> header* {:team team
:layout layout
:on-change on-layout-change}]
[:section {:class (stl/css :dashboard-container :no-bg)
:data-testid "deleted-page-section"}
[:*
[:div {:class (stl/css :no-bg)}
[:> menu* {:team-id team-id :section :dashboard-deleted}]
[:> menu* {:team-id team-id
:section :dashboard-deleted}]
(if (seq projects)
[:*
@ -322,6 +336,7 @@
(sort-by :modified-at #(compare %2 %1))))]
[:> deleted-project-item* {:project project
:files files
:layout layout
:key id}]))]
;; when no deleted projects

View File

@ -16,6 +16,7 @@
[app.main.store :as st]
[app.main.ui.dashboard.grid :refer [grid*]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]]
[app.main.ui.dashboard.pin-button :refer [pin-button*]]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
@ -32,7 +33,7 @@
(mf/defc header*
{::mf/private true}
[{:keys [project create-fn can-edit]}]
[{:keys [project create-fn can-edit layout on-change]}]
(let [project-id (:id project)
local
@ -73,7 +74,8 @@
(dd/clear-selected-files))))]
[:header {:class (stl/css :dashboard-header) :data-testid "dashboard-header"}
[:header {:class (stl/css :dashboard-header)
:data-testid "dashboard-header"}
(if (:is-default project)
[:div#dashboard-drafts-title {:class (stl/css :dashboard-title)}
[:h1 (tr "labels.drafts")]]
@ -95,6 +97,9 @@
(:name project)]]))
[:div {:class (stl/css :dashboard-header-actions)}
[:> layout-toggle* {:layout layout
:on-change on-change}]
(when ^boolean can-edit
[:a {:class (stl/css :btn-secondary :btn-small :new-file)
:tab-index "0"
@ -155,6 +160,14 @@
selected-files (mf/deref refs/selected-files)
layout* (hooks/use-persisted-state lt/layout-key lt/default-layout)
layout (deref layout*)
on-layout-change
(mf/use-fn
(fn [value]
(reset! layout* (keyword value))))
on-file-created
(mf/use-fn
(fn [file-data]
@ -188,7 +201,9 @@
[:> header* {:team team
:can-edit can-edit?
:project project
:create-fn create-file}]
:create-fn create-file
:layout layout
:on-change on-layout-change}]
[:section {:class (stl/css :dashboard-container :no-bg)
:ref rowref}
(if empty-state-viewer
@ -206,5 +221,5 @@
:can-edit can-edit?
:origin :files
:create-fn create-file
:limit limit}])]]))
:limit limit
:layout layout}])]]))

View File

@ -31,9 +31,9 @@
[app.main.ui.dashboard.import :refer [use-import-file]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.placeholder :refer [empty-grid-placeholder* loading-placeholder*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.hooks :as h]
[app.main.ui.icons :as deprecated-icon]
[app.main.worker :as mw]
[app.util.color :as uc]
[app.util.dom :as dom]
@ -108,8 +108,8 @@
:message (ex-message cause)))))]
(partial rx/dispose! subscription))))
[:div {:class (stl/css-case :grid-item-th true
:deleted-item can-restore)
[:div {:class (stl/css-case :grid-item-thumbnail true
:is-deleted can-restore)
:style {:background-color bg-color}
:ref container}
(when visible?
@ -127,9 +127,6 @@
;; --- Grid Item Library
(def ^:private menu-icon
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
(mf/defc grid-item-library*
[{:keys [file can-restore]}]
(mf/with-effect [file]
@ -137,113 +134,115 @@
(let [font-ids (map :font-id (get-in file [:library-summary :typographies :sample] []))]
(run! fonts/ensure-loaded! font-ids))))
[:div {:class (stl/css-case :grid-item-th true
:library true
:deleted-item can-restore)}
[:div {:class (stl/css-case :library-thumbnail true
:is-deleted can-restore)}
(if (nil? file)
[:> loader* {:class (stl/css :grid-loader)
:overlay true
:title (tr "labels.loading")}]
(let [summary (:library-summary file)
components (:components summary)
colors (:colors summary)
(let [summary (:library-summary file)
components (:components summary)
colors (:colors summary)
typographies (:typographies summary)]
[:*
(when (and (zero? (:count components)) (zero? (:count colors)) (zero? (:count typographies)))
[:*
[:div {:class (stl/css :asset-section)}
[:div {:class (stl/css :asset-title)}
[:div {:class (stl/css :library-asset-section)}
[:div {:class (stl/css :library-asset-title)}
[:span (tr "workspace.assets.components")]
[:span {:class (stl/css :num-assets)} (str "\u00A0(") 0 ")"]]] ;; Unicode 00A0 is non-breaking space
[:div {:class (stl/css :asset-section)}
[:div {:class (stl/css :asset-title)}
[:span {:class (stl/css :library-num-assets)} (str "\u00A0(") 0 ")"]]] ;; Unicode 00A0 is non-breaking space
[:div {:class (stl/css :library-asset-section)}
[:div {:class (stl/css :library-asset-title)}
[:span (tr "workspace.assets.colors")]
[:span {:class (stl/css :num-assets)} (str "\u00A0(") 0 ")"]]] ;; Unicode 00A0 is non-breaking space
[:div {:class (stl/css :asset-section)}
[:div {:class (stl/css :asset-title)}
[:span {:class (stl/css :library-num-assets)} (str "\u00A0(") 0 ")"]]] ;; Unicode 00A0 is non-breaking space
[:div {:class (stl/css :library-asset-section)}
[:div {:class (stl/css :library-asset-title)}
[:span (tr "workspace.assets.typography")]
[:span {:class (stl/css :num-assets)} (str "\u00A0(") 0 ")"]]]]) ;; Unicode 00A0 is non-breaking space
[:span {:class (stl/css :library-num-assets)} (str "\u00A0(") 0 ")"]]]]) ;; Unicode 00A0 is non-breaking space
(when (pos? (:count components))
[:div {:class (stl/css :asset-section)}
[:div {:class (stl/css :asset-title)}
[:div {:class (stl/css :library-asset-section)}
[:div {:class (stl/css :library-asset-title)}
[:span (tr "workspace.assets.components")]
[:span {:class (stl/css :num-assets)} (str "\u00A0(") (:count components) ")"]] ;; Unicode 00A0 is non-breaking space
[:div {:class (stl/css :asset-list)}
[:span {:class (stl/css :library-num-assets)} (str "\u00A0(") (:count components) ")"]] ;; Unicode 00A0 is non-breaking space
[:div {:class (stl/css :library-asset-list)}
(for [component (:sample components)]
(let [root-id (:main-instance-id component)]
[:div {:class (stl/css :asset-list-item)
[:div {:class (stl/css :library-asset-item)
:key (str "assets-component-" (:id component))}
[:& render/component-svg {:root-shape (get-in component [:objects root-id])
[:& render/component-svg {:class (stl/css :library-asset-icon)
:root-shape (get-in component [:objects root-id])
:objects (:objects component)}] ;; Components in the summary come loaded with objects, even in v2
[:div {:class (stl/css :name-block)}
[:span {:class (stl/css :item-name)
[:div {:class (stl/css :library-name-block)}
[:span {:class (stl/css :library-item-name)
:title (:name component)}
(:name component)]]]))
(when (> (:count components) (count (:sample components)))
[:div {:class (stl/css :asset-list-item)}
[:div {:class (stl/css :name-block)}
[:span {:class (stl/css :item-name)} "(...)"]]])]])
[:div {:class (stl/css :library-asset-item)}
[:div {:class (stl/css :library-name-block)}
[:span {:class (stl/css :library-item-name)} "(...)"]]])]])
(when (pos? (:count colors))
[:div {:class (stl/css :asset-section)}
[:div {:class (stl/css :asset-title)}
[:div {:class (stl/css :library-asset-section)}
[:div {:class (stl/css :library-asset-title)}
[:span (tr "workspace.assets.colors")]
[:span {:class (stl/css :num-assets)} (str "\u00A0(") (:count colors) ")"]] ;; Unicode 00A0 is non-breaking space
[:div {:class (stl/css :asset-list)}
[:span {:class (stl/css :library-num-assets)} (str "\u00A0(") (:count colors) ")"]] ;; Unicode 00A0 is non-breaking space
[:div {:class (stl/css :library-asset-list)}
(for [color (:sample colors)]
(let [default-name (cond
(:gradient color) (uc/gradient-type->string (get-in color [:gradient :type]))
(:color color) (:color color)
:else (:value color))]
[:div {:class (stl/css :asset-list-item :color-item)
[:div {:class (stl/css :library-asset-item :library-color-item)
:key (str "assets-color-" (:id color))}
[:> bc/color-bullet* {:color {:color (:color color)
:id (:id color)
:opacity (:opacity color)}
:mini true}]
[:div {:class (stl/css :name-block)}
[:span {:class (stl/css :color-name)} (:name color)]
[:div {:class (stl/css :library-name-block)}
[:span {:class (stl/css :library-color-name)} (:name color)]
(when-not (= (:name color) default-name)
[:span {:class (stl/css :color-value)} (:color color)])]]))
[:span {:class (stl/css :library-color-value)} (:color color)])]]))
(when (> (:count colors) (count (:sample colors)))
[:div {:class (stl/css :asset-list-item)}
[:div {:class (stl/css :name-block)}
[:span {:class (stl/css :item-name)} "(...)"]]])]])
[:div {:class (stl/css :library-asset-item)}
[:div {:class (stl/css :library-name-block)}
[:span {:class (stl/css :library-item-name)} "(...)"]]])]])
(when (pos? (:count typographies))
[:div {:class (stl/css :asset-section)}
[:div {:class (stl/css :asset-title)}
[:div {:class (stl/css :library-asset-section)}
[:div {:class (stl/css :library-asset-title)}
[:span (tr "workspace.assets.typography")]
[:span {:class (stl/css :num-assets)} (str "\u00A0(") (:count typographies) ")"]] ;; Unicode 00A0 is non-breaking space
[:div {:class (stl/css :asset-list)}
[:span {:class (stl/css :library-num-assets)} (str "\u00A0(") (:count typographies) ")"]] ;; Unicode 00A0 is non-breaking space
[:div {:class (stl/css :library-asset-list)}
(for [typography (:sample typographies)]
[:div {:class (stl/css :asset-list-item)
[:div {:class (stl/css :library-asset-item)
:key (str "assets-typography-" (:id typography))}
[:div {:class (stl/css :typography-sample)
[:div {:class (stl/css :library-typography-sample)
:style {:font-family (:font-family typography)
:font-weight (:font-weight typography)
:font-style (:font-style typography)}}
(tr "workspace.assets.typography.sample")]
[:div {:class (stl/css :name-block)}
[:span {:class (stl/css :item-name)
[:div {:class (stl/css :library-name-block)}
[:span {:class (stl/css :library-item-name)
:title (:name typography)}
(:name typography)]]])
(when (> (:count typographies) (count (:sample typographies)))
[:div {:class (stl/css :asset-list-item)}
[:div {:class (stl/css :name-block)}
[:span {:class (stl/css :item-name)} "(...)"]]])]])]))])
[:div {:class (stl/css :library-asset-item)}
[:div {:class (stl/css :library-name-block)}
[:span {:class (stl/css :library-item-name)} "(...)"]]])]])]))])
;; --- Grid Item
(mf/defc grid-item-metadata*
[{:keys [file]}]
{::mf/private true}
[{:keys [file layout]}]
(let [time (ct/timeago (or (:will-be-deleted-at file)
(:modified-at file)))]
[:span {:class (stl/css :date)
[:span {:class (stl/css-case :grid-item-date (= layout :grid)
:list-item-date (= layout :list))
:title (tr "dashboard.deleted.will-be-deleted-at" time)}
time]))
@ -255,31 +254,32 @@
counter-el))
(mf/defc grid-item*
[{:keys [file origin can-edit selected-files can-restore]}]
(let [file-id (get file :id)
state (mf/deref refs/dashboard-local)
{::mf/private true}
[{:keys [file origin can-edit selected-files can-restore layout]}]
(let [node-ref (mf/use-ref)
menu-ref (mf/use-ref)
menu-pos
(get state :menu-pos)
state (mf/deref refs/dashboard-local)
menu-open?
(and (get state :menu-open)
(= file-id (:file-id state)))
file-id (get file :id)
selected?
(contains? selected-files file-id)
menu-pos (get state :menu-pos)
menu-open? (and (get state :menu-open)
(= file-id (:file-id state)))
selected-num
(count selected-files)
selected? (contains? selected-files file-id)
selected-num (count selected-files)
node-ref (mf/use-ref)
menu-ref (mf/use-ref)
list? (= layout :list)
is-library-view?
(= origin :libraries)
editing? (and (= file-id (:file-id state))
(:edition state))
library-view? (= origin :libraries)
on-menu-close
(mf/use-fn #(st/emit! (dd/hide-file-menu)))
(mf/use-fn
#(st/emit! (dd/hide-file-menu)))
on-select
(mf/use-fn
@ -335,29 +335,32 @@
on-menu-click
(mf/use-fn
(mf/deps file selected?)
(mf/deps file selected? menu-open?)
(fn [event]
(dom/stop-propagation event)
(when-not selected?
(when-not (kbd/shift? event)
(st/emit! (dd/clear-selected-files)))
(if menu-open?
(st/emit! (dd/hide-file-menu))
(do
(st/emit! (dd/toggle-file-select file))))
(when-not selected?
(when-not (kbd/shift? event)
(st/emit! (dd/clear-selected-files)))
(st/emit! (dd/toggle-file-select file)))
(let [client-position
(dom/get-client-position event)
(let [client-position
(dom/get-client-position event)
position
(if (and (nil? (:y client-position)) (nil? (:x client-position)))
(let [target-element (dom/get-target event)
points (dom/get-bounding-rect target-element)
y (:top points)
x (:left points)]
(gpt/point x y))
client-position)]
position
(if (and (nil? (:y client-position)) (nil? (:x client-position)))
(let [target-element (dom/get-target event)
points (dom/get-bounding-rect target-element)
y (:top points)
x (:left points)]
(gpt/point x y))
client-position)]
(st/emit! (dd/show-file-menu-with-position file-id position)))))
(st/emit! (dd/show-file-menu-with-position file-id position)))))))
on-context-menu
(mf/use-fn
@ -390,7 +393,10 @@
(when (kbd/enter? event)
(on-navigate event))
(when (kbd/shift? event)
(when (or (kbd/down-arrow? event) (kbd/left-arrow? event) (kbd/up-arrow? event) (kbd/right-arrow? event))
(when (or (kbd/down-arrow? event)
(kbd/left-arrow? event)
(kbd/up-arrow? event)
(kbd/right-arrow? event))
;; TODO Fix this
(on-select event)))))
@ -401,73 +407,117 @@
(when (kbd/enter? event)
(dom/stop-propagation event)
(dom/prevent-default event)
(on-menu-click event))))]
(on-menu-click event))))
[:li {:class (stl/css-case :grid-item true
:project-th true
:library is-library-view?)}
[:div
{:class (stl/css-case :selected selected?
:library is-library-view?)
:ref node-ref
:role "button"
:title (:name file)
:aria-label (:name file)
:draggable (dm/str can-edit)
:on-click on-select
:on-key-down on-key-down
:on-double-click on-navigate
:on-drag-start on-drag-start
:on-context-menu on-context-menu}
;; The options menu is identical in both layouts, so we build it once
;; and place it where each layout needs it. NOTE: hiccup bound in a
;; let is not compiled by rumext, so it must be wrapped in mf/html.
menu-element
(mf/html
[:div {:class (stl/css-case :project-thumbnail-actions true
:is-force-display menu-open?)}
[:div {:class (stl/css :project-thumbnail-icon :menu)
:tab-index "0"
:role "button"
:aria-label (tr "dashboard.options")
:ref menu-ref
:id (dm/str file-id "-action-menu")
:on-click on-menu-click
:on-key-down on-menu-key-down}
[:div {:class (stl/css :overlay)}]
[:> icon* {:icon-id i/menu
:class (stl/css :menu-icon)}]
(if ^boolean is-library-view?
[:> grid-item-library* {:file file :can-restore can-restore}]
[:> grid-item-thumbnail* {:file file :can-edit can-edit :can-restore can-restore}])
(when (and selected? menu-open?)
;; When the menu is open we disable events in the dashboard. We need to force pointer events
;; so the menu can be handled
[:> portal-on-document* {}
[:> file-menu* {:files (vals selected-files)
:left (+ 24 (:x menu-pos))
:top (:y menu-pos)
:can-edit can-edit
:navigate true
:on-edit on-edit
:on-close on-menu-close
:origin origin
:parent-id (dm/str file-id "-action-menu")
:can-restore can-restore}]])]])]
(when (and (:is-shared file) (not is-library-view?))
[:div {:class (stl/css :item-badge)} deprecated-icon/library])
(if ^boolean list?
[:li {:class (stl/css-case :grid-item true
:list-item true
:library-item library-view?)}
[:div
{:class (stl/css-case :list-item-row true
:is-selected selected?)
:ref node-ref
:role "button"
:title (:name file)
:aria-label (:name file)
:draggable (dm/str can-edit)
:on-click on-select
:on-key-down on-key-down
:on-double-click on-navigate
:on-drag-start on-drag-start
:on-context-menu on-context-menu}
[:div {:class (stl/css :info-wrapper)}
[:div {:class (stl/css :item-info)}
(if (and (= file-id (:file-id state)) (:edition state))
(if ^boolean editing?
[:& inline-edition {:content (:name file)
:on-end edit
:max-length 250}]
[:h3 (:name file)])
[:> grid-item-metadata* {:file file}]]
[:h3 {:class (stl/css :list-item-name)} (:name file)])
[:div {:class (stl/css-case :project-th-actions true :force-display menu-open?)}
[:div
{:class (stl/css :project-th-icon :menu)
:tab-index "0"
:role "button"
:aria-label (tr "dashboard.options")
:ref menu-ref
:id (dm/str file-id "-action-menu")
:on-click on-menu-click
:on-key-down on-menu-key-down}
(when (and (:is-shared file) (not library-view?))
[:span {:class (stl/css :list-item-badge)
:aria-label (tr "workspace.assets.shared-library")
:title (tr "workspace.assets.shared-library")}
[:> icon* {:icon-id i/library}]])
menu-icon
(when (and selected? menu-open?)
;; When the menu is open we disable events in the dashboard. We need to force pointer events
;; so the menu can be handled
[:> portal-on-document* {}
[:> file-menu* {:files (vals selected-files)
:left (+ 24 (:x menu-pos))
:top (:y menu-pos)
:can-edit can-edit
:navigate true
:on-edit on-edit
:on-close on-menu-close
:origin origin
:parent-id (dm/str file-id "-action-menu")
:can-restore can-restore}]])]]]]]))
[:> grid-item-metadata* {:file file :layout :list}]
menu-element]]
[:li {:class (stl/css-case :grid-item true
:project-thumbnail true
:library-item library-view?)}
[:div {:class (stl/css-case :is-selected selected?)
:ref node-ref
:role "button"
:title (:name file)
:aria-label (:name file)
:draggable (dm/str can-edit)
:on-click on-select
:on-key-down on-key-down
:on-double-click on-navigate
:on-drag-start on-drag-start
:on-context-menu on-context-menu}
(if ^boolean library-view?
[:> grid-item-library* {:file file
:can-restore can-restore}]
[:> grid-item-thumbnail* {:file file
:can-edit can-edit
:can-restore can-restore}])
(when (and (:is-shared file) (not library-view?))
[:div {:class (stl/css :grid-item-badge)}
[:> icon* {:icon-id i/library}]])
[:div {:class (stl/css :grid-item-info)}
[:div {:class (stl/css :grid-item-meta)}
(if ^boolean editing?
[:& inline-edition {:content (:name file)
:on-end edit
:max-length 250}]
[:h3 {:class (stl/css :grid-item-title)} (:name file)])
[:> grid-item-metadata* {:file file :layout :grid}]]
menu-element]]])))
(mf/defc grid*
[{:keys [files project origin limit create-fn can-edit selected-files can-restore]}]
[{:keys [files project origin limit create-fn can-edit selected-files can-restore layout]}]
(let [dragging? (mf/use-state false)
list? (= layout :list)
project-id (get project :id)
team-id (get project :team-id)
@ -534,6 +584,19 @@
(nil? files)
[:> loading-placeholder*]
(and (seq files) list?)
[:ul {:class (stl/css :grid-row :list-view)}
(when @dragging?
[:li {:class (stl/css :list-item-dragged)}])
(for [item files]
[:> grid-item* {:file item
:key (dm/str (:id item))
:origin origin
:selected-files selected-files
:can-edit can-edit
:can-restore can-restore
:layout :list}])]
(seq files)
(for [[index slice] (d/enumerate (partition-all limit files))]
@ -541,45 +604,57 @@
(when @dragging?
[:li {:class (stl/css :grid-item)}])
(for [item slice]
[:> grid-item*
{:file item
:key (dm/str (:id item))
:origin origin
:selected-files selected-files
:can-edit can-edit
:can-restore can-restore}])])
[:> grid-item* {:file item
:key (dm/str (:id item))
:origin origin
:selected-files selected-files
:can-edit can-edit
:can-restore can-restore}])])
:else
[:> empty-grid-placeholder*
{:limit limit
:can-edit can-edit
:create-fn create-fn
:origin origin
:project-id project-id
:team-id team-id
:on-finish-import on-finish-import}])]))
[:> empty-grid-placeholder* {:limit limit
:can-edit can-edit
:create-fn create-fn
:origin origin
:project-id project-id
:team-id team-id
:on-finish-import on-finish-import}])]))
(mf/defc line-grid-row
[{:keys [files selected-files dragging? limit can-edit can-restore] :as props}]
(mf/defc line-grid-row*
{::mf/private true}
[{:keys [files selected-files is-dragging limit can-edit can-restore layout]}]
(let [elements limit
limit (if dragging? (dec limit) limit)]
[:ul {:class (stl/css :grid-row :no-wrap)
:style {:grid-template-columns (dm/str "repeat(" elements ", 1fr)")}}
limit (if is-dragging (dec limit) limit)
list? (= layout :list)]
(if ^boolean list?
[:ul {:class (stl/css :grid-row :list-view)}
(when is-dragging
[:li {:class (stl/css :list-item-dragged)}])
(for [item (take limit files)]
[:> grid-item* {:id (:id item)
:file item
:selected-files selected-files
:can-edit can-edit
:key (dm/str (:id item))
:can-restore can-restore
:layout :list}])]
(when dragging?
[:li {:class (stl/css :grid-item :dragged)}])
[:ul {:class (stl/css :grid-row :no-wrap)
:style {:grid-template-columns (dm/str "repeat(" elements ", 1fr)")}}
(for [item (take limit files)]
[:> grid-item*
{:id (:id item)
:file item
:selected-files selected-files
:can-edit can-edit
:key (dm/str (:id item))
:can-restore can-restore}])]))
(when is-dragging
[:li {:class (stl/css :grid-item :is-dragged)}])
(mf/defc line-grid
[{:keys [project team files limit create-fn can-edit can-restore] :as props}]
(for [item (take limit files)]
[:> grid-item* {:id (:id item)
:file item
:selected-files selected-files
:can-edit can-edit
:key (dm/str (:id item))
:can-restore can-restore}])])))
(mf/defc line-grid*
[{:keys [project team files limit create-fn can-edit can-restore layout]}]
(let [dragging? (mf/use-state false)
project-id (:id project)
team-id (:id team)
@ -672,20 +747,20 @@
[:> loading-placeholder*]
(seq files)
[:& line-grid-row {:files files
:team-id team-id
:selected-files selected-files
:dragging? @dragging?
:can-edit can-edit
:limit limit
:can-restore can-restore}]
[:> line-grid-row* {:files files
:team-id team-id
:selected-files selected-files
:is-dragging @dragging?
:can-edit can-edit
:limit limit
:can-restore can-restore
:layout layout}]
:else
[:> empty-grid-placeholder*
{:is-dragging @dragging?
:limit limit
:can-edit can-edit
:create-fn create-fn
:project-id project-id
:team-id team-id
:on-finish-import on-finish-import}])]))
[:> empty-grid-placeholder* {:is-dragging @dragging?
:limit limit
:can-edit can-edit
:create-fn create-fn
:project-id project-id
:team-id team-id
:on-finish-import on-finish-import}])]))

View File

@ -4,247 +4,214 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/typography.scss" as t;
@use "ds/z-index.scss" as *;
// VARIABLES
// TODO: Legacy sass variables. We should remove them in favor of DS tokens.
$bp-max-1366: "(max-width: 1366px)";
$thumbnail-default-width: deprecated.$s-252; // Default width
$thumbnail-default-height: deprecated.$s-168; // Default width
$thumbnail-default-width: $sz-252;
$thumbnail-default-height: px2rem(168);
// DASHBOARD GRID
.dashboard-grid {
font-size: deprecated.$fs-14;
height: 100%;
@include t.use-typography("body-medium");
block-size: 100%;
overflow: hidden auto;
padding: 0 var(--sp-l) deprecated.$s-16;
padding: 0 var(--sp-l) var(--sp-l);
}
// GRID ROW
.grid-row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: calc(deprecated.$s-12 + var(--th-width, #{$thumbnail-default-width}));
width: 100%;
gap: deprecated.$s-24;
grid-auto-columns: calc(var(--sp-m) + var(--thumbnail-width, #{$thumbnail-default-width}));
inline-size: 100%;
gap: var(--sp-xxl);
&.list-view {
display: flex;
flex-direction: column;
grid-auto-flow: initial;
grid-auto-columns: initial;
gap: var(--sp-s);
padding: var(--sp-s) 0;
inline-size: 100%;
}
}
// CARD VIEW: GRID ITEM
.grid-item {
align-items: center;
cursor: pointer;
display: flex;
flex-direction: column;
margin: deprecated.$s-12 0;
margin: var(--sp-m) 0;
position: relative;
text-align: center;
a,
button {
width: 100%;
font-weight: deprecated.$fw400;
}
button {
background-color: transparent;
border: none;
padding: 0 deprecated.$s-6;
}
.grid-item-th {
border-radius: deprecated.$br-8;
&.is-dragged {
border-radius: $br-4;
outline: $br-4 solid var(--color-accent-primary);
text-align: initial;
width: var(--th-width, #{$thumbnail-default-width});
height: var(--th-height, #{$thumbnail-default-height});
background-size: cover;
overflow: hidden;
img {
object-fit: contain;
}
}
&.dragged {
border-radius: deprecated.$br-4;
outline: deprecated.$br-4 solid var(--color-accent-primary);
text-align: initial;
width: calc(var(--th-width) + deprecated.$s-12);
height: var(--th-height, #{$thumbnail-default-height});
}
&.overlay {
border-radius: deprecated.$br-4;
border: deprecated.$s-2 solid var(--color-accent-tertiary);
height: 100%;
opacity: 0;
pointer-events: none;
position: absolute;
width: 100%;
z-index: deprecated.$z-index-1;
}
&:hover .overlay {
display: block;
opacity: 1;
}
.info-wrapper {
display: grid;
grid-template-columns: 1fr auto;
cursor: pointer;
max-width: var(--th-width, $thumbnail-default-width);
}
.item-info {
display: grid;
padding: deprecated.$s-8;
text-align: left;
width: 100%;
font-size: deprecated.$fs-12;
h3 {
border: deprecated.$s-1 solid transparent;
color: var(--color-foreground-primary);
font-size: deprecated.$fs-16;
font-weight: deprecated.$fw400;
height: deprecated.$s-28;
line-height: 1.92;
max-width: deprecated.$s-260;
overflow: hidden;
padding: 0;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
@media #{$bp-max-1366} {
max-width: deprecated.$s-232;
}
}
.date {
color: var(--color-foreground-secondary);
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
white-space: nowrap;
max-width: deprecated.$s-260;
&::first-letter {
text-transform: capitalize;
}
@media #{$bp-max-1366} {
max-width: deprecated.$s-232;
}
}
}
.item-badge {
background-color: var(--color-accent-primary);
border: none;
border-radius: deprecated.$br-6;
position: absolute;
top: deprecated.$s-12;
right: deprecated.$s-12;
height: deprecated.$s-32;
width: deprecated.$s-32;
display: flex;
align-items: center;
justify-content: center;
svg {
stroke: var(--color-background-secondary);
fill: none;
height: deprecated.$s-16;
width: deprecated.$s-16;
}
}
&.add-file {
border: deprecated.$s-1 dashed var(--color-foreground-secondary);
justify-content: center;
box-shadow: none;
span {
color: var(--color-background-primary);
font-size: deprecated.$fs-14;
}
&:hover {
background-color: var(--color-foreground-primary);
border: deprecated.$s-2 solid var(--color-accent-tertiary);
}
inline-size: calc(var(--thumbnail-width) + var(--sp-m));
block-size: var(--thumbnail-height, #{$thumbnail-default-height});
}
}
.drag-counter {
position: absolute;
top: deprecated.$s-4;
left: deprecated.$s-4;
width: deprecated.$s-32;
height: deprecated.$s-32;
background-color: var(--color-accent-tertiary);
border-radius: deprecated.$br-circle;
.grid-item-info {
display: grid;
grid-template-columns: 1fr auto;
max-inline-size: var(--thumbnail-width, #{$thumbnail-default-width});
}
.grid-item-meta {
@include t.use-typography("body-small");
display: grid;
padding: var(--sp-s);
text-align: left;
inline-size: 100%;
}
.grid-item-title {
@include t.use-typography("body-large");
border: $b-1 solid transparent;
color: var(--color-foreground-primary);
line-height: 1.92;
block-size: $sz-28;
max-inline-size: px2rem(260);
overflow: hidden;
padding: 0;
text-overflow: ellipsis;
white-space: nowrap;
inline-size: 100%;
@media #{$bp-max-1366} {
max-inline-size: px2rem(232);
}
}
.grid-item-date {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
overflow: hidden;
text-overflow: ellipsis;
inline-size: 100%;
white-space: nowrap;
max-inline-size: px2rem(260);
&::first-letter {
text-transform: capitalize;
}
@media #{$bp-max-1366} {
max-inline-size: px2rem(232);
}
}
.grid-item-badge {
color: var(--color-background-secondary);
font-size: deprecated.$fs-16;
background-color: var(--color-accent-primary);
border: none;
border-radius: $br-6;
position: absolute;
inset-block-start: var(--sp-m);
inset-inline-end: var(--sp-m);
block-size: $sz-32;
inline-size: $sz-32;
display: flex;
justify-content: center;
align-items: center;
justify-content: center;
}
// PROJECTS, ELEMENTS & ICONS GRID
.project-th {
.grid-item-thumbnail {
border-radius: $br-8;
block-size: var(--thumbnail-height, #{$thumbnail-default-height});
inline-size: var(--thumbnail-width, #{$thumbnail-default-width});
overflow: hidden;
position: relative;
text-align: initial;
background-size: cover;
&.is-deleted {
opacity: 0.5;
}
}
.grid-item-thumbnail-image {
object-fit: contain;
block-size: auto;
inline-size: 100%;
}
// CARD VIEW: PROJECT THUMBNAIL
.project-thumbnail {
background-color: transparent;
border-radius: deprecated.$br-8;
padding-top: deprecated.$s-6;
border-radius: $br-8;
padding-block-start: $sz-6;
&.library-item {
block-size: px2rem(612);
}
&:hover,
&:focus,
&:focus-within {
background-color: var(--color-background-tertiary);
.project-th-actions {
.project-thumbnail-actions {
opacity: 1;
}
a {
text-decoration: none;
}
}
.selected {
.grid-item-th {
outline: deprecated.$s-4 solid var(--color-accent-tertiary);
}
.is-selected .grid-item-thumbnail {
outline: px2rem(4) solid var(--color-accent-tertiary);
}
}
.project-th-actions {
.project-thumbnail-actions {
align-items: center;
display: flex;
height: 100%;
block-size: 100%;
justify-content: center;
opacity: 0;
right: deprecated.$s-6;
width: deprecated.$s-32;
inset-inline-end: $sz-6;
inline-size: $sz-32;
span {
color: var(--color-background-secondary);
&.is-force-display {
opacity: 1;
}
}
.project-th-icon {
.project-thumbnail-icon {
align-items: center;
display: flex;
margin-right: deprecated.$s-8;
margin-top: 0;
margin-inline-end: var(--sp-s);
margin-block-start: 0;
}
// CARD VIEW: MENU
.menu {
align-items: flex-end;
display: flex;
flex-direction: column;
height: deprecated.$s-32;
block-size: $sz-32;
justify-content: center;
margin-right: 0;
margin-top: deprecated.$s-20;
width: 100%;
margin-inline-end: 0;
margin-block-start: var(--sp-xl);
inline-size: 100%;
--menu-icon-color: var(--button-tertiary-foreground-color-rest);
@ -255,130 +222,219 @@ $thumbnail-default-height: deprecated.$s-168; // Default width
}
.menu-icon {
stroke: var(--menu-icon-color);
fill: none;
margin-right: 0;
height: deprecated.$s-16;
width: deprecated.$s-16;
color: var(--menu-icon-color);
margin-inline-end: 0;
block-size: $sz-16;
inline-size: $sz-16;
}
.project-th-actions.force-display {
opacity: 1;
}
// LIST VIEW
.grid-item-th {
border-radius: deprecated.$br-4;
cursor: pointer;
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
display: flex;
justify-content: center;
.list-item {
align-items: stretch;
flex-direction: row;
margin: 0;
text-align: initial;
inline-size: 100%;
.img-th {
height: auto;
width: 100%;
}
}
// LIBRARY VIEW
.library {
height: deprecated.$s-580;
}
.grid-item.project-th.library {
height: deprecated.$s-612;
}
.grid-item-th.library {
background-color: var(--color-background-tertiary);
flex-direction: column;
height: 90%;
justify-content: flex-start;
max-height: deprecated.$s-580;
padding: deprecated.$s-32;
.asset-section {
font-size: deprecated.$fs-12;
color: var(--color-foreground-secondary);
&:not(:first-child) {
margin-top: deprecated.$s-16;
}
// Reuse the grid options menu, but neutralize the card-oriented offsets so
// it sits inline at the end of the row.
.project-thumbnail-actions {
flex: 0 0 auto;
block-size: auto;
opacity: 1;
inline-size: auto;
}
.asset-title {
display: flex;
font-size: deprecated.$fs-12;
text-transform: uppercase;
.num-assets {
color: var(--color-foreground-secondary);
}
.project-thumbnail-icon {
margin: 0;
}
.asset-list-item {
.menu {
align-items: center;
border-radius: deprecated.$br-4;
border: deprecated.$s-1 solid transparent;
color: var(--color-foreground-primary);
display: flex;
font-size: deprecated.$fs-12;
margin-top: deprecated.$s-4;
padding: deprecated.$s-2;
position: relative;
.name-block {
color: var(--color-foreground-secondary);
width: calc(100% - deprecated.$s-24 - deprecated.$s-8);
}
.item-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
svg {
background-color: var(--color-canvas);
border-radius: deprecated.$br-4;
border: deprecated.$s-2 solid transparent;
height: deprecated.$s-24;
margin-right: deprecated.$s-8;
width: deprecated.$s-24;
}
.color-name {
color: var(--color-foreground-primary);
}
.color-value {
color: var(--color-foreground-secondary);
margin-left: deprecated.$s-4;
text-transform: uppercase;
}
.typography-sample {
height: deprecated.$s-20;
margin-right: deprecated.$s-4;
width: deprecated.$s-20;
}
flex-direction: row;
block-size: $sz-32;
margin: 0;
inline-size: auto;
}
}
.color-item {
.list-item-dragged {
border-radius: $br-8;
block-size: $sz-48;
outline: $br-4 solid var(--color-accent-primary);
outline-offset: -$br-4;
inline-size: 100%;
}
.list-item-row {
align-items: center;
border-radius: $br-8;
border: $b-1 solid var(--color-background-quaternary);
display: flex;
gap: var(--sp-m);
padding: var(--sp-s) var(--sp-m);
inline-size: 100%;
&:hover,
&:focus,
&:focus-within {
background-color: var(--color-background-tertiary);
}
&.is-selected {
background-color: var(--color-background-tertiary);
outline: $b-2 solid var(--color-accent-tertiary);
}
}
.list-item-name {
@include t.use-typography("body-medium");
color: var(--color-foreground-primary);
flex: 0 1 auto;
margin: 0;
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-item-badge {
align-items: center;
color: var(--color-background-secondary);
background-color: var(--color-accent-primary);
border-radius: $br-6;
display: flex;
flex: 0 0 auto;
justify-content: center;
block-size: $sz-24;
inline-size: $sz-24;
}
.list-item-date {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
flex: 0 0 auto;
margin-inline-start: auto;
white-space: nowrap;
&::first-letter {
text-transform: capitalize;
}
}
// LIBRARY
.library-thumbnail {
border-radius: $br-4;
position: relative;
overflow: hidden;
background-color: var(--color-background-tertiary);
display: flex;
flex-direction: column;
block-size: 90%;
justify-content: flex-start;
max-block-size: px2rem(580);
padding: var(--sp-xxxl);
}
.library-num-assets {
color: var(--color-foreground-secondary);
}
.library-name-block {
color: var(--color-foreground-secondary);
inline-size: calc(100% - var(--sp-xxl) - var(--sp-s));
}
.library-item-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-asset-section {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
&:not(:first-child) {
margin-block-start: var(--sp-l);
}
}
.library-asset-item {
@include t.use-typography("body-small");
display: flex;
align-items: center;
border-radius: $br-4;
border: $b-1 solid transparent;
color: var(--color-foreground-primary);
margin-block-start: var(--sp-xs);
padding: var(--sp-xxs);
position: relative;
}
.library-asset-title {
@include t.use-typography("body-small");
display: flex;
text-transform: uppercase;
}
.library-asset-icon {
background-color: var(--color-canvas);
border-radius: $br-4;
border: $b-2 solid transparent;
margin-inline-end: var(--sp-s);
block-size: $sz-24;
inline-size: $sz-24;
}
.library-color-name {
color: var(--color-foreground-primary);
}
.library-color-value {
color: var(--color-foreground-secondary);
margin-inline-start: var(--sp-xs);
text-transform: uppercase;
}
.library-color-item {
display: grid;
grid-template-columns: auto 1fr;
gap: deprecated.$s-8;
gap: var(--sp-s);
}
.library-typography-sample {
block-size: px2rem(20);
margin-inline-end: var(--sp-xs);
inline-size: px2rem(20);
}
// MISC
.drag-counter {
@include t.use-typography("body-large");
position: absolute;
inset-block-start: var(--sp-xs);
inset-inline-start: var(--sp-xs);
inline-size: $sz-32;
block-size: $sz-32;
background-color: var(--color-accent-tertiary);
border-radius: $br-circle;
color: var(--color-background-secondary);
display: flex;
justify-content: center;
align-items: center;
}
.grid-loader {
--icon-width: calc(var(--th-width, #{$thumbnail-default-width}) * 0.25);
}
.deleted-item {
opacity: 0.5;
--icon-width: calc(var(--thumbnail-width, #{$thumbnail-default-width}) * 0.25);
}

View File

@ -0,0 +1,32 @@
;; 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 Sucursal en España SL
(ns app.main.ui.dashboard.layout-toggle
"Reactive, persisted preference for how dashboard files are laid out
(as a grid of thumbnails or as a compact list). The preference is shared
between the team (projects) view and the project (files) view."
(:require
[app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(def layout-key ::dashboard-layout)
(def default-layout :grid)
(mf/defc layout-toggle*
[{:keys [layout on-change]}]
[:> radio-buttons* {:selected (name layout)
:on-change on-change
:name "dashboard-files-layout"
:options [{:id "dashboard-files-layout-list"
:value "list"
:icon i/view-as-list
:label (tr "dashboard.files-layout.list")}
{:id "dashboard-files-layout-grid"
:value "grid"
:icon i/view-as-icons
:label (tr "dashboard.files-layout.grid")}]}])

View File

@ -103,7 +103,7 @@
[:ul
{:class (stl/css :grid-row :no-wrap)
:style {:grid-template-columns (str "repeat(" limit ", 1fr)")}}
[:li {:class (stl/css :grid-item :grid-empty-placeholder :dragged)}]]
[:li {:class (stl/css :grid-item :grid-empty-placeholder :is-dragged)}]]
(= :libraries origin)
[:> empty-placeholder*

View File

@ -49,8 +49,8 @@
cursor: pointer;
margin: deprecated.$s-8;
border: deprecated.$s-2 solid transparent;
width: var(--th-width, #{g.$thumbnail-default-width});
height: var(--th-height, #{g.$thumbnail-default-height});
width: var(--thumbnail-width, #{g.$thumbnail-default-width});
height: var(--thumbnail-height, #{g.$thumbnail-default-height});
svg {
width: deprecated.$s-32;

View File

@ -19,8 +19,9 @@
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.dashboard.deleted :as deleted]
[app.main.ui.dashboard.grid :refer [line-grid]]
[app.main.ui.dashboard.grid :refer [line-grid*]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]]
[app.main.ui.dashboard.pin-button :refer [pin-button*]]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
[app.main.ui.ds.buttons.button :refer [button*]]
@ -50,16 +51,20 @@
(mf/defc header*
{::mf/wrap [mf/memo]
::mf/private true}
[{:keys [can-edit]}]
[{:keys [can-edit layout on-change]}]
(let [on-click (mf/use-fn #(st/emit! (dd/create-project)))]
[:header {:class (stl/css :dashboard-header) :data-testid "dashboard-header"}
[:header {:class (stl/css :dashboard-header)
:data-testid "dashboard-header"}
[:div#dashboard-projects-title {:class (stl/css :dashboard-title)}
[:h1 (tr "dashboard.projects-title")]]
(when can-edit
[:button {:class (stl/css :btn-secondary :btn-small)
:on-click on-click
:data-testid "new-project-button"}
(tr "dashboard.new-project")])]))
[:div {:class (stl/css :dashboard-header-actions)}
[:> layout-toggle* {:layout layout
:on-change on-change}]
(when can-edit
[:button {:class (stl/css :btn-secondary :btn-small)
:on-click on-click
:data-testid "new-project-button"}
(tr "dashboard.new-project")])]]))
(mf/defc team-hero*
{::mf/wrap [mf/memo]}
@ -99,7 +104,7 @@
(mf/defc project-item*
{::mf/private true}
[{:keys [project is-first team files can-edit]}]
[{:keys [project is-first team files can-edit layout]}]
(let [project-id (get project :id)
team-id (get team :id)
@ -285,13 +290,13 @@
(tr "dashboard.empty-placeholder-drafts-subtitle")
(tr "dashboard.empty-placeholder-files-subtitle"))}]
[:& line-grid
{:project project
:team team
:files files
:create-fn create-file
:can-edit can-edit
:limit limit}])]
[:> line-grid* {:project project
:team team
:files files
:create-fn create-file
:can-edit can-edit
:limit limit
:layout layout}])]
(when (and (> limit 0)
(> file-count limit))
@ -329,6 +334,14 @@
show-deleted? (:can-edit permisions)
layout* (hooks/use-persisted-state lt/layout-key lt/default-layout)
layout (deref layout*)
on-layout-change
(mf/use-fn
(fn [value]
(reset! layout* (keyword value))))
projects
(mf/with-memo [projects]
(->> projects
@ -360,7 +373,9 @@
(when (seq projects)
[:*
[:> header* {:can-edit can-edit}]
[:> header* {:can-edit can-edit
:layout layout
:on-change on-layout-change}]
[:div {:class (stl/css :projects-container)}
[:*
(when (and show-team-hero?
@ -377,7 +392,8 @@
can-invite))}
(when show-deleted?
[:> deleted/menu* {:team-id team-id :section :dashboard-recent}])
[:> deleted/menu* {:team-id team-id
:section :dashboard-recent}])
(for [{:keys [id] :as project} projects]
;; FIXME: refactor this, looks inneficient
@ -389,5 +405,6 @@
:team team
:files files
:can-edit can-edit
:layout layout
:is-first (= project (first projects))
:key id}]))]]]])))

View File

@ -1266,7 +1266,7 @@
(let [initial (mf/with-memo []
(or (some-> webhook (update :uri str))
{:is-active false :mtype "application/json" :uri "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}))
{:is-active false :mtype "application/json"}))
form (fm/use-form :schema schema:webhook-form
:initial initial)
on-success

View File

@ -438,8 +438,8 @@
[th-size]
(when th-size
(let [node (mf/ref-val rowref)]
(.setProperty (.-style node) "--th-width" (str th-size "px"))
(.setProperty (.-style node) "--th-height" (str (mth/ceil (* th-size (/ 2 3))) "px")))))
(.setProperty (.-style node) "--thumbnail-width" (str th-size "px"))
(.setProperty (.-style node) "--thumbnail-height" (str (mth/ceil (* th-size (/ 2 3))) "px")))))
(mf/with-effect []
(let [node (mf/ref-val rowref)

View File

@ -0,0 +1,105 @@
;; 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 Sucursal en España SL
(ns app.main.ui.hooks.floating-drag
"Pointer drag hook for floating panels, mirroring the plugin modal drag
handler in plugins-runtime."
(:require
[app.common.geom.point :as gpt]
[app.util.dom :as dom]
[rumext.v2 :as mf]))
(defn- parse-translate
"Reads the current translate offset from an element's computed transform."
[^js el]
(if (and el (.-DOMMatrixReadOnly js/window))
(let [cs (.getComputedStyle js/window el nil)
matrix (js/DOMMatrixReadOnly. (.-transform cs))]
{:x (.-m41 matrix)
:y (.-m42 matrix)})
{:x 0 :y 0}))
(defn- set-dragging-class!
"Toggle `dragging-class` on `target` while a drag is active."
[^js target dragging-class dragging?]
(when (and target dragging-class)
(if dragging?
(dom/add-class! target dragging-class)
(dom/remove-class! target dragging-class))))
(defn use-floating-drag
"Returns pointer handlers to drag `target-ref` by its header.
Optional `on-move` is called on pointer down (e.g. to raise z-index).
Optional `dragging-class` is toggled on `target-ref` while dragging."
([target-ref]
(use-floating-drag target-ref nil nil))
([target-ref on-move]
(use-floating-drag target-ref on-move nil))
([target-ref on-move dragging-class]
(let [dragging-ref (mf/use-ref false)
pointer-id-ref (mf/use-ref nil)
initial-translate-ref (mf/use-ref {:x 0 :y 0})
initial-client-ref (mf/use-ref (gpt/point 0 0))
end-drag
(mf/use-fn
(mf/deps dragging-class)
(fn [event]
(when (mf/ref-val dragging-ref)
(mf/set-ref-val! dragging-ref false)
(mf/set-ref-val! pointer-id-ref nil)
(set-dragging-class! (mf/ref-val target-ref) dragging-class false)
(when event (dom/release-pointer event)))))
handle-lost-pointer-capture
(mf/use-fn
(fn [event]
(end-drag event)))
handle-pointer-up
(mf/use-fn
(fn [event]
(when (= (.-pointerId event) (mf/ref-val pointer-id-ref))
(end-drag event))))
handle-pointer-move
(mf/use-fn
(fn [event]
(when (and (mf/ref-val dragging-ref)
(= (.-pointerId event) (mf/ref-val pointer-id-ref)))
(let [target (mf/ref-val target-ref)
start (mf/ref-val initial-client-ref)
pos (dom/get-client-position event)
{:keys [x y]} (mf/ref-val initial-translate-ref)
delta-x (+ x (- (:x pos) (:x start)))
delta-y (+ y (- (:y pos) (:y start)))]
(when target
(dom/set-css-property! target "transform"
(str "translate(" delta-x "px, " delta-y "px)")))))))
handle-pointer-down
(mf/use-fn
(mf/deps on-move dragging-class)
(fn [event]
(when (and (= (.-button event) 0)
(not (and (instance? js/Element (.-target event))
(.closest (.-target event) "button"))))
(dom/prevent-default event)
(let [target (mf/ref-val target-ref)]
(when target
(mf/set-ref-val! pointer-id-ref (.-pointerId event))
(mf/set-ref-val! initial-client-ref (dom/get-client-position event))
(mf/set-ref-val! initial-translate-ref (parse-translate target))
(mf/set-ref-val! dragging-ref true)
(set-dragging-class! target dragging-class true)
(dom/capture-pointer event)
(when on-move (on-move)))))))]
{:on-pointer-down handle-pointer-down
:on-pointer-move handle-pointer-move
:on-pointer-up handle-pointer-up
:on-lost-pointer-capture handle-lost-pointer-capture})))

View File

@ -25,6 +25,7 @@
[app.main.ui.hooks.resize :refer [use-resize-observer]]
[app.main.ui.modal :refer [modal-container*]]
[app.main.ui.workspace.colorpicker]
[app.main.ui.workspace.components-debugger :refer [components-debugger*]]
[app.main.ui.workspace.context-menu :refer [context-menu*]]
[app.main.ui.workspace.coordinates :as coordinates]
[app.main.ui.workspace.libraries]
@ -198,10 +199,7 @@
{::mf/wrap [mf/memo]}
[{:keys [team-id project-id file-id page-id layout-name]}]
(let [file-id (hooks/use-equal-memo file-id)
page-id (hooks/use-equal-memo page-id)
layout (mf/deref refs/workspace-layout)
(let [layout (mf/deref refs/workspace-layout)
wglobal (mf/deref refs/workspace-global)
team-ref (mf/with-memo [team-id]
@ -274,6 +272,7 @@
[:> (mf/provider ctx/design-tokens) {:value design-tokens?}
[:> (mf/provider ctx/workspace-read-only?) {:value read-only?}
[:> modal-container*]
[:> components-debugger*]
[:section {:class (stl/css :workspace)
:style {:background-color background-color
:touch-action "none"
@ -296,6 +295,12 @@
(mf/defc workspace-page*
{::mf/lazy-load true}
[props]
[:> workspace* props])
[{:keys [file-id page-id] :as props}]
(let [file-id (hooks/use-equal-memo file-id)
page-id (hooks/use-equal-memo page-id)
props (mf/spread-props props {:file-id file-id
:page-id page-id})]
(when (uuid? file-id)
[:> workspace* props])))

View File

@ -11,6 +11,7 @@
[app.main.data.event :as ev]
[app.main.data.workspace :as dw]
[app.main.data.workspace.comments :as dwcm]
[app.main.data.workspace.drawing.common :as dwdc]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.comments :as cmt]
@ -99,7 +100,8 @@
(fn []
(if from-viewer
(st/emit! (dcmt/update-options {:show-sidebar? false}))
(st/emit! (dw/clear-edition-mode)
(st/emit! (dwdc/clear-drawing)
(dw/clear-edition-mode)
(dw/deselect-all true)))))
tgroups (->> threads

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,224 @@
// 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 Sucursal en España SL
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/typography.scss" as t;
.wrapper {
position: fixed;
padding: var(--sp-s);
border-radius: $br-8;
border: $b-2 solid var(--color-background-quaternary);
box-shadow: 0 0 var(--sp-s) 0 var(--color-shadow-light);
overflow: hidden;
min-inline-size: $sz-24;
min-block-size: $sz-200;
max-inline-size: 90vw;
max-block-size: 90vh;
resize: both;
user-select: none;
background-color: var(--color-background-primary);
color: var(--color-foreground-secondary);
z-index: var(--z-index-set);
}
.inner {
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
block-size: 100%;
padding: var(--sp-s);
}
.header {
align-items: center;
display: flex;
flex-shrink: 0;
justify-content: space-between;
border-block-end: $b-1 solid var(--color-background-quaternary);
padding-block-end: var(--sp-xxs);
cursor: grab;
touch-action: none;
}
.wrapper.is-dragging .header {
cursor: grabbing;
}
.title {
@include t.use-typography("body-small");
font-weight: var(--font-weight-medium);
margin: 0;
margin-inline-end: var(--sp-xxs);
}
.canvas {
flex: 1;
min-block-size: 0;
overflow: auto;
padding-block-start: var(--sp-s);
}
.canvas-svg {
display: block;
min-block-size: 100%;
min-inline-size: 100%;
}
.selection-preview-box {
--stroke-color: var(--color-background-quaternary);
fill: var(--color-background-primary);
stroke: var(--stroke-color);
stroke-width: $b-2;
&.selected {
--stroke-color: var(--color-accent-secondary);
}
&.swap {
--stroke-color: var(--color-accent-success);
}
&.deleted {
--stroke-color: var(--color-accent-error);
}
}
.selection-preview-icon {
--icon-stroke-color: var(--color-foreground-secondary);
display: block;
&.component {
--icon-stroke-color: var(--color-accent-secondary);
}
}
.selection-preview-text {
@include t.use-typography("body-small");
fill: var(--color-foreground-primary);
user-select: text;
}
.deleted-header-bg {
fill: var(--color-accent-error);
}
.deleted-header-text {
@include t.use-typography("body-small");
fill: var(--color-static-white);
font-weight: var(--font-weight-medium);
}
.info-labels-bg {
fill: var(--color-background-quaternary);
}
.info-text {
@include t.use-typography("body-small");
fill: var(--color-foreground-primary);
text-anchor: start;
user-select: text;
}
.shape-thumbnail-bg {
fill: var(--color-background-primary);
stroke: var(--color-background-quaternary);
stroke-width: $b-1;
&.is-loading {
animation: thumbnail-loading-pulse 1.2s ease-in-out infinite;
}
&.unavailable {
fill: var(--color-background-quaternary);
}
}
.shape-thumbnail-image {
opacity: 0.95;
}
.shape-thumbnail-loading {
@include t.use-typography("body-small");
fill: var(--color-foreground-secondary);
pointer-events: none;
}
.shape-thumbnail-unavailable {
@include t.use-typography("body-small");
fill: var(--color-foreground-secondary);
pointer-events: none;
}
@keyframes thumbnail-loading-pulse {
0%,
100% {
fill: var(--color-background-primary);
}
50% {
fill: var(--color-background-quaternary);
}
}
.highlight-arrows {
pointer-events: none;
}
.highlight-arrow {
stroke: var(--color-accent-secondary);
stroke-width: $b-2;
}
.highlight-arrow-marker-circle {
fill: var(--color-accent-secondary);
}
.highlight-arrow-marker-icon {
fill: var(--color-accent-secondary);
stroke: var(--color-background-primary);
stroke-width: $b-2;
}
.swap-arrow {
stroke: var(--color-accent-success);
stroke-width: $b-2;
}
.swap-arrow-marker-circle {
fill: var(--color-accent-success);
}
.swap-arrow-marker-icon {
fill: var(--color-accent-success);
stroke: var(--color-background-primary);
stroke-width: $b-2;
}
.legend-text {
@include t.use-typography("body-small");
fill: var(--color-foreground-primary);
}
.legend-bg {
fill: var(--color-background-quaternary);
}
::-webkit-resizer {
display: none;
}

View File

@ -705,6 +705,8 @@
[{:keys [mdata]}]
(let [page (:page mdata)
deletable? (:deletable? mdata)
selected-pages (:selected-pages mdata)
multi? (> (count selected-pages) 1)
id (:id page)
delete-fn #(st/emit! (dw/delete-page id))
do-delete #(st/emit! (modal/show
@ -712,20 +714,31 @@
:title (tr "modals.delete-page.title")
:message (tr "modals.delete-page.body")
:on-accept delete-fn}))
delete-many-fn #(st/emit! (dw/delete-pages selected-pages))
do-delete-many #(st/emit! (modal/show
{:type :confirm
:title (tr "modals.delete-pages.title")
:message (tr "modals.delete-pages.body")
:on-accept delete-many-fn}))
do-duplicate #(st/emit!
(dw/duplicate-page id)
(ev/event {::ev/name "duplicate-page"}))
do-rename #(st/emit! (dw/start-rename-page-item id))]
[:*
(when deletable?
[:> menu-entry* {:title (tr "workspace.assets.delete")
:on-click do-delete}])
(if multi?
;; When several pages are selected, the only available action is
;; deleting all of them at once.
[:> menu-entry* {:title (tr "workspace.assets.delete-pages")
:on-click do-delete-many}]
[:*
(when deletable?
[:> menu-entry* {:title (tr "workspace.assets.delete")
:on-click do-delete}])
[:> menu-entry* {:title (tr "workspace.assets.rename")
:on-click do-rename}]
[:> menu-entry* {:title (tr "workspace.assets.duplicate")
:on-click do-duplicate}]]))
[:> menu-entry* {:title (tr "workspace.assets.rename")
:on-click do-rename}]
[:> menu-entry* {:title (tr "workspace.assets.duplicate")
:on-click do-duplicate}]])))
(mf/defc viewport-context-menu*
[]

View File

@ -665,7 +665,7 @@
(mf/use-fn
(mf/deps frames)
(fn [_]
(st/emit! (de/show-workspace-export-frames-dialog (reverse frames)))))
(st/emit! (de/show-workspace-export-frames-dialog frames))))
on-export-frames-key-down
(mf/use-fn

View File

@ -61,6 +61,20 @@
(.removeAllRanges sel)
(.addRange sel range)))))
(defn- keep-input-alive
"Keeps the capture surface able to receive further input WITHOUT clearing it.
Unlike `reset-input-node`, this does not empty the surface. The macOS
press-and-hold accent menu replaces the previously typed base character (its
'marked text' when an accent is chosen, and that replacement only works
while the base character is still present in the surface DOM. Clearing it
drops the marked text, so the accent gets appended instead of replacing it,
producing e.g. 'oö' instead of 'ö'."
[^js node]
(when (and (some? node)
(not= (.-activeElement js/document) node))
(.focus node)))
(defn- font-family-from-font-id [font-id]
(if (str/includes? font-id "gfont-noto-sans")
(let [lang (str/replace font-id #"gfont\-noto\-sans\-" "")]
@ -94,6 +108,11 @@
contenteditable-ref (mf/use-ref nil)
;; Number of characters the browser is about to replace via marked text
;; (macOS press-and-hold accent menu). Set on `beforeinput`, consumed on
;; `input`. See on-before-input / on-input.
pending-replace-ref (mf/use-ref 0)
fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false)
fallback-families (map (fn [font]
(font-family-from-font-id (:font-id font))) fallback-fonts)
@ -279,6 +298,32 @@
;; Let contenteditable handle text input via on-input
:else nil)))))
;; Native `beforeinput` listener (see the use-effect that registers it).
;; We use the native event, not React's synthetic `onBeforeInput`, because
;; only the native event reliably exposes `getTargetRanges()`.
;;
;; The macOS press-and-hold accent menu does NOT use composition events:
;; picking an accent arrives as a plain `insertText` whose target range
;; spans the previously typed base character (marked text), so the browser
;; replaces it instead of appending. We remember how many characters that
;; range covers so `on-input` can delete them from the WASM editor before
;; inserting the accented one. We ignore it while the WASM editor has an
;; active selection, since that selection is replaced by `insert-text`
;; itself and deleting extra characters would corrupt the content.
on-before-input
(mf/use-fn
(fn [^js native]
(mf/set-ref-val! pending-replace-ref 0)
(when (and (= (.-inputType native) "insertText")
(not (.-isComposing native))
(not (text-editor/text-editor-has-selection?)))
(let [ranges (.getTargetRanges native)]
(when (pos? (.-length ranges))
(let [range (aget ranges 0)
n (- (.-endOffset range) (.-startOffset range))]
(when (pos? n)
(mf/set-ref-val! pending-replace-ref n))))))))
on-input
(mf/use-fn
(fn [^js event]
@ -289,10 +334,19 @@
(when (and (not (composing-event? event))
(not= input-type "insertCompositionText"))
(when (and data (seq data))
;; Marked-text replacement (macOS accent menu): remove the base
;; character(s) the browser is replacing before inserting.
(let [pending (mf/ref-val pending-replace-ref)]
(dotimes [_ pending]
(text-editor/text-editor-delete-backward)))
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-input"))
(reset-input-node (mf/ref-val contenteditable-ref))))))
(mf/set-ref-val! pending-replace-ref 0)
;; IMPORTANT: do NOT clear the surface here (see keep-input-alive):
;; the browser must retain the just-typed character so the macOS
;; accent menu can replace it on the next input.
(keep-input-alive (mf/ref-val contenteditable-ref))))))
on-pointer-down
(mf/use-fn
@ -345,6 +399,19 @@
"--editor-container-height" (dm/str height "px")
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}]
;; Register the native `beforeinput` listener. React's synthetic
;; `onBeforeInput` does not expose `getTargetRanges()`, even with
;; nativeEvent (it's fully synthetic, composed of other two events).
;; We need `getTargetRranges` to detect macOS accent-menu replacements.
;; See https://github.com/react/react/issues/11211
(mf/use-effect
(mf/deps on-before-input)
(fn []
(when-let [node (mf/ref-val contenteditable-ref)]
(.addEventListener node "beforeinput" on-before-input)
(fn []
(.removeEventListener node "beforeinput" on-before-input)))))
;; Focus contenteditable on mount
(mf/use-effect
(mf/deps contenteditable-ref)
@ -394,6 +461,13 @@
{:ref contenteditable-ref
:contentEditable true
:suppressContentEditableWarning true
;; The surface retains typed text between keystrokes (see
;; keep-input-alive), so disable text assistance that would otherwise
;; rewrite that retained text and desync the WASM editor.
;; NOTE: this was already not working in v1/v2
:spellCheck false
:autoCorrect "off"
:autoCapitalize "off"
:on-composition-start on-composition-start
:on-composition-update on-composition-update
:on-composition-end on-composition-end

View File

@ -23,7 +23,8 @@
These options are handled reactively via okulary subscriptions."
#{:shape-panel
:show-ids
:show-touched})
:show-touched
:components-debugger})
(mf/defc debug-panel*
[{:keys [class]}]

View File

@ -27,7 +27,7 @@
variant-id variant-name variant-properties variant-error
on-tab-press ref]}]
(let [;; Subscribe to dbg/state so the component re-renders when
;; :show-ids or :show-touched are toggled without a page reload.
;; debug options are toggled without a page reload.
_dbg (mf/deref dbg/state)
edition* (mf/use-state false)

View File

@ -62,9 +62,10 @@
;; --- Page Item
(mf/defc page-item
{::mf/wrap-props false}
[{:keys [page index deletable? selected? editing? hovering? current-page-id]}]
(mf/defc page-item*
{::mf/private true
::mf/wrap-props false}
[{:keys [page index is-deletable is-selected is-editing is-hovering current-page-id]}]
(let [input-ref (mf/use-ref)
id (:id page)
name (:name page "")
@ -76,38 +77,53 @@
on-click
(mf/use-fn
(mf/deps id current-page-id is-separator?)
(fn []
(fn [event]
(when-not is-separator?
;; WASM page transitions:
;; - Capture the current page (A) once
;; - Show a blurred snapshot while the target page (B/C/...) renders
;; - If the user clicks again during the transition, keep showing the original (A) snapshot
(if (and (features/active-feature? @st/state "render-wasm/v1")
(not= id current-page-id))
(-> (if @wasm.api/page-transition?
(p/resolved nil)
;; Blur with Skia, then capture the already-blurred frame.
(do (wasm.api/render-blurred-snapshot!)
(wasm.api/capture-canvas-snapshot)))
(p/finally
(fn []
(wasm.api/apply-canvas-blur)
;; Two RAF so the overlay paints before navigation.
(timers/raf
(fn []
(timers/raf navigate-fn))))))
(navigate-fn)))))
(cond
;; Shift + click: select the range of pages from the anchor
;; to this page (does not navigate).
(kbd/shift? event)
(st/emit! (dw/select-pages-range id))
;; Ctrl/Cmd + click: add/remove this page to/from the
;; multi-selection (does not navigate).
(kbd/mod? event)
(st/emit! (dw/toggle-page-selection id))
;; Plain click: reset the selection to this page and
;; navigate to it.
:else
(do
(st/emit! (dw/select-page id))
;; WASM page transitions:
;; - Capture the current page (A) once
;; - Show a blurred snapshot while the target page (B/C/...) renders
;; - If the user clicks again during the transition, keep showing the original (A) snapshot
(if (and (features/active-feature? @st/state "render-wasm/v1")
(not= id current-page-id))
(-> (if @wasm.api/page-transition?
(p/resolved nil)
;; Blur with Skia, then capture the already-blurred frame.
(do (wasm.api/render-blurred-snapshot!)
(wasm.api/capture-canvas-snapshot)))
(p/finally
(fn []
(wasm.api/apply-canvas-blur)
;; Two RAF so the overlay paints before navigation.
(timers/raf
(fn []
(timers/raf navigate-fn))))))
(navigate-fn)))))))
on-delete
(mf/use-fn
(mf/deps id)
(fn [event]
(dom/stop-propagation event)
(st/emit! (modal/show
{:type :confirm
:title (tr "modals.delete-page.title")
:message (tr "modals.delete-page.body")
:on-accept delete-fn}))))
(st/emit! (modal/show {:type :confirm
:title (tr "modals.delete-page.title")
:message (tr "modals.delete-page.body")
:on-accept delete-fn}))))
on-double-click
(mf/use-fn
@ -153,7 +169,7 @@
:data {:id id
:index index
:name (:name page)}
:draggable? (and (not read-only?) (not editing?)))
:draggable? (and (not read-only?) (not is-editing)))
on-context-menu
(mf/use-fn
@ -166,50 +182,49 @@
(st/emit! (dw/show-page-item-context-menu
{:position position
:page page
:deletable? deletable?}))))))]
:deletable? is-deletable}))))))]
(mf/use-effect
(mf/deps selected?)
(mf/deps is-selected)
(fn []
(when selected?
(when is-selected
(let [node (mf/ref-val dref)]
(dom/scroll-into-view-if-needed! node)))))
(mf/use-layout-effect
(mf/deps editing?)
(mf/deps is-editing)
(fn []
(when editing?
(when is-editing
(let [edit-input (mf/ref-val input-ref)]
(dom/select-text! edit-input))
nil)))
(let [selected? (and selected? (not is-separator?))]
[:li {:class (stl/css-case
:page-element true
:separator is-separator?
:selected selected?
:dnd-over-top (= (:over dprops) :top)
:dnd-over-bot (= (:over dprops) :bot))
(let [selected? (and is-selected (not is-separator?))]
[:li {:class (stl/css-case :page-item true
:separator is-separator?
:selected selected?
:dnd-over-top (= (:over dprops) :top)
:dnd-over-bot (= (:over dprops) :bot))
:ref dref}
[:div {:class (stl/css-case
:element-list-body true
:separator-body is-separator?
:hover (and hovering? (not is-separator?))
:selected selected?)
[:div {:class (stl/css-case :page-item-body true
:separator is-separator?
:hover (and is-hovering (not is-separator?))
:selected selected?)
:data-testid (dm/str "page-" id)
:tab-index "0"
:on-click on-click
:on-double-click on-double-click
:on-context-menu on-context-menu}
(if (and is-separator? (not editing?))
[:div {:class (stl/css :page-separator)
(if (and is-separator? (not is-editing))
[:div {:class (stl/css :page-divider)
:data-testid "page-separator"}]
[:*
(when-not is-separator?
[:div {:class (stl/css :page-icon)}
[:> icon* {:icon-id i/document :size "s"}]])
(if editing?
[:input {:class (stl/css :element-name)
[:div {:class (stl/css :page-item-icon)}
[:> icon* {:icon-id i/document
:size "s"}]])
(if is-editing
[:input {:class (stl/css :page-item-input)
:type "text"
:ref input-ref
:on-blur on-blur
@ -217,32 +232,35 @@
:auto-focus true
:default-value name}]
[:*
[:span {:class (stl/css :page-name) :title name :data-testid "page-name"}
[:span {:class (stl/css :page-item-label)
:title name
:data-testid "page-name"}
name]
[:div {:class (stl/css :page-actions)}
(when (and deletable? (not read-only?))
[:div {:class (stl/css :page-item-actions)}
(when (and is-deletable (not read-only?))
[:> icon-button* {:variant "action"
:aria-label (tr "modals.delete-page.title")
:on-click on-delete
:icon-size "s"
:class (stl/css :page-delete-button)
:icon-class (stl/css :page-delete-button-icon)
:class (stl/css :page-delete-btn)
:icon-class (stl/css :page-delete-icon)
:icon i/delete}])]])])]])))
;; --- Page Item Wrapper
(mf/defc page-item-wrapper
{::mf/wrap-props false}
[{:keys [page-id index deletable? selected? editing? current-page-id]}]
(mf/defc page-item-wrapper*
{::mf/private true
::mf/wrap-props false}
[{:keys [page-id index is-deletable is-selected is-editing current-page-id]}]
(let [page-ref (mf/with-memo [page-id]
(make-page-ref page-id))
page (mf/deref page-ref)]
[:& page-item {:page page
:index index
:current-page-id current-page-id
:deletable? deletable?
:selected? selected?
:editing? editing?}]))
[:> page-item* {:page page
:index index
:current-page-id current-page-id
:is-deletable is-deletable
:is-selected is-selected
:is-editing is-editing}]))
;; --- Pages List
@ -252,18 +270,23 @@
(let [pages (:pages file)
deletable? (> (count pages) 1)
editing-page-id (mf/deref refs/editing-page-item)
current-page-id (mf/use-ctx ctx/current-page-id)]
[:ul {:class (stl/css :page-list)}
selected-pages (mf/deref refs/selected-pages)
current-page-id (mf/use-ctx ctx/current-page-id)
;; When there is no explicit multi-selection, the current page
;; is the selected one.
selected-pages (if (seq selected-pages)
selected-pages
#{current-page-id})]
[:ul
[:> hooks/sortable-container* {}
(for [[index page-id] (d/enumerate pages)]
[:& page-item-wrapper
{:page-id page-id
:index index
:deletable? deletable?
:editing? (= page-id editing-page-id)
:selected? (= page-id current-page-id)
:current-page-id current-page-id
:key page-id}])]]))
[:> page-item-wrapper* {:page-id page-id
:index index
:is-deletable deletable?
:is-editing (= page-id editing-page-id)
:is-selected (contains? selected-pages page-id)
:current-page-id current-page-id
:key page-id}])]]))
;; --- Sitemap Toolbox
@ -276,7 +299,8 @@
on-create (mf/use-fn
(mf/deps file-id project-id)
(fn [event]
(st/emit! (dw/create-page {:file-id file-id :project-id project-id}))
(st/emit! (dw/create-page {:file-id file-id
:project-id project-id}))
(-> event dom/get-current-target dom/blur!)))
read-only? (mf/use-ctx ctx/workspace-read-only?)
@ -289,7 +313,7 @@
:collapsed collapsed
:on-collapsed on-toggle-collapsed
:title (tr "workspace.sidebar.sitemap")
:class (stl/css :title-spacing-sitemap)}
:class (stl/css :sitemap-title)}
(if ^boolean read-only?
(when ^boolean (:can-edit permissions)
@ -297,12 +321,11 @@
:size :small
:content (tr "labels.view-only")}])
[:> icon-button* {:variant "ghost"
:class (stl/css :add-page)
:aria-label (tr "workspace.sidebar.sitemap.add-page")
:on-click on-create
:icon i/add}])]
(when-not ^boolean collapsed
[:div {:class (stl/css :tool-window-content)}
[:> pages-list* {:file file :key (dm/str (:id file))}]])]))
[:div {:class (stl/css :sitemap-content)}
[:> pages-list* {:key (dm/str (:id file))
:file file}]])]))

View File

@ -4,236 +4,155 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_utils.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/typography.scss" as t;
@use "ds/mixins.scss" as *;
.sitemap {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
height: var(--height, deprecated.$s-200);
inline-size: 100%;
block-size: var(--height, $sz-200);
}
.title {
margin-left: deprecated.$s-2;
color: var(--title-foreground-color-hover);
.sitemap-title {
padding-inline-start: var(--sp-s);
margin-block: var(--sp-s) var(--sp-xs);
}
.resize-area {
position: absolute;
bottom: calc(-1 * deprecated.$s-8);
left: 0;
width: 100%;
height: deprecated.$s-12;
border-top: deprecated.$s-2 solid var(--resize-area-border-color);
background-color: var(--resize-area-background-color);
cursor: ns-resize;
&:hover {
border-color: var(--resize-area-border-color);
}
}
.tool-window-content {
.sitemap-content {
display: flex;
flex-direction: column;
height: calc(-38px + var(--height, deprecated.$s-200));
width: var(--left-sidebar-width);
inline-size: var(--left-sidebar-width);
overflow: hidden auto;
scrollbar-gutter: stable;
.element-list {
display: grid;
}
}
.pages-list {
width: 100%;
max-height: deprecated.$s-152;
margin-bottom: deprecated.$s-12;
}
.page-item {
@include t.use-typography("body-small");
.page-delete-button {
--delete-button-display: none;
}
.page-delete-button-icon {
display: var(--delete-button-display);
}
.page-element {
@include deprecated.body-small-typography;
min-height: deprecated.$s-32;
width: 100%;
cursor: pointer;
min-block-size: $sz-32;
inline-size: 100%;
&.dnd-over-top {
border-top: deprecated.$s-1 solid var(--layer-row-foreground-color-drag);
border-block-start: $b-1 solid var(--layer-row-foreground-color-drag);
}
&.dnd-over-bot {
border-bottom: deprecated.$s-1 solid var(--layer-row-foreground-color-drag);
border-block-end: $b-1 solid var(--layer-row-foreground-color-drag);
}
.dnd-over > .element-list-body {
border: deprecated.$s-1 solid var(--layer-row-foreground-color-drag);
}
.element-list-body {
.page-item-body {
display: flex;
align-items: center;
height: deprecated.$s-32;
width: 100%;
padding: 0 deprecated.$s-12 0 0;
block-size: $sz-32;
inline-size: 100%;
padding: 0 var(--sp-m) 0 0;
transition: none;
color: var(--layer-row-foreground-color);
user-select: none;
.page-name {
@include deprecated.text-ellipsis;
flex-grow: 1;
padding-left: deprecated.$s-2;
}
.page-icon {
@include deprecated.flex-center;
padding: 0 deprecated.$s-4 0 deprecated.$s-8;
}
.page-actions {
height: deprecated.$s-32;
display: flex;
align-items: center;
}
.element-name {
@include deprecated.text-ellipsis;
color: var(--layer-row-foreground-color-focus);
}
input.element-name {
@include deprecated.text-ellipsis;
@include deprecated.body-small-typography;
@include deprecated.remove-input-style;
flex-grow: 1;
height: deprecated.$s-28;
max-width: calc(var(--parent-size) - (var(--depth) * var(--layer-indentation-size)));
padding-left: deprecated.$s-6;
margin: 0;
border-radius: deprecated.$br-8;
border: deprecated.$s-1 solid var(--input-border-color-focus);
color: var(--layer-row-foreground-color);
&.separator {
block-size: auto;
min-block-size: var(--sp-xxxl);
padding: 0;
}
}
&:active,
&.on-drag {
.element-list-body {
color: var(--layer-row-foreground-color-drag);
background-color: var(--layer-row-background-color-drag);
.page-item-label {
@include text-ellipsis;
.page-icon svg {
stroke: var(--layer-row-foreground-color-drag);
}
}
flex-grow: 1;
padding-inline-start: var(--sp-xxs);
}
&.selected,
&.selected:hover {
.element-list-body {
color: var(--layer-row-foreground-color-selected);
background-color: var(--layer-row-background-color-selected);
box-shadow: deprecated.$s-16 deprecated.$s-0 deprecated.$s-0 deprecated.$s-0
var(--layer-row-background-color-selected);
.page-icon svg {
stroke: var(--layer-row-foreground-color-selected);
}
}
.page-item-icon {
display: flex;
justify-content: center;
align-items: center;
padding: 0 var(--sp-xs) 0 var(--sp-s);
}
&:hover,
&.hover {
.element-list-body {
color: var(--layer-row-foreground-color-hover);
background-color: var(--layer-row-background-color-hover);
box-shadow: deprecated.$s-16 deprecated.$s-0 deprecated.$s-0 deprecated.$s-0
var(--layer-row-background-color-hover);
.page-actions button {
opacity: deprecated.$op-10;
--delete-button-display: initial;
}
.page-icon svg {
stroke: var(--layer-row-foreground-color-hover);
}
}
.page-item-actions {
block-size: $sz-32;
display: flex;
align-items: center;
}
&:focus {
.element-list-body {
color: var(--layer-row-foreground-color-focus);
border: deprecated.$s-1 solid var(--layer-row-border-color-focus);
outline: none;
.page-item-input {
@include text-ellipsis;
@include t.use-typography("body-small");
.page-actions button {
opacity: deprecated.$op-10;
}
}
background: none;
outline: none;
flex-grow: 1;
block-size: $sz-28;
max-inline-size: calc(var(--parent-size) - (var(--depth) * var(--layer-indentation-size)));
padding-inline-start: $sz-6;
margin: 0;
border-radius: $br-8;
border: $b-1 solid var(--input-border-color-focus);
color: var(--layer-row-foreground-color);
}
&:focus-within {
.element-list-body {
outline: none;
.page-actions button {
opacity: deprecated.$op-10;
}
}
&:active .page-item-body {
color: var(--layer-row-foreground-color-drag);
background-color: var(--layer-row-background-color-drag);
}
&.hidden {
.element-list-body {
color: var(--layer-row-foreground-color-hidden);
background-color: var(--layer-row-background-color-hidden);
opacity: deprecated.$op-7;
&.selected .page-item-body,
&.selected:hover .page-item-body {
color: var(--layer-row-foreground-color-selected);
background-color: var(--layer-row-background-color-selected);
box-shadow: $sz-16 0 0 0 var(--layer-row-background-color-selected);
}
.page-icon svg {
stroke: var(--layer-row-foreground-color-hidden);
}
}
&:hover .page-item-body,
&.hover .page-item-body {
color: var(--layer-row-foreground-color-hover);
background-color: var(--layer-row-background-color-hover);
box-shadow: $sz-16 0 0 0 var(--layer-row-background-color-hover);
}
&:hover .page-delete-btn,
&.hover .page-delete-btn {
--delete-button-display: initial;
}
&:focus .page-item-body {
color: var(--layer-row-foreground-color-focus);
border: $b-1 solid var(--layer-row-border-color-focus);
outline: none;
}
&:focus-within .page-item-body {
outline: none;
}
&.separator:hover .page-item-body,
&.separator.hover .page-item-body {
color: var(--layer-row-foreground-color);
background-color: transparent;
box-shadow: none;
}
}
.element-list-body.separator-body {
height: auto;
min-height: var(--sp-xxxl);
padding: 0;
}
.page-separator {
width: 100%;
height: $b-1;
.page-divider {
inline-size: 100%;
block-size: $b-1;
margin: var(--sp-s);
background-color: var(--color-background-quaternary);
}
.page-element.separator:hover .element-list-body,
.page-element.separator.hover .element-list-body {
color: var(--layer-row-foreground-color);
background-color: transparent;
box-shadow: none;
.page-delete-btn {
--delete-button-display: none;
}
.title-spacing-sitemap {
padding-inline-start: deprecated.$s-8;
margin-block: deprecated.$s-8 deprecated.$s-4;
.page-delete-icon {
display: var(--delete-button-display);
}

View File

@ -1043,7 +1043,12 @@
ids (into #{} (map #(obj/get % "$id")) shapes)]
(st/emit! (-> (dwl/add-component id-ref ids)
(se/add-event plugin-id)))
(lib-component-proxy plugin-id file-id @id-ref))))
;; add-component only sets id-ref when it actually creates a
;; component; an empty selection or shapes that can't form one
;; leave it nil, so reject instead of returning a broken proxy.
(if-let [id @id-ref]
(lib-component-proxy plugin-id file-id id)
(u/not-valid plugin-id :createComponent "Cannot create a component from the given shapes")))))
;; Plugin data
:getPluginData

View File

@ -328,10 +328,217 @@
[[(str/join "." path) (:message v)]])))
m)))
(def ^:private max-repr-length 100)
(def ^:private max-repr-depth 2)
(def ^:private max-repr-items 5)
(defn- abbreviate
"Shorten `s` to `max-repr-length` code points. Cutting on a UTF-16 code
unit would split surrogate pairs and render astral characters as mojibake."
[s]
(if (> (count s) max-repr-length)
(let [points (js/Array.from s)]
(if (> (alength points) max-repr-length)
(dm/str (.join (.slice points 0 max-repr-length) "") "…")
s))
s))
(defn- value->type
[value]
(cond
(string? value) "string"
(boolean? value) "boolean"
(number? value) "number"
(keyword? value) "keyword"
(map? value) "object"
(coll? value) "array"
(array? value) "array"
(fn? value) "function"
(instance? js/Object value) "object"
:else "unknown"))
(defn- data-property
"Own property `k` of the JS object `o`, or `::skip` when `k` is an accessor.
Plugin proxies expose their contents through getters that read the
application state and may throw, so the error path must not run them."
[o k]
(let [descriptor (js/Object.getOwnPropertyDescriptor o k)]
(if (and (some? descriptor)
(undefined? (unchecked-get descriptor "get")))
(unchecked-get descriptor "value")
::skip)))
(defn- value->repr
"Bounded representation of a value received from a plugin. Such values are
arbitrary JS data: `pr-str` never returns on a self referencing object
(`a.parent.child === a`), so the traversal is capped in depth and in width
and never descends into an ancestor."
([value]
(value->repr value 0 []))
([value depth seen]
(cond
(string? value)
(pr-str (abbreviate value))
(or (nil? value) (number? value) (boolean? value) (keyword? value))
(pr-str value)
(fn? value)
"#function"
(some #(identical? % value) seen)
"#recursive"
(>= depth max-repr-depth)
"…"
(map? value)
(let [seen (conj seen value)]
(dm/str "{" (->> (take max-repr-items value)
(map (fn [[k v]]
(dm/str (value->repr k (inc depth) seen) " "
(value->repr v (inc depth) seen))))
(str/join ", "))
(when (> (count value) max-repr-items) ", …") "}"))
(or (array? value) (coll? value))
(let [seen (conj seen value)
items (if (array? value) (array-seq value) (seq value))]
(dm/str "[" (->> (take max-repr-items items)
(map #(value->repr % (inc depth) seen))
(str/join " "))
(when (seq (drop max-repr-items items)) " …") "]"))
(instance? js/Object value)
(let [seen (conj seen value)
ks (js/Object.keys value)]
(dm/str "{" (->> (take max-repr-items ks)
(keep (fn [k]
(let [v (data-property value k)]
(when-not (= ::skip v)
(dm/str k " " (value->repr v (inc depth) seen))))))
(str/join ", "))
(when (> (alength ks) max-repr-items) ", …") "}"))
:else
(value->type value))))
(defn- printable?
"True when a schema form contains only data, so it can be shown to a plugin
author. `[:fn pred]` forms embed the predicate itself, which prints as an
unreadable `#object[…]` with the internal munged name."
[form]
(cond
(map? form) (every? printable? (vals form))
(coll? form) (every? printable? form)
(keyword? form) true
(string? form) true
(number? form) true
(boolean? form) true
(symbol? form) true
(regexp? form) true
(nil? form) true
:else false))
(defn- simplify-form
"Drop from a schema form the properties that are not data, so a schema is
described by its shape alone: `[::sm/text {:error/fn f}]` becomes
`::sm/text`. Yields `::unprintable` when what is left cannot describe the
schema, as in `[:fn pred]`, where dropping the predicate would advertise a
schema (`fn`) that means nothing to a plugin author."
[form]
(cond
(map? form)
(not-empty (into {} (filter (comp printable? val)) form))
(vector? form)
(let [items (into [] (keep simplify-form) form)]
(cond
(some #(= ::unprintable %) items) ::unprintable
(= 1 (count items)) (first items)
:else items))
(printable? form)
form
:else
::unprintable))
(defn- expected-form
"Readable representation of the schema a problem failed against, or nil when
the schema cannot be shown."
[schema]
(let [title (or (:title (sm/properties schema))
(:title (sm/type-properties schema)))]
(if (some? title)
title
(let [form (simplify-form (sm/form schema))]
(cond
(= ::unprintable form) nil
(keyword? form) (name form)
:else (pr-str form))))))
(defn- schema-message
"Message rendered by `csm/interpret-schema-problem` for a problem, or nil when
it degrades to the generic \"invalid data\": the token value schemas declare an
`:error/fn` that only speaks about empty values, so it renders nothing at all
for a wrong typed one, and saying nothing must not win over reporting what was
expected and what was received (#10072)."
[problem field]
(let [message (-> (csm/interpret-schema-problem {} problem)
(get-in field)
(get :message))]
(when (and (some? message)
(not= message (tr "errors.invalid-data")))
message)))
(defn- interpret-problem
"Like `csm/interpret-schema-problem`, but when the schema renders no message of
its own it reports the expected schemas together with the received value and
its type instead of a generic \"Invalid data\" (#10072).
Malli reports one problem per `:or` branch, all on the same field, so the
branches are accumulated: reporting only one of them would tell the plugin
author that an alternative that is in fact valid is not accepted."
[acc {:keys [schema in value] :as problem}]
(let [field (or (:error/field (sm/properties schema)) in)
field (if (vector? field) field [field])
current (when (seq field) (get-in acc field))]
(cond
(empty? field)
acc
;; A message the schema renders itself always wins over the generic
;; expected/received one, and is never overwritten by it.
(and (some? current) (not (contains? current :expected)))
acc
:else
(if-let [message (schema-message problem field)]
(assoc-in acc field {:message message})
(let [form (expected-form schema)
complete? (and (get current :complete? true) (some? form))
expected (if complete?
(-> (get current :expected [])
(conj form)
(distinct)
(vec))
[])
received (value->type value)
repr (abbreviate (value->repr value))
message (if (seq expected)
(tr "plugins.validation.invalid-value"
(abbreviate (str/join " or " expected))
received repr)
(tr "plugins.validation.received-value" received repr))]
(assoc-in acc field {:message message
:expected expected
:complete? complete?}))))))
(defn error-messages
[explain]
(let [msg (->> (:errors explain)
(reduce csm/interpret-schema-problem {})
(reduce interpret-problem {})
(flatten-error-map)
(map (fn [[field message]]
(tr "plugins.validation.message" field message)))

View File

@ -17,7 +17,6 @@
[app.common.math :as mth]
[app.common.types.color :as clr]
[app.common.types.fills :as types.fills]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.types.path :as path]
[app.common.types.path.impl :as path.impl]
[app.common.types.shape.layout :as ctl]
@ -33,7 +32,7 @@
[app.main.store :as st]
[app.main.ui.shapes.text]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.shapes :as shapes]
[app.render-wasm.api.props :as props]
[app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl]
[app.render-wasm.deserializers :as dr]
@ -43,6 +42,7 @@
[app.render-wasm.mem.heap32 :as mem.h32]
[app.render-wasm.performance :as perf]
[app.render-wasm.rulers-state :as rulers-state]
[app.render-wasm.serialize-shape :as serialize-shape]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters]
@ -251,8 +251,6 @@
(def ^:const GRID-LAYOUT-COLUMN-U8-SIZE 8)
(def ^:const GRID-LAYOUT-CELL-U8-SIZE 36)
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
(def ^:const DEBOUNCE_DELAY_MS 100)
(defonce ^:private view-interaction-active? (atom false))
@ -634,7 +632,7 @@
(defn set-masked
[masked]
(h/call wasm/internal-module "_set_shape_masked_group" masked))
(props/set-masked masked))
(defn set-shape-selrect
[selrect]
@ -729,10 +727,6 @@
(perf/end-measure "set-shape-children")
nil)
(defn- get-string-length
[string]
(+ (count string) 1))
(defn- get-texture-id-for-gl-object
"Registers a WebGL texture with Emscripten's GL object system and returns its ID"
@ -743,56 +737,87 @@
(aset textures new-id texture)
new-id))
(defn- retrieve-image
[url]
(rx/from
(-> (js/fetch url)
(p/then (fn [^js response] (.blob response)))
(p/then (fn [^js image] (js/createImageBitmap image))))))
(defn- svg-blob?
[^js blob]
(str/starts-with? (.-type blob) "image/svg"))
(defn- store-svg-image
"Sends raw SVG bytes to WASM so Skia parses and rasterizes them there.
Browsers reject SVG blobs in `createImageBitmap`, so SVGs skip the
shared-texture path."
[shape-id image-id thumbnail? ^js blob]
(-> (.arrayBuffer blob)
(p/then
(fn [buffer]
(let [image-bytes (js/Uint8Array. buffer)
;; Header: 16 bytes shape uuid + 16 bytes image uuid
;; + 4 bytes thumbnail flag, then the raw SVG payload.
offset (mem/alloc (+ 36 (.-byteLength image-bytes)))
heap (mem/get-heap-u8)
dview (mem/get-data-view)]
(-> offset
(mem/write-uuid dview shape-id)
(mem/write-uuid dview image-id)
(mem/write-u32 dview (if thumbnail? 1 0))
(mem/write-buffer heap image-bytes))
(h/call wasm/internal-module "_store_image")
true)))))
(defn- store-image-texture
"Creates a WebGL texture from a decoded image and passes the texture ID to
WASM. This avoids decoding the image twice (once in browser, once in WASM)."
[shape-id image-id thumbnail? img]
(when-let [gl (webgl/get-webgl-context)]
(let [texture (webgl/create-webgl-texture-from-image gl img)
texture-id (get-texture-id-for-gl-object texture)
width (.-width ^js img)
height (.-height ^js img)
;; Header: 32 bytes (2 UUIDs) + 4 bytes (thumbnail)
;; + 4 bytes (texture ID) + 8 bytes (dimensions)
total-bytes 48
offset (mem/alloc->offset-32 total-bytes)
heap32 (mem/get-heap-u32)]
;; 1. Set shape id (offset + 0 to offset + 3)
(mem.h32/write-uuid offset heap32 shape-id)
;; 2. Set image id (offset + 4 to offset + 7)
(mem.h32/write-uuid (+ offset 4) heap32 image-id)
;; 3. Set thumbnail flag as u32 (offset + 8)
(aset heap32 (+ offset 8) (if thumbnail? 1 0))
;; 4. Set texture ID (offset + 9)
(aset heap32 (+ offset 9) texture-id)
;; 5. Set width (offset + 10)
(aset heap32 (+ offset 10) width)
;; 6. Set height (offset + 11)
(aset heap32 (+ offset 11) height)
(h/call wasm/internal-module "_store_image_from_texture")
true)))
(defn- fetch-image
"Loads an image and creates a WebGL texture from it, passing the texture ID to WASM.
This avoids decoding the image twice (once in browser, once in WASM)."
"Loads an image and hands it to WASM. Raster images are decoded by the
browser and shared as a WebGL texture; SVG images are sent as raw bytes
so Skia rasterizes them."
[shape-id image-id thumbnail?]
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
{:key url
:thumbnail? thumbnail?
:callback
(fn []
(->> (retrieve-image url)
(rx/map
(fn [img]
(when-let [gl (webgl/get-webgl-context)]
(let [texture (webgl/create-webgl-texture-from-image gl img)
texture-id (get-texture-id-for-gl-object texture)
width (.-width ^js img)
height (.-height ^js img)
;; Header: 32 bytes (2 UUIDs) + 4 bytes (thumbnail)
;; + 4 bytes (texture ID) + 8 bytes (dimensions)
total-bytes 48
offset (mem/alloc->offset-32 total-bytes)
heap32 (mem/get-heap-u32)]
;; 1. Set shape id (offset + 0 to offset + 3)
(mem.h32/write-uuid offset heap32 shape-id)
;; 2. Set image id (offset + 4 to offset + 7)
(mem.h32/write-uuid (+ offset 4) heap32 image-id)
;; 3. Set thumbnail flag as u32 (offset + 8)
(aset heap32 (+ offset 8) (if thumbnail? 1 0))
;; 4. Set texture ID (offset + 9)
(aset heap32 (+ offset 9) texture-id)
;; 5. Set width (offset + 10)
(aset heap32 (+ offset 10) width)
;; 6. Set height (offset + 11)
(aset heap32 (+ offset 11) height)
(h/call wasm/internal-module "_store_image_from_texture")
true))))
(->> (rx/from (-> (js/fetch url)
(p/then (fn [^js response] (.blob response)))))
(rx/mapcat
(fn [^js blob]
(rx/from
(if (svg-blob? blob)
(store-svg-image shape-id image-id thumbnail? blob)
(p/then (js/createImageBitmap blob)
(partial store-image-texture shape-id image-id thumbnail?))))))
(rx/catch
(fn [cause]
(log/error :hint "Could not fetch image"
@ -833,124 +858,49 @@
(defn set-shape-fills
[shape-id fills thumbnail?]
(if (empty? fills)
(h/call wasm/internal-module "_clear_shape_fills")
(let [fills (types.fills/coerce fills)
image-ids (types.fills/get-image-ids fills)
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
heap (mem/get-heap-u32)]
;; write fills to the heap
(types.fills/write-to fills heap offset)
;; send fills to wasm
(h/call wasm/internal-module "_set_shape_fills")
;; load images for image fills if not cached
(keep (fn [id]
(let [buffer (uuid/get-u32 id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
image-ids))))
;; Record write is shared with the headless exporter; the image fetch below is
;; browser-only (WebGL textures).
(when-let [fills (props/write-shape-fills! fills)]
(keep (fn [id]
(let [buffer (uuid/get-u32 id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
(types.fills/get-image-ids fills))))
(defn set-shape-strokes
[shape-id strokes thumbnail?]
(h/call wasm/internal-module "_clear_shape_strokes")
(keep (fn [stroke]
(when-not (:hidden stroke)
(let [opacity (or (:stroke-opacity stroke) 1.0)
color (:stroke-color stroke)
gradient (:stroke-color-gradient stroke)
image (:stroke-image stroke)
width (:stroke-width stroke)
align (:stroke-alignment stroke)
style (-> stroke :stroke-style sr/translate-stroke-style)
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
;; Sentinel -1 means "unset" on the Rust side — keeps the
;; FFI signature flat while letting the renderer fall back
;; to its default dash pattern when no override is stored.
dash (or (:stroke-dash stroke) -1)
gap (or (:stroke-gap stroke) -1)
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
(case align
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap)
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap)
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap))
(cond
(some? gradient)
(do
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
(h/call wasm/internal-module "_add_shape_stroke_fill")
nil)
(some? image)
(let [image-id (get image :id)
buffer (uuid/get-u32 image-id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0) (aget buffer 1)
(aget buffer 2) (aget buffer 3)
thumbnail?)]
(types.fills.impl/write-image-fill offset dview opacity image)
(h/call wasm/internal-module "_add_shape_stroke_fill")
(when (== cached-image? 0)
(fetch-image shape-id image-id thumbnail?)))
(some? color)
(do
(types.fills.impl/write-solid-fill offset dview opacity color)
(h/call wasm/internal-module "_add_shape_stroke_fill")
nil)))))
strokes))
;; Record write is shared with the headless exporter; the image fetch below is
;; browser-only (WebGL textures).
(keep (fn [image-id]
(let [buffer (uuid/get-u32 image-id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id image-id thumbnail?))))
(props/write-shape-strokes! strokes)))
(defn set-shape-svg-attrs
[attrs]
(let [style (:style attrs)
fill-rule (-> (or (:fillRule style) (:fillRule attrs)) sr/translate-fill-rule)
stroke-linecap (-> (or (:strokeLinecap style) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
stroke-linejoin (-> (or (:strokeLinejoin style) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
fill-none (= "none" (or (:fill style) (:fill attrs)))]
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
(props/set-shape-svg-attrs attrs))
(defn set-shape-path-content
"Upload path content in chunks to WASM."
[content]
(let [chunk-size (quot MAX_BUFFER_CHUNK_SIZE 4)
buffer-size (path/get-byte-size content)
padded-size (* 4 (mth/ceil (/ buffer-size 4)))
buffer (js/Uint8Array. padded-size)]
(path/write-to content (.-buffer buffer) 0)
(h/call wasm/internal-module "_start_shape_path_buffer")
(let [heapu32 (mem/get-heap-u32)]
(loop [offset 0]
(when (< offset padded-size)
(let [end (min padded-size (+ offset (* chunk-size 4)))
chunk (.subarray buffer offset end)
chunk-u32 (js/Uint32Array. chunk.buffer chunk.byteOffset (quot (.-length chunk) 4))
offset-size (.-length chunk-u32)
heap-offset (mem/alloc->offset-32 (* 4 offset-size))]
(.set heapu32 chunk-u32 heap-offset)
(h/call wasm/internal-module "_set_shape_path_chunk_buffer")
(recur end)))))
(h/call wasm/internal-module "_set_shape_path_buffer")))
(props/set-shape-path-content content))
(defn set-shape-svg-raw-content
[content]
(let [size (get-string-length content)
offset (mem/alloc size)]
(h/call wasm/internal-module "stringToUTF8" content offset size)
(h/call wasm/internal-module "_set_shape_svg_raw_content")))
(props/set-shape-svg-raw-content content))
(defn set-shape-blend-mode
[blend-mode]
@ -995,25 +945,15 @@
(defn set-shape-bool-type
[bool-type]
(h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type)))
(props/set-shape-bool-type bool-type))
(defn set-shape-blur
[blur]
(let [type (sr/translate-blur-type :layer-blur)]
(if (some? blur)
(let [hidden (:hidden blur)
value (:value blur)]
(h/call wasm/internal-module "_set_shape_blur" type hidden value))
(h/call wasm/internal-module "_clear_shape_blur" type))))
(props/set-shape-blur blur))
(defn set-shape-background-blur
[background-blur]
(let [type (sr/translate-blur-type :background-blur)]
(if (some? background-blur)
(let [hidden (:hidden background-blur)
value (:value background-blur)]
(h/call wasm/internal-module "_set_shape_blur" type hidden value))
(h/call wasm/internal-module "_clear_shape_blur" type))))
(props/set-shape-background-blur background-blur))
(defn set-shape-corners
[corners]
@ -1230,27 +1170,7 @@
(defn set-shape-shadows
[shadows]
(h/call wasm/internal-module "_clear_shape_shadows")
(run! (fn [shadow]
(let [color (get shadow :color)
blur (get shadow :blur)
rgba (sr-clr/hex->u32argb (get color :color)
(get color :opacity))
hidden (get shadow :hidden)
x (get shadow :offset-x)
y (get shadow :offset-y)
spread (get shadow :spread)
style (get shadow :style)]
(h/call wasm/internal-module "_add_shape_shadow"
rgba
blur
spread
x
y
(sr/translate-shadow-style style)
hidden)))
shadows))
(props/set-shape-shadows shadows))
(defn fonts-from-text-content [content fallback-fonts-only?]
(let [paragraph-set (first (get content :children))
@ -1289,7 +1209,7 @@
(defn set-shape-grow-type
[grow-type]
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
(props/set-shape-grow-type grow-type))
(defn get-text-dimensions
([id]
@ -1400,45 +1320,19 @@
id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
masked (get shape :masked-group)
fills (get shape :fills)
strokes (if (= type :group)
[] (get shape :strokes))
children (get shape :shapes)
content (let [content (get shape :content)]
(if (= type :text)
(ensure-text-content content)
content))
bool-type (get shape :bool-type)
grow-type (get shape :grow-type)
blur (get shape :blur)
background-blur (get shape :background-blur)
svg-attrs (get shape :svg-attrs)
shadows (get shape :shadow)]
content))]
(shapes/set-shape-base-props shape)
(serialize-shape/serialize-shape! shape)
;; Remaining properties that need separate calls (variable-length or conditional)
(set-shape-children children)
(set-shape-blur blur)
(set-shape-background-blur background-blur)
(when (= type :group)
(set-masked (boolean masked)))
(when (= type :bool)
(set-shape-bool-type bool-type))
(when (and (some? content)
(or (= type :path)
(= type :bool)))
(set-shape-path-content content))
(when (some? svg-attrs)
(set-shape-svg-attrs svg-attrs))
;; Browser-only: svg-raw markup (needs React) + workspace layout.
(when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape)))
(set-shape-shadows shadows)
(when (= type :text)
(set-shape-grow-type grow-type))
(set-shape-layout shape)
(set-layout-data shape)
(let [is-text? (= type :text)
@ -2562,7 +2456,7 @@
(when (and element element-text)
(let [text (subs element-text start-pos end-pos)]
(d/patch-object
txt/default-text-attrs
(txt/get-default-text-attrs)
(d/without-nils
{:x x
:y (+ y height)

View File

@ -14,6 +14,7 @@
[app.config :as cf]
[app.main.fonts :as fonts]
[app.main.store :as st]
[app.render-wasm.fallback-fonts :as fbf]
[app.render-wasm.helpers :as h]
[app.render-wasm.wasm :as wasm]
[app.util.http :as http]
@ -405,68 +406,6 @@
[fonts]
(keep (fn [font] (store-font font)) fonts))
(defn add-emoji-font
[fonts]
(conj fonts {:font-id "gfont-noto-color-emoji"
:font-variant-id "regular"
:style 0
:weight 400
:is-emoji true
:is-fallback true}))
(def noto-fonts
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cyrillic {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:greek {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:hebrew {:font-id "gfont-noto-sans-hebrew" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:thai {:font-id "gfont-noto-sans-thai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:devanagari {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tamil {:font-id "gfont-noto-sans-tamil" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:latin-ext {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:vietnamese {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:armenian {:font-id "gfont-noto-sans-armenian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:bengali {:font-id "gfont-noto-sans-bengali" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cherokee {:font-id "gfont-noto-sans-cherokee" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:ethiopic {:font-id "gfont-noto-sans-ethiopic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:georgian {:font-id "gfont-noto-sans-georgian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gujarati {:font-id "gfont-noto-sans-gujarati" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gurmukhi {:font-id "gfont-noto-sans-gurmukhi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:khmer {:font-id "gfont-noto-sans-khmer" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:lao {:font-id "gfont-noto-sans-lao" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:malayalam {:font-id "gfont-noto-sans-malayalam" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:myanmar {:font-id "gfont-noto-sans-myanmar" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:sinhala {:font-id "gfont-noto-sans-sinhala" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:telugu {:font-id "gfont-noto-sans-telugu" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tibetan {:font-id "gfont-noto-serif-tibetan" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:javanese {:font-id "gfont-noto-sans-javanese" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:kannada {:font-id "gfont-noto-sans-kannada" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:oriya {:font-id "gfont-noto-sans-oriya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:mongolian {:font-id "gfont-noto-sans-mongolian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:syriac {:font-id "gfont-noto-sans-syriac" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tifinagh {:font-id "gfont-noto-sans-tifinagh" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:coptic {:font-id "gfont-noto-sans-coptic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:ol-chiki {:font-id "gfont-noto-sans-ol-chiki" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:vai {:font-id "gfont-noto-sans-vai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:shavian {:font-id "gfont-noto-sans-shavian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:osmanya {:font-id "gfont-noto-sans-osmanya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:runic {:font-id "gfont-noto-sans-runic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:old-italic {:font-id "gfont-noto-sans-old-italic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:brahmi {:font-id "gfont-noto-sans-brahmi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:modi {:font-id "gfont-noto-sans-modi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:sora-sompeng {:font-id "gfont-noto-sans-sora-sompeng" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:bamum {:font-id "gfont-noto-sans-bamum" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}})
(defn add-noto-fonts [fonts languages]
(reduce (fn [acc lang]
(if-let [font (get noto-fonts lang)]
(conj acc font)
acc))
fonts
languages))
(def add-emoji-font fbf/add-emoji-font)
(def noto-fonts fbf/noto-fonts)
(def add-noto-fonts fbf/add-noto-fonts)

View File

@ -0,0 +1,199 @@
;; 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 Sucursal en España SL
(ns app.render-wasm.api.props
"Browser-free WASM shape property setters, shared by the workspace render
orchestrator (`app.render-wasm.api`) and the headless exporter
(`app.wasm.serialize`).
These only touch the WASM FFI (`app.render-wasm.{helpers,mem,serializers,
wasm}`) and the shared `common` byte layouts — no store/DOM/React — so they
run identically in the browser and under Node. Setters that need host-specific
data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`."
(:require
[app.common.math :as mth]
[app.common.types.fills :as types.fills]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.types.path :as path]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.mem.heap32 :as mem.h32]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]))
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
(def ^:const UUID-U8-SIZE 16)
(defn set-shape-children
"Uploads the child id list via the dynamic `_set_children` path (handles any
count). The browser also has fixed-arity fast paths for the incremental edit
path; this dynamic one is the shared/batch version."
[children]
(let [children (into [] (filter uuid?) children)]
(if (empty? children)
(h/call wasm/internal-module "_set_children_0")
(let [heap (mem/get-heap-u32)
size (mem/get-alloc-size children UUID-U8-SIZE)
offset (mem/alloc->offset-32 size)]
(reduce (fn [o id] (mem.h32/write-uuid o heap id)) offset children)
(h/call wasm/internal-module "_set_children")))))
(defn set-shape-bool-type
[bool-type]
(h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type)))
(defn set-shape-grow-type
[grow-type]
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
(defn write-shape-fills!
"Serializes a fill vector into WASM using the shared `common` byte layout.
Only writes the fill *records*; image fill BYTES are host-specific (the browser
fetches WebGL textures, the exporter provisions decoded bytes), so callers load
images after. Returns the coerced `Fills` (for image-id extraction) or nil when
empty."
[fills]
(if (empty? fills)
(do (h/call wasm/internal-module "_clear_shape_fills") nil)
(let [fills (types.fills/coerce fills)
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
heap (mem/get-heap-u32)]
(types.fills/write-to fills heap offset)
(h/call wasm/internal-module "_set_shape_fills")
fills)))
(defn write-shape-strokes!
"Serializes the stroke vector (records only, like `write-shape-fills!`);
image stroke BYTES are host-specific, so callers load images after. Returns
the image ids referenced by the strokes' image fills."
[strokes]
(h/call wasm/internal-module "_clear_shape_strokes")
(into []
(keep
(fn [stroke]
(when-not (:hidden stroke)
(let [opacity (or (:stroke-opacity stroke) 1.0)
color (:stroke-color stroke)
gradient (:stroke-color-gradient stroke)
image (:stroke-image stroke)
width (:stroke-width stroke)
align (:stroke-alignment stroke)
style (-> stroke :stroke-style sr/translate-stroke-style)
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
;; Sentinel -1 means "unset" on the Rust side — keeps the
;; FFI signature flat while letting the renderer fall back
;; to its default dash pattern when no override is stored.
dash (or (:stroke-dash stroke) -1)
gap (or (:stroke-gap stroke) -1)
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
(case align
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap)
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap)
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap))
(cond
(some? gradient)
(do (types.fills.impl/write-gradient-fill offset dview opacity gradient)
(h/call wasm/internal-module "_add_shape_stroke_fill")
nil)
(some? image)
(do (types.fills.impl/write-image-fill offset dview opacity image)
(h/call wasm/internal-module "_add_shape_stroke_fill")
(get image :id))
(some? color)
(do (types.fills.impl/write-solid-fill offset dview opacity color)
(h/call wasm/internal-module "_add_shape_stroke_fill")
nil))))))
strokes))
(defn- get-string-length
[string]
(+ (count string) 1))
(defn set-shape-blur
[blur]
(let [type (sr/translate-blur-type :layer-blur)]
(if (some? blur)
(h/call wasm/internal-module "_set_shape_blur" type (boolean (:hidden blur)) (:value blur))
(h/call wasm/internal-module "_clear_shape_blur" type))))
(defn set-shape-background-blur
[background-blur]
(let [type (sr/translate-blur-type :background-blur)]
(if (some? background-blur)
(h/call wasm/internal-module "_set_shape_blur" type (boolean (:hidden background-blur)) (:value background-blur))
(h/call wasm/internal-module "_clear_shape_blur" type))))
(defn set-shape-shadows
[shadows]
(h/call wasm/internal-module "_clear_shape_shadows")
(run! (fn [shadow]
(let [color (get shadow :color)
blur (get shadow :blur)
rgba (sr-clr/hex->u32argb (get color :color)
(get color :opacity))
hidden (get shadow :hidden)
x (get shadow :offset-x)
y (get shadow :offset-y)
spread (get shadow :spread)
style (get shadow :style)]
(h/call wasm/internal-module "_add_shape_shadow"
rgba
blur
spread
x
y
(sr/translate-shadow-style style)
hidden)))
shadows))
(defn set-masked
[masked]
(h/call wasm/internal-module "_set_shape_masked_group" masked))
(defn set-shape-svg-attrs
[attrs]
(let [style (:style attrs)
fill-rule (-> (or (:fillRule style) (:fillRule attrs)) sr/translate-fill-rule)
stroke-linecap (-> (or (:strokeLinecap style) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
stroke-linejoin (-> (or (:strokeLinejoin style) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
fill-none (= "none" (or (:fill style) (:fill attrs)))]
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
(defn set-shape-svg-raw-content
[content]
(let [size (get-string-length content)
offset (mem/alloc size)]
(h/call wasm/internal-module "stringToUTF8" content offset size)
(h/call wasm/internal-module "_set_shape_svg_raw_content")))
(defn set-shape-path-content
"Upload path content in chunks to WASM."
[content]
(let [chunk-size (quot MAX_BUFFER_CHUNK_SIZE 4)
buffer-size (path/get-byte-size content)
padded-size (* 4 (mth/ceil (/ buffer-size 4)))
buffer (js/Uint8Array. padded-size)]
(path/write-to content (.-buffer buffer) 0)
(h/call wasm/internal-module "_start_shape_path_buffer")
(let [heapu32 (mem/get-heap-u32)]
(loop [offset 0]
(when (< offset padded-size)
(let [end (min padded-size (+ offset (* chunk-size 4)))
chunk (.subarray buffer offset end)
chunk-u32 (js/Uint32Array. chunk.buffer chunk.byteOffset (quot (.-length chunk) 4))
offset-size (.-length chunk-u32)
heap-offset (mem/alloc->offset-32 (* 4 offset-size))]
(.set heapu32 chunk-u32 heap-offset)
(h/call wasm/internal-module "_set_shape_path_chunk_buffer")
(recur end)))))
(h/call wasm/internal-module "_set_shape_path_buffer")))

View File

@ -6,240 +6,21 @@
(ns app.render-wasm.api.texts
(:require
[app.common.data :as d]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.uuid :as uuid]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.wasm :as wasm]))
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
(def ^:const SPAN-ATTR-U8-SIZE 64)
(def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS)
(defn- encode-text
"Into an UTF8 buffer. Returns an ArrayBuffer instance"
[text]
(let [encoder (js/TextEncoder.)]
(.encode encoder text)))
(defn- write-span-fills
[offset dview fills]
(let [new-ofset (reduce (fn [offset fill]
(let [opacity (get fill :fill-opacity 1.0)
color (get fill :fill-color)
gradient (get fill :fill-color-gradient)
image (get fill :fill-image)]
(cond
(some? color)
(types.fills.impl/write-solid-fill offset dview opacity color)
(some? gradient)
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
(some? image)
(types.fills.impl/write-image-fill offset dview opacity image))))
offset
fills)
padding-fills (max 0 (- MAX-TEXT-FILLS (count fills)))]
(+ new-ofset (* padding-fills types.fills.impl/FILL-U8-SIZE))))
(defn- write-paragraph
[offset dview paragraph]
(let [text-align (sr/translate-text-align (get paragraph :text-align))
text-direction (sr/translate-text-direction (get paragraph :text-direction))
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
text-transform (sr/translate-text-transform (get paragraph :text-transform))
line-height (f/serialize-line-height (get paragraph :line-height))
letter-spacing (f/serialize-letter-spacing (get paragraph :letter-spacing))]
(-> offset
(mem/write-u8 dview text-align)
(mem/write-u8 dview text-direction)
(mem/write-u8 dview text-decoration)
(mem/write-u8 dview text-transform)
(mem/write-f32 dview line-height)
(mem/write-f32 dview letter-spacing)
(mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE))))
(defn- write-spans
[offset dview spans paragraph]
(let [paragraph-font-size (get paragraph :font-size)
paragraph-font-weight (-> paragraph :font-weight f/serialize-font-weight)
paragraph-line-height (f/serialize-line-height (get paragraph :line-height))]
(reduce (fn [offset span]
(let [font-style (sr/translate-font-style (get span :font-style "normal"))
font-size (get span :font-size paragraph-font-size)
font-size (f/serialize-font-size font-size)
line-height (f/serialize-line-height (get span :line-height) paragraph-line-height)
letter-spacing (f/serialize-letter-spacing (get span :letter-spacing))
font-weight (get span :font-weight paragraph-font-weight)
font-weight (f/serialize-font-weight font-weight)
font-id (f/normalize-font-id (get span :font-id "sourcesanspro"))
font-family (hash (get span :font-family "sourcesanspro"))
text-buffer (encode-text (get span :text ""))
text-length (mem/size text-buffer)
fills (take MAX-TEXT-FILLS (get span :fills []))
font-variant-id
(get span :font-variant-id)
font-variant-id
(if (uuid? font-variant-id)
font-variant-id
uuid/zero)
text-decoration
(or (sr/translate-text-decoration (:text-decoration span))
(sr/translate-text-decoration (:text-decoration paragraph))
(sr/translate-text-decoration "none"))
text-transform
(or (sr/translate-text-transform (:text-transform span))
(sr/translate-text-transform (:text-transform paragraph))
(sr/translate-text-transform "none"))
text-direction
(or (sr/translate-text-direction (:text-direction span))
(sr/translate-text-direction (:text-direction paragraph))
(sr/translate-text-direction "ltr"))]
(-> offset
(mem/write-u8 dview font-style)
(mem/write-u8 dview text-decoration)
(mem/write-u8 dview text-transform)
(mem/write-u8 dview text-direction)
(mem/write-f32 dview font-size)
(mem/write-f32 dview line-height)
(mem/write-f32 dview letter-spacing)
(mem/write-u32 dview font-weight)
(mem/write-uuid dview font-id)
(mem/write-i32 dview font-family)
(mem/write-uuid dview (d/nilv font-variant-id uuid/zero))
(mem/write-i32 dview text-length)
(mem/write-i32 dview (count fills))
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
(write-span-fills dview fills))))
offset
spans)))
[app.render-wasm.fallback-fonts :as fbf]
[app.render-wasm.text-content :as tc]))
(defn write-shape-text
;; buffer has the following format:
;; [<num-spans> <paragraph_attributes> <spans_attributes> <text>]
"Workspace text serialization: the byte writing is shared via
`app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
[spans paragraph text]
(let [normalized-paragraph (f/normalize-paragraph-font paragraph)
normalized-spans (map #(f/normalize-span-font % normalized-paragraph) spans)
num-spans (count normalized-spans)
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
text-buffer (encode-text text)
text-size (mem/size text-buffer)
total-size (+ 4 metadata-size text-size)
heapu8 (mem/get-heap-u8)
dview (mem/get-data-view)
offset (mem/alloc total-size)]
(-> offset
(mem/write-u32 dview num-spans)
(write-paragraph dview normalized-paragraph)
(write-spans dview normalized-spans normalized-paragraph)
(mem/write-buffer heapu8 text-buffer))
(h/call wasm/internal-module "_set_shape_text_content")))
(def ^:private emoji-pattern
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
(def ^:private unicode-ranges
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
:chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]"
:korean #"[\uAC00-\uD7AF]"
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"
:cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]"
:greek #"[\u0370-\u03FF\u1F00-\u1FFF]"
:hebrew #"[\u0590-\u05FF\uFB1D-\uFB4F]"
:thai #"[\u0E00-\u0E7F]"
:devanagari #"[\u0900-\u097F\uA8E0-\uA8FF]"
:tamil #"[\u0B80-\u0BFF]"
:latin-ext #"[\u0100-\u017F\u0180-\u024F]"
:vietnamese #"[\u1EA0-\u1EF9]"
:armenian #"[\u0530-\u058F\uFB13-\uFB17]"
:bengali #"[\u0980-\u09FF]"
:cherokee #"[\u13A0-\u13FF]"
:ethiopic #"[\u1200-\u137F]"
:georgian #"[\u10A0-\u10FF]"
:gujarati #"[\u0A80-\u0AFF]"
:gurmukhi #"[\u0A00-\u0A7F]"
:khmer #"[\u1780-\u17FF\u19E0-\u19FF]"
:lao #"[\u0E80-\u0EFF]"
:malayalam #"[\u0D00-\u0D7F]"
:myanmar #"[\u1000-\u109F\uAA60-\uAA7F]"
:sinhala #"[\u0D80-\u0DFF]"
:telugu #"[\u0C00-\u0C7F]"
:tibetan #"[\u0F00-\u0FFF]"
:javanese #"[\uA980-\uA9DF]"
:kannada #"[\u0C80-\u0CFF]"
:oriya #"[\u0B00-\u0B7F]"
:mongolian #"[\u1800-\u18AF]"
:syriac #"[\u0700-\u074F]"
:tifinagh #"[\u2D30-\u2D7F]"
:coptic #"[\u2C80-\u2CFF]"
:ol-chiki #"[\u1C50-\u1C7F]"
:vai #"[\uA500-\uA63F]"
:shavian #"\uD801[\uDC50-\uDC7F]"
:osmanya #"\uD801[\uDC80-\uDCAF]"
:runic #"[\u16A0-\u16FF]"
:old-italic #"\uD800[\uDF00-\uDF2F]"
:brahmi #"\uD804[\uDC00-\uDC7F]"
:modi #"\uD805[\uDE00-\uDE5F]"
:sora-sompeng #"\uD804[\uDCD0-\uDCFF]"
:bamum #"[\uA6A0-\uA6FF]"
:meroitic #"\uD802[\uDD80-\uDD9F]"
;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc.
:symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]"
;; Additional symbol blocks covered by Noto Sans Symbols 2:
;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.)
;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF),
;; Legacy Computing Symbols (U+1FB00-1FBFF)
:symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]"
:music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"})
(defn contains-emoji? [text]
(let [result (re-find emoji-pattern text)]
(boolean result)))
(defn collect-used-languages
[used text]
(reduce-kv (fn [result lang pattern]
(cond
;; Skip regex operation if we already know that
;; langage is present
(contains? result lang)
result
(re-find pattern text)
(conj result lang)
:else
result))
used
unicode-ranges))
(tc/write-shape-text! spans paragraph text
{:normalize-font-id f/normalize-font-id
:normalize-paragraph f/normalize-paragraph-font
:normalize-span f/normalize-span-font}))
;; Emoji/script detection lives in the host-agnostic
;; `app.render-wasm.fallback-fonts`; kept re-exported here for existing
;; workspace callers.
(def contains-emoji? fbf/contains-emoji?)
(def collect-used-languages fbf/collect-used-languages)

View File

@ -0,0 +1,157 @@
;; 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 Sucursal en España SL
(ns app.render-wasm.fallback-fonts
"Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and
which (google) fallback fonts cover them. Pure data + pure fns — no browser
or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the
headless exporter (`app.renderer.wasm`) compute the SAME fallback set from
the same source. Anything a host must fetch/upload for text to render
belongs here, not in host code.")
(def ^:private emoji-pattern
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
(def ^:private unicode-ranges
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
:chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]"
:korean #"[\uAC00-\uD7AF]"
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"
:cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]"
:greek #"[\u0370-\u03FF\u1F00-\u1FFF]"
:hebrew #"[\u0590-\u05FF\uFB1D-\uFB4F]"
:thai #"[\u0E00-\u0E7F]"
:devanagari #"[\u0900-\u097F\uA8E0-\uA8FF]"
:tamil #"[\u0B80-\u0BFF]"
:latin-ext #"[\u0100-\u017F\u0180-\u024F]"
:vietnamese #"[\u1EA0-\u1EF9]"
:armenian #"[\u0530-\u058F\uFB13-\uFB17]"
:bengali #"[\u0980-\u09FF]"
:cherokee #"[\u13A0-\u13FF]"
:ethiopic #"[\u1200-\u137F]"
:georgian #"[\u10A0-\u10FF]"
:gujarati #"[\u0A80-\u0AFF]"
:gurmukhi #"[\u0A00-\u0A7F]"
:khmer #"[\u1780-\u17FF\u19E0-\u19FF]"
:lao #"[\u0E80-\u0EFF]"
:malayalam #"[\u0D00-\u0D7F]"
:myanmar #"[\u1000-\u109F\uAA60-\uAA7F]"
:sinhala #"[\u0D80-\u0DFF]"
:telugu #"[\u0C00-\u0C7F]"
:tibetan #"[\u0F00-\u0FFF]"
:javanese #"[\uA980-\uA9DF]"
:kannada #"[\u0C80-\u0CFF]"
:oriya #"[\u0B00-\u0B7F]"
:mongolian #"[\u1800-\u18AF]"
:syriac #"[\u0700-\u074F]"
:tifinagh #"[\u2D30-\u2D7F]"
:coptic #"[\u2C80-\u2CFF]"
:ol-chiki #"[\u1C50-\u1C7F]"
:vai #"[\uA500-\uA63F]"
:shavian #"\uD801[\uDC50-\uDC7F]"
:osmanya #"\uD801[\uDC80-\uDCAF]"
:runic #"[\u16A0-\u16FF]"
:old-italic #"\uD800[\uDF00-\uDF2F]"
:brahmi #"\uD804[\uDC00-\uDC7F]"
:modi #"\uD805[\uDE00-\uDE5F]"
:sora-sompeng #"\uD804[\uDCD0-\uDCFF]"
:bamum #"[\uA6A0-\uA6FF]"
:meroitic #"\uD802[\uDD80-\uDD9F]"
;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc.
:symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]"
;; Additional symbol blocks covered by Noto Sans Symbols 2:
;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.)
;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF),
;; Legacy Computing Symbols (U+1FB00-1FBFF)
:symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]"
:music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"})
(defn contains-emoji? [text]
(let [result (re-find emoji-pattern text)]
(boolean result)))
(defn collect-used-languages
[used text]
(reduce-kv (fn [result lang pattern]
(cond
;; Skip regex operation if we already know that
;; langage is present
(contains? result lang)
result
(re-find pattern text)
(conj result lang)
:else
result))
used
unicode-ranges))
(defn add-emoji-font
[fonts]
(conj fonts {:font-id "gfont-noto-color-emoji"
:font-variant-id "regular"
:style 0
:weight 400
:is-emoji true
:is-fallback true}))
(def noto-fonts
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cyrillic {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:greek {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:hebrew {:font-id "gfont-noto-sans-hebrew" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:thai {:font-id "gfont-noto-sans-thai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:devanagari {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tamil {:font-id "gfont-noto-sans-tamil" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:latin-ext {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:vietnamese {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:armenian {:font-id "gfont-noto-sans-armenian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:bengali {:font-id "gfont-noto-sans-bengali" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cherokee {:font-id "gfont-noto-sans-cherokee" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:ethiopic {:font-id "gfont-noto-sans-ethiopic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:georgian {:font-id "gfont-noto-sans-georgian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gujarati {:font-id "gfont-noto-sans-gujarati" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gurmukhi {:font-id "gfont-noto-sans-gurmukhi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:khmer {:font-id "gfont-noto-sans-khmer" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:lao {:font-id "gfont-noto-sans-lao" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:malayalam {:font-id "gfont-noto-sans-malayalam" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:myanmar {:font-id "gfont-noto-sans-myanmar" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:sinhala {:font-id "gfont-noto-sans-sinhala" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:telugu {:font-id "gfont-noto-sans-telugu" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tibetan {:font-id "gfont-noto-serif-tibetan" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:javanese {:font-id "gfont-noto-sans-javanese" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:kannada {:font-id "gfont-noto-sans-kannada" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:oriya {:font-id "gfont-noto-sans-oriya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:mongolian {:font-id "gfont-noto-sans-mongolian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:syriac {:font-id "gfont-noto-sans-syriac" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tifinagh {:font-id "gfont-noto-sans-tifinagh" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:coptic {:font-id "gfont-noto-sans-coptic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:ol-chiki {:font-id "gfont-noto-sans-ol-chiki" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:vai {:font-id "gfont-noto-sans-vai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:shavian {:font-id "gfont-noto-sans-shavian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:osmanya {:font-id "gfont-noto-sans-osmanya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:runic {:font-id "gfont-noto-sans-runic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:old-italic {:font-id "gfont-noto-sans-old-italic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:brahmi {:font-id "gfont-noto-sans-brahmi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:modi {:font-id "gfont-noto-sans-modi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:sora-sompeng {:font-id "gfont-noto-sans-sora-sompeng" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:bamum {:font-id "gfont-noto-sans-bamum" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}})
(defn add-noto-fonts [fonts languages]
(reduce (fn [acc lang]
(if-let [font (get noto-fonts lang)]
(conj acc font)
acc))
fonts
languages))

View File

@ -0,0 +1,47 @@
;; 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 Sucursal en España SL
(ns app.render-wasm.resources
"Host-agnostic enumeration of the external resources a scene needs to
render: which image bytes its shapes reference. Pure data walking — no
browser or Node dependencies — so the workspace and the headless exporter
derive the same set from the same source (sibling of
`app.render-wasm.fallback-fonts`, which does the same for fonts)."
(:require
[app.common.types.fills :as types.fills]))
(defn- fill-image-ids
[fills]
(some-> fills not-empty types.fills/coerce types.fills/get-image-ids))
(defn- stroke-image-ids
[strokes]
(keep (comp :id :stroke-image) strokes))
(defn- text-image-ids
"Image-fill ids referenced by a text shape's span fills."
[content]
(when content
(->> (tree-seq :children :children content)
(mapcat :fills)
(keep (comp :id :fill-image)))))
(defn shape-image-ids
"Distinct image ids referenced by one shape: its fills, its strokes' image
fills, and (for texts) its spans' image fills."
[shape]
(-> #{}
(into (fill-image-ids (:fills shape)))
(into (stroke-image-ids (:strokes shape)))
;; `:content` is only a text tree for text shapes (paths reuse the key
;; for geometry).
(into (when (= :text (:type shape))
(text-image-ids (:content shape))))))
(defn scene-image-ids
"Distinct image ids referenced anywhere in an `objects` map."
[scene]
(into #{} (mapcat shape-image-ids) (vals scene)))

View File

@ -0,0 +1,54 @@
;; 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 Sucursal en España SL
(ns app.render-wasm.serialize-shape
"Single source of truth for the host-independent part of serializing a whole
shape into the WASM design state.
Both batch serializers call this so they can't drift:
- the workspace `app.render-wasm.api/set-object` (browser), and
- the headless exporter `app.wasm.serialize/set-shape!` (Node).
It applies only the properties that need no host-specific resources or driver:
base props, children, blur, background blur, shadows, svg attrs, group mask,
bool type, path/bool geometry and text grow type. The parts that DO differ by
host are handled by each caller AFTER this runs:
- fills / strokes (image bytes are fetched + uploaded differently),
- text content (fonts),
- svg-raw markup (browser renders it via React),
- layout (grid/flex — workspace only).
The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps
dispatching per changed key through the same underlying `props` setters."
(:require
[app.render-wasm.api.props :as props]
[app.render-wasm.api.shapes :as shapes]))
(defn serialize-shape!
"Applies every host-independent WASM property of `shape`. `set-shape-base-props`
runs first because it selects the current shape (`use_shape`) the rest mutate."
[shape]
(let [type (get shape :type)]
(shapes/set-shape-base-props shape)
(props/set-shape-children (get shape :shapes))
(props/set-shape-blur (get shape :blur))
(props/set-shape-background-blur (get shape :background-blur))
(props/set-shape-shadows (get shape :shadow))
(when (some? (get shape :svg-attrs))
(props/set-shape-svg-attrs (get shape :svg-attrs)))
(when (= type :group)
(props/set-masked (boolean (get shape :masked-group))))
(when (= type :bool)
(props/set-shape-bool-type (get shape :bool-type)))
(when (and (contains? #{:path :bool} type) (some? (get shape :content)))
(props/set-shape-path-content (get shape :content)))
(when (= type :text)
(props/set-shape-grow-type (get shape :grow-type)))))

View File

@ -0,0 +1,198 @@
;; 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 Sucursal en España SL
(ns app.render-wasm.text-content
"Single source of truth for writing a text shape's content into the WASM design
state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is
identical for the workspace and the headless exporter — only *font resolution*
differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts
catalog + custom variants). So the byte-writing lives here and font resolution
is injected via the `opts` map passed to `write-shape-text!`.
Fully portable (no store/DOM/React), so it runs under Node too."
(:require
[app.common.data :as d]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.uuid :as uuid]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.wasm :as wasm]
[cuerdas.core :as str]))
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
(def ^:const SPAN-ATTR-U8-SIZE 64)
(def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS)
(def ^:private default-font-size 14)
(def ^:private default-line-height 1.2)
(def ^:private default-letter-spacing 0.0)
;; --- pure attribute serializers -------------------------------------------
(defn serialize-font-size
[font-size]
(cond
(number? font-size) font-size
(string? font-size) (or (d/parse-double font-size) default-font-size)
:else default-font-size))
(defn serialize-font-weight
[font-weight]
(if (number? font-weight)
font-weight
(let [font-weight-str (str font-weight)]
(cond
(re-matches #"\d+" font-weight-str) (js/Number font-weight-str)
(str/includes? font-weight-str "bold") 700
(str/includes? font-weight-str "black") 900
(str/includes? font-weight-str "extrabold") 800
(str/includes? font-weight-str "extralight") 200
(str/includes? font-weight-str "light") 300
(str/includes? font-weight-str "medium") 500
(str/includes? font-weight-str "semibold") 600
(str/includes? font-weight-str "thin") 100
:else 400))))
(defn serialize-line-height
([line-height] (serialize-line-height line-height default-line-height))
([line-height default-value]
(cond
(number? line-height) line-height
(string? line-height) (or (d/parse-double line-height) default-value)
:else default-value)))
(defn serialize-letter-spacing
[letter-spacing]
(cond
(number? letter-spacing) letter-spacing
(string? letter-spacing) (or (d/parse-double letter-spacing) default-letter-spacing)
:else default-letter-spacing))
;; --- binary writers --------------------------------------------------------
(defn- encode-text
"Into an UTF8 buffer. Returns an ArrayBuffer instance."
[text]
(let [encoder (js/TextEncoder.)]
(.encode encoder text)))
(defn- write-span-fills
[offset dview fills]
(let [new-ofset (reduce (fn [offset fill]
(let [opacity (get fill :fill-opacity 1.0)
color (get fill :fill-color)
gradient (get fill :fill-color-gradient)
image (get fill :fill-image)]
(cond
(some? color)
(types.fills.impl/write-solid-fill offset dview opacity color)
(some? gradient)
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
(some? image)
(types.fills.impl/write-image-fill offset dview opacity image))))
offset
fills)
padding-fills (max 0 (- MAX-TEXT-FILLS (count fills)))]
(+ new-ofset (* padding-fills types.fills.impl/FILL-U8-SIZE))))
(defn- write-paragraph
[offset dview paragraph]
(let [text-align (sr/translate-text-align (get paragraph :text-align))
text-direction (sr/translate-text-direction (get paragraph :text-direction))
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
text-transform (sr/translate-text-transform (get paragraph :text-transform))
line-height (serialize-line-height (get paragraph :line-height))
letter-spacing (serialize-letter-spacing (get paragraph :letter-spacing))]
(-> offset
(mem/write-u8 dview text-align)
(mem/write-u8 dview text-direction)
(mem/write-u8 dview text-decoration)
(mem/write-u8 dview text-transform)
(mem/write-f32 dview line-height)
(mem/write-f32 dview letter-spacing)
(mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE))))
(defn- write-spans
[offset dview spans paragraph normalize-font-id]
(let [paragraph-font-size (get paragraph :font-size)
paragraph-font-weight (-> paragraph :font-weight serialize-font-weight)
paragraph-line-height (serialize-line-height (get paragraph :line-height))]
(reduce
(fn [offset span]
(let [font-style (sr/translate-font-style (get span :font-style "normal"))
font-size (serialize-font-size (get span :font-size paragraph-font-size))
line-height (serialize-line-height (get span :line-height) paragraph-line-height)
letter-spacing (serialize-letter-spacing (get span :letter-spacing))
font-weight (serialize-font-weight (get span :font-weight paragraph-font-weight))
font-id (normalize-font-id (get span :font-id "sourcesanspro"))
font-family (hash (get span :font-family "sourcesanspro"))
text-buffer (encode-text (get span :text ""))
text-length (mem/size text-buffer)
fills (take MAX-TEXT-FILLS (get span :fills []))
font-variant-id (get span :font-variant-id)
font-variant-id (if (uuid? font-variant-id) font-variant-id uuid/zero)
text-decoration (or (sr/translate-text-decoration (:text-decoration span))
(sr/translate-text-decoration (:text-decoration paragraph))
(sr/translate-text-decoration "none"))
text-transform (or (sr/translate-text-transform (:text-transform span))
(sr/translate-text-transform (:text-transform paragraph))
(sr/translate-text-transform "none"))
text-direction (or (sr/translate-text-direction (:text-direction span))
(sr/translate-text-direction (:text-direction paragraph))
(sr/translate-text-direction "ltr"))]
(-> offset
(mem/write-u8 dview font-style)
(mem/write-u8 dview text-decoration)
(mem/write-u8 dview text-transform)
(mem/write-u8 dview text-direction)
(mem/write-f32 dview font-size)
(mem/write-f32 dview line-height)
(mem/write-f32 dview letter-spacing)
(mem/write-u32 dview font-weight)
(mem/write-uuid dview font-id)
(mem/write-i32 dview font-family)
(mem/write-uuid dview (d/nilv font-variant-id uuid/zero))
(mem/write-i32 dview text-length)
(mem/write-i32 dview (count fills))
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
(write-span-fills dview fills))))
offset
spans)))
(defn write-shape-text!
"Writes one paragraph's spans + text into WASM and appends it to the current
shape via `_set_shape_text_content`.
`opts` injects host-specific font resolution:
- `:normalize-font-id` (string font-id -> wasm uuid) — required in practice,
- `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a
fonts DB (workspace); default to identity (the exporter resolves variants
differently / not at all)."
[spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span]
:or {normalize-font-id identity
normalize-paragraph identity
normalize-span (fn [span _paragraph] span)}}]
(let [paragraph (normalize-paragraph paragraph)
spans (map #(normalize-span % paragraph) spans)
num-spans (count spans)
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
text-buffer (encode-text text)
text-size (mem/size text-buffer)
total-size (+ 4 metadata-size text-size)
heapu8 (mem/get-heap-u8)
dview (mem/get-data-view)
offset (mem/alloc total-size)]
(-> offset
(mem/write-u32 dview num-spans)
(write-paragraph dview paragraph)
(write-spans dview spans paragraph normalize-font-id)
(mem/write-buffer heapu8 text-buffer))
(h/call wasm/internal-module "_set_shape_text_content")))

View File

@ -89,6 +89,9 @@
;; Show info about shapes
:shape-panel
;; Show the floating components debugger window
:components-debugger
;; Show what is touched in copies
:display-touched

View File

@ -9,17 +9,19 @@
[app.common.data :as d]
[app.common.exceptions :as ex]
[cljs.spec.alpha :as s]
[clojure.string :refer [index-of]]
[cuerdas.core :as str]
[instaparse.core :as insta]))
(def parser
(insta/parser
"opt-expr = '' | expr
expr = term (<spaces> ('+'|'-') <spaces> expr)* |
('+'|'-'|'*'|'/') <spaces> factor
term = factor (<spaces> ('*'|'/') <spaces> term)*
factor = number | ('(' <spaces> expr <spaces> ')')
;; Note that there is ambiguity, so we don't allow relative substraction,
;; a leading '-' is always a negation.
"opt-expr = '' | rel-expr | expr
rel-expr = ('+'|'*'|'/') <spaces> factor
expr = term (<spaces> ('+'|'-') <spaces> term)*
term = factor (<spaces> ('*'|'/') <spaces> factor)*
factor = number | neg | ('(' <spaces> expr <spaces> ')')
neg = <'-'> <spaces> factor
number = #'[0-9]*[.,]?[0-9]+%?'
spaces = ' '*"))
@ -31,26 +33,29 @@
:opt-expr
(if (empty? args) nil (interpret (first args) init-value))
:rel-expr
(let [operator (first args)
second-value (interpret (second args) init-value)]
(case operator
"+" (+ init-value second-value)
"*" (* init-value second-value)
"/" (/ init-value second-value)))
:neg
(- (interpret (first args) init-value))
:expr
(if (index-of "+-*/" (first args))
(let [operator (first args)
second-value (interpret (second args) init-value)]
(case operator
"+" (+ init-value second-value)
"-" (- 0 second-value) ;; Note that there is ambiguity, so we don't allow
"*" (* init-value second-value) ;; relative substraction, it's only a negative number
"/" (/ init-value second-value)))
(let [value (interpret (first args) init-value)]
(loop [value value
rest-expr (rest args)]
(if (empty? rest-expr)
value
(let [operator (first rest-expr)
second-value (interpret (second rest-expr) init-value)
rest-expr (-> rest-expr rest rest)]
(case operator
"+" (recur (+ value second-value) rest-expr)
"-" (recur (- value second-value) rest-expr)))))))
(let [value (interpret (first args) init-value)]
(loop [value value
rest-expr (rest args)]
(if (empty? rest-expr)
value
(let [operator (first rest-expr)
second-value (interpret (second rest-expr) init-value)
rest-expr (-> rest-expr rest rest)]
(case operator
"+" (recur (+ value second-value) rest-expr)
"-" (recur (- value second-value) rest-expr))))))
:term
(let [value (interpret (first args) init-value)]

View File

@ -9,6 +9,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.transit :as transit]
[app.common.types.text :as txt]
[app.main.fonts :as fonts]
[app.util.dom :as dom]
[app.util.text-position-data :as tpd]
@ -105,7 +106,8 @@
:text-decoration (dm/str (get-prop styles "text-decoration"))
:letter-spacing (dm/str (get-prop styles "letter-spacing"))
:font-style (dm/str (get-prop styles "font-style"))
:fills (transit/decode-str (get-prop styles "--fills"))
:fills (or (transit/decode-str (get-prop styles "--fills"))
txt/default-text-fills)
:text text})))]
(when (some? shape-id)

View File

@ -9,12 +9,16 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh]
[app.common.files.repair :as cfr]
[app.common.files.validate :as cfv]
[app.common.json :as json]
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.transit :as t]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.uuid :as uuid]
[app.main.data.changes :as dwc]
@ -28,6 +32,7 @@
[app.main.data.workspace.path.shortcuts]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shortcuts]
[app.main.data.workspace.undo :as dwu]
[app.main.errors :as errors]
[app.main.repo :as rp]
[app.main.store :as st]
@ -528,3 +533,112 @@
[o]
(app.common.pprint/pprint o {:level 100 :length 100}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PRUNE UNRELATED ITEMS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- get-related-ids
"Given a `context` map (:pindex, :fdata) and a `[page-id id]` pair, returns
the set of `[page-id id]` pairs directly related to it, NOT recursively:
- its children (including itself)
- its ancestors
- the main instance `[page-id id]` of its component, if it's inside a
component copy of a component defined in the current file (libraries
are ignored)
Ids are only unique within a page, so every id is always tracked together
with the page it belongs to."
[{:keys [pindex fdata]} [page-id id]]
(let [page-objects (-> (get pindex page-id) :objects)
shape (get page-objects id)]
(if (nil? shape)
#{}
(let [related (-> #{}
(into (map (partial vector page-id) (cfh/get-parent-ids page-objects id)))
(into (map (partial vector page-id) (cfh/get-children-ids-with-self page-objects id))))
head (when (ctk/in-component-copy? shape)
(ctn/get-head-shape page-objects shape))
component (when (and (some? head) (= (:component-file head) (:id fdata)))
(ctkl/get-component fdata (:component-id head)))]
(cond-> related
(some? component)
(conj [(:main-instance-page component) (:main-instance-id component)]))))))
(defn ^:export prune-unrelated-items
"This function is DESTRUCTIVE. It deletes from the current file all the pages and layers unrelated to the selection.
It is used to isolate bugs"
[]
(let [state @st/state
current-pid (:current-page-id state)
selected (get-selected state)
fdata (dsh/lookup-file-data state)
pindex (:pages-index fdata)
context {:pindex pindex
:fdata fdata}
related
(loop [related (into #{} (map (partial vector current-pid)) selected)]
(let [expanded (->> related
(reduce (fn [acc pair] (into acc (get-related-ids context pair)))
related)
(remove (fn [[_ id]] (= id uuid/zero)))
set)]
(if (= expanded related)
related
(recur expanded))))
related-by-page
(reduce (fn [acc [page-id id]] (update acc page-id (fnil conj #{}) id))
{}
related)
unrelated-pages
(->> (:pages fdata)
(remove (fn [page-id] (seq (get related-by-page page-id))))
vec)
unrelated-items-by-page
(->> (:pages fdata)
(remove (set unrelated-pages))
(map (fn [page-id]
(let [page-related (get related-by-page page-id #{})
ids (->> (get pindex page-id)
:objects
keys
(remove #{uuid/zero})
(remove page-related)
set)]
[page-id ids])))
(filter (fn [[_ ids]] (seq ids)))
vec)
items-to-delete
(reduce + (map (comp count second) unrelated-items-by-page))]
(js/console.log (str "Pages to delete: " (count unrelated-pages)
", items to delete: " items-to-delete))
(.table js/console
(->> unrelated-items-by-page
(mapcat (fn [[page-id ids]]
(let [objects (-> (get pindex page-id) :objects)]
(map (fn [id]
{:page-id (str page-id)
:id (str id)
:name (:name (get objects id))})
ids))))
(clj->js)))
(when (and (or (seq unrelated-pages) (pos? items-to-delete))
(js/confirm (str "Delete " (count unrelated-pages) " unrelated page(s) and "
items-to-delete " unrelated item(s)?")))
(let [undo-id (js/Symbol)]
(apply st/emit!
(concat
[(dwu/start-undo-transaction undo-id)]
(map dw/delete-page unrelated-pages)
(map (fn [[page-id ids]]
(dw/delete-shapes page-id ids))
unrelated-items-by-page)
[(dwu/commit-undo-transaction undo-id)]))))
nil))

View File

@ -26,15 +26,19 @@
[app.common.data :as d]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.logic.variants :as clv]
[app.common.math :as mth]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.container :as ctn]
[app.common.types.modifiers :as ctm]
[frontend-tests.composable-tests.core :as tm]))
;; ---------------------------------------------------------------------------
@ -127,6 +131,86 @@
(def ^{:doc "Alias of `has-property-of`."} has-attr? has-property-of)
(def ^{:doc "Alias of `applied-property`."} applied-attr applied-property)
;; ---------------------------------------------------------------------------
;; Geometry operations
;;
;; Unlike `change-property` (a raw attribute write), geometric changes must go
;; through the TRANSFORM pipeline: on the frontend, the interpreter dispatches
;; the real sidebar events (`dwt/increase-rotation`, `dwt/update-dimensions`),
;; whose apply-modifiers step also runs the placement-vs-override classification
;; for component copies (calculate-ignore-tree / check-delta) — which is part of
;; what these operations exist to exercise. The synchronous `apply-to` fallback
;; below performs the geometrically equivalent transform through the production
;; math, but cannot classify placement (that code is frontend-only), so cases
;; using these operations are meant to run through the interpreter.
(defrecord Rotate [target angle]
tm/IOperation
(apply-to [this situation]
(let [the-file (tm/file situation)
shape-id (tm/target-shape-id situation target)
page (thf/current-page the-file)
objects (:objects page)
shape (get objects shape-id)
;; rotating a container rotates its whole subtree, as the real event does
ids (into #{shape-id} (cfh/get-children-ids objects shape-id))
center (gsh/shape->center shape)
rotate1 (fn [s] (gsh/transform-shape s (ctm/rotation-modifiers s center angle)))
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
ids
rotate1
objects
{})
file' (thf/apply-changes the-file changes)]
(-> situation
(tm/with-file file')
(tm/record-application this {:target target :angle angle})))))
(defn rotate
"Constructor for the rotation operation: rotate the shape named by `target`
(a role, a label, or a `(situation -> id)` fn) — including its whole subtree —
by `angle` degrees around its center. On the frontend this dispatches the real
`dwt/increase-rotation` event."
[target angle]
(tm/assign-id (->Rotate target angle)))
(defrecord ChangeHeight [target value]
tm/IOperation
(apply-to [this situation]
(let [the-file (tm/file situation)
shape-id (tm/target-shape-id situation target)
page (thf/current-page the-file)
objects (:objects page)
shape (get objects shape-id)
resize1 (fn [s] (gsh/transform-shape
s
(ctm/resize-modifiers (gpt/point 1 (/ value (:height s)))
(gpt/point (:x s) (:y s)))))
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id shape)}
resize1
objects
{})
file' (thf/apply-changes the-file changes)]
(-> situation
(tm/with-file file')
(tm/record-application this {:target target :value value})))))
(defn change-height
"Constructor for the height-change operation: resize the shape named by `target`
to height `value` (width unchanged). On the frontend this dispatches the real
`dwt/update-dimensions` event. Implements `IPropertyCheck`, so a `one-of` over
property and geometry edits can assert uniformly via `has-property-of`."
[target value]
(tm/assign-id (->ChangeHeight target value)))
(extend-type ChangeHeight
IPropertyCheck
(applied-property [_node] :height)
(applied-value [node] (:value node))
(has-property-of [node shape]
(mth/close? (:value node) (:height shape))))
;; ===========================================================================
;; Synchronisation-scenario building blocks
;;

View File

@ -27,6 +27,10 @@
Async: each deftest uses `t/async`; `ftm/check` drives the store and calls
`done` when finished."
(:require
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.math :as mth]
[app.common.types.component :as ctk]
[app.common.types.shape-tree :as ctst]
[cljs.test :as t :include-macros true]
@ -343,4 +347,87 @@
(t/is (= (expected-at s i) (level-color s m i))
(str "level " i)))))]))}))))
(t/deftest case-n-geometry-sync-with-rotated-instances
;; Regression sweep for #10109, with #13267's semantics as its complement: an
;; instance root's transformation is inherited, overridable content —
;; asymmetric to position, which is free per-instance placement.
;;
;; On the simple component-with-copy, sweep three axes: optionally rotate the
;; COPY as a whole (an override of its root's placement — must not block
;; propagation), optionally rotate the MAIN as a whole (inherited content —
;; an untouched copy must follow it), and apply ONE of a property edit
;; (fills) or a geometry edit (height) to the main child. In EVERY variant
;; the chosen edit must arrive at the copy child; the copy's rotation is its
;; own 45° if the copy was rotated (override wins), else the main's 45° if
;; the main was rotated (clean copy follows), else 0; and only a rotated
;; copy's ROOT is touched (:geometry-group) — its child merely follows and
;; stays untouched.
(t/async
done
(let [rotate-copy (n/rotate :copy-root 45)
rotate-main (n/rotate :main-root 45)
edits (tm/one-of
[(n/change-attr :main-instance :fills red)
(n/change-height :main-instance 80)])]
(ftm/check
done
{:setup setup/simple-component-with-labeled-copy
:operation (tm/in-sequence [(tm/optional rotate-copy)
(tm/optional rotate-main)
edits])}
(fn [situation]
(let [chosen (tm/get-choice situation edits)
copy-root (setup/copy-root situation)
copy-child (setup/copy-instance situation)
expected-rotation (cond
(tm/applied? situation rotate-copy) 45
(tm/applied? situation rotate-main) 45
:else 0)]
;; the chosen main edit arrived at the copy child, in every variant
(t/is (some? chosen))
(t/is (n/has-property-of chosen copy-child))
;; the copy shows the expected rotation (own override > followed main > none)
(t/is (mth/close? (or (:rotation copy-root) 0) expected-rotation))
(t/is (mth/close? (or (:rotation copy-child) 0) expected-rotation))
;; only a rotated copy's ROOT is an override; the child merely follows
(t/is (= (if (tm/applied? situation rotate-copy) #{:geometry-group} nil)
(:touched copy-root)))
(t/is (nil? (:touched copy-child)))))))))
(t/deftest case-touched-copy-child-survives-main-rotation
;; A copy whose ROOT is rotated (a geometry override) AND whose CHILD has its
;; own geometry override (resized) must keep the child exactly in place when the
;; MAIN is later rotated: the child's placement is its own, shielded by its
;; touched :geometry-group. We capture the child's position relative to the copy
;; root before the main rotation and assert it is unchanged after.
(t/async
done
(let [before (atom nil)
rel-pos (fn [situation]
(let [child (setup/copy-instance situation)
root (setup/copy-root situation)]
(-> (gpt/subtract (grc/rect->center (:selrect child))
(grc/rect->center (:selrect root)))
(gpt/transform (:transform-inverse root (gmt/matrix))))))
capture (tm/test-that (fn [s] (reset! before (rel-pos s)) (t/is (some? @before))))]
(ftm/check
done
{:setup setup/simple-component-with-labeled-copy
:operation (tm/in-sequence [(n/rotate :copy-root 30)
(n/change-height :copy-instance 60)
capture
(n/rotate :main-root 40)])}
(fn [situation]
(let [copy-child (setup/copy-instance situation)
copy-root (setup/copy-root situation)
after (rel-pos situation)]
;; both overrides are recorded as geometry touches
(t/is (contains? (:touched copy-root) :geometry-group))
(t/is (contains? (:touched copy-child) :geometry-group))
;; the child keeps its own overridden height
(t/is (mth/close? (:height copy-child) 60))
;; and its position relative to the copy root is unchanged by the main rotation
(t/is (mth/close? (:x @before) (:x after) 0.5) (str "rel-x " (:x @before) " -> " (:x after)))
(t/is (mth/close? (:y @before) (:y after) 0.5) (str "rel-y " (:y @before) " -> " (:y after)))))))))

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