diff --git a/.github/workflows/tests-backend.yml b/.github/workflows/tests-backend.yml index fb58ed7ea4..d7a3377772 100644 --- a/.github/workflows/tests-backend.yml +++ b/.github/workflows/tests-backend.yml @@ -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 diff --git a/.gitignore b/.gitignore index 24df33f1ab..2fbd8f5971 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.opencode/plugins/penpot.js b/.opencode/plugins/penpot.js new file mode 100644 index 0000000000..5f0fbba554 --- /dev/null +++ b/.opencode/plugins/penpot.js @@ -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, +// }, +// } +// } diff --git a/.opencode/skills/backport-commit/SKILL.md b/.opencode/skills/backport-commit/SKILL.md deleted file mode 100644 index 79494a3c5d..0000000000 --- a/.opencode/skills/backport-commit/SKILL.md +++ /dev/null @@ -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 - -# Get the full diff (including new/deleted files) -git show - -# Capture the original commit message for later reuse -git log --format='%B' -1 -``` - -### 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 /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 ` | -| Rename/move file | `bash mv `, 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 diff --git a/.opencode/skills/nrepl-eval/SKILL.md b/.opencode/skills/nrepl-eval/SKILL.md index b4b3d76d7f..c84dc803c1 100644 --- a/.opencode/skills/nrepl-eval/SKILL.md +++ b/.opencode/skills/nrepl-eval/SKILL.md @@ -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] [] +./scripts/nrepl-eval.mjs [options] [] ``` | 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 ``` diff --git a/.opencode/skills/refine-prompt/SKILL.md b/.opencode/skills/refine-prompt/SKILL.md index 8cc7473619..c2439e02b9 100644 --- a/.opencode/skills/refine-prompt/SKILL.md +++ b/.opencode/skills/refine-prompt/SKILL.md @@ -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 1–4 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 (3–7 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/.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. diff --git a/.opencode/skills/taiga/SKILL.md b/.opencode/skills/taiga/SKILL.md index 890c965c03..e63788698c 100644 --- a/.opencode/skills/taiga/SKILL.md +++ b/.opencode/skills/taiga/SKILL.md @@ -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 " " 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 -python3 tools/taiga.py -python3 tools/taiga.py [--json] -python3 tools/taiga.py [--json] +python3 scripts/taiga.py +python3 scripts/taiga.py +python3 scripts/taiga.py [--json] +python3 scripts/taiga.py [--json] ``` ### 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 diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index 0a241216df..d5c0ccf2af 100644 --- a/.opencode/skills/update-changelog/SKILL.md +++ b/.opencode/skills/update-changelog/SKILL.md @@ -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 @)` 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 | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])" +python3 scripts/gh.py prs | 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 | python3 -c " +python3 scripts/gh.py prs | 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 --state all`. If it's no + `python3 scripts/gh.py issues --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 "" --state merged > /tmp/milestone-prs.json +python3 scripts/gh.py prs --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 "" --state all | python3 -c " +python3 scripts/gh.py prs --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 diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 15f0dd109d..6530f74308 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -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--`. +`./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--`. ```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 diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index d291c81bf9..fcc2394424 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -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:/core` @@ -54,18 +54,21 @@ module. You can read it from `mem:/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 diff --git a/.serena/memories/workflow/docs.md b/.serena/memories/docs/core.md similarity index 95% rename from .serena/memories/workflow/docs.md rename to .serena/memories/docs/core.md index 8504e5db1d..4d03b7ddea 100644 --- a/.serena/memories/workflow/docs.md +++ b/.serena/memories/docs/core.md @@ -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. \ No newline at end of file +Documentation changes should follow the existing page structure and rendered Help Center conventions rather than inventing a new style locally. diff --git a/.serena/memories/frontend/core.md b/.serena/memories/frontend/core.md index 5f06f8cfb0..b229e2b124 100644 --- a/.serena/memories/frontend/core.md +++ b/.serena/memories/frontend/core.md @@ -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 diff --git a/.serena/memories/frontend/handling-errors-and-debugging.md b/.serena/memories/frontend/handling-errors-and-debugging.md index ede8c1581b..c44cb6b89e 100644 --- a/.serena/memories/frontend/handling-errors-and-debugging.md +++ b/.serena/memories/frontend/handling-errors-and-debugging.md @@ -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!` diff --git a/.serena/memories/tools/gh.md b/.serena/memories/scripts/gh.md similarity index 67% rename from .serena/memories/tools/gh.md rename to .serena/memories/scripts/gh.md index 3f74b82b66..ed6adfa8f1 100644 --- a/.serena/memories/tools/gh.md +++ b/.serena/memories/scripts/gh.md @@ -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. diff --git a/.serena/memories/tools/nrepl-eval.md b/.serena/memories/scripts/nrepl-eval.md similarity index 66% rename from .serena/memories/tools/nrepl-eval.md rename to .serena/memories/scripts/nrepl-eval.md index f5ec3ab994..96915492fb 100644 --- a/.serena/memories/tools/nrepl-eval.md +++ b/.serena/memories/scripts/nrepl-eval.md @@ -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] [] +node scripts/nrepl-eval.mjs [options] [] # or -./tools/nrepl-eval.mjs [options] [] +./scripts/nrepl-eval.mjs [options] [] ``` ## Options @@ -47,37 +47,37 @@ Sessions are persisted to `/tmp/penpot-nrepl-session--`. 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 diff --git a/.serena/memories/scripts/paren-repair.md b/.serena/memories/scripts/paren-repair.md new file mode 100644 index 0000000000..162e6d28d8 --- /dev/null +++ b/.serena/memories/scripts/paren-repair.md @@ -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") +``` + + diff --git a/.serena/memories/scripts/psql.md b/.serena/memories/scripts/psql.md new file mode 100644 index 0000000000..c969fd6742 --- /dev/null +++ b/.serena/memories/scripts/psql.md @@ -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) +``` diff --git a/.serena/memories/tools/taiga.md b/.serena/memories/scripts/taiga.md similarity index 69% rename from .serena/memories/tools/taiga.md rename to .serena/memories/scripts/taiga.md index b4e623e3e1..d68274fde5 100644 --- a/.serena/memories/tools/taiga.md +++ b/.serena/memories/scripts/taiga.md @@ -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 diff --git a/.serena/memories/tools/paren-repair.md b/.serena/memories/tools/paren-repair.md deleted file mode 100644 index e3817ca839..0000000000 --- a/.serena/memories/tools/paren-repair.md +++ /dev/null @@ -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. diff --git a/.serena/memories/tools/psql.md b/.serena/memories/tools/psql.md deleted file mode 100644 index c502b4c1e9..0000000000 --- a/.serena/memories/tools/psql.md +++ /dev/null @@ -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. diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md index 1171511e5b..d37d4672a5 100644 --- a/.serena/memories/workflow/creating-commits.md +++ b/.serena/memories/workflow/creating-commits.md @@ -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 diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index c0f763f9e3..00252a7661 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -4,10 +4,10 @@ PR only on explicit request. Branch: issue/feature-specific; fallback `/ /tmp/pr-body.md << 'PR_BODY' PR_BODY -TARGET=$(tools/detect-target-branch) +TARGET=$(scripts/detect-target-branch) gh pr create \ --repo penpot/penpot \ diff --git a/.serena/project.yml b/.serena/project.yml index b7e8941c75..4b63be5624 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 8c3e6ba431..b6aae1f32d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`) + +- `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. + diff --git a/CHANGES.md b/CHANGES.md index 12566c36d1..a9df8cff57 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12729395d4..63b931900f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: +: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: - -``` -/ -``` - -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 diff --git a/backend/resources/app/email/invite-to-team/en.html b/backend/resources/app/email/invite-to-team/en.html index 96fe6de59d..9035d01a70 100644 --- a/backend/resources/app/email/invite-to-team/en.html +++ b/backend/resources/app/email/invite-to-team/en.html @@ -186,7 +186,7 @@
- {{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 %}.
diff --git a/backend/resources/app/email/invite-to-team/en.txt b/backend/resources/app/email/invite-to-team/en.txt index c688f7d61d..8cdced742a 100644 --- a/backend/resources/app/email/invite-to-team/en.txt +++ b/backend/resources/app/email/invite-to-team/en.txt @@ -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 diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 4f7583d39d..37f7280b69 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -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 diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index d3ff7fdff2..30527857ad 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -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))) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index c6fed68e39..150e69ced2 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -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] diff --git a/backend/src/app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql b/backend/src/app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql new file mode 100644 index 0000000000..ae0fe29372 --- /dev/null +++ b/backend/src/app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql @@ -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(); diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index 0cb1b46e57..12da9bb7c5 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -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 diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 5efc564daa..b091516586 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -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)))) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index f6f5073d8a..c6178cda54 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -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, diff --git a/backend/src/app/srepl/binfile.clj b/backend/src/app/srepl/binfile.clj index c1792321f7..0fbc09c460 100644 --- a/backend/src/app/srepl/binfile.clj +++ b/backend/src/app/srepl/binfile.clj @@ -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 diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index 7ec2f4bdaf..22ccb624fe 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -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"))))))) diff --git a/backend/test/backend_tests/media_test.clj b/backend/test/backend_tests/media_test.clj index 85b749274c..f4d6d78e81 100644 --- a/backend/test/backend_tests/media_test.clj +++ b/backend/test/backend_tests/media_test.clj @@ -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)))))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index bff2a59478..8ca30ccfce 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -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. diff --git a/common/scripts/test b/common/scripts/test index b064f2b8d2..ced37269e4 100755 --- a/common/scripts/test +++ b/common/scripts/test @@ -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; diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index d18f7019ec..a89aa633ab 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -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] diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 34e6b0acd2..f0932a78db 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -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)"; \ diff --git a/docker/devenv/docker-compose.main.yml b/docker/devenv/docker-compose.main.yml index 944a36885c..f5cbe95620 100644 --- a/docker/devenv/docker-compose.main.yml +++ b/docker/devenv/docker-compose.main.yml @@ -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: diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 4d2fa76fc0..8509bb7574 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -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:///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 ``` + + 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:///mcp/stream?userToken=YOUR_MCP_KEY` - * Auth: MCP key in `userToken` + * URL (copy from your Penpot account overview): `https:///mcp/stream?userToken=YOUR_MCP_KEY` + * Configure your MCP client with `npx -y add-mcp -g -n penpot ` 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", "", "--allow-http"] } } } @@ -326,9 +264,8 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted ### 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. ### 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. ### Use diff --git a/docs/plugins/deployment.md b/docs/plugins/deployment.md index d18616623f..72af7348ff 100644 --- a/docs/plugins/deployment.md +++ b/docs/plugins/deployment.md @@ -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 package.json, npm run build should work. -The resulting build should be located somewhere in the dist/ folder, maybe somewhere else if you have configured so. +The resulting build should be located somewhere in the dist/ folder, or somewhere else if you configured it that way. -Be wary that some framework's builders can add additional folders like apps/project-name/, project-name/ or browser/. +Be wary that some framework builders can add additional folders like apps/project-name/, project-name/ or browser/. Examples: @@ -27,7 +27,7 @@ Examples: ### Create an account -You need a Netlify account if you don't already have one. You can sign up with Github, GItlab, BItbucket or via email and password. +You need a Netlify account if you don't already have one. You can sign up with GitHub, GitLab, Bitbucket or via email and password. ### CORS issues @@ -82,7 +82,7 @@ npm run build 2. Go to Netlify Drop. -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. diff --git a/docs/technical-guide/developer/data-model/penpot-file-format.md b/docs/technical-guide/developer/data-model/penpot-file-format.md new file mode 100644 index 0000000000..431dbffaa2 --- /dev/null +++ b/docs/technical-guide/developer/data-model/penpot-file-format.md @@ -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) diff --git a/docs/technical-guide/developer/data-model/penpot-file-inspector.njk b/docs/technical-guide/developer/data-model/penpot-file-inspector.njk new file mode 100644 index 0000000000..5321f79fe0 --- /dev/null +++ b/docs/technical-guide/developer/data-model/penpot-file-inspector.njk @@ -0,0 +1,1401 @@ +--- +title: 3.02.02. Penpot file inspector +desc: Interactive browser-based inspector for .penpot (v3) files. Upload a file to explore its ZIP structure, browse JSON contents, preview images, and navigate cross-references. +--- + + + +

Penpot file inspector

+ +

Upload a .penpot file to explore its contents. The inspector runs entirely in your browser — your file is never uploaded to a server.

+ +
+ +
+ + +
+ +

How it works

+ +

This tool is a static page with no backend. When you upload a file, the browser:

+ +
    +
  1. Reads the file as a ZIP archive using JSZip (loaded lazily from a CDN on first use).
  2. +
  3. Parses the manifest, file metadata, and structure to build an overview.
  4. +
  5. Lets you navigate the file tree, view JSON contents with syntax highlighting, preview embedded images, and click through cross-references (UUIDs).
  6. +
+ +

For full details on the file format, see the complete technical specification.

+ +

Privacy

+ +

Your file is processed entirely in your browser. Nothing is uploaded to any server. The only network request the page makes is fetching the JSZip library from cdn.jsdelivr.net on the first file upload.

+ + diff --git a/docs/user-guide/export-import/export-import-files.njk b/docs/user-guide/export-import/export-import-files.njk index e916fd6304..ac462d64cf 100644 --- a/docs/user-guide/export-import/export-import-files.njk +++ b/docs/user-guide/export-import/export-import-files.njk @@ -48,21 +48,22 @@ desc: Learn how to import and export files in Penpot, the free, open-source desi
Import penpot file

Penpot file format

-

Penpot export to a unique format that streamline the import and export of files and assets by being more efficient and interoperable.

+

Penpot exports to a unique format that streamlines the import and export of files and assets by being more efficient and interoperable.

Unlike other design tools, Penpot's format is built on standard languages. 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.

+

For a detailed look at what's inside a .penpot file, see The .penpot file format. Developers can also check the technical specification.

Deprecated Penpot file formats

These formats can only be exported from version 2.3 or earlier versions, but can be imported to any Penpot version.

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.

-

[Deprecated] Penpot file (.penpot).

+

[Deprecated] Binary file (.penpot) — v1

The fast one. Binary Penpot specific.

  • ✅ Highly efficient in terms of memory and transfer time when exporting and importing.
  • ❌ It can be opened only in Penpot.
  • ❌ Not transparent, code difficult to explore.
-

[Deprecated] Standard file (.zip).

-

The open one. A compressed file that includes SVG and JSON.

+

[Deprecated] Standard file (.zip) — v2 (never released)

+

