mirror of
https://github.com/penpot/penpot.git
synced 2026-07-29 01:16:14 +00:00
Merge branch 'develop' into niwinz-performance-tests
This commit is contained in:
commit
0835c51e11
13
.github/workflows/tests-backend.yml
vendored
13
.github/workflows/tests-backend.yml
vendored
@ -62,14 +62,15 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Fmt
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
cljfmt check --parallel=true src/ test/
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt
|
||||
pnpm run lint
|
||||
clj-kondo --parallel --lint ../common/src/ src/
|
||||
|
||||
- name: Tests
|
||||
working-directory: ./backend
|
||||
@ -81,4 +82,4 @@ jobs:
|
||||
|
||||
run: |
|
||||
mkdir -p /tmp/penpot;
|
||||
clojure -M:dev:test --reporter kaocha.report/documentation
|
||||
clojure -M:dev:test
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -9,6 +9,7 @@
|
||||
.clj-kondo
|
||||
.cpcache
|
||||
.lsp
|
||||
.env
|
||||
.nrepl-port
|
||||
.nyc_output
|
||||
.rebel_readline_history
|
||||
@ -74,8 +75,6 @@ opencode.json
|
||||
/frontend/target/
|
||||
/frontend/test-results/
|
||||
/frontend/.shadow-cljs
|
||||
/other/
|
||||
/scripts/
|
||||
/nexus/
|
||||
/tmp/
|
||||
/vendor/**/target
|
||||
|
||||
299
.opencode/plugins/penpot.js
Normal file
299
.opencode/plugins/penpot.js
Normal file
@ -0,0 +1,299 @@
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import path from "path"
|
||||
import { spawn } from "child_process"
|
||||
|
||||
const penpotPsqlTool = tool({
|
||||
description:
|
||||
"Execute a SQL command against the Penpot database. Uses the defaults from scripts/psql.",
|
||||
|
||||
args: {
|
||||
sql: tool.schema
|
||||
.string()
|
||||
.describe("SQL command to execute"),
|
||||
|
||||
test: tool.schema
|
||||
.boolean()
|
||||
.describe("Use the penpot_test database")
|
||||
.optional(),
|
||||
},
|
||||
|
||||
async execute(args, context) {
|
||||
const host = process.env.PENPOT_DB_HOST || "postgres"
|
||||
const user = process.env.PENPOT_DB_USER || "penpot"
|
||||
const db = args.test
|
||||
? "penpot_test"
|
||||
: process.env.PENPOT_DB_NAME || "penpot"
|
||||
const password = process.env.PENPOT_DB_PASSWORD || "penpot"
|
||||
|
||||
const psqlArgs = ["-h", host, "-U", user, "-d", db, "-c", args.sql]
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const proc = spawn("psql", psqlArgs, {
|
||||
cwd: context.worktree,
|
||||
env: { ...process.env, PGPASSWORD: password },
|
||||
})
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
proc.on("error", (error) => {
|
||||
resolve(`Error: ${error.message}`)
|
||||
})
|
||||
|
||||
proc.on("close", (exitCode) => {
|
||||
const output =
|
||||
exitCode === 0
|
||||
? stdout.trim() || "Query executed successfully"
|
||||
: `Error (exit ${exitCode}): ${
|
||||
(stderr || stdout).trim() || "No error output"
|
||||
}`
|
||||
resolve(output)
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const parenRepairTool = tool({
|
||||
description:
|
||||
"Fix mismatched parentheses/braces in Clojure files (.clj, .cljs, .cljc) then reformat with cljfmt.",
|
||||
|
||||
args: {
|
||||
// A string is used instead of an array so OpenCode displays it
|
||||
// in the generic tool invocation.
|
||||
files: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Comma-separated file paths to fix, for example: frontend/src/app/config.cljs, backend/src/core.clj",
|
||||
)
|
||||
.optional(),
|
||||
|
||||
code: tool.schema
|
||||
.string()
|
||||
.describe("Code string to fix via stdin")
|
||||
.optional(),
|
||||
},
|
||||
|
||||
async execute(args, context) {
|
||||
const script = path.join(context.worktree, "scripts/paren-repair")
|
||||
|
||||
const files = args.files
|
||||
? args.files
|
||||
.split(",")
|
||||
.map((file) => file.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
const paramInfo =
|
||||
files.length > 0
|
||||
? `files=[${files.join(", ")}]`
|
||||
: args.code !== undefined
|
||||
? `code=(${args.code.length} chars)`
|
||||
: "none"
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const childArgs =
|
||||
files.length > 0
|
||||
? [script, ...files]
|
||||
: [script]
|
||||
|
||||
const proc = spawn("bb", childArgs, {
|
||||
cwd: context.worktree,
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
proc.on("error", (error) => {
|
||||
resolve(`Error: ${error.message}`)
|
||||
})
|
||||
|
||||
proc.on("close", (exitCode) => {
|
||||
const output =
|
||||
exitCode === 0
|
||||
? stdout.trim() || "No changes needed"
|
||||
: `Error (exit ${exitCode}): ${
|
||||
(stderr || stdout).trim() || "No error output"
|
||||
}`
|
||||
|
||||
resolve(output)
|
||||
})
|
||||
|
||||
// Close stdin in all cases so the process cannot wait indefinitely.
|
||||
if (args.code !== undefined) {
|
||||
proc.stdin.end(args.code)
|
||||
} else {
|
||||
proc.stdin.end()
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export default async function plugin() {
|
||||
return {
|
||||
tool: {
|
||||
"paren-repair": parenRepairTool,
|
||||
"penpot-psql": penpotPsqlTool,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// import { tool } from "@opencode-ai/plugin"
|
||||
// import path from "path"
|
||||
// import { spawn } from "child_process"
|
||||
|
||||
// function formatFiles(files) {
|
||||
// if (files.length === 0) return "stdin"
|
||||
|
||||
// // Keep the visible tool title reasonably short.
|
||||
// if (files.length <= 3) return files.join(", ")
|
||||
|
||||
// return `${files.slice(0, 3).join(", ")} (+${files.length - 3} more)`
|
||||
// }
|
||||
|
||||
// const parenRepairTool = tool({
|
||||
// description:
|
||||
// "Fix mismatched parentheses/braces in Clojure files, then reformat with cljfmt.",
|
||||
|
||||
// args: {
|
||||
// files: tool.schema
|
||||
// .array(tool.schema.string())
|
||||
// .describe("Array of file paths to fix")
|
||||
// .optional(),
|
||||
|
||||
// code: tool.schema
|
||||
// .string()
|
||||
// .describe("Code string to fix via stdin")
|
||||
// .optional(),
|
||||
// },
|
||||
|
||||
// async execute(args, context) {
|
||||
// const script = path.join(context.worktree, "scripts/paren-repair")
|
||||
|
||||
// const files = (args.files ?? []).map((file) => {
|
||||
// const absolute = path.isAbsolute(file)
|
||||
// ? file
|
||||
// : path.resolve(context.worktree, file)
|
||||
|
||||
// return path.relative(context.worktree, absolute)
|
||||
// })
|
||||
|
||||
// const targetSummary =
|
||||
// files.length > 0
|
||||
// ? formatFiles(files)
|
||||
// : args.code !== undefined
|
||||
// ? `stdin (${args.code.length} chars)`
|
||||
// : "no input"
|
||||
|
||||
// // This updates the tool-call title immediately, while it is running.
|
||||
// await context.metadata({
|
||||
// title: `Paren repair: ${targetSummary}`,
|
||||
// metadata: {
|
||||
// files,
|
||||
// codeChars: args.code?.length,
|
||||
// },
|
||||
// })
|
||||
|
||||
// const childArgs =
|
||||
// args.files && args.files.length > 0
|
||||
// ? [script, ...args.files]
|
||||
// : [script]
|
||||
|
||||
// return new Promise((resolve) => {
|
||||
// const proc = spawn("bb", childArgs, {
|
||||
// cwd: context.worktree,
|
||||
// })
|
||||
|
||||
// let stdout = ""
|
||||
// let stderr = ""
|
||||
|
||||
// if (args.code !== undefined) {
|
||||
// proc.stdin.end(args.code)
|
||||
// }
|
||||
|
||||
// proc.stdout.on("data", (data) => {
|
||||
// stdout += data.toString()
|
||||
// })
|
||||
|
||||
// proc.stderr.on("data", (data) => {
|
||||
// stderr += data.toString()
|
||||
// })
|
||||
|
||||
// proc.on("close", (exitCode) => {
|
||||
// const successful = exitCode === 0
|
||||
|
||||
// const commandOutput = successful
|
||||
// ? stdout.trim() || "No changes needed"
|
||||
// : `Error (exit ${exitCode}): ${(stderr || stdout).trim()}`
|
||||
|
||||
// const parameterOutput =
|
||||
// files.length > 0
|
||||
// ? `Files passed:\n${files.map((file) => `- ${file}`).join("\n")}`
|
||||
// : args.code !== undefined
|
||||
// ? `Input passed through stdin: ${args.code.length} characters`
|
||||
// : "No files or stdin input were passed"
|
||||
|
||||
// resolve({
|
||||
// title: `Paren repair: ${targetSummary}`,
|
||||
// output: `${parameterOutput}\n\n${commandOutput}`,
|
||||
// metadata: {
|
||||
// files,
|
||||
// codeChars: args.code?.length,
|
||||
// exitCode,
|
||||
// successful,
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// proc.on("error", (error) => {
|
||||
// resolve({
|
||||
// title: `Paren repair failed: ${targetSummary}`,
|
||||
// output: [
|
||||
// files.length > 0
|
||||
// ? `Files passed:\n${files.map((file) => `- ${file}`).join("\n")}`
|
||||
// : `Input: ${targetSummary}`,
|
||||
// `Failed to start bb: ${error.message}`,
|
||||
// ].join("\n\n"),
|
||||
// metadata: {
|
||||
// files,
|
||||
// codeChars: args.code?.length,
|
||||
// successful: false,
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
// },
|
||||
// })
|
||||
|
||||
// export default async function plugin() {
|
||||
// return {
|
||||
// tool: {
|
||||
// "paren-repair": parenRepairTool,
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
@ -1,85 +0,0 @@
|
||||
---
|
||||
name: backport-commit
|
||||
description: Port changes from a specific Git commit to the current branch by manually applying the diff, avoiding cherry-pick when it would introduce complex conflicts.
|
||||
---
|
||||
|
||||
# Backport Commit
|
||||
|
||||
Port changes from a specific Git commit to the current branch by manually
|
||||
applying the diff, avoiding `git cherry-pick` when it would introduce
|
||||
complex conflicts.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill whenever the user asks to backport a commit, especially when:
|
||||
|
||||
- The commit touches multiple modules or files with significant divergence
|
||||
- `git cherry-pick` is explicitly ruled out ("do not use cherry-pick")
|
||||
- The target commit is old enough that conflicts are likely
|
||||
- The commit introduces both source changes AND new files (tests, etc.)
|
||||
- You need full control over how each hunk is applied
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the target commit
|
||||
|
||||
```bash
|
||||
# Verify the commit exists and understand what it does
|
||||
git log --oneline -1 <commit-sha>
|
||||
|
||||
# Get the full diff (including new/deleted files)
|
||||
git show <commit-sha>
|
||||
|
||||
# Capture the original commit message for later reuse
|
||||
git log --format='%B' -1 <commit-sha>
|
||||
```
|
||||
|
||||
### 2. Identify affected modules
|
||||
|
||||
From the file paths in the diff, determine which Penpot modules are affected
|
||||
(frontend, backend, common, render-wasm, etc.) and read their `AGENTS.md`
|
||||
files **before** making any changes. If a module has no `AGENTS.md`, skip
|
||||
that step — verify with `ls <module>/AGENTS.md` first.
|
||||
|
||||
### 3. Read the current state of each affected file
|
||||
|
||||
For every file the diff touches, read the current version on disk to understand
|
||||
context and ensure correct placement before editing.
|
||||
|
||||
### 4. Apply changes manually (the core of this approach)
|
||||
|
||||
Process every hunk in the diff using the appropriate tool:
|
||||
|
||||
| Diff action | Tool to use |
|
||||
|-------------|-------------|
|
||||
| Modify existing file | `edit` — use enough surrounding context in `oldString` to uniquely match the location |
|
||||
| Add new file | `write` — include proper license header and namespace conventions matching project style |
|
||||
| Delete file | `bash rm <path>` |
|
||||
| Rename/move file | `bash mv <old> <new>`, then apply any content changes with `edit` |
|
||||
|
||||
> **Tip:** Group nearby hunks from the same file into a single `edit` call.
|
||||
> Use separate calls when hunks are far apart to keep `oldString` short and
|
||||
> unambiguous.
|
||||
|
||||
Repeat until **all** hunks in the diff are ported.
|
||||
|
||||
### 5. Validate
|
||||
|
||||
Run **lint**, **check-fmt**, and **tests** for every affected module (see each
|
||||
module's `AGENTS.md` for the exact commands). If the formatter auto-fixes
|
||||
indentation, verify the logic is still semantically correct. All checks must
|
||||
pass before moving on.
|
||||
|
||||
### 6. Commit
|
||||
|
||||
Ask the `commiter` sub-agent to create a commit. Stage all relevant files
|
||||
(exclude unrelated untracked files) and provide the original commit message as
|
||||
a reference, adapting it as needed for the target branch context.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Context matters** — always read files before editing; never guess
|
||||
indentation or surrounding code
|
||||
- **Lint + format + test** — never skip validation before committing
|
||||
- **Preserve intent** — keep the original commit message meaning; the
|
||||
`commiter` agent handles formatting
|
||||
@ -1,19 +1,19 @@
|
||||
---
|
||||
name: nrepl-eval
|
||||
description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-eval.mjs CLI tool.
|
||||
description: Evaluate Clojure code via nREPL using the standalone scripts/nrepl-eval.mjs CLI tool.
|
||||
---
|
||||
|
||||
# nREPL Eval
|
||||
|
||||
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
|
||||
`tools/nrepl-eval.mjs`.
|
||||
`scripts/nrepl-eval.mjs`.
|
||||
|
||||
Full documentation: `mem:tools/nrepl-eval` (file: `.serena/memories/tools/nrepl-eval.md`)
|
||||
Full documentation: `mem:scripts/nrepl-eval` (file: `.serena/memories/scripts/nrepl-eval.md`)
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs [options] [<code>]
|
||||
./scripts/nrepl-eval.mjs [options] [<code>]
|
||||
```
|
||||
|
||||
| Flag | Description | Default |
|
||||
@ -30,8 +30,8 @@ Full documentation: `mem:tools/nrepl-eval` (file: `.serena/memories/tools/nrepl-
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs '(+ 1 2 3)'
|
||||
./tools/nrepl-eval.mjs --backend '(+ 1 2 3)'
|
||||
./tools/nrepl-eval.mjs --frontend '(js/alert "hi")'
|
||||
./tools/nrepl-eval.mjs -e
|
||||
./scripts/nrepl-eval.mjs '(+ 1 2 3)'
|
||||
./scripts/nrepl-eval.mjs --backend '(+ 1 2 3)'
|
||||
./scripts/nrepl-eval.mjs --frontend '(js/alert "hi")'
|
||||
./scripts/nrepl-eval.mjs -e
|
||||
```
|
||||
|
||||
@ -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/<descriptive-name>.md`.
|
||||
- Use a **kebab-case** filename that summarises the task, e.g.
|
||||
`add-error-reports-management-rpc.md`, `backend-rpc-security-audit.md`. No
|
||||
spaces, no uppercase, no version numbers or dates in the filename.
|
||||
- If `.opencode/prompts/` does not exist, create it before writing.
|
||||
- If a file with the same name already exists, overwrite it (the file is the
|
||||
refined prompt, not a log).
|
||||
- Only skip the file write when the user explicitly opts out (e.g. "don't save
|
||||
this one", "just show it in the chat"). When in doubt, save it.
|
||||
|
||||
@ -13,7 +13,7 @@ Fetch information from Taiga public API for the **Penpot** project
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `python3` — the `tools/taiga.py` CLI script is self-contained (stdlib only)
|
||||
- `python3` — the `scripts/taiga.py` CLI script is self-contained (stdlib only)
|
||||
|
||||
## Quick Start
|
||||
|
||||
@ -21,17 +21,17 @@ The easiest way is to use the bundled Python script:
|
||||
|
||||
```bash
|
||||
# Pass a Taiga URL directly
|
||||
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
|
||||
python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714
|
||||
|
||||
# Or use "<type> <ref>" syntax
|
||||
python3 tools/taiga.py us 14128
|
||||
python3 tools/taiga.py task 13648
|
||||
python3 scripts/taiga.py us 14128
|
||||
python3 scripts/taiga.py task 13648
|
||||
|
||||
# Add --json for raw output
|
||||
python3 tools/taiga.py --json issue 13714
|
||||
python3 scripts/taiga.py --json issue 13714
|
||||
|
||||
# See full usage
|
||||
python3 tools/taiga.py --help
|
||||
python3 scripts/taiga.py --help
|
||||
```
|
||||
|
||||
## URL Pattern Reference
|
||||
@ -51,30 +51,30 @@ To extract the **type** and **ref** from a URL:
|
||||
|
||||
## Python Script Reference
|
||||
|
||||
The `tools/taiga.py` script wraps the Taiga API into a single convenient CLI
|
||||
The `scripts/taiga.py` script wraps the Taiga API into a single convenient CLI
|
||||
with sensible defaults.
|
||||
|
||||
### Usage
|
||||
|
||||
```
|
||||
python3 tools/taiga.py <taiga-url>
|
||||
python3 tools/taiga.py <type> <ref>
|
||||
python3 tools/taiga.py [--json] <taiga-url>
|
||||
python3 tools/taiga.py [--json] <type> <ref>
|
||||
python3 scripts/taiga.py <taiga-url>
|
||||
python3 scripts/taiga.py <type> <ref>
|
||||
python3 scripts/taiga.py [--json] <taiga-url>
|
||||
python3 scripts/taiga.py [--json] <type> <ref>
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# By URL (recommended — no need to think about type/ref)
|
||||
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
|
||||
python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714
|
||||
|
||||
# By type and ref
|
||||
python3 tools/taiga.py us 14128
|
||||
python3 tools/taiga.py task 13648
|
||||
python3 scripts/taiga.py us 14128
|
||||
python3 scripts/taiga.py task 13648
|
||||
|
||||
# Raw JSON output
|
||||
python3 tools/taiga.py --json issue 13714
|
||||
python3 scripts/taiga.py --json issue 13714
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
@ -20,7 +20,7 @@ primary link, with the fix PR inline on the same line.
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
- Python 3.8+
|
||||
- `tools/gh.py` helper script available
|
||||
- `scripts/gh.py` helper script available
|
||||
|
||||
## Workflow
|
||||
|
||||
@ -36,13 +36,13 @@ Use the helper script. It uses GraphQL for efficient single-pass fetching
|
||||
|
||||
```bash
|
||||
# All closed issues (default)
|
||||
python3 tools/gh.py issues "2.16.0"
|
||||
python3 scripts/gh.py issues "2.16.0"
|
||||
|
||||
# Include open issues too
|
||||
python3 tools/gh.py issues "2.16.0" --state all
|
||||
python3 scripts/gh.py issues "2.16.0" --state all
|
||||
|
||||
# Exclude entries that should not go in the changelog
|
||||
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
||||
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
||||
```
|
||||
|
||||
**Exclusion rules (issue-level):**
|
||||
@ -68,7 +68,7 @@ If updating from an existing `CHANGES.md`, find issues in the milestone that
|
||||
are NOT yet referenced in the changelog:
|
||||
|
||||
```bash
|
||||
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
|
||||
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
|
||||
```
|
||||
|
||||
This returns a filtered JSON array with only the missing issues.
|
||||
@ -85,23 +85,23 @@ community contribution attribution, or to read the PR body for
|
||||
|
||||
```bash
|
||||
# One or more PR numbers
|
||||
python3 tools/gh.py prs 9179 9204 9311
|
||||
python3 scripts/gh.py prs 9179 9204 9311
|
||||
|
||||
# From a file
|
||||
python3 tools/gh.py prs --file prs.txt
|
||||
python3 scripts/gh.py prs --file prs.txt
|
||||
|
||||
# From stdin
|
||||
cat prs.txt | python3 tools/gh.py prs --stdin
|
||||
cat prs.txt | python3 scripts/gh.py prs --stdin
|
||||
```
|
||||
|
||||
The `prs` command also supports listing all PRs in a milestone in one call:
|
||||
|
||||
```bash
|
||||
# All merged PRs in a milestone (default)
|
||||
python3 tools/gh.py prs --milestone "2.16.0"
|
||||
python3 scripts/gh.py prs --milestone "2.16.0"
|
||||
|
||||
# All states (merged, open, closed)
|
||||
python3 tools/gh.py prs --milestone "2.16.0" --state all
|
||||
python3 scripts/gh.py prs --milestone "2.16.0" --state all
|
||||
```
|
||||
|
||||
The `prs` command returns JSON with `number`, `title`, `body`, `state`,
|
||||
@ -113,13 +113,13 @@ You can also list all PRs in a milestone in a single call:
|
||||
|
||||
```bash
|
||||
# All merged PRs in a milestone (default)
|
||||
python3 tools/gh.py prs --milestone "2.16.0"
|
||||
python3 scripts/gh.py prs --milestone "2.16.0"
|
||||
|
||||
# All states (merged, open, closed)
|
||||
python3 tools/gh.py prs --milestone "2.16.0" --state all
|
||||
python3 scripts/gh.py prs --milestone "2.16.0" --state all
|
||||
|
||||
# Open PRs only
|
||||
python3 tools/gh.py prs --milestone "2.16.0" --state open
|
||||
python3 scripts/gh.py prs --milestone "2.16.0" --state open
|
||||
```
|
||||
|
||||
The milestone path uses paginated GraphQL on the milestone's `pullRequests`
|
||||
@ -147,6 +147,12 @@ belongs to.
|
||||
The `gh.py` issues command already includes `issue_type` in every entry's
|
||||
output. **No separate GraphQL query is needed.**
|
||||
|
||||
**Preserve highlighted entries:** If an entry is already featured in
|
||||
`### :rocket: Epics and highlights`, keep it in that section when refreshing a
|
||||
changelog version. Do not remove a highlighted entry just because issue type
|
||||
categorization would otherwise place it under `### :sparkles: New features &
|
||||
Enhancements`.
|
||||
|
||||
**Community contribution attribution:** If the issue or its fix PR has the
|
||||
`community contribution` label, add an attribution `(by @<github_username>)`
|
||||
on the changelog entry line, **before** the GitHub issue/PR references.
|
||||
@ -155,7 +161,7 @@ The attribution should reference the **PR author**, not the issue author.
|
||||
The `prs` subcommand includes the `author` field — use that:
|
||||
|
||||
```bash
|
||||
python3 tools/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
|
||||
python3 scripts/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
|
||||
```
|
||||
|
||||
Placement in the entry line:
|
||||
@ -190,7 +196,7 @@ only reference **merged** PRs. Verify before writing:
|
||||
|
||||
```bash
|
||||
# Collect all PR numbers from the candidate entries and check them
|
||||
python3 tools/gh.py prs <ALL_PR_NUMBERS> | python3 -c "
|
||||
python3 scripts/gh.py prs <ALL_PR_NUMBERS> | python3 -c "
|
||||
import json, sys
|
||||
for pr in json.load(sys.stdin):
|
||||
if pr['state'] != 'MERGED':
|
||||
@ -265,7 +271,7 @@ section) or in the candidate set for the current milestone, check:
|
||||
current milestone since the changelog was last updated (e.g., a fix
|
||||
arrived late and the issue was reassigned to a future milestone)?
|
||||
- Verify the issue is still in the current milestone via
|
||||
`python3 tools/gh.py issues <MILESTONE> --state all`. If it's no
|
||||
`python3 scripts/gh.py issues <MILESTONE> --state all`. If it's no
|
||||
longer there, remove the entry from the current section. (If the
|
||||
target section doesn't exist yet, the entry is simply dropped.)
|
||||
|
||||
@ -337,7 +343,7 @@ cross-reference to catch gaps:
|
||||
|
||||
```bash
|
||||
# List all merged PRs in the milestone
|
||||
python3 tools/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
|
||||
python3 scripts/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
|
||||
|
||||
# Extract PR numbers from the changelog section
|
||||
python3 -c "
|
||||
@ -378,7 +384,7 @@ changelog or is legitimately excluded (check its labels).
|
||||
Also verify that no closed-unmerged PRs remain in the changelog:
|
||||
|
||||
```bash
|
||||
python3 tools/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
|
||||
python3 scripts/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
closed = [p for p in data if p['state'] == 'CLOSED']
|
||||
@ -483,13 +489,13 @@ def fmt_issue_list(nums):
|
||||
|
||||
# --- Fetch milestone data ---
|
||||
result = subprocess.run(
|
||||
["python3", "tools/gh.py", "issues", MILESTONE, "--state", "all"],
|
||||
["python3", "scripts/gh.py", "issues", MILESTONE, "--state", "all"],
|
||||
capture_output=True, text=True)
|
||||
all_issues = json.loads(result.stdout)
|
||||
issue_by_num = {i['number']: i for i in all_issues}
|
||||
|
||||
result = subprocess.run(
|
||||
["python3", "tools/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
|
||||
["python3", "scripts/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
|
||||
capture_output=True, text=True)
|
||||
all_prs = json.loads(result.stdout)
|
||||
pr_by_num = {p['number']: p for p in all_prs}
|
||||
@ -728,7 +734,7 @@ self-contained and clickable in any Markdown viewer.
|
||||
reference if applicable.
|
||||
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
|
||||
before making edits, don't rely on cached data.
|
||||
- **Use `tools/gh.py`.** Prefer the helper script over raw `gh api` calls for
|
||||
- **Use `scripts/gh.py`.** Prefer the helper script over raw `gh api` calls for
|
||||
milestone issue listing and PR detail fetching. It handles GraphQL
|
||||
pagination, batching, and label filtering automatically.
|
||||
- **Verify PR merge status.** Not all closing PRs are merged — community PRs
|
||||
@ -739,7 +745,7 @@ self-contained and clickable in any Markdown viewer.
|
||||
labels. Check both.
|
||||
- **Cross-reference milestone PRs, not just issues.** The `--compare` flag on
|
||||
the `issues` command only compares issue numbers. Merged PRs not linked to
|
||||
any milestone issue can be missed. Use `python3 tools/gh.py prs --milestone`
|
||||
any milestone issue can be missed. Use `python3 scripts/gh.py prs --milestone`
|
||||
for a full PR cross-reference.
|
||||
- **False-positive PR-to-issue associations.** A PR may claim to close an
|
||||
issue from a different project or context. If the PR title and issue title
|
||||
|
||||
@ -39,8 +39,8 @@ Backend RPC command areas without focused memories include access tokens, binfil
|
||||
|
||||
Database migrations live in `backend/src/app/migrations/`; pure SQL migrations are under `backend/src/app/migrations/sql/`. SQL filenames conventionally start with a sequence and verb/table description, e.g. `0026-mod-profile-table-add-is-active-field`. Applied migrations are tracked in the `migrations` table.
|
||||
|
||||
For interactive PostgreSQL access with correct dev defaults, use `tools/psql`; to dump
|
||||
the current DDL schema, use `tools/db-schema` (see `mem:tools/psql`).
|
||||
For interactive PostgreSQL access with correct dev defaults, use `scripts/psql`; to dump
|
||||
the current DDL schema, use `scripts/db-schema` (see `mem:scripts/psql`).
|
||||
|
||||
For deeper details on transaction semantics, advisory locks, Transit vs JSON helpers, and dev/test DB URLs: `mem:backend/rpc-db-worker-subtleties`.
|
||||
|
||||
@ -56,14 +56,14 @@ In devenv, backend nREPL is exposed on port 6064.
|
||||
|
||||
### Non-interactive eval (preferred for agents)
|
||||
|
||||
`./tools/nrepl-eval.mjs` connects to an already-running nREPL server and evaluates code. Session state (defs, `in-ns`) persists across invocations via a stored session ID in `/tmp/penpot-nrepl-session-<host>-<port>`.
|
||||
`./scripts/nrepl-eval.mjs` connects to an already-running nREPL server and evaluates code. Session state (defs, `in-ns`) persists across invocations via a stored session ID in `/tmp/penpot-nrepl-session-<host>-<port>`.
|
||||
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs '(+ 1 2)' # single expression
|
||||
./tools/nrepl-eval.mjs "(require '[my.ns :as ns] :reload)" # reload after edits
|
||||
./tools/nrepl-eval.mjs -e # inspect last exception (*e)
|
||||
./tools/nrepl-eval.mjs --reset-session '(def x 0)' # discard session, start fresh
|
||||
./tools/nrepl-eval.mjs <<'EOF' # multi-expression heredoc
|
||||
./scripts/nrepl-eval.mjs '(+ 1 2)' # single expression
|
||||
./scripts/nrepl-eval.mjs "(require '[my.ns :as ns] :reload)" # reload after edits
|
||||
./scripts/nrepl-eval.mjs -e # inspect last exception (*e)
|
||||
./scripts/nrepl-eval.mjs --reset-session '(def x 0)' # discard session, start fresh
|
||||
./scripts/nrepl-eval.mjs <<'EOF' # multi-expression heredoc
|
||||
(def x 10)
|
||||
(+ x 20)
|
||||
EOF
|
||||
@ -92,12 +92,12 @@ Fixtures can populate local data for manual testing/perf work. From the backend
|
||||
|
||||
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
|
||||
|
||||
* **Linting:** `pnpm run lint` from the repository root.
|
||||
* **Formatting:** `pnpm run check-fmt`. Use `pnpm run fmt` to fix. Avoid unrelated whitespace diffs.
|
||||
* **Linting:** `clj-kondo --lint ../common/src/ src/`.
|
||||
* **Formatting:** `cljfmt check src/ test/` to check, `cljfmt fix src/ test/` to fix. Avoid unrelated whitespace diffs.
|
||||
|
||||
**Before linting:** if delimiter errors are suspected (after LLM edits), run
|
||||
`tools/paren-repair.bb` on the affected files first. Delimiter errors produce
|
||||
misleading linter/compiler output. See `mem:tools/paren-repair`.
|
||||
`scripts/paren-repair` on the affected files first. Delimiter errors produce
|
||||
misleading linter/compiler output. See `mem:scripts/paren-repair`.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@ -22,23 +22,23 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
- Align `let` binding values: when a `let` form has multiple bindings spanning
|
||||
several lines, align the value forms to the same column with spaces.
|
||||
- If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files,
|
||||
fix them with `tools/paren-repair.bb` BEFORE running lint/format checks.
|
||||
See `mem:tools/paren-repair` for usage.
|
||||
fix them with `scripts/paren-repair` BEFORE running lint/format checks.
|
||||
See `mem:scripts/paren-repair` for usage.
|
||||
- Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra.
|
||||
|
||||
# Project modules
|
||||
|
||||
This is a monorepo. Principles that apply to one module do *not* generally apply to others. Do not make assumptions.
|
||||
|
||||
- `frontend/`: ClojureScript + SCSS SPA/design editor.
|
||||
- `backend/`: JVM Clojure HTTP/RPC server with PostgreSQL, Redis, storage, mail, and workers.Runtime services and the task-queue vs Pub/Sub topology that constrains horizontal scaling: `mem:prod-infra/core`.
|
||||
- `common/`: shared CLJC data types, geometry, schemas, file/change logic, and utilities.
|
||||
- `render-wasm/`: Rust -> WebAssembly Skia renderer consumed by frontend.
|
||||
- `exporter/`: ClojureScript/Node headless Playwright SVG/PDF export.
|
||||
- `mcp/`: TypeScript Model Context Protocol integration.
|
||||
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types.
|
||||
- `library/`: design library workflows.
|
||||
- `docs/`: documentation site.
|
||||
- `frontend/`: ClojureScript + SCSS SPA/design editor; core conventions: `mem:frontend/core`.
|
||||
- `backend/`: JVM Clojure HTTP/RPC server with PostgreSQL, Redis, storage, mail, and workers; core conventions: `mem:backend/core`. Runtime services and the task-queue vs Pub/Sub topology that constrains horizontal scaling: `mem:prod-infra/core`.
|
||||
- `common/`: shared CLJC data types, geometry, schemas, file/change logic, and utilities; core conventions: `mem:common/core`.
|
||||
- `render-wasm/`: Rust -> WebAssembly Skia renderer consumed by frontend; core conventions: `mem:render-wasm/core`.
|
||||
- `exporter/`: ClojureScript/Node headless Playwright SVG/PDF export; core conventions: `mem:exporter/core`.
|
||||
- `mcp/`: TypeScript Model Context Protocol integration; core conventions: `mem:mcp/core`.
|
||||
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
|
||||
- `library/`: design library workflows; core conventions: `mem:library/core`.
|
||||
- `docs/`: documentation site; core workflow and conventions: `mem:docs/core`.
|
||||
|
||||
The memory is structured in a way that you can get the critical information about the
|
||||
module. You can read it from `mem:<MODULE>/core`
|
||||
@ -54,18 +54,21 @@ module. You can read it from `mem:<MODULE>/core`
|
||||
|
||||
# Dev tools
|
||||
|
||||
- `tools/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
|
||||
- `scripts/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
|
||||
Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases.
|
||||
See `mem:tools/nrepl-eval`.
|
||||
- `tools/paren-repair.bb` — Fix mismatched delimiters in Clojure/CLJS files
|
||||
See `mem:scripts/nrepl-eval`.
|
||||
- `scripts/paren-repair` — Fix mismatched delimiters in Clojure/CLJS files
|
||||
and reformat with cljfmt. Run before lint checks when LLM edits break parens.
|
||||
See `mem:tools/paren-repair`.
|
||||
- `tools/psql` — PostgreSQL client wrapper with devenv defaults.
|
||||
Companion: `tools/db-schema` for DDL dumps. See `mem:tools/psql`.
|
||||
- `tools/taiga.py` — Fetch public issues, user stories, and tasks from the
|
||||
Penpot Taiga project without authentication. See `mem:tools/taiga`.
|
||||
- `tools/gh.py` — GitHub operations helper: list milestone issues, fetch PR
|
||||
details, compare against CHANGES.md. Requires `gh` CLI. See `mem:tools/gh`.
|
||||
See `mem:scripts/paren-repair`.
|
||||
- `scripts/psql` — PostgreSQL client wrapper with devenv defaults.
|
||||
Companion: `scripts/db-schema` for DDL dumps. See `mem:scripts/psql`.
|
||||
- `scripts/taiga.py` — Fetch public issues, user stories, and tasks from the
|
||||
Penpot Taiga project without authentication. See `mem:scripts/taiga`.
|
||||
- `scripts/gh.py` — GitHub operations helper: list milestone issues, fetch PR
|
||||
details, compare against CHANGES.md. Requires `gh` CLI. See `mem:scripts/gh`.
|
||||
- `scripts/error-reports.mjs` — Query error reports via RPC API with token
|
||||
authentication. Supports list/get operations with filtering and pagination.
|
||||
See `mem:scripts/error-reports`.
|
||||
|
||||
# Dependency graph
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Docs Workflow
|
||||
# Docs
|
||||
|
||||
`docs/`: Penpot documentation site; Eleventy.
|
||||
|
||||
@ -16,4 +16,4 @@ From `docs/`:
|
||||
- Build: `pnpm run build`.
|
||||
- Watch: `pnpm run watch`.
|
||||
|
||||
Documentation changes should follow the existing page structure and rendered Help Center conventions rather than inventing a new style locally.
|
||||
Documentation changes should follow the existing page structure and rendered Help Center conventions rather than inventing a new style locally.
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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!`
|
||||
|
||||
|
||||
@ -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.
|
||||
@ -1,7 +1,7 @@
|
||||
# nREPL Eval
|
||||
|
||||
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
|
||||
`tools/nrepl-eval.mjs` — a standalone CLI application.
|
||||
`scripts/nrepl-eval.mjs` — a standalone CLI application.
|
||||
|
||||
Session state (defs, in-ns, etc.) persists across invocations via a stored
|
||||
session ID, so you can build up state incrementally.
|
||||
@ -9,9 +9,9 @@ session ID, so you can build up state incrementally.
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
node tools/nrepl-eval.mjs [options] [<code>]
|
||||
node scripts/nrepl-eval.mjs [options] [<code>]
|
||||
# or
|
||||
./tools/nrepl-eval.mjs [options] [<code>]
|
||||
./scripts/nrepl-eval.mjs [options] [<code>]
|
||||
```
|
||||
|
||||
## Options
|
||||
@ -47,37 +47,37 @@ Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State
|
||||
carries across calls automatically:
|
||||
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs '(def x 42)'
|
||||
./tools/nrepl-eval.mjs 'x'
|
||||
./scripts/nrepl-eval.mjs '(def x 42)'
|
||||
./scripts/nrepl-eval.mjs 'x'
|
||||
# => 42
|
||||
```
|
||||
|
||||
Reset the session to start fresh:
|
||||
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs --reset-session '(def x 0)'
|
||||
./scripts/nrepl-eval.mjs --reset-session '(def x 0)'
|
||||
```
|
||||
|
||||
### Evaluate code
|
||||
|
||||
**Single expression (inline) — uses default port 6064:**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs '(+ 1 2 3)'
|
||||
./scripts/nrepl-eval.mjs '(+ 1 2 3)'
|
||||
```
|
||||
|
||||
**Backend nREPL (explicit):**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs --backend '(+ 1 2 3)'
|
||||
./scripts/nrepl-eval.mjs --backend '(+ 1 2 3)'
|
||||
```
|
||||
|
||||
**Frontend nREPL:**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs --frontend '(js/alert "hi")'
|
||||
./scripts/nrepl-eval.mjs --frontend '(js/alert "hi")'
|
||||
```
|
||||
|
||||
**Multiple expressions via heredoc (recommended — avoids escaping issues):**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs <<'EOF'
|
||||
./scripts/nrepl-eval.mjs <<'EOF'
|
||||
(def x 10)
|
||||
(+ x 20)
|
||||
EOF
|
||||
@ -85,7 +85,7 @@ EOF
|
||||
|
||||
**Override with a different port:**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
|
||||
./scripts/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
|
||||
```
|
||||
|
||||
### Inspect last exception
|
||||
@ -93,26 +93,48 @@ EOF
|
||||
After code throws an error, retrieve the full exception details:
|
||||
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs -e
|
||||
./scripts/nrepl-eval.mjs -e
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Require a namespace with reload:**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
|
||||
./scripts/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
|
||||
```
|
||||
|
||||
**Test a function:**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
|
||||
./scripts/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
|
||||
```
|
||||
|
||||
**Long-running operation with custom timeout:**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)"
|
||||
./scripts/nrepl-eval.mjs -t 300000 "(long-running-fn)"
|
||||
```
|
||||
|
||||
### Accessing Private Functions
|
||||
|
||||
Private functions (declared with `^:private` or `defn-`) cannot be called
|
||||
directly from outside their namespace. Use the var quote syntax `#'` to
|
||||
access the underlying var:
|
||||
|
||||
**This fails:**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs "(app.rpc.commands.error-reports/build-list-query {})"
|
||||
# => Syntax error: app.rpc.commands.error-reports/build-list-query is not public
|
||||
```
|
||||
|
||||
**This works:**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs "(#'app.rpc.commands.error-reports/build-list-query {})"
|
||||
# => Returns the result
|
||||
```
|
||||
|
||||
The `#'` reader macro resolves to `(var ...)`, giving you direct access to
|
||||
the var regardless of its visibility modifier. The syntax is `#'` followed
|
||||
by the fully qualified symbol.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Default port is 6064** — just pass code directly, no `-p` needed when
|
||||
43
.serena/memories/scripts/paren-repair.md
Normal file
43
.serena/memories/scripts/paren-repair.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Paren-Repair
|
||||
|
||||
`scripts/paren-repair` fixes mismatched parentheses, brackets, and braces in
|
||||
Clojure/ClojureScript files, then reformats them with cljfmt.
|
||||
|
||||
## When to use
|
||||
|
||||
- After LLM edits introduce broken delimiters — proactively run it on files
|
||||
you just touched.
|
||||
- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax
|
||||
errors mentioning mismatched/unclosed delimiters, reader errors, or
|
||||
unexpected EOF.
|
||||
- Before running lint/format checks — delimiter errors make linter output
|
||||
misleading. Fix them first, then lint.
|
||||
|
||||
## How to use (CLI)
|
||||
|
||||
```bash
|
||||
# File mode (in-place fix + format)
|
||||
bb scripts/paren-repair path/to/file.clj
|
||||
|
||||
# Pipe mode (stdin → fixed code to stdout)
|
||||
echo '(def x 1' | bb scripts/paren-repair
|
||||
|
||||
# Help
|
||||
bb scripts/paren-repair --help
|
||||
```
|
||||
`bb` must be invoked from the repo root so the path `scripts/paren-repair` resolves.
|
||||
|
||||
## Native Tool Available (opencode)
|
||||
|
||||
A native opencode tool `paren-repair` is available at `.opencode/tools/paren-repair.ts`.
|
||||
The LLM can call it directly with:
|
||||
- `files`: Array of file paths to fix
|
||||
- `code`: Code string to fix via stdin
|
||||
|
||||
Example usage by the LLM:
|
||||
```
|
||||
paren-repair(files="src/foo.clj, src/bar.cljs")
|
||||
paren-repair(code="(defn foo [x")
|
||||
```
|
||||
|
||||
|
||||
39
.serena/memories/scripts/psql.md
Normal file
39
.serena/memories/scripts/psql.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Psql
|
||||
|
||||
`scripts/psql` is a wrapper around `psql` that connects to the Penpot PostgreSQL
|
||||
database using environment variables (`PENPOT_DB_HOST`, `PENPOT_DB_USER`,
|
||||
`PENPOT_DB_PASSWORD`, `PENPOT_DB_NAME`) with sensible defaults for local
|
||||
development.
|
||||
|
||||
## When to use
|
||||
|
||||
- Running ad-hoc SQL queries against the Penpot database.
|
||||
- Inspecting schema, migrations, or data during development or debugging.
|
||||
|
||||
## How to use (CLI)
|
||||
|
||||
```bash
|
||||
# Default connection (penpot db, localhost)
|
||||
scripts/psql -c "SELECT version();"
|
||||
|
||||
# Test database
|
||||
scripts/psql --test -c "SELECT * FROM migrations;"
|
||||
|
||||
# Custom host/user/database
|
||||
scripts/psql --host myhost --user myuser --db mydb
|
||||
```
|
||||
|
||||
`scripts/psql` must be invoked from the repo root so the path resolves.
|
||||
|
||||
## Native Tool Available (opencode)
|
||||
|
||||
A native opencode tool `penpot-psql` is available. The LLM can call it directly
|
||||
with:
|
||||
- `sql`: SQL command string to execute
|
||||
- `test`: Boolean flag to use the `penpot_test` database
|
||||
|
||||
Example usage by the LLM:
|
||||
```
|
||||
penpot-psql(sql="SELECT version();")
|
||||
penpot-psql(sql="SELECT * FROM migrations;", test=true)
|
||||
```
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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
|
||||
|
||||
@ -4,10 +4,10 @@ PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<s
|
||||
|
||||
## Target Branch
|
||||
|
||||
Auto-detect the base branch with `tools/detect-target-branch`:
|
||||
Auto-detect the base branch with `scripts/detect-target-branch`:
|
||||
|
||||
```bash
|
||||
TARGET=$(tools/detect-target-branch)
|
||||
TARGET=$(scripts/detect-target-branch)
|
||||
```
|
||||
|
||||
This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails.
|
||||
@ -83,7 +83,7 @@ cat > /tmp/pr-body.md << 'PR_BODY'
|
||||
<body content here>
|
||||
PR_BODY
|
||||
|
||||
TARGET=$(tools/detect-target-branch)
|
||||
TARGET=$(scripts/detect-target-branch)
|
||||
|
||||
gh pr create \
|
||||
--repo penpot/penpot \
|
||||
|
||||
@ -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
|
||||
|
||||
20
AGENTS.md
20
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/<name>`)
|
||||
|
||||
- `scripts/paren-repair` — Fix mismatched delimiters in Clojure/CLJS files + reformat with cljfmt. See `mem:scripts/paren-repair`.
|
||||
- `scripts/psql` — Connect to the Penpot PostgreSQL database (wraps `psql` with env-var defaults). See `mem:scripts/psql`.
|
||||
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
|
||||
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
|
||||
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
|
||||
|
||||
|
||||
23
CHANGES.md
23
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
|
||||
|
||||
|
||||
@ -14,9 +14,9 @@ Center](https://help.penpot.app/).
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Pull Requests](#pull-requests)
|
||||
- [Workflow](#workflow)
|
||||
- [Title format](#title-format)
|
||||
- [Description](#description)
|
||||
- [Branch naming](#branch-naming)
|
||||
- [Format](#format)
|
||||
- [Title format](#title-format)
|
||||
- [Description](#description)
|
||||
- [Review process](#review-process)
|
||||
- [What we won't accept](#what-we-wont-accept)
|
||||
- [Good first issues](#good-first-issues)
|
||||
@ -73,35 +73,23 @@ Advisories](https://github.com/penpot/penpot/security/advisories)
|
||||
4. **Format and lint** — run the checks described in
|
||||
[Formatting and Linting](#formatting-and-linting) before submitting.
|
||||
|
||||
### Title format
|
||||
### Format
|
||||
|
||||
#### Title
|
||||
|
||||
> **IMPORTANT:** When a PR is squash-merged, the PR title becomes the
|
||||
> commit message on the main branch. Getting the title right matters.
|
||||
|
||||
Pull request titles **must** follow the same convention as commit subjects:
|
||||
|
||||
```
|
||||
:emoji: <subject>
|
||||
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
|
||||
```
|
||||
|
||||
- Use the **imperative mood** (e.g. "Fix", not "Fixed").
|
||||
- Capitalize the first letter of the subject.
|
||||
- Do not end the subject with a period.
|
||||
- Keep the subject to **70 characters** or fewer.
|
||||
- Use one of the [commit type emojis](#commit-types) listed below.
|
||||
Read [Creating Commits](./.serena/memories/workflow/creating-commits.md)
|
||||
for more concrete information.
|
||||
|
||||
When a PR contains multiple unrelated commits, choose the emoji that
|
||||
best represents the dominant change.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
:bug: Fix unexpected error on launching modal
|
||||
:sparkles: Enable new modal for profile
|
||||
:zap: Improve performance of dashboard navigation
|
||||
```
|
||||
|
||||
> **Note:** When a PR is squash-merged, the PR title becomes the
|
||||
> commit message on the main branch. Getting the title right matters.
|
||||
|
||||
### Description
|
||||
#### Description
|
||||
|
||||
Every pull request should include a description that helps reviewers
|
||||
understand the change quickly:
|
||||
@ -114,24 +102,8 @@ understand the change quickly:
|
||||
5. **Breaking changes** — call out anything that affects existing users
|
||||
or requires migration steps.
|
||||
|
||||
### Branch naming
|
||||
|
||||
Use a descriptive branch name that reflects the type and scope of the
|
||||
change:
|
||||
|
||||
```
|
||||
<type>/<short-description>
|
||||
```
|
||||
|
||||
Types: `fix`, `feat`, `refactor`, `docs`, `chore`, `perf`.
|
||||
|
||||
Optionally include the issue number:
|
||||
|
||||
```
|
||||
fix/9122-email-blacklisting
|
||||
feat/export-webp
|
||||
refactor/layout-sizing
|
||||
```
|
||||
Read [Creating PRs](./.serena/memories/workflow/creating-prs.md)
|
||||
for more concrete information.
|
||||
|
||||
### Review process
|
||||
|
||||
@ -151,10 +123,10 @@ refactor/layout-sizing
|
||||
To save time on both sides, please avoid submitting PRs that:
|
||||
|
||||
- Introduce new dependencies without prior discussion.
|
||||
- Change the build system or CI configuration without maintainer
|
||||
approval.
|
||||
- Mix unrelated changes in a single PR — keep PRs focused on one
|
||||
concern.
|
||||
- Change the build system or CI configuration without maintainer approval.
|
||||
- Mix unrelated changes in a single PR — keep PRs focused on one concern.
|
||||
- Submit AI-generated code without human review.
|
||||
- Skip local syntax and formatting checks before submitting.
|
||||
- Skip the [discussion step](#workflow) for non-bug-fix changes.
|
||||
|
||||
### Good first issues
|
||||
@ -217,36 +189,8 @@ Commit messages must follow this format:
|
||||
|
||||
## Formatting and Linting
|
||||
|
||||
We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
|
||||
[clj-kondo](https://github.com/clj-kondo/clj-kondo) for linting.
|
||||
|
||||
```bash
|
||||
# Check formatting (does not modify files)
|
||||
./scripts/check-fmt
|
||||
|
||||
# Fix formatting (modifies files in place)
|
||||
./scripts/fmt
|
||||
|
||||
# Lint
|
||||
./scripts/lint
|
||||
```
|
||||
|
||||
For frontend SCSS, we use `stylelint` for linting and
|
||||
`Prettier` for formatting:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Lint SCSS
|
||||
pnpm run lint:scss (does not modify files)
|
||||
|
||||
# Fix SCSS formatting (modifies files in place)
|
||||
pnpm run fmt:scss
|
||||
```
|
||||
|
||||
Ideally, run these as git pre-commit hooks.
|
||||
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
|
||||
setting this up.
|
||||
Each module has its own linting and formatting commands — see the relevant one on the
|
||||
[Serena Memories](./.serena/memories/)
|
||||
|
||||
## Changelog
|
||||
|
||||
|
||||
@ -186,7 +186,7 @@
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if
|
||||
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:50 }}”{% if
|
||||
organization %}
|
||||
part of the organization “{{ organization.name|abbreviate:50 }}”{% endif %}.</div>
|
||||
</td>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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();
|
||||
@ -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
|
||||
|
||||
@ -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))))
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")))))))
|
||||
|
||||
@ -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))))))
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)"; \
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -129,17 +129,18 @@ If you just want to try Penpot AI workflows quickly through the MCP, follow this
|
||||

|
||||
|
||||
4. #### Add the server to your MCP client
|
||||
In your MCP-aware IDE/agent (Cursor, Claude Code, etc.), add a new server pointing to that URL.
|
||||
**Example (generic JSON config):**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"penpot": {
|
||||
"url": "https://<your-penpot-domain>/mcp/stream?userToken=YOUR_MCP_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
We recommend using the [add-mcp](https://github.com/neon-solutions/add-mcp) project for connecting your MCP client to the Penpot MCP server.
|
||||
With `npx` available, call
|
||||
|
||||
```shell
|
||||
npx -y add-mcp -g -n penpot <URL>
|
||||
```
|
||||
|
||||
and follow the interactive setup. Alternatively, follow your client's instructions for connecting
|
||||
to a remote MCP server. For Claude Desktop, we recommend adding the Penpot MCP Server as a
|
||||
custom [connector](http://claude.ai/customize/connectors).
|
||||
See the section **Connect your MCP client** for more details on how to connect.
|
||||
|
||||
5. #### Open a Penpot file and connect MCP
|
||||
In Penpot, open a design file and use **File → MCP Server → Connect** to connect the plugin to your current file.
|
||||
|
||||
@ -216,84 +217,21 @@ You can use Penpot MCP server in two main ways:
|
||||
|
||||
## Connect your MCP client
|
||||
|
||||
Use the same client setup flow for both modes. What changes is the server URL and authentication method.
|
||||
|
||||
### Connection values by mode
|
||||
|
||||
* **Remote MCP**
|
||||
* URL: `https://<your-penpot-domain>/mcp/stream?userToken=YOUR_MCP_KEY`
|
||||
* Auth: MCP key in `userToken`
|
||||
* URL (copy from your Penpot account overview): `https://<your-penpot-domain>/mcp/stream?userToken=YOUR_MCP_KEY`
|
||||
* Configure your MCP client with `npx -y add-mcp -g -n penpot <URL>` or by following your client's instructions for connecting to a remote MCP server.
|
||||
For Claude Desktop, the server can be added as a custom [connector](http://claude.ai/customize/connectors).
|
||||
* **Local MCP**
|
||||
* URL: `http://localhost:4401/mcp`
|
||||
* Auth: none (uses your active Penpot browser session)
|
||||
* Configure your MCP client with `npx -y add-mcp -g -n penpot http://localhost:4401/mcp` (adjust the port if you changed `PENPOT_MCP_SERVER_PORT`) or by following your client's instructions for connecting to an HTTP MCP server.
|
||||
|
||||
### Cursor
|
||||
|
||||
1. Open Cursor MCP/tool configuration.
|
||||
2. Add a Penpot MCP server entry:
|
||||
Note: For clients that do not support HTTP servers directly (like Claude Desktop), the local MCP server can be connected via [mcp-remote](https://github.com/geelen/mcp-remote) as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"penpot": {
|
||||
"url": "REMOTE_OR_LOCAL_URL",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace `REMOTE_OR_LOCAL_URL` with the URL for your mode.
|
||||
|
||||
### Claude Code
|
||||
|
||||
1. Open MCP configuration in Claude Code.
|
||||
2. Add a Penpot server with `http` transport and the URL for your mode.
|
||||
3. Restart Claude Code or reload tools.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"penpot": {
|
||||
"transport": "http",
|
||||
"url": "REMOTE_OR_LOCAL_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### VS Code / Copilot
|
||||
|
||||
1. Open external MCP server configuration in your extension/settings.
|
||||
2. Add Penpot with the URL for your mode.
|
||||
3. Save and reload tools.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp.servers": {
|
||||
"penpot": {
|
||||
"transport": "http",
|
||||
"url": "REMOTE_OR_LOCAL_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Codex / OpenCode etc
|
||||
|
||||
1. Use your client's "Add MCP server" flow.
|
||||
2. Set the URL for your mode.
|
||||
3. Reload tools and verify Penpot tools are available.
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"penpot": {
|
||||
"url": "REMOTE_OR_LOCAL_URL",
|
||||
"transport": {
|
||||
"type": "http"
|
||||
}
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "<URL>", "--allow-http"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -326,9 +264,8 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted
|
||||
<a id="connect-remote"></a>
|
||||
### Connect
|
||||
|
||||
For client-specific setup, use the shared section **Connect your MCP client**.
|
||||
|
||||
For remote mode, use the URL shown in **Your account → Integrations → MCP Server**, which includes your `userToken`.
|
||||
See the section **Connect your MCP client** for details on how to connect.
|
||||
|
||||
### Setup videos
|
||||
|
||||
@ -464,9 +401,7 @@ For advanced or repository-based workflows, see the [MCP README](https://github.
|
||||
<a id="connect-local"></a>
|
||||
### Connect
|
||||
|
||||
For client-specific setup, use the shared section **Connect your MCP client**.
|
||||
|
||||
For local mode, use `http://localhost:4401/mcp` with HTTP transport (no MCP key; authentication uses your active Penpot browser session).
|
||||
See the section **Connect your MCP client** for details on how to connect.
|
||||
|
||||
<a id="use-local"></a>
|
||||
### Use
|
||||
|
||||
@ -8,15 +8,15 @@ desc: Deploy your free Penpot plugins! Learn about Netlify, Cloudflare, Surge &
|
||||
|
||||
When it comes to deploying your plugin there are several platforms to choose from. Each platform has its unique features and benefits, so the choice depends on you.
|
||||
|
||||
In this guide you will found some options for static sites that have free plans.
|
||||
In this guide you will find some options for static sites that have free plans.
|
||||
|
||||
## 3.1. Building your project
|
||||
|
||||
The building may vary between frameworks but if you had previously configured your scripts in <code class="language-bash">package.json</code>, <code class="language-bash">npm run build</code> should work.
|
||||
|
||||
The resulting build should be located somewhere in the <code class="language-bash">dist/</code> folder, maybe somewhere else if you have configured so.
|
||||
The resulting build should be located somewhere in the <code class="language-bash">dist/</code> folder, or somewhere else if you configured it that way.
|
||||
|
||||
Be wary that some framework's builders can add additional folders like <code class="language-bash">apps/project-name/</code>, <code class="language-bash">project-name/</code> or <code class="language-bash">browser/</code>.
|
||||
Be wary that some framework builders can add additional folders like <code class="language-bash">apps/project-name/</code>, <code class="language-bash">project-name/</code> or <code class="language-bash">browser/</code>.
|
||||
|
||||
Examples:
|
||||
|
||||
@ -27,7 +27,7 @@ Examples:
|
||||
|
||||
### Create an account
|
||||
|
||||
You need a Netlify account if you don't already have one. You can <a target="_blank" href="https://app.netlify.com/signup">sign up</a> with Github, GItlab, BItbucket or via email and password.
|
||||
You need a Netlify account if you don't already have one. You can <a target="_blank" href="https://app.netlify.com/signup">sign up</a> with GitHub, GitLab, Bitbucket or via email and password.
|
||||
|
||||
### CORS issues
|
||||
|
||||
@ -82,7 +82,7 @@ npm run build
|
||||
|
||||
2. Go to <a target="_blank" href="https://app.netlify.com/drop">Netlify Drop</a>.
|
||||
|
||||
3. Drag and drop the build folder into Netlify Sites. Dropping the whole dist may not work, you should drop the folder where the main files are located.
|
||||
3. Drag and drop the build folder into Netlify Sites. Dropping the whole dist may not work; you should drop the folder where the main files are located.
|
||||
|
||||
4. Done!
|
||||
|
||||
@ -122,11 +122,11 @@ Cloudflare allows you to import an existing project from GitHub or GitLab.
|
||||
|
||||

|
||||
|
||||
4. Configure your build settings.
|
||||
3. Configure your build settings.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
|
||||
|
||||
628
docs/technical-guide/developer/data-model/penpot-file-format.md
Normal file
628
docs/technical-guide/developer/data-model/penpot-file-format.md
Normal file
@ -0,0 +1,628 @@
|
||||
---
|
||||
title: 3.02.01. Penpot file format (.penpot)
|
||||
desc: Complete technical specification for the .penpot file format, including structure, schemas, and inspection methods.
|
||||
---
|
||||
|
||||
# Penpot File Format (.penpot)
|
||||
|
||||
The `.penpot` file format is Penpot's native export format for design files. It's a ZIP archive containing JSON metadata and binary assets, designed to be open, inspectable, and efficient.
|
||||
|
||||
## Overview
|
||||
|
||||
The `.penpot` format (version 3) uses a ZIP container with JSON files for metadata and binary files for media assets. This approach provides several advantages:
|
||||
|
||||
- **Open and inspectable**: All metadata is human-readable JSON
|
||||
- **Efficient**: ZIP compression reduces file size
|
||||
- **Interoperable**: Standard formats enable third-party tooling
|
||||
- **Versioned**: Clear versioning system for format and data evolution
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| v1 | Custom binary format | Deprecated |
|
||||
| v2 | SQLite-based format | Never released (internal only) |
|
||||
| v3 | ZIP + JSON format | **Current** |
|
||||
|
||||
## File Structure
|
||||
|
||||
A `.penpot` file contains the following structure:
|
||||
|
||||
```
|
||||
pencil.penpot (ZIP archive)
|
||||
├── manifest.json # Root metadata
|
||||
├── files/
|
||||
│ ├── {file-id}.json # File metadata
|
||||
│ └── {file-id}/
|
||||
│ ├── pages/
|
||||
│ │ ├── {page-id}.json # Page metadata
|
||||
│ │ └── {page-id}/
|
||||
│ │ └── {shape-id}.json # Individual shapes
|
||||
│ ├── media/
|
||||
│ │ └── {media-id}.json # Media references
|
||||
│ ├── colors/
|
||||
│ │ └── {color-id}.json # Library colors
|
||||
│ ├── components/
|
||||
│ │ └── {component-id}.json # Library components
|
||||
│ ├── typographies/
|
||||
│ │ └── {typography-id}.json # Library typographies
|
||||
│ ├── tokens.json # Design tokens library
|
||||
│ └── thumbnails/
|
||||
│ └── {tag}/{page-id}/{frame-id}.json # Thumbnail metadata
|
||||
└── objects/
|
||||
├── {uuid}.json # Storage object metadata
|
||||
└── {uuid}.{ext} # Binary media files (png, jpg, etc.)
|
||||
```
|
||||
|
||||
## Manifest Specification
|
||||
|
||||
The `manifest.json` file is the root of the archive and contains metadata about the export.
|
||||
|
||||
### Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"type": "penpot/export-files",
|
||||
"generatedBy": "penpot/2.12.0",
|
||||
"refer": "penpot",
|
||||
"files": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "File Name",
|
||||
"features": ["feature1", "feature2"]
|
||||
}
|
||||
],
|
||||
"relations": []
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `version` | integer | Yes | Format version (currently `1`) |
|
||||
| `type` | string | Yes | Must be `"penpot/export-files"` |
|
||||
| `generatedBy` | string | No | Penpot version that created the file |
|
||||
| `refer` | string | No | Source system (typically `"penpot"`) |
|
||||
| `files` | array | Yes | List of files in the archive |
|
||||
| `files[].id` | UUID | Yes | File identifier |
|
||||
| `files[].name` | string | Yes | File name |
|
||||
| `files[].features` | array | Yes | Set of feature flags |
|
||||
| `relations` | array | No | Library relationships `[file-id, library-id]` |
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "penpot/export-files",
|
||||
"version": 1,
|
||||
"generatedBy": "penpot/2.12.0-RC1-99-g40c27591f",
|
||||
"refer": "penpot",
|
||||
"files": [
|
||||
{
|
||||
"id": "73b59a94-3ea3-8189-8007-3d36adc8c3e3",
|
||||
"name": "Pencil | Penpot Design System",
|
||||
"features": [
|
||||
"fdata/path-data",
|
||||
"design-tokens/v1",
|
||||
"variants/v1",
|
||||
"layout/grid",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relations": []
|
||||
}
|
||||
```
|
||||
|
||||
## File Metadata
|
||||
|
||||
Each file has a JSON file at `files/{file-id}.json` containing the file's metadata.
|
||||
|
||||
### Schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | UUID | Yes | File identifier |
|
||||
| `name` | string | Yes | File name |
|
||||
| `revn` | integer | Yes | Revision number |
|
||||
| `vern` | integer | No | Version number |
|
||||
| `createdAt` | timestamp | Yes | Creation timestamp |
|
||||
| `modifiedAt` | timestamp | Yes | Last modification timestamp |
|
||||
| `deletedAt` | timestamp | No | Deletion timestamp (if soft-deleted) |
|
||||
| `projectId` | UUID | No | Project identifier |
|
||||
| `teamId` | UUID | No | Team identifier |
|
||||
| `isShared` | boolean | No | Whether file is a shared library |
|
||||
| `hasMediaTrimmed` | boolean | No | Whether media has been trimmed |
|
||||
| `features` | array | Yes | Set of enabled feature flags |
|
||||
| `migrations` | array | No | List of applied data migrations |
|
||||
| `options` | object | No | File-level options |
|
||||
|
||||
### Features
|
||||
|
||||
The `features` field is a set of strings indicating which features are enabled in the file. Common features include:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| `fdata/path-data` | Path data format |
|
||||
| `fdata/shape-data-type` | Shape data type system |
|
||||
| `design-tokens/v1` | Design tokens support |
|
||||
| `variants/v1` | Component variants |
|
||||
| `layout/grid` | Grid layout system |
|
||||
| `components/v2` | Component system v2 |
|
||||
| `plugins/runtime` | Plugin runtime support |
|
||||
|
||||
### Migrations
|
||||
|
||||
The `migrations` field lists all data migrations applied to the file. This ensures backward compatibility when the data model evolves.
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "73b59a94-3ea3-8189-8007-3d36adc8c3e3",
|
||||
"name": "Pencil | Penpot Design System",
|
||||
"revn": 28425,
|
||||
"vern": 0,
|
||||
"createdAt": "2025-12-10T10:24:18.686066Z",
|
||||
"modifiedAt": "2025-12-10T12:13:49.799076Z",
|
||||
"teamId": "b62e1aa4-d9a7-8147-8005-2813bed4056e",
|
||||
"projectId": "f23add0e-6b77-8069-8005-41b48b93a5da",
|
||||
"isShared": true,
|
||||
"features": [
|
||||
"fdata/path-data",
|
||||
"design-tokens/v1",
|
||||
"variants/v1",
|
||||
"layout/grid",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
],
|
||||
"migrations": [
|
||||
"legacy-2",
|
||||
"legacy-3",
|
||||
"0001-remove-tokens-from-groups",
|
||||
"0002-normalize-bool-content-v2"
|
||||
],
|
||||
"options": {
|
||||
"componentsV2": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pages and Shapes
|
||||
|
||||
### Page Structure
|
||||
|
||||
Each page is stored at `files/{file-id}/pages/{page-id}.json`.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | UUID | Yes | Page identifier |
|
||||
| `name` | string | Yes | Page name |
|
||||
| `index` | integer | No | Page order in the file |
|
||||
| `options` | object | No | Page options (guides, etc.) |
|
||||
| `background` | string | No | Background color (hex) |
|
||||
| `flows` | object | No | Prototype flows |
|
||||
| `guides` | object | No | Ruler guides |
|
||||
|
||||
### Shapes
|
||||
|
||||
Individual shapes are stored at `files/{file-id}/pages/{page-id}/{shape-id}.json`.
|
||||
|
||||
#### Shape Types
|
||||
|
||||
Penpot supports 9 shape types:
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `frame` | Container frame (artboard) |
|
||||
| `group` | Group of shapes |
|
||||
| `rect` | Rectangle |
|
||||
| `circle` | Circle/Ellipse |
|
||||
| `path` | Vector path |
|
||||
| `text` | Text shape |
|
||||
| `image` | Image |
|
||||
| `bool` | Boolean operation |
|
||||
| `svg-raw` | Raw SVG element |
|
||||
|
||||
#### Base Shape Attributes
|
||||
|
||||
All shapes share these base attributes:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | UUID | Yes | Shape identifier |
|
||||
| `name` | string | Yes | Shape name |
|
||||
| `type` | string | Yes | Shape type (see above) |
|
||||
| `selrect` | object | Yes | Selection rectangle `{x, y, width, height}` |
|
||||
| `points` | array | Yes | Array of points `[{x, y}, ...]` |
|
||||
| `transform` | array | Yes | 2D transformation matrix |
|
||||
| `transformInverse` | array | Yes | Inverse transformation matrix |
|
||||
| `parentId` | UUID | Yes | Parent shape identifier |
|
||||
| `frameId` | UUID | Yes | Containing frame identifier |
|
||||
|
||||
#### Geometry Attributes
|
||||
|
||||
Shapes with geometry (frame, rect, circle, image, svg-raw, text):
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `x` | number | Yes | X position |
|
||||
| `y` | number | Yes | Y position |
|
||||
| `width` | number | Yes | Width |
|
||||
| `height` | number | Yes | Height |
|
||||
|
||||
#### Generic Attributes
|
||||
|
||||
Optional attributes available on all shapes:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `fills` | array | Fill styles |
|
||||
| `strokes` | array | Stroke styles |
|
||||
| `opacity` | number | Opacity (0-1) |
|
||||
| `blendMode` | string | Blend mode |
|
||||
| `shadow` | array | Shadow effects |
|
||||
| `blur` | object | Blur effect |
|
||||
| `constraintsH` | string | Horizontal constraint |
|
||||
| `constraintsV` | string | Vertical constraint |
|
||||
| `r1`, `r2`, `r3`, `r4` | number | Border radius corners |
|
||||
| `blocked` | boolean | Shape is locked |
|
||||
| `hidden` | boolean | Shape is hidden |
|
||||
| `collapsed` | boolean | Shape is collapsed |
|
||||
| `componentId` | UUID | Component reference |
|
||||
| `componentFile` | UUID | Component library file |
|
||||
| `shapeRef` | UUID | Shape reference for components |
|
||||
| `touched` | array | Modified component properties |
|
||||
| `interactions` | array | Prototype interactions |
|
||||
| `exports` | array | Export settings |
|
||||
| `grids` | array | Grid configurations |
|
||||
| `appliedTokens` | object | Applied design tokens |
|
||||
| `pluginData` | object | Plugin-specific data |
|
||||
|
||||
#### Type-Specific Attributes
|
||||
|
||||
**Frame**
|
||||
- `shapes`: array of child shape UUIDs
|
||||
- `showContent`: boolean
|
||||
- `hideInViewer`: boolean
|
||||
|
||||
**Group**
|
||||
- `shapes`: array of child shape UUIDs
|
||||
|
||||
**Bool**
|
||||
- `shapes`: array of child shape UUIDs
|
||||
- `boolType`: string (`union`, `difference`, `exclude`, `intersection`)
|
||||
- `content`: path data
|
||||
|
||||
**Path**
|
||||
- `content`: path data (SVG path commands)
|
||||
|
||||
**Text**
|
||||
- `content`: text content with formatting
|
||||
- `positionData`: glyph position data
|
||||
|
||||
**Image**
|
||||
- `metadata`: object with `width`, `height`, `mtype`, `id`
|
||||
|
||||
### Example Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "260aea33-4e55-808c-8007-3d4f2efe4230",
|
||||
"name": "Rectangle",
|
||||
"type": "rect",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"width": 200,
|
||||
"height": 150,
|
||||
"selrect": {
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"width": 200,
|
||||
"height": 150
|
||||
},
|
||||
"points": [
|
||||
{"x": 100, "y": 100},
|
||||
{"x": 300, "y": 100},
|
||||
{"x": 300, "y": 250},
|
||||
{"x": 100, "y": 250}
|
||||
],
|
||||
"transform": [1, 0, 0, 1, 0, 0],
|
||||
"transformInverse": [1, 0, 0, 1, 0, 0],
|
||||
"parentId": "00000000-0000-0000-0000-000000000000",
|
||||
"frameId": "00000000-0000-0000-0000-000000000001",
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FF5733",
|
||||
"opacity": 1
|
||||
}
|
||||
],
|
||||
"r1": 8,
|
||||
"r2": 8,
|
||||
"r3": 8,
|
||||
"r4": 8
|
||||
}
|
||||
```
|
||||
|
||||
## Library Assets
|
||||
|
||||
### Colors
|
||||
|
||||
Stored at `files/{file-id}/colors/{color-id}.json`.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | UUID | Yes | Color identifier |
|
||||
| `name` | string | Yes | Color name |
|
||||
| `path` | string | No | Path in the library tree |
|
||||
| `opacity` | number | No | Opacity (0-1) |
|
||||
| `color` | string | Conditional | Hex color (for plain colors) |
|
||||
| `gradient` | object | Conditional | Gradient definition |
|
||||
| `image` | object | Conditional | Image fill definition |
|
||||
|
||||
#### Plain Color Example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "abc123...",
|
||||
"name": "Primary Blue",
|
||||
"path": "Brand/Primary",
|
||||
"color": "#0066CC",
|
||||
"opacity": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### Gradient Color Example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "def456...",
|
||||
"name": "Sunset Gradient",
|
||||
"gradient": {
|
||||
"type": "linear",
|
||||
"startX": 0,
|
||||
"startY": 0,
|
||||
"endX": 1,
|
||||
"endY": 1,
|
||||
"stops": [
|
||||
{"color": "#FF6B6B", "offset": 0, "opacity": 1},
|
||||
{"color": "#4ECDC4", "offset": 1, "opacity": 1}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
Stored at `files/{file-id}/components/{component-id}.json`.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | UUID | Yes | Component identifier |
|
||||
| `name` | string | Yes | Component name |
|
||||
| `path` | string | Yes | Path in the library tree |
|
||||
| `mainInstanceId` | UUID | Yes | Root shape of main instance |
|
||||
| `mainInstancePage` | UUID | Yes | Page containing main instance |
|
||||
| `modifiedAt` | timestamp | No | Last modification |
|
||||
| `objects` | object | No | Captured shapes (if deleted) |
|
||||
|
||||
### Typographies
|
||||
|
||||
Stored at `files/{file-id}/typographies/{typography-id}.json`.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | UUID | Yes | Typography identifier |
|
||||
| `name` | string | Yes | Typography name |
|
||||
| `fontId` | string | Yes | Font identifier |
|
||||
| `fontFamily` | string | Yes | Font family name |
|
||||
| `fontVariantId` | string | Yes | Font variant |
|
||||
| `fontSize` | string | Yes | Font size |
|
||||
| `fontWeight` | string | Yes | Font weight |
|
||||
| `fontStyle` | string | Yes | Font style |
|
||||
| `lineHeight` | string | Yes | Line height |
|
||||
| `letterSpacing` | string | Yes | Letter spacing |
|
||||
| `textTransform` | string | Yes | Text transform |
|
||||
|
||||
### Design Tokens
|
||||
|
||||
Stored at `files/{file-id}/tokens.json`.
|
||||
|
||||
The tokens library contains:
|
||||
|
||||
- **Sets**: Collections of tokens organized hierarchically
|
||||
- **Themes**: Named combinations of token sets
|
||||
- **Active Themes**: Currently applied themes
|
||||
|
||||
#### Token Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"sets": {
|
||||
"core": {
|
||||
"id": "uuid",
|
||||
"name": "Core",
|
||||
"tokens": {
|
||||
"color": {
|
||||
"primary": {
|
||||
"id": "uuid",
|
||||
"name": "primary",
|
||||
"type": "color",
|
||||
"value": "#0066CC"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"themes": {
|
||||
"light": {
|
||||
"id": "uuid",
|
||||
"name": "Light",
|
||||
"sets": ["core"]
|
||||
}
|
||||
},
|
||||
"activeThemes": ["light"]
|
||||
}
|
||||
```
|
||||
|
||||
## Media and Storage Objects
|
||||
|
||||
### Storage Objects
|
||||
|
||||
Binary assets (images, fonts, etc.) are stored in the `objects/` directory.
|
||||
|
||||
Each storage object has:
|
||||
- `objects/{uuid}.json` - Metadata
|
||||
- `objects/{uuid}.{ext}` - Binary content (png, jpg, svg, etc.)
|
||||
|
||||
#### Metadata Schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | UUID | Yes | Storage object identifier |
|
||||
| `size` | integer | Yes | File size in bytes |
|
||||
| `contentType` | string | Yes | MIME type |
|
||||
| `bucket` | string | Yes | Storage bucket |
|
||||
| `hash` | string | No | Content hash (blake2b) |
|
||||
|
||||
#### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "0039433d-adc8-430d-b2c3-d884dea6e050",
|
||||
"size": 575,
|
||||
"contentType": "image/png",
|
||||
"bucket": "file-media-object",
|
||||
"hash": "blake2b:77d447db38eb5daf31acb7344a504cacc6b79aa11855a00501d9475c595053d0"
|
||||
}
|
||||
```
|
||||
|
||||
### Media References
|
||||
|
||||
File media references are stored at `files/{file-id}/media/{media-id}.json`.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | UUID | Yes | Media identifier |
|
||||
| `name` | string | Yes | File name |
|
||||
| `width` | integer | Yes | Image width |
|
||||
| `height` | integer | Yes | Image height |
|
||||
| `mtype` | string | Yes | MIME type |
|
||||
| `mediaId` | UUID | Yes | Reference to storage object |
|
||||
| `thumbnaillId` | UUID | No | Reference to thumbnail |
|
||||
| `isLocal` | boolean | No | Whether media is local to file |
|
||||
|
||||
### Thumbnails
|
||||
|
||||
Page thumbnails are stored at `files/{file-id}/thumbnails/{tag}/{page-id}/{frame-id}.json`.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `fileId` | UUID | Yes | File identifier |
|
||||
| `pageId` | UUID | Yes | Page identifier |
|
||||
| `frameId` | UUID | Yes | Frame identifier |
|
||||
| `tag` | string | Yes | Thumbnail tag |
|
||||
| `mediaId` | UUID | Yes | Reference to storage object |
|
||||
|
||||
## Plugin Data
|
||||
|
||||
Plugins can store custom data on files, pages, shapes, components, colors, and typographies using the `pluginData` field.
|
||||
|
||||
### Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"pluginData": {
|
||||
"plugin-id": {
|
||||
"key1": "value1",
|
||||
"key2": "value2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The plugin ID is a keyword (e.g., `"my-plugin"`), and the values are string key-value pairs.
|
||||
|
||||
## Inspecting a .penpot File
|
||||
|
||||
### List Contents
|
||||
|
||||
```bash
|
||||
unzip -l design.penpot
|
||||
```
|
||||
|
||||
### Extract and View
|
||||
|
||||
```bash
|
||||
# Extract to temporary directory
|
||||
unzip design.penpot -d /tmp/penpot-inspect
|
||||
|
||||
# View manifest
|
||||
cat /tmp/penpot-inspect/manifest.json | jq .
|
||||
|
||||
# List all files
|
||||
find /tmp/penpot-inspect -name "*.json" | head -20
|
||||
|
||||
# View a specific shape
|
||||
cat /tmp/penpot-inspect/files/*/pages/*/*.json | jq .
|
||||
```
|
||||
|
||||
### Browser-Based Inspector
|
||||
|
||||
For an interactive way to explore a `.penpot` file, try the [**Penpot file inspector**](/technical-guide/developer/data-model/penpot-file-inspector/). It runs entirely in your browser, displays a collapsible file tree, syntax-highlighted JSON, image previews, and clickable cross-references between shapes, components, colors, and media.
|
||||
|
||||
### Quick Inspection Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Inspect .penpot file structure
|
||||
|
||||
FILE=$1
|
||||
TMPDIR=$(mktemp -d)
|
||||
|
||||
unzip -q "$FILE" -d "$TMPDIR"
|
||||
|
||||
echo "=== Manifest ==="
|
||||
cat "$TMPDIR/manifest.json" | jq '{type, version, files: [.files[] | {id, name}]}'
|
||||
|
||||
echo -e "\n=== Files ==="
|
||||
for f in "$TMPDIR"/files/*.json; do
|
||||
echo "- $(jq -r '.name' "$f")"
|
||||
done
|
||||
|
||||
echo -e "\n=== Pages ==="
|
||||
for f in "$TMPDIR"/files/*/pages/*.json; do
|
||||
echo "- $(jq -r '.name' "$f")"
|
||||
done
|
||||
|
||||
echo -e "\n=== Storage Objects ==="
|
||||
ls -lh "$TMPDIR"/objects/*.{png,jpg,svg} 2>/dev/null | wc -l
|
||||
echo "media files"
|
||||
|
||||
rm -rf "$TMPDIR"
|
||||
```
|
||||
|
||||
## Cross-References
|
||||
|
||||
- [Penpot file inspector](/technical-guide/developer/data-model/penpot-file-inspector/) - Browser-based interactive inspector
|
||||
- [Data Model](/technical-guide/developer/data-model/) - Conceptual data model
|
||||
- [Data Guide](/technical-guide/developer/data-guide/) - Working with data structures
|
||||
- [Export/Import Files](/user-guide/export-import/export-import-files/) - User guide for exporting and importing
|
||||
|
||||
## Source Code References
|
||||
|
||||
The authoritative schema definitions are in the Penpot source code:
|
||||
|
||||
- **Manifest**: `backend/src/app/binfile/v3.clj` (schema:manifest)
|
||||
- **File**: `common/src/app/common/types/file.cljc` (schema:file)
|
||||
- **Page**: `common/src/app/common/types/page.cljc` (schema:page)
|
||||
- **Shape**: `common/src/app/common/types/shape.cljc` (schema:shape)
|
||||
- **Component**: `common/src/app/common/types/component.cljc` (schema:component)
|
||||
- **Color**: `common/src/app/common/types/color.cljc` (schema:library-color)
|
||||
- **Typography**: `common/src/app/common/types/typography.cljc` (schema:typography)
|
||||
- **Tokens**: `common/src/app/common/types/tokens_lib.cljc` (schema:tokens-lib)
|
||||
- **Plugin Data**: `common/src/app/common/types/plugins.cljc` (schema:plugin-data)
|
||||
- **Features**: `common/src/app/common/features.cljc` (schema:features)
|
||||
1401
docs/technical-guide/developer/data-model/penpot-file-inspector.njk
Normal file
1401
docs/technical-guide/developer/data-model/penpot-file-inspector.njk
Normal file
File diff suppressed because it is too large
Load Diff
@ -48,21 +48,22 @@ desc: Learn how to import and export files in Penpot, the free, open-source desi
|
||||
<figure><img src="/img/import-export/import-selection.webp" alt="Import penpot file" /></figure>
|
||||
|
||||
<h2 id="penpot-formats">Penpot file format</h2>
|
||||
<p>Penpot export to a unique format that streamline the import and export of files and assets by being more efficient and interoperable.</p>
|
||||
<p>Penpot exports to a unique format that streamlines the import and export of files and assets by being more efficient and interoperable.</p>
|
||||
<p>Unlike other design tools, <strong>Penpot's format is built on standard languages</strong>. The exported file is essentially a ZIP archive containing binary assets (such as bitmap and vector images) alongside a readable JSON structure. By avoiding proprietary formats, Penpot empowers users with autonomy from specific tools while enabling seamless third-party integrations.</p>
|
||||
<p>For a detailed look at what's inside a .penpot file, see <a href="/user-guide/export-import/penpot-file-format/">The .penpot file format</a>. Developers can also check the <a href="/technical-guide/developer/data-model/penpot-file-format/">technical specification</a>.</p>
|
||||
|
||||
<h3>Deprecated Penpot file formats</h3>
|
||||
<p class="advice">These formats can only be exported from version 2.3 or earlier versions, but can be imported to any Penpot version.</p>
|
||||
<p>There are two different deprecated Penpot file formats in which you can import/export Penpot files. A standard one and a binary one. You always have the chance to use both for any file.</p>
|
||||
<h4>[Deprecated] Penpot file (.penpot).</h4>
|
||||
<h4>[Deprecated] Binary file (.penpot) — v1</h4>
|
||||
<p>The fast one. Binary Penpot specific.</p>
|
||||
<ul>
|
||||
<li>✅ Highly efficient in terms of memory and transfer time when exporting and importing.</li>
|
||||
<li>❌ It can be opened only in Penpot.</li>
|
||||
<li>❌ Not transparent, code difficult to explore.</li>
|
||||
</ul>
|
||||
<h4>[Deprecated] Standard file (.zip).</h4>
|
||||
<p>The open one. A compressed file that includes SVG and JSON.</p>
|
||||
<h4>[Deprecated] Standard file (.zip) — v2 (never released)</h4>
|
||||
<p>The open one. A compressed file that includes SVG and JSON. This format was developed but never released to users.</p>
|
||||
<ul>
|
||||
<li>✅ Allows the file to be opened by other softwares (still, for those cases export to SVG seems to be the common practice).</li>
|
||||
<li>✅ Allows some automations and integrations.</li>
|
||||
|
||||
@ -13,7 +13,13 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
|
||||
<p>How to export and import your Penpot files</p>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<li>
|
||||
<a href="/user-guide/export-import/penpot-file-format/">
|
||||
<h2>The .penpot file format →</h2>
|
||||
<p>Understand what's inside a .penpot file and how to inspect it</p>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user-guide/export-import/exporting-layers/">
|
||||
<h2>Exporting layers →</h2>
|
||||
<p>How to export elements from your design into different file formats</p>
|
||||
|
||||
155
docs/user-guide/export-import/penpot-file-format.njk
Normal file
155
docs/user-guide/export-import/penpot-file-format.njk
Normal file
@ -0,0 +1,155 @@
|
||||
---
|
||||
title: The .penpot file format
|
||||
order: 2
|
||||
desc: Understand the .penpot file format, what's inside, and how to inspect your design files.
|
||||
---
|
||||
|
||||
<h1 id="penpot-file-format">The .penpot file format</h1>
|
||||
<p class="main-paragraph">Penpot's native file format is designed to be open, inspectable, and efficient. Unlike proprietary formats, you can always look inside a .penpot file to understand your design data.</p>
|
||||
|
||||
<h2 id="what-is-penpot-format">What is a .penpot file?</h2>
|
||||
|
||||
<p>A <code class="language-text">.penpot</code> file is a <strong>ZIP archive</strong> containing:</p>
|
||||
<ul>
|
||||
<li><strong>JSON metadata</strong> — human-readable descriptions of your pages, shapes, colors, components, and more</li>
|
||||
<li><strong>Binary media files</strong> — images, fonts, and other assets used in your design</li>
|
||||
</ul>
|
||||
|
||||
<p>This means your design data is never locked in a proprietary format. You can always unzip a .penpot file and read the JSON to understand what's inside.</p>
|
||||
|
||||
<h2 id="whats-inside">What's inside a .penpot file?</h2>
|
||||
|
||||
<p>When you unzip a .penpot file, you'll find this structure:</p>
|
||||
|
||||
<figure>
|
||||
<pre class="language-text"><code>your-design.penpot (ZIP archive)
|
||||
├── manifest.json ← Table of contents
|
||||
├── files/ ← Your design data
|
||||
│ ├── file-metadata.json
|
||||
│ └── file-data/
|
||||
│ ├── pages/ ← Each page of your design
|
||||
│ ├── colors/ ← Library colors
|
||||
│ ├── components/ ← Library components
|
||||
│ ├── typographies/ ← Library typographies
|
||||
│ ├── tokens.json ← Design tokens
|
||||
│ └── media/ ← Media references
|
||||
└── objects/ ← Images and binary assets</code></pre>
|
||||
</figure>
|
||||
|
||||
<h3>manifest.json</h3>
|
||||
<p>The manifest is the table of contents. It tells you:</p>
|
||||
<ul>
|
||||
<li>Which version of the format is used</li>
|
||||
<li>Which Penpot version created the file</li>
|
||||
<li>What files are included</li>
|
||||
<li>What features are enabled</li>
|
||||
</ul>
|
||||
|
||||
<h3>File data</h3>
|
||||
<p>The <code class="language-text">files/</code> directory contains all your design data organized by type:</p>
|
||||
<ul>
|
||||
<li><strong>Pages</strong> — Each page is split into individual shape files for efficiency</li>
|
||||
<li><strong>Colors</strong> — Your color library with hex values, gradients, or image fills</li>
|
||||
<li><strong>Components</strong> — Reusable component definitions</li>
|
||||
<li><strong>Typographies</strong> — Text style definitions</li>
|
||||
<li><strong>Tokens</strong> — Design tokens organized in sets and themes</li>
|
||||
<li><strong>Media</strong> — References to images and other assets</li>
|
||||
</ul>
|
||||
|
||||
<h3>Objects</h3>
|
||||
<p>The <code class="language-text">objects/</code> directory contains the actual binary files (PNG, JPG, SVG) referenced by your design. Each object has a JSON metadata file alongside the binary file.</p>
|
||||
|
||||
<h2 id="how-to-inspect">How to inspect a .penpot file</h2>
|
||||
|
||||
<p>Since .penpot files are just ZIP archives, you can inspect them with standard tools.</p>
|
||||
|
||||
<h3>On macOS or Linux</h3>
|
||||
|
||||
<p><strong>1. List the contents without extracting:</strong></p>
|
||||
<pre class="language-bash"><code>unzip -l your-design.penpot</code></pre>
|
||||
|
||||
<p><strong>2. Extract to a temporary folder:</strong></p>
|
||||
<pre class="language-bash"><code>unzip your-design.penpot -d /tmp/penpot-inspect</code></pre>
|
||||
|
||||
<p><strong>3. View the manifest:</strong></p>
|
||||
<pre class="language-bash"><code>cat /tmp/penpot-inspect/manifest.json | jq .</code></pre>
|
||||
|
||||
<p><strong>4. Browse the structure:</strong></p>
|
||||
<pre class="language-bash"><code>tree /tmp/penpot-inspect</code></pre>
|
||||
|
||||
<p><strong>5. View a specific shape:</strong></p>
|
||||
<pre class="language-bash"><code>cat /tmp/penpot-inspect/files/*/pages/*/*.json | jq .</code></pre>
|
||||
|
||||
<h3>On Windows</h3>
|
||||
|
||||
<p><strong>1. Right-click the .penpot file</strong> and select "Extract All..." or use 7-Zip.</p>
|
||||
|
||||
<p><strong>2. Browse the extracted folder</strong> to see the structure.</p>
|
||||
|
||||
<p><strong>3. Open any JSON file</strong> with a text editor or use a JSON viewer tool.</p>
|
||||
|
||||
<h3>Using AI tools</h3>
|
||||
|
||||
<p>You can use the <a href="/mcp/">Penpot MCP server</a> to inspect .penpot files with AI assistance. The MCP server can read and analyze your design files, making it easy to understand complex designs or automate tasks.</p>
|
||||
|
||||
<h2 id="versioning">Format versions</h2>
|
||||
|
||||
<p>The .penpot format has evolved over time:</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>v3</strong></td>
|
||||
<td>ZIP + JSON format (current)</td>
|
||||
<td>✅ Current</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>v1</td>
|
||||
<td>Custom binary format</td>
|
||||
<td>⚠️ Deprecated</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="advice">All current Penpot versions export in v3 format. Old v1 files can still be imported, but new exports will use v3.</p>
|
||||
|
||||
<h3>Version numbers inside the file</h3>
|
||||
|
||||
<p>Inside a .penpot file, you'll see two different version numbers:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Format version</strong> (in <code class="language-text">manifest.json</code>) — Tracks changes to the ZIP structure itself. Currently <code class="language-text">1</code>.</li>
|
||||
<li><strong>Data version</strong> (in each file's JSON) — Tracks changes to the data model. This number increases as Penpot evolves.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Feature flags</h3>
|
||||
|
||||
<p>Files also include a list of <strong>feature flags</strong> that indicate which capabilities are used. For example:</p>
|
||||
|
||||
<ul>
|
||||
<li><code class="language-text">design-tokens/v1</code> — Uses design tokens</li>
|
||||
<li><code class="language-text">components/v2</code> — Uses the v2 component system</li>
|
||||
<li><code class="language-text">variants/v1</code> — Uses component variants</li>
|
||||
<li><code class="language-text">layout/grid</code> — Uses grid layouts</li>
|
||||
</ul>
|
||||
|
||||
<p>These flags help Penpot know what features need to be supported when importing the file.</p>
|
||||
|
||||
<h2 id="for-developers">For developers</h2>
|
||||
|
||||
<p>If you're building tools or integrations that work with .penpot files, the <a href="/technical-guide/developer/data-model/penpot-file-format/">complete technical specification</a> provides detailed schema definitions for every JSON file in the archive.</p>
|
||||
|
||||
<p>Key resources:</p>
|
||||
<ul>
|
||||
<li><a href="/technical-guide/developer/data-model/penpot-file-format/">Technical specification</a> — Complete schema reference</li>
|
||||
<li><a href="/technical-guide/developer/data-model/penpot-file-inspector/">File inspector</a> — Browser-based tool to interactively explore a .penpot file</li>
|
||||
<li><a href="/mcp/">MCP Server</a> — AI-powered file inspection and manipulation</li>
|
||||
<li><a href="/technical-guide/developer/data-model/">Data model</a> — Conceptual overview of Penpot's data structures</li>
|
||||
</ul>
|
||||
@ -22,7 +22,7 @@
|
||||
|
||||
(def ^:private defaults
|
||||
{:public-uri "http://localhost:3449"
|
||||
:internal-uri nil
|
||||
;; :internal-uri nil ;; internal-uri cannot be nil
|
||||
:tenant "default"
|
||||
:host "localhost"
|
||||
:http-server-port 6061
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -609,4 +609,22 @@ test("Renders a file with group with strokes and not 100% opacities", async ({
|
||||
maxDiffPixelRatio: 0,
|
||||
threshold: 0.01,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Renders background blur on text shapes", async ({ page }) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockFileMediaAsset(
|
||||
"814272d9-d3f8-812d-8008-55a1fb78211b",
|
||||
"render-wasm/assets/squares-background.png",
|
||||
);
|
||||
await workspace.mockGetFile("render-wasm/get-file-text-background-blur.json");
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "814272d9-d3f8-812d-8008-54c11cbba219",
|
||||
pageId: "814272d9-d3f8-812d-8008-54c11cbba21a",
|
||||
});
|
||||
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 548 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 30 KiB |
@ -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
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)}))))))))
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}])]]))
|
||||
|
||||
@ -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}])]))
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
32
frontend/src/app/main/ui/dashboard/layout_toggle.cljs
Normal file
32
frontend/src/app/main/ui/dashboard/layout_toggle.cljs
Normal file
@ -0,0 +1,32 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.main.ui.dashboard.layout-toggle
|
||||
"Reactive, persisted preference for how dashboard files are laid out
|
||||
(as a grid of thumbnails or as a compact list). The preference is shared
|
||||
between the team (projects) view and the project (files) view."
|
||||
(:require
|
||||
[app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def layout-key ::dashboard-layout)
|
||||
(def default-layout :grid)
|
||||
|
||||
(mf/defc layout-toggle*
|
||||
[{:keys [layout on-change]}]
|
||||
[:> radio-buttons* {:selected (name layout)
|
||||
:on-change on-change
|
||||
:name "dashboard-files-layout"
|
||||
:options [{:id "dashboard-files-layout-list"
|
||||
:value "list"
|
||||
:icon i/view-as-list
|
||||
:label (tr "dashboard.files-layout.list")}
|
||||
{:id "dashboard-files-layout-grid"
|
||||
:value "grid"
|
||||
:icon i/view-as-icons
|
||||
:label (tr "dashboard.files-layout.grid")}]}])
|
||||
@ -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*
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}]))]]]])))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
105
frontend/src/app/main/ui/hooks/floating_drag.cljs
Normal file
105
frontend/src/app/main/ui/hooks/floating_drag.cljs
Normal file
@ -0,0 +1,105 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.main.ui.hooks.floating-drag
|
||||
"Pointer drag hook for floating panels, mirroring the plugin modal drag
|
||||
handler in plugins-runtime."
|
||||
(:require
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.util.dom :as dom]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- parse-translate
|
||||
"Reads the current translate offset from an element's computed transform."
|
||||
[^js el]
|
||||
(if (and el (.-DOMMatrixReadOnly js/window))
|
||||
(let [cs (.getComputedStyle js/window el nil)
|
||||
matrix (js/DOMMatrixReadOnly. (.-transform cs))]
|
||||
{:x (.-m41 matrix)
|
||||
:y (.-m42 matrix)})
|
||||
{:x 0 :y 0}))
|
||||
|
||||
(defn- set-dragging-class!
|
||||
"Toggle `dragging-class` on `target` while a drag is active."
|
||||
[^js target dragging-class dragging?]
|
||||
(when (and target dragging-class)
|
||||
(if dragging?
|
||||
(dom/add-class! target dragging-class)
|
||||
(dom/remove-class! target dragging-class))))
|
||||
|
||||
(defn use-floating-drag
|
||||
"Returns pointer handlers to drag `target-ref` by its header.
|
||||
|
||||
Optional `on-move` is called on pointer down (e.g. to raise z-index).
|
||||
Optional `dragging-class` is toggled on `target-ref` while dragging."
|
||||
([target-ref]
|
||||
(use-floating-drag target-ref nil nil))
|
||||
([target-ref on-move]
|
||||
(use-floating-drag target-ref on-move nil))
|
||||
([target-ref on-move dragging-class]
|
||||
(let [dragging-ref (mf/use-ref false)
|
||||
pointer-id-ref (mf/use-ref nil)
|
||||
initial-translate-ref (mf/use-ref {:x 0 :y 0})
|
||||
initial-client-ref (mf/use-ref (gpt/point 0 0))
|
||||
|
||||
end-drag
|
||||
(mf/use-fn
|
||||
(mf/deps dragging-class)
|
||||
(fn [event]
|
||||
(when (mf/ref-val dragging-ref)
|
||||
(mf/set-ref-val! dragging-ref false)
|
||||
(mf/set-ref-val! pointer-id-ref nil)
|
||||
(set-dragging-class! (mf/ref-val target-ref) dragging-class false)
|
||||
(when event (dom/release-pointer event)))))
|
||||
|
||||
handle-lost-pointer-capture
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(end-drag event)))
|
||||
|
||||
handle-pointer-up
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(when (= (.-pointerId event) (mf/ref-val pointer-id-ref))
|
||||
(end-drag event))))
|
||||
|
||||
handle-pointer-move
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(when (and (mf/ref-val dragging-ref)
|
||||
(= (.-pointerId event) (mf/ref-val pointer-id-ref)))
|
||||
(let [target (mf/ref-val target-ref)
|
||||
start (mf/ref-val initial-client-ref)
|
||||
pos (dom/get-client-position event)
|
||||
{:keys [x y]} (mf/ref-val initial-translate-ref)
|
||||
delta-x (+ x (- (:x pos) (:x start)))
|
||||
delta-y (+ y (- (:y pos) (:y start)))]
|
||||
(when target
|
||||
(dom/set-css-property! target "transform"
|
||||
(str "translate(" delta-x "px, " delta-y "px)")))))))
|
||||
|
||||
handle-pointer-down
|
||||
(mf/use-fn
|
||||
(mf/deps on-move dragging-class)
|
||||
(fn [event]
|
||||
(when (and (= (.-button event) 0)
|
||||
(not (and (instance? js/Element (.-target event))
|
||||
(.closest (.-target event) "button"))))
|
||||
(dom/prevent-default event)
|
||||
(let [target (mf/ref-val target-ref)]
|
||||
(when target
|
||||
(mf/set-ref-val! pointer-id-ref (.-pointerId event))
|
||||
(mf/set-ref-val! initial-client-ref (dom/get-client-position event))
|
||||
(mf/set-ref-val! initial-translate-ref (parse-translate target))
|
||||
(mf/set-ref-val! dragging-ref true)
|
||||
(set-dragging-class! target dragging-class true)
|
||||
(dom/capture-pointer event)
|
||||
(when on-move (on-move)))))))]
|
||||
|
||||
{:on-pointer-down handle-pointer-down
|
||||
:on-pointer-move handle-pointer-move
|
||||
:on-pointer-up handle-pointer-up
|
||||
:on-lost-pointer-capture handle-lost-pointer-capture})))
|
||||
@ -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])))
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
1189
frontend/src/app/main/ui/workspace/components_debugger.cljs
Normal file
1189
frontend/src/app/main/ui/workspace/components_debugger.cljs
Normal file
File diff suppressed because it is too large
Load Diff
224
frontend/src/app/main/ui/workspace/components_debugger.scss
Normal file
224
frontend/src/app/main/ui/workspace/components_debugger.scss
Normal file
@ -0,0 +1,224 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/_utils.scss" as *;
|
||||
@use "ds/typography.scss" as t;
|
||||
|
||||
.wrapper {
|
||||
position: fixed;
|
||||
padding: var(--sp-s);
|
||||
border-radius: $br-8;
|
||||
border: $b-2 solid var(--color-background-quaternary);
|
||||
box-shadow: 0 0 var(--sp-s) 0 var(--color-shadow-light);
|
||||
overflow: hidden;
|
||||
min-inline-size: $sz-24;
|
||||
min-block-size: $sz-200;
|
||||
max-inline-size: 90vw;
|
||||
max-block-size: 90vh;
|
||||
resize: both;
|
||||
user-select: none;
|
||||
background-color: var(--color-background-primary);
|
||||
color: var(--color-foreground-secondary);
|
||||
z-index: var(--z-index-set);
|
||||
}
|
||||
|
||||
.inner {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
block-size: 100%;
|
||||
padding: var(--sp-s);
|
||||
}
|
||||
|
||||
.header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
border-block-end: $b-1 solid var(--color-background-quaternary);
|
||||
padding-block-end: var(--sp-xxs);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.wrapper.is-dragging .header {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.title {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin: 0;
|
||||
margin-inline-end: var(--sp-xxs);
|
||||
}
|
||||
|
||||
.canvas {
|
||||
flex: 1;
|
||||
min-block-size: 0;
|
||||
overflow: auto;
|
||||
padding-block-start: var(--sp-s);
|
||||
}
|
||||
|
||||
.canvas-svg {
|
||||
display: block;
|
||||
min-block-size: 100%;
|
||||
min-inline-size: 100%;
|
||||
}
|
||||
|
||||
.selection-preview-box {
|
||||
--stroke-color: var(--color-background-quaternary);
|
||||
|
||||
fill: var(--color-background-primary);
|
||||
stroke: var(--stroke-color);
|
||||
stroke-width: $b-2;
|
||||
|
||||
&.selected {
|
||||
--stroke-color: var(--color-accent-secondary);
|
||||
}
|
||||
|
||||
&.swap {
|
||||
--stroke-color: var(--color-accent-success);
|
||||
}
|
||||
|
||||
&.deleted {
|
||||
--stroke-color: var(--color-accent-error);
|
||||
}
|
||||
}
|
||||
|
||||
.selection-preview-icon {
|
||||
--icon-stroke-color: var(--color-foreground-secondary);
|
||||
|
||||
display: block;
|
||||
|
||||
&.component {
|
||||
--icon-stroke-color: var(--color-accent-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.selection-preview-text {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
fill: var(--color-foreground-primary);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.deleted-header-bg {
|
||||
fill: var(--color-accent-error);
|
||||
}
|
||||
|
||||
.deleted-header-text {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
fill: var(--color-static-white);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.info-labels-bg {
|
||||
fill: var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
fill: var(--color-foreground-primary);
|
||||
text-anchor: start;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.shape-thumbnail-bg {
|
||||
fill: var(--color-background-primary);
|
||||
stroke: var(--color-background-quaternary);
|
||||
stroke-width: $b-1;
|
||||
|
||||
&.is-loading {
|
||||
animation: thumbnail-loading-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.unavailable {
|
||||
fill: var(--color-background-quaternary);
|
||||
}
|
||||
}
|
||||
|
||||
.shape-thumbnail-image {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.shape-thumbnail-loading {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
fill: var(--color-foreground-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shape-thumbnail-unavailable {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
fill: var(--color-foreground-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes thumbnail-loading-pulse {
|
||||
0%,
|
||||
100% {
|
||||
fill: var(--color-background-primary);
|
||||
}
|
||||
|
||||
50% {
|
||||
fill: var(--color-background-quaternary);
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-arrows {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.highlight-arrow {
|
||||
stroke: var(--color-accent-secondary);
|
||||
stroke-width: $b-2;
|
||||
}
|
||||
|
||||
.highlight-arrow-marker-circle {
|
||||
fill: var(--color-accent-secondary);
|
||||
}
|
||||
|
||||
.highlight-arrow-marker-icon {
|
||||
fill: var(--color-accent-secondary);
|
||||
stroke: var(--color-background-primary);
|
||||
stroke-width: $b-2;
|
||||
}
|
||||
|
||||
.swap-arrow {
|
||||
stroke: var(--color-accent-success);
|
||||
stroke-width: $b-2;
|
||||
}
|
||||
|
||||
.swap-arrow-marker-circle {
|
||||
fill: var(--color-accent-success);
|
||||
}
|
||||
|
||||
.swap-arrow-marker-icon {
|
||||
fill: var(--color-accent-success);
|
||||
stroke: var(--color-background-primary);
|
||||
stroke-width: $b-2;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
fill: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
.legend-bg {
|
||||
fill: var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
::-webkit-resizer {
|
||||
display: none;
|
||||
}
|
||||
@ -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*
|
||||
[]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]}]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}]])]))
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
199
frontend/src/app/render_wasm/api/props.cljs
Normal file
199
frontend/src/app/render_wasm/api/props.cljs
Normal file
@ -0,0 +1,199 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.api.props
|
||||
"Browser-free WASM shape property setters, shared by the workspace render
|
||||
orchestrator (`app.render-wasm.api`) and the headless exporter
|
||||
(`app.wasm.serialize`).
|
||||
|
||||
These only touch the WASM FFI (`app.render-wasm.{helpers,mem,serializers,
|
||||
wasm}`) and the shared `common` byte layouts — no store/DOM/React — so they
|
||||
run identically in the browser and under Node. Setters that need host-specific
|
||||
data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`."
|
||||
(:require
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.types.path :as path]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.mem.heap32 :as mem.h32]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.serializers.color :as sr-clr]
|
||||
[app.render-wasm.wasm :as wasm]))
|
||||
|
||||
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
|
||||
|
||||
(def ^:const UUID-U8-SIZE 16)
|
||||
|
||||
(defn set-shape-children
|
||||
"Uploads the child id list via the dynamic `_set_children` path (handles any
|
||||
count). The browser also has fixed-arity fast paths for the incremental edit
|
||||
path; this dynamic one is the shared/batch version."
|
||||
[children]
|
||||
(let [children (into [] (filter uuid?) children)]
|
||||
(if (empty? children)
|
||||
(h/call wasm/internal-module "_set_children_0")
|
||||
(let [heap (mem/get-heap-u32)
|
||||
size (mem/get-alloc-size children UUID-U8-SIZE)
|
||||
offset (mem/alloc->offset-32 size)]
|
||||
(reduce (fn [o id] (mem.h32/write-uuid o heap id)) offset children)
|
||||
(h/call wasm/internal-module "_set_children")))))
|
||||
|
||||
(defn set-shape-bool-type
|
||||
[bool-type]
|
||||
(h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type)))
|
||||
|
||||
(defn set-shape-grow-type
|
||||
[grow-type]
|
||||
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
|
||||
|
||||
(defn write-shape-fills!
|
||||
"Serializes a fill vector into WASM using the shared `common` byte layout.
|
||||
Only writes the fill *records*; image fill BYTES are host-specific (the browser
|
||||
fetches WebGL textures, the exporter provisions decoded bytes), so callers load
|
||||
images after. Returns the coerced `Fills` (for image-id extraction) or nil when
|
||||
empty."
|
||||
[fills]
|
||||
(if (empty? fills)
|
||||
(do (h/call wasm/internal-module "_clear_shape_fills") nil)
|
||||
(let [fills (types.fills/coerce fills)
|
||||
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
|
||||
heap (mem/get-heap-u32)]
|
||||
(types.fills/write-to fills heap offset)
|
||||
(h/call wasm/internal-module "_set_shape_fills")
|
||||
fills)))
|
||||
|
||||
(defn write-shape-strokes!
|
||||
"Serializes the stroke vector (records only, like `write-shape-fills!`);
|
||||
image stroke BYTES are host-specific, so callers load images after. Returns
|
||||
the image ids referenced by the strokes' image fills."
|
||||
[strokes]
|
||||
(h/call wasm/internal-module "_clear_shape_strokes")
|
||||
(into []
|
||||
(keep
|
||||
(fn [stroke]
|
||||
(when-not (:hidden stroke)
|
||||
(let [opacity (or (:stroke-opacity stroke) 1.0)
|
||||
color (:stroke-color stroke)
|
||||
gradient (:stroke-color-gradient stroke)
|
||||
image (:stroke-image stroke)
|
||||
width (:stroke-width stroke)
|
||||
align (:stroke-alignment stroke)
|
||||
style (-> stroke :stroke-style sr/translate-stroke-style)
|
||||
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
|
||||
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
|
||||
;; Sentinel -1 means "unset" on the Rust side — keeps the
|
||||
;; FFI signature flat while letting the renderer fall back
|
||||
;; to its default dash pattern when no override is stored.
|
||||
dash (or (:stroke-dash stroke) -1)
|
||||
gap (or (:stroke-gap stroke) -1)
|
||||
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
(case align
|
||||
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap)
|
||||
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap)
|
||||
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap))
|
||||
(cond
|
||||
(some? gradient)
|
||||
(do (types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
nil)
|
||||
|
||||
(some? image)
|
||||
(do (types.fills.impl/write-image-fill offset dview opacity image)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
(get image :id))
|
||||
|
||||
(some? color)
|
||||
(do (types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
nil))))))
|
||||
strokes))
|
||||
|
||||
(defn- get-string-length
|
||||
[string]
|
||||
(+ (count string) 1))
|
||||
|
||||
(defn set-shape-blur
|
||||
[blur]
|
||||
(let [type (sr/translate-blur-type :layer-blur)]
|
||||
(if (some? blur)
|
||||
(h/call wasm/internal-module "_set_shape_blur" type (boolean (:hidden blur)) (:value blur))
|
||||
(h/call wasm/internal-module "_clear_shape_blur" type))))
|
||||
|
||||
(defn set-shape-background-blur
|
||||
[background-blur]
|
||||
(let [type (sr/translate-blur-type :background-blur)]
|
||||
(if (some? background-blur)
|
||||
(h/call wasm/internal-module "_set_shape_blur" type (boolean (:hidden background-blur)) (:value background-blur))
|
||||
(h/call wasm/internal-module "_clear_shape_blur" type))))
|
||||
|
||||
(defn set-shape-shadows
|
||||
[shadows]
|
||||
(h/call wasm/internal-module "_clear_shape_shadows")
|
||||
(run! (fn [shadow]
|
||||
(let [color (get shadow :color)
|
||||
blur (get shadow :blur)
|
||||
rgba (sr-clr/hex->u32argb (get color :color)
|
||||
(get color :opacity))
|
||||
hidden (get shadow :hidden)
|
||||
x (get shadow :offset-x)
|
||||
y (get shadow :offset-y)
|
||||
spread (get shadow :spread)
|
||||
style (get shadow :style)]
|
||||
(h/call wasm/internal-module "_add_shape_shadow"
|
||||
rgba
|
||||
blur
|
||||
spread
|
||||
x
|
||||
y
|
||||
(sr/translate-shadow-style style)
|
||||
hidden)))
|
||||
shadows))
|
||||
|
||||
(defn set-masked
|
||||
[masked]
|
||||
(h/call wasm/internal-module "_set_shape_masked_group" masked))
|
||||
|
||||
(defn set-shape-svg-attrs
|
||||
[attrs]
|
||||
(let [style (:style attrs)
|
||||
fill-rule (-> (or (:fillRule style) (:fillRule attrs)) sr/translate-fill-rule)
|
||||
stroke-linecap (-> (or (:strokeLinecap style) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
|
||||
stroke-linejoin (-> (or (:strokeLinejoin style) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
|
||||
fill-none (= "none" (or (:fill style) (:fill attrs)))]
|
||||
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
|
||||
|
||||
(defn set-shape-svg-raw-content
|
||||
[content]
|
||||
(let [size (get-string-length content)
|
||||
offset (mem/alloc size)]
|
||||
(h/call wasm/internal-module "stringToUTF8" content offset size)
|
||||
(h/call wasm/internal-module "_set_shape_svg_raw_content")))
|
||||
|
||||
(defn set-shape-path-content
|
||||
"Upload path content in chunks to WASM."
|
||||
[content]
|
||||
(let [chunk-size (quot MAX_BUFFER_CHUNK_SIZE 4)
|
||||
buffer-size (path/get-byte-size content)
|
||||
padded-size (* 4 (mth/ceil (/ buffer-size 4)))
|
||||
buffer (js/Uint8Array. padded-size)]
|
||||
(path/write-to content (.-buffer buffer) 0)
|
||||
(h/call wasm/internal-module "_start_shape_path_buffer")
|
||||
(let [heapu32 (mem/get-heap-u32)]
|
||||
(loop [offset 0]
|
||||
(when (< offset padded-size)
|
||||
(let [end (min padded-size (+ offset (* chunk-size 4)))
|
||||
chunk (.subarray buffer offset end)
|
||||
chunk-u32 (js/Uint32Array. chunk.buffer chunk.byteOffset (quot (.-length chunk) 4))
|
||||
offset-size (.-length chunk-u32)
|
||||
heap-offset (mem/alloc->offset-32 (* 4 offset-size))]
|
||||
(.set heapu32 chunk-u32 heap-offset)
|
||||
(h/call wasm/internal-module "_set_shape_path_chunk_buffer")
|
||||
(recur end)))))
|
||||
(h/call wasm/internal-module "_set_shape_path_buffer")))
|
||||
@ -6,240 +6,21 @@
|
||||
|
||||
(ns app.render-wasm.api.texts
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.render-wasm.api.fonts :as f]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.wasm :as wasm]))
|
||||
|
||||
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
|
||||
(def ^:const SPAN-ATTR-U8-SIZE 64)
|
||||
(def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS)
|
||||
|
||||
(defn- encode-text
|
||||
"Into an UTF8 buffer. Returns an ArrayBuffer instance"
|
||||
[text]
|
||||
(let [encoder (js/TextEncoder.)]
|
||||
(.encode encoder text)))
|
||||
|
||||
(defn- write-span-fills
|
||||
[offset dview fills]
|
||||
(let [new-ofset (reduce (fn [offset fill]
|
||||
(let [opacity (get fill :fill-opacity 1.0)
|
||||
color (get fill :fill-color)
|
||||
gradient (get fill :fill-color-gradient)
|
||||
image (get fill :fill-image)]
|
||||
|
||||
(cond
|
||||
(some? color)
|
||||
(types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
|
||||
(some? gradient)
|
||||
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
|
||||
(some? image)
|
||||
(types.fills.impl/write-image-fill offset dview opacity image))))
|
||||
|
||||
offset
|
||||
fills)
|
||||
padding-fills (max 0 (- MAX-TEXT-FILLS (count fills)))]
|
||||
(+ new-ofset (* padding-fills types.fills.impl/FILL-U8-SIZE))))
|
||||
|
||||
|
||||
(defn- write-paragraph
|
||||
[offset dview paragraph]
|
||||
(let [text-align (sr/translate-text-align (get paragraph :text-align))
|
||||
text-direction (sr/translate-text-direction (get paragraph :text-direction))
|
||||
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
|
||||
text-transform (sr/translate-text-transform (get paragraph :text-transform))
|
||||
line-height (f/serialize-line-height (get paragraph :line-height))
|
||||
letter-spacing (f/serialize-letter-spacing (get paragraph :letter-spacing))]
|
||||
|
||||
(-> offset
|
||||
(mem/write-u8 dview text-align)
|
||||
(mem/write-u8 dview text-direction)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
|
||||
(mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE))))
|
||||
|
||||
(defn- write-spans
|
||||
[offset dview spans paragraph]
|
||||
(let [paragraph-font-size (get paragraph :font-size)
|
||||
paragraph-font-weight (-> paragraph :font-weight f/serialize-font-weight)
|
||||
paragraph-line-height (f/serialize-line-height (get paragraph :line-height))]
|
||||
(reduce (fn [offset span]
|
||||
(let [font-style (sr/translate-font-style (get span :font-style "normal"))
|
||||
font-size (get span :font-size paragraph-font-size)
|
||||
font-size (f/serialize-font-size font-size)
|
||||
|
||||
line-height (f/serialize-line-height (get span :line-height) paragraph-line-height)
|
||||
letter-spacing (f/serialize-letter-spacing (get span :letter-spacing))
|
||||
|
||||
font-weight (get span :font-weight paragraph-font-weight)
|
||||
font-weight (f/serialize-font-weight font-weight)
|
||||
|
||||
font-id (f/normalize-font-id (get span :font-id "sourcesanspro"))
|
||||
font-family (hash (get span :font-family "sourcesanspro"))
|
||||
|
||||
text-buffer (encode-text (get span :text ""))
|
||||
text-length (mem/size text-buffer)
|
||||
fills (take MAX-TEXT-FILLS (get span :fills []))
|
||||
|
||||
font-variant-id
|
||||
(get span :font-variant-id)
|
||||
|
||||
font-variant-id
|
||||
(if (uuid? font-variant-id)
|
||||
font-variant-id
|
||||
uuid/zero)
|
||||
|
||||
text-decoration
|
||||
(or (sr/translate-text-decoration (:text-decoration span))
|
||||
(sr/translate-text-decoration (:text-decoration paragraph))
|
||||
(sr/translate-text-decoration "none"))
|
||||
|
||||
text-transform
|
||||
(or (sr/translate-text-transform (:text-transform span))
|
||||
(sr/translate-text-transform (:text-transform paragraph))
|
||||
(sr/translate-text-transform "none"))
|
||||
|
||||
text-direction
|
||||
(or (sr/translate-text-direction (:text-direction span))
|
||||
(sr/translate-text-direction (:text-direction paragraph))
|
||||
(sr/translate-text-direction "ltr"))]
|
||||
|
||||
(-> offset
|
||||
(mem/write-u8 dview font-style)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
(mem/write-u8 dview text-direction)
|
||||
|
||||
(mem/write-f32 dview font-size)
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
(mem/write-u32 dview font-weight)
|
||||
|
||||
(mem/write-uuid dview font-id)
|
||||
(mem/write-i32 dview font-family)
|
||||
(mem/write-uuid dview (d/nilv font-variant-id uuid/zero))
|
||||
|
||||
(mem/write-i32 dview text-length)
|
||||
(mem/write-i32 dview (count fills))
|
||||
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
|
||||
|
||||
(write-span-fills dview fills))))
|
||||
offset
|
||||
spans)))
|
||||
[app.render-wasm.fallback-fonts :as fbf]
|
||||
[app.render-wasm.text-content :as tc]))
|
||||
|
||||
(defn write-shape-text
|
||||
;; buffer has the following format:
|
||||
;; [<num-spans> <paragraph_attributes> <spans_attributes> <text>]
|
||||
"Workspace text serialization: the byte writing is shared via
|
||||
`app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
|
||||
[spans paragraph text]
|
||||
(let [normalized-paragraph (f/normalize-paragraph-font paragraph)
|
||||
normalized-spans (map #(f/normalize-span-font % normalized-paragraph) spans)
|
||||
num-spans (count normalized-spans)
|
||||
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
|
||||
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
|
||||
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
|
||||
|
||||
text-buffer (encode-text text)
|
||||
text-size (mem/size text-buffer)
|
||||
|
||||
total-size (+ 4 metadata-size text-size)
|
||||
heapu8 (mem/get-heap-u8)
|
||||
dview (mem/get-data-view)
|
||||
offset (mem/alloc total-size)]
|
||||
|
||||
(-> offset
|
||||
(mem/write-u32 dview num-spans)
|
||||
(write-paragraph dview normalized-paragraph)
|
||||
(write-spans dview normalized-spans normalized-paragraph)
|
||||
(mem/write-buffer heapu8 text-buffer))
|
||||
|
||||
(h/call wasm/internal-module "_set_shape_text_content")))
|
||||
|
||||
(def ^:private emoji-pattern
|
||||
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
|
||||
|
||||
(def ^:private unicode-ranges
|
||||
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
|
||||
:chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]"
|
||||
:korean #"[\uAC00-\uD7AF]"
|
||||
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"
|
||||
:cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]"
|
||||
:greek #"[\u0370-\u03FF\u1F00-\u1FFF]"
|
||||
:hebrew #"[\u0590-\u05FF\uFB1D-\uFB4F]"
|
||||
:thai #"[\u0E00-\u0E7F]"
|
||||
:devanagari #"[\u0900-\u097F\uA8E0-\uA8FF]"
|
||||
:tamil #"[\u0B80-\u0BFF]"
|
||||
:latin-ext #"[\u0100-\u017F\u0180-\u024F]"
|
||||
:vietnamese #"[\u1EA0-\u1EF9]"
|
||||
:armenian #"[\u0530-\u058F\uFB13-\uFB17]"
|
||||
:bengali #"[\u0980-\u09FF]"
|
||||
:cherokee #"[\u13A0-\u13FF]"
|
||||
:ethiopic #"[\u1200-\u137F]"
|
||||
:georgian #"[\u10A0-\u10FF]"
|
||||
:gujarati #"[\u0A80-\u0AFF]"
|
||||
:gurmukhi #"[\u0A00-\u0A7F]"
|
||||
:khmer #"[\u1780-\u17FF\u19E0-\u19FF]"
|
||||
:lao #"[\u0E80-\u0EFF]"
|
||||
:malayalam #"[\u0D00-\u0D7F]"
|
||||
:myanmar #"[\u1000-\u109F\uAA60-\uAA7F]"
|
||||
:sinhala #"[\u0D80-\u0DFF]"
|
||||
:telugu #"[\u0C00-\u0C7F]"
|
||||
:tibetan #"[\u0F00-\u0FFF]"
|
||||
:javanese #"[\uA980-\uA9DF]"
|
||||
:kannada #"[\u0C80-\u0CFF]"
|
||||
:oriya #"[\u0B00-\u0B7F]"
|
||||
:mongolian #"[\u1800-\u18AF]"
|
||||
:syriac #"[\u0700-\u074F]"
|
||||
:tifinagh #"[\u2D30-\u2D7F]"
|
||||
:coptic #"[\u2C80-\u2CFF]"
|
||||
:ol-chiki #"[\u1C50-\u1C7F]"
|
||||
:vai #"[\uA500-\uA63F]"
|
||||
:shavian #"\uD801[\uDC50-\uDC7F]"
|
||||
:osmanya #"\uD801[\uDC80-\uDCAF]"
|
||||
:runic #"[\u16A0-\u16FF]"
|
||||
:old-italic #"\uD800[\uDF00-\uDF2F]"
|
||||
:brahmi #"\uD804[\uDC00-\uDC7F]"
|
||||
:modi #"\uD805[\uDE00-\uDE5F]"
|
||||
:sora-sompeng #"\uD804[\uDCD0-\uDCFF]"
|
||||
:bamum #"[\uA6A0-\uA6FF]"
|
||||
:meroitic #"\uD802[\uDD80-\uDD9F]"
|
||||
;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc.
|
||||
:symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]"
|
||||
;; Additional symbol blocks covered by Noto Sans Symbols 2:
|
||||
;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.)
|
||||
;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF),
|
||||
;; Legacy Computing Symbols (U+1FB00-1FBFF)
|
||||
:symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]"
|
||||
:music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"})
|
||||
|
||||
(defn contains-emoji? [text]
|
||||
(let [result (re-find emoji-pattern text)]
|
||||
(boolean result)))
|
||||
|
||||
(defn collect-used-languages
|
||||
[used text]
|
||||
(reduce-kv (fn [result lang pattern]
|
||||
(cond
|
||||
;; Skip regex operation if we already know that
|
||||
;; langage is present
|
||||
(contains? result lang)
|
||||
result
|
||||
|
||||
(re-find pattern text)
|
||||
(conj result lang)
|
||||
|
||||
:else
|
||||
result))
|
||||
used
|
||||
unicode-ranges))
|
||||
(tc/write-shape-text! spans paragraph text
|
||||
{:normalize-font-id f/normalize-font-id
|
||||
:normalize-paragraph f/normalize-paragraph-font
|
||||
:normalize-span f/normalize-span-font}))
|
||||
|
||||
;; Emoji/script detection lives in the host-agnostic
|
||||
;; `app.render-wasm.fallback-fonts`; kept re-exported here for existing
|
||||
;; workspace callers.
|
||||
(def contains-emoji? fbf/contains-emoji?)
|
||||
(def collect-used-languages fbf/collect-used-languages)
|
||||
|
||||
157
frontend/src/app/render_wasm/fallback_fonts.cljs
Normal file
157
frontend/src/app/render_wasm/fallback_fonts.cljs
Normal file
@ -0,0 +1,157 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.fallback-fonts
|
||||
"Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and
|
||||
which (google) fallback fonts cover them. Pure data + pure fns — no browser
|
||||
or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the
|
||||
headless exporter (`app.renderer.wasm`) compute the SAME fallback set from
|
||||
the same source. Anything a host must fetch/upload for text to render
|
||||
belongs here, not in host code.")
|
||||
|
||||
(def ^:private emoji-pattern
|
||||
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
|
||||
|
||||
(def ^:private unicode-ranges
|
||||
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
|
||||
:chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]"
|
||||
:korean #"[\uAC00-\uD7AF]"
|
||||
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"
|
||||
:cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]"
|
||||
:greek #"[\u0370-\u03FF\u1F00-\u1FFF]"
|
||||
:hebrew #"[\u0590-\u05FF\uFB1D-\uFB4F]"
|
||||
:thai #"[\u0E00-\u0E7F]"
|
||||
:devanagari #"[\u0900-\u097F\uA8E0-\uA8FF]"
|
||||
:tamil #"[\u0B80-\u0BFF]"
|
||||
:latin-ext #"[\u0100-\u017F\u0180-\u024F]"
|
||||
:vietnamese #"[\u1EA0-\u1EF9]"
|
||||
:armenian #"[\u0530-\u058F\uFB13-\uFB17]"
|
||||
:bengali #"[\u0980-\u09FF]"
|
||||
:cherokee #"[\u13A0-\u13FF]"
|
||||
:ethiopic #"[\u1200-\u137F]"
|
||||
:georgian #"[\u10A0-\u10FF]"
|
||||
:gujarati #"[\u0A80-\u0AFF]"
|
||||
:gurmukhi #"[\u0A00-\u0A7F]"
|
||||
:khmer #"[\u1780-\u17FF\u19E0-\u19FF]"
|
||||
:lao #"[\u0E80-\u0EFF]"
|
||||
:malayalam #"[\u0D00-\u0D7F]"
|
||||
:myanmar #"[\u1000-\u109F\uAA60-\uAA7F]"
|
||||
:sinhala #"[\u0D80-\u0DFF]"
|
||||
:telugu #"[\u0C00-\u0C7F]"
|
||||
:tibetan #"[\u0F00-\u0FFF]"
|
||||
:javanese #"[\uA980-\uA9DF]"
|
||||
:kannada #"[\u0C80-\u0CFF]"
|
||||
:oriya #"[\u0B00-\u0B7F]"
|
||||
:mongolian #"[\u1800-\u18AF]"
|
||||
:syriac #"[\u0700-\u074F]"
|
||||
:tifinagh #"[\u2D30-\u2D7F]"
|
||||
:coptic #"[\u2C80-\u2CFF]"
|
||||
:ol-chiki #"[\u1C50-\u1C7F]"
|
||||
:vai #"[\uA500-\uA63F]"
|
||||
:shavian #"\uD801[\uDC50-\uDC7F]"
|
||||
:osmanya #"\uD801[\uDC80-\uDCAF]"
|
||||
:runic #"[\u16A0-\u16FF]"
|
||||
:old-italic #"\uD800[\uDF00-\uDF2F]"
|
||||
:brahmi #"\uD804[\uDC00-\uDC7F]"
|
||||
:modi #"\uD805[\uDE00-\uDE5F]"
|
||||
:sora-sompeng #"\uD804[\uDCD0-\uDCFF]"
|
||||
:bamum #"[\uA6A0-\uA6FF]"
|
||||
:meroitic #"\uD802[\uDD80-\uDD9F]"
|
||||
;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc.
|
||||
:symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]"
|
||||
;; Additional symbol blocks covered by Noto Sans Symbols 2:
|
||||
;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.)
|
||||
;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF),
|
||||
;; Legacy Computing Symbols (U+1FB00-1FBFF)
|
||||
:symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]"
|
||||
:music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"})
|
||||
|
||||
(defn contains-emoji? [text]
|
||||
(let [result (re-find emoji-pattern text)]
|
||||
(boolean result)))
|
||||
|
||||
(defn collect-used-languages
|
||||
[used text]
|
||||
(reduce-kv (fn [result lang pattern]
|
||||
(cond
|
||||
;; Skip regex operation if we already know that
|
||||
;; langage is present
|
||||
(contains? result lang)
|
||||
result
|
||||
|
||||
(re-find pattern text)
|
||||
(conj result lang)
|
||||
|
||||
:else
|
||||
result))
|
||||
used
|
||||
unicode-ranges))
|
||||
|
||||
(defn add-emoji-font
|
||||
[fonts]
|
||||
(conj fonts {:font-id "gfont-noto-color-emoji"
|
||||
:font-variant-id "regular"
|
||||
:style 0
|
||||
:weight 400
|
||||
:is-emoji true
|
||||
:is-fallback true}))
|
||||
|
||||
(def noto-fonts
|
||||
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:cyrillic {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:greek {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:hebrew {:font-id "gfont-noto-sans-hebrew" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:thai {:font-id "gfont-noto-sans-thai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:devanagari {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tamil {:font-id "gfont-noto-sans-tamil" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:latin-ext {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:vietnamese {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:armenian {:font-id "gfont-noto-sans-armenian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:bengali {:font-id "gfont-noto-sans-bengali" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:cherokee {:font-id "gfont-noto-sans-cherokee" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:ethiopic {:font-id "gfont-noto-sans-ethiopic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:georgian {:font-id "gfont-noto-sans-georgian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:gujarati {:font-id "gfont-noto-sans-gujarati" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:gurmukhi {:font-id "gfont-noto-sans-gurmukhi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:khmer {:font-id "gfont-noto-sans-khmer" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:lao {:font-id "gfont-noto-sans-lao" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:malayalam {:font-id "gfont-noto-sans-malayalam" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:myanmar {:font-id "gfont-noto-sans-myanmar" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:sinhala {:font-id "gfont-noto-sans-sinhala" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:telugu {:font-id "gfont-noto-sans-telugu" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tibetan {:font-id "gfont-noto-serif-tibetan" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:javanese {:font-id "gfont-noto-sans-javanese" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:kannada {:font-id "gfont-noto-sans-kannada" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:oriya {:font-id "gfont-noto-sans-oriya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:mongolian {:font-id "gfont-noto-sans-mongolian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:syriac {:font-id "gfont-noto-sans-syriac" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tifinagh {:font-id "gfont-noto-sans-tifinagh" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:coptic {:font-id "gfont-noto-sans-coptic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:ol-chiki {:font-id "gfont-noto-sans-ol-chiki" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:vai {:font-id "gfont-noto-sans-vai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:shavian {:font-id "gfont-noto-sans-shavian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:osmanya {:font-id "gfont-noto-sans-osmanya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:runic {:font-id "gfont-noto-sans-runic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:old-italic {:font-id "gfont-noto-sans-old-italic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:brahmi {:font-id "gfont-noto-sans-brahmi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:modi {:font-id "gfont-noto-sans-modi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:sora-sompeng {:font-id "gfont-noto-sans-sora-sompeng" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:bamum {:font-id "gfont-noto-sans-bamum" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}})
|
||||
|
||||
(defn add-noto-fonts [fonts languages]
|
||||
(reduce (fn [acc lang]
|
||||
(if-let [font (get noto-fonts lang)]
|
||||
(conj acc font)
|
||||
acc))
|
||||
fonts
|
||||
languages))
|
||||
47
frontend/src/app/render_wasm/resources.cljs
Normal file
47
frontend/src/app/render_wasm/resources.cljs
Normal file
@ -0,0 +1,47 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.resources
|
||||
"Host-agnostic enumeration of the external resources a scene needs to
|
||||
render: which image bytes its shapes reference. Pure data walking — no
|
||||
browser or Node dependencies — so the workspace and the headless exporter
|
||||
derive the same set from the same source (sibling of
|
||||
`app.render-wasm.fallback-fonts`, which does the same for fonts)."
|
||||
(:require
|
||||
[app.common.types.fills :as types.fills]))
|
||||
|
||||
(defn- fill-image-ids
|
||||
[fills]
|
||||
(some-> fills not-empty types.fills/coerce types.fills/get-image-ids))
|
||||
|
||||
(defn- stroke-image-ids
|
||||
[strokes]
|
||||
(keep (comp :id :stroke-image) strokes))
|
||||
|
||||
(defn- text-image-ids
|
||||
"Image-fill ids referenced by a text shape's span fills."
|
||||
[content]
|
||||
(when content
|
||||
(->> (tree-seq :children :children content)
|
||||
(mapcat :fills)
|
||||
(keep (comp :id :fill-image)))))
|
||||
|
||||
(defn shape-image-ids
|
||||
"Distinct image ids referenced by one shape: its fills, its strokes' image
|
||||
fills, and (for texts) its spans' image fills."
|
||||
[shape]
|
||||
(-> #{}
|
||||
(into (fill-image-ids (:fills shape)))
|
||||
(into (stroke-image-ids (:strokes shape)))
|
||||
;; `:content` is only a text tree for text shapes (paths reuse the key
|
||||
;; for geometry).
|
||||
(into (when (= :text (:type shape))
|
||||
(text-image-ids (:content shape))))))
|
||||
|
||||
(defn scene-image-ids
|
||||
"Distinct image ids referenced anywhere in an `objects` map."
|
||||
[scene]
|
||||
(into #{} (mapcat shape-image-ids) (vals scene)))
|
||||
54
frontend/src/app/render_wasm/serialize_shape.cljs
Normal file
54
frontend/src/app/render_wasm/serialize_shape.cljs
Normal file
@ -0,0 +1,54 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.serialize-shape
|
||||
"Single source of truth for the host-independent part of serializing a whole
|
||||
shape into the WASM design state.
|
||||
|
||||
Both batch serializers call this so they can't drift:
|
||||
- the workspace `app.render-wasm.api/set-object` (browser), and
|
||||
- the headless exporter `app.wasm.serialize/set-shape!` (Node).
|
||||
|
||||
It applies only the properties that need no host-specific resources or driver:
|
||||
base props, children, blur, background blur, shadows, svg attrs, group mask,
|
||||
bool type, path/bool geometry and text grow type. The parts that DO differ by
|
||||
host are handled by each caller AFTER this runs:
|
||||
- fills / strokes (image bytes are fetched + uploaded differently),
|
||||
- text content (fonts),
|
||||
- svg-raw markup (browser renders it via React),
|
||||
- layout (grid/flex — workspace only).
|
||||
|
||||
The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps
|
||||
dispatching per changed key through the same underlying `props` setters."
|
||||
(:require
|
||||
[app.render-wasm.api.props :as props]
|
||||
[app.render-wasm.api.shapes :as shapes]))
|
||||
|
||||
(defn serialize-shape!
|
||||
"Applies every host-independent WASM property of `shape`. `set-shape-base-props`
|
||||
runs first because it selects the current shape (`use_shape`) the rest mutate."
|
||||
[shape]
|
||||
(let [type (get shape :type)]
|
||||
(shapes/set-shape-base-props shape)
|
||||
(props/set-shape-children (get shape :shapes))
|
||||
(props/set-shape-blur (get shape :blur))
|
||||
(props/set-shape-background-blur (get shape :background-blur))
|
||||
(props/set-shape-shadows (get shape :shadow))
|
||||
|
||||
(when (some? (get shape :svg-attrs))
|
||||
(props/set-shape-svg-attrs (get shape :svg-attrs)))
|
||||
|
||||
(when (= type :group)
|
||||
(props/set-masked (boolean (get shape :masked-group))))
|
||||
|
||||
(when (= type :bool)
|
||||
(props/set-shape-bool-type (get shape :bool-type)))
|
||||
|
||||
(when (and (contains? #{:path :bool} type) (some? (get shape :content)))
|
||||
(props/set-shape-path-content (get shape :content)))
|
||||
|
||||
(when (= type :text)
|
||||
(props/set-shape-grow-type (get shape :grow-type)))))
|
||||
198
frontend/src/app/render_wasm/text_content.cljs
Normal file
198
frontend/src/app/render_wasm/text_content.cljs
Normal file
@ -0,0 +1,198 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.text-content
|
||||
"Single source of truth for writing a text shape's content into the WASM design
|
||||
state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is
|
||||
identical for the workspace and the headless exporter — only *font resolution*
|
||||
differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts
|
||||
catalog + custom variants). So the byte-writing lives here and font resolution
|
||||
is injected via the `opts` map passed to `write-shape-text!`.
|
||||
|
||||
Fully portable (no store/DOM/React), so it runs under Node too."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
|
||||
(def ^:const SPAN-ATTR-U8-SIZE 64)
|
||||
(def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS)
|
||||
|
||||
(def ^:private default-font-size 14)
|
||||
(def ^:private default-line-height 1.2)
|
||||
(def ^:private default-letter-spacing 0.0)
|
||||
|
||||
;; --- pure attribute serializers -------------------------------------------
|
||||
|
||||
(defn serialize-font-size
|
||||
[font-size]
|
||||
(cond
|
||||
(number? font-size) font-size
|
||||
(string? font-size) (or (d/parse-double font-size) default-font-size)
|
||||
:else default-font-size))
|
||||
|
||||
(defn serialize-font-weight
|
||||
[font-weight]
|
||||
(if (number? font-weight)
|
||||
font-weight
|
||||
(let [font-weight-str (str font-weight)]
|
||||
(cond
|
||||
(re-matches #"\d+" font-weight-str) (js/Number font-weight-str)
|
||||
(str/includes? font-weight-str "bold") 700
|
||||
(str/includes? font-weight-str "black") 900
|
||||
(str/includes? font-weight-str "extrabold") 800
|
||||
(str/includes? font-weight-str "extralight") 200
|
||||
(str/includes? font-weight-str "light") 300
|
||||
(str/includes? font-weight-str "medium") 500
|
||||
(str/includes? font-weight-str "semibold") 600
|
||||
(str/includes? font-weight-str "thin") 100
|
||||
:else 400))))
|
||||
|
||||
(defn serialize-line-height
|
||||
([line-height] (serialize-line-height line-height default-line-height))
|
||||
([line-height default-value]
|
||||
(cond
|
||||
(number? line-height) line-height
|
||||
(string? line-height) (or (d/parse-double line-height) default-value)
|
||||
:else default-value)))
|
||||
|
||||
(defn serialize-letter-spacing
|
||||
[letter-spacing]
|
||||
(cond
|
||||
(number? letter-spacing) letter-spacing
|
||||
(string? letter-spacing) (or (d/parse-double letter-spacing) default-letter-spacing)
|
||||
:else default-letter-spacing))
|
||||
|
||||
;; --- binary writers --------------------------------------------------------
|
||||
|
||||
(defn- encode-text
|
||||
"Into an UTF8 buffer. Returns an ArrayBuffer instance."
|
||||
[text]
|
||||
(let [encoder (js/TextEncoder.)]
|
||||
(.encode encoder text)))
|
||||
|
||||
(defn- write-span-fills
|
||||
[offset dview fills]
|
||||
(let [new-ofset (reduce (fn [offset fill]
|
||||
(let [opacity (get fill :fill-opacity 1.0)
|
||||
color (get fill :fill-color)
|
||||
gradient (get fill :fill-color-gradient)
|
||||
image (get fill :fill-image)]
|
||||
(cond
|
||||
(some? color)
|
||||
(types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
|
||||
(some? gradient)
|
||||
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
|
||||
(some? image)
|
||||
(types.fills.impl/write-image-fill offset dview opacity image))))
|
||||
offset
|
||||
fills)
|
||||
padding-fills (max 0 (- MAX-TEXT-FILLS (count fills)))]
|
||||
(+ new-ofset (* padding-fills types.fills.impl/FILL-U8-SIZE))))
|
||||
|
||||
(defn- write-paragraph
|
||||
[offset dview paragraph]
|
||||
(let [text-align (sr/translate-text-align (get paragraph :text-align))
|
||||
text-direction (sr/translate-text-direction (get paragraph :text-direction))
|
||||
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
|
||||
text-transform (sr/translate-text-transform (get paragraph :text-transform))
|
||||
line-height (serialize-line-height (get paragraph :line-height))
|
||||
letter-spacing (serialize-letter-spacing (get paragraph :letter-spacing))]
|
||||
(-> offset
|
||||
(mem/write-u8 dview text-align)
|
||||
(mem/write-u8 dview text-direction)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
(mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE))))
|
||||
|
||||
(defn- write-spans
|
||||
[offset dview spans paragraph normalize-font-id]
|
||||
(let [paragraph-font-size (get paragraph :font-size)
|
||||
paragraph-font-weight (-> paragraph :font-weight serialize-font-weight)
|
||||
paragraph-line-height (serialize-line-height (get paragraph :line-height))]
|
||||
(reduce
|
||||
(fn [offset span]
|
||||
(let [font-style (sr/translate-font-style (get span :font-style "normal"))
|
||||
font-size (serialize-font-size (get span :font-size paragraph-font-size))
|
||||
line-height (serialize-line-height (get span :line-height) paragraph-line-height)
|
||||
letter-spacing (serialize-letter-spacing (get span :letter-spacing))
|
||||
font-weight (serialize-font-weight (get span :font-weight paragraph-font-weight))
|
||||
font-id (normalize-font-id (get span :font-id "sourcesanspro"))
|
||||
font-family (hash (get span :font-family "sourcesanspro"))
|
||||
text-buffer (encode-text (get span :text ""))
|
||||
text-length (mem/size text-buffer)
|
||||
fills (take MAX-TEXT-FILLS (get span :fills []))
|
||||
font-variant-id (get span :font-variant-id)
|
||||
font-variant-id (if (uuid? font-variant-id) font-variant-id uuid/zero)
|
||||
text-decoration (or (sr/translate-text-decoration (:text-decoration span))
|
||||
(sr/translate-text-decoration (:text-decoration paragraph))
|
||||
(sr/translate-text-decoration "none"))
|
||||
text-transform (or (sr/translate-text-transform (:text-transform span))
|
||||
(sr/translate-text-transform (:text-transform paragraph))
|
||||
(sr/translate-text-transform "none"))
|
||||
text-direction (or (sr/translate-text-direction (:text-direction span))
|
||||
(sr/translate-text-direction (:text-direction paragraph))
|
||||
(sr/translate-text-direction "ltr"))]
|
||||
(-> offset
|
||||
(mem/write-u8 dview font-style)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
(mem/write-u8 dview text-direction)
|
||||
(mem/write-f32 dview font-size)
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
(mem/write-u32 dview font-weight)
|
||||
(mem/write-uuid dview font-id)
|
||||
(mem/write-i32 dview font-family)
|
||||
(mem/write-uuid dview (d/nilv font-variant-id uuid/zero))
|
||||
(mem/write-i32 dview text-length)
|
||||
(mem/write-i32 dview (count fills))
|
||||
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
|
||||
(write-span-fills dview fills))))
|
||||
offset
|
||||
spans)))
|
||||
|
||||
(defn write-shape-text!
|
||||
"Writes one paragraph's spans + text into WASM and appends it to the current
|
||||
shape via `_set_shape_text_content`.
|
||||
|
||||
`opts` injects host-specific font resolution:
|
||||
- `:normalize-font-id` (string font-id -> wasm uuid) — required in practice,
|
||||
- `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a
|
||||
fonts DB (workspace); default to identity (the exporter resolves variants
|
||||
differently / not at all)."
|
||||
[spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span]
|
||||
:or {normalize-font-id identity
|
||||
normalize-paragraph identity
|
||||
normalize-span (fn [span _paragraph] span)}}]
|
||||
(let [paragraph (normalize-paragraph paragraph)
|
||||
spans (map #(normalize-span % paragraph) spans)
|
||||
num-spans (count spans)
|
||||
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
|
||||
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
|
||||
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
|
||||
text-buffer (encode-text text)
|
||||
text-size (mem/size text-buffer)
|
||||
total-size (+ 4 metadata-size text-size)
|
||||
heapu8 (mem/get-heap-u8)
|
||||
dview (mem/get-data-view)
|
||||
offset (mem/alloc total-size)]
|
||||
(-> offset
|
||||
(mem/write-u32 dview num-spans)
|
||||
(write-paragraph dview paragraph)
|
||||
(write-spans dview spans paragraph normalize-font-id)
|
||||
(mem/write-buffer heapu8 text-buffer))
|
||||
(h/call wasm/internal-module "_set_shape_text_content")))
|
||||
@ -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
|
||||
|
||||
|
||||
@ -9,17 +9,19 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[cljs.spec.alpha :as s]
|
||||
[clojure.string :refer [index-of]]
|
||||
[cuerdas.core :as str]
|
||||
[instaparse.core :as insta]))
|
||||
|
||||
(def parser
|
||||
(insta/parser
|
||||
"opt-expr = '' | expr
|
||||
expr = term (<spaces> ('+'|'-') <spaces> expr)* |
|
||||
('+'|'-'|'*'|'/') <spaces> factor
|
||||
term = factor (<spaces> ('*'|'/') <spaces> term)*
|
||||
factor = number | ('(' <spaces> expr <spaces> ')')
|
||||
;; Note that there is ambiguity, so we don't allow relative substraction,
|
||||
;; a leading '-' is always a negation.
|
||||
"opt-expr = '' | rel-expr | expr
|
||||
rel-expr = ('+'|'*'|'/') <spaces> factor
|
||||
expr = term (<spaces> ('+'|'-') <spaces> term)*
|
||||
term = factor (<spaces> ('*'|'/') <spaces> factor)*
|
||||
factor = number | neg | ('(' <spaces> expr <spaces> ')')
|
||||
neg = <'-'> <spaces> factor
|
||||
number = #'[0-9]*[.,]?[0-9]+%?'
|
||||
spaces = ' '*"))
|
||||
|
||||
@ -31,26 +33,29 @@
|
||||
:opt-expr
|
||||
(if (empty? args) nil (interpret (first args) init-value))
|
||||
|
||||
:rel-expr
|
||||
(let [operator (first args)
|
||||
second-value (interpret (second args) init-value)]
|
||||
(case operator
|
||||
"+" (+ init-value second-value)
|
||||
"*" (* init-value second-value)
|
||||
"/" (/ init-value second-value)))
|
||||
|
||||
:neg
|
||||
(- (interpret (first args) init-value))
|
||||
|
||||
:expr
|
||||
(if (index-of "+-*/" (first args))
|
||||
(let [operator (first args)
|
||||
second-value (interpret (second args) init-value)]
|
||||
(case operator
|
||||
"+" (+ init-value second-value)
|
||||
"-" (- 0 second-value) ;; Note that there is ambiguity, so we don't allow
|
||||
"*" (* init-value second-value) ;; relative substraction, it's only a negative number
|
||||
"/" (/ init-value second-value)))
|
||||
(let [value (interpret (first args) init-value)]
|
||||
(loop [value value
|
||||
rest-expr (rest args)]
|
||||
(if (empty? rest-expr)
|
||||
value
|
||||
(let [operator (first rest-expr)
|
||||
second-value (interpret (second rest-expr) init-value)
|
||||
rest-expr (-> rest-expr rest rest)]
|
||||
(case operator
|
||||
"+" (recur (+ value second-value) rest-expr)
|
||||
"-" (recur (- value second-value) rest-expr)))))))
|
||||
(let [value (interpret (first args) init-value)]
|
||||
(loop [value value
|
||||
rest-expr (rest args)]
|
||||
(if (empty? rest-expr)
|
||||
value
|
||||
(let [operator (first rest-expr)
|
||||
second-value (interpret (second rest-expr) init-value)
|
||||
rest-expr (-> rest-expr rest rest)]
|
||||
(case operator
|
||||
"+" (recur (+ value second-value) rest-expr)
|
||||
"-" (recur (- value second-value) rest-expr))))))
|
||||
|
||||
:term
|
||||
(let [value (interpret (first args) init-value)]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
@ -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
|
||||
;;
|
||||
|
||||
@ -27,6 +27,10 @@
|
||||
Async: each deftest uses `t/async`; `ftm/check` drives the store and calls
|
||||
`done` when finished."
|
||||
(:require
|
||||
[app.common.geom.matrix :as gmt]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.shape-tree :as ctst]
|
||||
[cljs.test :as t :include-macros true]
|
||||
@ -343,4 +347,87 @@
|
||||
(t/is (= (expected-at s i) (level-color s m i))
|
||||
(str "level " i)))))]))}))))
|
||||
|
||||
(t/deftest case-n-geometry-sync-with-rotated-instances
|
||||
;; Regression sweep for #10109, with #13267's semantics as its complement: an
|
||||
;; instance root's transformation is inherited, overridable content —
|
||||
;; asymmetric to position, which is free per-instance placement.
|
||||
;;
|
||||
;; On the simple component-with-copy, sweep three axes: optionally rotate the
|
||||
;; COPY as a whole (an override of its root's placement — must not block
|
||||
;; propagation), optionally rotate the MAIN as a whole (inherited content —
|
||||
;; an untouched copy must follow it), and apply ONE of a property edit
|
||||
;; (fills) or a geometry edit (height) to the main child. In EVERY variant
|
||||
;; the chosen edit must arrive at the copy child; the copy's rotation is its
|
||||
;; own 45° if the copy was rotated (override wins), else the main's 45° if
|
||||
;; the main was rotated (clean copy follows), else 0; and only a rotated
|
||||
;; copy's ROOT is touched (:geometry-group) — its child merely follows and
|
||||
;; stays untouched.
|
||||
(t/async
|
||||
done
|
||||
(let [rotate-copy (n/rotate :copy-root 45)
|
||||
rotate-main (n/rotate :main-root 45)
|
||||
edits (tm/one-of
|
||||
[(n/change-attr :main-instance :fills red)
|
||||
(n/change-height :main-instance 80)])]
|
||||
(ftm/check
|
||||
done
|
||||
{:setup setup/simple-component-with-labeled-copy
|
||||
:operation (tm/in-sequence [(tm/optional rotate-copy)
|
||||
(tm/optional rotate-main)
|
||||
edits])}
|
||||
(fn [situation]
|
||||
(let [chosen (tm/get-choice situation edits)
|
||||
copy-root (setup/copy-root situation)
|
||||
copy-child (setup/copy-instance situation)
|
||||
expected-rotation (cond
|
||||
(tm/applied? situation rotate-copy) 45
|
||||
(tm/applied? situation rotate-main) 45
|
||||
:else 0)]
|
||||
;; the chosen main edit arrived at the copy child, in every variant
|
||||
(t/is (some? chosen))
|
||||
(t/is (n/has-property-of chosen copy-child))
|
||||
;; the copy shows the expected rotation (own override > followed main > none)
|
||||
(t/is (mth/close? (or (:rotation copy-root) 0) expected-rotation))
|
||||
(t/is (mth/close? (or (:rotation copy-child) 0) expected-rotation))
|
||||
;; only a rotated copy's ROOT is an override; the child merely follows
|
||||
(t/is (= (if (tm/applied? situation rotate-copy) #{:geometry-group} nil)
|
||||
(:touched copy-root)))
|
||||
(t/is (nil? (:touched copy-child)))))))))
|
||||
|
||||
(t/deftest case-touched-copy-child-survives-main-rotation
|
||||
;; A copy whose ROOT is rotated (a geometry override) AND whose CHILD has its
|
||||
;; own geometry override (resized) must keep the child exactly in place when the
|
||||
;; MAIN is later rotated: the child's placement is its own, shielded by its
|
||||
;; touched :geometry-group. We capture the child's position relative to the copy
|
||||
;; root before the main rotation and assert it is unchanged after.
|
||||
(t/async
|
||||
done
|
||||
(let [before (atom nil)
|
||||
rel-pos (fn [situation]
|
||||
(let [child (setup/copy-instance situation)
|
||||
root (setup/copy-root situation)]
|
||||
(-> (gpt/subtract (grc/rect->center (:selrect child))
|
||||
(grc/rect->center (:selrect root)))
|
||||
(gpt/transform (:transform-inverse root (gmt/matrix))))))
|
||||
capture (tm/test-that (fn [s] (reset! before (rel-pos s)) (t/is (some? @before))))]
|
||||
(ftm/check
|
||||
done
|
||||
{:setup setup/simple-component-with-labeled-copy
|
||||
:operation (tm/in-sequence [(n/rotate :copy-root 30)
|
||||
(n/change-height :copy-instance 60)
|
||||
capture
|
||||
(n/rotate :main-root 40)])}
|
||||
(fn [situation]
|
||||
(let [copy-child (setup/copy-instance situation)
|
||||
copy-root (setup/copy-root situation)
|
||||
after (rel-pos situation)]
|
||||
;; both overrides are recorded as geometry touches
|
||||
(t/is (contains? (:touched copy-root) :geometry-group))
|
||||
(t/is (contains? (:touched copy-child) :geometry-group))
|
||||
;; the child keeps its own overridden height
|
||||
(t/is (mth/close? (:height copy-child) 60))
|
||||
;; and its position relative to the copy root is unchanged by the main rotation
|
||||
(t/is (mth/close? (:x @before) (:x after) 0.5) (str "rel-x " (:x @before) " -> " (:x after)))
|
||||
(t/is (mth/close? (:y @before) (:y after) 0.5) (str "rel-y " (:y @before) " -> " (:y after)))))))))
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user