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