Refactor: vendor kiwi-schema, extract types/constants, culori colors

- Fork kiwi-schema from source TypeScript (evanw/kiwi), drop npm dep + patch
- Deduplicate GUID/Color types into src/types.ts
- Extract UI colors and default fills into src/constants.ts
- Replace hand-rolled hex parsing with culori library
- Extract demo shapes from App.vue into src/demo.ts
- useUrlSearchParams from VueUse for test mode detection
- Move fig-file/fig-import into kiwi/ (format layer)
- Delete PoC files (poc-yoga.ts, poc-test.ts)
This commit is contained in:
Danila Poyarkov 2026-02-28 08:08:04 +03:00
parent e03a01dbe3
commit f0d817c747
32 changed files with 1195 additions and 594 deletions

View file

@ -10,9 +10,9 @@
"@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.2.1",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"kiwi-schema": "^0.5.0",
"reka-ui": "^2.8.2",
"tailwindcss": "^4.2.1",
"vue": "^3.5.29",
@ -30,9 +30,6 @@
},
},
},
"patchedDependencies": {
"kiwi-schema@0.5.0": "patches/kiwi-schema@0.5.0.patch",
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
@ -364,6 +361,8 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@ -388,8 +387,6 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"kiwi-schema": ["kiwi-schema@0.5.0", "", { "bin": { "kiwic": "cli.js" } }, "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],

View file

@ -24,9 +24,9 @@
"@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.2.1",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"kiwi-schema": "^0.5.0",
"reka-ui": "^2.8.2",
"tailwindcss": "^4.2.1",
"vue": "^3.5.29",
@ -41,8 +41,5 @@
"oxlint": "^1.50.0",
"typescript": "~5.8.3",
"vite": "^7.0.4"
},
"patchedDependencies": {
"kiwi-schema@0.5.0": "patches/kiwi-schema@0.5.0.patch"
}
}

View file

