Add clipboard text v4 export (#164)
* chore: remove unused pi context file - Delete .pi/context.md - Remove an unused local context artifact from the repository * Add clipboard text v4 export * fix(clipboard): rebase v4 export branch onto master - Resolve conflicts after the core text module reorganization - Fix clipboard text export imports and bundled font asset loading - Update raw binary expectations for textUserLayoutVersion 4 - Re-run check and unit tests after the rebase
This commit is contained in:
parent
825d3bbe49
commit
28272ebda0
110
.pi/context.md
110
.pi/context.md
|
|
@ -1,110 +0,0 @@
|
|||
# Code Context
|
||||
|
||||
## Files Retrieved
|
||||
1. `packages/core/src/tools/analyze.ts` (lines 1-391) - Full file: analyze tools, diff tools, eval tool
|
||||
2. `packages/core/src/tools/vector.ts` (lines 1-264) - Full file: boolean ops, path ops, viewport, SVG/image export
|
||||
3. `packages/core/src/tools/ai-adapter.ts` (lines 1-96) - Full file: Vercel AI SDK adapter
|
||||
4. `packages/core/src/tools/read.ts` (lines 1-183) - Full file: query/read tools
|
||||
5. `packages/core/src/tools/registry.ts` (lines 1-100) - Full file: ALL_TOOLS array
|
||||
|
||||
## analyze.ts — Tool Definitions
|
||||
|
||||
| Line | Tool Name |
|
||||
|------|-----------|
|
||||
| 128 | `analyze_colors` |
|
||||
| 197 | `analyze_typography` |
|
||||
| 251 | `analyze_spacing` |
|
||||
| 295 | `analyze_clusters` |
|
||||
| 356 | `diff_create` (under "Diff tools" section) |
|
||||
| 398 | `diff_show` |
|
||||
| 449 | `eval` |
|
||||
|
||||
**No `describe` tool exists** in analyze.ts or anywhere in the tools directory.
|
||||
|
||||
## read.ts — Tool Names
|
||||
|
||||
| Tool Name | Description |
|
||||
|-----------|-------------|
|
||||
| `get_selection` | Currently selected nodes |
|
||||
| `get_page_tree` | Node tree of current page |
|
||||
| `get_node` | Detailed properties by ID |
|
||||
| `find_nodes` | Find by name/type |
|
||||
| `get_components` | List components |
|
||||
| `list_pages` | List all pages |
|
||||
| `switch_page` | Switch page by name/ID |
|
||||
| `get_current_page` | Current page name/ID |
|
||||
| `page_bounds` | Bounding box of all page objects |
|
||||
| `select_nodes` | Select nodes by ID |
|
||||
| `list_fonts` | Fonts used on current page |
|
||||
|
||||
**Neither `get_jsx` nor `diff_jsx` exist** in read.ts or any other tool file.
|
||||
|
||||
## vector.ts — exportImage Tool (line ~225)
|
||||
|
||||
- **Default format:** `'PNG'`
|
||||
- **Default scale:** `1` (min: 0.1, max: 4)
|
||||
- **Base64 conversion:** Dual-path:
|
||||
- Node.js: `Buffer.from(data).toString('base64')`
|
||||
- Browser: `btoa(String.fromCharCode(...data))`
|
||||
- Returns: `{ mimeType, base64, byteLength }`
|
||||
- Falls back to error if `figma.exportImage` is unavailable
|
||||
|
||||
## ai-adapter.ts — Full Contents
|
||||
|
||||
- **`AIAdapterOptions`** interface: `getFigma()`, `onBeforeExecute?`, `onAfterExecute?`, `onFlashNodes?`
|
||||
- **`toolsToAI()`** function: Converts `ToolDef[]` → Vercel AI SDK `tool()` objects
|
||||
- Accepts deps: `{ v: valibot, valibotSchema, tool }` (lazy imports)
|
||||
- Builds valibot schemas from `ParamDef` type declarations
|
||||
- Wraps each `execute` with before/after hooks and error catching
|
||||
- If tool `mutates` and returns node IDs, calls `onFlashNodes` for visual feedback
|
||||
- **`paramToValibot()`**: Maps param types → valibot schemas
|
||||
- `string` → `v.string()` or `v.picklist()` if enum
|
||||
- `number` → `v.number()` with optional `minValue`/`maxValue` pipes
|
||||
- `boolean` → `v.boolean()`
|
||||
- `color` → `v.string()` with description
|
||||
- `string[]` → `v.array(v.string())` with `minLength(1)`
|
||||
- Non-required params wrapped in `v.optional()`
|
||||
- **`extractNodeIds()`**: Extracts IDs from result objects (handles `id`, `selection[]`, `results[]`)
|
||||
|
||||
## registry.ts — ALL_TOOLS Array (84 tools total)
|
||||
|
||||
**Read (11):** get_selection, get_page_tree, get_node, find_nodes, get_components, list_pages, switch_page, get_current_page, page_bounds, select_nodes, list_fonts
|
||||
|
||||
**Create (7):** createShape, render, createComponent, createInstance, createPage, createVector, createSlice
|
||||
|
||||
**Modify (20):** setFill, setStroke, setEffects, updateNode, setLayout, setConstraints, setRotation, setOpacity, setRadius, setMinMax, setText, setFont, setFontRange, setTextResize, setVisible, setBlend, setLocked, setStrokeAlign, setTextProperties, setLayoutChild
|
||||
|
||||
**Structure (17):** deleteNode, cloneNode, renameNode, reparentNode, groupNodes, ungroupNode, flattenNodes, nodeToComponent, nodeBounds, nodeMove, nodeResize, nodeAncestors, nodeChildren, nodeTree, nodeBindings, nodeReplaceWith, arrangeNodes
|
||||
|
||||
**Variables (11):** listVariables, listCollections, getVariable, findVariables, createVariable, setVariable, deleteVariable, bindVariable, getCollection, createCollection, deleteCollection
|
||||
|
||||
**Vector & Export (14):** booleanUnion, booleanSubtract, booleanIntersect, booleanExclude, pathGet, pathSet, pathScale, pathFlip, pathMove, viewportGet, viewportSet, viewportZoomToFit, exportSvg, exportImage
|
||||
|
||||
**Analyze & Diff (6):** analyzeColors, analyzeTypography, analyzeSpacing, analyzeClusters, diffCreate, diffShow
|
||||
|
||||
**Eval (1):** evalCode
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ToolDef (schema.ts) -- type-safe tool definition with params + execute
|
||||
├── read.ts -- query tools (selection, find, pages, fonts)
|
||||
├── create.ts -- shape/component/page creation
|
||||
├── modify.ts -- property setters
|
||||
├── structure.ts -- tree operations
|
||||
├── variables.ts -- variable/collection CRUD
|
||||
├── vector.ts -- boolean ops, paths, viewport, export
|
||||
└── analyze.ts -- analysis, diff, eval
|
||||
│
|
||||
registry.ts -- assembles ALL_TOOLS array
|
||||
│
|
||||
ai-adapter.ts -- toolsToAI() → Vercel AI SDK tool() objects
|
||||
(valibot schemas, before/after hooks, flash nodes)
|
||||
```
|
||||
|
||||
Each `defineTool()` creates a `ToolDef` with typed params. The `registry.ts` collects all into `ALL_TOOLS[]`. The `ai-adapter.ts` converts them to Vercel AI SDK format with valibot validation schemas, injecting `FigmaAPI` at execution time via `getFigma()`.
|
||||
|
||||
## Start Here
|
||||
|
||||
- **`packages/core/src/tools/registry.ts`** — the central manifest of all 84 tools, organized by domain. Shows the full tool surface and imports from all domain files.
|
||||
- **`packages/core/src/tools/ai-adapter.ts`** — the bridge between tool definitions and the AI chat system. Key to understanding how tools are exposed to LLMs.
|
||||
10
bun.lock
10
bun.lock
|
|
@ -33,6 +33,7 @@
|
|||
"fzstd": "^0.1.1",
|
||||
"lib0": "^0.2.117",
|
||||
"motion-v": "^2.0.0",
|
||||
"opentype.js": "^1.3.4",
|
||||
"prismjs": "^1.30.0",
|
||||
"reka-ui": "^2.9.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
|
@ -59,6 +60,7 @@
|
|||
"@tauri-apps/cli": "^2",
|
||||
"@types/culori": "^4.0.1",
|
||||
"@types/node": "^25.3.2",
|
||||
"@types/opentype.js": "^1.3.9",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
|
||||
|
|
@ -1071,6 +1073,8 @@
|
|||
|
||||
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@types/opentype.js": ["@types/opentype.js@1.3.9", "", {}, "sha512-KOGywvDPncA4/tTWV5xKNhjpsoSSAHIx3mHOhL5l3XX+c6Xu2dQnHvGs7mRNQsQRte1EqmQ0cPQQ8Z14lkv+yw=="],
|
||||
|
||||
"@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
|
||||
|
||||
"@types/readable-stream": ["@types/readable-stream@4.0.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig=="],
|
||||
|
|
@ -2021,6 +2025,8 @@
|
|||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
|
||||
|
||||
"opentype.js": ["opentype.js@1.3.4", "", { "dependencies": { "string.prototype.codepointat": "^0.2.1", "tiny-inflate": "^1.0.3" }, "bin": { "ot": "bin/ot" } }, "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
|
||||
"oxfmt": ["oxfmt@0.35.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.35.0", "@oxfmt/binding-android-arm64": "0.35.0", "@oxfmt/binding-darwin-arm64": "0.35.0", "@oxfmt/binding-darwin-x64": "0.35.0", "@oxfmt/binding-freebsd-x64": "0.35.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.35.0", "@oxfmt/binding-linux-arm-musleabihf": "0.35.0", "@oxfmt/binding-linux-arm64-gnu": "0.35.0", "@oxfmt/binding-linux-arm64-musl": "0.35.0", "@oxfmt/binding-linux-ppc64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-musl": "0.35.0", "@oxfmt/binding-linux-s390x-gnu": "0.35.0", "@oxfmt/binding-linux-x64-gnu": "0.35.0", "@oxfmt/binding-linux-x64-musl": "0.35.0", "@oxfmt/binding-openharmony-arm64": "0.35.0", "@oxfmt/binding-win32-arm64-msvc": "0.35.0", "@oxfmt/binding-win32-ia32-msvc": "0.35.0", "@oxfmt/binding-win32-x64-msvc": "0.35.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q=="],
|
||||
|
|
@ -2279,6 +2285,8 @@
|
|||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="],
|
||||
|
||||
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
|
||||
|
||||
"string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="],
|
||||
|
|
@ -2331,6 +2339,8 @@
|
|||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
"fzstd": "^0.1.1",
|
||||
"lib0": "^0.2.117",
|
||||
"motion-v": "^2.0.0",
|
||||
"opentype.js": "^1.3.4",
|
||||
"prismjs": "^1.30.0",
|
||||
"reka-ui": "^2.9.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
|
@ -84,6 +85,7 @@
|
|||
"@tauri-apps/cli": "^2",
|
||||
"@types/culori": "^4.0.1",
|
||||
"@types/node": "^25.3.2",
|
||||
"@types/opentype.js": "^1.3.9",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { resolveRGBAForPreview } from '../color/management'
|
||||
import { getCanvasKit } from '../canvaskit'
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '../constants'
|
||||
import { resolveNodeTextDirection } from '../text/direction'
|
||||
import { getArabicFallbackFamilies, getCJKFallbackFamilies, isFontLoaded } from '../text/fonts'
|
||||
import {
|
||||
getArabicFallbackFamilies,
|
||||
getCJKFallbackFamilies,
|
||||
getFontProvider,
|
||||
isFontLoaded
|
||||
} from '../text/fonts'
|
||||
|
||||
import type { SceneNode } from '../scene-graph'
|
||||
import type { CanvasKit, FontWeight, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm'
|
||||
|
|
@ -12,6 +18,23 @@ interface TextRenderer {
|
|||
fontsLoaded: boolean
|
||||
}
|
||||
|
||||
export interface ClipboardShapedGlyph {
|
||||
glyphIndex: number
|
||||
firstCharacter: number
|
||||
x: number
|
||||
y: number
|
||||
advance: number
|
||||
}
|
||||
|
||||
export interface ClipboardShapedText {
|
||||
lineHeight: number
|
||||
lineAscent: number
|
||||
lineWidth: number
|
||||
baseline: number
|
||||
glyphs: ClipboardShapedGlyph[]
|
||||
logicalIndexToCharacterOffsetMap: number[]
|
||||
}
|
||||
|
||||
export function isNodeFontLoaded(_r: TextRenderer, node: SceneNode): boolean {
|
||||
const families = new Set<string>()
|
||||
families.add(node.fontFamily || DEFAULT_FONT_FAMILY)
|
||||
|
|
@ -224,3 +247,60 @@ export function buildParagraph(
|
|||
builder.delete()
|
||||
return paragraph
|
||||
}
|
||||
|
||||
export async function shapeTextForClipboard(node: SceneNode): Promise<ClipboardShapedText | null> {
|
||||
const ck = await getCanvasKit()
|
||||
const fontProvider = getFontProvider()
|
||||
if (!fontProvider) return null
|
||||
|
||||
const paragraph = buildParagraph({ ck, fontProvider, fontsLoaded: true }, node)
|
||||
const shapedLines = paragraph.getShapedLines()
|
||||
const lineMetrics = paragraph.getLineMetrics()
|
||||
const firstLine = shapedLines[0]
|
||||
const firstMetrics = lineMetrics[0]
|
||||
|
||||
const glyphs: ClipboardShapedGlyph[] = []
|
||||
const logicalIndexToCharacterOffsetMap = Array.from({ length: node.text.length + 1 }, () => 0)
|
||||
|
||||
for (const run of firstLine.runs) {
|
||||
const positions = run.positions
|
||||
for (let i = 0; i < run.glyphs.length; i++) {
|
||||
const x = positions[i * 2] ?? 0
|
||||
const y = positions[i * 2 + 1] ?? firstLine.baseline
|
||||
const nextX = positions[(i + 1) * 2] ?? x
|
||||
const firstCharacter = run.offsets[i] ?? i
|
||||
glyphs.push({
|
||||
glyphIndex: i,
|
||||
firstCharacter,
|
||||
x,
|
||||
y,
|
||||
advance: nextX - x
|
||||
})
|
||||
if (firstCharacter >= 0 && firstCharacter < logicalIndexToCharacterOffsetMap.length) {
|
||||
logicalIndexToCharacterOffsetMap[firstCharacter] = x
|
||||
}
|
||||
}
|
||||
const finalOffset = run.offsets[run.offsets.length - 1]
|
||||
const finalX = positions[positions.length - 2] ?? firstMetrics.width
|
||||
if (finalOffset >= 0 && finalOffset < logicalIndexToCharacterOffsetMap.length) {
|
||||
logicalIndexToCharacterOffsetMap[finalOffset] = finalX
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 1; i < logicalIndexToCharacterOffsetMap.length; i++) {
|
||||
if (logicalIndexToCharacterOffsetMap[i] === 0) {
|
||||
logicalIndexToCharacterOffsetMap[i] = logicalIndexToCharacterOffsetMap[i - 1]
|
||||
}
|
||||
}
|
||||
|
||||
paragraph.delete()
|
||||
|
||||
return {
|
||||
lineHeight: firstMetrics.height,
|
||||
lineAscent: Math.abs(firstMetrics.ascent),
|
||||
lineWidth: firstMetrics.width,
|
||||
baseline: firstMetrics.baseline,
|
||||
glyphs,
|
||||
logicalIndexToCharacterOffsetMap
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
packages/core/src/clipboard-text-outlines.ts
Normal file
98
packages/core/src/clipboard-text-outlines.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import * as OpenTypeSync from 'opentype.js'
|
||||
|
||||
import { getLoadedFontData } from './text/fonts'
|
||||
|
||||
interface OutlineCommand {
|
||||
type: string
|
||||
x?: number
|
||||
y?: number
|
||||
x1?: number
|
||||
y1?: number
|
||||
x2?: number
|
||||
y2?: number
|
||||
}
|
||||
|
||||
interface OutlinePath {
|
||||
commands: OutlineCommand[]
|
||||
}
|
||||
|
||||
interface OutlineGlyph {
|
||||
path: OutlinePath
|
||||
getPath(x: number, y: number, fontSize: number): OutlinePath
|
||||
}
|
||||
|
||||
interface OutlineFont {
|
||||
unitsPerEm: number
|
||||
stringToGlyphs(text: string): OutlineGlyph[]
|
||||
}
|
||||
|
||||
export interface GlyphOutlineProbe {
|
||||
family: string
|
||||
style: string
|
||||
unitsPerEm: number
|
||||
commandCount: number
|
||||
firstGlyphCommandSample: OutlineCommand[]
|
||||
}
|
||||
|
||||
interface OpenTypeModule {
|
||||
parse(buffer: ArrayBuffer): OutlineFont
|
||||
}
|
||||
|
||||
let openTypeModulePromise: Promise<OpenTypeModule> | null = null
|
||||
|
||||
async function loadOpenTypeModule() {
|
||||
openTypeModulePromise ??= import('opentype.js') as Promise<typeof OpenTypeSync & OpenTypeModule>
|
||||
return openTypeModulePromise
|
||||
}
|
||||
|
||||
function commandsToFigmaNumbers(commands: OutlineCommand[]): Array<string | number> {
|
||||
const result: Array<string | number> = []
|
||||
for (const command of commands) {
|
||||
result.push(command.type)
|
||||
if (command.x1 !== undefined) result.push(command.x1)
|
||||
if (command.y1 !== undefined) result.push(command.y1)
|
||||
if (command.x2 !== undefined) result.push(command.x2)
|
||||
if (command.y2 !== undefined) result.push(command.y2)
|
||||
if (command.x !== undefined) result.push(command.x)
|
||||
if (command.y !== undefined) result.push(command.y)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function getGlyphOutlineCommandsSync(
|
||||
family: string,
|
||||
style: string,
|
||||
text: string,
|
||||
fontSize: number
|
||||
): Array<Array<string | number>> | null {
|
||||
const bytes = getLoadedFontData(family, style)
|
||||
if (!bytes) return null
|
||||
|
||||
const font = (OpenTypeSync as OpenTypeModule).parse(bytes.slice(0))
|
||||
const glyphs = font.stringToGlyphs(text)
|
||||
return glyphs.map((glyph) => commandsToFigmaNumbers(glyph.getPath(0, 0, fontSize).commands))
|
||||
}
|
||||
|
||||
export async function probeGlyphOutlineCommands(
|
||||
family: string,
|
||||
style: string,
|
||||
text: string,
|
||||
fontSize: number
|
||||
): Promise<GlyphOutlineProbe | null> {
|
||||
const bytes = getLoadedFontData(family, style)
|
||||
if (!bytes) return null
|
||||
|
||||
const openType = await loadOpenTypeModule()
|
||||
const font = openType.parse(bytes.slice(0))
|
||||
const glyphs = font.stringToGlyphs(text)
|
||||
const firstGlyph = glyphs.find((glyph: OutlineGlyph) => glyph.path.commands.length > 0)
|
||||
const firstGlyphCommandSample = (firstGlyph?.getPath(0, 0, fontSize).commands ?? []).slice(0, 12)
|
||||
|
||||
return {
|
||||
family,
|
||||
style,
|
||||
unitsPerEm: font.unitsPerEm,
|
||||
commandCount: firstGlyph?.path.commands.length ?? 0,
|
||||
firstGlyphCommandSample
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,10 @@ import {
|
|||
parseFigKiwiChunks,
|
||||
decompressFigKiwiDataAsync,
|
||||
makeDocumentNodeChange,
|
||||
makeCanvasNodeChange
|
||||
makeCanvasNodeChange,
|
||||
buildFontDigestMap
|
||||
} from './kiwi/serialize'
|
||||
import { buildDerivedTextDataV4 } from './kiwi/serialize-clipboard-v4'
|
||||
import { randomInt } from './random'
|
||||
|
||||
import type { NodeChange as KiwiNodeChange } from './kiwi/codec'
|
||||
|
|
@ -285,9 +287,13 @@ export function importClipboardNodes(
|
|||
return createdIds
|
||||
}
|
||||
|
||||
export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph): string | null {
|
||||
export async function buildFigmaClipboardHTML(
|
||||
nodes: SceneNode[],
|
||||
graph: SceneGraph
|
||||
): Promise<string | null> {
|
||||
const compiled = getCompiledSchema()
|
||||
const schemaDeflated = deflateSync(getSchemaBytes())
|
||||
const fontDigestMap = await buildFontDigestMap(graph)
|
||||
|
||||
const docGuid = { sessionID: 0, localID: 0 }
|
||||
const canvasGuid = { sessionID: 0, localID: 1 }
|
||||
|
|
@ -298,11 +304,34 @@ export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph):
|
|||
makeCanvasNodeChange(canvasGuid, docGuid, '!', 'Page 1')
|
||||
]
|
||||
|
||||
const exportedTextNodes: SceneNode[] = []
|
||||
const collectTextNodes = (node: SceneNode) => {
|
||||
if (node.type === 'TEXT') exportedTextNodes.push(node)
|
||||
for (const childId of node.childIds) {
|
||||
const child = graph.getNode(childId)
|
||||
if (child) collectTextNodes(child)
|
||||
}
|
||||
}
|
||||
|
||||
const blobs: Uint8Array[] = []
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
nodeChanges.push(...sceneNodeToKiwi(nodes[i], canvasGuid, i, localIdCounter, graph, blobs))
|
||||
collectTextNodes(nodes[i])
|
||||
nodeChanges.push(
|
||||
...sceneNodeToKiwi(nodes[i], canvasGuid, i, localIdCounter, graph, blobs, undefined, fontDigestMap)
|
||||
)
|
||||
}
|
||||
|
||||
const textNodeQueue = [...exportedTextNodes]
|
||||
await Promise.all(
|
||||
nodeChanges.map(async (change) => {
|
||||
if (change.type !== 'TEXT') return
|
||||
const source = textNodeQueue.shift()
|
||||
if (!source) return
|
||||
change.textUserLayoutVersion = 4
|
||||
change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap)
|
||||
})
|
||||
)
|
||||
|
||||
const msg: Record<string, unknown> = {
|
||||
type: 'NODE_CHANGES',
|
||||
sessionID: 0,
|
||||
|
|
|
|||
|
|
@ -116,11 +116,11 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
}
|
||||
}
|
||||
|
||||
function writeCopyData(clipboardData: DataTransfer, selectedNodes: SceneNode[]) {
|
||||
async function writeCopyData(clipboardData: DataTransfer, selectedNodes: SceneNode[]) {
|
||||
if (selectedNodes.length === 0) return
|
||||
|
||||
const names = selectedNodes.map((n) => n.name).join('\n')
|
||||
const html = buildFigmaClipboardHTML(selectedNodes, ctx.graph)
|
||||
const html = await buildFigmaClipboardHTML(selectedNodes, ctx.graph)
|
||||
if (html) clipboardData.setData('text/html', html)
|
||||
clipboardData.setData('text/plain', names)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,10 +289,12 @@ export {
|
|||
parseFigKiwiChunks,
|
||||
decompressFigKiwiData,
|
||||
decompressFigKiwiDataAsync,
|
||||
buildFontDigestMap,
|
||||
sceneNodeToKiwi,
|
||||
fractionalPosition,
|
||||
mapToFigmaType
|
||||
} from './kiwi/serialize'
|
||||
export { buildDerivedTextDataV4 } from './kiwi/serialize-clipboard-v4'
|
||||
|
||||
export {
|
||||
createElement,
|
||||
|
|
@ -339,6 +341,7 @@ export {
|
|||
type TextPictureBuilder,
|
||||
type OpenPencilClipboardData
|
||||
} from './clipboard'
|
||||
export { probeGlyphOutlineCommands, type GlyphOutlineProbe } from './clipboard-text-outlines'
|
||||
|
||||
export { readPenFile, parsePenFile } from './io/formats/pen'
|
||||
|
||||
|
|
|
|||
|
|
@ -320,6 +320,22 @@ export interface NodeChange {
|
|||
}
|
||||
derivedTextData?: {
|
||||
layoutSize?: Vector
|
||||
baselines?: Array<{
|
||||
firstCharacter: number
|
||||
endCharacter: number
|
||||
position: Vector
|
||||
width: number
|
||||
lineHeight: number
|
||||
lineAscent: number
|
||||
}>
|
||||
glyphs?: Array<{
|
||||
commands: Array<string | number>
|
||||
position: Vector
|
||||
fontSize: number
|
||||
firstCharacter: number
|
||||
advance: number
|
||||
rotation: number
|
||||
}>
|
||||
fontMetaData?: Array<{
|
||||
key: { family: string; style: string; postscript?: string }
|
||||
fontLineHeight: number
|
||||
|
|
@ -327,6 +343,8 @@ export interface NodeChange {
|
|||
fontStyle?: string
|
||||
fontWeight?: number
|
||||
}>
|
||||
logicalIndexToCharacterOffsetMap?: number[]
|
||||
derivedLines?: Array<{ directionality: 'LTR' | 'RTL' }>
|
||||
truncationStartIndex?: number
|
||||
truncatedHeight?: number
|
||||
}
|
||||
|
|
|
|||
75
packages/core/src/kiwi/serialize-clipboard-v4.ts
Normal file
75
packages/core/src/kiwi/serialize-clipboard-v4.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { getGlyphOutlineCommandsSync } from '../clipboard-text-outlines'
|
||||
import { normalizeFontFamily, weightToStyle } from '../text/fonts'
|
||||
|
||||
import type { SceneNode } from '../scene-graph'
|
||||
import type { NodeChange } from './codec'
|
||||
|
||||
export interface ShapedClipboardText {
|
||||
lineHeight: number
|
||||
lineAscent: number
|
||||
lineWidth: number
|
||||
baseline: number
|
||||
glyphs: Array<{
|
||||
firstCharacter: number
|
||||
x: number
|
||||
y: number
|
||||
advance: number
|
||||
}>
|
||||
logicalIndexToCharacterOffsetMap: number[]
|
||||
}
|
||||
|
||||
export async function buildDerivedTextDataV4(
|
||||
node: SceneNode,
|
||||
digestMap: Map<string, Uint8Array>,
|
||||
shaped?: ShapedClipboardText | null
|
||||
): Promise<NodeChange['derivedTextData']> {
|
||||
const style = weightToStyle(node.fontWeight, node.italic)
|
||||
const normalizedFamily = normalizeFontFamily(node.fontFamily)
|
||||
const key = `${normalizedFamily}|${style}`
|
||||
const lineHeightFallback = node.lineHeight ?? Math.ceil(node.fontSize * 1.2)
|
||||
const glyphCommandLists = getGlyphOutlineCommandsSync(node.fontFamily, style, node.text, node.fontSize) ?? []
|
||||
|
||||
const glyphs = glyphCommandLists.map((commands, index) => {
|
||||
const shapedGlyph = shaped?.glyphs[index]
|
||||
return {
|
||||
commands,
|
||||
position: {
|
||||
x: shapedGlyph?.x ?? 0,
|
||||
y: shapedGlyph?.y ?? (shaped?.baseline ?? lineHeightFallback)
|
||||
},
|
||||
fontSize: node.fontSize,
|
||||
firstCharacter: shapedGlyph?.firstCharacter ?? index,
|
||||
advance: shapedGlyph?.advance ?? 0,
|
||||
rotation: 0
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
layoutSize: { x: node.width, y: node.height },
|
||||
baselines: [
|
||||
{
|
||||
firstCharacter: 0,
|
||||
endCharacter: Math.max(node.text.length - 1, 0),
|
||||
position: { x: 0, y: shaped?.baseline ?? lineHeightFallback },
|
||||
width: shaped?.lineWidth ?? node.width,
|
||||
lineHeight: shaped?.lineHeight ?? lineHeightFallback,
|
||||
lineAscent: shaped?.lineAscent ?? Math.max(lineHeightFallback - (node.fontSize * 0.2), 0)
|
||||
}
|
||||
],
|
||||
glyphs,
|
||||
fontMetaData: [
|
||||
{
|
||||
key: { family: normalizedFamily, style, postscript: '' },
|
||||
fontLineHeight: 1.2,
|
||||
fontDigest: digestMap.get(key),
|
||||
fontStyle: node.italic ? 'ITALIC' : 'NORMAL',
|
||||
fontWeight: node.fontWeight
|
||||
}
|
||||
],
|
||||
logicalIndexToCharacterOffsetMap:
|
||||
shaped?.logicalIndexToCharacterOffsetMap ?? Array.from({ length: node.text.length + 1 }, () => 0),
|
||||
derivedLines: [{ directionality: 'LTR' }],
|
||||
truncationStartIndex: -1,
|
||||
truncatedHeight: -1
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ export const FIG_KIWI_DEFAULT_VERSION = 101
|
|||
|
||||
import { deflateSync, inflateSync } from 'fflate'
|
||||
|
||||
import { getGlyphOutlineCommandsSync } from '../clipboard-text-outlines'
|
||||
import { getLoadedFontData, normalizeFontFamily, weightToStyle } from '../text/fonts'
|
||||
import { encodeVectorNetworkBlob, buildStyleOverrideTable } from '../vector'
|
||||
import { stringToGuid, VARIABLE_BINDING_FIELDS } from './convert'
|
||||
|
|
@ -206,9 +207,42 @@ function buildDerivedTextData(
|
|||
)
|
||||
}
|
||||
|
||||
const style = weightToStyle(node.fontWeight, node.italic)
|
||||
const glyphCommandLists = getGlyphOutlineCommandsSync(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 = glyphCommandLists.map((commands, index) => ({
|
||||
commands,
|
||||
position: { x: index * glyphAdvance, y: lineHeight },
|
||||
fontSize: node.fontSize,
|
||||
firstCharacter: index,
|
||||
advance: glyphAdvance,
|
||||
rotation: 0
|
||||
}))
|
||||
|
||||
const logicalIndexToCharacterOffsetMap = Array.from({ length: node.text.length + 1 }, (_, index) =>
|
||||
index * glyphAdvance
|
||||
)
|
||||
|
||||
return {
|
||||
layoutSize: { x: node.width, y: node.height },
|
||||
fontMetaData: fontMeta
|
||||
baselines: [
|
||||
{
|
||||
firstCharacter: 0,
|
||||
endCharacter: Math.max(node.text.length - 1, 0),
|
||||
position: { x: 0, y: lineHeight },
|
||||
width: node.width,
|
||||
lineHeight,
|
||||
lineAscent: Math.max(lineHeight - (node.fontSize * 0.2), 0)
|
||||
}
|
||||
],
|
||||
glyphs,
|
||||
fontMetaData: fontMeta,
|
||||
logicalIndexToCharacterOffsetMap,
|
||||
derivedLines: [{ directionality: 'LTR' }],
|
||||
truncationStartIndex: -1,
|
||||
truncatedHeight: -1
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -341,7 +375,7 @@ function serializeTextProps(
|
|||
const autoResize = resolveTextAutoResize(node, graph)
|
||||
if (autoResize !== 'NONE') nc.textAutoResize = autoResize
|
||||
nc.textAlignHorizontal = node.textAlignHorizontal
|
||||
nc.textUserLayoutVersion = 3
|
||||
nc.textUserLayoutVersion = 4
|
||||
if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap)
|
||||
// Figma needs explicit lineHeight to compute text bounding boxes.
|
||||
// Without it (and without baselines/glyphs data), text gets 0 height.
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ export async function fetchBundledFont(url: string): Promise<ArrayBuffer | null>
|
|||
}
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const { fileURLToPath } = await import('node:url')
|
||||
const assetPath = fileURLToPath(new URL(`../assets${url}`, import.meta.url))
|
||||
const assetPath = fileURLToPath(new URL(`../../assets${url}`, import.meta.url))
|
||||
const buf = await readFile(assetPath)
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
|
||||
}
|
||||
|
|
|
|||
27
packages/core/src/types-opentype.d.ts
vendored
Normal file
27
packages/core/src/types-opentype.d.ts
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
declare module 'opentype.js' {
|
||||
export interface OpenTypePathCommand {
|
||||
type: string
|
||||
x?: number
|
||||
y?: number
|
||||
x1?: number
|
||||
y1?: number
|
||||
x2?: number
|
||||
y2?: number
|
||||
}
|
||||
|
||||
export interface OpenTypeGlyphPath {
|
||||
commands: OpenTypePathCommand[]
|
||||
}
|
||||
|
||||
export interface OpenTypeGlyph {
|
||||
path: OpenTypeGlyphPath
|
||||
getPath(x: number, y: number, fontSize: number): OpenTypeGlyphPath
|
||||
}
|
||||
|
||||
export interface OpenTypeFont {
|
||||
unitsPerEm: number
|
||||
stringToGlyphs(text: string): OpenTypeGlyph[]
|
||||
}
|
||||
|
||||
export function parse(buffer: ArrayBuffer): OpenTypeFont
|
||||
}
|
||||
|
|
@ -65,13 +65,13 @@ export function useKeyboard() {
|
|||
useEventListener(window, 'copy', (e: ClipboardEvent) => {
|
||||
if (isEditing(e)) return
|
||||
e.preventDefault()
|
||||
if (e.clipboardData) store.writeCopyData(e.clipboardData)
|
||||
if (e.clipboardData) void store.writeCopyData(e.clipboardData)
|
||||
})
|
||||
|
||||
useEventListener(window, 'cut', (e: ClipboardEvent) => {
|
||||
if (isEditing(e)) return
|
||||
e.preventDefault()
|
||||
if (e.clipboardData) store.writeCopyData(e.clipboardData)
|
||||
if (e.clipboardData) void store.writeCopyData(e.clipboardData)
|
||||
store.deleteSelected()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1186,14 +1186,14 @@ export function createEditorStore(initialGraph?: SceneGraph) {
|
|||
|
||||
// ─── Mobile clipboard ─────────────────────────────────────────
|
||||
|
||||
function mobileCopy() {
|
||||
async function mobileCopy() {
|
||||
const transfer = new DataTransfer()
|
||||
editor.writeCopyData(transfer)
|
||||
await editor.writeCopyData(transfer)
|
||||
state.clipboardHtml = transfer.getData('text/html')
|
||||
}
|
||||
|
||||
function mobileCut() {
|
||||
mobileCopy()
|
||||
async function mobileCut() {
|
||||
await mobileCopy()
|
||||
editor.deleteSelected()
|
||||
}
|
||||
|
||||
|
|
|
|||
64
tests/engine/clipboard-derived-text.test.ts
Normal file
64
tests/engine/clipboard-derived-text.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
fetchBundledFont,
|
||||
markFontLoaded,
|
||||
SceneGraph,
|
||||
buildFontDigestMap,
|
||||
buildDerivedTextDataV4,
|
||||
initCodec
|
||||
} from '@open-pencil/core'
|
||||
|
||||
describe('clipboard derived text export', () => {
|
||||
test('builds richer v4 derivedTextData from shaped text + glyph outlines', async () => {
|
||||
await initCodec()
|
||||
|
||||
const font = await fetchBundledFont('/Inter-Regular.ttf')
|
||||
expect(font).toBeTruthy()
|
||||
if (!font) return
|
||||
markFontLoaded('Inter', 'Regular', font)
|
||||
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const text = graph.createNode('TEXT', page.id, {
|
||||
name: 'Hello',
|
||||
text: 'Hello',
|
||||
width: 120,
|
||||
height: 24,
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: 400
|
||||
})
|
||||
|
||||
const fontDigestMap = await buildFontDigestMap(graph)
|
||||
const derived = await buildDerivedTextDataV4(text, fontDigestMap, {
|
||||
lineHeight: 20,
|
||||
lineAscent: 15,
|
||||
lineWidth: 42,
|
||||
baseline: 16,
|
||||
glyphs: [
|
||||
{ firstCharacter: 0, x: 0, y: 16, advance: 8 },
|
||||
{ firstCharacter: 1, x: 8, y: 16, advance: 8 },
|
||||
{ firstCharacter: 2, x: 16, y: 16, advance: 8 },
|
||||
{ firstCharacter: 3, x: 24, y: 16, advance: 8 },
|
||||
{ firstCharacter: 4, x: 32, y: 16, advance: 10 }
|
||||
],
|
||||
logicalIndexToCharacterOffsetMap: [0, 8, 16, 24, 32, 42]
|
||||
})
|
||||
|
||||
expect(derived?.fontMetaData?.length).toBeGreaterThan(0)
|
||||
expect(derived?.glyphs?.length).toBeGreaterThan(0)
|
||||
expect(derived?.baselines?.length).toBeGreaterThan(0)
|
||||
expect(derived?.logicalIndexToCharacterOffsetMap?.length).toBe(text.text.length + 1)
|
||||
expect(derived?.logicalIndexToCharacterOffsetMap?.[5]).toBe(42)
|
||||
expect(derived?.derivedLines?.[0]?.directionality).toBe('LTR')
|
||||
expect(derived?.truncationStartIndex).toBe(-1)
|
||||
expect(derived?.truncatedHeight).toBe(-1)
|
||||
expect(derived?.glyphs?.[0]?.commands.length).toBeGreaterThan(0)
|
||||
expect(derived?.glyphs?.[0]?.position.x).toBe(0)
|
||||
expect(derived?.glyphs?.[4]?.position.x).toBe(32)
|
||||
expect(derived?.baselines?.[0]?.lineHeight).toBe(20)
|
||||
expect(derived?.baselines?.[0]?.lineAscent).toBe(15)
|
||||
expect(derived?.baselines?.[0]?.width).toBe(42)
|
||||
})
|
||||
})
|
||||
21
tests/engine/clipboard-text-outlines.test.ts
Normal file
21
tests/engine/clipboard-text-outlines.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { fetchBundledFont, markFontLoaded, probeGlyphOutlineCommands } from '@open-pencil/core'
|
||||
|
||||
describe('clipboard text outline probe', () => {
|
||||
test('lazy-loads opentype.js and extracts glyph commands from a loaded font', async () => {
|
||||
const font = await fetchBundledFont('/Inter-Regular.ttf')
|
||||
expect(font).toBeTruthy()
|
||||
if (!font) return
|
||||
|
||||
markFontLoaded('Inter', 'Regular', font)
|
||||
|
||||
const probe = await probeGlyphOutlineCommands('Inter', 'Regular', 'Hello', 16)
|
||||
expect(probe).toBeTruthy()
|
||||
expect(probe?.family).toBe('Inter')
|
||||
expect(probe?.style).toBe('Regular')
|
||||
expect(probe?.unitsPerEm).toBeGreaterThan(0)
|
||||
expect(probe?.commandCount).toBeGreaterThan(0)
|
||||
expect(probe?.firstGlyphCommandSample.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -598,7 +598,7 @@ describe('buildFigmaClipboardHTML', () => {
|
|||
await initCodec()
|
||||
})
|
||||
|
||||
it('encodes a simple frame without throwing', () => {
|
||||
it('encodes a simple frame without throwing', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, {
|
||||
|
|
@ -607,12 +607,12 @@ describe('buildFigmaClipboardHTML', () => {
|
|||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
||||
})
|
||||
|
||||
const html = buildFigmaClipboardHTML([frame], graph)
|
||||
const html = await buildFigmaClipboardHTML([frame], graph)
|
||||
expect(html).toContain('figmeta')
|
||||
expect(html).toContain('figma')
|
||||
})
|
||||
|
||||
it('encodes text nodes with style runs', () => {
|
||||
it('encodes text nodes with style runs', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const text = graph.createNode('TEXT', page.id, {
|
||||
|
|
@ -628,11 +628,17 @@ describe('buildFigmaClipboardHTML', () => {
|
|||
],
|
||||
})
|
||||
|
||||
const html = buildFigmaClipboardHTML([text], graph)
|
||||
const html = await buildFigmaClipboardHTML([text], graph)
|
||||
expect(html).toContain('figmeta')
|
||||
|
||||
const parsed = await parseFigmaClipboard(html)
|
||||
const textNode = parsed?.nodes.find((node) => node.type === 'TEXT')
|
||||
expect(textNode?.textUserLayoutVersion).toBe(4)
|
||||
expect(textNode?.derivedTextData?.glyphs).toBeDefined()
|
||||
expect(textNode?.derivedTextData?.baselines?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('encodes auto-layout frames', () => {
|
||||
it('encodes auto-layout frames', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, {
|
||||
|
|
@ -649,7 +655,7 @@ describe('buildFigmaClipboardHTML', () => {
|
|||
x: 0, y: 0, width: 50, height: 50,
|
||||
})
|
||||
|
||||
const html = buildFigmaClipboardHTML([frame], graph)
|
||||
const html = await buildFigmaClipboardHTML([frame], graph)
|
||||
expect(html).toContain('figmeta')
|
||||
})
|
||||
|
||||
|
|
@ -682,7 +688,7 @@ describe('buildFigmaClipboardHTML', () => {
|
|||
fontSize: 14,
|
||||
})
|
||||
|
||||
const html = buildFigmaClipboardHTML([frame], graph)
|
||||
const html = await buildFigmaClipboardHTML([frame], graph)
|
||||
expect(html).not.toBeNull()
|
||||
|
||||
const parsed = await parseFigmaClipboard(html!)
|
||||
|
|
@ -767,7 +773,7 @@ describe('gold-preview.fig clipboard roundtrip', () => {
|
|||
})
|
||||
|
||||
it('Figma format: preserves node count, clipsContent, constraints, arcData, layoutAlignSelf', async () => {
|
||||
const html = buildFigmaClipboardHTML(topLevelNodes, graph)
|
||||
const html = await buildFigmaClipboardHTML(topLevelNodes, graph)
|
||||
expect(html).not.toBeNull()
|
||||
|
||||
const parsed = await parseFigmaClipboard(html!)
|
||||
|
|
|
|||
|
|
@ -946,7 +946,7 @@ describe('text node export', () => {
|
|||
expect(textNc.textData.lines.length).toBeGreaterThanOrEqual(1)
|
||||
expect(textNc.textData.lines[0].lineType).toBe('PLAIN')
|
||||
|
||||
expect(textNc.textUserLayoutVersion).toBe(3)
|
||||
expect(textNc.textUserLayoutVersion).toBe(4)
|
||||
|
||||
expect(textNc.derivedTextData).toBeDefined()
|
||||
expect(textNc.derivedTextData.layoutSize).toBeDefined()
|
||||
|
|
|
|||
Loading…
Reference in a new issue