diff --git a/bun.lock b/bun.lock index 07642febb..3c91ff0ce 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index e827ece0d..c8312a944 100644 --- a/package.json +++ b/package.json @@ -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" } } \ No newline at end of file diff --git a/patches/kiwi-schema@0.5.0.patch b/patches/kiwi-schema@0.5.0.patch deleted file mode 100644 index b71c33044..000000000 --- a/patches/kiwi-schema@0.5.0.patch +++ /dev/null @@ -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); diff --git a/src/App.vue b/src/App.vue index b3574dd9c..979599f81 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,8 @@ diff --git a/src/components/ColorPicker.vue b/src/components/ColorPicker.vue index d52e0662e..0e087cb0d 100644 --- a/src/components/ColorPicker.vue +++ b/src/components/ColorPicker.vue @@ -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(null) diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index 95033b0bd..c7f2a39b7 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -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) { diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 000000000..c8da11aa6 --- /dev/null +++ b/src/constants.ts @@ -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 diff --git a/src/demo.ts b/src/demo.ts new file mode 100644 index 000000000..a14bbd589 --- /dev/null +++ b/src/demo.ts @@ -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 }] + }) + } +} diff --git a/src/engine/clipboard.ts b/src/engine/clipboard.ts index a0e1bdc09..2b743c486 100644 --- a/src/engine/clipboard.ts +++ b/src/engine/clipboard.ts @@ -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' diff --git a/src/engine/color.ts b/src/engine/color.ts new file mode 100644 index 000000000..a932c136e --- /dev/null +++ b/src/engine/color.ts @@ -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 + } +} diff --git a/src/engine/poc-yoga.ts b/src/engine/poc-yoga.ts deleted file mode 100644 index daa3af86f..000000000 --- a/src/engine/poc-yoga.ts +++ /dev/null @@ -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() diff --git a/src/engine/renderer.ts b/src/engine/renderer.ts index 82ae17a6c..986f82f36 100644 --- a/src/engine/renderer.ts +++ b/src/engine/renderer.ts @@ -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, 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) => ({ diff --git a/src/engine/scene-graph.ts b/src/engine/scene-graph.ts index ed00f1d1f..38f8fcc40 100644 --- a/src/engine/scene-graph.ts +++ b/src/engine/scene-graph.ts @@ -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' diff --git a/src/kiwi/codec.ts b/src/kiwi/codec.ts index 333dcfa7a..b71dc6440 100644 --- a/src/kiwi/codec.ts +++ b/src/kiwi/codec.ts @@ -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 diff --git a/src/engine/fig-file.ts b/src/kiwi/fig-file.ts similarity index 95% rename from src/engine/fig-file.ts rename to src/kiwi/fig-file.ts index c2d4f7a94..e9c434e7a 100644 --- a/src/engine/fig-file.ts +++ b/src/kiwi/fig-file.ts @@ -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. diff --git a/src/engine/fig-import.ts b/src/kiwi/fig-import.ts similarity index 98% rename from src/engine/fig-import.ts rename to src/kiwi/fig-import.ts index 77016e738..53352d66a 100644 --- a/src/engine/fig-import.ts +++ b/src/kiwi/fig-import.ts @@ -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 { return nc as unknown as Record diff --git a/src/kiwi/kiwi-schema/bb.ts b/src/kiwi/kiwi-schema/bb.ts new file mode 100644 index 000000000..a65f2613d --- /dev/null +++ b/src/kiwi/kiwi-schema/bb.ts @@ -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); + } +} diff --git a/src/kiwi/kiwi-schema/binary.ts b/src/kiwi/kiwi-schema/binary.ts new file mode 100644 index 000000000..8260c7707 --- /dev/null +++ b/src/kiwi/kiwi-schema/binary.ts @@ -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(); +} diff --git a/src/kiwi/kiwi-schema/index.ts b/src/kiwi/kiwi-schema/index.ts new file mode 100644 index 000000000..7d45da2f9 --- /dev/null +++ b/src/kiwi/kiwi-schema/index.ts @@ -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' diff --git a/src/kiwi/kiwi-schema/js.ts b/src/kiwi/kiwi-schema/js.ts new file mode 100644 index 000000000..5d30de6cd --- /dev/null +++ b/src/kiwi/kiwi-schema/js.ts @@ -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; +} diff --git a/src/kiwi/kiwi-schema/parser.ts b/src/kiwi/kiwi-schema/parser.ts new file mode 100644 index 000000000..498a79814 --- /dev/null +++ b/src/kiwi/kiwi-schema/parser.ts @@ -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; +} diff --git a/src/kiwi/kiwi-schema/schema.ts b/src/kiwi/kiwi-schema/schema.ts new file mode 100644 index 000000000..87c65a311 --- /dev/null +++ b/src/kiwi/kiwi-schema/schema.ts @@ -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; +} diff --git a/src/kiwi/kiwi-schema/util.ts b/src/kiwi/kiwi-schema/util.ts new file mode 100644 index 000000000..3f40e4ab9 --- /dev/null +++ b/src/kiwi/kiwi-schema/util.ts @@ -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; +} diff --git a/src/kiwi/poc-test.ts b/src/kiwi/poc-test.ts deleted file mode 100644 index 3be08d222..000000000 --- a/src/kiwi/poc-test.ts +++ /dev/null @@ -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) -}) diff --git a/src/kiwi/schema.ts b/src/kiwi/schema.ts index 20120c876..c9d56990d 100644 --- a/src/kiwi/schema.ts +++ b/src/kiwi/schema.ts @@ -1,4 +1,4 @@ -import { parseSchema } from 'kiwi-schema' +import { parseSchema } from './kiwi-schema' const schemaText = ` package Fig; diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 2a1e52156..3d38c1dec 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -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 = { 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 = { - 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 ? [] diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 000000000..62a218f81 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,11 @@ +export interface GUID { + sessionID: number + localID: number +} + +export interface Color { + r: number + g: number + b: number + a: number +} diff --git a/tests/e2e/create-shapes.spec.ts-snapshots/draw-and-delete-openpencil-darwin.png b/tests/e2e/create-shapes.spec.ts-snapshots/draw-and-delete-openpencil-darwin.png index c9081c3fd..04fc4e7ae 100644 Binary files a/tests/e2e/create-shapes.spec.ts-snapshots/draw-and-delete-openpencil-darwin.png and b/tests/e2e/create-shapes.spec.ts-snapshots/draw-and-delete-openpencil-darwin.png differ diff --git a/tests/e2e/create-shapes.spec.ts-snapshots/draw-and-undo-openpencil-darwin.png b/tests/e2e/create-shapes.spec.ts-snapshots/draw-and-undo-openpencil-darwin.png index a39e5f55d..264ed3dd0 100644 Binary files a/tests/e2e/create-shapes.spec.ts-snapshots/draw-and-undo-openpencil-darwin.png and b/tests/e2e/create-shapes.spec.ts-snapshots/draw-and-undo-openpencil-darwin.png differ diff --git a/tests/e2e/create-shapes.spec.ts-snapshots/draw-ellipse-openpencil-darwin.png b/tests/e2e/create-shapes.spec.ts-snapshots/draw-ellipse-openpencil-darwin.png index 3199413be..4241b3aa7 100644 Binary files a/tests/e2e/create-shapes.spec.ts-snapshots/draw-ellipse-openpencil-darwin.png and b/tests/e2e/create-shapes.spec.ts-snapshots/draw-ellipse-openpencil-darwin.png differ diff --git a/tests/e2e/create-shapes.spec.ts-snapshots/draw-rectangle-openpencil-darwin.png b/tests/e2e/create-shapes.spec.ts-snapshots/draw-rectangle-openpencil-darwin.png index dd66a8836..1584c2e51 100644 Binary files a/tests/e2e/create-shapes.spec.ts-snapshots/draw-rectangle-openpencil-darwin.png and b/tests/e2e/create-shapes.spec.ts-snapshots/draw-rectangle-openpencil-darwin.png differ diff --git a/tests/e2e/create-shapes.spec.ts-snapshots/draw-rectangle-then-move-it-openpencil-darwin.png b/tests/e2e/create-shapes.spec.ts-snapshots/draw-rectangle-then-move-it-openpencil-darwin.png index cc5f82143..a2f030387 100644 Binary files a/tests/e2e/create-shapes.spec.ts-snapshots/draw-rectangle-then-move-it-openpencil-darwin.png and b/tests/e2e/create-shapes.spec.ts-snapshots/draw-rectangle-then-move-it-openpencil-darwin.png differ