@ -1,42 +0,0 @@
diff --git a/node_modules/kiwi-schema/.bun-tag-962afbd98e4e08f9 b/.bun-tag-962afbd98e4e08f9
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/cli.js b/cli.js
index a729eb694b4dfd87302228d6e0fd3ff1cb75266e..67f33fb533e0dfd5176057d26288b7c324d441fb 100755
--- a/cli.js
+++ b/cli.js
@@ -2078,7 +2078,7 @@ function verify(root) {
if (field.value <= 0) {
error("The id for field " + quote(field.name) + " must be positive", field.line, field.column);
}
- if (field.value > fields.length) {
+ if (false && field.value > fields.length) {
error("The id for field " + quote(field.name) + " cannot be larger than " + fields.length, field.line, field.column);
}
values.push(field.value);
diff --git a/kiwi-esm.js b/kiwi-esm.js
index f0f3f8b950abd8150e360511efcfdb10ea019bb9..b57c433a77444a38072364ab24c4e967734645f1 100644
--- a/kiwi-esm.js
+++ b/kiwi-esm.js
@@ -2130,7 +2130,7 @@ function verify(root) {
if (field.value <= 0) {
error("The id for field " + quote(field.name) + " must be positive", field.line, field.column);
}
- if (field.value > fields.length) {
+ if (false && field.value > fields.length) {
error("The id for field " + quote(field.name) + " cannot be larger than " + fields.length, field.line, field.column);
}
values.push(field.value);
diff --git a/kiwi.js b/kiwi.js
index 26a46b6a2efbbdb9623a5275751eaaa10378e617..5a57b99fd2fd5fe087fc1c556672c58aa63b57ec 100644
--- a/kiwi.js
+++ b/kiwi.js
@@ -2167,7 +2167,7 @@ function verify(root) {
if (field.value <= 0) {
error("The id for field " + quote(field.name) + " must be positive", field.line, field.column);
}
- if (field.value > fields.length) {
+ if (false && field.value > fields.length) {
error("The id for field " + quote(field.name) + " cannot be larger than " + fields.length, field.line, field.column);
}
values.push(field.value);

View file

@ -1,7 +1,8 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useEventListener, useUrlSearchParams } from '@vueuse/core'
import { useKeyboard } from './composables/use-keyboard'
import { createDemoShapes } from './demo'
import { provideEditorStore } from './stores/editor'
import EditorCanvas from './components/EditorCanvas.vue'
@ -16,76 +17,9 @@ useEventListener(document, 'wheel', (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) e.preventDefault()
}, { passive: false })
const isTestMode = new URLSearchParams(window.location.search).has('test')
// Demo shapes
if (!isTestMode) {
store.createShape('FRAME', 100, 80, 800, 500)
store.graph.updateNode(store.graph.getChildren(store.graph.rootId)[0].id, {
name: 'Desktop',
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
strokes: [
{
color: { r: 0.87, g: 0.87, b: 0.87, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'INSIDE'
}
]
})
}
const demo = [
{
type: 'RECTANGLE' as const,
name: 'Blue card',
x: 150,
y: 140,
w: 240,
h: 160,
color: { r: 0.23, g: 0.51, b: 0.96, a: 1 },
radius: 12
},
{
type: 'ELLIPSE' as const,
name: 'Green circle',
x: 440,
y: 160,
w: 120,
h: 120,
color: { r: 0.13, g: 0.77, b: 0.42, a: 1 }
},
{
type: 'RECTANGLE' as const,
name: 'Orange rect',
x: 620,
y: 140,
w: 200,
h: 100,
color: { r: 0.96, g: 0.52, b: 0.13, a: 1 },
radius: 8
},
{
type: 'RECTANGLE' as const,
name: 'Purple pill',
x: 150,
y: 360,
w: 300,
h: 56,
color: { r: 0.55, g: 0.36, b: 0.96, a: 1 },
radius: 28
}
]
if (!isTestMode)
for (const d of demo) {
const id = store.createShape(d.type, d.x, d.y, d.w, d.h)
store.graph.updateNode(id, {
name: d.name,
cornerRadius: d.radius ?? 0,
fills: [{ type: 'SOLID', color: d.color, opacity: 1, visible: true }]
})
const params = useUrlSearchParams('history')
if (!('test' in params)) {
createDemoShapes(store)
}
</script>

View file

@ -2,7 +2,9 @@
import { ref, computed, watch } from 'vue'
import { PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent } from 'reka-ui'
import type { Color } from '../engine/scene-graph'
import { colorToHex, parseColor } from '../engine/color'
import type { Color } from '../types'
const props = defineProps<{
color: Color
@ -72,21 +74,14 @@ function emitColor() {
}
const hexValue = computed(() => {
const hex = (v: number) =>
Math.round(v * 255)
.toString(16)
.padStart(2, '0')
return `${hex(props.color.r)}${hex(props.color.g)}${hex(props.color.b)}`
return colorToHex(props.color).slice(1)
})
function onHexInput(e: Event) {
const input = (e.target as HTMLInputElement).value.replace('#', '')
if (input.length !== 6) return
const r = parseInt(input.slice(0, 2), 16) / 255
const g = parseInt(input.slice(2, 4), 16) / 255
const b = parseInt(input.slice(4, 6), 16) / 255
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return
emit('update', { r, g, b, a: alpha.value })
const parsed = parseColor(`#${input}`)
emit('update', { ...parsed, a: alpha.value })
}
const svAreaRef = ref<HTMLDivElement | null>(null)

View file

@ -6,6 +6,8 @@ import ColorPicker from './ColorPicker.vue'
import ScrubInput from './ScrubInput.vue'
import { useEditorStore } from '../stores/editor'
import { DEFAULT_SHAPE_FILL } from '../constants'
import type { Color, Fill, Stroke, LayoutSizing, LayoutAlign, LayoutCounterAlign, SceneNode } from '../engine/scene-graph'
const store = useEditorStore()
@ -155,13 +157,7 @@ function updateFillColor(index: number, color: Color) {
function addFill() {
if (!node.value) return
const fill: Fill = {
type: 'SOLID',
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
opacity: 1,
visible: true
}
store.updateNodeWithUndo(node.value.id, { fills: [...node.value.fills, fill] }, "Add fill")
store.updateNodeWithUndo(node.value.id, { fills: [...node.value.fills, { ...DEFAULT_SHAPE_FILL }] }, "Add fill")
}
function removeFill(index: number) {

25
src/constants.ts Normal file
View file

@ -0,0 +1,25 @@
import type { Color } from './types'
import type { Fill } from './engine/scene-graph'
export const SELECTION_COLOR = { r: 0.23, g: 0.51, b: 0.96, a: 1 } satisfies Color
export const SNAP_COLOR = { r: 1.0, g: 0.0, b: 0.56, a: 1 } satisfies Color
export const CANVAS_BG_COLOR = { r: 0.96, g: 0.96, b: 0.96, a: 1 } satisfies Color
export const DEFAULT_SHAPE_FILL: Fill = {
type: 'SOLID',
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
opacity: 1,
visible: true
}
export const DEFAULT_FRAME_FILL: Fill = {
type: 'SOLID',
color: { r: 1, g: 1, b: 1, a: 1 },
opacity: 1,
visible: true
}
export const HANDLE_SIZE = 6
export const ROTATION_HANDLE_OFFSET = 20
export const SNAP_THRESHOLD = 5
export const DRAG_DEAD_ZONE = 4

57
src/demo.ts Normal file
View file

@ -0,0 +1,57 @@
import type { EditorStore } from './stores/editor'
export function createDemoShapes(store: EditorStore) {
store.createShape('FRAME', 100, 80, 800, 500)
store.graph.updateNode(store.graph.getChildren(store.graph.rootId)[0].id, {
name: 'Desktop',
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
strokes: [
{
color: { r: 0.87, g: 0.87, b: 0.87, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'INSIDE'
}
]
})
const shapes = [
{
type: 'RECTANGLE' as const,
name: 'Blue card',
x: 150, y: 140, w: 240, h: 160,
color: { r: 0.23, g: 0.51, b: 0.96, a: 1 },
radius: 12
},
{
type: 'ELLIPSE' as const,
name: 'Green circle',
x: 440, y: 160, w: 120, h: 120,
color: { r: 0.13, g: 0.77, b: 0.42, a: 1 }
},
{
type: 'RECTANGLE' as const,
name: 'Orange rect',
x: 620, y: 140, w: 200, h: 100,
color: { r: 0.96, g: 0.52, b: 0.13, a: 1 },
radius: 8
},
{
type: 'RECTANGLE' as const,
name: 'Purple pill',
x: 150, y: 360, w: 300, h: 56,
color: { r: 0.55, g: 0.36, b: 0.96, a: 1 },
radius: 28
}
]
for (const d of shapes) {
const id = store.createShape(d.type, d.x, d.y, d.w, d.h)
store.graph.updateNode(id, {
name: d.name,
cornerRadius: d.radius ?? 0,
fills: [{ type: 'SOLID', color: d.color, opacity: 1, visible: true }]
})
}
}

View file

@ -1,4 +1,4 @@
import { decodeBinarySchema, compileSchema, ByteBuffer } from 'kiwi-schema'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '../kiwi/kiwi-schema'
import { inflateSync, deflateSync } from 'fflate'
import type { SceneGraph, SceneNode, Fill, Stroke, Color, LayoutMode, LayoutSizing, LayoutAlign, LayoutCounterAlign, VectorNetwork } from './scene-graph'

31
src/engine/color.ts Normal file
View file

@ -0,0 +1,31 @@
import { parse, formatHex, converter } from 'culori'
import type { Color } from '../types'
const toRgb = converter('rgb')
export function parseColor(input: string): Color {
const parsed = parse(input)
if (!parsed) return { r: 0, g: 0, b: 0, a: 1 }
const rgb = toRgb(parsed)
return {
r: rgb?.r ?? 0,
g: rgb?.g ?? 0,
b: rgb?.b ?? 0,
a: parsed.alpha ?? 1
}
}
export function colorToHex(color: Color): string {
return (formatHex({ mode: 'rgb', r: color.r, g: color.g, b: color.b }) ?? '#000000').toUpperCase()
}
export function colorToFill(color: string | Color) {
const rgba = typeof color === 'string' ? parseColor(color) : color
return {
type: 'SOLID' as const,
color: { r: rgba.r, g: rgba.g, b: rgba.b, a: rgba.a },
opacity: rgba.a,
visible: true
}
}

View file

@ -1,199 +0,0 @@
/**
* PoC 2: Yoga WASM Layout Engine
*
* Validates:
* 1. yoga-layout WASM loads and initializes
* 2. Flex layout (auto-layout) matches Figma's behavior
* 3. Nested frames with padding, gap, alignment
* 4. Performance: layout 1000 nodes
*/
import Yoga, {
Align,
Direction,
FlexDirection,
Gutter,
Justify,
Edge,
Wrap,
type Node as YogaNode
} from 'yoga-layout'
function runTest() {
console.log('=== PoC 2: Yoga WASM Layout ===\n')
// 1. Basic flex layout (Figma auto-layout: vertical, gap=16, padding=24)
{
const root = Yoga.Node.create()
root.setWidth(400)
root.setHeight('auto')
root.setFlexDirection(FlexDirection.Column)
root.setPadding(Edge.All, 24)
root.setGap(Gutter.All, 16)
const child1 = Yoga.Node.create()
child1.setWidth('100%')
child1.setHeight(48)
const child2 = Yoga.Node.create()
child2.setWidth('100%')
child2.setHeight(120)
const child3 = Yoga.Node.create()
child3.setWidth('100%')
child3.setHeight(48)
root.insertChild(child1, 0)
root.insertChild(child2, 1)
root.insertChild(child3, 2)
root.calculateLayout(undefined, undefined, Direction.LTR)
console.log('Test 1: Vertical auto-layout (gap=16, padding=24)')
console.log(` Root: ${root.getComputedWidth()}×${root.getComputedHeight()}`)
// Expected: height = 24 + 48 + 16 + 120 + 16 + 48 + 24 = 296
const expectedHeight = 24 + 48 + 16 + 120 + 16 + 48 + 24
const actualHeight = root.getComputedHeight()
console.log(
` Expected height: ${expectedHeight}, Actual: ${actualHeight} ${actualHeight === expectedHeight ? '✅' : '❌'}`
)
console.log(` Child 1: y=${child1.getComputedTop()}, h=${child1.getComputedHeight()}`)
console.log(` Child 2: y=${child2.getComputedTop()}, h=${child2.getComputedHeight()}`)
console.log(` Child 3: y=${child3.getComputedTop()}, h=${child3.getComputedHeight()}`)
// Verify positions
const y1 = child1.getComputedTop() // Expected: 24
const y2 = child2.getComputedTop() // Expected: 24 + 48 + 16 = 88
const y3 = child3.getComputedTop() // Expected: 88 + 120 + 16 = 224
console.log(` Positions: ${y1 === 24 && y2 === 88 && y3 === 224 ? '✅' : '❌'}`)
root.free()
child1.free()
child2.free()
child3.free()
}
// 2. Horizontal auto-layout with space-between (Figma: distribute)
{
const root = Yoga.Node.create()
root.setWidth(600)
root.setHeight(80)
root.setFlexDirection(FlexDirection.Row)
root.setJustifyContent(Justify.SpaceBetween)
root.setAlignItems(Align.Center)
root.setPadding(Edge.Horizontal, 16)
for (let i = 0; i < 4; i++) {
const child = Yoga.Node.create()
child.setWidth(100)
child.setHeight(48)
root.insertChild(child, i)
}
root.calculateLayout(undefined, undefined, Direction.LTR)
console.log('\nTest 2: Horizontal space-between')
const positions: string[] = []
for (let i = 0; i < 4; i++) {
const child = root.getChild(i)
positions.push(`x=${child.getComputedLeft()} y=${child.getComputedTop()}`)
}
console.log(` Children: ${positions.join(' | ')}`)
// Items should be centered vertically (y = (80-48)/2 = 16)
const centeredCorrectly = root.getChild(0).getComputedTop() === 16
console.log(` Vertical centering: ${centeredCorrectly ? '✅' : '❌'}`)
// Space-between with padding: available = 600-32 = 568, items = 4*100 = 400, gaps = 168/3 = 56
const gap = root.getChild(1).getComputedLeft() - root.getChild(0).getComputedLeft() - 100
console.log(` Gap between items: ${gap}px ${Math.abs(gap - 56) < 1 ? '✅' : '❌'}`)
for (let i = root.getChildCount() - 1; i >= 0; i--) root.getChild(i).free()
root.free()
}
// 3. Nested frames (card with header + content)
{
const card = Yoga.Node.create()
card.setWidth(320)
card.setFlexDirection(FlexDirection.Column)
card.setPadding(Edge.All, 0)
const header = Yoga.Node.create()
header.setWidth('100%')
header.setHeight(56)
header.setFlexDirection(FlexDirection.Row)
header.setAlignItems(Align.Center)
header.setPadding(Edge.Horizontal, 16)
header.setGap(Gutter.All, 12)
const avatar = Yoga.Node.create()
avatar.setWidth(32)
avatar.setHeight(32)
const title = Yoga.Node.create()
title.setFlexGrow(1)
title.setHeight(20)
header.insertChild(avatar, 0)
header.insertChild(title, 1)
const content = Yoga.Node.create()
content.setWidth('100%')
content.setHeight(200)
card.insertChild(header, 0)
card.insertChild(content, 1)
card.calculateLayout(undefined, undefined, Direction.LTR)
console.log('\nTest 3: Nested card layout')
console.log(` Card: ${card.getComputedWidth()}×${card.getComputedHeight()}`)
console.log(` Header: y=${header.getComputedTop()}, h=${header.getComputedHeight()}`)
console.log(
` Avatar: x=${avatar.getComputedLeft()}, y=${avatar.getComputedTop()}, centered=${avatar.getComputedTop() === 12 ? '✅' : '❌'}`
)
console.log(
` Title: x=${title.getComputedLeft()}, w=${title.getComputedWidth()}, grows=${title.getComputedWidth() === 320 - 16 - 32 - 12 - 16 ? '✅' : '❌'}`
)
console.log(` Content: y=${content.getComputedTop()}, h=${content.getComputedHeight()}`)
avatar.free()
title.free()
header.free()
content.free()
card.free()
}
// 4. Performance: layout 1000 nodes
{
const root = Yoga.Node.create()
root.setWidth(1200)
root.setFlexDirection(FlexDirection.Row)
root.setFlexWrap(Wrap.Wrap) // Wrap
root.setGap(Gutter.All, 8)
root.setPadding(Edge.All, 16)
const nodes: YogaNode[] = [root]
for (let i = 0; i < 1000; i++) {
const child = Yoga.Node.create()
child.setWidth(80)
child.setHeight(80)
root.insertChild(child, i)
nodes.push(child)
}
const t0 = performance.now()
root.calculateLayout(undefined, undefined, Direction.LTR)
const layoutTime = performance.now() - t0
console.log(`\nTest 4: Performance (1000 nodes)`)
console.log(` Layout time: ${layoutTime.toFixed(2)}ms ${layoutTime < 50 ? '✅' : '⚠️'}`)
console.log(` Root height: ${root.getComputedHeight()}px`)
for (const n of nodes) n.free()
}
console.log('\n✅ PoC 2 PASSED: Yoga WASM layout works correctly')
}
runTest()

View file

@ -1,3 +1,4 @@
import { SELECTION_COLOR, SNAP_COLOR, CANVAS_BG_COLOR, HANDLE_SIZE, ROTATION_HANDLE_OFFSET } from '../constants'
import type { SceneNode, SceneGraph, Fill } from './scene-graph'
import type { SnapGuide } from './snap'
import { vectorNetworkToPath } from './vector'
@ -52,6 +53,10 @@ export class SkiaRenderer {
zoom = 1
dpr = 1
private selColor(alpha = 1) {
return this.ck.Color4f(SELECTION_COLOR.r, SELECTION_COLOR.g, SELECTION_COLOR.b, alpha)
}
constructor(ck: CanvasKit, surface: Surface) {
this.ck = ck
this.surface = surface
@ -67,20 +72,20 @@ export class SkiaRenderer {
this.selectionPaint = new ck.Paint()
this.selectionPaint.setStyle(ck.PaintStyle.Stroke)
this.selectionPaint.setStrokeWidth(1)
this.selectionPaint.setColor(ck.Color4f(0.23, 0.51, 0.96, 1.0))
this.selectionPaint.setColor(this.selColor())
this.selectionPaint.setAntiAlias(true)
this.parentOutlinePaint = new ck.Paint()
this.parentOutlinePaint.setStyle(ck.PaintStyle.Stroke)
this.parentOutlinePaint.setStrokeWidth(1)
this.parentOutlinePaint.setColor(ck.Color4f(0.23, 0.51, 0.96, 0.5))
this.parentOutlinePaint.setColor(this.selColor(0.5))
this.parentOutlinePaint.setAntiAlias(true)
this.parentOutlinePaint.setPathEffect(ck.PathEffect.MakeDash([4, 4], 0))
this.snapPaint = new ck.Paint()
this.snapPaint.setStyle(ck.PaintStyle.Stroke)
this.snapPaint.setStrokeWidth(1)
this.snapPaint.setColor(ck.Color4f(1.0, 0.0, 0.56, 1.0))
this.snapPaint.setColor(this.ck.Color4f(SNAP_COLOR.r, SNAP_COLOR.g, SNAP_COLOR.b, 1))
this.snapPaint.setAntiAlias(true)
this.textFont = new ck.Font(null, 14)
@ -109,7 +114,7 @@ export class SkiaRenderer {
render(graph: SceneGraph, selectedIds: Set<string>, overlays: RenderOverlays = {}): void {
const canvas = this.surface.getCanvas()
canvas.clear(this.ck.Color4f(0.96, 0.96, 0.96, 1.0))
canvas.clear(this.ck.Color4f(CANVAS_BG_COLOR.r, CANVAS_BG_COLOR.g, CANVAS_BG_COLOR.b, 1))
// Scene layer (world coordinates)
canvas.save()
@ -225,7 +230,7 @@ export class SkiaRenderer {
const rotLinePaint = new this.ck.Paint()
rotLinePaint.setStyle(this.ck.PaintStyle.Stroke)
rotLinePaint.setStrokeWidth(1)
rotLinePaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
rotLinePaint.setColor(this.this.selColor())
rotLinePaint.setAntiAlias(true)
canvas.drawLine(mx, y1, mx, rotHandleY, rotLinePaint)
@ -280,7 +285,7 @@ export class SkiaRenderer {
if (node.type === 'FRAME' && node.parentId === graph.rootId) {
const labelPaint = new this.ck.Paint()
labelPaint.setStyle(this.ck.PaintStyle.Fill)
labelPaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
labelPaint.setColor(this.this.selColor())
labelPaint.setAntiAlias(true)
canvas.drawText(node.name, sx1, sy1 - 8, labelPaint, this.labelFont)
labelPaint.delete()
@ -302,7 +307,7 @@ export class SkiaRenderer {
const pillPaint = new this.ck.Paint()
pillPaint.setStyle(this.ck.PaintStyle.Fill)
pillPaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
pillPaint.setColor(this.this.selColor())
pillPaint.setAntiAlias(true)
const rrect = this.ck.RRectXY(this.ck.LTRBRect(pillX, pillY, pillX + pillW, pillY + pillH), 4, 4)
@ -405,7 +410,7 @@ export class SkiaRenderer {
const dashPaint = new this.ck.Paint()
dashPaint.setStyle(this.ck.PaintStyle.Stroke)
dashPaint.setStrokeWidth(1)
dashPaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.6))
dashPaint.setColor(this.selColor(0.6))
dashPaint.setAntiAlias(true)
canvas.drawRect(this.ck.LTRBRect(minX, minY, maxX, maxY), dashPaint)
@ -490,7 +495,7 @@ export class SkiaRenderer {
const fill = new this.ck.Paint()
fill.setStyle(this.ck.PaintStyle.Fill)
fill.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.08))
fill.setColor(this.selColor(0.08))
canvas.drawRect(rect, fill)
canvas.drawRect(rect, this.selectionPaint)
fill.delete()
@ -507,7 +512,7 @@ export class SkiaRenderer {
const paint = new this.ck.Paint()
paint.setStyle(this.ck.PaintStyle.Stroke)
paint.setStrokeWidth(2)
paint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
paint.setColor(this.this.selColor())
paint.setAntiAlias(true)
if (indicator.direction === 'HORIZONTAL') {
@ -560,7 +565,7 @@ export class SkiaRenderer {
const highlight = new this.ck.Paint()
highlight.setStyle(this.ck.PaintStyle.Stroke)
highlight.setStrokeWidth(2 / this.zoom)
highlight.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.8))
highlight.setColor(this.selColor(0.8))
highlight.setAntiAlias(true)
canvas.drawRect(this.ck.LTRBRect(0, 0, node.width, node.height), highlight)
highlight.delete()
@ -747,13 +752,13 @@ export class SkiaRenderer {
const pathPaint = new this.ck.Paint()
pathPaint.setStyle(this.ck.PaintStyle.Stroke)
pathPaint.setStrokeWidth(2)
pathPaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
pathPaint.setColor(this.this.selColor())
pathPaint.setAntiAlias(true)
const handlePaint = new this.ck.Paint()
handlePaint.setStyle(this.ck.PaintStyle.Stroke)
handlePaint.setStrokeWidth(1)
handlePaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.5))
handlePaint.setColor(this.selColor(0.5))
handlePaint.setAntiAlias(true)
const vertexFill = new this.ck.Paint()
@ -764,7 +769,7 @@ export class SkiaRenderer {
const vertexStroke = new this.ck.Paint()
vertexStroke.setStyle(this.ck.PaintStyle.Stroke)
vertexStroke.setStrokeWidth(2)
vertexStroke.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
vertexStroke.setColor(this.this.selColor())
vertexStroke.setAntiAlias(true)
const toScreen = (x: number, y: number) => ({

View file

@ -1,3 +1,5 @@
export type { GUID, Color } from '../types'
export type HandleMirroring = 'NONE' | 'ANGLE' | 'ANGLE_AND_LENGTH'
export type WindingRule = 'NONZERO' | 'EVENODD'
@ -40,17 +42,7 @@ export type NodeType =
| 'GROUP'
| 'SECTION'
export interface GUID {
sessionID: number
localID: number
}
export interface Color {
r: number
g: number
b: number
a: number
}
import type { Color } from '../types'
export interface Fill {
type: 'SOLID'

View file

@ -7,24 +7,14 @@
*/
import { decompress as zstdDecompress } from 'fzstd'
import { compileSchema, type Schema } from 'kiwi-schema'
import { compileSchema } from './kiwi-schema'
import type { Schema } from './kiwi-schema'
import { parseColor } from '../engine/color'
import { isZstdCompressed, getKiwiMessageType } from './protocol.ts'
import figmaSchema from './schema.ts'
function parseColor(color: string): Color {
if (color.startsWith('#')) {
const hex = color.slice(1)
return {
r: parseInt(hex.slice(0, 2), 16) / 255,
g: parseInt(hex.slice(2, 4), 16) / 255,
b: parseInt(hex.slice(4, 6), 16) / 255,
a: hex.length > 6 ? parseInt(hex.slice(6, 8), 16) / 255 : 1
}
}
return { r: 0, g: 0, b: 0, a: 1 }
}
interface CompiledSchema {
encodeMessage(message: unknown): Uint8Array
decodeMessage(data: Uint8Array): unknown
@ -164,17 +154,9 @@ export function peekMessageType(data: Uint8Array): number | null {
// Type definitions
export interface GUID {
sessionID: number
localID: number
}
export type { GUID, Color } from '../types'
export interface Color {
r: number
g: number
b: number
a: number
}
import type { GUID, Color } from '../types'
export interface Vector {
x: number

View file

@ -1,9 +1,9 @@
import { unzipSync } from 'fflate'
import { initCodec, decodeMessage } from '../kiwi/codec'
import { initCodec, decodeMessage } from './codec'
import { importNodeChanges } from './fig-import'
import type { SceneGraph } from './scene-graph'
import type { SceneGraph } from '../engine/scene-graph'
/**
* Parse a .fig file into a SceneGraph.

View file

@ -1,8 +1,8 @@
import { SceneGraph } from './scene-graph'
import { decodeVectorNetworkBlob } from './vector'
import { SceneGraph } from '../engine/scene-graph'
import { decodeVectorNetworkBlob } from '../engine/vector'
import type { NodeChange, Paint, Effect as KiwiEffect, GUID } from '../kiwi/codec'
import type { NodeType, Fill, Stroke, Effect, Color, LayoutMode, LayoutSizing, LayoutAlign, LayoutCounterAlign, VectorNetwork } from './scene-graph'
import type { NodeChange, Paint, Effect as KiwiEffect, GUID } from './codec'
import type { NodeType, Fill, Stroke, Effect, Color, LayoutMode, LayoutSizing, LayoutAlign, LayoutCounterAlign, VectorNetwork } from '../engine/scene-graph'
function ext(nc: NodeChange): Record<string, unknown> {
return nc as unknown as Record<string, unknown>

274
src/kiwi/kiwi-schema/bb.ts Normal file
View file

@ -0,0 +1,274 @@
let int32 = new Int32Array(1);
let float32 = new Float32Array(int32.buffer);
export class ByteBuffer {
private _data: Uint8Array;
private _index: number;
length: number;
constructor(data?: Uint8Array) {
if (data && !(data instanceof Uint8Array)) {
throw new Error('Must initialize a ByteBuffer with a Uint8Array');
}
this._data = data || new Uint8Array(256);
this._index = 0;
this.length = data ? data.length : 0;
}
toUint8Array(): Uint8Array {
return this._data.subarray(0, this.length);
}
readByte(): number {
if (this._index + 1 > this._data.length) {
throw new Error('Index out of bounds');
}
return this._data[this._index++];
}
readByteArray(): Uint8Array {
let length = this.readVarUint();
let start = this._index;
let end = start + length;
if (end > this._data.length) {
throw new Error('Read array out of bounds');
}
this._index = end;
// Copy into a new array instead of just creating another view.
let result = new Uint8Array(length);
result.set(this._data.subarray(start, end));
return result;
}
readVarFloat(): number {
let index = this._index;
let data = this._data;
let length = data.length;
// Optimization: use a single byte to store zero
if (index + 1 > length) {
throw new Error('Index out of bounds');
}
let first = data[index];
if (first === 0) {
this._index = index + 1;
return 0;
}
// Endian-independent 32-bit read
if (index + 4 > length) {
throw new Error('Index out of bounds');
}
let bits = first | (data[index + 1] << 8) | (data[index + 2] << 16) | (data[index + 3] << 24);
this._index = index + 4;
// Move the exponent back into place
bits = (bits << 23) | (bits >>> 9);
// Reinterpret as a floating-point number
int32[0] = bits;
return float32[0];
}
readVarUint(): number {
let value = 0;
let shift = 0;
do {
var byte = this.readByte();
value |= (byte & 127) << shift;
shift += 7;
} while (byte & 128 && shift < 35);
return value >>> 0;
}
readVarInt(): number {
let value = this.readVarUint() | 0;
return value & 1 ? ~(value >>> 1) : value >>> 1;
}
readVarUint64(): bigint {
let value = BigInt(0);
let shift = BigInt(0);
let seven = BigInt(7);
let byte: number;
while ((byte = this.readByte()) & 128 && shift < 56) {
value |= BigInt(byte & 127) << shift;
shift += seven;
}
value |= BigInt(byte) << shift;
return value;
}
readVarInt64(): bigint {
let value = this.readVarUint64();
let one = BigInt(1);
let sign = value & one;
value >>= one;
return sign ? ~value : value;
}
readString(): string {
let result = '';
while (true) {
let codePoint;
// Decode UTF-8
let a = this.readByte();
if (a < 0xC0) {
codePoint = a;
} else {
let b = this.readByte();
if (a < 0xE0) {
codePoint = ((a & 0x1F) << 6) | (b & 0x3F);
} else {
let c = this.readByte();
if (a < 0xF0) {
codePoint = ((a & 0x0F) << 12) | ((b & 0x3F) << 6) | (c & 0x3F);
} else {
let d = this.readByte();
codePoint = ((a & 0x07) << 18) | ((b & 0x3F) << 12) | ((c & 0x3F) << 6) | (d & 0x3F);
}
}
}
// Strings are null-terminated
if (codePoint === 0) {
break;
}
// Encode UTF-16
if (codePoint < 0x10000) {
result += String.fromCharCode(codePoint);
} else {
codePoint -= 0x10000;
result += String.fromCharCode((codePoint >> 10) + 0xD800, (codePoint & ((1 << 10) - 1)) + 0xDC00);
}
}
return result;
}
private _growBy(amount: number): void {
if (this.length + amount > this._data.length) {
let data = new Uint8Array(this.length + amount << 1);
data.set(this._data);
this._data = data;
}
this.length += amount;
}
writeByte(value: number): void {
let index = this.length;
this._growBy(1);
this._data[index] = value;
}
writeByteArray(value: Uint8Array): void {
this.writeVarUint(value.length);
let index = this.length;
this._growBy(value.length);
this._data.set(value, index);
}
writeVarFloat(value: number): void {
let index = this.length;
// Reinterpret as an integer
float32[0] = value;
let bits = int32[0];
// Move the exponent to the first 8 bits
bits = (bits >>> 23) | (bits << 9);
// Optimization: use a single byte to store zero and denormals (check for an exponent of 0)
if ((bits & 255) === 0) {
this.writeByte(0);
return;
}
// Endian-independent 32-bit write
this._growBy(4);
let data = this._data;
data[index] = bits;
data[index + 1] = bits >> 8;
data[index + 2] = bits >> 16;
data[index + 3] = bits >> 24;
}
writeVarUint(value: number): void {
if (value < 0 || value > 0xFFFF_FFFF) throw new Error('Outside uint range: ' + value);
do {
let byte = value & 127;
value >>>= 7;
this.writeByte(value ? byte | 128 : byte);
} while (value);
}
writeVarInt(value: number): void {
if (value < -0x8000_0000 || value > 0x7FFF_FFFF) throw new Error('Outside int range: ' + value);
this.writeVarUint(((value << 1) ^ (value >> 31)) >>> 0);
}
writeVarUint64(value: bigint | string): void {
if (typeof value === 'string') value = BigInt(value);
else if (typeof value !== 'bigint') throw new Error('Expected bigint but got ' + typeof value + ': ' + value);
if (value < 0 || value > BigInt('0xFFFFFFFFFFFFFFFF')) throw new Error('Outside uint64 range: ' + value);
let mask = BigInt(127);
let seven = BigInt(7);
for (let i = 0; value > mask && i < 8; i++) {
this.writeByte(Number(value & mask) | 128);
value >>= seven;
}
this.writeByte(Number(value));
}
writeVarInt64(value: bigint | string): void {
if (typeof value === 'string') value = BigInt(value);
else if (typeof value !== 'bigint') throw new Error('Expected bigint but got ' + typeof value + ': ' + value);
if (value < -BigInt('0x8000000000000000') || value > BigInt('0x7FFFFFFFFFFFFFFF')) throw new Error('Outside int64 range: ' + value);
let one = BigInt(1);
this.writeVarUint64(value < 0 ? ~(value << one) : value << one);
}
writeString(value: string): void {
let codePoint;
for (let i = 0; i < value.length; i++) {
// Decode UTF-16
let a = value.charCodeAt(i);
if (i + 1 === value.length || a < 0xD800 || a >= 0xDC00) {
codePoint = a;
} else {
let b = value.charCodeAt(++i);
codePoint = (a << 10) + b + (0x10000 - (0xD800 << 10) - 0xDC00);
}
// Strings are null-terminated
if (codePoint === 0) {
throw new Error('Cannot encode a string containing the null character');
}
// Encode UTF-8
if (codePoint < 0x80) {
this.writeByte(codePoint);
} else {
if (codePoint < 0x800) {
this.writeByte(((codePoint >> 6) & 0x1F) | 0xC0);
} else {
if (codePoint < 0x10000) {
this.writeByte(((codePoint >> 12) & 0x0F) | 0xE0);
} else {
this.writeByte(((codePoint >> 18) & 0x07) | 0xF0);
this.writeByte(((codePoint >> 12) & 0x3F) | 0x80);
}
this.writeByte(((codePoint >> 6) & 0x3F) | 0x80);
}
this.writeByte((codePoint & 0x3F) | 0x80);
}
}
// Strings are null-terminated
this.writeByte(0);
}
}

View file

@ -0,0 +1,104 @@
import { ByteBuffer } from "./bb";
import { Schema, Field, Definition, DefinitionKind } from "./schema";
let types: (string | null)[] = ['bool', 'byte', 'int', 'uint', 'float', 'string', 'int64', 'uint64'];
let kinds: DefinitionKind[] = ['ENUM', 'STRUCT', 'MESSAGE'];
export function decodeBinarySchema(buffer: Uint8Array | ByteBuffer): Schema {
let bb = buffer instanceof ByteBuffer ? buffer : new ByteBuffer(buffer);
let definitionCount = bb.readVarUint();
let definitions: Definition[] = [];
// Read in the schema
for (let i = 0; i < definitionCount; i++) {
let definitionName = bb.readString();
let kind = bb.readByte();
let fieldCount = bb.readVarUint();
let fields: Field[] = [];
for (let j = 0; j < fieldCount; j++) {
let fieldName = bb.readString();
let type = bb.readVarInt();
let isArray = !!(bb.readByte() & 1);
let value = bb.readVarUint();
fields.push({
name: fieldName,
line: 0,
column: 0,
type: kinds[kind] === 'ENUM' ? null : type as any,
isArray: isArray,
isDeprecated: false,
value: value,
});
}
definitions.push({
name: definitionName,
line: 0,
column: 0,
kind: kinds[kind],
fields: fields,
});
}
// Bind type names afterwards
for (let i = 0; i < definitionCount; i++) {
let fields = definitions[i].fields;
for (let j = 0; j < fields.length; j++) {
let field = fields[j];
let type = field.type as any as number | null;
if (type !== null && type < 0) {
if (~type >= types.length) {
throw new Error('Invalid type ' + type);
}
field.type = types[~type];
}
else {
if (type !== null && type >= definitions.length) {
throw new Error('Invalid type ' + type);
}
field.type = type === null ? null : definitions[type].name;
}
}
}
return {
package: null,
definitions: definitions,
};
}
export function encodeBinarySchema(schema: Schema): Uint8Array {
let bb = new ByteBuffer();
let definitions = schema.definitions;
let definitionIndex: { [name: string]: number } = {};
bb.writeVarUint(definitions.length);
for (let i = 0; i < definitions.length; i++) {
definitionIndex[definitions[i].name] = i;
}
for (let i = 0; i < definitions.length; i++) {
let definition = definitions[i];
bb.writeString(definition.name);
bb.writeByte(kinds.indexOf(definition.kind));
bb.writeVarUint(definition.fields.length);
for (let j = 0; j < definition.fields.length; j++) {
let field = definition.fields[j];
let type = types.indexOf(field.type);
bb.writeString(field.name);
bb.writeVarInt(type === -1 ? definitionIndex[field.type!] : ~type);
bb.writeByte(field.isArray ? 1 : 0);
bb.writeVarUint(field.value);
}
}
return bb.toUint8Array();
}

View file

@ -0,0 +1,5 @@
export type { Schema, Definition, Field } from './schema'
export { ByteBuffer } from './bb'
export { compileSchema } from './js'
export { decodeBinarySchema, encodeBinarySchema } from './binary'
export { parseSchema } from './parser'

311
src/kiwi/kiwi-schema/js.ts Normal file
View file

@ -0,0 +1,311 @@
import { Schema, Definition } from "./schema";
import { ByteBuffer } from "./bb";
import { error, quote } from "./util";
function compileDecode(definition: Definition, definitions: { [name: string]: Definition }): string {
let lines: string[] = [];
let indent = ' ';
lines.push('function (bb) {');
lines.push(' var result = {};');
lines.push(' if (!(bb instanceof this.ByteBuffer)) {');
lines.push(' bb = new this.ByteBuffer(bb);');
lines.push(' }');
lines.push('');
if (definition.kind === 'MESSAGE') {
lines.push(' while (true) {');
lines.push(' switch (bb.readVarUint()) {');
lines.push(' case 0:');
lines.push(' return result;');
lines.push('');
indent = ' ';
}
for (let i = 0; i < definition.fields.length; i++) {
let field = definition.fields[i];
let code: string;
switch (field.type) {
case 'bool': {
code = '!!bb.readByte()';
break;
}
case 'byte': {
code = 'bb.readByte()'; // only used if not array
break;
}
case 'int': {
code = 'bb.readVarInt()';
break;
}
case 'uint': {
code = 'bb.readVarUint()';
break;
}
case 'float': {
code = 'bb.readVarFloat()';
break;
}
case 'string': {
code = 'bb.readString()';
break;
}
case 'int64': {
code = 'bb.readVarInt64()';
break;
}
case 'uint64': {
code = 'bb.readVarUint64()';
break;
}
default: {
let type = definitions[field.type!];
if (!type) {
error('Invalid type ' + quote(field.type!) + ' for field ' + quote(field.name), field.line, field.column);
} else if (type.kind === 'ENUM') {
code = 'this[' + quote(type.name) + '][bb.readVarUint()]';
} else {
code = 'this[' + quote('decode' + type.name) + '](bb)';
}
}
}
if (definition.kind === 'MESSAGE') {
lines.push(' case ' + field.value + ':');
}
if (field.isArray) {
if (field.isDeprecated) {
if (field.type === 'byte') {
lines.push(indent + 'bb.readByteArray();');
} else {
lines.push(indent + 'var length = bb.readVarUint();');
lines.push(indent + 'while (length-- > 0) ' + code + ';');
}
} else {
if (field.type === 'byte') {
lines.push(indent + 'result[' + quote(field.name) + '] = bb.readByteArray();');
} else {
lines.push(indent + 'var length = bb.readVarUint();');
lines.push(indent + 'var values = result[' + quote(field.name) + '] = Array(length);');
lines.push(indent + 'for (var i = 0; i < length; i++) values[i] = ' + code + ';');
}
}
}
else {
if (field.isDeprecated) {
lines.push(indent + code + ';');
} else {
lines.push(indent + 'result[' + quote(field.name) + '] = ' + code + ';');
}
}
if (definition.kind === 'MESSAGE') {
lines.push(' break;');
lines.push('');
}
}
if (definition.kind === 'MESSAGE') {
lines.push(' default:');
lines.push(' throw new Error("Attempted to parse invalid message");');
lines.push(' }');
lines.push(' }');
}
else {
lines.push(' return result;');
}
lines.push('}');
return lines.join('\n');
}
function compileEncode(definition: Definition, definitions: { [name: string]: Definition }): string {
let lines: string[] = [];
lines.push('function (message, bb) {');
lines.push(' var isTopLevel = !bb;');
lines.push(' if (isTopLevel) bb = new this.ByteBuffer();');
for (let j = 0; j < definition.fields.length; j++) {
let field = definition.fields[j];
let code: string;
if (field.isDeprecated) {
continue;
}
switch (field.type) {
case 'bool': {
code = 'bb.writeByte(value);';
break;
}
case 'byte': {
code = 'bb.writeByte(value);'; // only used if not array
break;
}
case 'int': {
code = 'bb.writeVarInt(value);';
break;
}
case 'uint': {
code = 'bb.writeVarUint(value);';
break;
}
case 'float': {
code = 'bb.writeVarFloat(value);';
break;
}
case 'string': {
code = 'bb.writeString(value);';
break;
}
case 'int64': {
code = 'bb.writeVarInt64(value);';
break;
}
case 'uint64': {
code = 'bb.writeVarUint64(value);';
break;
}
default: {
let type = definitions[field.type!];
if (!type) {
throw new Error('Invalid type ' + quote(field.type!) + ' for field ' + quote(field.name));
} else if (type.kind === 'ENUM') {
code =
'var encoded = this[' + quote(type.name) + '][value]; ' +
'if (encoded === void 0) throw new Error("Invalid value " + JSON.stringify(value) + ' + quote(' for enum ' + quote(type.name)) + '); ' +
'bb.writeVarUint(encoded);';
} else {
code = 'this[' + quote('encode' + type.name) + '](value, bb);';
}
}
}
lines.push('');
lines.push(' var value = message[' + quote(field.name) + '];');
lines.push(' if (value != null) {'); // Comparing with null using "!=" also checks for undefined
if (definition.kind === 'MESSAGE') {
lines.push(' bb.writeVarUint(' + field.value + ');');
}
if (field.isArray) {
if (field.type === 'byte') {
lines.push(' bb.writeByteArray(value);');
} else {
lines.push(' var values = value, n = values.length;');
lines.push(' bb.writeVarUint(n);');
lines.push(' for (var i = 0; i < n; i++) {');
lines.push(' value = values[i];');
lines.push(' ' + code);
lines.push(' }');
}
}
else {
lines.push(' ' + code);
}
if (definition.kind === 'STRUCT') {
lines.push(' } else {');
lines.push(' throw new Error(' + quote('Missing required field ' + quote(field.name)) + ');');
}
lines.push(' }');
}
// A field id of zero is reserved to indicate the end of the message
if (definition.kind === 'MESSAGE') {
lines.push(' bb.writeVarUint(0);');
}
lines.push('');
lines.push(' if (isTopLevel) return bb.toUint8Array();');
lines.push('}');
return lines.join('\n');
}
export function compileSchemaJS(schema: Schema): string {
let definitions: { [name: string]: Definition } = {};
let name = schema.package;
let js: string[] = [];
if (name !== null) {
js.push('var ' + name + ' = exports || ' + name + ' || {}, exports;');
} else {
js.push('var exports = exports || {};');
name = 'exports';
}
js.push(name + '.ByteBuffer = ' + name + '.ByteBuffer || require("kiwi-schema").ByteBuffer;');
for (let i = 0; i < schema.definitions.length; i++) {
let definition = schema.definitions[i];
definitions[definition.name] = definition;
}
for (let i = 0; i < schema.definitions.length; i++) {
let definition = schema.definitions[i];
switch (definition.kind) {
case 'ENUM': {
let value: any = {};
for (let j = 0; j < definition.fields.length; j++) {
let field = definition.fields[j];
value[field.name] = field.value;
value[field.value] = field.name;
}
js.push(name + '[' + quote(definition.name) + '] = ' + JSON.stringify(value, null, 2) + ';');
break;
}
case 'STRUCT':
case 'MESSAGE': {
js.push('');
js.push(name + '[' + quote('decode' + definition.name) + '] = ' + compileDecode(definition, definitions) + ';');
js.push('');
js.push(name + '[' + quote('encode' + definition.name) + '] = ' + compileEncode(definition, definitions) + ';');
break;
}
default: {
error('Invalid definition kind ' + quote(definition.kind), definition.line, definition.column);
break;
}
}
}
js.push('');
return js.join('\n');
}
export function compileSchema(schema: Schema): any {
let result = {
ByteBuffer: ByteBuffer,
};
new Function('exports', compileSchemaJS(schema))(result);
return result;
}

View file

@ -0,0 +1,279 @@
import { Schema, Definition, Field, DefinitionKind } from "./schema";
import { error, quote } from "./util";
export let nativeTypes = [
'bool',
'byte',
'float',
'int',
'int64',
'string',
'uint',
'uint64',
];
// These are special names on the object returned by compileSchema()
export let reservedNames = [
'ByteBuffer',
'package',
];
let regex = /((?:-|\b)\d+\b|[=;{}]|\[\]|\[deprecated\]|\b[A-Za-z_][A-Za-z0-9_]*\b|\/\/.*|\s+)/g;
let identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
let whitespace = /^\/\/.*|\s+$/;
let equals = /^=$/;
let endOfFile = /^$/;
let semicolon = /^;$/;
let integer = /^-?\d+$/;
let leftBrace = /^\{$/;
let rightBrace = /^\}$/;
let arrayToken = /^\[\]$/;
let enumKeyword = /^enum$/;
let structKeyword = /^struct$/;
let messageKeyword = /^message$/;
let packageKeyword = /^package$/;
let deprecatedToken = /^\[deprecated\]$/;
interface Token {
text: string
line: number
column: number
}
function tokenize(text: string): Token[] {
let parts = text.split(regex);
let tokens = [];
let column = 0;
let line = 0;
for (let i = 0; i < parts.length; i++) {
let part = parts[i];
// Keep non-whitespace tokens
if (i & 1) {
if (!whitespace.test(part)) {
tokens.push({
text: part,
line: line + 1,
column: column + 1,
});
}
}
// Detect syntax errors
else if (part !== '') {
error('Syntax error ' + quote(part), line + 1, column + 1);
}
// Keep track of the line and column counts
let lines = part.split('\n');
if (lines.length > 1) column = 0;
line += lines.length - 1;
column += lines[lines.length - 1].length;
}
// End-of-file token
tokens.push({
text: '',
line: line,
column: column,
});
return tokens;
}
function parse(tokens: Token[]): Schema {
function current(): Token {
return tokens[index];
}
function eat(test: RegExp): boolean {
if (test.test(current().text)) {
index++;
return true;
}
return false;
}
function expect(test: RegExp, expected: string): void {
if (!eat(test)) {
let token = current();
error('Expected ' + expected + ' but found ' + quote(token.text), token.line, token.column);
}
}
function unexpectedToken(): never {
let token = current();
error('Unexpected token ' + quote(token.text), token.line, token.column);
}
let definitions: Definition[] = [];
let packageText = null;
let index = 0;
if (eat(packageKeyword)) {
packageText = current().text;
expect(identifier, 'identifier');
expect(semicolon, '";"');
}
while (index < tokens.length && !eat(endOfFile)) {
let fields: Field[] = [];
let kind: DefinitionKind;
if (eat(enumKeyword)) kind = 'ENUM';
else if (eat(structKeyword)) kind = 'STRUCT';
else if (eat(messageKeyword)) kind = 'MESSAGE';
else unexpectedToken();
// All definitions start off the same
let name = current();
expect(identifier, 'identifier');
expect(leftBrace, '"{"');
// Parse fields
while (!eat(rightBrace)) {
let type: string | null = null;
let isArray = false;
let isDeprecated = false;
// Enums don't have types
if (kind !== 'ENUM') {
type = current().text;
expect(identifier, 'identifier');
isArray = eat(arrayToken);
}
let field = current();
expect(identifier, 'identifier');
// Structs don't have explicit values
let value: Token | null = null;
if (kind !== 'STRUCT') {
expect(equals, '"="');
value = current();
expect(integer, 'integer');
if ((+value.text | 0) + '' !== value.text) {
error('Invalid integer ' + quote(value.text), value.line, value.column);
}
}
let deprecated = current();
if (eat(deprecatedToken)) {
if (kind !== 'MESSAGE') {
error('Cannot deprecate this field', deprecated.line, deprecated.column);
}
isDeprecated = true;
}
expect(semicolon, '";"');
fields.push({
name: field.text,
line: field.line,
column: field.column,
type: type,
isArray: isArray,
isDeprecated: isDeprecated,
value: value !== null ? +value.text | 0 : fields.length + 1,
});
}
definitions.push({
name: name.text,
line: name.line,
column: name.column,
kind: kind,
fields: fields,
});
}
return {
package: packageText,
definitions: definitions,
};
}
function verify(root: Schema): void {
let definedTypes = nativeTypes.slice();
let definitions: { [name: string]: Definition } = {};
// Define definitions
for (let i = 0; i < root.definitions.length; i++) {
let definition = root.definitions[i];
if (definedTypes.indexOf(definition.name) !== -1) {
error('The type ' + quote(definition.name) + ' is defined twice', definition.line, definition.column);
}
if (reservedNames.indexOf(definition.name) !== -1) {
error('The type name ' + quote(definition.name) + ' is reserved', definition.line, definition.column);
}
definedTypes.push(definition.name);
definitions[definition.name] = definition;
}
// Check fields
for (let i = 0; i < root.definitions.length; i++) {
let definition = root.definitions[i];
let fields = definition.fields;
if (definition.kind === 'ENUM' || fields.length === 0) {
continue;
}
// Check types
for (let j = 0; j < fields.length; j++) {
let field = fields[j];
if (definedTypes.indexOf(field.type!) === -1) {
error('The type ' + quote(field.type!) + ' is not defined for field ' + quote(field.name), field.line, field.column);
}
}
// Check values
let values: number[] = [];
for (let j = 0; j < fields.length; j++) {
let field = fields[j];
if (values.indexOf(field.value) !== -1) {
error('The id for field ' + quote(field.name) + ' is used twice', field.line, field.column);
}
if (field.value <= 0) {
error('The id for field ' + quote(field.name) + ' must be positive', field.line, field.column);
}
// Figma schema uses sparse field IDs (up to 435), so skip sequential check
values.push(field.value);
}
}
// Check that structs don't contain themselves
let state: { [name: string]: number } = {};
let check = (name: string): boolean => {
let definition = definitions[name];
if (definition && definition.kind === 'STRUCT') {
if (state[name] === 1) {
error('Recursive nesting of ' + quote(name) + ' is not allowed', definition.line, definition.column);
}
if (state[name] !== 2 && definition) {
state[name] = 1;
let fields = definition.fields;
for (let i = 0; i < fields.length; i++) {
let field = fields[i];
if (!field.isArray) {
check(field.type!);
}
}
state[name] = 2;
}
}
return true;
};
for (let i = 0; i < root.definitions.length; i++) {
check(root.definitions[i].name);
}
}
export function parseSchema(text: string): Schema {
let schema = parse(tokenize(text));
verify(schema);
return schema;
}

View file

@ -0,0 +1,24 @@
export interface Schema {
package: string | null;
definitions: Definition[];
}
export type DefinitionKind = 'ENUM' | 'STRUCT' | 'MESSAGE';
export interface Definition {
name: string;
line: number;
column: number;
kind: DefinitionKind;
fields: Field[];
}
export interface Field {
name: string;
line: number;
column: number;
type: string | null;
isArray: boolean;
isDeprecated: boolean;
value: number;
}

View file

@ -0,0 +1,10 @@
export function quote(text: string): string {
return JSON.stringify(text);
}
export function error(text: string, line: number, column: number): never {
var error = new Error(text);
(error as any).line = line;
(error as any).column = column;
throw error;
}

View file

@ -1,180 +0,0 @@
/**
* PoC 3: Kiwi codec round-trip test
*
* Validates:
* 1. Schema compiles (194 definitions, 2178 lines)
* 2. Encode a NODE_CHANGES message with shapes
* 3. Decode it back verify data integrity
* 4. Zstd compress/decompress round-trip
*/
import {
initCodec,
encodeMessage,
decodeMessage,
createNodeChange,
createNodeChangesMessage
} from './codec.ts'
import type { NodeChange } from './codec.ts'
async function runTest() {
console.log('=== PoC 3: Kiwi Codec Round-Trip ===\n')
// 1. Init codec (compiles schema)
const t0 = performance.now()
await initCodec()
console.log(`✅ Schema compiled in ${(performance.now() - t0).toFixed(1)}ms`)
// 2. Create test node changes
const sessionID = 99999
const changes: NodeChange[] = [
createNodeChange({
sessionID,
localID: 1,
parentSessionID: 0,
parentLocalID: 0,
type: 'FRAME',
name: 'Test Frame',
x: 0,
y: 0,
width: 800,
height: 600,
fill: '#FFFFFF'
}),
createNodeChange({
sessionID,
localID: 2,
parentSessionID: sessionID,
parentLocalID: 1,
type: 'RECTANGLE',
name: 'Blue Card',
x: 50,
y: 50,
width: 200,
height: 150,
fill: '#3B82F6',
cornerRadius: 12
}),
createNodeChange({
sessionID,
localID: 3,
parentSessionID: sessionID,
parentLocalID: 1,
type: 'ELLIPSE',
name: 'Green Circle',
x: 300,
y: 80,
width: 100,
height: 100,
fill: '#22C55E'
}),
createNodeChange({
sessionID,
localID: 4,
parentSessionID: sessionID,
parentLocalID: 1,
type: 'TEXT',
name: 'Hello World',
x: 50,
y: 250,
width: 200,
height: 40,
fill: '#000000'
})
]
console.log(`✅ Created ${changes.length} node changes`)
// 3. Encode
const message = createNodeChangesMessage(sessionID, 1, changes)
const t1 = performance.now()
const encoded = encodeMessage(message)
const encodeTime = performance.now() - t1
console.log(`✅ Encoded: ${encoded.byteLength} bytes (${encodeTime.toFixed(2)}ms)`)
console.log(
` Zstd compressed: starts with 0x${encoded[0]?.toString(16)}${encoded[1]?.toString(16)}${encoded[2]?.toString(16)}${encoded[3]?.toString(16)}`
)
// 4. Decode
const t2 = performance.now()
const decoded = decodeMessage(encoded)
const decodeTime = performance.now() - t2
console.log(`✅ Decoded in ${decodeTime.toFixed(2)}ms`)
// 5. Verify round-trip
const nc = decoded.nodeChanges
if (!nc || nc.length !== changes.length) {
console.error(`❌ Node count mismatch: expected ${changes.length}, got ${nc?.length}`)
process.exit(1)
}
for (let i = 0; i < nc.length; i++) {
const original = changes[i]!
const roundtripped = nc[i]!
// Verify GUID
if (
roundtripped.guid?.sessionID !== original.guid.sessionID ||
roundtripped.guid?.localID !== original.guid.localID
) {
console.error(`❌ Node ${i} GUID mismatch`)
process.exit(1)
}
// Verify name
if (roundtripped.name !== original.name) {
console.error(`❌ Node ${i} name mismatch: "${roundtripped.name}" !== "${original.name}"`)
process.exit(1)
}
// Verify type
if (roundtripped.type !== original.type) {
console.error(`❌ Node ${i} type mismatch: "${roundtripped.type}" !== "${original.type}"`)
process.exit(1)
}
// Verify size
if (roundtripped.size?.x !== original.size?.x || roundtripped.size?.y !== original.size?.y) {
console.error(`❌ Node ${i} size mismatch`)
process.exit(1)
}
// Verify transform (position)
if (
roundtripped.transform?.m02 !== original.transform?.m02 ||
roundtripped.transform?.m12 !== original.transform?.m12
) {
console.error(`❌ Node ${i} position mismatch`)
process.exit(1)
}
// Verify fill
if (original.fillPaints && original.fillPaints.length > 0) {
const origFill = original.fillPaints[0]!
const rtFill = roundtripped.fillPaints?.[0]
if (!rtFill) {
console.error(`❌ Node ${i} missing fill`)
process.exit(1)
}
if (rtFill.type !== origFill.type) {
console.error(`❌ Node ${i} fill type mismatch`)
process.exit(1)
}
}
console.log(
` ✓ Node ${i}: ${roundtripped.name} (${roundtripped.type}) @ ${roundtripped.transform?.m02},${roundtripped.transform?.m12} [${roundtripped.size?.x}×${roundtripped.size?.y}]`
)
}
console.log(`\n✅ PoC 3 PASSED: ${changes.length} nodes round-tripped through Kiwi+Zstd`)
console.log(` Schema: 194 definitions`)
console.log(` Encode: ${encodeTime.toFixed(2)}ms → ${encoded.byteLength} bytes`)
console.log(` Decode: ${decodeTime.toFixed(2)}ms`)
}
runTest().catch((e) => {
console.error('❌ PoC 3 FAILED:', e)
process.exit(1)
})

View file

@ -1,4 +1,4 @@
import { parseSchema } from 'kiwi-schema'
import { parseSchema } from './kiwi-schema'
const schemaText = `
package Fig;

View file

@ -1,5 +1,6 @@
import { reactive, shallowRef, computed } from 'vue'
import { DEFAULT_SHAPE_FILL, DEFAULT_FRAME_FILL } from '../constants'
import {
parseFigmaClipboard,
importClipboardNodes,
@ -8,7 +9,7 @@ import {
buildOpenPencilClipboardHTML,
prefetchFigmaSchema
} from '../engine/clipboard'
import { readFigFile } from '../engine/fig-file'
import { readFigFile } from '../kiwi/fig-file'
import { computeLayout, computeAllLayouts } from '../engine/layout'
import { SceneGraph } from '../engine/scene-graph'
import { UndoManager } from '../engine/undo'
@ -53,22 +54,14 @@ export const TOOL_SHORTCUTS: Record<string, Tool> = {
h: 'HAND'
}
const BLACK_FILL: Fill = { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
const DEFAULT_FILLS: Record<string, Fill> = {
FRAME: { type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true },
RECTANGLE: {
type: 'SOLID',
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
opacity: 1,
visible: true
},
ELLIPSE: {
type: 'SOLID',
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
opacity: 1,
visible: true
},
LINE: { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true },
TEXT: { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
FRAME: DEFAULT_FRAME_FILL,
RECTANGLE: DEFAULT_SHAPE_FILL,
ELLIPSE: DEFAULT_SHAPE_FILL,
LINE: BLACK_FILL,
TEXT: BLACK_FILL
}
export function createEditorStore() {
@ -306,7 +299,7 @@ export function createEditorStore() {
vectorNetwork: normalizedNetwork,
name: 'Vector',
fills: closed
? [{ type: 'SOLID' as const, color: { r: 0.83, g: 0.83, b: 0.83, a: 1 }, opacity: 1, visible: true }]
? [{ ...DEFAULT_SHAPE_FILL }]
: [],
strokes: closed
? []

11
src/types.ts Normal file
View file

@ -0,0 +1,11 @@
export interface GUID {
sessionID: number
localID: number
}
export interface Color {
r: number
g: number
b: number
a: number
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 18 KiB