The open one. A compressed file that includes SVG and JSON. This format was developed but never released to users.

  • ✅ Allows the file to be opened by other softwares (still, for those cases export to SVG seems to be the common practice).
  • ✅ Allows some automations and integrations.
  • diff --git a/docs/user-guide/export-import/index.njk b/docs/user-guide/export-import/index.njk index 48b4bcbd04..f218893dd7 100644 --- a/docs/user-guide/export-import/index.njk +++ b/docs/user-guide/export-import/index.njk @@ -13,7 +13,13 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial

    How to export and import your Penpot files

    -
  • +
  • + +

    The .penpot file format →

    +

    Understand what's inside a .penpot file and how to inspect it

    +
    +
  • +
  • Exporting layers →

    How to export elements from your design into different file formats

    diff --git a/docs/user-guide/export-import/penpot-file-format.njk b/docs/user-guide/export-import/penpot-file-format.njk new file mode 100644 index 0000000000..6febaa47e5 --- /dev/null +++ b/docs/user-guide/export-import/penpot-file-format.njk @@ -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. +--- + +

    The .penpot file format

    +

    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.

    + +

    What is a .penpot file?

    + +

    A .penpot file is a ZIP archive containing:

    +
      +
    • JSON metadata — human-readable descriptions of your pages, shapes, colors, components, and more
    • +
    • Binary media files — images, fonts, and other assets used in your design
    • +
    + +

    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.

    + +

    What's inside a .penpot file?

    + +

    When you unzip a .penpot file, you'll find this structure:

    + +
    +
    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
    +
    + +

    manifest.json

    +

    The manifest is the table of contents. It tells you:

    +
      +
    • Which version of the format is used
    • +
    • Which Penpot version created the file
    • +
    • What files are included
    • +
    • What features are enabled
    • +
    + +

    File data

    +

    The files/ directory contains all your design data organized by type:

    +
      +
    • Pages — Each page is split into individual shape files for efficiency
    • +
    • Colors — Your color library with hex values, gradients, or image fills
    • +
    • Components — Reusable component definitions
    • +
    • Typographies — Text style definitions
    • +
    • Tokens — Design tokens organized in sets and themes
    • +
    • Media — References to images and other assets
    • +
    + +

    Objects

    +

    The objects/ directory contains the actual binary files (PNG, JPG, SVG) referenced by your design. Each object has a JSON metadata file alongside the binary file.

    + +

    How to inspect a .penpot file

    + +

    Since .penpot files are just ZIP archives, you can inspect them with standard tools.

    + +

    On macOS or Linux

    + +

    1. List the contents without extracting:

    +
    unzip -l your-design.penpot
    + +

    2. Extract to a temporary folder:

    +
    unzip your-design.penpot -d /tmp/penpot-inspect
    + +

    3. View the manifest:

    +
    cat /tmp/penpot-inspect/manifest.json | jq .
    + +

    4. Browse the structure:

    +
    tree /tmp/penpot-inspect
    + +

    5. View a specific shape:

    +
    cat /tmp/penpot-inspect/files/*/pages/*/*.json | jq .
    + +

    On Windows

    + +

    1. Right-click the .penpot file and select "Extract All..." or use 7-Zip.

    + +

    2. Browse the extracted folder to see the structure.

    + +

    3. Open any JSON file with a text editor or use a JSON viewer tool.

    + +

    Using AI tools

    + +

    You can use the Penpot MCP server 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.

    + +

    Format versions

    + +

    The .penpot format has evolved over time:

    + + + + + + + + + + + + + + + + + + + + + +
    VersionDescriptionStatus
    v3ZIP + JSON format (current)✅ Current
    v1Custom binary format⚠️ Deprecated
    + +

    All current Penpot versions export in v3 format. Old v1 files can still be imported, but new exports will use v3.

    + +

    Version numbers inside the file

    + +

    Inside a .penpot file, you'll see two different version numbers:

    + +
      +
    • Format version (in manifest.json) — Tracks changes to the ZIP structure itself. Currently 1.
    • +
    • Data version (in each file's JSON) — Tracks changes to the data model. This number increases as Penpot evolves.
    • +
    + +

    Feature flags

    + +

    Files also include a list of feature flags that indicate which capabilities are used. For example:

    + +
      +
    • design-tokens/v1 — Uses design tokens
    • +
    • components/v2 — Uses the v2 component system
    • +
    • variants/v1 — Uses component variants
    • +
    • layout/grid — Uses grid layouts
    • +
    + +

    These flags help Penpot know what features need to be supported when importing the file.

    + +

    For developers

    + +

    If you're building tools or integrations that work with .penpot files, the complete technical specification provides detailed schema definitions for every JSON file in the archive.

    + +

    Key resources:

    + diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index 8010032680..127a28fa99 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -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 diff --git a/frontend/playwright/data/render-wasm/assets/squares-background.png b/frontend/playwright/data/render-wasm/assets/squares-background.png new file mode 100644 index 0000000000..55b49b4480 Binary files /dev/null and b/frontend/playwright/data/render-wasm/assets/squares-background.png differ diff --git a/frontend/playwright/data/render-wasm/get-file-text-background-blur.json b/frontend/playwright/data/render-wasm/get-file-text-background-blur.json new file mode 100644 index 0000000000..fd18aaa594 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-text-background-blur.json @@ -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" + } + } +} diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js b/frontend/playwright/ui/render-wasm-specs/texts.spec.js index b474c7ca05..f660356d91 100644 --- a/frontend/playwright/ui/render-wasm-specs/texts.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/texts.spec.js @@ -609,4 +609,22 @@ test("Renders a file with group with strokes and not 100% opacities", async ({ maxDiffPixelRatio: 0, threshold: 0.01, }); -}); \ No newline at end of file +}); + +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(); +}); diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-background-blur-on-text-shapes-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-background-blur-on-text-shapes-1.png new file mode 100644 index 0000000000..4988e566e7 Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-background-blur-on-text-shapes-1.png differ diff --git a/frontend/resources/images/assets/nitrate-welcome.svg b/frontend/resources/images/assets/nitrate-welcome.svg index 18ced86fa1..ef9296dbcc 100644 --- a/frontend/resources/images/assets/nitrate-welcome.svg +++ b/frontend/resources/images/assets/nitrate-welcome.svg @@ -1,52 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 24d72e5e92..83a7cbb5fa 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -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 diff --git a/frontend/src/app/main/data/media.cljs b/frontend/src/app/main/data/media.cljs index 688d4d9d5f..74eec0ac56 100644 --- a/frontend/src/app/main/data/media.cljs +++ b/frontend/src/app/main/data/media.cljs @@ -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)))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index fb6245c31c..fc2404f39b 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -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) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 41f14c0eea..91268a8550 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -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" diff --git a/frontend/src/app/main/data/workspace/pages.cljs b/frontend/src/app/main/data/workspace/pages.cljs index 37d12730b8..0bfa559469 100644 --- a/frontend/src/app/main/data/workspace/pages.cljs +++ b/frontend/src/app/main/data/workspace/pages.cljs @@ -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)})))))))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index cf6e9e17f9..08d0a3ab7a 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -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)) diff --git a/frontend/src/app/main/ui/dashboard/deleted.cljs b/frontend/src/app/main/ui/dashboard/deleted.cljs index e5be919ea3..9a46b697ba 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.cljs +++ b/frontend/src/app/main/ui/dashboard/deleted.cljs @@ -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 diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs index e602720c72..05ed77851e 100644 --- a/frontend/src/app/main/ui/dashboard/files.cljs +++ b/frontend/src/app/main/ui/dashboard/files.cljs @@ -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}])]])) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index eeec8323d1..0b19614858 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -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}])])) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index 66df5339f4..448600c835 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -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); } diff --git a/frontend/src/app/main/ui/dashboard/layout_toggle.cljs b/frontend/src/app/main/ui/dashboard/layout_toggle.cljs new file mode 100644 index 0000000000..75bd938cbe --- /dev/null +++ b/frontend/src/app/main/ui/dashboard/layout_toggle.cljs @@ -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")}]}]) diff --git a/frontend/src/app/main/ui/dashboard/placeholder.cljs b/frontend/src/app/main/ui/dashboard/placeholder.cljs index 1c47e099b9..4161db7658 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.cljs +++ b/frontend/src/app/main/ui/dashboard/placeholder.cljs @@ -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* diff --git a/frontend/src/app/main/ui/dashboard/placeholder.scss b/frontend/src/app/main/ui/dashboard/placeholder.scss index da0355bba4..cf17521433 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.scss +++ b/frontend/src/app/main/ui/dashboard/placeholder.scss @@ -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; diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index ee9ca03325..56e84ad53c 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -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}]))]]]]))) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index e134930b4c..d100c2af27 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -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 diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index ae8ebd30d5..ed13c36e4e 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -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) diff --git a/frontend/src/app/main/ui/hooks/floating_drag.cljs b/frontend/src/app/main/ui/hooks/floating_drag.cljs new file mode 100644 index 0000000000..f5f4481923 --- /dev/null +++ b/frontend/src/app/main/ui/hooks/floating_drag.cljs @@ -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}))) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index e70cbe685c..5aa885e9a9 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -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]))) diff --git a/frontend/src/app/main/ui/workspace/comments.cljs b/frontend/src/app/main/ui/workspace/comments.cljs index d0cbed35fc..64882acde7 100644 --- a/frontend/src/app/main/ui/workspace/comments.cljs +++ b/frontend/src/app/main/ui/workspace/comments.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/components_debugger.cljs b/frontend/src/app/main/ui/workspace/components_debugger.cljs new file mode 100644 index 0000000000..d4aee9d90b --- /dev/null +++ b/frontend/src/app/main/ui/workspace/components_debugger.cljs @@ -0,0 +1,1189 @@ +;; 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.workspace.components-debugger + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.common.files.helpers :as cfh] + [app.common.thumbnails :as thc] + [app.common.types.component :as ctk] + [app.common.types.container :as ctn] + [app.common.types.file :as ctf] + [app.main.data.workspace.thumbnails :as dwt] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.hooks :as hooks] + [app.main.ui.hooks.floating-drag :as fd] + [app.util.debug :as dbg] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [app.util.shape-icon :as usi] + [app.util.timers :as tm] + [app.util.webapi :as wapi] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(def ^:private panel-width 1024) +(def ^:private panel-height 768) +(def ^:private sidebar-offset 290) +(def ^:private min-z-index 300) + +(def ^:private preview-x 16) +(def ^:private preview-y 16) +(def ^:private preview-width 400) +(def ^:private panels-gap 56) +(def ^:private marker-radius 11) +(def ^:private marker-dot-radius 4) +(def ^:private text-padding 8) +(def ^:private text-line-height 16) +(def ^:private icon-size 16) +(def ^:private labels-title-gap 12) +(def ^:private thumbnail-height 80) +(def ^:private thumbnail-margin 8) +(def ^:private inner-padding 8) +(def ^:private child-gap 20) +(def ^:private box-min-height 40) +(def ^:private legend-gap 20) +(def ^:private legend-row-gap 24) +(def ^:private legend-line-width 36) +(def ^:private legend-width 220) +(def ^:private legend-padding-x 12) +(def ^:private legend-padding-y 10) +(def ^:private deleted-banner-height (+ text-padding text-line-height text-padding)) + +(defn- canvas-content-width + "Total SVG canvas width for the given number of panel columns." + [column-count] + (let [column-count (max 1 column-count) + column-step (+ preview-x preview-width panels-gap)] + (+ (* (dec column-count) column-step) + preview-width + (* 2 preview-x)))) + +(defn- column-x + "Left x coordinate of the panel column at `column-idx`." + [column-idx] + (+ preview-x (* column-idx (+ preview-width panels-gap)))) + +(defn- legend-total-height + "Height of the arrows legend (fixed two-row layout)." + [] + (+ (* 2 legend-padding-y) legend-row-gap (* 2 marker-radius))) + +(defn- initial-position + "Default fixed position for the floating debugger panel." + [] + {:top 40 + :left (- (:width (dom/get-window-size)) panel-width sidebar-offset)}) + +(defn- shape-box-labels + "Build footer info labels for a panel root (file/page name and ids)." + [_shape & {:keys [file-id page-id file-name page-name]}] + (cond-> [] + file-name + (conj (str "File: " file-name)) + + file-id + (conj (str "File Id: " file-id)) + + page-name + (conj (str "Page: " page-name)) + + page-id + (conj (str "Page Id: " page-id)))) + +(defn- thumbnail-slot-height + "Vertical space reserved for a shape thumbnail, including margins." + [] + (+ thumbnail-margin thumbnail-height thumbnail-margin)) + +(defn- shape-thumbnail-slot? + "Whether `shape` can show a thumbnail given file/page context." + [shape {:keys [file-id page-id]}] + (and file-id page-id + (or (cfh/frame-shape? shape) + (ctk/instance-root? shape) + (ctk/main-instance? shape) + ;; Swapped nested heads are not instance-roots, but still need a slot. + (some? (ctk/get-swap-slot shape))))) + +(defn- box-thumbnail-slot? + "A box reserves a thumbnail slot when it is the root of a panel or the + highlighted shape, and the shape itself qualifies for a thumbnail." + [shape root? highlight-id thumbnail-context] + (and (some? thumbnail-context) + (or root? (= (:id shape) highlight-id)) + (shape-thumbnail-slot? shape thumbnail-context))) + +(defn- labels-bg-height + "Height of the labels footer background, or nil when there are no labels." + [label-count] + (when (pos? label-count) + (+ text-padding + (* label-count text-line-height) + text-padding))) + +(defn- context-deleted-banner-height + "Extra top banner height when the panel marks a deleted component." + [thumbnail-context] + (if (:deleted-banner thumbnail-context) + deleted-banner-height + 0)) + +(defn- child-thumbnail-context + "Thumbnail context for nested boxes; deleted banner applies only to the root." + [thumbnail-context] + (dissoc thumbnail-context :deleted-banner)) + +(defn- box-header-height + "Height of the box header (optional deleted banner, thumbnail, title row)." + [_label-count thumbnail-slot? thumbnail-context] + (+ (context-deleted-banner-height thumbnail-context) + (if thumbnail-slot? + (+ thumbnail-margin + (thumbnail-slot-height) + labels-title-gap) + text-padding) + icon-size + text-padding)) + +(defn- labels-footer-height + "Height of the labels footer including the gap above it." + [label-count] + (if (pos? label-count) + (+ labels-title-gap + (labels-bg-height label-count)) + 0)) + +(defn- header-thumbnail-y + "Y coordinate of the thumbnail slot within a box header." + [y _label-count thumbnail-context] + (+ y + (context-deleted-banner-height thumbnail-context) + thumbnail-margin)) + +(defn- header-title-y + "Y coordinate of the title/icon row within a box header." + [y _label-count thumbnail-slot? thumbnail-context] + (+ y + (context-deleted-banner-height thumbnail-context) + (if thumbnail-slot? + (+ thumbnail-margin + (thumbnail-slot-height) + labels-title-gap) + text-padding))) + +(defn- shape-children + "Child shapes of `shape` looked up in `objects`, skipping missing ids." + [objects shape] + (->> (:shapes shape) + (map #(get objects %)) + (remove nil?))) + +(defn- shape-display-label + "Short label combining truncated shape name and trailing id digits." + [shape] + (let [name (:name shape) + short-name (if (< (count name) 24) + name + (str (str/slice name 0 22) "...")) + short-id (str/slice (str (:id shape)) 24)] + (str short-name " | " short-id))) + +(defn- near-match-source-shape + "Shape used to resolve the near component match." + [objects shape] + (if (and (not (ctk/in-component-copy? shape)) + (ctn/has-any-copy-parent? objects shape)) + (last (ctn/get-parent-copy-heads objects shape)) + shape)) + +(defn- ref-shape->component-head + "Walk from a ref shape to its component head, preserving context metadata." + [ref-shape] + (when ref-shape + (let [meta' (meta ref-shape) + container (:container meta') + objects (:objects container) + head (or (last (ctn/get-parent-heads objects ref-shape)) + ref-shape)] + (cond-> head + meta' (with-meta meta'))))) + +(defn- near-component-render-data + "Panel render data for a near-component head and its highlighted ref." + [near-head ref-shape] + (when near-head + (let [container (-> near-head meta :container)] + {:shape near-head + :objects (:objects container) + :highlight-id (:id ref-shape)}))) + + +(defn- resolve-near-component-shape + "Resolve near-match (or ref-shape fallback) render data for `shape`. + Includes deleted components so the chain can surface them." + [file page libraries objects shape] + (when-let [source (near-match-source-shape objects shape)] + (when-let [ref-shape (or (ctf/find-near-match file page libraries source + :with-context? true + :include-deleted? true) + (ctf/find-ref-shape file page libraries source + :with-context? true + :include-deleted? true))] + (near-component-render-data (ref-shape->component-head ref-shape) + ref-shape)))) + +(defn- resolve-file-name + "File objects from with-context metadata often lack :name; look it up." + [file libraries] + (or (:name file) + (when-let [file-id (:id file)] + (get-in libraries [file-id :name])))) + +(defn- deleted-component-container? + "True when the container is a deleted component (not a live page)." + [container] + (boolean (or (:deleted container) + (ctn/component? container)))) + +(defn- enrich-near-component-data + "Attach root shape, file/page ids and display names to near-match data." + [near-data {:keys [file page]} libraries] + (when near-data + (let [shape (:shape near-data) + objects (:objects near-data) + meta' (meta shape) + ;; Prefer the matched shape's file/page (where the tree lives). + match-file (or (:file meta') file) + match-page (or (:container meta') page) + deleted? (deleted-component-container? match-page) + file-id (:id match-file) + root-shape (or (ctn/get-instance-root objects shape) + (when deleted? + (ctk/get-deleted-component-root match-page)) + shape)] + (assoc near-data + :root-shape root-shape + :file match-file + :page match-page + :deleted deleted? + :file-name (resolve-file-name match-file libraries) + :file-id file-id + :page-name (:name match-page) + :page-id (:id match-page))))) + +(defn- resolve-swap-slot-main-data + "When the highlighted shape has a swap-slot, return its component main tree." + [file page libraries objects highlight-id] + (when-let [shape (get objects highlight-id)] + (when (ctk/get-swap-slot shape) + (when-let [component (ctf/find-ref-component file page libraries shape + :include-deleted? true)] + (let [head (ctn/get-head-shape objects shape) + component-file (ctf/find-component-file file libraries (:component-file head)) + file-data (:data component-file) + container (ctf/get-component-container file-data component) + root-shape (ctf/get-component-root file-data component) + component-page (ctf/get-component-page file-data component) + highlight-main-id + (or (ctf/find-ref-id-for-swapped shape page libraries) + (some-> (ctf/get-ref-shape file-data component shape) :id) + (ctk/get-swap-slot shape))] + (when root-shape + {:root-shape root-shape + :objects (:objects container) + :highlight-id highlight-main-id + :file component-file + :deleted (boolean (:deleted component)) + :file-name (resolve-file-name component-file libraries) + :file-id (:id component-file) + :page-name (:name component-page) + :page-id (:id component-page)})))))) + +(defn- near-match-context + "Resolve file/page/objects for near-match lookup from shape metadata, + falling back to the workspace context." + [shape workspace-file workspace-page workspace-objects] + (let [meta' (meta shape)] + (if-let [container (:container meta')] + {:file (or (:file meta') workspace-file) + :page container + :objects (:objects container)} + {:file workspace-file + :page workspace-page + :objects workspace-objects}))) + +(defn- near-component-data-chain + "Resolve near-component matches recursively until no more matches. + Stops if a shape id repeats to avoid cycles, and does not walk past + a deleted component." + [workspace-file workspace-page libraries workspace-objects initial-shape] + (loop [shape initial-shape + result [] + seen #{}] + (if (or (nil? shape) (contains? seen (:id shape))) + result + (let [seen (conj seen (:id shape)) + {:keys [file page objects]} + (near-match-context shape workspace-file workspace-page workspace-objects)] + (if-let [near-data (when (and file page) + (enrich-near-component-data + (resolve-near-component-shape file page libraries objects shape) + {:file file :page page} + libraries))] + (if (:deleted near-data) + (conj result near-data) + (recur (:shape near-data) (conj result near-data) seen)) + result))))) + +(defn- child-insets + "Left/right padding applied around nested child boxes." + [] + {:left (* 2 inner-padding) + :right inner-padding}) + +(defn- child-content-width + "Inner width available for child boxes inside a parent of `width`." + [width] + (let [{:keys [left right]} (child-insets)] + (- width left right))) + +(defn- shape-box-height + "Computed height of a shape box including nested children." + ([objects shape width] + (shape-box-height objects shape width nil nil nil false)) + ([objects shape width labels thumbnail-context highlight-id root?] + (let [thumbnail-slot? (box-thumbnail-slot? shape root? highlight-id thumbnail-context) + label-count (count labels) + header (box-header-height label-count thumbnail-slot? thumbnail-context) + footer (labels-footer-height label-count) + children (shape-children objects shape)] + (if (empty? children) + (max box-min-height (+ header footer)) + (let [child-width (child-content-width width) + children-height + (->> children + (map #(shape-box-height objects % child-width nil + (child-thumbnail-context thumbnail-context) + highlight-id false)) + (interpose child-gap) + (reduce + 0))] + (+ header (* 2 inner-padding) children-height footer)))))) + +(defn- shape-child-layouts + "Layout descriptors for each child box under `shape`." + [objects shape x y width labels thumbnail-context highlight-id root?] + (let [thumbnail-slot? (box-thumbnail-slot? shape root? highlight-id thumbnail-context) + header (box-header-height (count labels) thumbnail-slot? thumbnail-context) + children (reverse (shape-children objects shape)) + {:keys [left]} (child-insets) + inner-x (+ x left) + inner-w (child-content-width width) + start-y (+ y header inner-padding) + child-ctx (child-thumbnail-context thumbnail-context)] + (loop [cy start-y + [child & rest] children + result []] + (if child + (let [child-height (shape-box-height objects child inner-w nil child-ctx highlight-id false)] + (recur (+ cy child-height child-gap) + rest + (conj result {:shape child :x inner-x :y cy :width inner-w}))) + result)))) + +(defn- root-panel-height + "Height of a top-level panel rooted at `root-shape`." + [objects root-shape labels thumbnail-context highlight-id] + (shape-box-height objects root-shape preview-width labels thumbnail-context highlight-id true)) + +(defn- swap-slot-panel + "Build a swap-main panel for the highlight at `column-idx`. + The panel is drawn one column to the right (`(inc column-idx)`), so + arrows pair it with `highlight-panels` via `(dec column-idx)`." + [column-idx swap-row-y file page libraries objects highlight-id] + (when-let [{:keys [root-shape objects highlight-id file deleted + file-id page-id file-name page-name]} + (resolve-swap-slot-main-data file page libraries objects highlight-id)] + {:column-idx (inc column-idx) + :y swap-row-y + :root-shape root-shape + :objects objects + :highlight-id highlight-id + :file file + :deleted deleted + :file-name file-name + :file-id file-id + :page-name page-name + :page-id page-id + :labels (shape-box-labels root-shape + :file-name file-name + :file-id file-id + :page-name page-name + :page-id page-id)})) + +(defn- shape-highlight-anchor + "Return {:left :right :y} for the highlighted shape box." + [objects shape x y width labels thumbnail-context highlight-id root?] + (if (= (:id shape) highlight-id) + (let [height (shape-box-height objects shape width labels thumbnail-context highlight-id root?)] + {:left x + :right (+ x width) + :y (+ y (/ height 2))}) + (some (fn [{:keys [shape x y width]}] + (shape-highlight-anchor objects shape x y width nil thumbnail-context highlight-id false)) + (shape-child-layouts objects shape x y width labels thumbnail-context highlight-id root?)))) + +(defn- panel-highlight-anchor + "Highlight anchor for a panel map used when drawing connection arrows." + [{:keys [objects root-shape highlight-id labels thumbnail-context panel-x panel-y]}] + (when highlight-id + (shape-highlight-anchor objects root-shape panel-x panel-y preview-width + labels thumbnail-context highlight-id true))) + +(defn- navigate-arrow-path + "SVG path data for the arrowhead drawn at connection endpoints." + [] + "M -6.5 0 L 5.5 0 M 6.715 0.715 L -0.5 -6.5 M 6.715 -0.715 L -0.365 6.635") + +(defn- highlight-arrow-path + "Cubic SVG path from `from-anchor` to `to-anchor` for a connection arrow." + [from-anchor to-anchor] + (let [x1 (+ (:right from-anchor) marker-dot-radius) + y1 (:y from-anchor) + x2 (- (:left to-anchor) marker-radius) + y2 (:y to-anchor) + mid-x (/ (+ x1 x2) 2)] + (dm/str "M " x1 " " y1 + " C " mid-x " " y1 ", " + mid-x " " y2 ", " + x2 " " y2))) + +(defn- arrow-marker-styles + "CSS module classes for arrow markers of the given variant (:swap or default)." + [variant] + (case variant + :swap {:circle (stl/css :swap-arrow-marker-circle) + :icon (stl/css :swap-arrow-marker-icon) + :path (stl/css :swap-arrow)} + {:circle (stl/css :highlight-arrow-marker-circle) + :icon (stl/css :highlight-arrow-marker-icon) + :path (stl/css :highlight-arrow)})) + +(defn- swap-panel-as-highlight-panel + "Adapt a swap panel map into the highlight-panel shape used for anchors." + [{:keys [column-idx y objects root-shape labels file-id page-id]}] + {:objects objects + :root-shape root-shape + :highlight-id (:id root-shape) + :labels labels + :thumbnail-context {:file-id file-id :page-id page-id} + :panel-x (column-x column-idx) + :panel-y y}) + +(defn- build-swap-panel-pairs + "Pair each swap panel with its source highlight panel for arrow drawing." + [highlight-panels swap-panels] + (keep (fn [swap] + (when-let [from (nth highlight-panels (dec (:column-idx swap)) nil)] + [from (swap-panel-as-highlight-panel swap)])) + swap-panels)) + +(mf/defc highlight-arrow-marker* + "Endpoint marker for a connection arrow (dot or arrowhead)." + {::mf/private true} + [{:keys [x y show-arrow circle-class icon-class]}] + [:g {:transform (dm/str "translate(" x "," y ")")} + [:circle {:cx 0 + :cy 0 + :r (if show-arrow marker-radius marker-dot-radius) + :class circle-class}] + (when show-arrow + [:path {:d (navigate-arrow-path) + :class icon-class}])]) + +(mf/defc connection-arrows* + "Draw curved arrows between consecutive panel pairs for a given variant." + {::mf/private true} + [{:keys [panel-pairs variant]}] + (let [{:keys [circle icon path]} (arrow-marker-styles variant)] + (for [[idx [from-panel to-panel]] (map-indexed vector panel-pairs) + :let [from (panel-highlight-anchor from-panel) + to (panel-highlight-anchor to-panel)] + :when (and from to)] + ^{:key (dm/str (name variant) "-arrow-" idx)} + [:g + [:path {:d (highlight-arrow-path from to) + :class path + :fill "none"}] + [:> highlight-arrow-marker* + {:x (+ (:right from) marker-dot-radius) + :y (:y from) + :circle-class circle}] + [:> highlight-arrow-marker* + {:x (- (:left to) marker-radius 2) + :y (:y to) + :show-arrow true + :circle-class circle + :icon-class icon}]]))) + +(mf/defc highlight-arrows* + "Near-match and swap connection arrows overlaid on the canvas." + {::mf/private true} + [{:keys [highlight-pairs swap-pairs]}] + [:g {:class (stl/css :highlight-arrows)} + [:> connection-arrows* {:panel-pairs highlight-pairs :variant :highlight}] + [:> connection-arrows* {:panel-pairs swap-pairs :variant :swap}]]) + +(mf/defc arrows-legend* + "Legend explaining near-match vs swap arrow colors." + {::mf/private true} + [{:keys [x y]}] + (let [rows [{:variant :highlight :label "Near shape ref"} + {:variant :swap :label "Swapped by"}] + row-count (count rows) + row-start-y (+ y legend-padding-y marker-radius) + marker-start-x (+ x legend-padding-x) + marker-end-x (+ marker-start-x legend-line-width) + legend-height (+ (* 2 legend-padding-y) + (* (dec row-count) legend-row-gap) + (* 2 marker-radius))] + [:g {:class (stl/css :highlight-arrows)} + [:rect {:x x + :y y + :width legend-width + :height legend-height + :rx 6 + :ry 6 + :class (stl/css :legend-bg)}] + (for [[idx {:keys [variant label]}] (map-indexed vector rows) + :let [row-y (+ row-start-y (* idx legend-row-gap)) + {:keys [circle icon path]} (arrow-marker-styles variant) + marker-x marker-end-x]] + ^{:key (dm/str "legend-" (name variant))} + [:g + [:path {:d (dm/str "M " marker-start-x " " row-y " L " marker-x " " row-y) + :class path + :fill "none"}] + [:> highlight-arrow-marker* + {:x marker-start-x + :y row-y + :circle-class circle}] + [:> highlight-arrow-marker* + {:x marker-x + :y row-y + :show-arrow true + :circle-class circle + :icon-class icon}] + [:text {:x (+ marker-x 18) + :y row-y + :dominant-baseline "middle" + :class (stl/css :legend-text)} + label]])])) + +(mf/defc labeled-rect* + "Single shape box: border, optional thumbnail, title, and footer labels." + {::mf/private true} + [{:keys [x y width height name labels icon-id icon-component? selected? + thumbnail-uri thumbnail-slot? thumbnail-unavailable + swap-variant deleted-variant root?]}] + (let [has-icon? (some? icon-id) + label-count (count labels) + labels-height (labels-bg-height label-count) + labels-y (when labels-height + (- (+ y height) labels-height)) + text-x (+ x text-padding) + deleted-root? (and deleted-variant root?) + banner-context (when deleted-root? {:deleted-banner true}) + thumbnail-slot-y (header-thumbnail-y y label-count banner-context) + thumbnail-y (+ thumbnail-slot-y thumbnail-margin) + thumbnail-w (- width (* 2 text-padding)) + title-y (header-title-y y label-count thumbnail-slot? banner-context)] + [:g + [:rect {:x x + :y y + :width width + :height height + :class (stl/css-case :selection-preview-box true + :selected selected? + :swap (and swap-variant (or root? selected?)) + :deleted (and deleted-variant (or root? selected?)))}] + + (when deleted-root? + [:g + [:rect {:x (+ x 1) + :y (+ y 1) + :width (- width 2) + :height (- deleted-banner-height 1) + :class (stl/css :deleted-header-bg)}] + [:text {:x text-x + :y (+ y text-padding) + :dominant-baseline "hanging" + :class (stl/css :deleted-header-text)} + "Deleted component"]]) + + (when labels-y + ;; Inset 1px so the box stroke isn't covered by the labels fill. + [:rect {:x (+ x 1) + :y labels-y + :width (- width 2) + :height (- labels-height 1) + :class (stl/css :info-labels-bg)}]) + + (for [[idx label] (map-indexed vector labels) + :let [text (if (string? label) label (str label))]] + ^{:key idx} + [:text {:x text-x + :y (+ labels-y text-padding (* idx text-line-height)) + :text-anchor "start" + :dominant-baseline "hanging" + :class (stl/css :info-text)} + text]) + + (when thumbnail-slot? + [:g + [:rect {:x (+ x text-padding) + :y thumbnail-y + :width thumbnail-w + :height thumbnail-height + :class (stl/css-case :shape-thumbnail-bg true + :is-loading (and (nil? thumbnail-uri) + (not thumbnail-unavailable)) + :unavailable thumbnail-unavailable)}] + (cond + thumbnail-uri + [:image {:x (+ x text-padding) + :y thumbnail-y + :width thumbnail-w + :height thumbnail-height + :href thumbnail-uri + :preserveAspectRatio "xMidYMid meet" + :class (stl/css :shape-thumbnail-image)}] + + thumbnail-unavailable + [:text {:x (+ x text-padding (/ thumbnail-w 2)) + :y (+ thumbnail-y (/ thumbnail-height 2)) + :text-anchor "middle" + :dominant-baseline "middle" + :class (stl/css :shape-thumbnail-unavailable)} + "No thumbnail available"] + + :else + [:text {:x (+ x text-padding (/ thumbnail-w 2)) + :y (+ thumbnail-y (/ thumbnail-height 2)) + :text-anchor "middle" + :dominant-baseline "middle" + :class (stl/css :shape-thumbnail-loading)} + "Loading…"])]) + + (when has-icon? + [:g {:transform (dm/str "translate(" text-x "," title-y ")")} + [:> icon* {:icon-id icon-id + :size "m" + :class (stl/css-case :selection-preview-icon true + :component icon-component?)}]]) + [:text {:x (+ text-x 20) + :y title-y + :dominant-baseline "hanging" + :class (stl/css :selection-preview-text)} + name]])) + +(defn- shape-thumbnail-tag + "Object-id tag used for a shape's own thumbnail: root frames render under + \"frame\", every other shape under \"component\"." + [shape] + (if (cfh/frame-shape? shape) "frame" "component")) + +;; Debugger-owned thumbnail URIs (object-id -> blob uri). After generation we +;; copy the workspace thumbnail here and clear it from workspace state so the +;; canvas is not left with a debugger-only render. +(defonce ^:private local-thumbnails (atom {})) + +;; Object-ids we have already asked to generate in this session (once each). +(defonce ^:private thumbnail-request-cache (atom #{})) + +;; Workspace uri present when a generation was requested; ignored on capture so +;; a swapped shape's stale thumbnail is never stored as the local result. +(defonce ^:private thumbnail-request-baseline (atom {})) + +(when-not (map? @local-thumbnails) + (reset! local-thumbnails {})) + +(when-not (set? @thumbnail-request-cache) + (reset! thumbnail-request-cache #{})) + +(when-not (map? @thumbnail-request-baseline) + (reset! thumbnail-request-baseline {})) + +(defn- clear-local-thumbnails! + "Revoke and drop all debugger-owned thumbnails (call when the debugger closes)." + [] + (doseq [uri (vals @local-thumbnails)] + (when (string? uri) + (wapi/revoke-uri uri))) + (reset! local-thumbnails {}) + (reset! thumbnail-request-cache #{}) + (reset! thumbnail-request-baseline {})) + +(defn- mark-thumbnail-requested! + "Mark `object-id` as requested with the current workspace `baseline-uri`. + Returns true on the first mark." + [object-id baseline-uri] + (let [first?* (atom false)] + (swap! thumbnail-request-cache + (fn [ids] + (let [ids (if (set? ids) ids #{})] + (if (contains? ids object-id) + ids + (do + (reset! first?* true) + (conj ids object-id)))))) + (when @first?* + (swap! thumbnail-request-baseline assoc object-id baseline-uri)) + @first?*)) + +(defn- store-local-thumbnail! + "Keep a private copy of `uri` for `object-id`, then clear the workspace + thumbnail (which would revoke the original blob URL)." + [file-id page-id shape-id tag object-id uri] + (when (and uri (nil? (get @local-thumbnails object-id))) + ;; Reserve the slot so concurrent captures do not race. + (swap! local-thumbnails assoc object-id :pending) + (-> (js/fetch uri) + (.then (fn [response] (.blob response))) + (.then (fn [blob] + (let [local-uri (wapi/create-uri blob)] + (swap! local-thumbnails assoc object-id local-uri) + (swap! thumbnail-request-baseline dissoc object-id) + (st/emit! (dwt/clear-thumbnail file-id page-id shape-id tag))))) + (.catch (fn [_] + (swap! local-thumbnails dissoc object-id)))))) + +(defn- use-shape-thumbnail + "URI of `shape`'s thumbnail for the debugger. + + Prefers a locally stored URI. Missing thumbnails and swapped shapes are + generated once into workspace state, then copied into `local-thumbnails` + and cleared from the workspace. Non-swapped shapes that already have a + workspace thumbnail keep using it. When `enabled?` is false (e.g. deleted + shapes) nothing is generated and nil is returned." + [shape file-id page-id enabled?] + (let [shape-id (:id shape) + tag (shape-thumbnail-tag shape) + swapped? (some? (ctk/get-swap-slot shape)) + object-id (mf/with-memo [file-id page-id shape-id tag] + (thc/fmt-object-id file-id page-id shape-id tag)) + thumb-ref (mf/with-memo [object-id] + (refs/workspace-thumbnail-by-id object-id)) + workspace-uri (:uri (mf/deref thumb-ref)) + local-entry (get @local-thumbnails object-id) + local-uri (when (string? local-entry) local-entry) + missing? (nil? workspace-uri)] + + ;; Request generation once for missing or swapped shapes with no local uri. + (mf/with-effect [object-id enabled? swapped? local-entry missing? workspace-uri] + (when (and enabled? file-id page-id shape-id (nil? local-entry)) + (cond + swapped? + (when (mark-thumbnail-requested! object-id workspace-uri) + (st/emit! (dwt/clear-thumbnail file-id page-id shape-id tag)) + (tm/schedule-on-idle + #(st/emit! (dwt/update-thumbnail file-id page-id shape-id tag + "components-debugger")))) + + missing? + (when (mark-thumbnail-requested! object-id nil) + (tm/schedule-on-idle + #(st/emit! (dwt/update-thumbnail file-id page-id shape-id tag + "components-debugger"))))))) + + ;; Once workspace has a freshly generated uri, keep it locally and clear. + (mf/with-effect [object-id enabled? workspace-uri] + (when (and enabled? workspace-uri (nil? local-entry) + (contains? @thumbnail-request-cache object-id) + (not= workspace-uri (get @thumbnail-request-baseline object-id))) + (store-local-thumbnail! file-id page-id shape-id tag object-id workspace-uri))) + + (when enabled? + (or local-uri + ;; Swapped workspace uris are stale until regenerated into local. + (when-not swapped? + workspace-uri))))) + +(mf/defc shape-box* + "Recursive shape tree box with thumbnails and nested children." + {::mf/private true} + [{:keys [shape objects x y width selected-id labels thumbnail-context root? + swap-variant deleted-variant]}] + (let [labels (or labels (shape-box-labels shape)) + thumbnail-slot? (box-thumbnail-slot? shape root? selected-id thumbnail-context) + file-id (:file-id thumbnail-context) + page-id (:page-id thumbnail-context) + thumbnail-unavailable? deleted-variant + thumbnail-uri (use-shape-thumbnail shape file-id page-id + (and thumbnail-slot? + (not thumbnail-unavailable?))) + height (shape-box-height objects shape width labels thumbnail-context selected-id root?) + child-layouts (shape-child-layouts objects shape x y width labels thumbnail-context selected-id root?) + selected? (= (:id shape) selected-id) + icon-component? (or (ctk/instance-head? shape) + (ctk/is-variant-container? shape))] + [:g + [:> labeled-rect* + {:x x + :y y + :width width + :height height + :name (shape-display-label shape) + :labels labels + :icon-id (usi/get-shape-icon shape) + :icon-component? icon-component? + :selected? selected? + :swap-variant swap-variant + :deleted-variant deleted-variant + :root? root? + :thumbnail-uri thumbnail-uri + :thumbnail-unavailable thumbnail-unavailable? + :thumbnail-slot? thumbnail-slot?}] + (for [{:keys [shape x y width]} child-layouts] + ^{:key (dm/str (:id shape))} + [:> shape-box* + {:shape shape + :objects objects + :x x + :y y + :width width + :selected-id selected-id + :swap-variant swap-variant + :deleted-variant deleted-variant + :thumbnail-context (child-thumbnail-context thumbnail-context)}])])) + +(mf/defc components-debugger-content* + "Floating debugger UI: selection, near-match chain, and swap panels." + {::mf/private true} + [] + (let [container (hooks/use-portal-container :popup) + + wrapper-ref (mf/use-ref nil) + z-index-ref (mf/use-ref min-z-index) + + selected (mf/deref refs/selected-shapes) + objects (mf/deref refs/workspace-page-objects) + file (mf/deref refs/file) + page (mf/deref refs/workspace-page) + libraries (mf/deref refs/files) + + selected-id + (when (= 1 (count selected)) + (first selected)) + + selected-shape + (when selected-id + (get objects selected-id)) + + root-shape + (when selected-shape + (ctn/get-instance-root objects selected-shape)) + + near-component-chain + (when (and selected-shape file page) + (near-component-data-chain file page libraries objects selected-shape)) + + selection-labels + (when root-shape + (shape-box-labels root-shape + :file-name (:name file) + :file-id (:id file) + :page-name (:name page) + :page-id (:id page))) + + selection-panel-height + (when root-shape + (root-panel-height objects root-shape selection-labels + {:file-id (:id file) :page-id (:id page)} + selected-id)) + + legend-x + (+ preview-x (/ (- preview-width legend-width) 2)) + + ;; When any near panel is deleted, shift non-deleted first-row panels + ;; down so their content lines up with the area below the deleted banner. + first-row-align-offset + (if (some :deleted near-component-chain) + deleted-banner-height + 0) + + selection-panel-y + (+ preview-y first-row-align-offset) + + legend-y + (when selection-panel-height + (+ selection-panel-y selection-panel-height legend-gap)) + + first-row-heights + (cond-> [] + selection-panel-height + (conj (+ selection-panel-height first-row-align-offset)) + + near-component-chain + (into (map (fn [{:keys [root-shape objects highlight-id deleted + file-name file-id page-name page-id]}] + (let [height (root-panel-height objects root-shape + (shape-box-labels root-shape + :file-name file-name + :file-id file-id + :page-name page-name + :page-id page-id) + {:file-id file-id + :page-id page-id + :deleted-banner deleted} + highlight-id)] + (if deleted + height + (+ height first-row-align-offset)))) + near-component-chain))) + + swap-row-y + (when (seq first-row-heights) + (+ preview-y (apply max first-row-heights) panels-gap)) + + selection-swap-panel + (when (and root-shape selected-id swap-row-y) + (swap-slot-panel 0 swap-row-y + file page libraries objects selected-id)) + + near-swap-panels + (when (and near-component-chain swap-row-y) + (keep-indexed + ;; Near columns are 1-based (selection is column 0), matching + ;; swap-slot-panel's (inc column-idx) placement and arrow pairing. + (fn [idx {:keys [highlight-id file page objects]}] + (swap-slot-panel (inc idx) swap-row-y + file page libraries objects highlight-id)) + near-component-chain)) + + swap-panels* + (cond-> [] + selection-swap-panel (conj selection-swap-panel) + (seq near-swap-panels) (into near-swap-panels)) + + swap-row-align-offset + (if (some :deleted swap-panels*) + deleted-banner-height + 0) + + swap-panels + (mapv (fn [panel] + (cond-> panel + (not (:deleted panel)) + (update :y + swap-row-align-offset))) + swap-panels*) + + primary-column-count + (if near-component-chain + (inc (count near-component-chain)) + (if root-shape 1 0)) + + max-column-idx + (reduce max (dec primary-column-count) + (map :column-idx swap-panels)) + + canvas-width + (canvas-content-width (inc max-column-idx)) + + swap-panel-heights + (map (fn [{:keys [objects root-shape labels highlight-id file-id page-id deleted]}] + (let [height (root-panel-height objects root-shape labels + {:file-id file-id + :page-id page-id + :deleted-banner deleted} + highlight-id)] + (if deleted + height + (+ height swap-row-align-offset)))) + swap-panels) + + canvas-height + (let [first-row-bottom (when (seq first-row-heights) + (+ preview-y (apply max first-row-heights))) + legend-bottom (when legend-y + (+ legend-y (legend-total-height))) + swap-bottom (when (and swap-row-y (seq swap-panel-heights)) + (+ swap-row-y (apply max swap-panel-heights))) + bottoms (remove nil? [first-row-bottom legend-bottom swap-bottom])] + (when (seq bottoms) + (+ (apply max bottoms) preview-y))) + + highlight-panels + (cond-> [] + (and root-shape selected-id) + (conj {:objects objects + :root-shape root-shape + :highlight-id selected-id + :labels selection-labels + :thumbnail-context {:file-id (:id file) :page-id (:id page)} + :panel-x preview-x + :panel-y selection-panel-y}) + + near-component-chain + (into (map-indexed + (fn [idx {:keys [root-shape objects highlight-id deleted + file-name file-id page-name page-id]}] + {:objects objects + :root-shape root-shape + :highlight-id highlight-id + :labels (shape-box-labels root-shape + :file-name file-name + :file-id file-id + :page-name page-name + :page-id page-id) + :thumbnail-context {:file-id file-id + :page-id page-id + :deleted-banner deleted} + :panel-x (column-x (inc idx)) + :panel-y (if deleted + preview-y + (+ preview-y first-row-align-offset))}) + near-component-chain))) + + bring-to-front + (mf/use-fn + (fn [] + (let [target (mf/ref-val wrapper-ref)] + (when target + (mf/set-ref-val! z-index-ref (inc (mf/ref-val z-index-ref))) + (dom/set-css-property! target "z-index" + (dm/str (mf/ref-val z-index-ref))))))) + + {:keys [on-pointer-down on-pointer-move on-pointer-up on-lost-pointer-capture]} + (fd/use-floating-drag wrapper-ref + bring-to-front + (stl/css :is-dragging)) + + {:keys [top left]} + (initial-position) + + handle-close + (mf/use-fn + (fn [] + (clear-local-thumbnails!) + (dbg/disable! :components-debugger)))] + + ;; Also clear if the debugger unmounts without using the close button + ;; (e.g. debug flag toggled elsewhere). + (mf/with-effect [] + (fn [] + (clear-local-thumbnails!))) + + (mf/portal + (mf/html + [:div {:ref wrapper-ref + :class (stl/css :wrapper) + :style {:position "fixed" + :top (dm/str top "px") + :left (dm/str left "px") + :width (dm/str panel-width "px") + :height (dm/str panel-height "px") + :z-index min-z-index}} + [:div {:class (stl/css :inner)} + [:div {:class (stl/css :header) + :on-pointer-down on-pointer-down + :on-pointer-move on-pointer-move + :on-pointer-up on-pointer-up + :on-lost-pointer-capture on-lost-pointer-capture} + [:h1 {:class (stl/css :title)} "COMPONENTS DEBUGGER (BETA)"] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click handle-close + :icon i/close}]] + + [:div {:class (stl/css :canvas)} + [:svg {:class (stl/css :canvas-svg) + :width canvas-width + :height (if canvas-height (dm/str canvas-height "px") "100%")} + (when (some? root-shape) + [:g + [:> shape-box* + {:shape root-shape + :objects objects + :selected-id selected-id + :root? true + :thumbnail-context {:file-id (:id file) + :page-id (:id page) + :file file + :libraries libraries} + :labels selection-labels + :x preview-x + :y selection-panel-y + :width preview-width}] + (for [[idx {:keys [root-shape objects highlight-id file deleted + file-name file-id page-name page-id]}] + (map-indexed vector near-component-chain) + :let [column-x (column-x (inc idx)) + panel-y (if deleted + preview-y + (+ preview-y first-row-align-offset))]] + ^{:key (dm/str "near-" (inc idx) "-" (:id root-shape))} + [:> shape-box* + {:shape root-shape + :objects objects + :selected-id highlight-id + :root? true + :deleted-variant deleted + :thumbnail-context {:file-id file-id + :page-id page-id + :file file + :libraries libraries + :deleted-banner deleted} + :labels (shape-box-labels root-shape + :file-name file-name + :file-id file-id + :page-name page-name + :page-id page-id) + :x column-x + :y panel-y + :width preview-width}]) + (for [{:keys [column-idx y root-shape objects highlight-id + file file-id page-id labels deleted]} + swap-panels + :let [swap-x (column-x column-idx)]] + ^{:key (dm/str "swap-" column-idx "-" (:id root-shape))} + [:> shape-box* + {:shape root-shape + :objects objects + :selected-id highlight-id + :root? true + :swap-variant true + :deleted-variant deleted + :thumbnail-context {:file-id file-id + :page-id page-id + :file file + :libraries libraries + :deleted-banner deleted} + :labels labels + :x swap-x + :y y + :width preview-width}]) + (when legend-y + [:> arrows-legend* {:x legend-x + :y legend-y}]) + [:> highlight-arrows* + {:highlight-pairs (map vector highlight-panels (rest highlight-panels)) + :swap-pairs (build-swap-panel-pairs highlight-panels swap-panels)}]])]]]]) + container))) + +(mf/defc components-debugger* + "Mounts the components debugger when the debug flag is enabled." + [] + (let [;; Subscribe to dbg/state so the component re-renders when + ;; debug options are toggled without a page reload. + _dbg (mf/deref dbg/state) + visible? (dbg/enabled? :components-debugger)] + (when visible? + [:> components-debugger-content*]))) diff --git a/frontend/src/app/main/ui/workspace/components_debugger.scss b/frontend/src/app/main/ui/workspace/components_debugger.scss new file mode 100644 index 0000000000..2dd49527a7 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/components_debugger.scss @@ -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; +} diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 6d1384790c..d79a194163 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -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* [] diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 394864c22a..7a33b98024 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 55310b0b87..84e786d8cf 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug.cljs b/frontend/src/app/main/ui/workspace/sidebar/debug.cljs index 9e46f48c41..a7f6c48db0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/debug.cljs @@ -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]}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs index 3e4b1ee570..3cd2038675 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs @@ -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) diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index 3283050d23..bcb0771477 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -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}]])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss index fb5e59cde6..4bfbe1c95d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss @@ -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); } diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 9fb646f205..5839ed57a4 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -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 diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 0bbf358e20..49622d9710 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -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))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index cf0842c01d..82cf07cc8f 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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) diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 86946db5ba..5937ebc014 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -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) diff --git a/frontend/src/app/render_wasm/api/props.cljs b/frontend/src/app/render_wasm/api/props.cljs new file mode 100644 index 0000000000..add4496c66 --- /dev/null +++ b/frontend/src/app/render_wasm/api/props.cljs @@ -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"))) diff --git a/frontend/src/app/render_wasm/api/texts.cljs b/frontend/src/app/render_wasm/api/texts.cljs index afe26e2726..3ea55dfdb3 100644 --- a/frontend/src/app/render_wasm/api/texts.cljs +++ b/frontend/src/app/render_wasm/api/texts.cljs @@ -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: - ;; [ ] + "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) diff --git a/frontend/src/app/render_wasm/fallback_fonts.cljs b/frontend/src/app/render_wasm/fallback_fonts.cljs new file mode 100644 index 0000000000..80f52be51c --- /dev/null +++ b/frontend/src/app/render_wasm/fallback_fonts.cljs @@ -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)) diff --git a/frontend/src/app/render_wasm/resources.cljs b/frontend/src/app/render_wasm/resources.cljs new file mode 100644 index 0000000000..9f564aa990 --- /dev/null +++ b/frontend/src/app/render_wasm/resources.cljs @@ -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))) diff --git a/frontend/src/app/render_wasm/serialize_shape.cljs b/frontend/src/app/render_wasm/serialize_shape.cljs new file mode 100644 index 0000000000..ffc659757e --- /dev/null +++ b/frontend/src/app/render_wasm/serialize_shape.cljs @@ -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))))) diff --git a/frontend/src/app/render_wasm/text_content.cljs b/frontend/src/app/render_wasm/text_content.cljs new file mode 100644 index 0000000000..457a633066 --- /dev/null +++ b/frontend/src/app/render_wasm/text_content.cljs @@ -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"))) diff --git a/frontend/src/app/util/debug.cljs b/frontend/src/app/util/debug.cljs index 0f8d4471f2..8eb7dfaffe 100644 --- a/frontend/src/app/util/debug.cljs +++ b/frontend/src/app/util/debug.cljs @@ -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 diff --git a/frontend/src/app/util/simple_math.cljs b/frontend/src/app/util/simple_math.cljs index 19c01a0f99..9831ec3d90 100644 --- a/frontend/src/app/util/simple_math.cljs +++ b/frontend/src/app/util/simple_math.cljs @@ -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 ( ('+'|'-') expr)* | - ('+'|'-'|'*'|'/') factor - term = factor ( ('*'|'/') term)* - factor = number | ('(' expr ')') + ;; 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 = ('+'|'*'|'/') factor + expr = term ( ('+'|'-') term)* + term = factor ( ('*'|'/') factor)* + factor = number | neg | ('(' expr ')') + neg = <'-'> 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)] diff --git a/frontend/src/app/util/text_svg_position.cljs b/frontend/src/app/util/text_svg_position.cljs index 6cfaa314e2..772a7f6b8a 100644 --- a/frontend/src/app/util/text_svg_position.cljs +++ b/frontend/src/app/util/text_svg_position.cljs @@ -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) diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index 32a0bdf944..fb5cd5b5fe 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -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)) \ No newline at end of file diff --git a/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs b/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs index 61c91d62c8..9d2089d601 100644 --- a/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs +++ b/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs @@ -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 ;; diff --git a/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs index 79a32492a4..11821550b0 100644 --- a/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs @@ -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))))))))) + diff --git a/frontend/test/frontend_tests/composable_tests/interpreter.cljs b/frontend/test/frontend_tests/composable_tests/interpreter.cljs index 1890fd0faf..0a6f3b9734 100644 --- a/frontend/test/frontend_tests/composable_tests/interpreter.cljs +++ b/frontend/test/frontend_tests/composable_tests/interpreter.cljs @@ -44,6 +44,7 @@ [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.thumbnails :as dwth] + [app.main.data.workspace.transforms :as dwt] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.variants :as dwv] [app.main.store :as st] @@ -140,6 +141,19 @@ [(dwsh/update-shapes #{(tm/target-shape-id situation target)} (fn [shape] (n/set-property shape property value)))]) + (instance? n/Rotate op) + ;; The real sidebar rotation event, absolute mode: rotates the shape (and its + ;; whole subtree) around its center. Its apply-modifiers step runs the + ;; placement-vs-override classification for component copies (check-delta), + ;; which is part of what a case using `rotate` exercises. + (let [{:keys [target angle]} op] + [(dwt/increase-rotation [(tm/target-shape-id situation target)] angle)]) + + (instance? n/ChangeHeight op) + ;; The real sidebar dimension event. + (let [{:keys [target value]} op] + [(dwt/update-dimensions [(tm/target-shape-id situation target)] :height value)]) + (instance? n/SwapComponent op) ;; Swap lineage `name`'s nesting level `level` for lineage `target`'s component ;; via the REAL swap event (dwl/component-swap), so it commits through the diff --git a/frontend/test/frontend_tests/plugins/utils_test.cljs b/frontend/test/frontend_tests/plugins/utils_test.cljs index a2a1e98124..3c731055ea 100644 --- a/frontend/test/frontend_tests/plugins/utils_test.cljs +++ b/frontend/test/frontend_tests/plugins/utils_test.cljs @@ -6,8 +6,23 @@ (ns frontend-tests.plugins.utils-test (:require + [app.common.files.tokens :as cfo] + [app.common.schema :as sm] [app.plugins.utils :as plugins.utils] - [cljs.test :as t :include-macros true])) + [app.util.i18n :as i18n] + [cljs.test :as t :include-macros true] + [cuerdas.core :as str])) + +;; The plugin error renderer goes through `tr`, which returns the bare +;; translation code when no locale data is loaded. Load the strings this +;; namespace asserts on so the assertions exercise the real rendering. +(i18n/set-default-translations + #js {"plugins.validation.message" "Field %s is invalid: %s" + "plugins.validation.invalid-value" "expected %s, got %s (%s)" + "plugins.validation.received-value" "got %s (%s)" + "errors.invalid-data" "Invalid data" + "errors.field-missing" "Missing field" + "errors.tokens.empty-input" "Empty input"}) ;; Access the private flattener for direct testing. (def ^:private flatten-error-map @#'plugins.utils/flatten-error-map) @@ -87,3 +102,190 @@ ;; `error-messages` returns nil (not "") on an explain with no mappable ;; errors, so `handle-error` can distinguish "no message" from a real one. (t/is (nil? (plugins.utils/error-messages {:errors []})))) + +;; --------------------------------------------------------------------- +;; Issue #10072 — schema validation errors must say what was expected and +;; what was received, instead of collapsing into a generic "Invalid data". + +(t/deftest test-error-messages-reports-expected-and-received + (let [explain (sm/explain [:map [:value :string]] {:value 16}) + message (plugins.utils/error-messages explain)] + (t/is (= "Field value is invalid: expected string, got number (16)" message)))) + +(t/deftest test-error-messages-reports-nested-field-path + (let [explain (sm/explain [:map [:sets [:vector [:map [:name :string]]]]] + {:sets [{:name true}]}) + message (plugins.utils/error-messages explain)] + (t/is (= "Field sets.0.name is invalid: expected string, got boolean (true)" message)))) + +(t/deftest test-error-messages-honors-custom-schema-message + ;; A schema that declares its own `:error/message` must keep it; the + ;; expected/received rendering is only a fallback. + (let [explain (sm/explain [:map [:value [:string {:error/message "must not be empty"}]]] + {:value 16}) + message (plugins.utils/error-messages explain)] + (t/is (= "Field value is invalid: must not be empty" message)))) + +(t/deftest test-error-messages-missing-key + ;; Missing keys keep the "field missing" message: there is no received + ;; value to report. + (let [explain (sm/explain [:map [:value :string]] {}) + message (plugins.utils/error-messages explain)] + (t/is (= "Field value is invalid: Missing field" message)))) + +(t/deftest test-error-messages-never-generic + (let [explain (sm/explain [:map [:value :string]] {:value 16}) + message (plugins.utils/error-messages explain)] + (t/is (not (str/includes? message "Invalid data"))))) + +(t/deftest test-error-messages-truncates-long-values + (let [value (apply str (repeat 500 "a")) + explain (sm/explain [:map [:value :int]] {:value value}) + message (plugins.utils/error-messages explain)] + (t/is (str/includes? message "got string")) + (t/is (< (count message) 200)))) + +(t/deftest test-error-messages-unicode-value + (let [explain (sm/explain [:map [:value :int]] {:value "ñ😀"}) + message (plugins.utils/error-messages explain)] + (t/is (str/includes? message "ñ😀")))) + +(t/deftest test-error-messages-collection-value + (let [explain (sm/explain [:map [:value :string]] {:value [1 2]}) + message (plugins.utils/error-messages explain)] + (t/is (str/includes? message "got array")))) + +(defn- proxy? [value] (and (map? value) (contains? value :proxy))) + +(defn- well-formed? + "False when the string contains a lone UTF-16 surrogate." + [s] + (try + (js/encodeURIComponent s) + true + (catch :default _ false))) + +(t/deftest test-error-messages-union-reports-every-branch + ;; Malli emits one problem per `:or` branch, all sharing the same path. + ;; Reporting a single branch as if it were the only requirement is worse + ;; than saying nothing: `plugins.tokens` declares the token value setter + ;; as `[:or :string base]`, so a plugin author would be told a vector is + ;; required when a plain string is equally valid. + (let [explain (sm/explain [:map [:value [:or [:vector :string] :string]]] {:value 16}) + message (plugins.utils/error-messages explain)] + (t/is (= "Field value is invalid: expected [:vector :string] or string, got number (16)" + message)))) + +(t/deftest test-error-messages-union-reports-every-branch-nested + (let [explain (sm/explain [:map [:sets [:vector [:or :string :int]]]] {:sets [true]}) + message (plugins.utils/error-messages explain)] + (t/is (= "Field sets.0 is invalid: expected string or int, got boolean (true)" + message)))) + +(t/deftest test-error-messages-never-leaks-function-objects + ;; `[:fn pred]` schemas (used by the token-set arguments) render their form + ;; as `#object[app$plugins$tokens$token_set_proxy_QMARK_]`. That must never + ;; reach a plugin author. + (let [explain (sm/explain [:map [:value [:or [:fn proxy?] :string]]] {:value 16}) + message (plugins.utils/error-messages explain)] + (t/is (not (str/includes? message "#object"))) + (t/is (= "Field value is invalid: got number (16)" message)))) + +(t/deftest test-error-messages-function-schema-falls-back-to-custom-message + ;; When one branch of the union carries its own message, that message wins + ;; over the un-renderable `[:fn …]` branch. + (let [explain (sm/explain [:map [:value [:or [:fn proxy?] ::sm/uuid]]] {:value "nope"}) + message (plugins.utils/error-messages explain)] + (t/is (not (str/includes? message "#object"))) + (t/is (= "Field value is invalid: should be an uuid" message)))) + +(t/deftest test-error-messages-truncation-keeps-surrogate-pairs + ;; Truncating at a UTF-16 code-unit boundary splits astral characters and + ;; produces mojibake in a message whose whole purpose is legibility. + (let [value (apply str (repeat 200 "😀")) + explain (sm/explain [:map [:value :int]] {:value value}) + message (plugins.utils/error-messages explain)] + (t/is (well-formed? message)) + (t/is (str/includes? message "…")))) + +(t/deftest test-error-messages-truncation-counts-code-points + ;; 60 emoji are 120 UTF-16 code units but only 60 code points: below the + ;; limit, so the value must be reported whole. + (let [value (apply str (repeat 60 "😀")) + explain (sm/explain [:map [:value :int]] {:value value}) + message (plugins.utils/error-messages explain)] + (t/is (well-formed? message)) + (t/is (str/includes? message value)))) + +;; --------------------------------------------------------------------- +;; The value reported back to the plugin author is arbitrary JS data, so +;; rendering it must terminate and must not run plugin code. + +(t/deftest test-error-messages-self-referencing-object + ;; A plugin argument is a native JS value, and an object graph with a back + ;; reference (`a.parent.child === a`) is ordinary. `pr-str` walks it forever, + ;; so the error renderer would throw a RangeError from inside the error + ;; handler itself: the author gets a stack overflow instead of a validation + ;; error. + (let [a #js {} + b #js {} + _ (unchecked-set a "parent" b) + _ (unchecked-set b "child" a) + explain (sm/explain [:map [:value :string]] {:value a}) + message (plugins.utils/error-messages explain)] + (t/is (str/includes? message "got object")))) + +(t/deftest test-error-messages-object-with-getters + ;; Penpot's own proxies expose their contents through getters that read the + ;; application state and may throw for a stale id. Rendering a value must + ;; never invoke them. + (let [called (atom 0) + value (js/Object.defineProperty + #js {:id "x"} "children" + #js {:enumerable true + :get (fn [] (swap! called inc) (throw (js/Error. "boom")))}) + explain (sm/explain [:map [:value :string]] {:value value}) + message (plugins.utils/error-messages explain)] + (t/is (str/includes? message "got object")) + (t/is (zero? @called)))) + +(t/deftest test-error-messages-deeply-nested-object + ;; A deep graph must not produce an unbounded message. + (let [value (reduce (fn [acc _] #js {:child acc}) #js {:leaf 1} (range 200)) + explain (sm/explain [:map [:value :string]] {:value value}) + message (plugins.utils/error-messages explain)] + (t/is (str/includes? message "got object")) + (t/is (< (count message) 200)))) + +;; --------------------------------------------------------------------- +;; Issue #10072 — the reported example: `addToken` with a wrong token value. +;; +;; The token value schemas declare an `:error/fn` that only speaks about empty +;; values, so it returns nil for a wrong-typed one and `interpret-schema-problem` +;; degrades to the generic "Invalid data". A message that says nothing must not +;; win over one that says what was expected and what was received. + +(t/deftest test-error-messages-token-value-numeric + (let [schema [:map [:value (cfo/make-token-value-schema :font-size)]] + message (plugins.utils/error-messages (sm/explain schema {:value 16}))] + (t/is (= "Field value is invalid: expected string, got number (16)" message)))) + +(t/deftest test-error-messages-token-value-opacity + (let [schema [:map [:value (cfo/make-token-value-schema :opacity)]] + message (plugins.utils/error-messages (sm/explain schema {:value true}))] + (t/is (= "Field value is invalid: expected string, got boolean (true)" message)))) + +(t/deftest test-error-messages-token-value-font-family + ;; Both branches of the union are printable, so both are reported. + (let [schema [:map [:value (cfo/make-token-value-schema :font-family)]] + message (plugins.utils/error-messages (sm/explain schema {:value 16}))] + (t/is (= (str "Field value is invalid: " + "expected [:vector :app.common.schema/text] or TokenRef, got number (16)") + message)))) + +(t/deftest test-error-messages-token-value-empty-keeps-custom-message + ;; When the schema's own `:error/fn` does render a message, it still wins. + (let [schema [:map [:value (cfo/make-token-value-schema :font-size)]] + message (plugins.utils/error-messages (sm/explain schema {:value ""}))] + (t/is (= "Field value is invalid: Empty input" message)))) + diff --git a/frontend/test/frontend_tests/util_simple_math_test.cljs b/frontend/test/frontend_tests/util_simple_math_test.cljs index 6b0aaecca1..c06f509d45 100644 --- a/frontend/test/frontend_tests/util_simple_math_test.cljs +++ b/frontend/test/frontend_tests/util_simple_math_test.cljs @@ -107,3 +107,81 @@ (t/testing "Partial invalid expression should return nil" (let [result (sm/expr-eval "10 + abc" 100)] (t/is (= result nil))))) + +(t/deftest test-relative-operators-only-at-top-level + (t/testing "Two consecutive operators should return nil" + (t/is (= nil (sm/expr-eval "10+*3" 100))) + (t/is (= nil (sm/expr-eval "10 + *3" 100))) + (t/is (= nil (sm/expr-eval "10+/3" 100))) + (t/is (= nil (sm/expr-eval "10 + +5" 100))) + (t/is (= nil (sm/expr-eval "10 * *5" 100))) + (t/is (= nil (sm/expr-eval "10 * /5" 100)))) + + (t/testing "Relative operators inside parentheses should return nil" + (t/is (= nil (sm/expr-eval "(*3)" 100))) + (t/is (= nil (sm/expr-eval "10 + (*3)" 100))) + (t/is (= nil (sm/expr-eval "+(10 + *3)" 100)))) + + (t/testing "A dangling operator should return nil" + (t/is (= nil (sm/expr-eval "10 +" 100))) + (t/is (= nil (sm/expr-eval "+" 100))) + (t/is (= nil (sm/expr-eval "*" 100)))) + + (t/testing "Valid relative expressions keep working" + (t/is (= 130 (sm/expr-eval "+30" 100))) + (t/is (= 300 (sm/expr-eval "*3" 100))) + (t/is (= -30 (sm/expr-eval "-30" 100))) + (t/is (= 50 (sm/expr-eval "/2" 100))))) + +(t/deftest test-negative-operands + (t/testing "A negative number is a valid operand" + (t/is (= 7 (sm/expr-eval "10 + -3" 100))) + (t/is (= 7 (sm/expr-eval "10+-3" 100))) + (t/is (= 15 (sm/expr-eval "10 - -5" 100))) + (t/is (= -30 (sm/expr-eval "10 * -3" 100))) + (t/is (= 4 (sm/expr-eval "10 + -3 * 2" 100))) + (t/is (= -20 (sm/expr-eval "-(10 + 10)" 100))) + (t/is (= 5 (sm/expr-eval "10 - -5 * 1 - 10" 100)))) + + (t/testing "A negative number is a valid relative operand" + (t/is (= 70 (sm/expr-eval "+ -30" 100))) + (t/is (= -300 (sm/expr-eval "* -3" 100)))) + + (t/testing "A negative percentage is a valid operand" + (t/is (= 75 (sm/expr-eval "100 + -25%" 100))) + (t/is (= -25 (sm/expr-eval "-25%" 100))))) + +(t/deftest test-operator-associativity + (t/testing "Chained subtraction is evaluated left to right" + (t/is (= 55 (sm/expr-eval "100 - 35 - 10" 999))) + (t/is (= 65 (sm/expr-eval "100 - 35 + 10 - 10" 999))) + (t/is (= 15 (sm/expr-eval "1 + 2 + 3 + 4 + 5" 999)))) + + (t/testing "Chained division is evaluated left to right" + (t/is (= 5 (sm/expr-eval "100 / 10 / 2" 999))) + (t/is (= 20 (sm/expr-eval "100 / 10 * 2" 999))) + (t/is (= 5 (sm/expr-eval "(100 / 10) / 2" 999)))) + + (t/testing "Precedence and parentheses are preserved" + (t/is (= 7 (sm/expr-eval "1 + 2 * 3" 999))) + (t/is (= 9 (sm/expr-eval "(1 + 2) * 3" 999))))) + +(t/deftest test-edge-cases + (t/testing "Relative division by zero returns nil" + (t/is (= nil (sm/expr-eval "/0" 100))) + (t/is (= nil (sm/expr-eval "10 / 5 / 0" 100)))) + + (t/testing "Relative operators fall back to a zero init-value" + (t/is (= 10 (sm/expr-eval "+10" nil))) + (t/is (= 0 (sm/expr-eval "*10" nil)))) + + (t/testing "Percentages of the init-value" + (t/is (= 25 (sm/expr-eval "25%" 100))) + (t/is (= 125 (sm/expr-eval "+25%" 100)))) + + (t/testing "Non numeric input returns nil" + (t/is (= nil (sm/expr-eval " " 100))) + (t/is (= nil (sm/expr-eval "10 20" 100))) + (t/is (= nil (sm/expr-eval "(10" 100))) + (t/is (= nil (sm/expr-eval "10€" 100))) + (t/is (= nil (sm/expr-eval "🙂" 100))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index ea0d60d7ec..051d183632 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -490,6 +490,14 @@ msgstr "Once a project member creates a file, it will be displayed here." msgid "dashboard.empty-placeholder-files-title" msgstr "No files yet." +#: src/app/main/ui/dashboard/files_layout.cljs +msgid "dashboard.files-layout.grid" +msgstr "Grid view" + +#: src/app/main/ui/dashboard/files_layout.cljs +msgid "dashboard.files-layout.list" +msgstr "List view" + #: src/app/main/ui/dashboard/placeholder.cljs:117 #, markdown msgid "dashboard.empty-placeholder-libraries" @@ -3918,6 +3926,14 @@ msgstr "Are you sure you want to delete this page?" msgid "modals.delete-page.title" msgstr "Delete page" +#: src/app/main/ui/workspace/context_menu.cljs:712 +msgid "modals.delete-pages.body" +msgstr "Are you sure you want to delete the selected pages?" + +#: src/app/main/ui/workspace/context_menu.cljs:711 +msgid "modals.delete-pages.title" +msgstr "Delete pages" + #: src/app/main/ui/dashboard/project_menu.cljs:73 msgid "modals.delete-project-confirm.accept" msgstr "Delete project" @@ -5026,10 +5042,18 @@ msgstr "This token does not exist or has been deleted." msgid "options.deleted-token-with-name" msgstr "{%s} token does not exist or has been deleted." -#: src/app/plugins/utils.cljs:318 +#: src/app/plugins/utils.cljs:533 +msgid "plugins.validation.invalid-value" +msgstr "expected %s, got %s (%s)" + +#: src/app/plugins/utils.cljs:547 msgid "plugins.validation.message" msgstr "Field %s is invalid: %s" +#: src/app/plugins/utils.cljs:536 +msgid "plugins.validation.received-value" +msgstr "got %s (%s)" + #: src/app/main/ui/auth/recovery.cljs:88 msgid "profile.recovery.go-to-login" msgstr "Go to login" @@ -6433,6 +6457,10 @@ msgstr "Your items are going to be named automatically as \"group name / item na msgid "workspace.assets.delete" msgstr "Delete" +#: src/app/main/ui/workspace/context_menu.cljs:733 +msgid "workspace.assets.delete-pages" +msgstr "Delete pages" + #: src/app/main/ui/workspace/sidebar/assets/groups.cljs:74 msgid "workspace.assets.delete-group" msgstr "Delete group" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index d117f9ca38..581aeb3551 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -495,6 +495,14 @@ msgstr "Cuando un miembro del equipo cree algún archivo, este aparecerá aquí. msgid "dashboard.empty-placeholder-files-title" msgstr "Aún no hay archivos." +#: src/app/main/ui/dashboard/files_layout.cljs +msgid "dashboard.files-layout.grid" +msgstr "Vista de cuadrícula" + +#: src/app/main/ui/dashboard/files_layout.cljs +msgid "dashboard.files-layout.list" +msgstr "Vista de lista" + #: src/app/main/ui/dashboard/placeholder.cljs:117 #, markdown msgid "dashboard.empty-placeholder-libraries" @@ -3802,6 +3810,14 @@ msgstr "¿Seguro que quieres borrar esta página?" msgid "modals.delete-page.title" msgstr "Borrar página" +#: src/app/main/ui/workspace/context_menu.cljs:712 +msgid "modals.delete-pages.body" +msgstr "¿Seguro que quieres borrar las páginas seleccionadas?" + +#: src/app/main/ui/workspace/context_menu.cljs:711 +msgid "modals.delete-pages.title" +msgstr "Borrar páginas" + #: src/app/main/ui/dashboard/project_menu.cljs:73 msgid "modals.delete-project-confirm.accept" msgstr "Eliminar proyecto" @@ -6309,6 +6325,10 @@ msgstr "" msgid "workspace.assets.delete" msgstr "Borrar" +#: src/app/main/ui/workspace/context_menu.cljs:733 +msgid "workspace.assets.delete-pages" +msgstr "Borrar páginas" + #: src/app/main/ui/workspace/context_menu.cljs:721, src/app/main/ui/workspace/sidebar/assets/colors.cljs:262, src/app/main/ui/workspace/sidebar/assets/typographies.cljs:471, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:537 msgid "workspace.assets.duplicate" msgstr "Duplicar" diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml index 7fac475f69..3685e20b4d 100644 --- a/mcp/.serena/project.yml +++ b/mcp/.serena/project.yml @@ -4,6 +4,13 @@ ignore_all_files_in_gitignore: true # 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: [] @@ -21,7 +28,7 @@ excluded_tools: [] # (contrary to the memories, which are loaded on demand). initial_prompt: | CRITICAL: Always read the "project_overview" memory. -# 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-mcp" # list of mode names to that are always to be included in the set of active modes @@ -56,23 +63,25 @@ fixed_tools: [] # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: utf-8 - -# list of languages for which language servers are started; choose from: -# al angular ansible bash clojure -# cpp cpp_ccls crystal csharp csharp_omnisharp -# dart elixir elm erlang fortran -# fsharp go groovy haskell haxe -# hlsl html java json julia -# kotlin lean4 lua luau markdown +# 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 +# html java json julia kotlin +# latex 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 scss solidity -# svelte 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.) +# 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 @@ -121,8 +130,8 @@ ignored_memory_patterns: [] # 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 mode names to be activated additionally for this project, e.g. ["query-projects"] @@ -130,13 +139,38 @@ ls_specific_settings: {} # 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 diff --git a/mcp/README.md b/mcp/README.md index bc6b2205cb..ac99679f28 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -59,14 +59,10 @@ The project requires [Node.js](https://nodejs.org/) (tested with v22.x). The easiest way to launch the servers is to use `npx` to run the appropriate version that matches your Penpot version. -* If you are using the latest Penpot release, e.g. as served on [design.penpot.app](https://design.penpot.app), run: - ```shell - npx -y @penpot/mcp@latest - ``` -* If you are participating in the MCP beta-test, which uses [test-mcp.penpot.dev](https://test-mcp.penpot.dev), run: - ```shell - npx -y @penpot/mcp@beta - ``` +If you are using the latest Penpot release, e.g. as served on [design.penpot.app](https://design.penpot.app), run: +```shell +npx -y @penpot/mcp@latest +``` Once the servers are running, continue with step 2. @@ -78,24 +74,13 @@ On Windows, use the Git Bash terminal to ensure compatibility with the provided ##### Clone the Appropriate Branch of the Repository -> [!IMPORTANT] -> The branches are subject to change in the future. -> Be sure to check the instructions for the latest information on which branch to use. +Clone the Penpot repository, using the proper branch/tag depending on the +version of Penpot you want to use the MCP server with. +For instance, to target the latest development version, use the `develop` branch: -Clone the Penpot repository, using the proper branch depending on the -version of Penpot you want to use the MCP server with. - - * For the current Penpot release 2.14, use the `mcp-prod-2.14.1` branch: - - ```shell - git clone https://github.com/penpot/penpot.git --branch mcp-prod-2.14.1 --depth 1 - ``` - - * For the MCP beta-test, use the `staging` branch: - - ```shell - git clone https://github.com/penpot/penpot.git --branch staging --depth 1 - ``` +```shell +git clone https://github.com/penpot/penpot.git --branch develop --depth 1 +``` Then change into the `mcp` directory: @@ -180,16 +165,27 @@ By default, the server runs on port 4401 and provides: - **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp` - **Legacy SSE endpoint**: `http://localhost:4401/sse` -These endpoints can be used directly by MCP clients that support them. +You can change the port by setting the `PENPOT_MCP_SERVER_PORT` environment variable +before starting the server. These endpoints can be used directly by MCP clients that support them. Simply configure the client to connect the MCP server by providing the respective URL. -When using a client that only supports stdio transport, -a proxy like `mcp-remote` is required. +#### Configuring your client + +You can configure your client with the [add-mcp](https://github.com/neon-solutions/add-mcp) helper. +Simply call + + npx -y add-mcp -g -n penpot http://localhost:4401/mcp + +and follow the interactive dialogue to configure the clients of your choice. +The config entry name is `penpot` (override it with `-n `) and the URL points to the local http +endpoint (adjust the port if you changed `PENPOT_MCP_SERVER_PORT`). + +When using a client that only supports stdio transport like **Claude Desktop**, +a proxy like [mcp-remote](https://github.com/geelen/mcp-remote) is required. +More information on connecting your client follows below. #### Using a Proxy for stdio Transport -NOTE: only relevant if you are executing this outside of devenv - The `mcp-remote` package can proxy stdio transport to HTTP/SSE, allowing clients that support only stdio to connect to the MCP server indirectly. Use it to provide the launch command for your MCP client as follows: @@ -232,12 +228,6 @@ After updating the configuration file, restart Claude Desktop completely for the After the restart, you should see the MCP server listed when clicking on the "Search and tools" icon at the bottom of the prompt input area. -#### Example: Claude Code - -To add the Penpot MCP server to a Claude Code project, issue the command - - claude mcp add penpot -t http http://localhost:4401/mcp - ## Repository Structure This repository is a monorepo containing four main components: @@ -296,9 +286,9 @@ The Penpot MCP server can be configured using environment variables. ## Beyond Local Execution The above instructions describe how to run the MCP server and plugin server locally. -We are working on enabling remote deployments of the MCP server, particularly -in [multi-user mode](docs/multi-user-mode.md), where multiple Penpot users will -be able to connect to the same MCP server instance. + +The Penpot MCP server can also support multiple remote users simultaneously +in [multi-user mode](docs/multi-user-mode.md). To run the server remotely (even for a single user), you may set the following environment variables to configure the two servers diff --git a/mcp/bin/client-setup.js b/mcp/bin/client-setup.js new file mode 100644 index 0000000000..d078277d2d --- /dev/null +++ b/mcp/bin/client-setup.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +// Client-setup entry point of the published @penpot/mcp package. +// Configures an MCP client to talk to the local Penpot MCP server by delegating +// to the add-mcp CLI. This launcher is deliberately independent of the server +// build/bootstrap: it only needs npx to fetch add-mcp on demand, so it does not +// require a source checkout, a prior install, or a running server. + +const { spawnSync } = require("child_process"); + +/** + * Runs the add-mcp CLI against the local Penpot MCP server URL. + * + * The server port is read from PENPOT_MCP_SERVER_PORT (default 4401), matching + * the server's own default. add-mcp is fetched via npx using @latest on + * purpose: MCP client config formats change frequently, so we always want the + * newest add-mcp rather than a pinned version. + * + * forwardedArgs are passed straight through to add-mcp so callers can pick + * agents, set headers, override the name or URL, etc. Two defaults are applied + * conditionally so caller args always win: "penpot" as the server name (dropped + * when the caller passes -n/--name) and the local server URL as the positional + * target (dropped when the caller passes their own URL). Unlike a repeated + * option, the URL is a positional, so add-mcp cannot "last-wins" two of them -- + * we must omit ours rather than append it. A caller URL is detected as an arg + * that starts with a scheme (e.g. http://, https://); matching only at the start + * avoids false positives from URLs embedded in --header/--env values. + * + * @param {string[]} forwardedArgs - additional arguments passed through to add-mcp + */ +function runClientSetup(forwardedArgs) { + const port = process.env.PENPOT_MCP_SERVER_PORT || "4401"; + const defaultUrl = `http://localhost:${port}/mcp`; + + const hasName = forwardedArgs.some( + (arg) => arg === "-n" || arg === "--name" || arg.startsWith("--name="), + ); + const nameArgs = hasName ? [] : ["-n", "penpot"]; + + const isUrl = (arg) => /^[a-zA-Z][\w+.-]*:\/\//.test(arg); + const hasUrl = forwardedArgs.some(isUrl); + const urlArgs = hasUrl ? [] : [defaultUrl]; + + const args = ["-y", "add-mcp@latest", "-g", ...nameArgs, ...forwardedArgs, ...urlArgs]; + + const result = spawnSync("npx", args, { + stdio: "inherit", + shell: process.platform === "win32", + }); + + if (result.error) { + throw result.error; + } + if (typeof result.status === "number" && result.status !== 0) { + const error = new Error(`add-mcp exited with code ${result.status}`); + error.status = result.status; + throw error; + } +} + +module.exports = { runClientSetup }; + +// run directly, e.g. via `pnpm run client-setup` or `node bin/client-setup.js` +if (require.main === module) { + try { + runClientSetup(process.argv.slice(2)); + } catch (error) { + process.exit(error.status ?? 1); + } +} diff --git a/mcp/bin/mcp-local.js b/mcp/bin/mcp-local.js index feecbc9e39..b3baf3326b 100644 --- a/mcp/bin/mcp-local.js +++ b/mcp/bin/mcp-local.js @@ -54,18 +54,37 @@ function prepareRuntimeRoot() { return runtimeRoot; } -const root = prepareRuntimeRoot(); - -// pnpm-lock.yaml is hard-excluded by npm pack; it is shipped as pnpm-lock.dist.yaml -// and restored here before bootstrap runs. -const distLock = path.join(root, "pnpm-lock.dist.yaml"); -const lock = path.join(root, "pnpm-lock.yaml"); -if (fs.existsSync(distLock)) { - fs.copyFileSync(distLock, lock); +// check arguments +const cliArgs = process.argv.slice(2); +if (cliArgs[0] === "client-setup") { + // when invoked as `penpot-mcp client-setup [...]`, configure an MCP client + // instead of bootstrapping/starting the server. This must run before the + // runtime-directory copy and bootstrap, neither of which client setup needs. + try { + require("./client-setup.js").runClientSetup(cliArgs.slice(1)); + process.exit(0); + } catch (error) { + process.exit(error.status ?? 1); + } } +else { + // regular case: run bootstrap, building and starting the servers -try { - execSync(`npx -y pnpm@${pnpmVersion()} run bootstrap`, { cwd: root, stdio: "inherit" }); -} catch (error) { - process.exit(error.status ?? 1); + // get runtime root + const root = prepareRuntimeRoot(); + + // pnpm-lock.yaml is hard-excluded by npm pack; it is shipped as pnpm-lock.dist.yaml + // and restored here before bootstrap runs. + const distLock = path.join(root, "pnpm-lock.dist.yaml"); + const lock = path.join(root, "pnpm-lock.yaml"); + if (fs.existsSync(distLock)) { + fs.copyFileSync(distLock, lock); + } + + // run the bootstrap command + try { + execSync(`npx -y pnpm@${pnpmVersion()} run bootstrap`, { cwd: root, stdio: "inherit" }); + } catch (error) { + process.exit(error.status ?? 1); + } } diff --git a/mcp/package.json b/mcp/package.json index 5f6dea413e..6607dd11b2 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -14,6 +14,7 @@ "start:multi-user": "pnpm -r --parallel run start:multi-user", "bootstrap": "pnpm -r install && pnpm run build && pnpm run start", "bootstrap:multi-user": "pnpm -r install && pnpm run build && pnpm run start:multi-user", + "client-setup": "node bin/client-setup.js", "fmt": "prettier --write packages/", "fmt:check": "prettier --check packages/", "test": "pnpm -r run test" diff --git a/package.json b/package.json index 1454dbd2bc..256ffc3436 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,22 @@ { "name": "penpot", - "version": "1.20.0", + "version": "2.17.0", "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.10.0+sha512.0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8", + "packageManager": "pnpm@11.12.0+sha512.820a6fbd0d9f04c226638002aead1e45340a9139dd5dc077c1d83ef44aa2481c8eb6637b4c9aa696a3c7e35ba818e49cf27213e5f2b91138d09b7a3e26e898ba", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" }, "type": "module", - "scripts": { - "lint": "./scripts/lint", - "check-fmt": "./scripts/check-fmt", - "fmt": "./scripts/fmt" - }, "devDependencies": { + "@playwright/mcp": "^0.0.76", + "@playwright/test": "^1.61.1", "@types/node": "^26.0.1", "esbuild": "^0.28.1", "mdts": "^0.20.3", "nrepl-client": "^0.3.0", - "opencode-ai": "^1.17.11" + "playwright": "^1.61.1" } } diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 323e8319ab..eaae151372 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -6,6 +6,7 @@ ### 🩹 Fixes +- **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing. - **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again. ## 1.5.0 (2026-07-08) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts index 7d3a8ccf0a..32da4f5a59 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts @@ -1,6 +1,6 @@ import { expect } from '../framework/expect'; import { describe, test } from '../framework/registry'; -import type { Text } from '@penpot/plugin-types'; +import type { Board, Text } from '@penpot/plugin-types'; import type { TestContext } from '../framework/types'; import { PNG_1X1 } from './fixtures'; @@ -310,5 +310,59 @@ describe('Library', () => { expect(instance).toBeDefined(); expect(typeof instance.id).toBe('string'); }); + + // Reported (via MCP): create a page-root shape (rectangle / ellipse / text), + // save it as a component, then use it from Assets — the component root was + // said to be a plain shape instead of a board/component. This does not + // reproduce: a lone page-root shape is wrapped in a board and that board + // becomes the component (main-instance) root. Pin the invariant for the + // exact repro. + test('createComponent wraps a page-root shape in a board component root', (ctx) => { + // A genuine page-root shape: created but NOT placed inside any board. + const rect = ctx.penpot.createRectangle(); + const comp = ctx.penpot.library.local.createComponent([rect]); + + const main = comp.mainInstance() as Board; + expect(main.type).toBe('board'); + expect(main.isComponentRoot()).toBeTruthy(); + expect(main.isComponentMainInstance()).toBeTruthy(); + expect(main.children.some((c) => c.id === rect.id)).toBe(true); + + const errors = ctx.penpot.currentFile?.validate() ?? []; + expect(errors.map((e) => e.code)).toEqual([]); + + // The generated main instance lands at the page root (outside ctx.board); + // tuck it under the scratch board so teardown removes it. + ctx.board.appendChild(main); + }); + + // createComponent must reject inputs that cannot form a component rather + // than succeeding silently or crashing. Both cases below currently surface + // an INTERNAL assertion ("Assert failed: (uuid? id)"): the operation is a + // no-op, so the returned LibraryComponent proxy is built from a nil id. It + // should be a clean rejection (or a null return) instead — and in a release + // build, where the assertion is elided, the caller gets a broken component + // proxy pointing at nothing. The file must stay valid regardless. + test('createComponent with no shapes is rejected', (ctx) => { + expect(() => ctx.penpot.library.local.createComponent([])).toThrow(); + const errors = ctx.penpot.currentFile?.validate() ?? []; + expect(errors.map((e) => e.code)).toEqual([]); + }); + + test('createComponent from a shape inside a component copy is rejected', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const source = ctx.penpot.library.local.createComponent([rect]); + const copy = source.instance() as Board; + ctx.board.appendChild(copy); + const copyChild = copy.children[0]; + expect(copyChild).toBeDefined(); + + expect(() => + ctx.penpot.library.local.createComponent([copyChild]), + ).toThrow(); + const errors = ctx.penpot.currentFile?.validate() ?? []; + expect(errors.map((e) => e.code)).toEqual([]); + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b75584b11a..0deb5862af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: devDependencies: + '@playwright/mcp': + specifier: ^0.0.76 + version: 0.0.76 + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@types/node': specifier: ^26.0.1 version: 26.0.1 @@ -20,9 +26,9 @@ importers: nrepl-client: specifier: ^0.3.0 version: 0.3.0 - opencode-ai: - specifier: ^1.17.11 - version: 1.17.11 + playwright: + specifier: ^1.61.1 + version: 1.61.1 packages: @@ -188,6 +194,16 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@playwright/mcp@0.0.76': + resolution: {integrity: sha512-ICcW6wAV+HoNEco19eni+JTVylCIIedruWkrRl2Rsv/qcGhBZLT/c0I1VounjIjSVSupw3pHmcr3CNCikTHTBQ==} + engines: {node: '>=18'} + hasBin: true + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@simple-git/args-pathspec@1.0.3': resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==} @@ -365,6 +381,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -545,75 +566,6 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} - opencode-ai@1.17.11: - resolution: {integrity: sha512-a4331YhfsIiDSve0YRP2eNdlY1iDqhzw979Ky66XWiKDA8jeykfsqGUDAmKYKjS3hLV8U8+krFBuMFVO7p5ptQ==} - cpu: [arm64, x64] - os: [darwin, linux, win32] - hasBin: true - - opencode-darwin-arm64@1.17.11: - resolution: {integrity: sha512-WpBokL8RL8BvdPKzJhQlLbVigz4jT0uESDWgwLcsU2JAP8hOWc/bMgzf87C7VtJlcjUY9ao60UzbPlsUffb/0g==} - cpu: [arm64] - os: [darwin] - - opencode-darwin-x64-baseline@1.17.11: - resolution: {integrity: sha512-hay37M7ALa4/4RrtJDggnOOCL226+cgQQQ0KiBFWNMDhygtwBszQnZmNxWxZtoRNIr3sIIS8JNsuBfhG4OwXYg==} - cpu: [x64] - os: [darwin] - - opencode-darwin-x64@1.17.11: - resolution: {integrity: sha512-ZxQzLT92FT96Y8ahpHZiejD+m7vQYhAfskfzMwc0baDjkctEXy6UkZT5gY5jTDl+Bb74xgmKYJK6Lz20luhOXA==} - cpu: [x64] - os: [darwin] - - opencode-linux-arm64-musl@1.17.11: - resolution: {integrity: sha512-haM+NZ/CC14VdHSi81pRlYDbLvu3sXAQF8HH1t7yi0YpH3wLibq0Ms8prM0809Sza+30pyUIxH6aoYghnr2Juw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - opencode-linux-arm64@1.17.11: - resolution: {integrity: sha512-CN3LSlqSrC1LbYHXZs21B8hIB951ebCRowwC+p4SDwPPUKknRzGYi5V7FjXAp8xq5hx27/QGgmGjfauv6RbAiA==} - cpu: [arm64] - os: [linux] - - opencode-linux-x64-baseline-musl@1.17.11: - resolution: {integrity: sha512-AR3soQ1kYquD+QHaNDcWhHxd6lT8yUZJ2jsnls+7oOTisQWUspi5TpFD5FL4Et1MMtoJx63CAySDTzUpRXUO9Q==} - cpu: [x64] - os: [linux] - libc: [musl] - - opencode-linux-x64-baseline@1.17.11: - resolution: {integrity: sha512-y0sibv7zg0KDLD7hdMCLe4+YYP6t5PQmqTV88KR7jWIU7qvl1hyuIAI84QLCt+7DACpNsFsUfofu6Hib051jZw==} - cpu: [x64] - os: [linux] - - opencode-linux-x64-musl@1.17.11: - resolution: {integrity: sha512-cl2SMRa1c6dJMxvs5BQMy7Q2LVBwGXbLHpjc0OaeMkdORwdBfwCXfW1GNA61pNR4mMHMKrbFEV6dETqCDuIaWQ==} - cpu: [x64] - os: [linux] - libc: [musl] - - opencode-linux-x64@1.17.11: - resolution: {integrity: sha512-at2oODO6N4yMTlvtKOFECVDvj4Nz3iFygmSKyoVdokgpMhYt6l9ta63pEp1jAaTxLpjQGbCzpRYbY/QqRAeB9Q==} - cpu: [x64] - os: [linux] - - opencode-windows-arm64@1.17.11: - resolution: {integrity: sha512-pWOT/Ml4e3S12BQTmw8i+CsQ7QF0kI6Z//9z/N60gLW1U5afVYQpHkT1pT5RVjysPVHP8G4TN7Rbyi8m+fapCQ==} - cpu: [arm64] - os: [win32] - - opencode-windows-x64-baseline@1.17.11: - resolution: {integrity: sha512-RkJX3S77bzFZOHyQsqBqkI567hGHiTGU2TTIkBphYvRNxCQy+ZDjn3QAh+h7zyCHfieeWRKA96kl9ycKaJGO4A==} - cpu: [x64] - os: [win32] - - opencode-windows-x64@1.17.11: - resolution: {integrity: sha512-4ON++fPbRVhLpYvy6j0RolFstU1OncbqZ4FLFeAkC4L6CNO06kHEwmwRfEVcGo+zFpug/ljdW2T0W+sYBw20SA==} - cpu: [x64] - os: [win32] - parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -635,6 +587,26 @@ packages: plantuml@0.0.2: resolution: {integrity: sha512-3YzQJUO1Yg+mDckTm3Ht5Q8bmtN8g3M9LD8fXqiqHDW3vzUpHrUe9lxVY6AT1I50w7FdOned0hhJno4JBIku2g==} + playwright-core@1.61.0-alpha-1781023400000: + resolution: {integrity: sha512-UdtUd9qnCO0zvb8p3OvOZpelY6mA40mTb3NmWGuMtrD+hqqWuorWCPlSGwj7jw/LEB9AxvYLHTL1CJi2flvksg==} + engines: {node: '>=18'} + hasBin: true + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.0-alpha-1781023400000: + resolution: {integrity: sha512-8TRUG3IvwaAhuVm6k3C5vB7CwC5Fxq76DCCxOgPr6r1dpTedDwxlmdOBUkSZ0zxfxP14jcuPxi86/Trq4eA03w==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -864,6 +836,15 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} + '@playwright/mcp@0.0.76': + dependencies: + playwright: 1.61.0-alpha-1781023400000 + playwright-core: 1.61.0-alpha-1781023400000 + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@simple-git/args-pathspec@1.0.3': {} '@simple-git/argv-parser@1.1.1': @@ -1079,6 +1060,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + function-bind@1.1.2: {} get-intrinsic@1.3.0: @@ -1263,57 +1247,6 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 - opencode-ai@1.17.11: - optionalDependencies: - opencode-darwin-arm64: 1.17.11 - opencode-darwin-x64: 1.17.11 - opencode-darwin-x64-baseline: 1.17.11 - opencode-linux-arm64: 1.17.11 - opencode-linux-arm64-musl: 1.17.11 - opencode-linux-x64: 1.17.11 - opencode-linux-x64-baseline: 1.17.11 - opencode-linux-x64-baseline-musl: 1.17.11 - opencode-linux-x64-musl: 1.17.11 - opencode-windows-arm64: 1.17.11 - opencode-windows-x64: 1.17.11 - opencode-windows-x64-baseline: 1.17.11 - - opencode-darwin-arm64@1.17.11: - optional: true - - opencode-darwin-x64-baseline@1.17.11: - optional: true - - opencode-darwin-x64@1.17.11: - optional: true - - opencode-linux-arm64-musl@1.17.11: - optional: true - - opencode-linux-arm64@1.17.11: - optional: true - - opencode-linux-x64-baseline-musl@1.17.11: - optional: true - - opencode-linux-x64-baseline@1.17.11: - optional: true - - opencode-linux-x64-musl@1.17.11: - optional: true - - opencode-linux-x64@1.17.11: - optional: true - - opencode-windows-arm64@1.17.11: - optional: true - - opencode-windows-x64-baseline@1.17.11: - optional: true - - opencode-windows-x64@1.17.11: - optional: true - parseurl@1.3.3: {} path-key@3.1.1: {} @@ -1332,6 +1265,22 @@ snapshots: execa: 4.1.0 get-stream: 5.2.0 + playwright-core@1.61.0-alpha-1781023400000: {} + + playwright-core@1.61.1: {} + + playwright@1.61.0-alpha-1781023400000: + dependencies: + playwright-core: 1.61.0-alpha-1781023400000 + optionalDependencies: + fsevents: 2.3.2 + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + powershell-utils@0.1.0: {} proxy-addr@2.0.7: diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 70887662df..0b506e415d 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -41,7 +41,7 @@ export EMCC_CFLAGS="--no-entry \ -sMAX_WEBGL_VERSION=2 \ -sEXPORT_NAME=createRustSkiaModule \ -sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \ - -sENVIRONMENT=web \ + -sENVIRONMENT=web,worker,node \ -sMODULARIZE=1 \ -sDISABLE_EXCEPTION_CATCHING=1 \ -sFILESYSTEM=0 \ diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index 64e7d8c94f..cd0fcf0bbf 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -4,12 +4,16 @@ use macros::wasm_error; use crate::emscripten::init_gl; use crate::mem; -use crate::render::{gpu_state::GpuState, RenderState}; +use crate::render::{gpu_state::GpuState, RenderResources, RenderState}; use crate::state::{State, TextEditorState, UIState}; static mut DESIGN_STATE: *mut State = std::ptr::null_mut(); +static mut GPU_STATE: *mut GpuState = std::ptr::null_mut(); +static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut(); +static mut UI_STATE: *mut UIState = std::ptr::null_mut(); +static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut(); +static mut RENDER_RESOURCES: *mut RenderResources = std::ptr::null_mut(); -/// Design State. pub(crate) fn get_design_state() -> &'static mut State { unsafe { debug_assert!(!DESIGN_STATE.is_null(), "Design State is null"); @@ -17,20 +21,17 @@ pub(crate) fn get_design_state() -> &'static mut State { } } -/// GPU State. -static mut GPU_STATE: *mut GpuState = std::ptr::null_mut(); - #[inline(always)] pub(crate) fn get_gpu_state() -> &'static mut GpuState { unsafe { - debug_assert!(!GPU_STATE.is_null(), "GPU State is null"); + assert!( + !GPU_STATE.is_null(), + "GPU State is null (headless instance?)" + ); &mut *GPU_STATE } } -/// Render State. -static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut(); - #[inline(always)] pub(crate) fn get_render_state() -> &'static mut RenderState { unsafe { @@ -39,8 +40,18 @@ pub(crate) fn get_render_state() -> &'static mut RenderState { } } -/// Text Editor State -static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut(); +#[inline(always)] +pub(crate) fn has_render_state() -> bool { + unsafe { !RENDER_STATE.is_null() } +} + +#[inline(always)] +pub(crate) fn get_resources() -> &'static mut RenderResources { + unsafe { + debug_assert!(!RENDER_RESOURCES.is_null(), "Render Resources is null"); + &mut *RENDER_RESOURCES + } +} #[inline(always)] pub(crate) fn get_text_editor_state() -> &'static mut TextEditorState { @@ -50,9 +61,6 @@ pub(crate) fn get_text_editor_state() -> &'static mut TextEditorState { } } -/// UI State -static mut UI_STATE: *mut UIState = std::ptr::null_mut(); - #[inline(always)] pub(crate) fn get_ui_state() -> &'static mut UIState { unsafe { @@ -113,6 +121,23 @@ fn render_init(width: i32, height: i32) { } } +/// Initializes the interactive RenderResources (GPU image store). +fn resources_init() { + unsafe { + let resources = RenderResources::try_new().expect("Cannot initialize RenderResources"); + RENDER_RESOURCES = Box::into_raw(Box::new(resources)); + } +} + +/// Initializes GPU-free RenderResources for the headless export path. +fn resources_init_headless() { + unsafe { + let resources = RenderResources::try_new_headless() + .expect("Cannot initialize headless RenderResources"); + RENDER_RESOURCES = Box::into_raw(Box::new(resources)); + } +} + /// Initializes DesignState. fn design_init() { unsafe { @@ -143,6 +168,7 @@ pub extern "C" fn init(width: i32, height: i32) -> Result<()> { #[cfg(target_arch = "wasm32")] init_gl!(); gpu_init(); + resources_init(); render_init(width, height); text_editor_init(); design_init(); @@ -150,6 +176,19 @@ pub extern "C" fn init(width: i32, height: i32) -> Result<()> { Ok(()) } +/// Boots the engine for headless export with no GPU/WebGL context: only +/// `RenderResources` (GPU-free) + the design state. The export (raster/PDF) +/// paths render onto their own surface; the interactive render loop is not +/// available on an instance initialized this way. `width`/`height` are kept for +/// API symmetry with `init` but unused — the export sizes from shape bounds. +#[no_mangle] +#[wasm_error] +pub extern "C" fn init_headless(_width: i32, _height: i32) -> Result<()> { + resources_init_headless(); + design_init(); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn clean_up() -> Result<()> { diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ffa8d34ca3..06ad3caaad 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -22,7 +22,7 @@ use std::collections::HashMap; use crate::error::{Error, Result}; use crate::render::{FrameType, RenderFlag}; -use globals::{get_design_state, get_gpu_state, get_render_state}; +use globals::{get_design_state, get_gpu_state, get_render_state, get_resources, has_render_state}; use macros::wasm_error; use math::{Bounds, Matrix}; @@ -720,7 +720,7 @@ pub extern "C" fn is_image_cached( is_thumbnail: bool, ) -> Result { let id = uuid_from_u32_quartet(a, b, c, d); - let result = get_render_state().has_image(&id, is_thumbnail); + let result = get_resources().images.contains(&id, is_thumbnail); Ok(result) } @@ -734,8 +734,7 @@ pub extern "C" fn set_shape_svg_raw_content() -> Result<()> { .trim_end_matches('\0') .to_string(); - let render_state = get_render_state(); - let font_manager = skia::FontMgr::from(render_state.fonts().font_provider().clone()); + let font_manager = skia::FontMgr::from(get_resources().fonts.font_provider().clone()); shape.set_svg_raw_content(svg_raw_content); shape.update_svg_raw_content(font_manager); }); @@ -1001,6 +1000,36 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) - }) } +/// PNG via CPU raster (no GPU/WebGL). Returns `[len][width][height][png]` (LE), +/// same layout as `render_shape_pixels`. +#[no_mangle] +#[wasm_error] +pub extern "C" fn render_shape_raster( + a: u32, + b: u32, + c: u32, + d: u32, + scale: f32, +) -> Result<*mut u8> { + let id = uuid_from_u32_quartet(a, b, c, d); + + if !scale.is_finite() { + return Err(Error::CriticalError("Scale is not finite".to_string())); + } + + with_state!(state, { + let (data, width, height) = state.render_shape_raster(&id, scale)?; + + let len = data.len() as u32; + let mut buf = Vec::with_capacity(12 + data.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&width.to_le_bytes()); + buf.extend_from_slice(&height.to_le_bytes()); + buf.extend_from_slice(&data); + Ok(mem::write_bytes(buf)) + }) +} + pub fn main() { // Why an empty main? // Right now with the target `wasm32-unknown-emscripten` it is not possible diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 191f4cf234..047aaaf393 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -7,6 +7,8 @@ pub mod grid_layout; mod images; mod options; pub mod pdf; +pub mod raster; +mod resources; mod shadows; pub mod shape_renderer; mod strokes; @@ -34,10 +36,11 @@ use crate::tiles::{self, PendingTiles, TileRect}; use crate::uuid::Uuid; use crate::view::Viewbox; use crate::wapi; -use crate::{get_gpu_state, performance}; +use crate::{get_gpu_state, get_resources, performance}; pub use fonts::*; pub use images::*; +pub(crate) use resources::RenderResources; type ClipStack = Vec<(Rect, Option, Matrix)>; @@ -348,15 +351,12 @@ pub(crate) struct RenderState { pub options: RenderOptions, stats: RenderStats, pub surfaces: Surfaces, - pub fonts: FontStore, pub viewbox: Viewbox, pub cached_viewbox: Viewbox, - pub images: ImageStore, pub background_color: skia::Color, // Stack of nodes pending to be rendered. pending_nodes: Vec, pub current_tile: Option, - pub sampling_options: skia::SamplingOptions, pub render_area: Rect, // render_area expanded by surface margins — used for visibility checks so that // shapes in the margin zone are rendered (needed for background blur sampling). @@ -546,19 +546,18 @@ impl RenderState { pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. - let sampling_options = - skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest); + let sampling_options = get_resources().sampling_options; - let fonts = FontStore::try_new()?; let surfaces = Surfaces::try_new( (width, height), sampling_options, tiles::get_tile_dimensions(), )?; - // This is used multiple times everywhere so instead of creating new instances every - // time we reuse this one. + Self::assemble(width, height, surfaces) + } + fn assemble(width: i32, height: i32, surfaces: Surfaces) -> Result { let viewbox = Viewbox::new(width as f32, height as f32); let tiles = tiles::TileHashMap::new(); let options = RenderOptions::default(); @@ -567,14 +566,11 @@ impl RenderState { options, stats: RenderStats::new(), surfaces, - fonts, viewbox, cached_viewbox: Viewbox::new(0., 0.), - images: ImageStore::new(), background_color: skia::Color::TRANSPARENT, pending_nodes: vec![], current_tile: None, - sampling_options, render_area: Rect::new_empty(), render_area_with_margins: Rect::new_empty(), tiles, @@ -660,8 +656,7 @@ impl RenderState { if self.options.is_fast_mode() { return; } - if matches!(shape.shape_type, Type::Text(_)) || matches!(shape.shape_type, Type::SVGRaw(_)) - { + if matches!(shape.shape_type, Type::SVGRaw(_)) { return; } let blur = match shape.visible_background_blur() { @@ -707,6 +702,40 @@ impl RenderState { canvas.translate(translation); canvas.concat(&matrix); + if matches!(shape.shape_type, Type::Text(_)) { + canvas.clip_rect(shape.selrect, skia::ClipOp::Intersect, true); + // Blur in device space + canvas.reset_matrix(); + + // Save the layer rect to the shape's selection rect + // so the blur is only applied inside the glyphs. + let layer_rec = skia::canvas::SaveLayerRec::default() + .backdrop(&blur_filter) + .backdrop_tile_mode(skia::TileMode::Clamp); + + canvas.save_layer(&layer_rec); + + // Reset + canvas.scale((scale, scale)); + canvas.translate(translation); + canvas.concat(&matrix); + + // Keep the blurred backdrop only where the glyphs are + let mut mask_paint = skia::Paint::default(); + mask_paint.set_blend_mode(skia::BlendMode::DstIn); + let mask_layer_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); + canvas.save_layer(&mask_layer_rec); + + text::paint_text_mask(canvas, shape); + + // Restore the mask layer, then the blur layer, + // then the shape transform + canvas.restore(); + canvas.restore(); + canvas.restore(); + return; + } + // Clip to shape's path based on shape type match &shape.shape_type { Type::Rect(data) if data.corners.is_some() => { @@ -778,35 +807,6 @@ impl RenderState { Ok(result) } - pub fn fonts(&self) -> &FontStore { - &self.fonts - } - - pub fn fonts_mut(&mut self) -> &mut FontStore { - &mut self.fonts - } - - pub fn add_image(&mut self, id: Uuid, is_thumbnail: bool, image_data: &[u8]) -> Result<()> { - self.images.add(id, is_thumbnail, image_data) - } - - /// Adds an image from an existing WebGL texture, avoiding re-decoding - pub fn add_image_from_gl_texture( - &mut self, - id: Uuid, - is_thumbnail: bool, - texture_id: u32, - width: i32, - height: i32, - ) -> Result<()> { - self.images - .add_image_from_gl_texture(id, is_thumbnail, texture_id, width, height) - } - - pub fn has_image(&self, id: &Uuid, is_thumbnail: bool) -> bool { - self.images.contains(id, is_thumbnail) - } - pub fn set_debug_flags(&mut self, debug: u32) { self.options.flags = debug; } @@ -821,7 +821,7 @@ impl RenderState { self.viewbox.width().floor() as i32, self.viewbox.height().floor() as i32, )?; - self.fonts.set_scale_debug_font(dpr); + get_resources().fonts.set_scale_debug_font(dpr); self.viewbox.set_dpr(dpr); self.surfaces.set_dpr(dpr); } @@ -969,7 +969,7 @@ impl RenderState { text_paint.set_color(skia::Color::GRAY); text_paint.set_anti_alias(true); - let font = self.fonts.debug_font(); + let font = get_resources().fonts.debug_font(); // FIXME let text = "Loading…"; let (text_width, _) = font.measure_str(text, None); @@ -1398,7 +1398,7 @@ impl RenderState { } match &shape.shape_type { - Type::SVGRaw(_) => { + Type::SVGRaw(sr) => { if let Some(svg_transform) = shape.svg_transform() { matrix.pre_concat(&svg_transform); } @@ -1410,7 +1410,18 @@ impl RenderState { if let Some(svg) = shape.svg.as_ref() { svg.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id)); } else { - panic!("SVG should be available"); + let font_manager = + skia::FontMgr::from(get_resources().fonts.font_provider().clone()); + let dom_result = skia::svg::Dom::from_str(&sr.content, font_manager); + match dom_result { + Ok(dom) => { + dom.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id)); + shape.to_mut().set_svg(dom); + } + Err(e) => { + eprintln!("Error parsing SVG. Error: {}", e); + } + } } } @@ -2987,7 +2998,7 @@ impl RenderState { surface.draw( drop_canvas, (0.0, 0.0), - self.sampling_options, + get_resources().sampling_options, Some(&drop_paint), ); drop_canvas.restore(); @@ -2997,7 +3008,7 @@ impl RenderState { surface.draw( drop_canvas, (0.0, 0.0), - self.sampling_options, + get_resources().sampling_options, Some(&drop_paint), ); drop_canvas.restore(); diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 0574e3ab28..d661fd2bd1 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -6,6 +6,7 @@ use macros::wasm_error; #[cfg(target_arch = "wasm32")] use crate::get_render_state; +use crate::get_resources; use skia_safe::{self as skia, Rect}; @@ -62,16 +63,16 @@ pub fn render_wasm_label(render_state: &mut RenderState) { "WebGL rendering" }; let dpr = render_state.options.dpr; - let (scalar, _) = render_state.fonts.debug_font().measure_str(str, None); + let (scalar, _) = get_resources().fonts.debug_font().measure_str(str, None); let mut p = skia::Point::new(canvas_width - (20.0 * dpr) - scalar, 50.0 * dpr); - let debug_font = render_state.fonts.debug_font(); + let debug_font = get_resources().fonts.debug_font(); canvas.draw_str(str, p, debug_font, &paint); if render_state.options.is_text_editor_v3() { str = "Text Editor v3"; - let (scalar, _) = render_state.fonts.debug_font().measure_str(str, None); + let (scalar, _) = get_resources().fonts.debug_font().measure_str(str, None); p.x = canvas_width - (20.0 * dpr) - scalar; p.y += 15.0 * dpr; canvas.draw_str(str, p, debug_font, &paint); @@ -85,7 +86,7 @@ pub fn render_debug_tiles_for_viewbox(render_state: &mut RenderState) { paint.set_color(skia::Color::RED); let str_rect = format!("{} {} {} {}", sx, sy, ex, ey); - let debug_font = render_state.fonts.debug_font(); + let debug_font = get_resources().fonts.debug_font(); canvas.draw_str(str_rect, skia::Point::new(100.0, 150.0), debug_font, &paint); } @@ -104,7 +105,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) { let str_rect = format!("{} {} {} {}", sx, sy, ex, ey); - let debug_font = render_state.fonts.debug_font(); + let debug_font = get_resources().fonts.debug_font(); canvas.draw_str(str_rect, skia::Point::new(100.0, 100.0), debug_font, &paint); let tile_size = tiles::get_tile_size(scale); @@ -114,7 +115,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) { let debug_rect = get_debug_rect(rect); let p = skia::Point::new(debug_rect.x(), debug_rect.y() - 1.); let str = format!("{}:{}", x, y); - let debug_font = render_state.fonts.debug_font(); + let debug_font = get_resources().fonts.debug_font(); paint.set_style(skia::PaintStyle::Fill); canvas.draw_str(str, p, debug_font, &paint); canvas.draw_rect(debug_rect, &paint); @@ -153,7 +154,7 @@ pub fn render_workspace_current_tile( let tile_position_origin = skia::Point::new(rect.x() + 10., rect.y() + 20.); p.set_style(skia::PaintStyle::Fill); let str = format!("{prefix} {}:{}", tile.x(), tile.y()); - let mut debug_font = render_state.fonts.debug_font().clone(); + let mut debug_font = get_resources().fonts.debug_font().clone(); debug_font.set_size(16.); canvas.draw_str(str, tile_position_origin, &debug_font, &p); } diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index 45f8fc3048..d7010cc2e5 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -2,40 +2,17 @@ use skia_safe::{self as skia, Paint, RRect}; use super::{filters, RenderState, SurfaceId}; use crate::error::Result; +use crate::get_resources; use crate::render::get_source_rect; use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type}; -fn draw_image_fill( - render_state: &mut RenderState, +// Set the clipping area to the shape outline within the container bounds +fn clip_to_shape( + canvas: &skia::Canvas, shape: &Shape, - image_fill: &ImageFill, - paint: &Paint, + container: &crate::math::Rect, antialias: bool, - surface_id: SurfaceId, ) { - let Some(image) = render_state.images.get(&image_fill.id()) else { - return; - }; - - let size = image.dimensions(); - let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); - let container = &shape.selrect; - let path_transform = shape.to_path_transform(); - - let src_rect = get_source_rect(size, container, image_fill); - let dest_rect = container; - - let mut image_paint = skia::Paint::default(); - image_paint.set_anti_alias(antialias); - if let Some(filter) = shape.image_filter(1.) { - image_paint.set_image_filter(filter.clone()); - } - - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&image_paint); - // Save the current canvas state - canvas.save_layer(&layer_rec); - - // Set the clipping rectangle to the container bounds match &shape.shape_type { Type::Rect(Rect { corners: Some(corners), @@ -60,7 +37,7 @@ fn draw_image_fill( } shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(path) = shape_type.path() { - if let Some(path_transform) = path_transform { + if let Some(path_transform) = shape.to_path_transform() { canvas.clip_path( &path .to_skia_path(shape.svg_attrs.as_ref()) @@ -77,13 +54,56 @@ fn draw_image_fill( Type::Group(_) => unreachable!("A group should not have fills"), Type::Text(_) => unimplemented!("TODO"), } +} + +fn draw_image_fill( + render_state: &mut RenderState, + shape: &Shape, + image_fill: &ImageFill, + paint: &Paint, + antialias: bool, + surface_id: SurfaceId, +) { + if draw_svg_image_fill( + render_state, + shape, + image_fill, + paint, + antialias, + surface_id, + ) { + return; + } + + let Some(image) = get_resources().images.get(&image_fill.id()) else { + return; + }; + + let size = image.dimensions(); + let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); + let container = &shape.selrect; + + let src_rect = get_source_rect(size, container, image_fill); + let dest_rect = container; + + let mut image_paint = skia::Paint::default(); + image_paint.set_anti_alias(antialias); + if let Some(filter) = shape.image_filter(1.) { + image_paint.set_image_filter(filter.clone()); + } + + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&image_paint); + // Save the current canvas state + canvas.save_layer(&layer_rec); + + clip_to_shape(canvas, shape, container, antialias); // Draw the image with the calculated destination rectangle canvas.draw_image_rect_with_sampling_options( image, Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), dest_rect, - render_state.sampling_options, + get_resources().sampling_options, paint, ); @@ -91,6 +111,63 @@ fn draw_image_fill( canvas.restore(); } +// Draws an SVG image fill from its parsed DOM. The canvas transform carries +// the current zoom, so the SVG rasterizes at display resolution instead of +// being stretched from a fixed-size texture. Returns false when the stored +// image is not an SVG. +fn draw_svg_image_fill( + render_state: &mut RenderState, + shape: &Shape, + image_fill: &ImageFill, + paint: &Paint, + antialias: bool, + surface_id: SurfaceId, +) -> bool { + let Some((dom, size)) = get_resources().images.get_svg(&image_fill.id()) else { + return false; + }; + + let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); + let container = &shape.selrect; + let size = skia::ISize::new(size.width as i32, size.height as i32); + let src_rect = get_source_rect(size, container, image_fill); + if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 { + return true; + } + + let mut image_paint = skia::Paint::default(); + image_paint.set_anti_alias(antialias); + if let Some(filter) = shape.image_filter(1.) { + image_paint.set_image_filter(filter.clone()); + } + + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&image_paint); + canvas.save_layer(&layer_rec); + + clip_to_shape(canvas, shape, container, antialias); + + // Apply the fill paint (opacity/blend) to the whole SVG as one layer. + let fill_layer = skia::canvas::SaveLayerRec::default().paint(paint); + canvas.save_layer(&fill_layer); + + // Map the cropped source rect onto the container: cover semantics when + // keep-aspect-ratio is set, stretch otherwise (same math as the raster + // path, expressed as a canvas transform). + let scale_x = container.width() / src_rect.width(); + let scale_y = container.height() / src_rect.height(); + canvas.translate(( + container.left - src_rect.left * scale_x, + container.top - src_rect.top * scale_y, + )); + canvas.scale((scale_x, scale_y)); + dom.render(canvas); + + canvas.restore(); + canvas.restore(); + + true +} + /** * This SHOULD be the only public function in this module. */ diff --git a/render-wasm/src/render/filters.rs b/render-wasm/src/render/filters.rs index 34b33f403d..b4313a043d 100644 --- a/render-wasm/src/render/filters.rs +++ b/render-wasm/src/render/filters.rs @@ -2,6 +2,7 @@ use skia_safe::{self as skia, ImageFilter, Rect}; use super::{RenderState, SurfaceId}; use crate::error::Result; +use crate::get_resources; /// Composes two image filters, returning a combined filter if both are present, /// or the individual filter if only one is present, or None if neither is present. @@ -51,12 +52,12 @@ where canvas.save(); canvas.scale((1.0 / scale, 1.0 / scale)); canvas.translate((bounds.left * scale, bounds.top * scale)); - surface.draw(canvas, (0.0, 0.0), render_state.sampling_options, None); + surface.draw(canvas, (0.0, 0.0), get_resources().sampling_options, None); canvas.restore(); } else { canvas.save(); canvas.translate((bounds.left, bounds.top)); - surface.draw(canvas, (0.0, 0.0), render_state.sampling_options, None); + surface.draw(canvas, (0.0, 0.0), get_resources().sampling_options, None); canvas.restore(); } Ok(true) diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index b567829ab7..a431ae5e4b 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -5,7 +5,7 @@ use crate::uuid::Uuid; use crate::error::Result; use crate::get_gpu_state; use skia_safe::gpu::{surfaces, Budgeted, DirectContext}; -use skia_safe::{self as skia, Codec, ISize}; +use skia_safe::{self as skia, Codec, ISize, Size}; use std::collections::HashMap; pub type Image = skia::Image; @@ -57,11 +57,19 @@ pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill enum StoredImage { Raw(Vec), Gpu(Image), + Svg { + dom: skia::svg::Dom, + size: Size, + // Lazy raster for consumers that need a texture (stroke fills, + // exports). The shape fill path draws the DOM directly instead. + raster: Option, + }, } pub struct ImageStore { images: HashMap<(Uuid, bool), StoredImage>, - context: Box, + /// gpu-only + context: Option>, } /// Creates a Skia image from an existing WebGL texture. @@ -143,13 +151,78 @@ fn decode_image(context: &mut Box, raw_data: &[u8]) -> Option Option<(skia::svg::Dom, Size)> { + // An empty font manager: elements inside SVG image fills won't + // resolve typefaces. Wire the render state's font provider here if that + // ever becomes a need. + let font_mgr = skia::FontMgr::new(); + let mut dom = skia::svg::Dom::from_bytes(raw_data, font_mgr).ok()?; + + let mut size = dom.root().intrinsic_size(); + if size.is_empty() { + // SVGs without width/height attributes have no intrinsic size; + // fall back to the viewBox dimensions. + size = dom + .root() + .view_box() + .map(|vb| Size::new(vb.width(), vb.height())) + .unwrap_or_else(|| Size::new(DEFAULT_SVG_SIZE, DEFAULT_SVG_SIZE)); + } + + // Ceil so the size matches the integer dimensions used when rasterizing. + let size = Size::new(size.width.ceil(), size.height.ceil()); + if size.is_empty() { + return None; + } + dom.set_container_size(size); + + Some((dom, size)) +} + +fn rasterize_svg( + context: &mut Box, + dom: &skia::svg::Dom, + size: Size, +) -> Option { + let dimensions = ISize::new(size.width as i32, size.height as i32); + let image_info = skia::ImageInfo::new_n32_premul(dimensions, None); + let mut surface = surfaces::render_target( + context, + Budgeted::Yes, + &image_info, + None, + None, + None, + true, + false, + )?; + + dom.render(surface.canvas()); + Some(surface.image_snapshot()) +} + impl ImageStore { pub fn new() -> Self { let gpu_state = get_gpu_state(); let context = &gpu_state.context; Self { images: HashMap::with_capacity(2048), - context: Box::new(context.clone()), + context: Some(Box::new(context.clone())), + } + } + + /// GPU-free image store for the headless export path: no GPU context, so + /// images are kept as encoded bytes and decoded on the CPU at draw time + /// (see `get_cpu_image`). + pub fn new_without_gpu() -> Self { + Self { + images: HashMap::with_capacity(16), + context: None, } } @@ -167,10 +240,41 @@ impl ImageStore { let raw_data = image_data.to_vec(); - if let Some(gpu_image) = decode_image(&mut self.context, &raw_data) { - self.images.insert(key, StoredImage::Gpu(gpu_image)); - } else { - self.images.insert(key, StoredImage::Raw(raw_data)); + match self.context.as_mut() { + Some(context) => { + if let Some(gpu_image) = decode_image(context, &raw_data) { + self.images.insert(key, StoredImage::Gpu(gpu_image)); + } else if let Some((dom, size)) = parse_svg(&raw_data) { + self.images.insert( + key, + StoredImage::Svg { + dom, + size, + raster: None, + }, + ); + } else { + // The lazy re-decode in `get_internal` only retries raster codecs, + // so SVGs that fail to parse here stay raw. + self.images.insert(key, StoredImage::Raw(raw_data)); + } + } + // GPU-free: keep the encoded bytes; decoded on the CPU at draw time. + // SVGs still get parsed up front since that needs no GPU context. + None => { + if let Some((dom, size)) = parse_svg(&raw_data) { + self.images.insert( + key, + StoredImage::Svg { + dom, + size, + raster: None, + }, + ); + } else { + self.images.insert(key, StoredImage::Raw(raw_data)); + } + } } Ok(()) } @@ -193,7 +297,12 @@ impl ImageStore { } // Create a Skia image from the existing GL texture - let image = create_image_from_gl_texture(&mut self.context, texture_id, width, height)?; + let Some(context) = self.context.as_mut() else { + return Err(crate::error::Error::CriticalError( + "Cannot register a GL texture without a GPU context".to_string(), + )); + }; + let image = create_image_from_gl_texture(context, texture_id, width, height)?; self.images.insert(key, StoredImage::Gpu(image)); Ok(()) @@ -214,8 +323,48 @@ impl ImageStore { } pub fn get_cpu_image(&mut self, id: &Uuid) -> Option { - let gpu_image = self.get(id)?.clone(); - gpu_image.make_non_texture_image(self.context.as_mut()) + // GPU path: promote to a texture, then copy to a CPU image. + if self.context.is_some() { + let gpu_image = self.get(id)?.clone(); + let context = self.context.as_mut()?; + return gpu_image.make_non_texture_image(context.as_mut()); + } + // Headless (no GPU context): decode the stored encoded bytes directly to + // a CPU image, which draws fine on a raster/PDF canvas. Try full first, + // then thumbnail. + self.decode_raw_cpu_image(id, false) + .or_else(|| self.decode_raw_cpu_image(id, true)) + } + + fn decode_raw_cpu_image(&self, id: &Uuid, is_thumbnail: bool) -> Option { + match self.images.get(&(*id, is_thumbnail))? { + StoredImage::Raw(raw_data) => { + let data = unsafe { skia::Data::new_bytes(raw_data) }; + Image::from_encoded(&data) + } + StoredImage::Gpu(img) => Some(img.clone()), + StoredImage::Svg { dom, size, .. } => { + // No GPU context in the headless path: rasterize on a CPU + // surface instead of `rasterize_svg` (which needs one). + let dimensions = ISize::new(size.width as i32, size.height as i32); + let mut surface = skia::surfaces::raster_n32_premul(dimensions)?; + dom.render(surface.canvas()); + Some(surface.image_snapshot()) + } + } + } + + /// Vector access for SVG images: the fill render path draws the DOM + /// directly so it stays crisp at any zoom level. + pub fn get_svg(&self, id: &Uuid) -> Option<(&skia::svg::Dom, Size)> { + let entry = self + .images + .get(&(*id, false)) + .or_else(|| self.images.get(&(*id, true)))?; + match entry { + StoredImage::Svg { dom, size, .. } => Some((dom, *size)), + _ => None, + } } fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> { @@ -225,7 +374,8 @@ impl ImageStore { match entry { StoredImage::Gpu(ref img) => Some(img), StoredImage::Raw(raw_data) => { - let gpu_image = decode_image(&mut self.context, raw_data)?; + let context = self.context.as_mut()?; + let gpu_image = decode_image(context, raw_data)?; *entry = StoredImage::Gpu(gpu_image); if let StoredImage::Gpu(ref img) = entry { @@ -234,6 +384,13 @@ impl ImageStore { None } } + StoredImage::Svg { dom, size, raster } => { + if raster.is_none() { + let context = self.context.as_mut()?; + *raster = rasterize_svg(context, dom, *size); + } + raster.as_ref() + } } } else { None diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 7a6a3a3d39..fed66505fe 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -69,6 +69,7 @@ impl RenderOptions { self.fast_mode = enabled; } + #[cfg(target_arch = "wasm32")] pub fn set_capture_frames(&mut self, capture_frames: i32) { self.capture_frames = capture_frames; } diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index 551a451c92..c61fef322b 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -4,8 +4,8 @@ use crate::error::Result; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; -use super::vector::{self, VectorTarget}; -use super::RenderState; +use super::vector; +use super::RenderResources; /// Renders a shape tree to a PDF document and returns the raw PDF bytes. /// @@ -16,7 +16,7 @@ use super::RenderState; /// pixel-based (blur, shadows with blur) are rasterised internally by Skia's /// PDF backend pub fn render_to_pdf( - shared: &mut RenderState, + shared: &mut RenderResources, id: &Uuid, tree: ShapesPoolRef, scale: f32, @@ -24,7 +24,15 @@ pub fn render_to_pdf( let shape = tree .get(id) .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?; - let bounds = shape.extrect(tree, scale); + // Boards export at their own bounds (like the classic exporter, which + // screenshots exactly the board box): content overflowing a board lands + // outside the page/surface and is cropped. Other shapes keep the extended + // rect so their own shadows/blur are included. + let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) { + shape.selrect() + } else { + shape.extrect(tree, scale) + }; let page_w = bounds.width() * scale; let page_h = bounds.height() * scale; @@ -45,7 +53,7 @@ pub fn render_to_pdf( let page_canvas = on_page.canvas(); page_canvas.scale((scale, scale)); page_canvas.translate((-bounds.left(), -bounds.top())); - vector::render_tree(shared, page_canvas, id, tree, scale, VectorTarget::Pdf)?; + vector::render_tree_pdf(shared, page_canvas, id, tree, scale, bounds)?; } let document = on_page.end_page(); diff --git a/render-wasm/src/render/raster.rs b/render-wasm/src/render/raster.rs new file mode 100644 index 0000000000..34692d6ab4 --- /dev/null +++ b/render-wasm/src/render/raster.rs @@ -0,0 +1,59 @@ +use skia_safe as skia; + +use crate::error::{Error, Result}; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +use super::vector; +use super::RenderResources; + +/// Renders a shape tree to PNG bytes on a CPU raster surface (no GPU/WebGL). +/// Returns `(png_bytes, width_px, height_px)`. +pub fn render_to_raster( + shared: &mut RenderResources, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, +) -> Result<(Vec, i32, i32)> { + let Some(shape) = tree.get(id) else { + return Ok((Vec::new(), 0, 0)); + }; + // Boards export at their own bounds (like the classic exporter, which + // screenshots exactly the board box): content overflowing a board lands + // outside the page/surface and is cropped. Other shapes keep the extended + // rect so their own shadows/blur are included. + let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) { + shape.selrect() + } else { + shape.extrect(tree, scale) + }; + + let width = (bounds.width() * scale).ceil() as i32; + let height = (bounds.height() * scale).ceil() as i32; + if width <= 0 || height <= 0 { + return Ok((Vec::new(), 0, 0)); + } + + let mut surface = skia::surfaces::raster_n32_premul((width, height)).ok_or_else(|| { + Error::CriticalError("Failed to create raster export surface".to_string()) + })?; + + { + let canvas = surface.canvas(); + canvas.clear(skia::Color::TRANSPARENT); + canvas.scale((scale, scale)); + canvas.translate((-bounds.left(), -bounds.top())); + vector::render_tree(shared, canvas, id, tree, scale, bounds)?; + } + + let data = surface + .image_snapshot() + .encode( + None::<&mut skia::gpu::DirectContext>, + skia::EncodedImageFormat::PNG, + 100, + ) + .ok_or_else(|| Error::CriticalError("PNG encode failed".to_string()))?; + + Ok((data.as_bytes().to_vec(), width, height)) +} diff --git a/render-wasm/src/render/resources.rs b/render-wasm/src/render/resources.rs new file mode 100644 index 0000000000..4ca9761809 --- /dev/null +++ b/render-wasm/src/render/resources.rs @@ -0,0 +1,43 @@ +use skia_safe as skia; + +use crate::error::Result; + +use super::{FontStore, ImageStore}; + +/// Render dependencies that the export (PDF) path needs independently of the +/// surface/tile/GPU machinery in [`RenderState`]: the font store, the +/// decoded-image store, and the default sampling options. +/// +/// Splitting these out keeps the vector/PDF export path (which takes +/// `&mut RenderResources`) decoupled from the interactive renderer, and gives a +/// single source of truth reached through `get_resources()`. +pub(crate) struct RenderResources { + pub fonts: FontStore, + pub images: ImageStore, + pub sampling_options: skia::SamplingOptions, +} + +impl RenderResources { + pub fn try_new() -> Result { + Ok(Self { + fonts: FontStore::try_new()?, + images: ImageStore::new(), + sampling_options: skia::SamplingOptions::new( + skia::FilterMode::Linear, + skia::MipmapMode::Nearest, + ), + }) + } + + /// Headless export resources: CPU-only image store, no GPU/WebGL. + pub fn try_new_headless() -> Result { + Ok(Self { + fonts: FontStore::try_new()?, + images: ImageStore::new_without_gpu(), + sampling_options: skia::SamplingOptions::new( + skia::FilterMode::Linear, + skia::MipmapMode::Nearest, + ), + }) + } +} diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 8221cad4b6..b5a5a93518 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -8,6 +8,7 @@ use skia_safe::{self as skia, ImageFilter, RRect}; use super::{filters, RenderState, SurfaceId}; use crate::error::{Error, Result}; +use crate::get_resources; use crate::render::filters::compose_filters; use crate::render::{get_dest_rect, get_source_rect}; @@ -467,7 +468,7 @@ fn draw_image_stroke_in_container( surface_id: SurfaceId, ) -> Result<()> { let scale = render_state.get_scale(); - let Some(image) = render_state.images.get(&image_fill.id()) else { + let Some(image) = get_resources().images.get(&image_fill.id()) else { return Ok(()); }; @@ -575,7 +576,7 @@ fn draw_image_stroke_in_container( image, Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), dest_rect, - render_state.sampling_options, + get_resources().sampling_options, &image_paint, ); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index dc91011abc..e68c0df0c3 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -106,6 +106,7 @@ impl DocAtlas { }) } + // TODO: delete one docatlas is optional pub fn is_empty(&self) -> bool { self.size.width <= 0 || self.size.height <= 0 } diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index d972e69813..4f1307e6eb 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -396,6 +396,12 @@ fn paint_text( paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false); } +/// Alpha mask for background blur coverage +pub fn paint_text_mask(canvas: &Canvas, shape: &Shape) { + let mut mask_builders = shape.get_text_content().paragraph_builder_group_opaque(); + paint_text(canvas, shape, &mut mask_builders); +} + fn paint_text_with_emoji_overlay( canvas: &Canvas, shape: &Shape, diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index 19854df768..4ba350ba1a 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -1,6 +1,7 @@ use skia_safe::{self as skia, Color4f}; use super::{RenderState, ShapesPoolRef, SurfaceId}; +use crate::get_resources; use crate::globals::get_ui_state; use crate::render::grid_layout; use crate::shapes::{Layout, Type}; @@ -67,7 +68,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { rulers::render( canvas, viewbox, - &render_state.fonts, + &get_resources().fonts, get_ui_state().ruler_state(), ); diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 04d91552d9..cf17a55b8e 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -9,19 +9,9 @@ use crate::uuid::Uuid; use super::shape_renderer::ShapeRenderer; use super::text; -use super::RenderState; +use super::RenderResources; use super::{get_dest_rect, get_source_rect}; -// --------------------------------------------------------------------------- -// VectorTarget — vector export backend selector -// --------------------------------------------------------------------------- - -/// Vector export backend selector (PDF today; SVG could be added as a variant). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum VectorTarget { - Pdf, -} - // --------------------------------------------------------------------------- // VectorRenderer — implements ShapeRenderer for canvas-based vector export // --------------------------------------------------------------------------- @@ -29,23 +19,16 @@ pub(super) enum VectorTarget { /// Canvas-based vector render backend (CPU Skia canvas, no GPU surfaces). pub(super) struct VectorRenderer<'a> { canvas: &'a Canvas, - shared: &'a mut RenderState, + shared: &'a mut RenderResources, scale: f32, - _target: VectorTarget, } impl<'a> VectorRenderer<'a> { - pub fn new( - canvas: &'a Canvas, - shared: &'a mut RenderState, - scale: f32, - target: VectorTarget, - ) -> Self { + pub fn new(canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32) -> Self { Self { canvas, shared, scale, - _target: target, } } } @@ -364,15 +347,88 @@ impl ShapeRenderer for VectorRenderer<'_> { // Tree traversal // --------------------------------------------------------------------------- -/// Depth-first render of the shape tree rooted at `id`. +/// Options threaded through the whole traversal. Carries the export root/page +/// (needed to re-render the backdrop for background blur) and the background +/// blur strategy for the active canvas. +struct TreeOpts<'a> { + /// The exported root shape id — the backdrop for any shape is re-rendered + /// from here. + root: &'a Uuid, + /// Root's export bounds (shape space); sizes/positions the offscreen + /// backdrop surface to match the page. + page: skia::Rect, + /// `true` on a PDF canvas (which ignores Skia backdrop filters): background + /// blur is produced by rendering the backdrop onto an offscreen raster + /// surface and embedding the blurred result as an image. `false` on a raster + /// canvas, where a plain Skia backdrop filter works directly. + embed_bg_blur: bool, + /// When rendering a backdrop, the shape whose own subtree must be omitted + /// (so the blur samples only what is *behind* it). + skip: Option<&'a Uuid>, +} + +/// Depth-first render of the shape tree rooted at `id`. Used for raster export +/// (PNG/webp), where background blur is applied with a direct Skia backdrop +/// filter (raster surfaces support it). `page` must be the same bounds the +/// caller used to size/translate its surface — the embedded background-blur +/// backdrop is drawn device-aligned against it. pub(super) fn render_tree( - shared: &mut RenderState, + shared: &mut RenderResources, canvas: &Canvas, id: &Uuid, tree: ShapesPoolRef, scale: f32, - target: VectorTarget, + page: skia::Rect, ) -> Result<()> { + render_tree_dispatch(shared, canvas, id, tree, scale, page, false) +} + +/// Like [`render_tree`], but for a PDF canvas: background blur is embedded as a +/// rasterised image (the PDF backend ignores backdrop filters). +pub(super) fn render_tree_pdf( + shared: &mut RenderResources, + canvas: &Canvas, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + page: skia::Rect, +) -> Result<()> { + render_tree_dispatch(shared, canvas, id, tree, scale, page, true) +} + +#[allow(clippy::too_many_arguments)] +fn render_tree_dispatch( + shared: &mut RenderResources, + canvas: &Canvas, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + page: skia::Rect, + embed_bg_blur: bool, +) -> Result<()> { + let opts = TreeOpts { + root: id, + page, + embed_bg_blur, + skip: None, + }; + render_tree_inner(shared, canvas, id, tree, scale, &opts) +} + +fn render_tree_inner( + shared: &mut RenderResources, + canvas: &Canvas, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + opts: &TreeOpts, +) -> Result<()> { + // When re-rendering a backdrop, omit the blur shape's own subtree so it only + // samples what is behind it. + if opts.skip == Some(id) { + return Ok(()); + } + let Some(element) = tree.get(id) else { return Ok(()); }; @@ -381,12 +437,27 @@ pub(super) fn render_tree( return Ok(()); } + // Background blur samples already-drawn content behind the shape, so it must + // run before the shape (and its subtree) paints. Text/SVGRaw are excluded, + // matching the GPU path. + if !matches!(element.shape_type, Type::Text(_) | Type::SVGRaw(_)) { + if let Some(blur) = element.visible_background_blur() { + if blur.value > 0.0 { + if opts.embed_bg_blur { + render_background_blur_image(shared, canvas, element, tree, scale, opts)?; + } else { + render_background_blur_backdrop(canvas, element, blur.value * scale); + } + } + } + } + match &element.shape_type { Type::Group(group) => { - render_group(shared, canvas, element, group.masked, tree, scale, target)?; + render_group(shared, canvas, element, group.masked, tree, scale, opts)?; } Type::Frame(_) => { - render_frame(shared, canvas, element, tree, scale, target)?; + render_frame(shared, canvas, element, tree, scale, opts)?; } // Leaf types listed explicitly (no `_`) so a new Type must be handled. Type::Rect(_) @@ -395,25 +466,135 @@ pub(super) fn render_tree( | Type::Bool(_) | Type::Text(_) | Type::SVGRaw(_) => { - render_leaf(shared, canvas, element, scale, target)?; + render_leaf(shared, canvas, element, scale)?; } } Ok(()) } +// --------------------------------------------------------------------------- +// Background blur +// --------------------------------------------------------------------------- + +/// Background blur on a canvas that supports Skia backdrop filters (raster). +/// Blurs the current device contents within the shape silhouette and stamps the +/// result back with `Src`. `sigma_radius` is the blur radius already multiplied +/// by the export scale. +fn render_background_blur_backdrop(canvas: &Canvas, shape: &Shape, sigma_radius: f32) { + let sigma = radius_to_sigma(sigma_radius); + let Some(blur_filter) = + skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None) + else { + return; + }; + + let matrix = shape.centered_transform(); + canvas.save(); + canvas.concat(&matrix); + clip_to_shape(canvas, shape, true); + // Apply the blur in device space (sigma already includes the export scale); + // the clip, set with the full transform, survives reset_matrix. + canvas.reset_matrix(); + + let mut paint = Paint::default(); + paint.set_blend_mode(skia::BlendMode::Src); + let layer_rec = skia::canvas::SaveLayerRec::default() + .backdrop(&blur_filter) + .backdrop_tile_mode(skia::TileMode::Clamp) + .paint(&paint); + canvas.save_layer(&layer_rec); + canvas.restore(); // composite the blurred-backdrop layer + canvas.restore(); // pop the clip + transform +} + +/// Background blur for a PDF canvas (backdrop filters unsupported): render the +/// backdrop — the whole page minus this shape's own subtree — onto an offscreen +/// raster surface, blur it, and embed the result as an image clipped to the +/// shape. The rest of the page stays vector. +/// +/// LIMITATION: the backdrop omits only this shape's subtree, not shapes painted +/// *after* it. For content stacked on top of the blur shape the foreground would +/// bleed into the blur; correct for the common case (nothing above the panel). +fn render_background_blur_image( + shared: &mut RenderResources, + canvas: &Canvas, + shape: &Shape, + tree: ShapesPoolRef, + scale: f32, + opts: &TreeOpts, +) -> Result<()> { + let bounds = opts.page; + let width = (bounds.width() * scale).ceil() as i32; + let height = (bounds.height() * scale).ceil() as i32; + if width <= 0 || height <= 0 { + return Ok(()); + } + + // Render the backdrop into an offscreen raster surface, in the same + // coordinate space as the PDF page (see `render/pdf.rs`). + let Some(mut surface) = skia::surfaces::raster_n32_premul((width, height)) else { + return Ok(()); + }; + { + let oc = surface.canvas(); + oc.clear(skia::Color::TRANSPARENT); + oc.scale((scale, scale)); + oc.translate((-bounds.left(), -bounds.top())); + let sub = TreeOpts { + root: opts.root, + page: opts.page, + embed_bg_blur: false, + skip: Some(&shape.id), + }; + render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?; + } + let image = surface.image_snapshot(); + + // Bake the blur into a raster bitmap. The PDF backend ignores image filters + // at draw time (same limitation as backdrop filters), so we must blur on a + // raster surface — where filters work — and embed the pre-blurred result. + let sigma = radius_to_sigma(shape.visible_background_blur().map_or(0.0, |b| b.value) * scale); + let blurred = { + let Some(mut blur_surface) = skia::surfaces::raster_n32_premul((width, height)) else { + return Ok(()); + }; + let bc = blur_surface.canvas(); + bc.clear(skia::Color::TRANSPARENT); + let mut paint = Paint::default(); + if let Some(filter) = + skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None) + { + paint.set_image_filter(filter); + } + bc.draw_image(&image, (0.0, 0.0), Some(&paint)); + blur_surface.image_snapshot() + }; + + let matrix = shape.centered_transform(); + canvas.save(); + canvas.concat(&matrix); + clip_to_shape(canvas, shape, true); + // Draw the pre-blurred full-page bitmap in device space (1 image px per + // device unit) so it aligns with the page regardless of the shape transform. + canvas.reset_matrix(); + canvas.draw_image(&blurred, (0.0, 0.0), None); + canvas.restore(); + Ok(()) +} + // --------------------------------------------------------------------------- // Groups // --------------------------------------------------------------------------- fn render_group( - shared: &mut RenderState, + shared: &mut RenderResources, canvas: &Canvas, element: &Shape, masked: bool, tree: ShapesPoolRef, scale: f32, - target: VectorTarget, + opts: &TreeOpts, ) -> Result<()> { // A group has no geometry of its own and does NOT propagate a transform to // its children: child shapes are stored in absolute coordinates and each @@ -422,7 +603,7 @@ fn render_group( canvas.save(); // Group drop shadow: subtree silhouette, below the opacity/clip layer. - render_container_drop_shadows(shared, canvas, element, tree, scale, target, false)?; + render_container_drop_shadows(shared, canvas, element, tree, scale, false, opts)?; // Layer for opacity / blend mode (and group-level layer blur) let needs_layer = element.needs_layer(); @@ -455,21 +636,21 @@ fn render_group( canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); for child_id in &children { - render_tree(shared, canvas, child_id, tree, scale, target)?; + render_tree_inner(shared, canvas, child_id, tree, scale, opts)?; } if let Some(mask_id) = element.mask_id() { let mut mask_paint = Paint::default(); mask_paint.set_blend_mode(skia::BlendMode::DstIn); canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint)); - render_tree(shared, canvas, mask_id, tree, scale, target)?; + render_tree_inner(shared, canvas, mask_id, tree, scale, opts)?; canvas.restore(); // mask layer } canvas.restore(); // composition layer } else { for child_id in &children { - render_tree(shared, canvas, child_id, tree, scale, target)?; + render_tree_inner(shared, canvas, child_id, tree, scale, opts)?; } } @@ -485,12 +666,12 @@ fn render_group( // --------------------------------------------------------------------------- fn render_frame( - shared: &mut RenderState, + shared: &mut RenderResources, canvas: &Canvas, element: &Shape, tree: ShapesPoolRef, scale: f32, - target: VectorTarget, + opts: &TreeOpts, ) -> Result<()> { // A frame's own geometry (background, clip, strokes) is placed by its // `centered_transform`, but — like groups — it does NOT propagate that @@ -503,7 +684,7 @@ fn render_frame( // Frame drop shadow: background + subtree silhouette, below the clip layer // so it extends outside the frame bounds. - render_container_drop_shadows(shared, canvas, element, tree, scale, target, true)?; + render_container_drop_shadows(shared, canvas, element, tree, scale, true, opts)?; let needs_layer = element.needs_layer(); @@ -542,7 +723,7 @@ fn render_frame( if !element.fills.is_empty() { canvas.save(); canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale, target); + let mut renderer = VectorRenderer::new(canvas, shared, scale); renderer.draw_fills(element, &element.fills)?; renderer.draw_fill_inner_shadows(element)?; canvas.restore(); @@ -551,7 +732,7 @@ fn render_frame( // Children (absolute coords, no frame transform). let children: Vec = element.children_ids_iter_forward(false).copied().collect(); for child_id in &children { - render_tree(shared, canvas, child_id, tree, scale, target)?; + render_tree_inner(shared, canvas, child_id, tree, scale, opts)?; } // Strokes over children (clipped frames), in the frame's space. @@ -559,7 +740,7 @@ fn render_frame( if !visible_strokes.is_empty() { canvas.save(); canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale, target); + let mut renderer = VectorRenderer::new(canvas, shared, scale); renderer.draw_strokes(element, &visible_strokes)?; canvas.restore(); } @@ -575,13 +756,13 @@ fn render_frame( /// layer (its alpha becomes the shadow). `draw_fills` includes the frame /// background in the silhouette. fn render_container_drop_shadows( - shared: &mut RenderState, + shared: &mut RenderResources, canvas: &Canvas, element: &Shape, tree: ShapesPoolRef, scale: f32, - target: VectorTarget, draw_fills: bool, + opts: &TreeOpts, ) -> Result<()> { for shadow in element.drop_shadows_visible() { let Some(filter) = shadow.get_drop_shadow_filter() else { @@ -592,13 +773,13 @@ fn render_container_drop_shadows( canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); if draw_fills && !element.fills.is_empty() { - let mut renderer = VectorRenderer::new(canvas, shared, scale, target); + let mut renderer = VectorRenderer::new(canvas, shared, scale); renderer.draw_fills(element, &element.fills)?; } let children: Vec = element.children_ids_iter_forward(false).copied().collect(); for child_id in &children { - render_tree(shared, canvas, child_id, tree, scale, target)?; + render_tree_inner(shared, canvas, child_id, tree, scale, opts)?; } canvas.restore(); @@ -611,11 +792,10 @@ fn render_container_drop_shadows( // --------------------------------------------------------------------------- fn render_leaf( - shared: &mut RenderState, + shared: &mut RenderResources, canvas: &Canvas, element: &Shape, scale: f32, - target: VectorTarget, ) -> Result<()> { let needs_layer = element.needs_layer(); @@ -633,7 +813,7 @@ fn render_leaf( canvas.save_layer(&layer_rec); } - let mut renderer = VectorRenderer::new(canvas, shared, scale, target); + let mut renderer = VectorRenderer::new(canvas, shared, scale); // Layer blur (non-text shapes) let blur_layer = if !matches!(element.shape_type, Type::Text(_)) { @@ -695,7 +875,7 @@ fn render_leaf_content(renderer: &mut R, shape: &Shap // --------------------------------------------------------------------------- fn draw_image_fill( - shared: &mut RenderState, + shared: &mut RenderResources, canvas: &Canvas, shape: &Shape, image_fill: &crate::shapes::ImageFill, @@ -737,7 +917,7 @@ fn draw_image_fill( fn draw_single_stroke( canvas: &Canvas, - shared: &mut RenderState, + shared: &mut RenderResources, scale: f32, shape: &Shape, stroke: &Stroke, @@ -848,7 +1028,7 @@ fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint /// CPU image over it with `SrcIn` so only the stroke area shows the image. fn draw_image_stroke( canvas: &Canvas, - shared: &mut RenderState, + shared: &mut RenderResources, scale: f32, shape: &Shape, stroke: &Stroke, diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 5dfbe9e384..7ae9fd8cef 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1289,6 +1289,15 @@ impl Shape { }) } + /// Font families used by this shape (the text spans' families), or an + /// empty vec for non-text shapes. + pub fn font_families(&self) -> Vec { + match &self.shape_type { + Type::Text(content) => content.font_families(), + _ => Vec::new(), + } + } + #[allow(dead_code)] pub fn mask_filter(&self, scale: f32) -> Option { self.blur diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 1ba1381172..f5f58f5a80 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -391,6 +391,20 @@ impl TextContent { self.bounds = Rect::from_xywh(x, y, w, h); } + /// Distinct font families referenced by this content's spans, in first-seen + /// order. + pub fn font_families(&self) -> Vec { + let mut seen: Vec = Vec::new(); + for paragraph in &self.paragraphs { + for span in paragraph.children() { + if !seen.contains(&span.font_family) { + seen.push(span.font_family); + } + } + } + seen + } + pub fn add_paragraph(&mut self, paragraph: Paragraph) { self.paragraphs.push(paragraph); self.content_version = self.content_version.wrapping_add(1); diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 38cf30226f..c0d48c2e9e 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -1,4 +1,4 @@ -use crate::get_render_state; +use crate::get_resources; use crate::shapes::text::TextContent; use skia_safe::{ self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob, @@ -174,7 +174,7 @@ impl TextPaths { ) -> Option<(skia::Path, skia::Rect)> { let utf16_text = span_text.encode_utf16().collect::>(); let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) }; - let emoji_font = get_render_state().fonts().get_emoji_font(font.size()); + let emoji_font = get_resources().fonts.get_emoji_font(font.size()); let use_font = emoji_font.as_ref().unwrap_or(font); if let Some(mut text_blob) = TextBlob::from_text(text, use_font) { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index c38b604592..136f5b52ba 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -11,9 +11,9 @@ pub use ui::UIState; use crate::error::{Error, Result}; use crate::render::FrameType; -use crate::shapes::{grid_layout::grid_cell_data, Shape}; +use crate::shapes::{grid_layout::grid_cell_data, FontFamily, Shape}; use crate::uuid::Uuid; -use crate::{get_render_state, tiles}; +use crate::{get_render_state, get_resources, has_render_state, tiles}; /// This struct holds the state of the Rust application between JS calls. /// @@ -96,7 +96,33 @@ impl State { } pub fn render_shape_pdf(&mut self, id: &Uuid, scale: f32) -> Result> { - crate::render::pdf::render_to_pdf(get_render_state(), id, &self.shapes, scale) + crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale) + } + + /// GPU-free counterpart of [`State::render_shape_pixels`]: PNG on a CPU + /// raster surface, no GPU/WebGL. + pub fn render_shape_raster(&mut self, id: &Uuid, scale: f32) -> Result<(Vec, i32, i32)> { + crate::render::raster::render_to_raster(get_resources(), id, &self.shapes, scale) + } + + /// Distinct font families used by the (visible) subtree rooted at `id`, in + /// first-seen order — the on-demand set the headless exporter provisions. + pub fn fonts_used_by_shape(&self, id: &Uuid) -> Vec { + let Some(root) = self.shapes.get(id) else { + return Vec::new(); + }; + + let mut result: Vec = Vec::new(); + for child_id in root.all_children_iter(&self.shapes, false, true) { + if let Some(shape) = self.shapes.get(&child_id) { + for family in shape.font_families() { + if !result.contains(&family) { + result.push(family); + } + } + } + } + result } pub fn start_render_loop(&mut self, timestamp: i32) -> Result { @@ -150,8 +176,6 @@ impl State { } pub fn delete_shape_children(&mut self, parent_id: Uuid, id: Uuid) { - let render_state = get_render_state(); - // We don't really do a self.shapes.remove so that redo/undo keep working let Some(shape) = self.shapes.get(&id) else { return; @@ -159,23 +183,28 @@ impl State { // Only remove the children when is being deleted from the owner if shape.parent_id.is_none() || shape.parent_id == Some(parent_id) { - // IMPORTANT: - // Do NOT use `get_tiles_for_shape` here. That method intersects the shape - // tiles with the current interest area, which means we'd only invalidate - // the subset currently near the viewport. When the user later pans/zooms - // to reveal previously cached tiles, stale pixels could reappear. - // - // Instead, remove the shape from *all* tiles where it was indexed, and - // drop cached tiles for those entries. - let indexed_tiles: Vec = render_state - .tiles - .get_tiles_of(shape.id) - .map(|t| t.iter().copied().collect()) - .unwrap_or_default(); + // Tile invalidation only applies to the on-screen render state; the + // headless export path has none, so skip it there. + if has_render_state() { + let render_state = get_render_state(); + // IMPORTANT: + // Do NOT use `get_tiles_for_shape` here. That method intersects the shape + // tiles with the current interest area, which means we'd only invalidate + // the subset currently near the viewport. When the user later pans/zooms + // to reveal previously cached tiles, stale pixels could reappear. + // + // Instead, remove the shape from *all* tiles where it was indexed, and + // drop cached tiles for those entries. + let indexed_tiles: Vec = render_state + .tiles + .get_tiles_of(shape.id) + .map(|t| t.iter().copied().collect()) + .unwrap_or_default(); - for tile in indexed_tiles { - render_state.remove_cached_tile(tile); - render_state.tiles.remove_shape_at(tile, shape.id); + for tile in indexed_tiles { + render_state.remove_cached_tile(tile); + render_state.tiles.remove_shape_at(tile, shape.id); + } } if let Some(shape_to_delete) = self.shapes.get(&id) { @@ -184,8 +213,8 @@ impl State { if let Some(shape_to_delete) = self.shapes.get_mut(&shape_id) { shape_to_delete.set_deleted(true); } - if render_state.show_grid == Some(shape_id) { - render_state.show_grid = None; + if has_render_state() && get_render_state().show_grid == Some(shape_id) { + get_render_state().show_grid = None; } } } @@ -215,7 +244,8 @@ impl State { /// and groups properly encompass their children. pub fn set_parent_for_current_shape(&mut self, id: Uuid) { // Reparent preview during drag is handled by structure modifiers only. - if get_render_state().options.is_interactive_transform() { + // Headless export has no render state and never runs interactive drags. + if has_render_state() && get_render_state().options.is_interactive_transform() { return; } @@ -265,7 +295,7 @@ impl State { } pub fn font_collection(&self) -> &FontCollection { - get_render_state().fonts().font_collection() + get_resources().fonts.font_collection() } pub fn get_grid_coords(&self, pos_x: f32, pos_y: f32) -> Option<(i32, i32)> { @@ -298,18 +328,20 @@ impl State { } pub fn touch_current(&mut self) { - let render_state = get_render_state(); - if !self.loading { - if let Some(current_id) = self.current_id { - render_state.mark_touched(current_id); - } + // `mark_touched` only drives incremental on-screen tile invalidation; + // the headless export path has no render state, so skip it there. + if self.loading || !has_render_state() { + return; + } + if let Some(current_id) = self.current_id { + get_render_state().mark_touched(current_id); } } pub fn touch_shape(&mut self, id: Uuid) { - let render_state = get_render_state(); - if !self.loading { - render_state.mark_touched(id); + if self.loading || !has_render_state() { + return; } + get_render_state().mark_touched(id); } } diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index 206b91dbb0..2301846c10 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -1,4 +1,4 @@ -use crate::get_render_state; +use crate::get_resources; use crate::skia::textlayout::FontCollection; use crate::skia::Image; use crate::uuid::Uuid; @@ -25,12 +25,12 @@ pub fn uuid_from_u32(id: [u32; 4]) -> Uuid { } pub fn get_image(image_id: &Uuid) -> Option<&Image> { - get_render_state().images.get(image_id) + get_resources().images.get(image_id) } // FIXME: move to a different place ? pub fn get_fallback_fonts() -> &'static HashSet { - get_render_state().fonts().get_fallback() + get_resources().fonts.get_fallback() } pub fn get_font_collection() -> &'static FontCollection { diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index 65a4697181..778b80bf92 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -1,5 +1,5 @@ use crate::error::{Error, Result}; -use crate::get_render_state; +use crate::get_resources; use crate::mem; use crate::shapes::Fill; use crate::state::State; @@ -104,7 +104,10 @@ pub extern "C" fn store_image() -> Result<()> { let image_bytes = &bytes[IMAGE_HEADER_SIZE..]; with_state!(state, { - if let Err(msg) = get_render_state().add_image(ids.image_id, is_thumbnail, image_bytes) { + if let Err(msg) = get_resources() + .images + .add(ids.image_id, is_thumbnail, image_bytes) + { eprintln!("{}", msg); } touch_shapes_with_image(state, ids.image_id); @@ -174,7 +177,7 @@ pub extern "C" fn store_image_from_texture() -> Result<()> { ); with_state!(state, { - if let Err(msg) = get_render_state().add_image_from_gl_texture( + if let Err(msg) = get_resources().images.add_image_from_gl_texture( ids.image_id, is_thumbnail, texture_id, diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index 21c4e6a797..112d523035 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -1,9 +1,11 @@ use macros::{wasm_error, ToJs}; -use crate::get_render_state; +use crate::get_resources; use crate::mem; +use crate::render::FontStore; use crate::shapes::{FontFamily, FontStyle}; use crate::utils::uuid_from_u32_quartet; +use crate::with_state; #[derive(Debug, PartialEq, Clone, Copy, ToJs)] #[repr(u8)] @@ -45,14 +47,44 @@ pub extern "C" fn store_font( let font_style = RawFontStyle::from(style); let family = FontFamily::new(id, weight, font_style.into()); - let _ = get_render_state() - .fonts_mut() + let _ = get_resources() + .fonts .add(family, &font_bytes, is_emoji, is_fallback); mem::free_bytes()?; Ok(()) } +/// Resets the font store to its default state, dropping every font uploaded via +/// `store_font`. A headless host that reuses a single WASM instance across +/// requests must call this per render so fonts don't accumulate unbounded. +#[no_mangle] +#[wasm_error] +pub extern "C" fn clear_fonts() -> Result<()> { + get_resources().fonts = FontStore::try_new()?; + Ok(()) +} + +/// Distinct font families used by the subtree, for on-demand provisioning by a +/// headless host. Buffer (LE): `[count u32]` then `count` × +/// `[uuid 16B][weight u32][style u32]` (0=normal, 1=italic); uuid bytes match +/// the quartet `store_font` consumes. +#[no_mangle] +pub extern "C" fn get_fonts_for_shape(a: u32, b: u32, c: u32, d: u32) -> *mut u8 { + let id = uuid_from_u32_quartet(a, b, c, d); + let families = with_state!(state, { state.fonts_used_by_shape(&id) }); + + let mut buf = Vec::with_capacity(4 + families.len() * 24); + buf.extend_from_slice(&(families.len() as u32).to_le_bytes()); + for family in families { + let id_bytes: [u8; 16] = family.id().into(); + buf.extend_from_slice(&id_bytes); + buf.extend_from_slice(&family.weight().to_le_bytes()); + buf.extend_from_slice(&(family.style() as u32).to_le_bytes()); + } + mem::write_bytes(buf) +} + #[no_mangle] pub extern "C" fn is_font_uploaded( a: u32, @@ -66,7 +98,5 @@ pub extern "C" fn is_font_uploaded( let id = uuid_from_u32_quartet(a, b, c, d); let font_style = RawFontStyle::from(style); let family = FontFamily::new(id, weight, font_style.into()); - let res = get_render_state().fonts().has_family(&family, is_emoji); - - res + get_resources().fonts.has_family(&family, is_emoji) } diff --git a/scripts/attach-opencode b/scripts/attach-opencode deleted file mode 100755 index ddf43bb828..0000000000 --- a/scripts/attach-opencode +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -set -e; - -PORT=14180 -while [[ $# -gt 0 ]]; do - case "$1" in - --port|-p) PORT="$2"; shift 2 ;; - *) echo "Unknown option: $1"; exit 1 ;; - esac -done - -pnpm exec opencode attach "http://localhost:${PORT}"; diff --git a/scripts/check-fmt b/scripts/check-fmt-clj similarity index 100% rename from scripts/check-fmt rename to scripts/check-fmt-clj diff --git a/tools/db-schema b/scripts/db-schema similarity index 100% rename from tools/db-schema rename to scripts/db-schema diff --git a/tools/detect-target-branch b/scripts/detect-target-branch similarity index 93% rename from tools/detect-target-branch rename to scripts/detect-target-branch index 5d3d584552..65ff49c0ca 100755 --- a/tools/detect-target-branch +++ b/scripts/detect-target-branch @@ -12,8 +12,8 @@ set -euo pipefail # branch shares its tip commit with the target (not yet diverged). # # Usage: -# tools/detect-target-branch # print branch name -# tools/detect-target-branch --verbose # print "branch~N" +# scripts/detect-target-branch # print branch name +# scripts/detect-target-branch --verbose # print "branch~N" # # Exit status: # 0 — target found diff --git a/scripts/fmt b/scripts/fmt deleted file mode 100755 index 3de70a2709..0000000000 --- a/scripts/fmt +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -set -ex - -cljfmt --parallel=true fix \ - common/src/ \ - common/test/ \ - frontend/src/ \ - frontend/test/ \ - backend/src/ \ - backend/test/ \ - exporter/src/ \ - library/src; diff --git a/tools/gh.py b/scripts/gh.py similarity index 96% rename from tools/gh.py rename to scripts/gh.py index 51779cefb3..7017389d52 100755 --- a/tools/gh.py +++ b/scripts/gh.py @@ -9,20 +9,20 @@ Subcommands: prs Fetch details for one or more PRs (by number or milestone) Usage: - python3 tools/gh.py issues (default: state=closed) - python3 tools/gh.py issues "2.16.0" --state all - python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" - python3 tools/gh.py issues "2.16.0" --label "bug" (include only issues with label) - python3 tools/gh.py issues "2.16.0" --label "bug,regression" --exclude "no changelog" - python3 tools/gh.py issues "2.16.0" --compare CHANGES.md - python3 tools/gh.py issues none (issues with no milestone) - python3 tools/gh.py issues none --label "enhancement" - python3 tools/gh.py issues none --state open - python3 tools/gh.py prs 9179 9204 9311 - python3 tools/gh.py prs --file prs.txt - cat prs.txt | python3 tools/gh.py prs --stdin - python3 tools/gh.py prs --milestone "2.16.0" (default: state=merged) - python3 tools/gh.py prs --milestone "2.16.0" --state all + python3 scripts/gh.py issues (default: state=closed) + python3 scripts/gh.py issues "2.16.0" --state all + python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog" + python3 scripts/gh.py issues "2.16.0" --label "bug" (include only issues with label) + python3 scripts/gh.py issues "2.16.0" --label "bug,regression" --exclude "no changelog" + python3 scripts/gh.py issues "2.16.0" --compare CHANGES.md + python3 scripts/gh.py issues none (issues with no milestone) + python3 scripts/gh.py issues none --label "enhancement" + python3 scripts/gh.py issues none --state open + python3 scripts/gh.py prs 9179 9204 9311 + python3 scripts/gh.py prs --file prs.txt + cat prs.txt | python3 scripts/gh.py prs --stdin + python3 scripts/gh.py prs --milestone "2.16.0" (default: state=merged) + python3 scripts/gh.py prs --milestone "2.16.0" --state all Prerequisites: - gh CLI authenticated (gh auth status) diff --git a/scripts/lint b/scripts/lint deleted file mode 100755 index 4ab59aed13..0000000000 --- a/scripts/lint +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -set -ex - -clj-kondo --parallel=true --lint common/src; -clj-kondo --parallel=true --lint frontend/src; -clj-kondo --parallel=true --lint backend/src; -clj-kondo --parallel=true --lint exporter/src/; -clj-kondo --parallel=true --lint library/src; diff --git a/tools/nrepl-eval.mjs b/scripts/nrepl-eval.mjs similarity index 100% rename from tools/nrepl-eval.mjs rename to scripts/nrepl-eval.mjs diff --git a/tools/paren-repair.bb b/scripts/paren-repair similarity index 93% rename from tools/paren-repair.bb rename to scripts/paren-repair index aba0ee26ac..d00930dcc1 100755 --- a/tools/paren-repair.bb +++ b/scripts/paren-repair @@ -323,27 +323,14 @@ success-count (count successes) failure-count (count failures)] - ;; Print results - (println) - (println "paren-repair Results") - (println "========================") - (println) - - (doseq [{:keys [file-path message delimiter-fixed formatted]} results] - (let [tags (when (or delimiter-fixed formatted) - (str " [" - (string/join ", " - (filter some? - [(when delimiter-fixed "delimiter-fixed") - (when formatted "formatted")])) - "]"))] - (println (str " " file-path ": " message tags)))) - - (println) - (println "Summary:") - (println " Success:" success-count) - (println " Failed: " failure-count) - (println) + (doseq [{:keys [file-path success message delimiter-fixed formatted]} results] + (let [status (cond + (not success) (str "error: " message) + (and delimiter-fixed formatted) "delimiter-fixed, formatted" + delimiter-fixed "delimiter-fixed" + formatted "formatted" + :else "no-changes")] + (println (str file-path ": " status)))) (if (zero? failure-count) (System/exit 0) diff --git a/tools/psql b/scripts/psql similarity index 100% rename from tools/psql rename to scripts/psql diff --git a/scripts/start-opencode b/scripts/start-opencode deleted file mode 100755 index 5a7bb34f39..0000000000 --- a/scripts/start-opencode +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash - -set -e; -pnpm exec opencode; diff --git a/scripts/start-opencode-server b/scripts/start-opencode-server deleted file mode 100755 index b173f24cba..0000000000 --- a/scripts/start-opencode-server +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash - -set -e; -pnpm exec opencode serve --hostname 0.0.0.0 --port 14180; diff --git a/tools/taiga.py b/scripts/taiga.py similarity index 95% rename from tools/taiga.py rename to scripts/taiga.py index 1e96364a31..78bce5ccbd 100755 --- a/tools/taiga.py +++ b/scripts/taiga.py @@ -4,15 +4,15 @@ Taiga API client — fetch public issues, user stories, and tasks from the Penpot project (id 345963) without authentication. Usage: - python3 tools/taiga.py - python3 tools/taiga.py - python3 tools/taiga.py [--json] - python3 tools/taiga.py [--json] + python3 scripts/taiga.py + python3 scripts/taiga.py + python3 scripts/taiga.py [--json] + python3 scripts/taiga.py [--json] Examples: - python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714 - python3 tools/taiga.py --json https://tree.taiga.io/project/penpot/us/14128 - python3 tools/taiga.py task 13648 + python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714 + python3 scripts/taiga.py --json https://tree.taiga.io/project/penpot/us/14128 + python3 scripts/taiga.py task 13648 """ import argparse