From 18609e00c36fcde8e72f7ae819ebc6ebfe0be882 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 28 Feb 2026 22:57:33 +0300 Subject: [PATCH] Fix all lint, typecheck, and formatting issues - 0 lint warnings (was 66), 0 type errors (was 30+) - Add typed interfaces for Paint.stops/transform, Effect.blendMode, NodeChange.strokeCap/strokeJoin/dashPattern in kiwi codec - Add CompiledSchema.encodePaint/encodeNodeChange methods - Remove all no-explicit-any: use Window declaration merging for queryLocalFonts, showOpenFilePicker, showSaveFilePicker - Remove all non-null assertions: add guards, use ?. operator, ?? fallbacks throughout - Add SceneGraph.copyProp typed helper for instance sync - Suppress kiwi-schema lint warnings via oxlint override - Remove unused imports, variables, parameters - Use proper CanvasKit StrokeCap/StrokeJoin enums in renderer - Run oxfmt with import sorting --- components.d.ts | 1 - oxlint.json | 7 +++ src/App.vue | 2 +- src/components/LayersPanel.vue | 3 +- src/components/properties/EffectsSection.vue | 34 ++++++++---- src/composables/use-keyboard.ts | 4 +- src/composables/use-menu.ts | 6 +- src/demo.ts | 2 +- src/engine/clipboard.ts | 4 +- src/engine/fig-export.ts | 12 ++-- src/engine/fonts.ts | 25 +++++++-- src/engine/renderer.ts | 58 ++++++++------------ src/engine/scene-graph.ts | 30 +++++----- src/engine/vector.ts | 4 +- src/global.d.ts | 9 +++ src/kiwi/client.ts | 5 +- src/kiwi/codec.ts | 46 +++++++++------- src/kiwi/fig-import.ts | 43 +++++++++------ src/stores/editor.ts | 14 +++-- 19 files changed, 181 insertions(+), 128 deletions(-) create mode 100644 src/global.d.ts diff --git a/components.d.ts b/components.d.ts index c0c74e250..e174cd2a5 100644 --- a/components.d.ts +++ b/components.d.ts @@ -44,7 +44,6 @@ declare module 'vue' { IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw')['default'] IconLucideRotateCw: typeof import('~icons/lucide/rotate-cw')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default'] - IconLucideX: typeof import('~icons/lucide/x')['default'] LayersPanel: typeof import('./src/components/LayersPanel.vue')['default'] LayoutSection: typeof import('./src/components/properties/LayoutSection.vue')['default'] PageSection: typeof import('./src/components/properties/PageSection.vue')['default'] diff --git a/oxlint.json b/oxlint.json index 556690a24..8fd76272c 100644 --- a/oxlint.json +++ b/oxlint.json @@ -25,6 +25,13 @@ "typescript/no-explicit-any": "off", "typescript/no-non-null-assertion": "off" } + }, + { + "files": ["**/kiwi/kiwi-schema/**"], + "rules": { + "typescript/no-explicit-any": "off", + "typescript/no-non-null-assertion": "off" + } } ], "ignorePatterns": ["node_modules", "dist", "desktop", "*.config.*"] diff --git a/src/App.vue b/src/App.vue index a3ee30e63..5abf627c3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -15,7 +15,7 @@ import Toolbar from './components/Toolbar.vue' const store = provideEditorStore() useKeyboard(store) useMenu(store) -;(window as any).__OPEN_PENCIL_STORE__ = store +;(window as Window & { __OPEN_PENCIL_STORE__?: typeof store }).__OPEN_PENCIL_STORE__ = store useEventListener( document, diff --git a/src/components/LayersPanel.vue b/src/components/LayersPanel.vue index 2f6eef87b..785b420ae 100644 --- a/src/components/LayersPanel.vue +++ b/src/components/LayersPanel.vue @@ -150,7 +150,8 @@ function updateDropTarget(ev: PointerEvent) { for (let i = 0; i < rows.length; i++) { const row = rows[i] - const rowId = row.dataset.nodeId! + const rowId = row.dataset.nodeId + if (!rowId) continue if (rowId === dragNodeId.value) continue const rect = row.getBoundingClientRect() diff --git a/src/components/properties/EffectsSection.vue b/src/components/properties/EffectsSection.vue index bd182da3a..f78478b39 100644 --- a/src/components/properties/EffectsSection.vue +++ b/src/components/properties/EffectsSection.vue @@ -39,9 +39,11 @@ function defaultEffect(): Effect { } function updateEffect(index: number, changes: Partial) { - const effects = [...node.value!.effects] + const n = node.value + if (!n) return + const effects = [...n.effects] effects[index] = { ...effects[index], ...changes } - store.updateNodeWithUndo(node.value!.id, { effects }, 'Change effect') + store.updateNodeWithUndo(n.id, { effects }, 'Change effect') } function updateColor(index: number, color: Color) { @@ -49,27 +51,35 @@ function updateColor(index: number, color: Color) { } function updateHex(index: number, hex: string) { + const n = node.value + if (!n) return const color = parseColor(hex.startsWith('#') ? hex : `#${hex}`) if (!color) return - const existing = node.value!.effects[index] + const existing = n.effects[index] updateColor(index, { ...color, a: existing.color.a }) } function updateColorOpacity(index: number, opacity: number) { - const existing = node.value!.effects[index] + const n = node.value + if (!n) return + const existing = n.effects[index] updateColor(index, { ...existing.color, a: Math.max(0, Math.min(1, opacity / 100)) }) } function toggleVisibility(index: number) { - updateEffect(index, { visible: !node.value!.effects[index].visible }) + const n = node.value + if (!n) return + updateEffect(index, { visible: !n.effects[index].visible }) } function updateType(index: number, type: EffectType) { + const n = node.value + if (!n) return const changes: Partial = { type } if (!isShadow(type)) { changes.offset = { x: 0, y: 0 } changes.spread = 0 - } else if (!isShadow(node.value!.effects[index].type)) { + } else if (!isShadow(n.effects[index].type)) { changes.offset = { x: 0, y: 4 } changes.spread = 0 } @@ -77,14 +87,18 @@ function updateType(index: number, type: EffectType) { } function add() { - const effects = [...node.value!.effects, defaultEffect()] - store.updateNodeWithUndo(node.value!.id, { effects }, 'Add effect') + const n = node.value + if (!n) return + const effects = [...n.effects, defaultEffect()] + store.updateNodeWithUndo(n.id, { effects }, 'Add effect') } function remove(index: number) { + const n = node.value + if (!n) return store.updateNodeWithUndo( - node.value!.id, - { effects: node.value!.effects.filter((_, i) => i !== index) }, + n.id, + { effects: n.effects.filter((_, i) => i !== index) }, 'Remove effect' ) if (expandedIndex.value === index) expandedIndex.value = null diff --git a/src/composables/use-keyboard.ts b/src/composables/use-keyboard.ts index e49f3172f..6a881c36c 100644 --- a/src/composables/use-keyboard.ts +++ b/src/composables/use-keyboard.ts @@ -13,13 +13,13 @@ export function useKeyboard(store: EditorStore) { useEventListener(window, 'copy', (e: ClipboardEvent) => { if (isEditing(e)) return e.preventDefault() - store.writeCopyData(e.clipboardData!) + if (e.clipboardData) store.writeCopyData(e.clipboardData) }) useEventListener(window, 'cut', (e: ClipboardEvent) => { if (isEditing(e)) return e.preventDefault() - store.writeCopyData(e.clipboardData!) + if (e.clipboardData) store.writeCopyData(e.clipboardData) store.deleteSelected() }) diff --git a/src/composables/use-menu.ts b/src/composables/use-menu.ts index 7d898ab38..020697a77 100644 --- a/src/composables/use-menu.ts +++ b/src/composables/use-menu.ts @@ -14,14 +14,14 @@ export async function openFileDialog(store: EditorStore) { }) if (!path) return const bytes = await readFile(path as string) - const file = new File([bytes], (path as string).split('/').pop()!) + const file = new File([bytes], (path as string).split('/').pop() ?? 'file.fig') await store.openFigFile(file, undefined, path as string) return } - if ('showOpenFilePicker' in window) { + if (window.showOpenFilePicker) { try { - const [handle] = await (window as any).showOpenFilePicker({ + const [handle] = await window.showOpenFilePicker({ types: [ { description: 'Figma file', diff --git a/src/demo.ts b/src/demo.ts index 8e68c4b3b..62b15274e 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -190,7 +190,7 @@ export function createDemoShapes(store: EditorStore) { ]) ] }) - const cardCompId = makeComponent(store, [cardId]) + makeComponent(store, [cardId]) // Input component const inputId = store.createShape('FRAME', 344, 128, 240, 40, compSectionId) diff --git a/src/engine/clipboard.ts b/src/engine/clipboard.ts index bbaef2346..833de92a4 100644 --- a/src/engine/clipboard.ts +++ b/src/engine/clipboard.ts @@ -268,7 +268,7 @@ export function importClipboardNodes( .filter((p) => p.type === 'SOLID' && p.color) .map((p) => ({ type: 'SOLID' as const, - color: p.color!, + color: p.color ?? { r: 0, g: 0, b: 0, a: 1 }, opacity: p.opacity ?? 1, visible: p.visible ?? true })) @@ -276,7 +276,7 @@ export function importClipboardNodes( const strokes: Stroke[] = (nc.strokePaints ?? []) .filter((p) => p.type === 'SOLID' && p.color) .map((p) => ({ - color: p.color!, + color: p.color ?? { r: 0, g: 0, b: 0, a: 1 }, weight: nc.strokeWeight ?? 1, opacity: p.opacity ?? 1, visible: p.visible ?? true, diff --git a/src/engine/fig-export.ts b/src/engine/fig-export.ts index 99ffbd1b8..03f307856 100644 --- a/src/engine/fig-export.ts +++ b/src/engine/fig-export.ts @@ -5,7 +5,7 @@ import { initCodec, getCompiledSchema, getSchemaBytes } from '../kiwi/codec' import { encodeVectorNetworkBlob } from './vector' import type { SkiaRenderer } from './renderer' -import type { SceneGraph, SceneNode, Color } from './scene-graph' +import type { Color, GradientTransform, SceneGraph, SceneNode } from './scene-graph' import type { CanvasKit } from 'canvaskit-wasm' const THUMBNAIL_1X1 = Uint8Array.from( @@ -112,12 +112,16 @@ function sceneNodeToKiwi( const sin = Math.sin((node.rotation * Math.PI) / 180) const fillPaints = node.fills.map((f) => { - const paint: Record = { - type: f.type, + const paint = { + type: f.type as string, color: f.color, opacity: f.opacity, visible: f.visible, - blendMode: f.blendMode ?? 'NORMAL' + blendMode: (f.blendMode ?? 'NORMAL') as string, + stops: undefined as { color: Color; position: number }[] | undefined, + transform: undefined as GradientTransform | undefined, + image: undefined as { hash: string } | undefined, + imageScaleMode: undefined as string | undefined } if (f.gradientStops) { paint.stops = f.gradientStops.map((s) => ({ color: s.color, position: s.position })) diff --git a/src/engine/fonts.ts b/src/engine/fonts.ts index 2f5177ccf..671f0269a 100644 --- a/src/engine/fonts.ts +++ b/src/engine/fonts.ts @@ -1,5 +1,19 @@ import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm' +declare global { + interface Window { + queryLocalFonts?(): Promise< + { + family: string + fullName: string + style: string + postscriptName: string + blob(): Promise + }[] + > + } +} + export interface FontInfo { family: string fullName: string @@ -19,9 +33,9 @@ export function getFontProvider(): TypefaceFontProvider | null { } export async function queryFonts(): Promise { - if (!('queryLocalFonts' in window)) return [] + if (!window.queryLocalFonts) return [] try { - const fonts = await (window as any).queryLocalFonts() + const fonts = await window.queryLocalFonts() const seen = new Set() const result: FontInfo[] = [] for (const f of fonts) { @@ -53,15 +67,16 @@ const BUNDLED_FONTS: Record = { export async function loadFont(family: string, style = 'Regular'): Promise { const cacheKey = `${family}|${style}` if (loadedFamilies.has(cacheKey)) { - const cached = loadedFamilies.get(cacheKey)! + const cached = loadedFamilies.get(cacheKey) + if (!cached) return null registerFontInCanvasKit(family, cached) return cached } // Try local font access API first - if ('queryLocalFonts' in window) { + if (window.queryLocalFonts) { try { - const fonts = await (window as any).queryLocalFonts() + const fonts = await window.queryLocalFonts() const match = fonts.find((f: FontInfo) => f.family === family && f.style === style) ?? fonts.find((f: FontInfo) => f.family === family) diff --git a/src/engine/renderer.ts b/src/engine/renderer.ts index 9e1117cff..ad462b6fe 100644 --- a/src/engine/renderer.ts +++ b/src/engine/renderer.ts @@ -56,16 +56,9 @@ import { } from '../constants' import { vectorNetworkToPath } from './vector' -import type { - SceneNode, - SceneGraph, - Fill, - GradientStop, - GradientTransform, - ArcData -} from './scene-graph' +import type { SceneNode, SceneGraph, Fill } from './scene-graph' import type { SnapGuide } from './snap' -import type { Image as CKImage } from 'canvaskit-wasm' +import type { EmbindEnumEntity, Image as CKImage, Path } from 'canvaskit-wasm' import type { CanvasKit, Surface, @@ -383,7 +376,7 @@ export class SkiaRenderer { // Section titles + component labels (screen coordinates, zoom-independent) canvas.save() canvas.scale(this.dpr, this.dpr) - this.drawSectionTitles(canvas, graph, selectedIds) + this.drawSectionTitles(canvas, graph) this.drawComponentLabels(canvas, graph) canvas.restore() @@ -973,7 +966,7 @@ export class SkiaRenderer { } } - private drawSectionTitles(canvas: Canvas, graph: SceneGraph, selectedIds: Set): void { + private drawSectionTitles(canvas: Canvas, graph: SceneGraph): void { if (!this.sectionTitleFont) return const pageNode = graph.getNode(this.pageId ?? graph.rootId) @@ -1116,12 +1109,6 @@ export class SkiaRenderer { const screenX = absX * this.zoom + this.panX const screenY = absY * this.zoom + this.panY - // Measure text - const glyphIds = font.getGlyphIDs(node.name) - const widths = font.getGlyphWidths(glyphIds) - let textWidth = 0 - for (const w of widths) textWidth += w - // Position: inside top-left for variants in a set, above top-left otherwise const labelX = screenX let labelY: number @@ -1239,20 +1226,20 @@ export class SkiaRenderer { this.strokePaint.setAlphaf(stroke.opacity) if (stroke.cap) { - const capMap: Record = { - NONE: 0, - ROUND: 1, - SQUARE: 2 + const capMap: Record = { + NONE: this.ck.StrokeCap.Butt, + ROUND: this.ck.StrokeCap.Round, + SQUARE: this.ck.StrokeCap.Square } - this.strokePaint.setStrokeCap(capMap[stroke.cap] ?? 0) + this.strokePaint.setStrokeCap(capMap[stroke.cap] ?? this.ck.StrokeCap.Butt) } if (stroke.join) { - const joinMap: Record = { - MITER: 0, - ROUND: 1, - BEVEL: 2 + const joinMap: Record = { + MITER: this.ck.StrokeJoin.Miter, + ROUND: this.ck.StrokeJoin.Round, + BEVEL: this.ck.StrokeJoin.Bevel } - this.strokePaint.setStrokeJoin(joinMap[stroke.join] ?? 0) + this.strokePaint.setStrokeJoin(joinMap[stroke.join] ?? this.ck.StrokeJoin.Miter) } if (stroke.dashPattern && stroke.dashPattern.length > 0) { this.strokePaint.setPathEffect(this.ck.PathEffect.MakeDash(stroke.dashPattern, 0)) @@ -1397,7 +1384,8 @@ export class SkiaRenderer { } private drawArc(canvas: Canvas, node: SceneNode, paint: Paint): void { - const arc = node.arcData! + const arc = node.arcData + if (!arc) return const cx = node.width / 2 const cy = node.height / 2 const rx = node.width / 2 @@ -1598,8 +1586,9 @@ export class SkiaRenderer { } private applyGradientFill(fill: Fill, node: SceneNode): void { - const stops = fill.gradientStops! - const t = fill.gradientTransform! + const stops = fill.gradientStops + const t = fill.gradientTransform + if (!stops || !t) return const colors = stops.map((s) => this.ck.Color4f(s.color.r, s.color.g, s.color.b, s.color.a)) const positions = stops.map((s) => s.position) @@ -1664,12 +1653,14 @@ export class SkiaRenderer { } private applyImageFill(fill: Fill, node: SceneNode, graph: SceneGraph): void { - let img = this.imageCache.get(fill.imageHash!) + const hash = fill.imageHash + if (!hash) return + let img = this.imageCache.get(hash) if (!img) { - const data = graph.images.get(fill.imageHash!) + const data = graph.images.get(hash) if (!data) return img = this.ck.MakeImageFromEncoded(data) ?? undefined - if (img) this.imageCache.set(fill.imageHash!, img) + if (img) this.imageCache.set(hash, img) else return } @@ -1685,7 +1676,6 @@ export class SkiaRenderer { sx = (imgW - sw) / 2 sy = (imgH - sh) / 2 } else if (scaleMode === 'FIT') { - const scale = Math.min(node.width / imgW, node.height / imgH) sw = imgW sh = imgH sx = 0 diff --git a/src/engine/scene-graph.ts b/src/engine/scene-graph.ts index 6d6d9005b..4d864b5cf 100644 --- a/src/engine/scene-graph.ts +++ b/src/engine/scene-graph.ts @@ -635,6 +635,15 @@ export class SceneGraph { 'clipsContent' ] + private static copyProp( + target: Partial | SceneNode, + source: SceneNode, + key: K + ): void { + const val = source[key] + target[key] = (Array.isArray(val) ? structuredClone(val) : val) as SceneNode[K] + } + createInstance( componentId: string, parentId: string, @@ -645,10 +654,7 @@ export class SceneGraph { const props: Partial = { name: component.name, componentId } for (const key of SceneGraph.INSTANCE_SYNC_PROPS) { - const val = component[key] - ;(props as Record)[key] = Array.isArray(val) - ? val.map((v: Record) => ({ ...v })) - : val + SceneGraph.copyProp(props, component, key) } const instance = this.createNode('INSTANCE', parentId, { ...props, ...overrides }) @@ -685,12 +691,8 @@ export class SceneGraph { for (const instance of this.getInstances(componentId)) { // Sync instance-level props (unless overridden) for (const key of SceneGraph.INSTANCE_SYNC_PROPS) { - const overrideKey = `${key}` - if (overrideKey in instance.overrides) continue - const val = component[key] - ;(instance as Record)[key] = Array.isArray(val) - ? val.map((v: Record) => ({ ...v })) - : val + if (key in instance.overrides) continue + SceneGraph.copyProp(instance, component, key) } // Sync children: match by componentId @@ -739,17 +741,13 @@ export class SceneGraph { for (const key of SceneGraph.INSTANCE_SYNC_PROPS) { const overrideKey = `${instChild.id}:${key}` if (overrideKey in overrides) continue - const val = compChild[key] - ;(instChild as Record)[key] = Array.isArray(val) - ? val.map((v: Record) => ({ ...v })) - : val + SceneGraph.copyProp(instChild, compChild, key) } - // Sync name and text unless overridden for (const key of ['name', 'text', 'fontSize', 'fontWeight', 'fontFamily'] as const) { const overrideKey = `${instChild.id}:${key}` if (overrideKey in overrides) continue - ;(instChild as Record)[key] = compChild[key] + SceneGraph.copyProp(instChild, compChild, key) } if (compChild.childIds.length > 0) { diff --git a/src/engine/vector.ts b/src/engine/vector.ts index bb17d9d12..8ce6159fd 100644 --- a/src/engine/vector.ts +++ b/src/engine/vector.ts @@ -256,8 +256,8 @@ function buildChains(segments: VectorSegment[], _vertexCount: number): number[][ const s = segments[i] if (!adj.has(s.start)) adj.set(s.start, []) if (!adj.has(s.end)) adj.set(s.end, []) - adj.get(s.start)!.push(i) - adj.get(s.end)!.push(i) + adj.get(s.start)?.push(i) + adj.get(s.end)?.push(i) } const visited = new Set() diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 000000000..9c7c7481b --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,9 @@ +interface FilePickerOptions { + types?: { description: string; accept: Record }[] + suggestedName?: string +} + +interface Window { + showOpenFilePicker?(options?: FilePickerOptions): Promise + showSaveFilePicker?(options?: FilePickerOptions): Promise +} diff --git a/src/kiwi/client.ts b/src/kiwi/client.ts index 6797f5f8e..056aecbd0 100644 --- a/src/kiwi/client.ts +++ b/src/kiwi/client.ts @@ -108,7 +108,8 @@ export class FigmaMultiplayerClient { // Skip non-Kiwi message data (e.g., schema definitions) if (!isKiwiMessage(decompressed)) return - const msgType = getKiwiMessageType(decompressed)! + const msgType = getKiwiMessageType(decompressed) + if (!msgType) return // JOIN_END: sync complete if (msgType === MESSAGE_TYPES.JOIN_END) { @@ -222,7 +223,7 @@ export class FigmaMultiplayerClient { } catch {} } - this.ws!.addEventListener('message', handler) + this.ws?.addEventListener('message', handler) this.sendNodeChanges(nodeChanges) }) } diff --git a/src/kiwi/codec.ts b/src/kiwi/codec.ts index f0afea4d0..671882698 100644 --- a/src/kiwi/codec.ts +++ b/src/kiwi/codec.ts @@ -18,6 +18,8 @@ import type { Schema } from './kiwi-schema' interface CompiledSchema { encodeMessage(message: unknown): Uint8Array decodeMessage(data: Uint8Array): unknown + encodePaint(paint: unknown): Uint8Array + encodeNodeChange(nodeChange: unknown): Uint8Array } let compiledSchema: CompiledSchema | null = null @@ -133,7 +135,7 @@ export function encodeMessage(message: FigmaMessage): Uint8Array { const ncHex = Buffer.from(ncBytes).toString('hex') const finalHex = beforeArray + ncHex + afterArray - const finalBytes = new Uint8Array(finalHex.match(/.{2}/g)!.map((b) => parseInt(b, 16))) + const finalBytes = new Uint8Array(finalHex.match(/.{2}/g)?.map((b) => parseInt(b, 16)) ?? []) return compress(finalBytes) } @@ -196,7 +198,11 @@ export interface Paint { opacity?: number visible?: boolean blendMode?: string - colorVariableBinding?: VariableBinding // Binds color to a Figma variable + stops?: { color: Color; position: number }[] + transform?: { m00: number; m01: number; m02: number; m10: number; m11: number; m12: number } + image?: { hash: string } + imageScaleMode?: string + colorVariableBinding?: VariableBinding } export interface Effect { @@ -206,6 +212,7 @@ export interface Effect { radius?: number visible?: boolean spread?: number + blendMode?: string } export interface NodeChange { @@ -225,6 +232,9 @@ export interface NodeChange { strokePaints?: Paint[] strokeWeight?: number strokeAlign?: string + strokeCap?: string + strokeJoin?: string + dashPattern?: number[] effects?: Effect[] // Layout stackMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL' @@ -403,11 +413,9 @@ export function encodePaintWithVariableBinding( throw new Error('Codec not initialized. Call initCodec() first.') } - // Encode base paint without variable binding - const basePaint = { ...paint } - delete (basePaint as any).colorVariableBinding + const { colorVariableBinding: _, ...basePaint } = paint - const baseBytes = (compiledSchema as any).encodePaint(basePaint) + const baseBytes = compiledSchema.encodePaint(basePaint) const baseArray = Array.from(baseBytes) as number[] // Remove trailing 00 @@ -439,8 +447,8 @@ export function parseVariableId(variableId: string): { sessionID: number; localI const match = variableId.match(/VariableID:(\d+):(\d+)/) if (!match) return null return { - sessionID: parseInt(match[1]!, 10), - localID: parseInt(match[2]!, 10) + sessionID: parseInt(match[1] ?? '0', 10), + localID: parseInt(match[2] ?? '0', 10) } } @@ -457,28 +465,24 @@ export function encodeNodeChangeWithVariables(nodeChange: NodeChange): Uint8Arra const hasStrokeBinding = nodeChange.strokePaints?.some((p) => p.colorVariableBinding) if (!hasFillBinding && !hasStrokeBinding) { - return (compiledSchema as any).encodeNodeChange(nodeChange) + return compiledSchema.encodeNodeChange(nodeChange) } // Create a copy without variable bindings for base encoding const cleanNodeChange = { ...nodeChange } if (cleanNodeChange.fillPaints) { - cleanNodeChange.fillPaints = cleanNodeChange.fillPaints.map((p) => { - const clean = { ...p } - delete (clean as any).colorVariableBinding - return clean - }) + cleanNodeChange.fillPaints = cleanNodeChange.fillPaints.map( + ({ colorVariableBinding: _, ...rest }) => rest + ) } if (cleanNodeChange.strokePaints) { - cleanNodeChange.strokePaints = cleanNodeChange.strokePaints.map((p) => { - const clean = { ...p } - delete (clean as any).colorVariableBinding - return clean - }) + cleanNodeChange.strokePaints = cleanNodeChange.strokePaints.map( + ({ colorVariableBinding: _, ...rest }) => rest + ) } // Encode clean version - const baseBytes = (compiledSchema as any).encodeNodeChange(cleanNodeChange) + const baseBytes = compiledSchema.encodeNodeChange(cleanNodeChange) let hex = Buffer.from(baseBytes).toString('hex') // Inject fill variable binding (field 38 = 0x26) @@ -493,7 +497,7 @@ export function encodeNodeChangeWithVariables(nodeChange: NodeChange): Uint8Arra hex = injectVariableBinding(hex, '2701', strokeBinding) } - return new Uint8Array(hex.match(/.{2}/g)!.map((b) => parseInt(b, 16))) + return new Uint8Array(hex.match(/.{2}/g)?.map((b) => parseInt(b, 16)) ?? []) } /** diff --git a/src/kiwi/fig-import.ts b/src/kiwi/fig-import.ts index 2f3eccace..9ec3a4d6d 100644 --- a/src/kiwi/fig-import.ts +++ b/src/kiwi/fig-import.ts @@ -10,7 +10,6 @@ import type { Color, BlendMode, ImageScaleMode, - GradientStop, GradientTransform, StrokeCap, StrokeJoin, @@ -48,7 +47,14 @@ function imageHashToString(hash: Record): string { return bytes.map((b) => b.toString(16).padStart(2, '0')).join('') } -function convertGradientTransform(t?: Record): GradientTransform | undefined { +function convertGradientTransform(t?: { + m00: number + m01: number + m02: number + m10: number + m11: number + m12: number +}): GradientTransform | undefined { if (!t) return undefined return { m00: t.m00, m01: t.m01, m02: t.m02, m10: t.m10, m11: t.m11, m12: t.m12 } } @@ -65,25 +71,28 @@ function convertFills(paints?: Paint[]): Fill[] { } if (p.type?.startsWith('GRADIENT') && p.stops) { - base.gradientStops = p.stops.map( - (s: { color: Record; position: number }) => ({ - color: convertColor(s.color as Color), - position: s.position - }) - ) - base.gradientTransform = convertGradientTransform(p.transform as Record) + base.gradientStops = p.stops.map((s) => ({ + color: convertColor(s.color), + position: s.position + })) + if (p.transform) { + base.gradientTransform = convertGradientTransform(p.transform) + } } if (p.type === 'IMAGE') { - const pAny = p as Record - if (pAny.image && typeof pAny.image === 'object') { - const img = pAny.image as Record - if (img.hash && typeof img.hash === 'object') { - base.imageHash = imageHashToString(img.hash as Record) + if (p.image && typeof p.image === 'object') { + const img = p.image as { hash: string | Record } + if (typeof img.hash === 'object') { + base.imageHash = imageHashToString(img.hash) + } else if (typeof img.hash === 'string') { + base.imageHash = img.hash } } - base.imageScaleMode = (pAny.imageScaleMode as ImageScaleMode) ?? 'FILL' - base.imageTransform = convertGradientTransform(p.transform as Record) + base.imageScaleMode = (p.imageScaleMode as ImageScaleMode) ?? 'FILL' + if (p.transform) { + base.imageTransform = convertGradientTransform(p.transform) + } } return base @@ -124,7 +133,7 @@ function convertEffects(effects?: KiwiEffect[]): Effect[] { radius: e.radius ?? 0, spread: e.spread ?? 0, visible: e.visible ?? true, - blendMode: ((e as Record).blendMode as BlendMode) ?? 'NORMAL' + blendMode: (e.blendMode as BlendMode) ?? 'NORMAL' })) } diff --git a/src/stores/editor.ts b/src/stores/editor.ts index db86db275..90d32edf7 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -539,9 +539,9 @@ export function createEditorStore() { return } - if ('showSaveFilePicker' in window) { + if (window.showSaveFilePicker) { try { - const handle = await (window as any).showSaveFilePicker({ + const handle = await window.showSaveFilePicker({ suggestedName: 'Untitled.fig', types: [ { @@ -559,7 +559,7 @@ export function createEditorStore() { } } - const blob = new Blob([data], { type: 'application/octet-stream' }) + const blob = new Blob([new Uint8Array(data)], { type: 'application/octet-stream' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url @@ -576,7 +576,7 @@ export function createEditorStore() { } if (fileHandle) { const writable = await fileHandle.createWritable() - await writable.write(data) + await writable.write(new Uint8Array(data)) await writable.close() } } @@ -681,7 +681,8 @@ export function createEditorStore() { runLayoutForNode(id) const finalState: Partial = {} - const updated = graph.getNode(id)! + const updated = graph.getNode(id) + if (!updated) return for (const key of Object.keys(previous) as (keyof SceneNode)[]) { ;(finalState as Record)[key] = updated[key] } @@ -1124,7 +1125,8 @@ export function createEditorStore() { const childIds = [...node.childIds] const prevSelection = new Set(state.selectedIds) const origPositions = childIds.map((id) => { - const child = graph.getNode(id)! + const child = graph.getNode(id) + if (!child) return { id, x: 0, y: 0 } return { id, x: child.x, y: child.y } }) const groupSnapshot = { ...node, childIds: [...node.childIds] }