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:
parent
046f23b138
commit
18609e00c3
1
components.d.ts
vendored
1
components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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.*"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
9
src/global.d.ts
vendored
Normal 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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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] }
|
||||
|
|
|
|||
Loading…
Reference in a new issue