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
This commit is contained in:
Danila Poyarkov 2026-02-28 22:57:33 +03:00
parent 046f23b138
commit 18609e00c3
19 changed files with 181 additions and 128 deletions

1
components.d.ts vendored
View file

@ -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']

View file

@ -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.*"]

View file

@ -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,

View file

@ -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()

View file

@ -39,9 +39,11 @@ function defaultEffect(): Effect {
}
function updateEffect(index: number, changes: Partial<Effect>) {
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<Effect> = { 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

View file

@ -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()
})

View file

@ -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',

View file

@ -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)

View file

@ -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,

View file

@ -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<string, unknown> = {
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 }))

View file

@ -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<Blob>
}[]
>
}
}
export interface FontInfo {
family: string
fullName: string
@ -19,9 +33,9 @@ export function getFontProvider(): TypefaceFontProvider | null {
}
export async function queryFonts(): Promise<FontInfo[]> {
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<string>()
const result: FontInfo[] = []
for (const f of fonts) {
@ -53,15 +67,16 @@ const BUNDLED_FONTS: Record<string, string> = {
export async function loadFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
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)

View file

@ -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<string>): 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<string, number> = {
NONE: 0,
ROUND: 1,
SQUARE: 2
const capMap: Record<string, EmbindEnumEntity> = {
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<string, number> = {
MITER: 0,
ROUND: 1,
BEVEL: 2
const joinMap: Record<string, EmbindEnumEntity> = {
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

View file

@ -635,6 +635,15 @@ export class SceneGraph {
'clipsContent'
]
private static copyProp<K extends keyof SceneNode>(
target: Partial<SceneNode> | 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<SceneNode> = { name: component.name, componentId }
for (const key of SceneGraph.INSTANCE_SYNC_PROPS) {
const val = component[key]
;(props as Record<string, unknown>)[key] = Array.isArray(val)
? val.map((v: Record<string, unknown>) => ({ ...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<string, unknown>)[key] = Array.isArray(val)
? val.map((v: Record<string, unknown>) => ({ ...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<string, unknown>)[key] = Array.isArray(val)
? val.map((v: Record<string, unknown>) => ({ ...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<string, unknown>)[key] = compChild[key]
SceneGraph.copyProp(instChild, compChild, key)
}
if (compChild.childIds.length > 0) {

View file

@ -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<number>()

9
src/global.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
interface FilePickerOptions {
types?: { description: string; accept: Record<string, string[]> }[]
suggestedName?: string
}
interface Window {
showOpenFilePicker?(options?: FilePickerOptions): Promise<FileSystemFileHandle[]>
showSaveFilePicker?(options?: FilePickerOptions): Promise<FileSystemFileHandle>
}

View file

@ -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)
})
}

View file

@ -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)) ?? [])
}
/**

View file

@ -10,7 +10,6 @@ import type {
Color,
BlendMode,
ImageScaleMode,
GradientStop,
GradientTransform,
StrokeCap,
StrokeJoin,
@ -48,7 +47,14 @@ function imageHashToString(hash: Record<string, number>): string {
return bytes.map((b) => b.toString(16).padStart(2, '0')).join('')
}
function convertGradientTransform(t?: Record<string, number>): 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<string, number>; position: number }) => ({
color: convertColor(s.color as Color),
position: s.position
})
)
base.gradientTransform = convertGradientTransform(p.transform as Record<string, number>)
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<string, unknown>
if (pAny.image && typeof pAny.image === 'object') {
const img = pAny.image as Record<string, unknown>
if (img.hash && typeof img.hash === 'object') {
base.imageHash = imageHashToString(img.hash as Record<string, number>)
if (p.image && typeof p.image === 'object') {
const img = p.image as { hash: string | Record<string, number> }
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<string, number>)
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<string, unknown>).blendMode as BlendMode) ?? 'NORMAL'
blendMode: (e.blendMode as BlendMode) ?? 'NORMAL'
}))
}

View file

@ -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<SceneNode> = {}
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<string, unknown>)[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] }