Merge branch 'open-pencil:master' into master
This commit is contained in:
commit
6b2de4ad7a
|
|
@ -268,7 +268,7 @@ OpenPencil follows a Reka UI-inspired component namespace structure:
|
|||
- No module-level mutable state in components — use the editor store
|
||||
- Prefer `tw-animate-css` for animations — don't hand-write `<style>` transition keyframes
|
||||
- No duplicated component logic — if two components share data (icon maps, util functions, constants), export from one place and import in both
|
||||
- `packages/core/src/kiwi/kiwi-schema/` is vendored — don't modify
|
||||
- `packages/core/src/kiwi/schema-runtime/` contains the vendored Kiwi codec runtime; keep runtime changes minimal and prefer wrappers/helpers for project-specific validation
|
||||
- Core code must guard browser APIs: `typeof window !== 'undefined'`, `typeof document === 'undefined'`
|
||||
- Constants in `src/constants.ts` — no magic numbers in components or composables
|
||||
|
||||
|
|
@ -375,8 +375,9 @@ Self-review checklist:
|
|||
## Publishing
|
||||
|
||||
- `bun publish` from package dirs — resolves `workspace:*` → actual versions
|
||||
- Core: `prepublishOnly` runs `tsc` to build `dist/` for Node.js consumers
|
||||
- CLI requires Bun runtime (`#!/usr/bin/env bun`)
|
||||
- Public packages publish built `dist/` output, not runtime TypeScript entrypoints
|
||||
- Core, Vue, MCP, and CLI build with tsdown before publishing
|
||||
- CLI publishes a Node-compatible `bin/openpencil.js` wrapper; do not point package `bin` entries at TypeScript source
|
||||
|
||||
## Reference
|
||||
|
||||
|
|
|
|||
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -2,6 +2,24 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
|
||||
- Greatly improve importing Figma `.fig` files with complex component systems: badges, avatars, icons, links, input fields, lists, date pickers, nested instances, component swaps, and variant properties now open much closer to their original Figma appearance.
|
||||
- Fix missing or white content in imported `.fig` files caused by unresolved Figma variable bindings, including image/avatar badges, icon colors, text colors, and variable-backed component overrides.
|
||||
- Preserve more Figma document details when opening and saving `.fig` files, including internal component pages, component ordering, page metadata, canvas backgrounds, text layout, glyph rendering, vector geometry, effects, shadows, and instance overrides.
|
||||
- Keep user edits after opening an imported `.fig` file: changing size, position, fills, text, or layout now wins over preserved Figma round-trip data when the document is saved again.
|
||||
- Fix `.fig` exports so files reopened in Figma or OpenPencil keep their pages, components, instances, text wrapping, icons, avatars, and preview thumbnail intact.
|
||||
- Fix live canvas updates during move/resize/edit previews so visible scene changes repaint immediately.
|
||||
- Fix accidental duplicate creation when Alt-clicking without dragging.
|
||||
- Fix MCP startup in the browser.
|
||||
- Fix CanvasKit loading outside the browser when project paths contain spaces.
|
||||
|
||||
### Performance
|
||||
|
||||
- Open large `.fig` files faster by deferring work for pages you have not viewed yet while still preparing all needed content before export.
|
||||
- Improve canvas responsiveness during zooming, panning, dragging, and editing by reusing cached scene backing where safe.
|
||||
- Speed up `.fig` export for documents with many preserved Figma paint and variable payloads.
|
||||
|
||||
## 0.12.2 — 2026-05-19
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ Or download from the [releases page](https://github.com/open-pencil/open-pencil/
|
|||
## CLI
|
||||
|
||||
```sh
|
||||
bun add -g @open-pencil/cli
|
||||
npm install -g @open-pencil/cli
|
||||
# or: bun add -g @open-pencil/cli
|
||||
```
|
||||
|
||||
### Inspect design files
|
||||
|
|
|
|||
22
bun.lock
22
bun.lock
|
|
@ -98,9 +98,9 @@
|
|||
},
|
||||
"packages/cli": {
|
||||
"name": "@open-pencil/cli",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.2",
|
||||
"bin": {
|
||||
"openpencil": "./src/index.ts",
|
||||
"openpencil": "./bin/openpencil.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-pencil/core": "workspace:*",
|
||||
|
|
@ -114,9 +114,11 @@
|
|||
},
|
||||
"packages/core": {
|
||||
"name": "@open-pencil/core",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.2",
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.7",
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"acorn": "^8.16.0",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"culori": "^4.0.2",
|
||||
|
|
@ -126,9 +128,11 @@
|
|||
"fflate": "^0.8.2",
|
||||
"fontoxpath": "^3.34.0",
|
||||
"fzstd": "^0.1.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"nanoevents": "^9.1.0",
|
||||
"opentype.js": "^1.3.4",
|
||||
"sucrase": "^3.35.1",
|
||||
"svg2pdf.js": "^2.7.0",
|
||||
"svgpath": "^2.6.0",
|
||||
"twirlwind": "^0.3.0",
|
||||
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3",
|
||||
|
|
@ -152,10 +156,10 @@
|
|||
},
|
||||
"packages/mcp": {
|
||||
"name": "@open-pencil/mcp",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.2",
|
||||
"bin": {
|
||||
"openpencil-mcp": "./dist/stdio.js",
|
||||
"openpencil-mcp-http": "./dist/index.js",
|
||||
"openpencil-mcp": "./bin/openpencil-mcp.js",
|
||||
"openpencil-mcp-http": "./bin/openpencil-mcp-http.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
|
|
@ -173,12 +177,16 @@
|
|||
},
|
||||
"packages/vue": {
|
||||
"name": "@open-pencil/vue",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.2",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.9",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
|
||||
"@nanostores/i18n": "^1.2.2",
|
||||
"@nanostores/vue": "^1.1.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"nanostores": "^1.2.0",
|
||||
"reka-ui": "^2.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsdown": "^0.21.7",
|
||||
|
|
|
|||
|
|
@ -415,7 +415,7 @@
|
|||
},
|
||||
{
|
||||
"files": [
|
||||
"**/kiwi/kiwi-schema/**"
|
||||
"**/kiwi/schema-runtime/**"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "off",
|
||||
|
|
|
|||
|
|
@ -9,14 +9,15 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "bun run lint && vite build",
|
||||
"build": "bun run build:packages && bun run lint && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"lint": "bun run lint:structure && oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/",
|
||||
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ tests/ scripts/",
|
||||
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ tests scripts/",
|
||||
"check": "bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:arch && bun run test:dupes",
|
||||
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:packages && bun run check:arch && bun run test:dupes",
|
||||
"check:i18n": "bun scripts/check-locales.ts",
|
||||
"check:packages": "bun scripts/check-package-metadata.ts",
|
||||
"check:arch": "steiger .",
|
||||
"check:vue": "vue-tsc --noEmit -p tsconfig.json && vue-tsc --noEmit -p packages/vue/tsconfig.json",
|
||||
"test": "playwright test --project=openpencil",
|
||||
|
|
@ -26,6 +27,8 @@
|
|||
"test:unit": "bun test ./tests/engine",
|
||||
"test:coverage": "bun test --coverage ./tests/engine",
|
||||
"test:dupes": "jscpd packages/core/src packages/cli/src src --min-lines 5 --min-tokens 50 --format typescript --threshold 0",
|
||||
"test:packages": "bun scripts/check-package-metadata.ts && bun scripts/smoke-packages.ts",
|
||||
"build:packages": "bun --filter @open-pencil/core build && bun --filter @open-pencil/vue build && bun --filter @open-pencil/mcp build && bun --filter @open-pencil/cli build",
|
||||
"open-pencil": "bun packages/cli/src/index.ts",
|
||||
"docs:dev": "bun --filter @open-pencil/docs dev",
|
||||
"docs:build": "bun --filter @open-pencil/docs build",
|
||||
|
|
|
|||
2
packages/cli/bin/openpencil.js
Executable file
2
packages/cli/bin/openpencil.js
Executable file
|
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env node
|
||||
import '../dist/index.mjs'
|
||||
|
|
@ -7,9 +7,16 @@
|
|||
"#cli/*": "./src/*"
|
||||
},
|
||||
"bin": {
|
||||
"openpencil": "./src/index.ts"
|
||||
"openpencil": "./bin/openpencil.js"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bunx tsdown --config tsdown.config.ts",
|
||||
"prepublishOnly": "bun run build"
|
||||
},
|
||||
"files": ["src"],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/open-pencil/open-pencil.git",
|
||||
|
|
|
|||
|
|
@ -119,9 +119,11 @@ async function exportFromFile(format: string, args: ExportArgs) {
|
|||
}
|
||||
|
||||
const formatId = format.toLowerCase()
|
||||
let options: { format?: string; scale?: number; quality?: number } | undefined
|
||||
let options: { format?: string; scale?: number; quality?: number; renderThumbnail?: boolean } | undefined
|
||||
if (format === 'JSX') {
|
||||
options = { format: args.style }
|
||||
} else if (format === 'FIG') {
|
||||
options = { renderThumbnail: true }
|
||||
} else if (format === 'PNG' || format === 'JPG' || format === 'WEBP') {
|
||||
options = {
|
||||
format,
|
||||
|
|
|
|||
17
packages/cli/tsdown.config.ts
Normal file
17
packages/cli/tsdown.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: './src/index.ts'
|
||||
},
|
||||
platform: 'node',
|
||||
format: ['esm'],
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
outDir: './dist',
|
||||
treeshake: false,
|
||||
deps: {
|
||||
neverBundle: ['@open-pencil/core', /^@open-pencil\/core\//, 'canvaskit-wasm', /^node:/],
|
||||
onlyBundle: false
|
||||
}
|
||||
})
|
||||
|
|
@ -9,150 +9,149 @@
|
|||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"bun": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./scene-graph": {
|
||||
"types": "./src/scene-graph/index.ts",
|
||||
"bun": "./src/scene-graph/index.ts",
|
||||
"default": "./src/scene-graph/index.ts"
|
||||
"types": "./dist/scene-graph/index.d.ts",
|
||||
"default": "./dist/scene-graph/index.js",
|
||||
"import": "./dist/scene-graph/index.js"
|
||||
},
|
||||
"./color": {
|
||||
"types": "./src/color/index.ts",
|
||||
"bun": "./src/color/index.ts",
|
||||
"default": "./src/color/index.ts"
|
||||
"types": "./dist/color/index.d.ts",
|
||||
"default": "./dist/color/index.js",
|
||||
"import": "./dist/color/index.js"
|
||||
},
|
||||
"./text": {
|
||||
"types": "./src/text/index.ts",
|
||||
"bun": "./src/text/index.ts",
|
||||
"default": "./src/text/index.ts"
|
||||
"types": "./dist/text/index.d.ts",
|
||||
"default": "./dist/text/index.js",
|
||||
"import": "./dist/text/index.js"
|
||||
},
|
||||
"./vector": {
|
||||
"types": "./src/vector/index.ts",
|
||||
"bun": "./src/vector/index.ts",
|
||||
"default": "./src/vector/index.ts"
|
||||
"types": "./dist/vector/index.d.ts",
|
||||
"default": "./dist/vector/index.js",
|
||||
"import": "./dist/vector/index.js"
|
||||
},
|
||||
"./figma-api": {
|
||||
"types": "./src/figma-api/index.ts",
|
||||
"bun": "./src/figma-api/index.ts",
|
||||
"default": "./src/figma-api/index.ts"
|
||||
"types": "./dist/figma-api/index.d.ts",
|
||||
"default": "./dist/figma-api/index.js",
|
||||
"import": "./dist/figma-api/index.js"
|
||||
},
|
||||
"./icons": {
|
||||
"types": "./src/icons/index.ts",
|
||||
"bun": "./src/icons/index.ts",
|
||||
"default": "./src/icons/index.ts"
|
||||
"types": "./dist/icons/index.d.ts",
|
||||
"default": "./dist/icons/index.js",
|
||||
"import": "./dist/icons/index.js"
|
||||
},
|
||||
"./canvas": {
|
||||
"types": "./src/canvas/index.ts",
|
||||
"bun": "./src/canvas/index.ts",
|
||||
"default": "./src/canvas/index.ts"
|
||||
"types": "./dist/canvas/index.d.ts",
|
||||
"default": "./dist/canvas/index.js",
|
||||
"import": "./dist/canvas/index.js"
|
||||
},
|
||||
"./design-jsx": {
|
||||
"types": "./src/design-jsx/index.ts",
|
||||
"bun": "./src/design-jsx/index.ts",
|
||||
"default": "./src/design-jsx/index.ts"
|
||||
"types": "./dist/design-jsx/index.d.ts",
|
||||
"default": "./dist/design-jsx/index.js",
|
||||
"import": "./dist/design-jsx/index.js"
|
||||
},
|
||||
"./editor": {
|
||||
"types": "./src/editor/index.ts",
|
||||
"bun": "./src/editor/index.ts",
|
||||
"default": "./src/editor/index.ts"
|
||||
"types": "./dist/editor/index.d.ts",
|
||||
"default": "./dist/editor/index.js",
|
||||
"import": "./dist/editor/index.js"
|
||||
},
|
||||
"./tools": {
|
||||
"types": "./src/tools/index.ts",
|
||||
"bun": "./src/tools/index.ts",
|
||||
"default": "./src/tools/index.ts"
|
||||
"types": "./dist/tools/index.d.ts",
|
||||
"default": "./dist/tools/index.js",
|
||||
"import": "./dist/tools/index.js"
|
||||
},
|
||||
"./profiler": {
|
||||
"types": "./src/profiler/index.ts",
|
||||
"bun": "./src/profiler/index.ts",
|
||||
"default": "./src/profiler/index.ts"
|
||||
"types": "./dist/profiler/index.d.ts",
|
||||
"default": "./dist/profiler/index.js",
|
||||
"import": "./dist/profiler/index.js"
|
||||
},
|
||||
"./rpc": {
|
||||
"types": "./src/rpc/index.ts",
|
||||
"bun": "./src/rpc/index.ts",
|
||||
"default": "./src/rpc/index.ts"
|
||||
"types": "./dist/rpc/index.d.ts",
|
||||
"default": "./dist/rpc/index.js",
|
||||
"import": "./dist/rpc/index.js"
|
||||
},
|
||||
"./lint": {
|
||||
"types": "./src/lint/index.ts",
|
||||
"bun": "./src/lint/index.ts",
|
||||
"default": "./src/lint/index.ts"
|
||||
"types": "./dist/lint/index.d.ts",
|
||||
"default": "./dist/lint/index.js",
|
||||
"import": "./dist/lint/index.js"
|
||||
},
|
||||
"./io": {
|
||||
"types": "./src/io/index.ts",
|
||||
"bun": "./src/io/index.ts",
|
||||
"default": "./src/io/index.ts"
|
||||
"types": "./dist/io/index.d.ts",
|
||||
"default": "./dist/io/index.js",
|
||||
"import": "./dist/io/index.js"
|
||||
},
|
||||
"./io/formats/fig": {
|
||||
"types": "./src/io/formats/fig/index.ts",
|
||||
"bun": "./src/io/formats/fig/index.ts",
|
||||
"default": "./src/io/formats/fig/index.ts"
|
||||
"types": "./dist/io/formats/fig/index.d.ts",
|
||||
"default": "./dist/io/formats/fig/index.js",
|
||||
"import": "./dist/io/formats/fig/index.js"
|
||||
},
|
||||
"./io/formats/pen": {
|
||||
"types": "./src/io/formats/pen/index.ts",
|
||||
"bun": "./src/io/formats/pen/index.ts",
|
||||
"default": "./src/io/formats/pen/index.ts"
|
||||
"types": "./dist/io/formats/pen/index.d.ts",
|
||||
"default": "./dist/io/formats/pen/index.js",
|
||||
"import": "./dist/io/formats/pen/index.js"
|
||||
},
|
||||
"./io/formats/jsx": {
|
||||
"types": "./src/io/formats/jsx/index.ts",
|
||||
"bun": "./src/io/formats/jsx/index.ts",
|
||||
"default": "./src/io/formats/jsx/index.ts"
|
||||
"types": "./dist/io/formats/jsx/index.d.ts",
|
||||
"default": "./dist/io/formats/jsx/index.js",
|
||||
"import": "./dist/io/formats/jsx/index.js"
|
||||
},
|
||||
"./io/formats/raster": {
|
||||
"types": "./src/io/formats/raster/index.ts",
|
||||
"bun": "./src/io/formats/raster/index.ts",
|
||||
"default": "./src/io/formats/raster/index.ts"
|
||||
"types": "./dist/io/formats/raster/index.d.ts",
|
||||
"default": "./dist/io/formats/raster/index.js",
|
||||
"import": "./dist/io/formats/raster/index.js"
|
||||
},
|
||||
"./io/formats/svg": {
|
||||
"types": "./src/io/formats/svg/index.ts",
|
||||
"bun": "./src/io/formats/svg/index.ts",
|
||||
"default": "./src/io/formats/svg/index.ts"
|
||||
"types": "./dist/io/formats/svg/index.d.ts",
|
||||
"default": "./dist/io/formats/svg/index.js",
|
||||
"import": "./dist/io/formats/svg/index.js"
|
||||
},
|
||||
"./kiwi": {
|
||||
"types": "./src/kiwi/index.ts",
|
||||
"bun": "./src/kiwi/index.ts",
|
||||
"default": "./src/kiwi/index.ts"
|
||||
"types": "./dist/kiwi/index.d.ts",
|
||||
"default": "./dist/kiwi/index.js",
|
||||
"import": "./dist/kiwi/index.js"
|
||||
},
|
||||
"./constants": {
|
||||
"types": "./src/constants.ts",
|
||||
"bun": "./src/constants.ts",
|
||||
"default": "./src/constants.ts"
|
||||
"types": "./dist/constants.d.ts",
|
||||
"default": "./dist/constants.js",
|
||||
"import": "./dist/constants.js"
|
||||
},
|
||||
"./random": {
|
||||
"types": "./src/random.ts",
|
||||
"bun": "./src/random.ts",
|
||||
"default": "./src/random.ts"
|
||||
"types": "./dist/random.d.ts",
|
||||
"default": "./dist/random.js",
|
||||
"import": "./dist/random.js"
|
||||
},
|
||||
"./xpath": {
|
||||
"types": "./src/xpath.ts",
|
||||
"bun": "./src/xpath.ts",
|
||||
"default": "./src/xpath.ts"
|
||||
"types": "./dist/xpath.d.ts",
|
||||
"default": "./dist/xpath.js",
|
||||
"import": "./dist/xpath.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types.ts",
|
||||
"bun": "./src/types.ts",
|
||||
"default": "./src/types.ts"
|
||||
"types": "./dist/types.d.ts",
|
||||
"default": "./dist/types.js",
|
||||
"import": "./dist/types.js"
|
||||
},
|
||||
"./canvaskit": {
|
||||
"types": "./src/canvaskit.ts",
|
||||
"bun": "./src/canvaskit.ts",
|
||||
"default": "./src/canvaskit.ts"
|
||||
"types": "./dist/canvaskit.d.ts",
|
||||
"default": "./dist/canvaskit.js",
|
||||
"import": "./dist/canvaskit.js"
|
||||
},
|
||||
"./layout": {
|
||||
"types": "./src/layout.ts",
|
||||
"bun": "./src/layout.ts",
|
||||
"default": "./src/layout.ts"
|
||||
"types": "./dist/layout.d.ts",
|
||||
"default": "./dist/layout.js",
|
||||
"import": "./dist/layout.js"
|
||||
},
|
||||
"./geometry": {
|
||||
"types": "./src/geometry.ts",
|
||||
"bun": "./src/geometry.ts",
|
||||
"default": "./src/geometry.ts"
|
||||
"types": "./dist/geometry.d.ts",
|
||||
"default": "./dist/geometry.js",
|
||||
"import": "./dist/geometry.js"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"assets"
|
||||
],
|
||||
|
|
@ -167,154 +166,12 @@
|
|||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./scene-graph": {
|
||||
"types": "./dist/scene-graph/index.d.ts",
|
||||
"import": "./dist/scene-graph/index.js",
|
||||
"default": "./dist/scene-graph/index.js"
|
||||
},
|
||||
"./color": {
|
||||
"types": "./dist/color/index.d.ts",
|
||||
"import": "./dist/color/index.js",
|
||||
"default": "./dist/color/index.js"
|
||||
},
|
||||
"./text": {
|
||||
"types": "./dist/text/index.d.ts",
|
||||
"import": "./dist/text/index.js",
|
||||
"default": "./dist/text/index.js"
|
||||
},
|
||||
"./vector": {
|
||||
"types": "./dist/vector/index.d.ts",
|
||||
"import": "./dist/vector/index.js",
|
||||
"default": "./dist/vector/index.js"
|
||||
},
|
||||
"./figma-api": {
|
||||
"types": "./dist/figma-api/index.d.ts",
|
||||
"import": "./dist/figma-api/index.js",
|
||||
"default": "./dist/figma-api/index.js"
|
||||
},
|
||||
"./icons": {
|
||||
"types": "./dist/icons/index.d.ts",
|
||||
"import": "./dist/icons/index.js",
|
||||
"default": "./dist/icons/index.js"
|
||||
},
|
||||
"./canvas": {
|
||||
"types": "./dist/canvas/index.d.ts",
|
||||
"import": "./dist/canvas/index.js",
|
||||
"default": "./dist/canvas/index.js"
|
||||
},
|
||||
"./design-jsx": {
|
||||
"types": "./dist/design-jsx/index.d.ts",
|
||||
"import": "./dist/design-jsx/index.js",
|
||||
"default": "./dist/design-jsx/index.js"
|
||||
},
|
||||
"./editor": {
|
||||
"types": "./dist/editor/index.d.ts",
|
||||
"import": "./dist/editor/index.js",
|
||||
"default": "./dist/editor/index.js"
|
||||
},
|
||||
"./tools": {
|
||||
"types": "./dist/tools/index.d.ts",
|
||||
"import": "./dist/tools/index.js",
|
||||
"default": "./dist/tools/index.js"
|
||||
},
|
||||
"./profiler": {
|
||||
"types": "./dist/profiler/index.d.ts",
|
||||
"import": "./dist/profiler/index.js",
|
||||
"default": "./dist/profiler/index.js"
|
||||
},
|
||||
"./rpc": {
|
||||
"types": "./dist/rpc/index.d.ts",
|
||||
"import": "./dist/rpc/index.js",
|
||||
"default": "./dist/rpc/index.js"
|
||||
},
|
||||
"./lint": {
|
||||
"types": "./dist/lint/index.d.ts",
|
||||
"import": "./dist/lint/index.js",
|
||||
"default": "./dist/lint/index.js"
|
||||
},
|
||||
"./io": {
|
||||
"types": "./dist/io/index.d.ts",
|
||||
"import": "./dist/io/index.js",
|
||||
"default": "./dist/io/index.js"
|
||||
},
|
||||
"./io/formats/fig": {
|
||||
"types": "./dist/io/formats/fig/index.d.ts",
|
||||
"import": "./dist/io/formats/fig/index.js",
|
||||
"default": "./dist/io/formats/fig/index.js"
|
||||
},
|
||||
"./io/formats/pen": {
|
||||
"types": "./dist/io/formats/pen/index.d.ts",
|
||||
"import": "./dist/io/formats/pen/index.js",
|
||||
"default": "./dist/io/formats/pen/index.js"
|
||||
},
|
||||
"./io/formats/jsx": {
|
||||
"types": "./dist/io/formats/jsx/index.d.ts",
|
||||
"import": "./dist/io/formats/jsx/index.js",
|
||||
"default": "./dist/io/formats/jsx/index.js"
|
||||
},
|
||||
"./io/formats/raster": {
|
||||
"types": "./dist/io/formats/raster/index.d.ts",
|
||||
"import": "./dist/io/formats/raster/index.js",
|
||||
"default": "./dist/io/formats/raster/index.js"
|
||||
},
|
||||
"./io/formats/svg": {
|
||||
"types": "./dist/io/formats/svg/index.d.ts",
|
||||
"import": "./dist/io/formats/svg/index.js",
|
||||
"default": "./dist/io/formats/svg/index.js"
|
||||
},
|
||||
"./kiwi": {
|
||||
"types": "./dist/kiwi/index.d.ts",
|
||||
"import": "./dist/kiwi/index.js",
|
||||
"default": "./dist/kiwi/index.js"
|
||||
},
|
||||
"./constants": {
|
||||
"types": "./dist/constants.d.ts",
|
||||
"import": "./dist/constants.js",
|
||||
"default": "./dist/constants.js"
|
||||
},
|
||||
"./random": {
|
||||
"types": "./dist/random.d.ts",
|
||||
"import": "./dist/random.js",
|
||||
"default": "./dist/random.js"
|
||||
},
|
||||
"./xpath": {
|
||||
"types": "./dist/xpath.d.ts",
|
||||
"import": "./dist/xpath.js",
|
||||
"default": "./dist/xpath.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/types.js",
|
||||
"default": "./dist/types.js"
|
||||
},
|
||||
"./canvaskit": {
|
||||
"types": "./dist/canvaskit.d.ts",
|
||||
"import": "./dist/canvaskit.js",
|
||||
"default": "./dist/canvaskit.js"
|
||||
},
|
||||
"./layout": {
|
||||
"types": "./dist/layout.d.ts",
|
||||
"import": "./dist/layout.js",
|
||||
"default": "./dist/layout.js"
|
||||
},
|
||||
"./geometry": {
|
||||
"types": "./dist/geometry.d.ts",
|
||||
"import": "./dist/geometry.js",
|
||||
"default": "./dist/geometry.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
"provenance": true
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.7",
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"acorn": "^8.16.0",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"culori": "^4.0.2",
|
||||
|
|
@ -324,9 +181,11 @@
|
|||
"fflate": "^0.8.2",
|
||||
"fontoxpath": "^3.34.0",
|
||||
"fzstd": "^0.1.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"nanoevents": "^9.1.0",
|
||||
"opentype.js": "^1.3.4",
|
||||
"sucrase": "^3.35.1",
|
||||
"svg2pdf.js": "^2.7.0",
|
||||
"svgpath": "^2.6.0",
|
||||
"twirlwind": "^0.3.0",
|
||||
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3"
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ function lineStrokePath(r: SkiaRenderer, node: SceneNode): Path | null {
|
|||
path.lineTo(node.width, node.height)
|
||||
const stroke = node.strokes.find((item) => item.visible)
|
||||
const outline = path.stroke({ width: stroke?.weight ?? 1 })
|
||||
path.delete()
|
||||
if (outline !== path) path.delete()
|
||||
return outline
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { uniq } from 'es-toolkit/array'
|
|||
import { getCanvasKit } from '#core/canvaskit'
|
||||
import { resolveRGBAForPreview } from '#core/color/management'
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '#core/constants'
|
||||
import type { NodeChange } from '#core/kiwi/binary/codec'
|
||||
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||
import type { SceneNode } from '#core/scene-graph'
|
||||
import { resolveNodeTextDirection } from '#core/text/direction'
|
||||
import { fontManager, weightToStyle } from '#core/text/fonts'
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ export async function getCanvasKit(options?: CanvasKitOptions): Promise<CanvasKi
|
|||
if (instance) return instance
|
||||
|
||||
const defaultLocate = (file: string) => {
|
||||
if (!IS_BROWSER) return file
|
||||
if (!IS_BROWSER) {
|
||||
const ckPath = import.meta.resolve('canvaskit-wasm')
|
||||
return decodeURIComponent(new URL(file, ckPath).pathname)
|
||||
}
|
||||
const base = 'env' in import.meta ? import.meta.env.BASE_URL : '/'
|
||||
const prefix = base === '/' ? '' : base.replace(/\/$/, '')
|
||||
return `${prefix}/${file}`
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { inflateSync, deflateSync } from 'fflate'
|
||||
|
||||
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/binary/codec'
|
||||
import type { NodeChange as KiwiNodeChange } from './kiwi/binary/codec'
|
||||
import { populateAndApplyOverrides } from './kiwi/instance-overrides'
|
||||
import type { InstanceNodeChange } from './kiwi/instance-overrides'
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi/kiwi-schema'
|
||||
import { nodeChangeToProps, sortChildren } from './kiwi/node-change/convert'
|
||||
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/fig/codec'
|
||||
import type { NodeChange as KiwiNodeChange } from './kiwi/fig/codec'
|
||||
import { populateAndApplyOverrides } from './kiwi/fig/instance-overrides'
|
||||
import type { InstanceNodeChange } from './kiwi/fig/instance-overrides'
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi/schema-runtime'
|
||||
import { nodeChangeToProps, sortChildren } from './kiwi/fig/node-change/convert'
|
||||
import {
|
||||
sceneNodeToKiwi,
|
||||
buildFigKiwi,
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
makeDocumentNodeChange,
|
||||
makeCanvasNodeChange,
|
||||
buildFontDigestMap
|
||||
} from './kiwi/node-change/serialize'
|
||||
} from './kiwi/fig/node-change/serialize'
|
||||
import { randomInt } from './random'
|
||||
import type { SceneGraph, SceneNode } from './scene-graph'
|
||||
import { shapeTextForClipboard } from './canvas/text'
|
||||
|
|
@ -337,6 +337,7 @@ export async function buildFigmaClipboardHTML(
|
|||
if (!source) return
|
||||
change.textAutoResize = 'NONE'
|
||||
change.textUserLayoutVersion = 5
|
||||
change.lineHeight = { value: source.lineHeight ?? 100, units: source.lineHeight ? 'PIXELS' : 'PERCENT' }
|
||||
const shaped = await shapeTextForClipboard(source).catch(() => null)
|
||||
change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap, shaped, blobs)
|
||||
})
|
||||
|
|
|
|||
5
packages/core/src/global.d.ts
vendored
5
packages/core/src/global.d.ts
vendored
|
|
@ -22,3 +22,8 @@ declare module '*.md' {
|
|||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare module '*?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ export {
|
|||
sceneNodeToKiwi,
|
||||
fractionalPosition,
|
||||
mapToFigmaType
|
||||
} from './kiwi/node-change/serialize'
|
||||
} from './kiwi/fig/node-change/serialize'
|
||||
export { buildDerivedTextDataV4 } from './text/derived-text/clipboard'
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -168,7 +168,8 @@ export const figFormat: IOFormatAdapter = {
|
|||
graph,
|
||||
context?.canvasKit,
|
||||
context?.renderer,
|
||||
options?.thumbnailPageId
|
||||
options?.thumbnailPageId,
|
||||
options?.renderThumbnail ?? false
|
||||
)
|
||||
return {
|
||||
format: 'fig',
|
||||
|
|
@ -183,7 +184,8 @@ export const figFormat: IOFormatAdapter = {
|
|||
extracted.graph,
|
||||
context?.canvasKit,
|
||||
context?.renderer,
|
||||
options?.thumbnailPageId ?? extracted.pageId ?? undefined
|
||||
options?.thumbnailPageId ?? extracted.pageId ?? undefined,
|
||||
options?.renderThumbnail ?? false
|
||||
)
|
||||
return {
|
||||
format: 'fig',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { zipSync, type Zippable } from 'fflate'
|
||||
|
||||
import { buildFigKiwi } from '#core/kiwi/node-change/serialize'
|
||||
import { buildFigKiwi } from '#core/kiwi/fig/node-change/serialize'
|
||||
|
||||
export function compressFigDataSync(
|
||||
schemaDeflated: Uint8Array,
|
||||
|
|
|
|||
|
|
@ -6,10 +6,18 @@ interface CompressMessage {
|
|||
thumbnailPng: Uint8Array
|
||||
metaJson: string
|
||||
images: Array<{ name: string; data: Uint8Array }>
|
||||
figKiwiVersion?: number
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<CompressMessage>) => {
|
||||
const { schemaDeflated, kiwiData, thumbnailPng, metaJson, images } = e.data
|
||||
const result = compressFigDataSync(schemaDeflated, kiwiData, thumbnailPng, metaJson, images)
|
||||
const { schemaDeflated, kiwiData, thumbnailPng, metaJson, images, figKiwiVersion } = e.data
|
||||
const result = compressFigDataSync(
|
||||
schemaDeflated,
|
||||
kiwiData,
|
||||
thumbnailPng,
|
||||
metaJson,
|
||||
images,
|
||||
figKiwiVersion
|
||||
)
|
||||
self.postMessage(result, { transfer: [result.buffer] })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import type { SkiaRenderer } from '#core/canvas'
|
|||
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from '#core/constants'
|
||||
import { renderThumbnail } from '#core/io/formats/raster'
|
||||
import { populateAllLazyFigImportRoots } from '#core/kiwi/fig/lazy-import'
|
||||
import { initCodec, getCompiledSchema, getSchemaBytes } from '#core/kiwi/binary/codec'
|
||||
import type { NodeChange } from '#core/kiwi/binary/codec'
|
||||
import { stringToGuid } from '#core/kiwi/node-change/convert'
|
||||
import { initCodec, getCompiledSchema, getSchemaBytes } from '#core/kiwi/fig/codec'
|
||||
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||
import { stringToGuid } from '#core/kiwi/fig/node-change/convert'
|
||||
import { buildFigmaPaintVariableColorMap } from '#core/kiwi/fig/node-change/export-node'
|
||||
import {
|
||||
sceneNodeToKiwi,
|
||||
fractionalPosition,
|
||||
|
|
@ -15,7 +16,7 @@ import {
|
|||
safeColor,
|
||||
makeDocumentNodeChange,
|
||||
makeCanvasNodeChange
|
||||
} from '#core/kiwi/node-change/serialize'
|
||||
} from '#core/kiwi/fig/node-change/serialize'
|
||||
import type { SceneGraph, VariableValue } from '#core/scene-graph'
|
||||
import type { GUID } from '#core/types'
|
||||
|
||||
|
|
@ -29,6 +30,13 @@ const THUMBNAIL_1X1 = Uint8Array.from(
|
|||
)
|
||||
|
||||
type KiwiNodeChange = NodeChange & Record<string, unknown>
|
||||
type FigExportPage = ReturnType<SceneGraph['getPages']>[number]
|
||||
|
||||
interface CanvasExportEntry {
|
||||
page: FigExportPage
|
||||
canvasGuid: GUID
|
||||
canvasNc: KiwiNodeChange
|
||||
}
|
||||
|
||||
function variableValueToKiwi(
|
||||
value: VariableValue,
|
||||
|
|
@ -74,6 +82,22 @@ function collectImageEntries(graph: SceneGraph): Array<{ name: string; data: Uin
|
|||
const THUMBNAIL_WIDTH = 400
|
||||
const THUMBNAIL_HEIGHT = 225
|
||||
|
||||
async function renderFigThumbnail(
|
||||
graph: SceneGraph,
|
||||
pageId: string | undefined,
|
||||
ck?: CanvasKit,
|
||||
renderer?: SkiaRenderer,
|
||||
renderHeadless = false
|
||||
): Promise<Uint8Array> {
|
||||
if (!pageId) return THUMBNAIL_1X1
|
||||
if (ck && renderer) {
|
||||
return renderThumbnail(ck, renderer, graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) ?? THUMBNAIL_1X1
|
||||
}
|
||||
if (!renderHeadless || IS_BROWSER || IS_TAURI) return THUMBNAIL_1X1
|
||||
const { headlessRenderThumbnail } = await import('#core/io/formats/raster')
|
||||
return (await headlessRenderThumbnail(graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)) ?? THUMBNAIL_1X1
|
||||
}
|
||||
|
||||
function assignVariableGuids(
|
||||
graph: SceneGraph,
|
||||
localIdCounter: { value: number },
|
||||
|
|
@ -170,11 +194,75 @@ function appendVariablesForCollection(
|
|||
}
|
||||
}
|
||||
|
||||
function applyImportedCanvasFields(page: FigExportPage, canvasNc: KiwiNodeChange): void {
|
||||
if (!page.source.id) return
|
||||
if (!('pageType' in page.source.fig.rawNodeFields)) delete canvasNc.pageType
|
||||
if ('backgroundColor' in page.source.fig.rawNodeFields) {
|
||||
canvasNc.backgroundColor = structuredClone(page.source.fig.rawNodeFields.backgroundColor)
|
||||
}
|
||||
const strokeJoin = page.source.fig.rawNodeFields.strokeJoin
|
||||
if (typeof strokeJoin === 'string') canvasNc.strokeJoin = strokeJoin
|
||||
const strokeWeight = page.source.fig.rawNodeFields.strokeWeight
|
||||
if (typeof strokeWeight === 'number') canvasNc.strokeWeight = strokeWeight
|
||||
}
|
||||
|
||||
function buildCanvasEntries(
|
||||
graph: SceneGraph,
|
||||
pages: FigExportPage[],
|
||||
docGuid: GUID,
|
||||
localIdCounter: { value: number },
|
||||
nodeIdToGuid: Map<string, GUID>
|
||||
): { canvasEntries: CanvasExportEntry[]; internalCanvasGuid: GUID | null } {
|
||||
const canvasEntries: CanvasExportEntry[] = []
|
||||
let internalCanvasGuid: GUID | null = null
|
||||
for (let p = 0; p < pages.length; p++) {
|
||||
const page = pages[p]
|
||||
const canvasGuid = page.source.id
|
||||
? stringToGuid(page.source.id)
|
||||
: { sessionID: 0, localID: localIdCounter.value++ }
|
||||
nodeIdToGuid.set(page.id, canvasGuid)
|
||||
if (page.internalOnly) internalCanvasGuid = canvasGuid
|
||||
|
||||
const canvasNc = makeCanvasNodeChange(
|
||||
canvasGuid,
|
||||
docGuid,
|
||||
page.source.orderKey ?? fractionalPosition(p),
|
||||
page.name,
|
||||
{
|
||||
backgroundOpacity: 1,
|
||||
backgroundColor: { ...CANVAS_BG_COLOR },
|
||||
backgroundEnabled: true
|
||||
}
|
||||
)
|
||||
applyImportedCanvasFields(page, canvasNc)
|
||||
if (page.internalOnly) canvasNc.internalOnly = true
|
||||
canvasEntries.push({ page, canvasGuid, canvasNc })
|
||||
}
|
||||
|
||||
if (graph.variableCollections.size > 0 && internalCanvasGuid === null) {
|
||||
internalCanvasGuid = { sessionID: 0, localID: localIdCounter.value++ }
|
||||
canvasEntries.push({
|
||||
page: { id: '', name: 'Internal Only Canvas', internalOnly: true } as FigExportPage,
|
||||
canvasGuid: internalCanvasGuid,
|
||||
canvasNc: makeCanvasNodeChange(
|
||||
internalCanvasGuid,
|
||||
docGuid,
|
||||
fractionalPosition(canvasEntries.length),
|
||||
'Internal Only Canvas',
|
||||
{ internalOnly: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return { canvasEntries, internalCanvasGuid }
|
||||
}
|
||||
|
||||
export async function exportFigFile(
|
||||
graph: SceneGraph,
|
||||
ck?: CanvasKit,
|
||||
renderer?: SkiaRenderer,
|
||||
pageId?: string
|
||||
pageId?: string,
|
||||
renderHeadlessThumbnail = false
|
||||
): Promise<Uint8Array> {
|
||||
populateAllLazyFigImportRoots(graph)
|
||||
await initCodec()
|
||||
|
|
@ -184,7 +272,10 @@ export async function exportFigFile(
|
|||
const docGuid = { sessionID: 0, localID: 0 }
|
||||
const localIdCounter = { value: 2 }
|
||||
|
||||
const nodeChanges: KiwiNodeChange[] = [makeDocumentNodeChange(docGuid, graph.documentColorSpace)]
|
||||
const documentNc = makeDocumentNodeChange(docGuid, graph.documentColorSpace)
|
||||
const rootNode = graph.getNode(graph.rootId)
|
||||
if (rootNode) Object.assign(documentNc, rootNode.source.fig.rawNodeFields)
|
||||
const nodeChanges: KiwiNodeChange[] = [documentNc]
|
||||
|
||||
const blobs: Uint8Array[] = []
|
||||
const pages = graph.getPages(true)
|
||||
|
|
@ -192,25 +283,27 @@ export async function exportFigFile(
|
|||
const varIdToGuid = new Map<string, GUID>()
|
||||
const modeIdToGuid = new Map<string, GUID>()
|
||||
const fontDigestMap = await buildFontDigestMap(graph)
|
||||
let internalCanvasGuid: GUID | null = null
|
||||
const glyphBlobMap = new Map<string, number>()
|
||||
const blobIndexByHex = new Map<string, number>()
|
||||
const paintVariableColorMap = buildFigmaPaintVariableColorMap(graph)
|
||||
|
||||
assignVariableGuids(graph, localIdCounter, varIdToGuid, modeIdToGuid)
|
||||
|
||||
for (let p = 0; p < pages.length; p++) {
|
||||
const page = pages[p]
|
||||
const canvasLocalID = localIdCounter.value++
|
||||
const canvasGuid = { sessionID: 0, localID: canvasLocalID }
|
||||
const { canvasEntries, internalCanvasGuid } = buildCanvasEntries(
|
||||
graph,
|
||||
pages,
|
||||
docGuid,
|
||||
localIdCounter,
|
||||
nodeIdToGuid
|
||||
)
|
||||
|
||||
if (page.internalOnly) internalCanvasGuid = canvasGuid
|
||||
|
||||
const canvasNc = makeCanvasNodeChange(canvasGuid, docGuid, fractionalPosition(p), page.name, {
|
||||
backgroundOpacity: 1,
|
||||
backgroundColor: { ...CANVAS_BG_COLOR },
|
||||
backgroundEnabled: true
|
||||
})
|
||||
if (page.internalOnly) canvasNc.internalOnly = true
|
||||
nodeChanges.push(canvasNc)
|
||||
for (const entry of canvasEntries) nodeChanges.push(entry.canvasNc)
|
||||
|
||||
const orderedCanvasEntries = [
|
||||
...canvasEntries.filter((entry) => entry.page.internalOnly),
|
||||
...canvasEntries.filter((entry) => !entry.page.internalOnly)
|
||||
]
|
||||
for (const { page, canvasGuid } of orderedCanvasEntries) {
|
||||
const children = graph.getChildren(page.id).filter((child) => !child.internalOnly)
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
nodeChanges.push(
|
||||
|
|
@ -223,27 +316,16 @@ export async function exportFigFile(
|
|||
blobs,
|
||||
nodeIdToGuid,
|
||||
fontDigestMap,
|
||||
varIdToGuid
|
||||
varIdToGuid,
|
||||
glyphBlobMap,
|
||||
paintVariableColorMap,
|
||||
blobIndexByHex
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (graph.variableCollections.size > 0) {
|
||||
if (!internalCanvasGuid) {
|
||||
const internalLocalID = localIdCounter.value++
|
||||
internalCanvasGuid = { sessionID: 0, localID: internalLocalID }
|
||||
nodeChanges.push(
|
||||
makeCanvasNodeChange(
|
||||
internalCanvasGuid,
|
||||
docGuid,
|
||||
fractionalPosition(pages.length),
|
||||
'Internal Only Canvas',
|
||||
{ internalOnly: true }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (graph.variableCollections.size > 0 && internalCanvasGuid) {
|
||||
appendVariableNodeChanges(graph, nodeChanges, internalCanvasGuid, varIdToGuid, modeIdToGuid)
|
||||
}
|
||||
|
||||
|
|
@ -261,10 +343,13 @@ export async function exportFigFile(
|
|||
const kiwiData = compiled.encodeMessage(msg)
|
||||
|
||||
const currentPageId = pageId ?? pages[0]?.id
|
||||
const thumbnailPng =
|
||||
(ck && renderer && currentPageId
|
||||
? renderThumbnail(ck, renderer, graph, currentPageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
||||
: null) ?? THUMBNAIL_1X1
|
||||
const thumbnailPng = await renderFigThumbnail(
|
||||
graph,
|
||||
currentPageId,
|
||||
ck,
|
||||
renderer,
|
||||
renderHeadlessThumbnail
|
||||
)
|
||||
|
||||
const metaJson = JSON.stringify({
|
||||
version: 1,
|
||||
|
|
@ -304,7 +389,8 @@ function compressViaWorker(
|
|||
kiwiData: Uint8Array,
|
||||
thumbnailPng: Uint8Array,
|
||||
metaJson: string,
|
||||
imageEntries: Array<{ name: string; data: Uint8Array }>
|
||||
imageEntries: Array<{ name: string; data: Uint8Array }>,
|
||||
figKiwiVersion?: number
|
||||
): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(new URL('./export-worker.ts', import.meta.url), {
|
||||
|
|
@ -324,7 +410,14 @@ function compressViaWorker(
|
|||
// internal buffer, so transferring kiwiData.buffer or schemaDeflated.buffer detaches
|
||||
// buffers that may be shared with other views, causing "already detached" errors on
|
||||
// subsequent saves. Structured clone (the default) copies the data safely.
|
||||
worker.postMessage({ schemaDeflated, kiwiData, thumbnailPng, metaJson, images: imageEntries })
|
||||
worker.postMessage({
|
||||
schemaDeflated,
|
||||
kiwiData,
|
||||
thumbnailPng,
|
||||
metaJson,
|
||||
images: imageEntries,
|
||||
figKiwiVersion
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -337,7 +430,14 @@ export function compressFigData(
|
|||
figKiwiVersion?: number
|
||||
): Promise<Uint8Array> {
|
||||
if (canUseWorker()) {
|
||||
return compressViaWorker(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries)
|
||||
return compressViaWorker(
|
||||
schemaDeflated,
|
||||
kiwiData,
|
||||
thumbnailPng,
|
||||
metaJson,
|
||||
imageEntries,
|
||||
figKiwiVersion
|
||||
)
|
||||
}
|
||||
return Promise.resolve(
|
||||
compressFigDataSync(
|
||||
|
|
|
|||
|
|
@ -65,13 +65,5 @@ export async function headlessRenderThumbnail(
|
|||
): Promise<Uint8Array | null> {
|
||||
const { ck, renderer } = await getRenderer()
|
||||
renderer.invalidateAllPictures()
|
||||
const page = graph.getNode(pageId)
|
||||
const restoreTextMeasurer = page
|
||||
? await renderer.prepareForExport(graph, pageId, page.childIds)
|
||||
: () => undefined
|
||||
try {
|
||||
return renderThumbnail(ck, renderer, graph, pageId, width, height)
|
||||
} finally {
|
||||
restoreTextMeasurer()
|
||||
}
|
||||
return renderThumbnail(ck, renderer, graph, pageId, width, height)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,9 @@ export interface ExtractedGraph {
|
|||
|
||||
function cloneIntoGraph(source: SceneGraph, ids: Set<string>): SceneGraph {
|
||||
const graph = new SceneGraph()
|
||||
const root = graph.getNode(graph.rootId)
|
||||
if (root) {
|
||||
root.childIds = []
|
||||
root.width = 0
|
||||
root.height = 0
|
||||
}
|
||||
graph.rootId = source.rootId
|
||||
graph.nodes = new Map()
|
||||
if (root) graph.nodes.set(root.id, root)
|
||||
graph.images = new Map(source.images)
|
||||
graph.images = new Map()
|
||||
graph.variables = new Map()
|
||||
graph.variableCollections = new Map()
|
||||
graph.activeMode = new Map(source.activeMode)
|
||||
|
|
@ -57,11 +51,11 @@ function cloneIntoGraph(source: SceneGraph, ids: Set<string>): SceneGraph {
|
|||
node.childIds = node.childIds.filter((childId) => ids.has(childId))
|
||||
}
|
||||
|
||||
const variableIds = new Set<string>()
|
||||
for (const node of graph.nodes.values()) {
|
||||
for (const variableId of Object.values(node.boundVariables)) {
|
||||
collectVariableClosure(source, variableId, variableIds)
|
||||
}
|
||||
const { imageHashes, variableIds } = collectReferencedResources(source, graph)
|
||||
|
||||
for (const imageHash of imageHashes) {
|
||||
const image = source.images.get(imageHash)
|
||||
if (image) graph.images.set(imageHash, image)
|
||||
}
|
||||
|
||||
for (const variableId of variableIds) {
|
||||
|
|
@ -84,6 +78,20 @@ function cloneIntoGraph(source: SceneGraph, ids: Set<string>): SceneGraph {
|
|||
return graph
|
||||
}
|
||||
|
||||
function collectReferencedResources(source: SceneGraph, graph: SceneGraph) {
|
||||
const imageHashes = new Set<string>()
|
||||
const variableIds = new Set<string>()
|
||||
for (const node of graph.nodes.values()) {
|
||||
for (const fill of node.fills) {
|
||||
if (fill.type === 'IMAGE' && fill.imageHash) imageHashes.add(fill.imageHash)
|
||||
}
|
||||
for (const variableId of Object.values(node.boundVariables)) {
|
||||
collectVariableClosure(source, variableId, variableIds)
|
||||
}
|
||||
}
|
||||
return { imageHashes, variableIds }
|
||||
}
|
||||
|
||||
function collectVariableClosure(source: SceneGraph, variableId: string, out: Set<string>) {
|
||||
if (out.has(variableId)) return
|
||||
const variable = source.variables.get(variableId)
|
||||
|
|
@ -116,6 +124,43 @@ function collectDescendants(source: SceneGraph, id: string, out: Set<string>) {
|
|||
}
|
||||
}
|
||||
|
||||
function collectAncestors(source: SceneGraph, id: string, out: Set<string>) {
|
||||
let current = source.getNode(id)
|
||||
while (current?.parentId) {
|
||||
out.add(current.parentId)
|
||||
current = source.getNode(current.parentId)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstanceComponentId(source: SceneGraph, componentId: string): string {
|
||||
const seen = new Set<string>()
|
||||
let currentId = componentId
|
||||
while (!seen.has(currentId)) {
|
||||
seen.add(currentId)
|
||||
const node = source.getNode(currentId)
|
||||
if (node?.type !== 'INSTANCE' || !node.componentId) return currentId
|
||||
currentId = node.componentId
|
||||
}
|
||||
return componentId
|
||||
}
|
||||
|
||||
function collectComponentDependencies(source: SceneGraph, ids: Set<string>) {
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (const id of Array.from(ids)) {
|
||||
const node = source.getNode(id)
|
||||
if (node?.type !== 'INSTANCE' || !node.componentId) continue
|
||||
const componentId = resolveInstanceComponentId(source, node.componentId)
|
||||
if (ids.has(componentId)) continue
|
||||
const before = ids.size
|
||||
collectAncestors(source, componentId, ids)
|
||||
collectDescendants(source, componentId, ids)
|
||||
changed ||= ids.size !== before
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findPageId(source: SceneGraph, nodeId: string): string | null {
|
||||
let current = source.getNode(nodeId)
|
||||
while (current?.parentId) {
|
||||
|
|
@ -156,12 +201,14 @@ function collectSelectionIds(source: SceneGraph, nodeIds: string[]): Set<string>
|
|||
ids.add(pageId)
|
||||
}
|
||||
|
||||
collectComponentDependencies(source, ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
function pageNodeIds(source: SceneGraph, pageId: string): Set<string> {
|
||||
const ids = new Set<string>([source.rootId, pageId])
|
||||
const ids = new Set<string>([source.rootId])
|
||||
collectDescendants(source, pageId, ids)
|
||||
collectComponentDependencies(source, ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export interface IOContext {
|
|||
|
||||
export interface FigWriteOptions {
|
||||
thumbnailPageId?: string
|
||||
renderThumbnail?: boolean
|
||||
}
|
||||
|
||||
export interface RasterExportOptions {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
import { decompress as zstdDecompress } from 'fzstd'
|
||||
|
||||
import { parseColor } from '#core/color'
|
||||
import { compileSchema, encodeBinarySchema } from '#core/kiwi/kiwi-schema'
|
||||
import { compileSchema, encodeBinarySchema } from '#core/kiwi/schema-runtime'
|
||||
|
||||
import { isZstdCompressed, getKiwiMessageType } from './protocol'
|
||||
import figmaSchema from './schema'
|
||||
|
|
@ -227,6 +227,7 @@ export interface VariableAnyValue {
|
|||
floatValue?: number
|
||||
colorValue?: Color
|
||||
alias?: { guid?: GUID; assetRef?: { key: string; version?: string } }
|
||||
symbolIdValue?: { guid?: GUID }
|
||||
}
|
||||
|
||||
export interface VariableDataEntry {
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
/* eslint-disable max-lines -- generated kiwi schema definition */
|
||||
import { parseSchema } from '#core/kiwi/kiwi-schema'
|
||||
|
||||
const schemaText = `
|
||||
package Fig;
|
||||
|
||||
enum MessageType {
|
||||
|
|
@ -846,7 +842,8 @@ message Paint {
|
|||
Video video = 18;
|
||||
uint originalImageWidth = 19;
|
||||
uint originalImageHeight = 20;
|
||||
PaintVariableBinding variableBinding = 21; // Not in .fig files. Discovered via WS sniffing 2026-01
|
||||
VariableData colorVar = 21;
|
||||
VariableData opacityVar = 38;
|
||||
}
|
||||
|
||||
message FontMetaData {
|
||||
|
|
@ -904,6 +901,7 @@ message Glyph {
|
|||
float fontSize = 4;
|
||||
uint firstCharacter = 5;
|
||||
float advance = 6;
|
||||
float rotation = 7;
|
||||
}
|
||||
|
||||
message Decoration {
|
||||
|
|
@ -1198,9 +1196,23 @@ message WidgetPointer {
|
|||
GUID nodeId = 1;
|
||||
}
|
||||
|
||||
message EditInfo {
|
||||
string timestampIso8601 = 1;
|
||||
string userId = 2;
|
||||
uint lastEditedAt = 3;
|
||||
uint createdAt = 4;
|
||||
}
|
||||
|
||||
enum EditorType {
|
||||
DESIGN = 0;
|
||||
WHITEBOARD = 1;
|
||||
SLIDES = 2;
|
||||
DEV_HANDOFF = 3;
|
||||
SITES = 4;
|
||||
COOPER = 5;
|
||||
ILLUSTRATION = 6;
|
||||
FIGMAKE = 7;
|
||||
FIGSPEC = 8;
|
||||
}
|
||||
|
||||
message NodeChange {
|
||||
|
|
@ -1369,6 +1381,11 @@ message NodeChange {
|
|||
GUID inheritEffectStyleID = 169;
|
||||
GUID inheritGridStyleID = 170;
|
||||
GUID inheritFillStyleIDForStroke = 185;
|
||||
StyleId styleIdForFill = 332;
|
||||
StyleId styleIdForStrokeFill = 333;
|
||||
StyleId styleIdForText = 334;
|
||||
StyleId styleIdForEffect = 335;
|
||||
StyleId styleIdForGrid = 336;
|
||||
bool isFillStyle = 157 [deprecated];
|
||||
bool isStrokeStyle = 161 [deprecated];
|
||||
StyleType styleType = 163;
|
||||
|
|
@ -1515,10 +1532,15 @@ message NodeChange {
|
|||
VariableSetID variableSetID = 313;
|
||||
VariableResolvedDataType variableResolvedType = 314;
|
||||
VariableDataValues variableDataValues = 315;
|
||||
EditInfo editInfo = 331;
|
||||
VariableScope[] variableScopes = 353;
|
||||
DerivedTextData derivedTextData = 359;
|
||||
EmojiImageSet emojiImageSet = 391;
|
||||
string sourceLibraryKey = 395;
|
||||
uint textExplicitLayoutVersion = 396;
|
||||
EditorType pageType = 397;
|
||||
string userFacingVersion = 399;
|
||||
VariableDataMap parameterConsumptionMap = 445;
|
||||
Paint[] textDecorationFillPaints = 411;
|
||||
bool textDecorationSkipInk = 412;
|
||||
Number textUnderlineOffset = 413;
|
||||
|
|
@ -1529,6 +1551,7 @@ message NodeChange {
|
|||
float gridColumnGap = 438;
|
||||
GridTrackSize[] gridColumnSizes = 474;
|
||||
GridTrackSize[] gridRowSizes = 475;
|
||||
VariantPropSpec[] variantPropSpecs = 483;
|
||||
StackWrap stackWrap = 476;
|
||||
float stackCounterSpacing = 477;
|
||||
}
|
||||
|
|
@ -1570,16 +1593,24 @@ message ComponentPropRef {
|
|||
bool isDeleted = 5;
|
||||
}
|
||||
|
||||
message VariantPropSpec {
|
||||
GUID propDefId = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
enum ComponentPropNodeField {
|
||||
VISIBLE = 0;
|
||||
TEXT_DATA = 1;
|
||||
OVERRIDDEN_SYMBOL_ID = 2;
|
||||
INHERIT_FILL_STYLE_ID = 3;
|
||||
SLOT_CONTENT_ID = 4;
|
||||
}
|
||||
|
||||
message ComponentPropAssignment {
|
||||
GUID defID = 1;
|
||||
ComponentPropValue value = 2;
|
||||
VariableData varValue = 3;
|
||||
DerivedTextData legacyDerivedTextData = 4;
|
||||
}
|
||||
|
||||
message ComponentPropDef {
|
||||
|
|
@ -1591,12 +1622,15 @@ message ComponentPropDef {
|
|||
ComponentPropType type = 6;
|
||||
bool isDeleted = 7;
|
||||
ComponentPropPreferredValues preferredValues = 8;
|
||||
VariableData varValue = 9;
|
||||
string description = 11;
|
||||
}
|
||||
|
||||
message ComponentPropValue {
|
||||
bool boolValue = 1;
|
||||
TextData textValue = 2;
|
||||
GUID guidValue = 3;
|
||||
float floatValue = 4;
|
||||
}
|
||||
|
||||
enum ComponentPropType {
|
||||
|
|
@ -1604,10 +1638,34 @@ enum ComponentPropType {
|
|||
TEXT = 1;
|
||||
COLOR = 2;
|
||||
INSTANCE_SWAP = 3;
|
||||
VARIANT = 4;
|
||||
NUMBER = 5;
|
||||
IMAGE = 6;
|
||||
SLOT = 7;
|
||||
EASING = 8;
|
||||
COLOR_ARRAY = 9;
|
||||
VECTOR = 10;
|
||||
LINE = 11;
|
||||
CIRCLE = 12;
|
||||
ROTATION_3D = 13;
|
||||
CIRCLE_POINT = 14;
|
||||
GRADIENT = 15;
|
||||
COLOR_POINT = 16;
|
||||
}
|
||||
|
||||
enum InstanceSwapPreferredValueType {
|
||||
COMPONENT = 0;
|
||||
STATE_GROUP = 1;
|
||||
}
|
||||
|
||||
message InstanceSwapPreferredValue {
|
||||
InstanceSwapPreferredValueType type = 1;
|
||||
string key = 2;
|
||||
}
|
||||
|
||||
message ComponentPropPreferredValues {
|
||||
string[] stringValues = 1;
|
||||
InstanceSwapPreferredValue[] instanceSwapValues = 2;
|
||||
}
|
||||
|
||||
enum WidgetEvent {
|
||||
|
|
@ -1698,6 +1756,12 @@ message TextLineData {
|
|||
int indentationLevel = 2;
|
||||
Directionality directionality = 3;
|
||||
DirectionalityIntent directionalityIntent = 4;
|
||||
int downgradeStyleId = 5;
|
||||
int consistencyStyleId = 6;
|
||||
int listStartOffset = 7;
|
||||
bool isFirstLineOfList = 8;
|
||||
SourceDirectionality sourceDirectionality = 9;
|
||||
int styleId = 10;
|
||||
}
|
||||
|
||||
enum BulletType {
|
||||
|
|
@ -1718,6 +1782,12 @@ enum Directionality {
|
|||
RTL = 1;
|
||||
}
|
||||
|
||||
enum SourceDirectionality {
|
||||
AUTO = 0;
|
||||
LTR = 1;
|
||||
RTL = 2;
|
||||
}
|
||||
|
||||
enum DirectionalityIntent {
|
||||
IMPLICIT = 0;
|
||||
EXPLICIT = 1;
|
||||
|
|
@ -2044,6 +2114,9 @@ enum VariableDataType {
|
|||
STRING = 2;
|
||||
ALIAS = 3;
|
||||
COLOR = 4;
|
||||
SYMBOL_ID = 7;
|
||||
TEXT_DATA = 9;
|
||||
PROP_REF = 13;
|
||||
}
|
||||
|
||||
enum VariableResolvedDataType {
|
||||
|
|
@ -2051,10 +2124,31 @@ enum VariableResolvedDataType {
|
|||
FLOAT = 1;
|
||||
STRING = 2;
|
||||
COLOR = 4;
|
||||
SYMBOL_ID = 6;
|
||||
TEXT_DATA = 8;
|
||||
}
|
||||
|
||||
message AssetRef {
|
||||
string key = 1;
|
||||
string version = 2;
|
||||
}
|
||||
|
||||
message StyleId {
|
||||
GUID guid = 1;
|
||||
AssetRef assetRef = 2;
|
||||
}
|
||||
|
||||
message VariableID {
|
||||
GUID guid = 1;
|
||||
AssetRef assetRef = 2;
|
||||
}
|
||||
|
||||
message SymbolId {
|
||||
GUID guid = 1;
|
||||
}
|
||||
|
||||
message PropRefValue {
|
||||
GUID defId = 1;
|
||||
}
|
||||
|
||||
message VariableSetID {
|
||||
|
|
@ -2067,6 +2161,8 @@ message VariableAnyValue {
|
|||
float floatValue = 3;
|
||||
VariableID alias = 4;
|
||||
Color colorValue = 5;
|
||||
SymbolId symbolIdValue = 8;
|
||||
PropRefValue propRefValue = 13;
|
||||
}
|
||||
|
||||
message VariableData {
|
||||
|
|
@ -2114,6 +2210,8 @@ enum VariableScope {
|
|||
enum VariableField {
|
||||
MISSING = 0;
|
||||
CORNER_RADIUS = 1;
|
||||
PARAGRAPH_SPACING = 2;
|
||||
PARAGRAPH_INDENT = 3;
|
||||
STROKE_WEIGHT = 4;
|
||||
STACK_SPACING = 5;
|
||||
STACK_PADDING_LEFT = 6;
|
||||
|
|
@ -2121,17 +2219,61 @@ enum VariableField {
|
|||
STACK_PADDING_RIGHT = 8;
|
||||
STACK_PADDING_BOTTOM = 9;
|
||||
VISIBLE = 10;
|
||||
TEXT_DATA = 11;
|
||||
WIDTH = 12;
|
||||
HEIGHT = 13;
|
||||
RECTANGLE_TOP_LEFT_CORNER_RADIUS = 14;
|
||||
RECTANGLE_TOP_RIGHT_CORNER_RADIUS = 15;
|
||||
RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS = 16;
|
||||
RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS = 17;
|
||||
BORDER_TOP_WEIGHT = 18;
|
||||
BORDER_BOTTOM_WEIGHT = 19;
|
||||
BORDER_LEFT_WEIGHT = 20;
|
||||
BORDER_RIGHT_WEIGHT = 21;
|
||||
VARIANT_PROPERTIES = 22;
|
||||
STACK_COUNTER_SPACING = 23;
|
||||
MIN_WIDTH = 24;
|
||||
MAX_WIDTH = 25;
|
||||
MIN_HEIGHT = 26;
|
||||
MAX_HEIGHT = 27;
|
||||
FONT_FAMILY = 28;
|
||||
FONT_STYLE = 29;
|
||||
FONT_VARIATIONS = 30;
|
||||
OPACITY = 31;
|
||||
FONT_SIZE = 32;
|
||||
LETTER_SPACING = 34;
|
||||
LINE_HEIGHT = 36;
|
||||
STACK_COUNTER_SPACING = 23;
|
||||
OVERRIDDEN_SYMBOL_ID = 37;
|
||||
HYPERLINK = 38;
|
||||
CMS_SERIALIZED_RICH_TEXT_DATA = 39;
|
||||
SLOT_CONTENT_ID = 40;
|
||||
GRID_ROW_GAP = 41;
|
||||
GRID_COLUMN_GAP = 42;
|
||||
X_POSITION = 43;
|
||||
Y_POSITION = 44;
|
||||
ROTATION = 45;
|
||||
MOTION_TRANSLATION_X = 46;
|
||||
MOTION_TRANSLATION_Y = 47;
|
||||
MOTION_ROTATION = 48;
|
||||
MOTION_SCALE_X = 49;
|
||||
MOTION_SCALE_Y = 50;
|
||||
MOTION_SHEAR = 51;
|
||||
SCROLL_OFFSET_X = 52;
|
||||
SCROLL_OFFSET_Y = 53;
|
||||
PATH_TRIM_START = 54;
|
||||
PATH_TRIM_END = 55;
|
||||
DISSOLVE_PROGRESS = 56;
|
||||
EASING_DATA = 57;
|
||||
MEDIA_CURRENT_TIME = 58;
|
||||
TRANSFORM_3D_PERSPECTIVE = 59;
|
||||
TRANSFORM_3D_TRANSLATION_Z = 60;
|
||||
TRANSFORM_3D_ROTATION_X = 61;
|
||||
TRANSFORM_3D_ROTATION_Y = 62;
|
||||
TRANSFORM_3D_ROTATION_Z = 63;
|
||||
POLYGON_COUNT = 64;
|
||||
ARC_DATA_STARTING_ANGLE = 65;
|
||||
ARC_DATA_ENDING_ANGLE = 66;
|
||||
ARC_DATA_INNER_RADIUS = 67;
|
||||
}
|
||||
|
||||
message VariableDataMapEntry {
|
||||
|
|
@ -2307,6 +2449,3 @@ enum ARIARole {
|
|||
ALERTDIALOG = 80;
|
||||
DIALOG = 81;
|
||||
}
|
||||
`
|
||||
|
||||
export default parseSchema(schemaText)
|
||||
8
packages/core/src/kiwi/fig/codec/schema/index.ts
Normal file
8
packages/core/src/kiwi/fig/codec/schema/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { parseSchema, validateSchema } from '#core/kiwi/schema-runtime'
|
||||
|
||||
import schemaText from './fig.kiwi?raw'
|
||||
|
||||
const schema = parseSchema(schemaText)
|
||||
validateSchema(schema)
|
||||
|
||||
export default schema
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { hexToBytes } from '#core/bytes/hex'
|
||||
import type { GUID } from '#core/types'
|
||||
|
||||
import type { NodeChange, Paint } from './codec'
|
||||
import type { NodeChange, Paint } from './index'
|
||||
|
||||
export interface VariableBindingCodec {
|
||||
encodePaint(paint: Paint): Uint8Array
|
||||
|
|
@ -2,23 +2,40 @@ import { isNotNil } from 'es-toolkit/predicate'
|
|||
|
||||
import { BLACK } from '#core/constants'
|
||||
import { setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
|
||||
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from '#core/kiwi/binary/codec'
|
||||
import { populateAndApplyOverrides } from '#core/kiwi/instance-overrides'
|
||||
import type { InstanceNodeChange } from '#core/kiwi/instance-overrides'
|
||||
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from '#core/kiwi/fig/codec'
|
||||
import { populateAndApplyOverrides } from '#core/kiwi/fig/instance-overrides'
|
||||
import type { InstanceNodeChange } from '#core/kiwi/fig/instance-overrides'
|
||||
import {
|
||||
guidToString,
|
||||
nodeChangeToProps,
|
||||
sortChildren,
|
||||
setVariableColorResolver,
|
||||
VARIABLE_BINDING_FIELDS_INVERSE
|
||||
} from '#core/kiwi/node-change/convert'
|
||||
import { applyStyleRefsToFields } from '#core/kiwi/node-change/style-refs'
|
||||
} from '#core/kiwi/fig/node-change/convert'
|
||||
import { applyStyleRefsToFields } from '#core/kiwi/fig/node-change/style-refs'
|
||||
import { SceneGraph } from '#core/scene-graph'
|
||||
import type { VariableType, VariableValue } from '#core/scene-graph'
|
||||
|
||||
type AssetRef = { key: string; version?: string }
|
||||
type AliasRef = { guid?: GUID; assetRef?: AssetRef }
|
||||
|
||||
function applyImportedCanvasMetadata(page: ReturnType<SceneGraph['addPage']>, canvasNc: NodeChange) {
|
||||
page.source.format = 'fig'
|
||||
page.source.orderKey = canvasNc.parentIndex?.position ?? null
|
||||
if (canvasNc.backgroundColor) page.source.fig.rawNodeFields.backgroundColor = structuredClone(canvasNc.backgroundColor)
|
||||
page.source.fig.rawNodeFields.strokeJoin = canvasNc.strokeJoin
|
||||
page.source.fig.rawNodeFields.strokeWeight = canvasNc.strokeWeight
|
||||
if (canvasNc.pageType) page.source.fig.rawNodeFields.pageType = canvasNc.pageType
|
||||
}
|
||||
|
||||
function applyImportedDocumentMetadata(graph: SceneGraph, docNc: NodeChange | undefined) {
|
||||
const rootNode = graph.getNode(graph.rootId)
|
||||
if (!docNc || !rootNode) return
|
||||
rootNode.source.format = 'fig'
|
||||
rootNode.source.fig.rawNodeFields.strokeJoin = docNc.strokeJoin
|
||||
rootNode.source.fig.rawNodeFields.strokeWeight = docNc.strokeWeight
|
||||
}
|
||||
|
||||
function assetRefKey(assetRef: AssetRef): string {
|
||||
return assetRef.version ? `${assetRef.key}@${assetRef.version}` : assetRef.key
|
||||
}
|
||||
|
|
@ -281,11 +298,15 @@ function importPages(
|
|||
}
|
||||
|
||||
if (docId) {
|
||||
applyImportedDocumentMetadata(graph, changeMap.get(docId))
|
||||
|
||||
for (const canvasId of childrenMap.get(docId) ?? []) {
|
||||
const canvasNc = changeMap.get(canvasId)
|
||||
if (!canvasNc) continue
|
||||
if (canvasNc.type === 'CANVAS') {
|
||||
const page = graph.addPage(canvasNc.name ?? 'Page')
|
||||
page.source.id = canvasId
|
||||
applyImportedCanvasMetadata(page, canvasNc)
|
||||
canvasIdToPageId.set(canvasId, page.id)
|
||||
if (canvasNc.internalOnly) page.internalOnly = true
|
||||
created.add(canvasId)
|
||||
|
|
@ -445,13 +466,15 @@ export function importNodeChanges(
|
|||
? [firstPageId, ...componentPageIds].filter(isNotNil)
|
||||
: undefined
|
||||
|
||||
populateAndApplyOverrides(
|
||||
graph,
|
||||
changeMap as Map<string, InstanceNodeChange>,
|
||||
guidToNodeId,
|
||||
blobs,
|
||||
activeRootIds
|
||||
)
|
||||
graph.preserveSourceMetadataDuring(() => {
|
||||
populateAndApplyOverrides(
|
||||
graph,
|
||||
changeMap as Map<string, InstanceNodeChange>,
|
||||
guidToNodeId,
|
||||
blobs,
|
||||
activeRootIds
|
||||
)
|
||||
})
|
||||
|
||||
if (activeRootIds) rememberLazyFigImportContext(graph, changeMap, guidToNodeId, blobs, activeRootIds)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { applyOverridePatch, type OverridePatch } from '#core/kiwi/instance-overrides/patches'
|
||||
import { getComponentRoot } from '#core/kiwi/instance-overrides/resolve'
|
||||
import { applyOverridePatch, type OverridePatch } from '#core/kiwi/fig/instance-overrides/patches'
|
||||
import { getComponentRoot } from '#core/kiwi/fig/instance-overrides/resolve'
|
||||
import type {
|
||||
ComponentPropRef,
|
||||
ComponentPropValue,
|
||||
OverrideContext
|
||||
} from '#core/kiwi/instance-overrides/types'
|
||||
import { guidToString } from '#core/kiwi/node-change/convert'
|
||||
} from '#core/kiwi/fig/instance-overrides/types'
|
||||
import { guidToString } from '#core/kiwi/fig/node-change/convert'
|
||||
import { copyFills, copyStyleRuns } from '#core/scene-graph/copy'
|
||||
|
||||
import { propTextCharacters } from './values'
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
import { buildCloneIndex, instanceAndClones } from '#core/kiwi/instance-overrides/clone-index'
|
||||
import { applyComponentPropRef } from '#core/kiwi/instance-overrides/component-props/apply'
|
||||
import { buildCloneIndex, instanceAndClones } from '#core/kiwi/fig/instance-overrides/clone-index'
|
||||
import { applyComponentPropRef } from '#core/kiwi/fig/instance-overrides/component-props/apply'
|
||||
import {
|
||||
fallbackRefsForChild,
|
||||
findPropRefs,
|
||||
valueForRef
|
||||
} from '#core/kiwi/instance-overrides/component-props/refs'
|
||||
import { assignmentsToValueMap } from '#core/kiwi/instance-overrides/component-props/values'
|
||||
import { resolveOverrideTarget } from '#core/kiwi/instance-overrides/resolve'
|
||||
} from '#core/kiwi/fig/instance-overrides/component-props/refs'
|
||||
import { assignmentsToValueMap } from '#core/kiwi/fig/instance-overrides/component-props/values'
|
||||
import { resolveOverrideTarget } from '#core/kiwi/fig/instance-overrides/resolve'
|
||||
import type {
|
||||
ComponentPropAssignment,
|
||||
ComponentPropRef,
|
||||
ComponentPropValue,
|
||||
OverrideContext
|
||||
} from '#core/kiwi/instance-overrides/types'
|
||||
} from '#core/kiwi/fig/instance-overrides/types'
|
||||
import type { SceneNode } from '#core/scene-graph'
|
||||
|
||||
function applyChildPropRefs(
|
||||
|
|
@ -3,7 +3,7 @@ import {
|
|||
applyOverrideAssignments
|
||||
} from './assignments'
|
||||
import { collectAssignmentsMap, collectPropRefsMap } from './maps'
|
||||
import type { OverrideContext } from '#core/kiwi/instance-overrides/types'
|
||||
import type { OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
|
||||
|
||||
/**
|
||||
* Apply all component property assignments (visibility toggles, instance swaps).
|
||||
|
|
@ -2,7 +2,7 @@ import type {
|
|||
ComponentPropAssignment,
|
||||
ComponentPropRef,
|
||||
OverrideContext
|
||||
} from '#core/kiwi/instance-overrides/types'
|
||||
} from '#core/kiwi/fig/instance-overrides/types'
|
||||
|
||||
export function collectPropRefsMap(ctx: OverrideContext): Map<string, ComponentPropRef[]> {
|
||||
const result = new Map<string, ComponentPropRef[]>()
|
||||
|
|
@ -2,8 +2,8 @@ import type {
|
|||
ComponentPropRef,
|
||||
ComponentPropValue,
|
||||
OverrideContext
|
||||
} from '#core/kiwi/instance-overrides/types'
|
||||
import { guidToString } from '#core/kiwi/node-change/convert'
|
||||
} from '#core/kiwi/fig/instance-overrides/types'
|
||||
import { guidToString } from '#core/kiwi/fig/node-change/convert'
|
||||
|
||||
import { normalizePropName, stringToGuidParts } from './values'
|
||||
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import type { GUID } from '#core/kiwi/binary/codec'
|
||||
import { guidToString } from '#core/kiwi/node-change/convert'
|
||||
import type { GUID } from '#core/kiwi/fig/codec'
|
||||
import { guidToString } from '#core/kiwi/fig/node-change/convert'
|
||||
|
||||
import type {
|
||||
ComponentPropAssignment,
|
||||
ComponentPropValue,
|
||||
OverrideContext
|
||||
} from '#core/kiwi/instance-overrides/types'
|
||||
} from '#core/kiwi/fig/instance-overrides/types'
|
||||
|
||||
export function normalizePropName(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { resolveGeometryPaths } from '#core/kiwi/node-change/convert'
|
||||
import type { DerivedSymbolOverride } from '#core/kiwi/instance-overrides/types'
|
||||
import { resolveGeometryPaths } from '#core/kiwi/fig/node-change/convert'
|
||||
import type { DerivedSymbolOverride } from '#core/kiwi/fig/instance-overrides/types'
|
||||
import type { GeometryPath, SceneNode } from '#core/scene-graph'
|
||||
|
||||
function scaleGeometryBlobs(geom: GeometryPath[], sx: number, sy: number): GeometryPath[] {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { applyOverridePatch } from '#core/kiwi/instance-overrides/patches'
|
||||
import { resolveOverrideTarget } from '#core/kiwi/instance-overrides/resolve'
|
||||
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/instance-overrides/types'
|
||||
import { applyOverridePatch } from '#core/kiwi/fig/instance-overrides/patches'
|
||||
import { resolveOverrideTarget } from '#core/kiwi/fig/instance-overrides/resolve'
|
||||
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
|
||||
|
||||
import { buildDsdLayoutUpdates } from './layout'
|
||||
import { propagateDsdChanges } from './propagate'
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { convertLetterSpacing, convertLineHeight } from '#core/kiwi/node-change/convert'
|
||||
import { convertFigmaDerivedTextGlyphs } from '#core/kiwi/node-change/derived-text-glyphs'
|
||||
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/instance-overrides/types'
|
||||
import { convertLetterSpacing, convertLineHeight } from '#core/kiwi/fig/node-change/convert'
|
||||
import { convertFigmaDerivedTextGlyphs } from '#core/kiwi/fig/node-change/derived-text-glyphs'
|
||||
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
|
||||
import type { SceneNode } from '#core/scene-graph'
|
||||
|
||||
import { resolveDsdGeometry } from './geometry'
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { buildClonesMap } from '#core/kiwi/instance-overrides/sync'
|
||||
import type { OverrideContext } from '#core/kiwi/instance-overrides/types'
|
||||
import { buildClonesMap } from '#core/kiwi/fig/instance-overrides/sync'
|
||||
import type { OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
|
||||
import type { SceneNode } from '#core/scene-graph'
|
||||
import { copyGeometryPaths } from '#core/scene-graph/copy'
|
||||
|
||||
|
|
@ -54,7 +54,9 @@ export function propagateDsdChanges(
|
|||
const clone = ctx.graph.getNode(cloneId)
|
||||
if (!clone) continue
|
||||
const updates = buildCloneUpdates(ctx, source, clone, cloneId, sizeSet)
|
||||
if (Object.keys(updates).length > 0) ctx.graph.updateNode(cloneId, updates)
|
||||
if (Object.keys(updates).length > 0) {
|
||||
ctx.graph.preserveSourceMetadataDuring(() => ctx.graph.updateNode(cloneId, updates))
|
||||
}
|
||||
queue.push(cloneId)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ export type {
|
|||
SymbolOverride
|
||||
} from './types'
|
||||
|
||||
import { guidToString } from '#core/kiwi/node-change/convert'
|
||||
import { guidToString } from '#core/kiwi/fig/node-change/convert'
|
||||
import type { SceneGraph, SceneNode } from '#core/scene-graph'
|
||||
import { copyFills, copyStyleRuns } from '#core/scene-graph/copy'
|
||||
|
||||
|
|
@ -273,4 +273,5 @@ export function populateAndApplyOverrides(
|
|||
)
|
||||
propagateResolvedTextClones(graph)
|
||||
applyConstraintScaling(ctx)
|
||||
applyComponentProperties(ctx)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { repopulateInstance } from '#core/kiwi/instance-overrides/resolve'
|
||||
import type { OverrideContext } from '#core/kiwi/instance-overrides/types'
|
||||
import { repopulateInstance } from '#core/kiwi/fig/instance-overrides/resolve'
|
||||
import type { OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
|
||||
import type { SceneNode } from '#core/scene-graph'
|
||||
|
||||
import { protectField, protectPatchProps } from './protection'
|
||||
|
|
@ -37,9 +37,10 @@ export function applyOverridePatch(ctx: OverrideContext, patch: OverridePatch):
|
|||
if (patch.props && Object.keys(patch.props).length > 0) {
|
||||
const target = ctx.graph.getNode(patch.targetId)
|
||||
if (target) {
|
||||
preserveStrokeShapeProps(target, patch.props)
|
||||
ctx.graph.updateNode(patch.targetId, patch.props)
|
||||
protectPatchProps(ctx.protectedFields, patch.targetId, patch.props)
|
||||
const props = patch.props
|
||||
preserveStrokeShapeProps(target, props)
|
||||
ctx.graph.preserveSourceMetadataDuring(() => ctx.graph.updateNode(patch.targetId, props))
|
||||
protectPatchProps(ctx.protectedFields, patch.targetId, props)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { GUID } from '#core/kiwi/binary/codec'
|
||||
import { guidToString } from '#core/kiwi/node-change/convert'
|
||||
import type { GUID } from '#core/kiwi/fig/codec'
|
||||
import { guidToString } from '#core/kiwi/fig/node-change/convert'
|
||||
import type { SceneNode } from '#core/scene-graph'
|
||||
import { copyStrokes } from '#core/scene-graph/copy'
|
||||
|
||||
|
|
@ -119,26 +119,26 @@ function getSiblingGroups(ctx: OverrideContext): Map<string, string[]> {
|
|||
return groups
|
||||
}
|
||||
|
||||
function sourceSiblingIndex(ctx: OverrideContext, figmaGuid: string): number | null {
|
||||
function sourceSiblingIndex(ctx: OverrideContext, sourceId: string): number | null {
|
||||
let cache = siblingIndexCache.get(ctx)
|
||||
if (!cache) {
|
||||
cache = new Map()
|
||||
siblingIndexCache.set(ctx, cache)
|
||||
}
|
||||
if (cache.has(figmaGuid)) return cache.get(figmaGuid) ?? null
|
||||
if (cache.has(sourceId)) return cache.get(sourceId) ?? null
|
||||
|
||||
const nc = ctx.changeMap.get(figmaGuid)
|
||||
const nc = ctx.changeMap.get(sourceId)
|
||||
const parentId = nc?.parentIndex?.guid ? guidToString(nc.parentIndex.guid) : null
|
||||
const symbolId = nc?.symbolData?.symbolID ? guidToString(nc.symbolData.symbolID) : null
|
||||
if (!nc || !parentId || !symbolId) {
|
||||
cache.set(figmaGuid, null)
|
||||
cache.set(sourceId, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const siblings = getSiblingGroups(ctx).get(`${parentId}\0${symbolId}`) ?? []
|
||||
const index = siblings.indexOf(figmaGuid)
|
||||
const index = siblings.indexOf(sourceId)
|
||||
const result = index !== -1 ? index : null
|
||||
cache.set(figmaGuid, result)
|
||||
cache.set(sourceId, result)
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -169,9 +169,9 @@ function findNodeBySourceSiblingIndex(
|
|||
ctx: OverrideContext,
|
||||
parentId: string,
|
||||
componentId: string,
|
||||
figmaGuid: string
|
||||
sourceId: string
|
||||
): string | null {
|
||||
const index = sourceSiblingIndex(ctx, figmaGuid)
|
||||
const index = sourceSiblingIndex(ctx, sourceId)
|
||||
if (index == null) return null
|
||||
|
||||
const targetRoot = ctx.preComputedRoot.get(componentId) ?? getComponentRoot(ctx, componentId)
|
||||
|
|
@ -265,13 +265,13 @@ export function findNodeByComponentId(
|
|||
/**
|
||||
* Resolve a guidPath to a target node within an instance subtree.
|
||||
*
|
||||
* Each GUID in the path identifies an overrideKey → figmaGuid → graph node.
|
||||
* Each GUID in the path identifies an overrideKey → source id → graph node.
|
||||
* The chain walks from the instance down to the target.
|
||||
*/
|
||||
function resolveOverrideStep(
|
||||
ctx: OverrideContext,
|
||||
currentId: string,
|
||||
figmaGuid: string,
|
||||
sourceId: string,
|
||||
remapped: string | undefined,
|
||||
targetNc: InstanceNodeChange | undefined
|
||||
): string | null {
|
||||
|
|
@ -282,7 +282,7 @@ function resolveOverrideStep(
|
|||
|
||||
return (
|
||||
findNodeByComponentId(ctx, currentId, remapped) ??
|
||||
findNodeBySourceSiblingIndex(ctx, currentId, remapped, figmaGuid) ??
|
||||
findNodeBySourceSiblingIndex(ctx, currentId, remapped, sourceId) ??
|
||||
findNodeByNameAndType(ctx, currentId, targetNc?.name, targetNc?.type)
|
||||
)
|
||||
}
|
||||
|
|
@ -295,14 +295,14 @@ export function resolveOverrideTarget(
|
|||
let currentId = instanceId
|
||||
for (let index = 0; index < guids.length; index++) {
|
||||
const key = guidToString(guids[index])
|
||||
const figmaGuid = ctx.overrideKeyToGuid.get(key) ?? key
|
||||
const targetNc = ctx.changeMap.get(figmaGuid)
|
||||
const sourceId = ctx.overrideKeyToGuid.get(key) ?? key
|
||||
const targetNc = ctx.changeMap.get(sourceId)
|
||||
const symbolGuid = targetNc?.symbolData?.symbolID
|
||||
? guidToString(targetNc.symbolData.symbolID)
|
||||
: null
|
||||
const remapped =
|
||||
ctx.guidToNodeId.get(figmaGuid) ?? (symbolGuid ? ctx.guidToNodeId.get(symbolGuid) : undefined)
|
||||
const resolved = resolveOverrideStep(ctx, currentId, figmaGuid, remapped, targetNc)
|
||||
ctx.guidToNodeId.get(sourceId) ?? (symbolGuid ? ctx.guidToNodeId.get(symbolGuid) : undefined)
|
||||
const resolved = resolveOverrideStep(ctx, currentId, sourceId, remapped, targetNc)
|
||||
if (resolved) {
|
||||
currentId = resolved
|
||||
continue
|
||||
|
|
@ -343,7 +343,11 @@ function applyStrokeDescendants(ctx: OverrideContext, nodeId: string, strokes: S
|
|||
const node = ctx.graph.getNode(id)
|
||||
if (!node) return
|
||||
if (node.strokes.length > 0) {
|
||||
if (index < strokes.length) ctx.graph.updateNode(id, { strokes: copyStrokes(strokes[index]) })
|
||||
if (index < strokes.length) {
|
||||
ctx.graph.preserveSourceMetadataDuring(() => {
|
||||
ctx.graph.updateNode(id, { strokes: copyStrokes(strokes[index]) })
|
||||
})
|
||||
}
|
||||
index++
|
||||
}
|
||||
for (const childId of node.childIds) visit(childId)
|
||||
|
|
@ -364,7 +368,7 @@ export function repopulateInstance(ctx: OverrideContext, nodeId: string, compId:
|
|||
if (comp?.name && rootComp?.name && node.name === rootComp.name) {
|
||||
updates.name = comp.name
|
||||
}
|
||||
ctx.graph.updateNode(nodeId, updates)
|
||||
ctx.graph.preserveSourceMetadataDuring(() => ctx.graph.updateNode(nodeId, updates))
|
||||
if (comp && comp.childIds.length > 0) {
|
||||
ctx.graph.populateInstanceChildren(nodeId, compId)
|
||||
applyStrokeDescendants(ctx, nodeId, previousStrokes)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { applyOverridePatch } from '#core/kiwi/instance-overrides/patches'
|
||||
import { resolveOverrideTarget } from '#core/kiwi/instance-overrides/resolve'
|
||||
import type { OverrideContext } from '#core/kiwi/instance-overrides/types'
|
||||
import { applyOverridePatch } from '#core/kiwi/fig/instance-overrides/patches'
|
||||
import { resolveOverrideTarget } from '#core/kiwi/fig/instance-overrides/resolve'
|
||||
import type { OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
|
||||
|
||||
import { patchFromSymbolOverride } from './patches'
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { OverridePatch } from '#core/kiwi/instance-overrides/patches'
|
||||
import type { OverrideContext, SymbolOverride } from '#core/kiwi/instance-overrides/types'
|
||||
import { guidToString } from '#core/kiwi/node-change/convert'
|
||||
import { applyStyleRefsToFields } from '#core/kiwi/node-change/style-refs'
|
||||
import type { OverridePatch } from '#core/kiwi/fig/instance-overrides/patches'
|
||||
import type { OverrideContext, SymbolOverride } from '#core/kiwi/fig/instance-overrides/types'
|
||||
import { guidToString } from '#core/kiwi/fig/node-change/convert'
|
||||
import { applyStyleRefsToFields } from '#core/kiwi/fig/node-change/style-refs'
|
||||
|
||||
import { convertOverrideToProps } from './props'
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NodeChange, Paint, Effect as KiwiEffect } from '#core/kiwi/binary/codec'
|
||||
import type { NodeChange, Paint, Effect as KiwiEffect } from '#core/kiwi/fig/codec'
|
||||
import {
|
||||
convertFills,
|
||||
mapStackSizing,
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
importStyleRuns,
|
||||
convertStrokes,
|
||||
convertEffects
|
||||
} from '#core/kiwi/node-change/convert'
|
||||
} from '#core/kiwi/fig/node-change/convert'
|
||||
import type { SceneNode, ArcData, TextAutoResize } from '#core/scene-graph'
|
||||
import { styleToWeight } from '#core/text/fonts'
|
||||
import type { Vector } from '#core/types'
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ProtectionMap } from '#core/kiwi/instance-overrides/patches'
|
||||
import type { ProtectionMap } from '#core/kiwi/fig/instance-overrides/patches'
|
||||
import type { SceneGraph, SceneNode } from '#core/scene-graph'
|
||||
|
||||
import { syncNodeProps } from './fields'
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ProtectionMap, ProtectedField } from '#core/kiwi/instance-overrides/patches'
|
||||
import { isFieldProtected } from '#core/kiwi/instance-overrides/patches'
|
||||
import type { ProtectionMap, ProtectedField } from '#core/kiwi/fig/instance-overrides/patches'
|
||||
import { isFieldProtected } from '#core/kiwi/fig/instance-overrides/patches'
|
||||
import type { SceneGraph, SceneNode } from '#core/scene-graph'
|
||||
import { copyFills, copyStrokes, copyEffects, copyStyleRuns } from '#core/scene-graph/copy'
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ProtectionMap } from '#core/kiwi/instance-overrides/patches'
|
||||
import type { ProtectionMap } from '#core/kiwi/fig/instance-overrides/patches'
|
||||
import type { SceneGraph } from '#core/scene-graph'
|
||||
|
||||
import { buildClonesMap, syncChildrenDeep } from './clones'
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { GUID, NodeChange } from '#core/kiwi/binary/codec'
|
||||
import type { GUID, NodeChange } from '#core/kiwi/fig/codec'
|
||||
import type { SceneGraph } from '#core/scene-graph'
|
||||
import type { Matrix, Vector } from '#core/types'
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { populateAndApplyOverrides } from '#core/kiwi/instance-overrides'
|
||||
import type { InstanceNodeChange } from '#core/kiwi/instance-overrides'
|
||||
import { populateAndApplyOverrides } from '#core/kiwi/fig/instance-overrides'
|
||||
import type { InstanceNodeChange } from '#core/kiwi/fig/instance-overrides'
|
||||
import type { SceneGraph } from '#core/scene-graph'
|
||||
|
||||
export interface LazyFigImportContext {
|
||||
|
|
@ -27,13 +27,15 @@ function populateRoots(
|
|||
const pending = [...rootIds].filter((id) => id && !context.populatedRootIds.has(id))
|
||||
if (pending.length === 0) return false
|
||||
|
||||
populateAndApplyOverrides(
|
||||
graph,
|
||||
context.changeMap,
|
||||
context.guidToNodeId,
|
||||
context.blobs,
|
||||
pending
|
||||
)
|
||||
graph.preserveSourceMetadataDuring(() => {
|
||||
populateAndApplyOverrides(
|
||||
graph,
|
||||
context.changeMap,
|
||||
context.guidToNodeId,
|
||||
context.blobs,
|
||||
pending
|
||||
)
|
||||
})
|
||||
|
||||
for (const id of pending) context.populatedRootIds.add(id)
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
import { resolveGeometryPaths, resolveVectorNetwork } from './vector-geometry'
|
||||
export { resolveGeometryPaths } from './vector-geometry'
|
||||
|
||||
import type { NodeChange } from '#core/kiwi/binary/codec'
|
||||
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||
import type {
|
||||
SceneNode,
|
||||
NodeType,
|
||||
|
|
@ -460,6 +460,7 @@ export function nodeChangeToProps(
|
|||
return {
|
||||
nodeType,
|
||||
name: nc.name ?? nodeType,
|
||||
source: extractSourceMetadata(nc, blobs),
|
||||
...convertTransformProps(nc),
|
||||
opacity: nc.opacity ?? 1,
|
||||
visible: nc.visible ?? true,
|
||||
|
|
@ -628,6 +629,43 @@ function isComponentSet(nc: NodeChange): boolean {
|
|||
return defs.some((d) => d.type === 'VARIANT')
|
||||
}
|
||||
|
||||
function extractFigmaLayoutMetadata(nc: NodeChange): SceneNode['source']['fig']['layout'] {
|
||||
return {
|
||||
stackMode: nc.stackMode,
|
||||
stackSpacing: nc.stackSpacing,
|
||||
stackPadding: nc.stackPadding,
|
||||
stackPaddingRight: nc.stackPaddingRight,
|
||||
stackPaddingBottom: nc.stackPaddingBottom,
|
||||
stackCounterAlign: nc.stackCounterAlign,
|
||||
stackJustify: nc.stackJustify,
|
||||
stackCounterAlignItems: nc.stackCounterAlignItems,
|
||||
stackPrimaryAlignItems: nc.stackPrimaryAlignItems,
|
||||
stackPrimarySizing: nc.stackPrimarySizing,
|
||||
stackCounterSizing: nc.stackCounterSizing,
|
||||
stackVerticalPadding: nc.stackVerticalPadding,
|
||||
stackHorizontalPadding: nc.stackHorizontalPadding,
|
||||
stackWrap: nc.stackWrap,
|
||||
stackPositioning: nc.stackPositioning,
|
||||
stackChildPrimaryGrow: nc.stackChildPrimaryGrow,
|
||||
stackChildAlignSelf: nc.stackChildAlignSelf,
|
||||
stackCounterSpacing: nc.stackCounterSpacing,
|
||||
bordersTakeSpace: nc.bordersTakeSpace as boolean | undefined
|
||||
}
|
||||
}
|
||||
|
||||
function extractSourceMetadata(nc: NodeChange, blobs: Uint8Array[]): SceneNode['source'] {
|
||||
return {
|
||||
format: 'fig',
|
||||
id: nc.guid ? guidToString(nc.guid) : null,
|
||||
orderKey: nc.parentIndex?.position ?? null,
|
||||
fig: {
|
||||
...extractFigmaRawGeometry(nc, blobs),
|
||||
...extractFigmaSymbolMetadata(nc, blobs),
|
||||
layout: extractFigmaLayoutMetadata(nc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sortChildren(
|
||||
children: string[],
|
||||
parentNc: NodeChange,
|
||||
|
|
@ -652,6 +690,122 @@ export function sortChildren(
|
|||
}
|
||||
}
|
||||
|
||||
interface PreservedFigmaBlob {
|
||||
__openPencilFigmaBlob: Uint8Array
|
||||
}
|
||||
|
||||
function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown {
|
||||
if (value instanceof Uint8Array) return value
|
||||
if (Array.isArray(value)) return value.map((item) => preserveFigmaPayloadBlobs(item, blobs))
|
||||
if (!value || typeof value !== 'object') return value
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if ((key === 'commandsBlob' || key === 'vectorNetworkBlob') && typeof child === 'number') {
|
||||
const blob: unknown = blobs[child]
|
||||
if (blob == null) {
|
||||
result[key] = child
|
||||
} else {
|
||||
result[key] = {
|
||||
__openPencilFigmaBlob:
|
||||
blob instanceof Uint8Array ? blob : new Uint8Array(Object.values(blob as Record<string, number>))
|
||||
} satisfies PreservedFigmaBlob
|
||||
}
|
||||
} else {
|
||||
result[key] = preserveFigmaPayloadBlobs(child, blobs)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const FIGMA_RAW_NODE_FIELD_KEYS = [
|
||||
'styleIdForFill',
|
||||
'styleIdForStrokeFill',
|
||||
'styleIdForText',
|
||||
'styleIdForEffect',
|
||||
'styleIdForGrid',
|
||||
'componentPropDefs',
|
||||
'componentPropRefs',
|
||||
'variantPropSpecs',
|
||||
'stateGroupPropertyValueOrders',
|
||||
'isStateGroup',
|
||||
'version',
|
||||
'sourceLibraryKey',
|
||||
'userFacingVersion',
|
||||
'sortPosition',
|
||||
'variableConsumptionMap',
|
||||
'parameterConsumptionMap',
|
||||
'editInfo',
|
||||
'backgroundColor',
|
||||
'pageType',
|
||||
'guides',
|
||||
'miterLimit',
|
||||
'strokeWeight',
|
||||
'strokeJoin',
|
||||
'borderStrokeWeightsIndependent',
|
||||
'borderTopWeight',
|
||||
'borderRightWeight',
|
||||
'borderBottomWeight',
|
||||
'borderLeftWeight',
|
||||
'textAutoResize',
|
||||
'textData',
|
||||
'lineHeight',
|
||||
'fontName',
|
||||
'fontSize',
|
||||
'letterSpacing',
|
||||
'textTracking',
|
||||
'fontVersion',
|
||||
'textUserLayoutVersion',
|
||||
'textExplicitLayoutVersion',
|
||||
'fontVariations',
|
||||
'derivedTextData',
|
||||
'fillPaints',
|
||||
'strokePaints',
|
||||
'effects',
|
||||
'vectorData',
|
||||
'fillGeometry',
|
||||
'strokeGeometry'
|
||||
]
|
||||
|
||||
function extractFigmaRawGeometry(
|
||||
nc: NodeChange,
|
||||
blobs: Uint8Array[]
|
||||
): Pick<SceneNode['source']['fig'], 'rawSize' | 'rawTransform' | 'rawNodeFields'> {
|
||||
const rawNodeFields: Record<string, unknown> = {}
|
||||
for (const key of FIGMA_RAW_NODE_FIELD_KEYS) {
|
||||
const value = (nc as Record<string, unknown>)[key]
|
||||
if (value !== undefined) rawNodeFields[key] = preserveFigmaPayloadBlobs(value, blobs)
|
||||
}
|
||||
return {
|
||||
rawSize: nc.size ? { ...nc.size } : null,
|
||||
rawTransform: nc.transform ? { ...nc.transform } : null,
|
||||
rawNodeFields
|
||||
}
|
||||
}
|
||||
|
||||
function extractFigmaSymbolMetadata(
|
||||
nc: NodeChange,
|
||||
blobs: Uint8Array[]
|
||||
): Pick<
|
||||
SceneNode['source']['fig'],
|
||||
| 'symbolOverrides'
|
||||
| 'componentPropAssignments'
|
||||
| 'derivedSymbolData'
|
||||
| 'derivedSymbolDataLayoutVersion'
|
||||
| 'uniformScaleFactor'
|
||||
> {
|
||||
const sd = nc.symbolData as
|
||||
| { symbolOverrides?: unknown[]; uniformScaleFactor?: number }
|
||||
| undefined
|
||||
return {
|
||||
symbolOverrides: preserveFigmaPayloadBlobs(sd?.symbolOverrides ?? [], blobs) as unknown[],
|
||||
componentPropAssignments: preserveFigmaPayloadBlobs(nc.componentPropAssignments ?? [], blobs) as unknown[],
|
||||
derivedSymbolData: preserveFigmaPayloadBlobs(nc.derivedSymbolData ?? [], blobs) as unknown[],
|
||||
derivedSymbolDataLayoutVersion:
|
||||
typeof nc.derivedSymbolDataLayoutVersion === 'number' ? nc.derivedSymbolDataLayoutVersion : null,
|
||||
uniformScaleFactor: typeof sd?.uniformScaleFactor === 'number' ? sd.uniformScaleFactor : null
|
||||
}
|
||||
}
|
||||
|
||||
function extractSymbolId(nc: NodeChange): string {
|
||||
const sd = nc.symbolData as { symbolID?: GUID } | undefined
|
||||
if (!sd?.symbolID) return ''
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NodeChange } from '#core/kiwi/binary/codec'
|
||||
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||
import type { FigmaDerivedTextGlyph } from '#core/scene-graph'
|
||||
|
||||
export function convertFigmaDerivedTextGlyphs(
|
||||
552
packages/core/src/kiwi/fig/node-change/export-node.ts
Normal file
552
packages/core/src/kiwi/fig/node-change/export-node.ts
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
import { bytesToHex } from '#core/bytes/hex'
|
||||
import type { NodeChange, Paint } from '#core/kiwi/fig/codec'
|
||||
import type { SceneGraph, SceneNode } from '#core/scene-graph'
|
||||
import type { Color, GUID, Matrix, Vector } from '#core/types'
|
||||
|
||||
import { stringToGuid } from './guid'
|
||||
import {
|
||||
mergePluginData,
|
||||
NODE_TYPE_PLUGIN_KEY,
|
||||
serializePluginRelaunchData,
|
||||
upsertPluginData
|
||||
} from './plugin-data'
|
||||
|
||||
export type KiwiNodeChange = NodeChange & Record<string, unknown>
|
||||
|
||||
interface SceneNodeToKiwiContext {
|
||||
graph: SceneGraph
|
||||
blobs: Uint8Array[]
|
||||
blobIndexByHex?: Map<string, number>
|
||||
nodeIdToGuid?: Map<string, GUID>
|
||||
fontDigestMap?: Map<string, Uint8Array>
|
||||
glyphBlobMap?: Map<string, number>
|
||||
varIdToGuid?: Map<string, GUID>
|
||||
paintVariableColorMap?: Map<string, Color>
|
||||
fractionalPosition: (index: number) => string
|
||||
mapToFigmaType: (type: SceneNode['type']) => string
|
||||
fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint
|
||||
safeColor: (color: Color) => Color
|
||||
computeExportTransform: (node: SceneNode) => Matrix
|
||||
serializeCornerRadii: (node: SceneNode, nc: KiwiNodeChange) => void
|
||||
serializeTextProps: (
|
||||
node: SceneNode,
|
||||
nc: KiwiNodeChange,
|
||||
graph: SceneGraph,
|
||||
fontDigestMap: Map<string, Uint8Array> | undefined,
|
||||
blobs: Uint8Array[],
|
||||
glyphBlobMap: Map<string, number> | undefined
|
||||
) => void
|
||||
serializeLayoutProps: (node: SceneNode, nc: KiwiNodeChange) => void
|
||||
serializeGeometry: (node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]) => void
|
||||
serializeVariableBindings: (
|
||||
node: SceneNode,
|
||||
nc: KiwiNodeChange,
|
||||
graph: SceneGraph,
|
||||
varIdToGuid?: Map<string, GUID>
|
||||
) => void
|
||||
sceneNodeToKiwi: (
|
||||
node: SceneNode,
|
||||
parentGuid: GUID,
|
||||
childIndex: number,
|
||||
localIdCounter: { value: number },
|
||||
context: SceneNodeToKiwiContext
|
||||
) => KiwiNodeChange[]
|
||||
}
|
||||
|
||||
const DEFAULT_STROKE_WEIGHT = 1
|
||||
|
||||
function applyColorVariableBinding(
|
||||
context: SceneNodeToKiwiContext,
|
||||
node: SceneNode,
|
||||
paint: Paint,
|
||||
field: string
|
||||
): Paint {
|
||||
const variableId = node.boundVariables[field]
|
||||
if (!variableId) return paint
|
||||
return {
|
||||
...paint,
|
||||
colorVariableBinding: {
|
||||
variableID: context.varIdToGuid?.get(variableId) ?? stringToGuid(variableId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): Paint[] {
|
||||
return node.strokes.map((stroke, index) =>
|
||||
applyColorVariableBinding(
|
||||
context,
|
||||
node,
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: context.safeColor(stroke.color),
|
||||
opacity: stroke.opacity,
|
||||
visible: stroke.visible,
|
||||
blendMode: 'NORMAL'
|
||||
},
|
||||
`strokes/${index}/color`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function componentPropertyValue(value: string) {
|
||||
return { textValue: { characters: value } }
|
||||
}
|
||||
|
||||
function componentPropertyTypeForKiwi(type: string) {
|
||||
if (type === 'BOOLEAN') return 'BOOL'
|
||||
if (type === 'VARIANT') return 'TEXT'
|
||||
return type
|
||||
}
|
||||
|
||||
function parseGuidOrNull(value: string) {
|
||||
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
|
||||
}
|
||||
|
||||
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set(['variableConsumptionMap', 'parameterConsumptionMap'])
|
||||
const FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS = new Set(['colorVar', 'opacityVar'])
|
||||
|
||||
const SUPPORTED_VARIABLE_DATA_TYPES = new Set([
|
||||
'BOOLEAN',
|
||||
'FLOAT',
|
||||
'STRING',
|
||||
'ALIAS',
|
||||
'COLOR',
|
||||
'SYMBOL_ID',
|
||||
'TEXT_DATA',
|
||||
'PROP_REF'
|
||||
])
|
||||
|
||||
function isSupportedVariableMapEntry(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const entry = value as { variableData?: { dataType?: string; value?: { propRefValue?: unknown } } }
|
||||
const dataType = entry.variableData?.dataType
|
||||
return (typeof dataType === 'string' && SUPPORTED_VARIABLE_DATA_TYPES.has(dataType)) || !!entry.variableData?.value?.propRefValue
|
||||
}
|
||||
|
||||
function isPropRefVariableMapEntry(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const entry = value as { variableData?: { dataType?: string; value?: { propRefValue?: unknown } } }
|
||||
return entry.variableData?.dataType === 'PROP_REF' || !!entry.variableData?.value?.propRefValue
|
||||
}
|
||||
|
||||
function materializeSafeVariableMap(
|
||||
value: unknown,
|
||||
blobs: Uint8Array[],
|
||||
options: MaterializeFigmaPayloadOptions,
|
||||
predicate: (value: unknown) => boolean
|
||||
): unknown {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
const entries = (value as { entries?: unknown[] }).entries?.filter(predicate) ?? []
|
||||
if (entries.length === 0) return undefined
|
||||
return { entries: entries.map((entry) => materializeFigmaPayload(entry, blobs, options)) }
|
||||
}
|
||||
|
||||
function paintVariableKey(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const assetRef = (value as { value?: { alias?: { assetRef?: { key?: unknown; version?: unknown } } } })
|
||||
.value?.alias?.assetRef
|
||||
return typeof assetRef?.key === 'string'
|
||||
? `${assetRef.key}:${typeof assetRef.version === 'string' ? assetRef.version : ''}`
|
||||
: null
|
||||
}
|
||||
|
||||
interface MaterializeFigmaPayloadOptions {
|
||||
blobIndexByHex?: Map<string, number>
|
||||
includePaintVariables?: boolean
|
||||
includeVariableMaps?: boolean
|
||||
paintVariableColorMap?: Map<string, Color>
|
||||
}
|
||||
|
||||
function materializeFigmaBlob(
|
||||
value: { __openPencilFigmaBlob?: Uint8Array | Record<string, number> },
|
||||
blobs: Uint8Array[],
|
||||
options: MaterializeFigmaPayloadOptions
|
||||
): number {
|
||||
const blob = value.__openPencilFigmaBlob
|
||||
const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(Object.values(blob ?? {}))
|
||||
const key = bytesToHex(bytes)
|
||||
const existing = options.blobIndexByHex?.get(key)
|
||||
if (existing !== undefined) return existing
|
||||
const index = blobs.length
|
||||
blobs.push(bytes)
|
||||
options.blobIndexByHex?.set(key, index)
|
||||
return index
|
||||
}
|
||||
|
||||
function normalizeFigmaPayloadValue(key: string, value: unknown): unknown {
|
||||
if (
|
||||
(key === 'stackJustify' ||
|
||||
key === 'stackPrimaryAlignItems' ||
|
||||
key === 'stackCounterAlign' ||
|
||||
key === 'stackCounterAlignItems') &&
|
||||
value === 'SPACE_EVENLY'
|
||||
) {
|
||||
return 'SPACE_BETWEEN'
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function materializeFigmaPayload(
|
||||
value: unknown,
|
||||
blobs: Uint8Array[],
|
||||
options: MaterializeFigmaPayloadOptions = {}
|
||||
): unknown {
|
||||
if (value instanceof Uint8Array) return value
|
||||
if (Array.isArray(value)) return value.map((item) => materializeFigmaPayload(item, blobs, options))
|
||||
if (!value || typeof value !== 'object') return value
|
||||
if ('__openPencilFigmaBlob' in value) {
|
||||
return materializeFigmaBlob(
|
||||
value as { __openPencilFigmaBlob?: Uint8Array | Record<string, number> },
|
||||
blobs,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
const materialized: Record<string, unknown> = {}
|
||||
const paintVariableColor = options.paintVariableColorMap?.get(
|
||||
paintVariableKey((value as { colorVar?: unknown }).colorVar) ?? ''
|
||||
)
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS.has(key) && !options.includePaintVariables) continue
|
||||
if (FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS.has(key)) {
|
||||
const variableMap = materializeSafeVariableMap(
|
||||
child,
|
||||
blobs,
|
||||
options,
|
||||
options.includeVariableMaps ? isSupportedVariableMapEntry : isPropRefVariableMapEntry
|
||||
)
|
||||
if (variableMap !== undefined) materialized[key] = variableMap
|
||||
continue
|
||||
}
|
||||
materialized[key] = normalizeFigmaPayloadValue(
|
||||
key,
|
||||
materializeFigmaPayload(child, blobs, options)
|
||||
)
|
||||
}
|
||||
if (paintVariableColor) materialized.color = paintVariableColor
|
||||
return materialized
|
||||
}
|
||||
|
||||
function collectPaintVariableColorCounts(
|
||||
value: unknown,
|
||||
counts: Map<string, Map<string, { color: Color; count: number }>>
|
||||
): void {
|
||||
if (!value || typeof value !== 'object' || ArrayBuffer.isView(value)) return
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (item && typeof item === 'object') collectPaintVariableColorCounts(item, counts)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const paint = value as { color?: Color; colorVar?: unknown }
|
||||
const key = paintVariableKey(paint.colorVar)
|
||||
if (key && paint.color) {
|
||||
const colorKey = [paint.color.r, paint.color.g, paint.color.b, paint.color.a]
|
||||
.map((component) => Math.round(component * 255))
|
||||
.join(',')
|
||||
const colorCounts = counts.get(key) ?? new Map<string, { color: Color; count: number }>()
|
||||
const current = colorCounts.get(colorKey)
|
||||
colorCounts.set(colorKey, { color: paint.color, count: (current?.count ?? 0) + 1 })
|
||||
counts.set(key, colorCounts)
|
||||
}
|
||||
|
||||
for (const child of Object.values(value)) collectPaintVariableColorCounts(child, counts)
|
||||
}
|
||||
|
||||
export function buildFigmaPaintVariableColorMap(graph: SceneGraph): Map<string, Color> {
|
||||
const counts = new Map<string, Map<string, { color: Color; count: number }>>()
|
||||
for (const node of graph.nodes.values()) {
|
||||
collectPaintVariableColorCounts(node.source.fig.rawNodeFields, counts)
|
||||
collectPaintVariableColorCounts(node.source.fig.symbolOverrides, counts)
|
||||
collectPaintVariableColorCounts(node.source.fig.componentPropAssignments, counts)
|
||||
collectPaintVariableColorCounts(node.source.fig.derivedSymbolData, counts)
|
||||
}
|
||||
|
||||
const colors = new Map<string, Color>()
|
||||
for (const [key, colorCounts] of counts) {
|
||||
const [mostCommon] = [...colorCounts.values()].sort((a, b) => b.count - a.count)
|
||||
colors.set(key, mostCommon.color)
|
||||
}
|
||||
return colors
|
||||
}
|
||||
|
||||
function resolveInstanceComponentId(context: SceneNodeToKiwiContext, componentId: string): string {
|
||||
const seen = new Set<string>()
|
||||
let currentId = componentId
|
||||
while (!seen.has(currentId)) {
|
||||
seen.add(currentId)
|
||||
const node = context.graph.getNode(currentId)
|
||||
if (node?.type !== 'INSTANCE' || !node.componentId) return currentId
|
||||
currentId = node.componentId
|
||||
}
|
||||
return componentId
|
||||
}
|
||||
|
||||
function getOrCreateNodeGuid(
|
||||
context: SceneNodeToKiwiContext,
|
||||
nodeId: string,
|
||||
localIdCounter: { value: number }
|
||||
): GUID | undefined {
|
||||
if (!context.graph.getNode(nodeId)) return undefined
|
||||
const existing = context.nodeIdToGuid?.get(nodeId)
|
||||
if (existing) return existing
|
||||
const node = context.graph.getNode(nodeId)
|
||||
const importedGuid = node?.source.id ? parseGuidOrNull(node.source.id) : null
|
||||
const guid = importedGuid ?? { sessionID: 1, localID: localIdCounter.value++ }
|
||||
context.nodeIdToGuid?.set(nodeId, guid)
|
||||
return guid
|
||||
}
|
||||
|
||||
function applyRawFigmaNodeFields(
|
||||
context: SceneNodeToKiwiContext,
|
||||
node: SceneNode,
|
||||
nc: KiwiNodeChange
|
||||
): void {
|
||||
Object.assign(
|
||||
nc,
|
||||
materializeFigmaPayload(node.source.fig.rawNodeFields, context.blobs, {
|
||||
blobIndexByHex: context.blobIndexByHex
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function applyInstancePayload(
|
||||
context: SceneNodeToKiwiContext,
|
||||
node: SceneNode,
|
||||
nc: KiwiNodeChange,
|
||||
localIdCounter: { value: number }
|
||||
): void {
|
||||
if (node.type !== 'INSTANCE' || !node.componentId) return
|
||||
const symbolID = getOrCreateNodeGuid(
|
||||
context,
|
||||
resolveInstanceComponentId(context, node.componentId),
|
||||
localIdCounter
|
||||
)
|
||||
if (symbolID) {
|
||||
const symbolData: Record<string, unknown> = { symbolID }
|
||||
if (node.source.fig.symbolOverrides.length > 0) {
|
||||
symbolData.symbolOverrides = materializeFigmaPayload(node.source.fig.symbolOverrides, context.blobs, {
|
||||
blobIndexByHex: context.blobIndexByHex,
|
||||
includeVariableMaps: true,
|
||||
paintVariableColorMap: context.paintVariableColorMap
|
||||
})
|
||||
}
|
||||
if (node.source.fig.uniformScaleFactor != null) {
|
||||
symbolData.uniformScaleFactor = node.source.fig.uniformScaleFactor
|
||||
}
|
||||
nc.symbolData = symbolData as KiwiNodeChange['symbolData']
|
||||
}
|
||||
if (node.source.fig.componentPropAssignments.length > 0) {
|
||||
nc.componentPropAssignments = materializeFigmaPayload(
|
||||
node.source.fig.componentPropAssignments,
|
||||
context.blobs,
|
||||
{
|
||||
blobIndexByHex: context.blobIndexByHex,
|
||||
includeVariableMaps: true,
|
||||
paintVariableColorMap: context.paintVariableColorMap
|
||||
}
|
||||
)
|
||||
}
|
||||
if (node.source.fig.derivedSymbolData.length > 0) {
|
||||
nc.derivedSymbolData = materializeFigmaPayload(node.source.fig.derivedSymbolData, context.blobs, {
|
||||
blobIndexByHex: context.blobIndexByHex,
|
||||
includeVariableMaps: true,
|
||||
paintVariableColorMap: context.paintVariableColorMap
|
||||
})
|
||||
}
|
||||
if (node.source.fig.derivedSymbolDataLayoutVersion != null) {
|
||||
nc.derivedSymbolDataLayoutVersion = node.source.fig.derivedSymbolDataLayoutVersion
|
||||
}
|
||||
}
|
||||
|
||||
function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
|
||||
if (node.componentKey) nc.componentKey = node.componentKey
|
||||
if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey
|
||||
const publishId = node.publishId ? parseGuidOrNull(node.publishId) : null
|
||||
const overrideKey = node.overrideKey ? parseGuidOrNull(node.overrideKey) : null
|
||||
if (publishId) nc.publishID = publishId
|
||||
if (overrideKey) nc.overrideKey = overrideKey
|
||||
if (node.sharedSymbolVersion) nc.sharedSymbolVersion = node.sharedSymbolVersion
|
||||
if (node.publishedVersion) nc.publishedVersion = node.publishedVersion
|
||||
if (node.type === 'COMPONENT_SET' || node.isPublishable) nc.isPublishable = node.isPublishable
|
||||
if (node.type === 'COMPONENT' || node.isSymbolPublishable) {
|
||||
nc.isSymbolPublishable = node.isSymbolPublishable
|
||||
}
|
||||
if (node.symbolDescription) nc.symbolDescription = node.symbolDescription
|
||||
if (node.symbolLinks.length > 0) nc.symbolLinks = structuredClone(node.symbolLinks)
|
||||
const componentPropDefs = node.componentPropertyDefinitions
|
||||
.map((def) => {
|
||||
const id = parseGuidOrNull(def.id)
|
||||
return id
|
||||
? {
|
||||
id,
|
||||
name: def.name,
|
||||
type: componentPropertyTypeForKiwi(def.type),
|
||||
initialValue: componentPropertyValue(def.defaultValue)
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter((def): def is NonNullable<typeof def> => def !== null)
|
||||
if (componentPropDefs.length > 0) nc.componentPropDefs = componentPropDefs
|
||||
|
||||
const variantPropSpecs = node.variantPropSpecs
|
||||
.map((spec) => {
|
||||
const propDefId = parseGuidOrNull(spec.propDefId)
|
||||
return propDefId ? { propDefId, value: spec.value } : null
|
||||
})
|
||||
.filter((spec): spec is NonNullable<typeof spec> => spec !== null)
|
||||
if (variantPropSpecs.length > 0) nc.variantPropSpecs = variantPropSpecs
|
||||
}
|
||||
|
||||
function exportNodeSize(node: SceneNode): Vector {
|
||||
return node.source.fig.rawSize ? { ...node.source.fig.rawSize } : { x: node.width, y: node.height }
|
||||
}
|
||||
|
||||
function exportNodeTransform(context: SceneNodeToKiwiContext, node: SceneNode): Matrix {
|
||||
return node.source.fig.rawTransform ? { ...node.source.fig.rawTransform } : context.computeExportTransform(node)
|
||||
}
|
||||
|
||||
function hasRawGeometryPayload(node: SceneNode): boolean {
|
||||
return 'fillGeometry' in node.source.fig.rawNodeFields || 'strokeGeometry' in node.source.fig.rawNodeFields
|
||||
}
|
||||
|
||||
function hasRawVectorPayload(node: SceneNode): boolean {
|
||||
return 'vectorData' in node.source.fig.rawNodeFields
|
||||
}
|
||||
|
||||
function nodeForGeometryExport(node: SceneNode): SceneNode {
|
||||
if (!hasRawGeometryPayload(node) && !hasRawVectorPayload(node)) return node
|
||||
return {
|
||||
...node,
|
||||
fillGeometry: hasRawGeometryPayload(node) ? [] : node.fillGeometry,
|
||||
strokeGeometry: hasRawGeometryPayload(node) ? [] : node.strokeGeometry,
|
||||
vectorNetwork: hasRawVectorPayload(node) ? null : node.vectorNetwork
|
||||
}
|
||||
}
|
||||
|
||||
function applyNodeVisualProps(
|
||||
context: SceneNodeToKiwiContext,
|
||||
node: SceneNode,
|
||||
nc: KiwiNodeChange
|
||||
): void {
|
||||
if (node.independentStrokeWeights) {
|
||||
nc.borderStrokeWeightsIndependent = true
|
||||
nc.borderTopWeight = node.borderTopWeight
|
||||
nc.borderRightWeight = node.borderRightWeight
|
||||
nc.borderBottomWeight = node.borderBottomWeight
|
||||
nc.borderLeftWeight = node.borderLeftWeight
|
||||
}
|
||||
|
||||
if (node.fills.length > 0) {
|
||||
nc.fillPaints = node.fills.map((fill, index) =>
|
||||
applyColorVariableBinding(
|
||||
context,
|
||||
node,
|
||||
context.fillToKiwiPaint(fill),
|
||||
`fills/${index}/color`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
context.serializeCornerRadii(node, nc)
|
||||
|
||||
if (node.effects.length > 0) {
|
||||
nc.effects = node.effects.map((effect) => ({
|
||||
type: effect.type === 'LAYER_BLUR' ? 'FOREGROUND_BLUR' : effect.type,
|
||||
color: context.safeColor(effect.color),
|
||||
offset: effect.offset,
|
||||
radius: effect.radius,
|
||||
spread: effect.spread,
|
||||
visible: effect.visible,
|
||||
showShadowBehindNode: effect.showShadowBehindNode
|
||||
}))
|
||||
}
|
||||
|
||||
if (node.type === 'TEXT') {
|
||||
context.serializeTextProps(
|
||||
node,
|
||||
nc,
|
||||
context.graph,
|
||||
context.fontDigestMap,
|
||||
context.blobs,
|
||||
context.glyphBlobMap
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type !== 'VECTOR') nc.frameMaskDisabled = !node.clipsContent
|
||||
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
|
||||
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
|
||||
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
|
||||
if (node.strokeJoin !== 'MITER') nc.strokeJoin = node.strokeJoin
|
||||
if (!node.source.id && node.strokeMiterLimit !== 28.96) nc.miterLimit = node.strokeMiterLimit
|
||||
if (node.dashPattern.length > 0) nc.dashPattern = node.dashPattern
|
||||
if (node.arcData) {
|
||||
nc.arcData = {
|
||||
startingAngle: node.arcData.startingAngle,
|
||||
endingAngle: node.arcData.endingAngle,
|
||||
innerRadius: node.arcData.innerRadius
|
||||
}
|
||||
}
|
||||
if (!node.autoRename) nc.autoRename = false
|
||||
}
|
||||
|
||||
export function sceneNodeToKiwiWithContext(
|
||||
node: SceneNode,
|
||||
parentGuid: GUID,
|
||||
childIndex: number,
|
||||
localIdCounter: { value: number },
|
||||
context: SceneNodeToKiwiContext
|
||||
): KiwiNodeChange[] {
|
||||
const guid = getOrCreateNodeGuid(context, node.id, localIdCounter) ?? {
|
||||
sessionID: 1,
|
||||
localID: localIdCounter.value++
|
||||
}
|
||||
|
||||
const strokePaints = createStrokePaints(context, node)
|
||||
|
||||
const nc: KiwiNodeChange = {
|
||||
guid,
|
||||
parentIndex: {
|
||||
guid: parentGuid,
|
||||
position: node.source.orderKey ?? context.fractionalPosition(childIndex)
|
||||
},
|
||||
type: context.mapToFigmaType(node.type),
|
||||
name: node.name,
|
||||
visible: node.visible,
|
||||
opacity: node.opacity,
|
||||
phase: 'CREATED',
|
||||
size: exportNodeSize(node),
|
||||
transform: exportNodeTransform(context, node),
|
||||
strokeWeight: node.strokes[0]?.weight ?? DEFAULT_STROKE_WEIGHT,
|
||||
strokeAlign: node.strokes[0]?.align ?? 'INSIDE'
|
||||
}
|
||||
|
||||
applyNodeVisualProps(context, node, nc)
|
||||
applyComponentMetadata(node, nc)
|
||||
applyInstancePayload(context, node, nc, localIdCounter)
|
||||
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
|
||||
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'
|
||||
if (strokePaints.length > 0) nc.strokePaints = strokePaints
|
||||
|
||||
context.serializeLayoutProps(node, nc)
|
||||
context.serializeGeometry(nodeForGeometryExport(node), nc, context.blobs)
|
||||
context.serializeVariableBindings(node, nc, context.graph, context.varIdToGuid)
|
||||
applyRawFigmaNodeFields(context, node, nc)
|
||||
|
||||
const pluginData = mergePluginData(node.pluginData)
|
||||
if (pluginData.length > 0) nc.pluginData = pluginData
|
||||
if (node.pluginRelaunchData.length > 0) {
|
||||
nc.pluginRelaunchData = serializePluginRelaunchData(node.pluginRelaunchData)
|
||||
}
|
||||
|
||||
const result: KiwiNodeChange[] = [nc]
|
||||
const children =
|
||||
node.type === 'INSTANCE'
|
||||
? []
|
||||
: context.graph.getChildren(node.id).filter((child) => !child.internalOnly)
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
result.push(...context.sceneNodeToKiwi(children[i], guid, i, localIdCounter, context))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { GUID } from '#core/kiwi/binary/codec'
|
||||
import type { GUID } from '#core/kiwi/fig/codec'
|
||||
|
||||
export function guidToString(guid: GUID): string {
|
||||
return `${guid.sessionID}:${guid.localID}`
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { normalizeColor } from '#core/color'
|
||||
import type { Paint, Effect as KiwiEffect } from '#core/kiwi/binary/codec'
|
||||
import type { Paint, Effect as KiwiEffect } from '#core/kiwi/fig/codec'
|
||||
import type {
|
||||
Fill,
|
||||
FillType,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NodeChange, PluginData, PluginRelaunchData } from '#core/kiwi/binary/codec'
|
||||
import type { NodeChange, PluginData, PluginRelaunchData } from '#core/kiwi/fig/codec'
|
||||
import type { PluginDataEntry, PluginRelaunchDataEntry } from '#core/scene-graph'
|
||||
|
||||
import { guidToString } from './guid'
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { hexToBytes } from '#core/bytes/hex'
|
||||
import { encodePathCommandsBlob } from '#core/kiwi/node-change/path-commands'
|
||||
import { bytesToHex, hexToBytes } from '#core/bytes/hex'
|
||||
import { encodePathCommandsBlob } from '#core/kiwi/fig/node-change/path-commands'
|
||||
import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/derived-text/data'
|
||||
import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts'
|
||||
import { getGlyphOutlineMetricsSync } from '#core/text/opentype'
|
||||
|
|
@ -10,10 +10,10 @@ export {
|
|||
decompressFigKiwiDataAsync,
|
||||
FIG_KIWI_DEFAULT_VERSION,
|
||||
parseFigKiwiChunks
|
||||
} from './fig/container'
|
||||
} from '#core/kiwi/fig/container/kiwi'
|
||||
export { buildFontDigestMap } from './font-digests'
|
||||
|
||||
import type { NodeChange, Paint, VariableConsumptionEntry } from '#core/kiwi/binary/codec'
|
||||
import type { NodeChange, Paint, VariableConsumptionEntry } from '#core/kiwi/fig/codec'
|
||||
import type { SceneGraph, SceneNode, CharacterStyleOverride } from '#core/scene-graph'
|
||||
import type { Color, GUID, Matrix } from '#core/types'
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ export function mapToFigmaType(type: SceneNode['type']): string {
|
|||
case 'COMPONENT':
|
||||
return 'SYMBOL'
|
||||
case 'COMPONENT_SET':
|
||||
return 'SYMBOL'
|
||||
return 'FRAME'
|
||||
case 'INSTANCE':
|
||||
return 'INSTANCE'
|
||||
case 'CONNECTOR':
|
||||
|
|
@ -74,10 +74,24 @@ function textLines(text: string): NonNullable<NodeChange['textData']>['lines'] {
|
|||
return Array.from({ length: lineCount }, () => ({ lineType: 'PLAIN' }))
|
||||
}
|
||||
|
||||
function appendGlyphBlob(
|
||||
blobs: Uint8Array[],
|
||||
glyphBlobMap: Map<string, number>,
|
||||
blob: Uint8Array
|
||||
): number {
|
||||
const key = bytesToHex(blob)
|
||||
const existing = glyphBlobMap.get(key)
|
||||
if (existing !== undefined) return existing
|
||||
const index = blobs.push(blob) - 1
|
||||
glyphBlobMap.set(key, index)
|
||||
return index
|
||||
}
|
||||
|
||||
function buildDerivedTextData(
|
||||
node: SceneNode,
|
||||
digestMap: Map<string, Uint8Array>,
|
||||
blobs: Uint8Array[]
|
||||
blobs: Uint8Array[],
|
||||
glyphBlobMap: Map<string, number>
|
||||
): NodeChange['derivedTextData'] {
|
||||
const fontMeta: NonNullable<NodeChange['derivedTextData']>['fontMetaData'] = []
|
||||
const seen = new Set<string>()
|
||||
|
|
@ -106,20 +120,41 @@ function buildDerivedTextData(
|
|||
)
|
||||
}
|
||||
|
||||
const style = weightToStyle(node.fontWeight, node.italic)
|
||||
const glyphMetrics =
|
||||
getGlyphOutlineMetricsSync(node.fontFamily, style, node.text, node.fontSize) ?? []
|
||||
const lineHeight = node.lineHeight ?? Math.ceil(node.fontSize * 1.2)
|
||||
const glyphAdvance = node.text.length > 0 ? node.width / Math.max(node.text.length, 1) : 0
|
||||
|
||||
const glyphs = glyphMetrics.map((glyph, index) => ({
|
||||
commandsBlob: blobs.push(encodePathCommandsBlob(glyph.commands, node.fontSize)) - 1,
|
||||
position: { x: glyph.x || index * glyphAdvance, y: lineHeight },
|
||||
fontSize: node.fontSize,
|
||||
firstCharacter: index,
|
||||
advance: glyph.advance || glyphAdvance,
|
||||
rotation: 0
|
||||
}))
|
||||
const derivedGlyphs = node.figmaDerivedTextGlyphs ?? []
|
||||
const glyphs =
|
||||
derivedGlyphs.length > 0
|
||||
? derivedGlyphs.map((glyph, index) => ({
|
||||
commandsBlob: appendGlyphBlob(blobs, glyphBlobMap, glyph.commandsBlob),
|
||||
position: { x: glyph.x, y: glyph.y },
|
||||
fontSize: glyph.fontSize,
|
||||
firstCharacter: index,
|
||||
advance:
|
||||
index + 1 < derivedGlyphs.length
|
||||
? Math.max(derivedGlyphs[index + 1].x - glyph.x, 0)
|
||||
: glyphAdvance,
|
||||
rotation: 0
|
||||
}))
|
||||
: (getGlyphOutlineMetricsSync(
|
||||
node.fontFamily,
|
||||
weightToStyle(node.fontWeight, node.italic),
|
||||
node.text,
|
||||
node.fontSize
|
||||
) ?? []
|
||||
).map((glyph, index) => ({
|
||||
commandsBlob: appendGlyphBlob(
|
||||
blobs,
|
||||
glyphBlobMap,
|
||||
encodePathCommandsBlob(glyph.commands, node.fontSize)
|
||||
),
|
||||
position: { x: glyph.x || index * glyphAdvance, y: lineHeight },
|
||||
fontSize: node.fontSize,
|
||||
firstCharacter: index,
|
||||
advance: glyph.advance || glyphAdvance,
|
||||
rotation: 0
|
||||
}))
|
||||
|
||||
const logicalIndexToCharacterOffsetMap = Array.from(
|
||||
{ length: node.text.length + 1 },
|
||||
|
|
@ -215,21 +250,21 @@ function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint {
|
|||
}
|
||||
|
||||
function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
|
||||
if (node.cornerRadius > 0 || node.independentCorners) {
|
||||
const hasCornerRadius = node.independentCorners
|
||||
? node.topLeftRadius > 0 ||
|
||||
node.topRightRadius > 0 ||
|
||||
node.bottomLeftRadius > 0 ||
|
||||
node.bottomRightRadius > 0
|
||||
: node.cornerRadius > 0
|
||||
if (hasCornerRadius) {
|
||||
nc.cornerRadius = node.cornerRadius
|
||||
nc.rectangleCornerRadiiIndependent = node.independentCorners
|
||||
nc.rectangleTopLeftCornerRadius = node.independentCorners
|
||||
? node.topLeftRadius
|
||||
: node.cornerRadius
|
||||
nc.rectangleTopRightCornerRadius = node.independentCorners
|
||||
? node.topRightRadius
|
||||
: node.cornerRadius
|
||||
nc.rectangleBottomLeftCornerRadius = node.independentCorners
|
||||
? node.bottomLeftRadius
|
||||
: node.cornerRadius
|
||||
nc.rectangleBottomRightCornerRadius = node.independentCorners
|
||||
? node.bottomRightRadius
|
||||
: node.cornerRadius
|
||||
if (node.independentCorners) {
|
||||
nc.rectangleCornerRadiiIndependent = true
|
||||
nc.rectangleTopLeftCornerRadius = node.topLeftRadius
|
||||
nc.rectangleTopRightCornerRadius = node.topRightRadius
|
||||
nc.rectangleBottomLeftCornerRadius = node.bottomLeftRadius
|
||||
nc.rectangleBottomRightCornerRadius = node.bottomRightRadius
|
||||
}
|
||||
}
|
||||
if (node.cornerSmoothing > 0) {
|
||||
nc.cornerSmoothing = node.cornerSmoothing
|
||||
|
|
@ -254,7 +289,8 @@ function serializeTextProps(
|
|||
nc: KiwiNodeChange,
|
||||
graph: SceneGraph,
|
||||
fontDigestMap: Map<string, Uint8Array> | undefined,
|
||||
blobs: Uint8Array[]
|
||||
blobs: Uint8Array[],
|
||||
glyphBlobMap: Map<string, number> | undefined
|
||||
): void {
|
||||
upsertPluginData(node, TEXT_DIRECTION_PLUGIN_KEY, node.textDirection)
|
||||
nc.fontSize = node.fontSize
|
||||
|
|
@ -276,7 +312,9 @@ function serializeTextProps(
|
|||
nc.fontVariantContextualLigatures = true
|
||||
nc.fontVersion = ''
|
||||
nc.emojiImageSet = 'APPLE'
|
||||
if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs)
|
||||
if (fontDigestMap) {
|
||||
nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs, glyphBlobMap ?? new Map())
|
||||
}
|
||||
if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' }
|
||||
nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' }
|
||||
if (node.textDecoration !== 'NONE') {
|
||||
|
|
@ -284,8 +322,53 @@ function serializeTextProps(
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeStackMode(
|
||||
value: string | undefined
|
||||
): KiwiNodeChange['stackMode'] {
|
||||
return value === 'HORIZONTAL' || value === 'VERTICAL' || value === 'NONE' ? value : undefined
|
||||
}
|
||||
|
||||
function normalizeStackSizing(
|
||||
value: string | undefined
|
||||
): KiwiNodeChange['stackPrimarySizing'] {
|
||||
return value === 'FIXED' || value === 'RESIZE_TO_FIT' || value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE'
|
||||
? value
|
||||
: undefined
|
||||
}
|
||||
|
||||
function normalizeStackJustify(value: string | undefined): string | undefined {
|
||||
return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value
|
||||
}
|
||||
|
||||
function normalizeStackCounterAlign(value: string | undefined): string | undefined {
|
||||
return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value
|
||||
}
|
||||
|
||||
function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
|
||||
upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection)
|
||||
if (!node.source.id) upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection)
|
||||
const figLayout = node.source.fig.layout
|
||||
if (figLayout) {
|
||||
nc.stackMode = normalizeStackMode(figLayout.stackMode)
|
||||
nc.stackSpacing = figLayout.stackSpacing
|
||||
nc.stackPadding = figLayout.stackPadding
|
||||
nc.stackPaddingRight = figLayout.stackPaddingRight
|
||||
nc.stackPaddingBottom = figLayout.stackPaddingBottom
|
||||
nc.stackCounterAlign = normalizeStackCounterAlign(figLayout.stackCounterAlign)
|
||||
nc.stackJustify = normalizeStackJustify(figLayout.stackJustify)
|
||||
nc.stackCounterAlignItems = normalizeStackCounterAlign(figLayout.stackCounterAlignItems)
|
||||
nc.stackPrimaryAlignItems = normalizeStackJustify(figLayout.stackPrimaryAlignItems)
|
||||
nc.stackPrimarySizing = normalizeStackSizing(figLayout.stackPrimarySizing)
|
||||
nc.stackCounterSizing = normalizeStackSizing(figLayout.stackCounterSizing)
|
||||
nc.stackVerticalPadding = figLayout.stackVerticalPadding
|
||||
nc.stackHorizontalPadding = figLayout.stackHorizontalPadding
|
||||
nc.stackWrap = figLayout.stackWrap
|
||||
nc.stackPositioning = figLayout.stackPositioning
|
||||
nc.stackChildPrimaryGrow = figLayout.stackChildPrimaryGrow
|
||||
nc.stackChildAlignSelf = figLayout.stackChildAlignSelf
|
||||
nc.stackCounterSpacing = figLayout.stackCounterSpacing
|
||||
nc.bordersTakeSpace = figLayout.bordersTakeSpace
|
||||
return
|
||||
}
|
||||
if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') {
|
||||
nc.stackMode = node.layoutMode
|
||||
nc.stackSpacing = node.itemSpacing
|
||||
|
|
@ -295,8 +378,8 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
|
|||
nc.stackPaddingRight = node.paddingRight
|
||||
nc.stackPrimarySizing = node.primaryAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
|
||||
nc.stackCounterSizing = node.counterAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
|
||||
nc.stackPrimaryAlignItems = node.primaryAxisAlign
|
||||
nc.stackCounterAlignItems = node.counterAxisAlign
|
||||
nc.stackPrimaryAlignItems = normalizeStackJustify(node.primaryAxisAlign)
|
||||
nc.stackCounterAlignItems = normalizeStackCounterAlign(node.counterAxisAlign)
|
||||
if (node.layoutWrap === 'WRAP') nc.stackWrap = 'WRAP'
|
||||
if (node.counterAxisSpacing > 0) nc.stackCounterSpacing = node.counterAxisSpacing
|
||||
nc.bordersTakeSpace = node.strokesIncludedInLayout
|
||||
|
|
@ -372,20 +455,11 @@ function serializeVariableBindings(
|
|||
if (entries.length > 0) nc.variableConsumptionMap = { entries }
|
||||
}
|
||||
|
||||
function computeExportTransform(node: SceneNode, graph: SceneGraph): Matrix {
|
||||
function computeExportTransform(node: SceneNode): Matrix {
|
||||
const sx = node.flipX ? -1 : 1
|
||||
const cos = Math.cos((node.rotation * Math.PI) / 180)
|
||||
const sin = Math.sin((node.rotation * Math.PI) / 180)
|
||||
|
||||
// Auto-layout children should have (0,0) transform — Figma computes
|
||||
// their positions from the layout engine at render time.
|
||||
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
|
||||
const isAutoLayoutChild =
|
||||
parent &&
|
||||
parent.layoutMode !== 'NONE' &&
|
||||
parent.layoutMode !== 'GRID' &&
|
||||
node.layoutPositioning !== 'ABSOLUTE'
|
||||
|
||||
const m00 = cos * sx
|
||||
const m01 = -sin
|
||||
const m10 = sin * sx
|
||||
|
|
@ -405,10 +479,10 @@ function computeExportTransform(node: SceneNode, graph: SceneGraph): Matrix {
|
|||
return {
|
||||
m00,
|
||||
m01,
|
||||
m02: isAutoLayoutChild ? 0 : node.x - offsetX,
|
||||
m02: node.x - offsetX,
|
||||
m10,
|
||||
m11,
|
||||
m12: isAutoLayoutChild ? 0 : node.y - offsetY
|
||||
m12: node.y - offsetY
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -421,14 +495,20 @@ export function sceneNodeToKiwi(
|
|||
blobs: Uint8Array[],
|
||||
nodeIdToGuid?: Map<string, GUID>,
|
||||
fontDigestMap?: Map<string, Uint8Array>,
|
||||
varIdToGuid?: Map<string, GUID>
|
||||
varIdToGuid?: Map<string, GUID>,
|
||||
glyphBlobMap = new Map<string, number>(),
|
||||
paintVariableColorMap?: Map<string, Color>,
|
||||
blobIndexByHex?: Map<string, number>
|
||||
): KiwiNodeChange[] {
|
||||
return sceneNodeToKiwiWithContext(node, parentGuid, childIndex, localIdCounter, {
|
||||
graph,
|
||||
blobs,
|
||||
blobIndexByHex,
|
||||
nodeIdToGuid,
|
||||
fontDigestMap,
|
||||
glyphBlobMap,
|
||||
varIdToGuid,
|
||||
paintVariableColorMap,
|
||||
fractionalPosition,
|
||||
mapToFigmaType,
|
||||
fillToKiwiPaint,
|
||||
|
|
@ -484,6 +564,7 @@ export function makeCanvasNodeChange(
|
|||
strokeWeight: DEFAULT_STROKE_WEIGHT,
|
||||
strokeAlign: 'CENTER',
|
||||
strokeJoin: 'MITER',
|
||||
pageType: 'DESIGN',
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { GUID, NodeChange } from '#core/kiwi/binary/codec'
|
||||
import { guidToString } from '#core/kiwi/node-change/guid'
|
||||
import type { GUID, NodeChange } from '#core/kiwi/fig/codec'
|
||||
import { guidToString } from '#core/kiwi/fig/node-change/guid'
|
||||
|
||||
const TEXT_STYLE_FIELDS = [
|
||||
'fontSize',
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NodeChange } from '#core/kiwi/binary/codec'
|
||||
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||
import type { CharacterStyleOverride, StyleRun } from '#core/scene-graph'
|
||||
import { styleToWeight } from '#core/text/fonts'
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NodeChange } from '#core/kiwi/binary/codec'
|
||||
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||
import type { GeometryPath, VectorNetwork, WindingRule } from '#core/scene-graph'
|
||||
import type { Vector } from '#core/types'
|
||||
import { decodeVectorNetworkBlob } from '#core/vector'
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { unzipSync, inflateSync } from 'fflate'
|
||||
import { decompress as zstdDecompress } from 'fzstd'
|
||||
|
||||
import type { FigmaMessage, NodeChange } from '#core/kiwi/binary/codec'
|
||||
import { isZstdCompressed } from '#core/kiwi/binary/protocol'
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from '#core/kiwi/kiwi-schema'
|
||||
import type { FigmaMessage, NodeChange } from '#core/kiwi/fig/codec'
|
||||
import { isZstdCompressed } from '#core/kiwi/fig/codec/protocol'
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from '#core/kiwi/schema-runtime'
|
||||
|
||||
/**
|
||||
* Deduplicates pluginData/pluginRelaunchData entries on raw NodeChange objects.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { getLazyFigImportContext, setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
|
||||
import type { InstanceNodeChange } from '#core/kiwi/instance-overrides'
|
||||
import type { InstanceNodeChange } from '#core/kiwi/fig/instance-overrides'
|
||||
import { SceneGraph } from '#core/scene-graph'
|
||||
import type { SceneNode, Variable, VariableCollection, DocumentColorSpace } from '#core/scene-graph'
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export {
|
|||
type VariableDataValuesEntry,
|
||||
type ParentIndex,
|
||||
type FigmaMessage
|
||||
} from './binary/codec'
|
||||
} from './fig/codec'
|
||||
export {
|
||||
MESSAGE_TYPES,
|
||||
NODE_TYPES,
|
||||
|
|
@ -48,4 +48,4 @@ export {
|
|||
getKiwiMessageType,
|
||||
parseVarint,
|
||||
FIG_WIRE_MAGIC
|
||||
} from './binary/protocol'
|
||||
} from './fig/codec/protocol'
|
||||
|
|
|
|||
|
|
@ -1,244 +0,0 @@
|
|||
import type { NodeChange, Paint } from '#core/kiwi/binary/codec'
|
||||
import type { SceneGraph, SceneNode } from '#core/scene-graph'
|
||||
import type { Color, GUID, Matrix } from '#core/types'
|
||||
|
||||
import { stringToGuid } from './guid'
|
||||
import {
|
||||
mergePluginData,
|
||||
NODE_TYPE_PLUGIN_KEY,
|
||||
serializePluginRelaunchData,
|
||||
upsertPluginData
|
||||
} from './plugin-data'
|
||||
|
||||
export type KiwiNodeChange = NodeChange & Record<string, unknown>
|
||||
|
||||
interface SceneNodeToKiwiContext {
|
||||
graph: SceneGraph
|
||||
blobs: Uint8Array[]
|
||||
nodeIdToGuid?: Map<string, GUID>
|
||||
fontDigestMap?: Map<string, Uint8Array>
|
||||
varIdToGuid?: Map<string, GUID>
|
||||
fractionalPosition: (index: number) => string
|
||||
mapToFigmaType: (type: SceneNode['type']) => string
|
||||
fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint
|
||||
safeColor: (color: Color) => Color
|
||||
computeExportTransform: (node: SceneNode, graph: SceneGraph) => Matrix
|
||||
serializeCornerRadii: (node: SceneNode, nc: KiwiNodeChange) => void
|
||||
serializeTextProps: (
|
||||
node: SceneNode,
|
||||
nc: KiwiNodeChange,
|
||||
graph: SceneGraph,
|
||||
fontDigestMap: Map<string, Uint8Array> | undefined,
|
||||
blobs: Uint8Array[]
|
||||
) => void
|
||||
serializeLayoutProps: (node: SceneNode, nc: KiwiNodeChange) => void
|
||||
serializeGeometry: (node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]) => void
|
||||
serializeVariableBindings: (
|
||||
node: SceneNode,
|
||||
nc: KiwiNodeChange,
|
||||
graph: SceneGraph,
|
||||
varIdToGuid?: Map<string, GUID>
|
||||
) => void
|
||||
sceneNodeToKiwi: (
|
||||
node: SceneNode,
|
||||
parentGuid: GUID,
|
||||
childIndex: number,
|
||||
localIdCounter: { value: number },
|
||||
context: SceneNodeToKiwiContext
|
||||
) => KiwiNodeChange[]
|
||||
}
|
||||
|
||||
const DEFAULT_STROKE_WEIGHT = 1
|
||||
|
||||
function applyColorVariableBinding(
|
||||
context: SceneNodeToKiwiContext,
|
||||
node: SceneNode,
|
||||
paint: Paint,
|
||||
field: string
|
||||
): Paint {
|
||||
const variableId = node.boundVariables[field]
|
||||
if (!variableId) return paint
|
||||
return {
|
||||
...paint,
|
||||
colorVariableBinding: {
|
||||
variableID: context.varIdToGuid?.get(variableId) ?? stringToGuid(variableId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): Paint[] {
|
||||
return node.strokes.map((stroke, index) =>
|
||||
applyColorVariableBinding(
|
||||
context,
|
||||
node,
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: context.safeColor(stroke.color),
|
||||
opacity: stroke.opacity,
|
||||
visible: stroke.visible,
|
||||
blendMode: 'NORMAL'
|
||||
},
|
||||
`strokes/${index}/color`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function componentPropertyValue(value: string) {
|
||||
return { textValue: { characters: value } }
|
||||
}
|
||||
|
||||
function componentPropertyTypeForKiwi(type: string) {
|
||||
if (type === 'BOOLEAN') return 'BOOL'
|
||||
if (type === 'VARIANT') return 'TEXT'
|
||||
return type
|
||||
}
|
||||
|
||||
function parseGuidOrNull(value: string) {
|
||||
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
|
||||
}
|
||||
|
||||
function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
|
||||
if (node.componentKey) nc.componentKey = node.componentKey
|
||||
if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey
|
||||
const publishId = node.publishId ? parseGuidOrNull(node.publishId) : null
|
||||
const overrideKey = node.overrideKey ? parseGuidOrNull(node.overrideKey) : null
|
||||
if (publishId) nc.publishID = publishId
|
||||
if (overrideKey) nc.overrideKey = overrideKey
|
||||
if (node.sharedSymbolVersion) nc.sharedSymbolVersion = node.sharedSymbolVersion
|
||||
if (node.publishedVersion) nc.publishedVersion = node.publishedVersion
|
||||
if (node.isPublishable) nc.isPublishable = true
|
||||
if (node.isSymbolPublishable) nc.isSymbolPublishable = true
|
||||
if (node.symbolDescription) nc.symbolDescription = node.symbolDescription
|
||||
if (node.symbolLinks.length > 0) nc.symbolLinks = structuredClone(node.symbolLinks)
|
||||
const componentPropDefs = node.componentPropertyDefinitions
|
||||
.map((def) => {
|
||||
const id = parseGuidOrNull(def.id)
|
||||
return id
|
||||
? {
|
||||
id,
|
||||
name: def.name,
|
||||
type: componentPropertyTypeForKiwi(def.type),
|
||||
initialValue: componentPropertyValue(def.defaultValue)
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter((def): def is NonNullable<typeof def> => def !== null)
|
||||
if (componentPropDefs.length > 0) nc.componentPropDefs = componentPropDefs
|
||||
|
||||
const variantPropSpecs = node.variantPropSpecs
|
||||
.map((spec) => {
|
||||
const propDefId = parseGuidOrNull(spec.propDefId)
|
||||
return propDefId ? { propDefId, value: spec.value } : null
|
||||
})
|
||||
.filter((spec): spec is NonNullable<typeof spec> => spec !== null)
|
||||
if (variantPropSpecs.length > 0) nc.variantPropSpecs = variantPropSpecs
|
||||
}
|
||||
|
||||
function applyNodeVisualProps(
|
||||
context: SceneNodeToKiwiContext,
|
||||
node: SceneNode,
|
||||
nc: KiwiNodeChange
|
||||
): void {
|
||||
if (node.independentStrokeWeights) {
|
||||
nc.borderStrokeWeightsIndependent = true
|
||||
nc.borderTopWeight = node.borderTopWeight
|
||||
nc.borderRightWeight = node.borderRightWeight
|
||||
nc.borderBottomWeight = node.borderBottomWeight
|
||||
nc.borderLeftWeight = node.borderLeftWeight
|
||||
}
|
||||
|
||||
if (node.fills.length > 0) {
|
||||
nc.fillPaints = node.fills.map((fill, index) =>
|
||||
applyColorVariableBinding(
|
||||
context,
|
||||
node,
|
||||
context.fillToKiwiPaint(fill),
|
||||
`fills/${index}/color`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
context.serializeCornerRadii(node, nc)
|
||||
|
||||
if (node.effects.length > 0) {
|
||||
nc.effects = node.effects.map((effect) => ({
|
||||
type: effect.type === 'LAYER_BLUR' ? 'FOREGROUND_BLUR' : effect.type,
|
||||
color: context.safeColor(effect.color),
|
||||
offset: effect.offset,
|
||||
radius: effect.radius,
|
||||
spread: effect.spread,
|
||||
visible: effect.visible,
|
||||
showShadowBehindNode: effect.showShadowBehindNode
|
||||
}))
|
||||
}
|
||||
|
||||
if (node.type === 'TEXT') {
|
||||
context.serializeTextProps(node, nc, context.graph, context.fontDigestMap, context.blobs)
|
||||
}
|
||||
|
||||
nc.frameMaskDisabled = !node.clipsContent
|
||||
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
|
||||
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
|
||||
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
|
||||
if (node.strokeJoin !== 'MITER') nc.strokeJoin = node.strokeJoin
|
||||
if (node.strokeMiterLimit !== 28.96) nc.miterLimit = node.strokeMiterLimit
|
||||
if (node.dashPattern.length > 0) nc.dashPattern = node.dashPattern
|
||||
if (node.arcData) {
|
||||
nc.arcData = {
|
||||
startingAngle: node.arcData.startingAngle,
|
||||
endingAngle: node.arcData.endingAngle,
|
||||
innerRadius: node.arcData.innerRadius
|
||||
}
|
||||
}
|
||||
if (!node.autoRename) nc.autoRename = false
|
||||
}
|
||||
|
||||
export function sceneNodeToKiwiWithContext(
|
||||
node: SceneNode,
|
||||
parentGuid: GUID,
|
||||
childIndex: number,
|
||||
localIdCounter: { value: number },
|
||||
context: SceneNodeToKiwiContext
|
||||
): KiwiNodeChange[] {
|
||||
const localID = localIdCounter.value++
|
||||
const guid = { sessionID: 1, localID }
|
||||
context.nodeIdToGuid?.set(node.id, guid)
|
||||
|
||||
const strokePaints = createStrokePaints(context, node)
|
||||
|
||||
const nc: KiwiNodeChange = {
|
||||
guid,
|
||||
parentIndex: { guid: parentGuid, position: context.fractionalPosition(childIndex) },
|
||||
type: context.mapToFigmaType(node.type),
|
||||
name: node.name,
|
||||
visible: node.visible,
|
||||
opacity: node.opacity,
|
||||
phase: 'CREATED',
|
||||
size: { x: node.width, y: node.height },
|
||||
transform: context.computeExportTransform(node, context.graph),
|
||||
strokeWeight: node.strokes[0]?.weight ?? DEFAULT_STROKE_WEIGHT,
|
||||
strokeAlign: node.strokes[0]?.align ?? 'INSIDE'
|
||||
}
|
||||
|
||||
applyNodeVisualProps(context, node, nc)
|
||||
applyComponentMetadata(node, nc)
|
||||
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
|
||||
if (strokePaints.length > 0) nc.strokePaints = strokePaints
|
||||
|
||||
context.serializeLayoutProps(node, nc)
|
||||
context.serializeGeometry(node, nc, context.blobs)
|
||||
context.serializeVariableBindings(node, nc, context.graph, context.varIdToGuid)
|
||||
|
||||
const pluginData = mergePluginData(node.pluginData)
|
||||
if (pluginData.length > 0) nc.pluginData = pluginData
|
||||
if (node.pluginRelaunchData.length > 0) {
|
||||
nc.pluginRelaunchData = serializePluginRelaunchData(node.pluginRelaunchData)
|
||||
}
|
||||
|
||||
const result: KiwiNodeChange[] = [nc]
|
||||
const children = context.graph.getChildren(node.id).filter((child) => !child.internalOnly)
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
result.push(...context.sceneNodeToKiwi(children[i], guid, i, localIdCounter, context))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -3,3 +3,4 @@ export { ByteBuffer } from './bb'
|
|||
export { compileSchema } from './js'
|
||||
export { decodeBinarySchema, encodeBinarySchema } from './binary'
|
||||
export { parseSchema } from './parser'
|
||||
export { validateSchema, expectFieldNumber, expectEnumValue, findDefinition, findField } from './validate'
|
||||
86
packages/core/src/kiwi/schema-runtime/validate.ts
Normal file
86
packages/core/src/kiwi/schema-runtime/validate.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { Definition, Field, Schema } from './schema'
|
||||
import { error, quote } from './util'
|
||||
|
||||
export function findDefinition(schema: Schema, name: string): Definition | null {
|
||||
return schema.definitions.find((definition) => definition.name === name) ?? null
|
||||
}
|
||||
|
||||
export function findField(schema: Schema, definitionName: string, fieldName: string): Field | null {
|
||||
return findDefinition(schema, definitionName)?.fields.find((field) => field.name === fieldName) ?? null
|
||||
}
|
||||
|
||||
export function expectFieldNumber(
|
||||
schema: Schema,
|
||||
definitionName: string,
|
||||
fieldName: string,
|
||||
expectedValue: number
|
||||
): void {
|
||||
const field = findField(schema, definitionName, fieldName)
|
||||
if (!field) {
|
||||
throw new Error(`Missing field ${definitionName}.${fieldName}`)
|
||||
}
|
||||
if (field.value !== expectedValue) {
|
||||
throw new Error(
|
||||
`Expected ${definitionName}.${fieldName} to use field ${expectedValue}, got ${field.value}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function expectEnumValue(
|
||||
schema: Schema,
|
||||
enumName: string,
|
||||
memberName: string,
|
||||
expectedValue: number
|
||||
): void {
|
||||
const definition = findDefinition(schema, enumName)
|
||||
if (!definition) {
|
||||
throw new Error(`Missing enum ${enumName}`)
|
||||
}
|
||||
if (definition.kind !== 'ENUM') {
|
||||
throw new Error(`${enumName} is a ${definition.kind}, not an enum`)
|
||||
}
|
||||
const field = definition.fields.find((candidate) => candidate.name === memberName)
|
||||
if (!field) {
|
||||
throw new Error(`Missing enum member ${enumName}.${memberName}`)
|
||||
}
|
||||
if (field.value !== expectedValue) {
|
||||
throw new Error(
|
||||
`Expected ${enumName}.${memberName} to use value ${expectedValue}, got ${field.value}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSchema(schema: Schema): void {
|
||||
for (const definition of schema.definitions) {
|
||||
validateUniqueFieldNames(definition)
|
||||
if (definition.kind === 'ENUM') validateUniqueEnumValues(definition)
|
||||
}
|
||||
}
|
||||
|
||||
function validateUniqueFieldNames(definition: Definition): void {
|
||||
const fieldsByName = new Set<string>()
|
||||
for (const field of definition.fields) {
|
||||
if (fieldsByName.has(field.name)) {
|
||||
error(
|
||||
`The field ${quote(field.name)} is defined twice in ${quote(definition.name)}`,
|
||||
field.line,
|
||||
field.column
|
||||
)
|
||||
}
|
||||
fieldsByName.add(field.name)
|
||||
}
|
||||
}
|
||||
|
||||
function validateUniqueEnumValues(definition: Definition): void {
|
||||
const fieldsByValue = new Set<number>()
|
||||
for (const field of definition.fields) {
|
||||
if (fieldsByValue.has(field.value)) {
|
||||
error(
|
||||
`The enum value ${field.value} is used twice in ${quote(definition.name)}`,
|
||||
field.line,
|
||||
field.column
|
||||
)
|
||||
}
|
||||
fieldsByValue.add(field.value)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import * as HitTest from './hit-test'
|
|||
import * as Instances from './instances'
|
||||
import { CONTAINER_TYPES, createDefaultNode } from './node-defaults'
|
||||
import { updateNodePreview } from './preview'
|
||||
import { clearEditedSourceMetadata } from './source-metadata'
|
||||
import { TEXT_PICTURE_KEYS } from './text-picture'
|
||||
import * as Variables from './variables'
|
||||
import { normalizeVectorNetwork } from './vector-network'
|
||||
|
||||
|
|
@ -50,6 +52,7 @@ export class SceneGraph {
|
|||
readonly emitter: Emitter<SceneGraphEvents> = createNanoEvents()
|
||||
private absPosCache = new Map<string, Vector>()
|
||||
private previewMutationDepth = 0
|
||||
private sourceMetadataPreservationDepth = 0
|
||||
positionPreviewVersion = 0
|
||||
instanceIndex = new Map<string, Set<string>>()
|
||||
|
||||
|
|
@ -97,11 +100,6 @@ export class SceneGraph {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count all descendants of a node (children, grandchildren, etc.).
|
||||
* Used for per-page node counts in the renderer to determine if a page
|
||||
* is "large" without counting nodes on other pages.
|
||||
*/
|
||||
countDescendants(nodeId: string): number {
|
||||
const node = this.nodes.get(nodeId)
|
||||
if (!node) return 0
|
||||
|
|
@ -113,9 +111,6 @@ export class SceneGraph {
|
|||
count++
|
||||
const child = this.nodes.get(id)
|
||||
if (child) {
|
||||
// Use a for-of loop instead of spread: stack.push(...ids) places every
|
||||
// element on the call stack as function arguments and crashes V8/JSC
|
||||
// with RangeError on nodes with >~125k direct children.
|
||||
for (const childId of child.childIds) {
|
||||
stack.push(childId)
|
||||
}
|
||||
|
|
@ -289,14 +284,8 @@ export class SceneGraph {
|
|||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties that affect absolute position computation (getNodeLocalMatrix).
|
||||
* Changing any of these on a node invalidates the absPosCache for that node
|
||||
* and all its descendants. Other changes (fills, strokes, effects, plugin data)
|
||||
* do NOT affect absolute position and can skip the expensive cache clear.
|
||||
*
|
||||
* These names MUST match the actual SceneNode field names (not Figma API proxy names).
|
||||
*/
|
||||
static TEXT_PICTURE_KEYS: ReadonlySet<string> = TEXT_PICTURE_KEYS
|
||||
|
||||
static LAYOUT_AFFECTING_KEYS: ReadonlySet<string> = new Set([
|
||||
'x',
|
||||
'y',
|
||||
|
|
@ -336,24 +325,7 @@ export class SceneGraph {
|
|||
'maxHeight'
|
||||
])
|
||||
|
||||
static TEXT_PICTURE_KEYS: ReadonlySet<string> = new Set([
|
||||
'text',
|
||||
'fontSize',
|
||||
'fontFamily',
|
||||
'fontWeight',
|
||||
'italic',
|
||||
'textAlignHorizontal',
|
||||
'textDirection',
|
||||
'textAlignVertical',
|
||||
'lineHeight',
|
||||
'letterSpacing',
|
||||
'textDecoration',
|
||||
'textCase',
|
||||
'styleRuns',
|
||||
'fills',
|
||||
'width',
|
||||
'height'
|
||||
])
|
||||
|
||||
|
||||
runPreviewUpdates(fn: () => void): void {
|
||||
this.previewMutationDepth++
|
||||
|
|
@ -363,6 +335,14 @@ export class SceneGraph {
|
|||
this.previewMutationDepth--
|
||||
}
|
||||
}
|
||||
preserveSourceMetadataDuring(fn: () => void): void {
|
||||
this.sourceMetadataPreservationDepth++
|
||||
try {
|
||||
fn()
|
||||
} finally {
|
||||
this.sourceMetadataPreservationDepth--
|
||||
}
|
||||
}
|
||||
updateNodePositionPreview(id: string, x: number, y: number): void {
|
||||
this.updateNodePreview(id, { x, y })
|
||||
}
|
||||
|
|
@ -398,7 +378,7 @@ export class SceneGraph {
|
|||
}
|
||||
}
|
||||
if (node.type === 'TEXT') {
|
||||
const textChanged = Object.keys(changes).some((k) => SceneGraph.TEXT_PICTURE_KEYS.has(k))
|
||||
const textChanged = Object.keys(changes).some((k) => TEXT_PICTURE_KEYS.has(k))
|
||||
if (node.textPicture && textChanged) node.textPicture = null
|
||||
if (node.figmaDerivedTextGlyphs && 'text' in changes) node.figmaDerivedTextGlyphs = null
|
||||
}
|
||||
|
|
@ -406,6 +386,9 @@ export class SceneGraph {
|
|||
changes = Object.fromEntries(
|
||||
entries.filter(([, value]) => value !== undefined)
|
||||
) as Partial<SceneNode>
|
||||
if (this.sourceMetadataPreservationDepth === 0) {
|
||||
clearEditedSourceMetadata(node, Object.keys(changes))
|
||||
}
|
||||
if (changes.vectorNetwork) {
|
||||
changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,22 @@ export function createDefaultNode(
|
|||
width: 100,
|
||||
height: 100,
|
||||
rotation: 0,
|
||||
source: {
|
||||
format: null,
|
||||
id: null,
|
||||
orderKey: null,
|
||||
fig: {
|
||||
rawSize: null,
|
||||
rawTransform: null,
|
||||
rawNodeFields: {},
|
||||
layout: null,
|
||||
symbolOverrides: [],
|
||||
componentPropAssignments: [],
|
||||
derivedSymbolData: [],
|
||||
derivedSymbolDataLayoutVersion: null,
|
||||
uniformScaleFactor: null
|
||||
}
|
||||
},
|
||||
figmaDerivedLayout: null,
|
||||
fills:
|
||||
type === 'TEXT'
|
||||
|
|
|
|||
72
packages/core/src/scene-graph/source-metadata.ts
Normal file
72
packages/core/src/scene-graph/source-metadata.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type { SceneNode } from './types'
|
||||
|
||||
const RAW_SIZE_KEYS = new Set(['width', 'height'])
|
||||
|
||||
const RAW_TRANSFORM_KEYS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY'])
|
||||
|
||||
const RAW_NODE_FIELD_KEYS = new Set([
|
||||
'visible',
|
||||
'opacity',
|
||||
'blendMode',
|
||||
'fills',
|
||||
'strokes',
|
||||
'effects',
|
||||
'cornerRadius',
|
||||
'topLeftRadius',
|
||||
'topRightRadius',
|
||||
'bottomRightRadius',
|
||||
'bottomLeftRadius',
|
||||
'independentCorners',
|
||||
'cornerSmoothing',
|
||||
'text',
|
||||
'fontSize',
|
||||
'fontFamily',
|
||||
'fontWeight',
|
||||
'italic',
|
||||
'textAlignHorizontal',
|
||||
'textAlignVertical',
|
||||
'textAutoResize',
|
||||
'textCase',
|
||||
'textDecoration',
|
||||
'lineHeight',
|
||||
'letterSpacing',
|
||||
'maxLines',
|
||||
'styleRuns',
|
||||
'textTruncation',
|
||||
'layoutMode',
|
||||
'itemSpacing',
|
||||
'paddingTop',
|
||||
'paddingBottom',
|
||||
'paddingLeft',
|
||||
'paddingRight',
|
||||
'primaryAxisSizing',
|
||||
'counterAxisSizing',
|
||||
'primaryAxisAlign',
|
||||
'counterAxisAlign',
|
||||
'layoutWrap',
|
||||
'counterAxisSpacing',
|
||||
'layoutPositioning',
|
||||
'layoutGrow',
|
||||
'layoutAlignSelf',
|
||||
'counterAxisAlignContent',
|
||||
'itemReverseZIndex',
|
||||
'strokesIncludedInLayout',
|
||||
'layoutDirection',
|
||||
'horizontalConstraint',
|
||||
'verticalConstraint',
|
||||
'strokeCap',
|
||||
'strokeJoin',
|
||||
'strokeMiterLimit',
|
||||
'dashPattern',
|
||||
'arcData',
|
||||
'vectorNetwork',
|
||||
'fillGeometry',
|
||||
'strokeGeometry',
|
||||
'clipsContent'
|
||||
])
|
||||
|
||||
export function clearEditedSourceMetadata(node: SceneNode, changeKeys: string[]): void {
|
||||
if (changeKeys.some((key) => RAW_SIZE_KEYS.has(key))) node.source.fig.rawSize = null
|
||||
if (changeKeys.some((key) => RAW_TRANSFORM_KEYS.has(key))) node.source.fig.rawTransform = null
|
||||
if (changeKeys.some((key) => RAW_NODE_FIELD_KEYS.has(key))) node.source.fig.rawNodeFields = {}
|
||||
}
|
||||
18
packages/core/src/scene-graph/text-picture.ts
Normal file
18
packages/core/src/scene-graph/text-picture.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export const TEXT_PICTURE_KEYS: ReadonlySet<string> = new Set([
|
||||
'text',
|
||||
'fontSize',
|
||||
'fontFamily',
|
||||
'fontWeight',
|
||||
'italic',
|
||||
'textAlignHorizontal',
|
||||
'textDirection',
|
||||
'textAlignVertical',
|
||||
'lineHeight',
|
||||
'letterSpacing',
|
||||
'textDecoration',
|
||||
'textCase',
|
||||
'styleRuns',
|
||||
'fills',
|
||||
'width',
|
||||
'height'
|
||||
])
|
||||
|
|
@ -18,6 +18,25 @@ export type SceneGraphEventHandlers = Partial<{
|
|||
|
||||
export type DocumentColorSpace = 'srgb' | 'display-p3'
|
||||
|
||||
export interface FigmaSourcePayload {
|
||||
rawSize: Vector | null
|
||||
rawTransform: Matrix | null
|
||||
rawNodeFields: Record<string, unknown>
|
||||
layout: FigmaLayoutMetadata | null
|
||||
symbolOverrides: unknown[]
|
||||
componentPropAssignments: unknown[]
|
||||
derivedSymbolData: unknown[]
|
||||
derivedSymbolDataLayoutVersion: number | null
|
||||
uniformScaleFactor: number | null
|
||||
}
|
||||
|
||||
export interface SourceMetadata {
|
||||
format: 'fig' | null
|
||||
id: string | null
|
||||
orderKey: string | null
|
||||
fig: FigmaSourcePayload
|
||||
}
|
||||
|
||||
export type HandleMirroring = 'NONE' | 'ANGLE' | 'ANGLE_AND_LENGTH'
|
||||
export type WindingRule = 'NONZERO' | 'EVENODD'
|
||||
|
||||
|
|
@ -229,6 +248,34 @@ export interface VariantPropSpec {
|
|||
value: string
|
||||
}
|
||||
|
||||
export type FigmaLayoutMetadata = Partial<
|
||||
Record<
|
||||
| 'stackMode'
|
||||
| 'stackCounterAlign'
|
||||
| 'stackJustify'
|
||||
| 'stackCounterAlignItems'
|
||||
| 'stackPrimaryAlignItems'
|
||||
| 'stackPrimarySizing'
|
||||
| 'stackCounterSizing'
|
||||
| 'stackWrap'
|
||||
| 'stackPositioning'
|
||||
| 'stackChildAlignSelf',
|
||||
string
|
||||
> &
|
||||
Record<
|
||||
| 'stackSpacing'
|
||||
| 'stackPadding'
|
||||
| 'stackPaddingRight'
|
||||
| 'stackPaddingBottom'
|
||||
| 'stackVerticalPadding'
|
||||
| 'stackHorizontalPadding'
|
||||
| 'stackChildPrimaryGrow'
|
||||
| 'stackCounterSpacing',
|
||||
number
|
||||
> &
|
||||
Record<'bordersTakeSpace', boolean>
|
||||
>
|
||||
|
||||
export interface SceneNode {
|
||||
id: string
|
||||
type: NodeType
|
||||
|
|
@ -241,6 +288,7 @@ export interface SceneNode {
|
|||
width: number
|
||||
height: number
|
||||
rotation: number
|
||||
source: SourceMetadata
|
||||
figmaDerivedLayout: Partial<Rect> | null
|
||||
|
||||
fills: Fill[]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
|
||||
|
||||
import type { NodeChange } from '#core/kiwi/binary/codec'
|
||||
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||
import type { SceneNode } from '#core/scene-graph'
|
||||
|
||||
import { encodePathCommandsBlob } from '#core/kiwi/node-change/path-commands'
|
||||
import { encodePathCommandsBlob } from '#core/kiwi/fig/node-change/path-commands'
|
||||
|
||||
import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts'
|
||||
import { type GlyphOutlineMetrics, getGlyphOutlineMetricsSync } from '#core/text/opentype'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NodeChange } from '#core/kiwi/binary/codec'
|
||||
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||
import type { SceneNode } from '#core/scene-graph'
|
||||
|
||||
interface DerivedTextDataOptions {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"lib": ["ESNext", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowArbitraryExtensions": true,
|
||||
"types": ["node", "bun"],
|
||||
|
||||
"strict": true,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { defineConfig } from 'tsdown'
|
||||
import type { Plugin } from 'rolldown'
|
||||
|
||||
function rawMd(): Plugin {
|
||||
const packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
function rawText(): Plugin {
|
||||
return {
|
||||
name: 'raw-md',
|
||||
name: 'raw-text',
|
||||
load(id) {
|
||||
if (id.endsWith('?raw')) {
|
||||
const path = id.slice(0, -'?raw'.length)
|
||||
return `export default ${JSON.stringify(readFileSync(path, 'utf8'))}`
|
||||
}
|
||||
},
|
||||
transform(code, id) {
|
||||
if (id.endsWith('.md')) {
|
||||
return { code: `export default ${JSON.stringify(code)}`, map: null }
|
||||
|
|
@ -14,7 +26,7 @@ function rawMd(): Plugin {
|
|||
|
||||
export default defineConfig({
|
||||
entry: ['src/**/*.ts', '!src/**/*.d.ts'],
|
||||
plugins: [rawMd()],
|
||||
plugins: [rawText()],
|
||||
unbundle: true,
|
||||
platform: 'neutral',
|
||||
format: ['esm'],
|
||||
|
|
@ -23,22 +35,7 @@ export default defineConfig({
|
|||
clean: true,
|
||||
outDir: './dist',
|
||||
deps: {
|
||||
neverBundle: [
|
||||
'@iconify/utils',
|
||||
'canvaskit-wasm',
|
||||
'culori',
|
||||
'diff',
|
||||
'expr-eval',
|
||||
'fflate',
|
||||
'fontoxpath',
|
||||
'fzstd',
|
||||
'nanoevents',
|
||||
'opentype.js',
|
||||
'sucrase',
|
||||
'svgpath',
|
||||
'yoga-layout',
|
||||
/^node:/
|
||||
],
|
||||
neverBundle: [...Object.keys(packageJson.dependencies ?? {}), /^node:/],
|
||||
onlyBundle: false
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ open-pencil tree # Live-Dokument
|
|||
open-pencil export -f png # Canvas-Screenshot
|
||||
```
|
||||
|
||||
Alle Befehle unterstützen `--json`. Installation: `bun add -g @open-pencil/cli`
|
||||
Alle Befehle unterstützen `--json`. Installation: `npm install -g @open-pencil/cli`
|
||||
|
||||
## Echtzeit-Kollaboration
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Das CLI ermöglicht es, `.fig`-Dateien zu erkunden, ohne den Editor zu öffnen.
|
|||
|
||||
::: tip Installation
|
||||
```sh
|
||||
bun add -g @open-pencil/cli
|
||||
npm install -g @open-pencil/cli
|
||||
# oder
|
||||
brew install open-pencil/tap/open-pencil
|
||||
```
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ open-pencil tree # Documento en vivo
|
|||
open-pencil export -f png # Captura del canvas
|
||||
```
|
||||
|
||||
Todos los comandos soportan `--json`. Instalar: `bun add -g @open-pencil/cli`
|
||||
Todos los comandos soportan `--json`. Instalar: `npm install -g @open-pencil/cli`
|
||||
|
||||
## Colaboración en tiempo real
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ El CLI te permite explorar archivos `.fig` sin abrir el editor. Cada comando tam
|
|||
|
||||
::: tip Instalar
|
||||
```sh
|
||||
bun add -g @open-pencil/cli
|
||||
npm install -g @open-pencil/cli
|
||||
# o
|
||||
brew install open-pencil/tap/open-pencil
|
||||
```
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ open-pencil tree # Document en direct
|
|||
open-pencil export -f png # Capture du canevas
|
||||
```
|
||||
|
||||
Toutes les commandes supportent `--json`. Installation : `bun add -g @open-pencil/cli`
|
||||
Toutes les commandes supportent `--json`. Installation : `npm install -g @open-pencil/cli`
|
||||
|
||||
## Collaboration en temps réel
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Le CLI vous permet d'explorer des fichiers `.fig` sans ouvrir l'éditeur. Chaque
|
|||
|
||||
::: tip Installation
|
||||
```sh
|
||||
bun add -g @open-pencil/cli
|
||||
npm install -g @open-pencil/cli
|
||||
# ou
|
||||
brew install open-pencil/tap/open-pencil
|
||||
```
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ open-pencil tree # Live document
|
|||
open-pencil export -f png # Screenshot canvas
|
||||
```
|
||||
|
||||
All commands support `--json`. Install: `bun add -g @open-pencil/cli`
|
||||
All commands support `--json`. Install: `npm install -g @open-pencil/cli` (or `bun add -g @open-pencil/cli`).
|
||||
|
||||
## Real-Time Collaboration
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue