Lint all packages: fix 193 errors across core, mcp, acp

Extend lint coverage from src/ to all packages (178 files, 140 rules).

Core (37 files):
- Remove 98 unnecessary conditions (dead ?./?? on non-nullish values)
- Remove 38 unnecessary type assertions (! and as casts)
- Convert 12 patterns to optional chain (a && a.b → a?.b)
- Fix 10 consistent-type-imports (merge duplicates, inline type keyword)
- Add parentheses to 16 nested ternaries
- Convert 4 indexed for-loops to for-of
- Merge 4 duplicate import statements

MCP (3 files):
- Replace typeof undefined check with 'in' operator
- Capture graph in local const to avoid ! assertions

ACP (1 file):
- Fix 5 TypeScript errors (type predicates, object shapes, dead code)
- Remove unnecessary template expression

Vendored kiwi-schema gets broader rule overrides to avoid touching it.
Core complexity stays as warning (24 functions over limit).
This commit is contained in:
Danila Poyarkov 2026-03-09 13:18:08 +03:00
parent d9154ef29d
commit f3eac5ac8b
38 changed files with 239 additions and 251 deletions

View file

@ -92,7 +92,13 @@
"files": ["**/kiwi/kiwi-schema/**"],
"rules": {
"typescript/no-explicit-any": "off",
"typescript/no-non-null-assertion": "off"
"typescript/no-non-null-assertion": "off",
"typescript/prefer-for-of": "off",
"typescript/prefer-optional-chain": "off",
"typescript/consistent-type-imports": "off",
"typescript/no-unnecessary-condition": "off",
"import/no-mutable-exports": "off",
"complexity": "off"
}
},
{
@ -110,6 +116,12 @@
"rules": {
"open-pencil/no-raw-console-format": "error"
}
},
{
"files": ["packages/core/src/**"],
"rules": {
"complexity": "warn"
}
}
],
"ignorePatterns": ["node_modules", "dist", "desktop", "*.config.*"]

View file

@ -12,7 +12,7 @@
"build": "bun run lint && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "oxlint -c oxlint.json --type-aware --type-check src/ packages/cli/src/",
"lint": "oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/acp/src/",
"format": "oxfmt --write src/",
"check": "bun run lint",
"test": "playwright test --project=openpencil",

View file

@ -152,7 +152,7 @@ export function createAgent(
commands.push({
name: bt.name,
description: bt.description,
input: { type: 'unstructured' }
input: { hint: 'JSON arguments or key=value pairs' }
})
}
@ -160,7 +160,7 @@ export function createAgent(
commands.push({
name: def.name,
description: def.description,
input: { type: 'unstructured' }
input: { hint: 'JSON arguments or key=value pairs' }
})
}
@ -216,7 +216,7 @@ export function createAgent(
const signal = session.pendingPrompt.signal
const userText = params.prompt
.filter((b): b is acp.TextContent => b.type === 'text')
.filter((b): b is acp.TextContent & { type: 'text' } => b.type === 'text')
.map((b) => b.text)
.join('\n')
@ -290,26 +290,13 @@ export function createAgent(
update: {
sessionUpdate: 'tool_call',
toolCallId,
title: `${toolName}`,
title: toolName,
kind,
status: 'pending',
rawInput: toolArgs
}
})
if (signal.aborted) {
await connection.sessionUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId,
status: 'cancelled'
}
})
session.pendingPrompt = null
return { stopReason: 'cancelled' }
}
await connection.sessionUpdate({
sessionId: params.sessionId,
update: {
@ -324,8 +311,8 @@ export function createAgent(
if (builtin) {
result = await builtin.execute(toolArgs, session)
} else {
result = await coreTool!.execute(makeFigma(session), toolArgs)
} else if (coreTool) {
result = await coreTool.execute(makeFigma(session), toolArgs)
}
await connection.sessionUpdate({

View file

@ -62,5 +62,5 @@ export async function getCanvasKit(options?: CanvasKitOptions): Promise<CanvasKi
})
}
return instance!
return instance
}

View file

@ -50,7 +50,7 @@ export async function parseFigmaClipboard(
}
const blobs: Uint8Array[] = (msg.blobs ?? []).map((b) =>
b.bytes instanceof Uint8Array ? b.bytes : new Uint8Array(Object.values(b.bytes) as number[])
b.bytes instanceof Uint8Array ? b.bytes : new Uint8Array(Object.values(b.bytes))
)
return { nodes: msg.nodeChanges ?? [], meta, blobs }
@ -87,13 +87,12 @@ export function figmaNodesBounds(
const parentTypes = new Map<string, string>()
for (const nc of nodeChanges) {
if (!nc.guid) continue
const id = `${nc.guid.sessionID}:${nc.guid.localID}`
parentTypes.set(id, nc.type ?? '')
}
for (const nc of nodeChanges) {
if (!nc.guid || !nc.type || NON_VISUAL_TYPES.has(nc.type)) continue
if (!nc.type || NON_VISUAL_TYPES.has(nc.type)) continue
const parentId = nc.parentIndex?.guid
? `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}`
: null
@ -125,7 +124,6 @@ export function importClipboardNodes(
const guidMap = new Map<string, KiwiNodeChange>()
const parentMap = new Map<string, string>()
for (const nc of nodeChanges) {
if (!nc.guid) continue
const id = `${nc.guid.sessionID}:${nc.guid.localID}`
guidMap.set(id, nc)
if (nc.parentIndex?.guid) {
@ -210,7 +208,7 @@ export function importClipboardNodes(
// Remap componentId from original Figma GUIDs to our node IDs
for (const [, ourId] of created) {
const node = graph.getNode(ourId)
if (!node || node.type !== 'INSTANCE' || !node.componentId) continue
if (node?.type !== 'INSTANCE' || !node.componentId) continue
const ourComponentId = created.get(node.componentId)
if (ourComponentId) graph.updateNode(ourId, { componentId: ourComponentId })
}
@ -232,8 +230,8 @@ export function importClipboardNodes(
// so the node at least renders its own fills/strokes/layout.
for (const [, ourId] of created) {
const node = graph.getNode(ourId)
if (!node || node.type !== 'INSTANCE') continue
if (node.childIds.length === 0 && !graph.getNode(node.componentId)) {
if (node?.type !== 'INSTANCE') continue
if (node.childIds.length === 0 && (!node.componentId || !graph.getNode(node.componentId))) {
graph.updateNode(ourId, { type: 'FRAME', componentId: '' })
}
}

View file

@ -11,9 +11,9 @@ export function parseColor(input: string): Color {
if (!parsed) return { ...BLACK }
const rgb = toRgb(parsed)
return {
r: rgb?.r ?? 0,
g: rgb?.g ?? 0,
b: rgb?.b ?? 0,
r: rgb.r,
g: rgb.g,
b: rgb.b,
a: parsed.alpha ?? 1
}
}
@ -24,14 +24,14 @@ export function normalizeColor(color?: Partial<Color>): Color {
}
export function colorToHex(color: Color): string {
return (formatHex({ mode: 'rgb', r: color.r, g: color.g, b: color.b }) ?? '#000000').toUpperCase()
return formatHex({ mode: 'rgb', r: color.r, g: color.g, b: color.b }).toUpperCase()
}
export function colorToHex8(color: Color, alpha?: number): string {
const a = alpha ?? color.a
if (a >= 1) return colorToHex(color)
return (
formatHex8({ mode: 'rgb', r: color.r, g: color.g, b: color.b, alpha: a }) ?? '#000000FF'
formatHex8({ mode: 'rgb', r: color.r, g: color.g, b: color.b, alpha: a })
).toUpperCase()
}
@ -49,7 +49,7 @@ export function colorToRgba255(color: Color) {
}
export function colorToCSS(color: Color): string {
return formatRgb({ mode: 'rgb', r: color.r, g: color.g, b: color.b, alpha: color.a }) ?? 'rgb(0, 0, 0)'
return formatRgb({ mode: 'rgb', r: color.r, g: color.g, b: color.b, alpha: color.a })
}
export function colorToCSSCompact(color: Color): string {

View file

@ -25,14 +25,14 @@ function variableValueToKiwi(
value: VariableValue,
type: string
): { value: Record<string, unknown>; dataType: string; resolvedDataType: string } {
if (typeof value === 'object' && value !== null && 'aliasId' in value) {
if (typeof value === 'object' && 'aliasId' in value) {
return {
value: { alias: { guid: stringToGuid(value.aliasId) } },
dataType: 'ALIAS',
resolvedDataType: type === 'COLOR' ? 'COLOR' : type === 'BOOLEAN' ? 'BOOLEAN' : type === 'STRING' ? 'STRING' : 'FLOAT'
resolvedDataType: { COLOR: 'COLOR', BOOLEAN: 'BOOLEAN', STRING: 'STRING' }[type] ?? 'FLOAT'
}
}
if (type === 'COLOR' && typeof value === 'object' && value !== null && 'r' in value) {
if (type === 'COLOR' && typeof value === 'object' && 'r' in value) {
return {
value: { colorValue: { r: value.r, g: value.g, b: value.b, a: value.a } },
dataType: 'COLOR',
@ -165,14 +165,8 @@ export async function exportFigFile(
if (!variable) continue
const varGuid = stringToGuid(varId)
const resolvedType =
variable.type === 'COLOR'
? 'COLOR'
: variable.type === 'BOOLEAN'
? 'BOOLEAN'
: variable.type === 'STRING'
? 'STRING'
: 'FLOAT'
const typeMap: Record<string, string> = { COLOR: 'COLOR', BOOLEAN: 'BOOLEAN', STRING: 'STRING' }
const resolvedType = typeMap[variable.type] ?? 'FLOAT'
const entries = Object.entries(variable.valuesByMode).map(([modeId, value]) => ({
modeID: stringToGuid(modeId),

View file

@ -1,6 +1,5 @@
import { SceneGraph } from './scene-graph'
import type {
SceneGraph,
SceneNode,
NodeType,
Fill,
@ -234,11 +233,11 @@ class FigmaNodeProxy {
set cornerRadius(v: number | typeof MIXED) {
if (v === MIXED) return
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
cornerRadius: v as number,
topLeftRadius: v as number,
topRightRadius: v as number,
bottomRightRadius: v as number,
bottomLeftRadius: v as number,
cornerRadius: v,
topLeftRadius: v,
topRightRadius: v,
bottomRightRadius: v,
bottomLeftRadius: v,
independentCorners: false
})
}
@ -705,7 +704,7 @@ class FigmaNodeProxy {
set layoutAlign(v: string) {
const mapped = v === 'STRETCH' ? 'STRETCH' : 'AUTO'
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
layoutAlignSelf: mapped as SceneNode['layoutAlignSelf']
layoutAlignSelf: mapped
})
}
@ -1101,7 +1100,7 @@ export class FigmaAPI {
ungroup(node: FigmaNodeProxy): void {
const raw = this.graph.getNode(node[INTERNAL_ID])
if (!raw || raw.type !== 'GROUP') return
if (raw?.type !== 'GROUP') return
const parentId = raw.parentId ?? this._currentPageId
for (const childId of [...raw.childIds]) {
this.graph.reparentNode(childId, parentId)
@ -1293,10 +1292,13 @@ export class FigmaAPI {
notify(message: string): { cancel: () => void } {
if (typeof console !== 'undefined') console.log(`[figma.notify] ${message}`)
// eslint-disable-next-line no-empty-function
return { cancel() {} }
}
// eslint-disable-next-line no-empty-function
commitUndo(): void {}
// eslint-disable-next-line no-empty-function
triggerUndo(): void {}
exportImage?: (

View file

@ -5,7 +5,8 @@ import {
CJK_FALLBACK_FAMILIES_MACOS,
CJK_FALLBACK_FAMILIES_WINDOWS,
CJK_FALLBACK_FAMILIES_LINUX,
CJK_GOOGLE_FONT
CJK_GOOGLE_FONT,
GOOGLE_FONTS_API_KEY
} from './constants'
import type { SceneGraph } from './scene-graph'
@ -59,8 +60,6 @@ const BUNDLED_FONTS: Record<string, string> = {
'Inter|Regular': '/Inter-Regular.ttf'
}
import { GOOGLE_FONTS_API_KEY } from './constants'
const googleFontsCache = new Map<string, Record<string, string>>()
const googleFontsFailed = new Set<string>()
@ -136,7 +135,7 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
}
// Try local font access API first (browser only)
if (typeof window !== 'undefined' && window.queryLocalFonts) {
if (window.queryLocalFonts) {
try {
const fonts = await window.queryLocalFonts()
const normalized = normalizeFontFamily(family)
@ -261,7 +260,7 @@ export function collectFontKeys(graph: SceneGraph, nodeIds: string[]): Array<[st
fontKeys.add(`${family}\0${weightToStyle(node.fontWeight || 400, node.italic)}`)
for (const run of node.styleRuns) {
const f = run.style.fontFamily ?? family
const w = run.style.fontWeight ?? node.fontWeight ?? 400
const w = run.style.fontWeight ?? node.fontWeight
const i = run.style.italic ?? node.italic
fontKeys.add(`${f}\0${weightToStyle(w, i)}`)
}

View file

@ -13,7 +13,7 @@ import type { GUID } from './types'
const fontDigestCache = new Map<string, Uint8Array>()
async function computeFontDigest(data: ArrayBuffer): Promise<Uint8Array> {
if (typeof crypto !== 'undefined' && crypto.subtle) {
if (typeof crypto !== 'undefined') {
const hash = await crypto.subtle.digest('SHA-1', data)
return new Uint8Array(hash)
}
@ -172,7 +172,7 @@ function buildDerivedTextData(
const key = `${family}|${style}`
if (seen.has(key)) return
seen.add(key)
fontMeta!.push({
fontMeta.push({
key: { family, style, postscript: '' },
fontLineHeight: 1.2,
fontDigest: digestMap.get(key),
@ -421,7 +421,8 @@ export function sceneNodeToKiwi(
const variable = graph.variables.get(varId)
if (!variable) continue
const varGuid = stringToGuid(varId)
const resolvedType = variable.type === 'COLOR' ? 'COLOR' : variable.type === 'BOOLEAN' ? 'BOOLEAN' : variable.type === 'STRING' ? 'STRING' : 'FLOAT'
const typeMap: Record<string, string> = { COLOR: 'COLOR', BOOLEAN: 'BOOLEAN', STRING: 'STRING' }
const resolvedType = typeMap[variable.type] ?? 'FLOAT'
entries.push({
variableData: {
value: { alias: { guid: varGuid } },

View file

@ -29,7 +29,7 @@ let compiledSchema: CompiledSchema | null = null
*/
export async function initCodec(): Promise<void> {
if (compiledSchema) return
compiledSchema = compileSchema(figmaSchema as Schema) as CompiledSchema
compiledSchema = compileSchema(figmaSchema) as CompiledSchema
}
export function getCompiledSchema() {
@ -38,7 +38,7 @@ export function getCompiledSchema() {
}
export function getSchemaBytes(): Uint8Array {
return encodeBinarySchema(figmaSchema as Schema)
return encodeBinarySchema(figmaSchema)
}
/**
@ -477,7 +477,7 @@ export function encodePaintWithVariableBinding(
const { colorVariableBinding: _, ...basePaint } = paint
const baseBytes = compiledSchema.encodePaint(basePaint)
const baseArray = Array.from(baseBytes) as number[]
const baseArray = Array.from(baseBytes)
// Remove trailing 00
if (baseArray[baseArray.length - 1] === 0) {

View file

@ -90,7 +90,7 @@ export async function parseFigFile(buffer: ArrayBuffer): Promise<SceneGraph> {
}
const blobs: Uint8Array[] = (message.blobs ?? []).map((b) =>
b.bytes instanceof Uint8Array ? b.bytes : new Uint8Array(Object.values(b.bytes) as number[])
b.bytes instanceof Uint8Array ? b.bytes : new Uint8Array(Object.values(b.bytes))
)
const images = new Map<string, Uint8Array>()

View file

@ -35,7 +35,6 @@ export function importNodeChanges(
const childrenMap = new Map<string, string[]>()
for (const nc of nodeChanges) {
if (!nc.guid) continue
if (nc.phase === 'REMOVED') continue
const id = guidToString(nc.guid)
changeMap.set(id, nc)
@ -87,8 +86,8 @@ export function importNodeChanges(
if (nc.type !== 'VARIABLE_SET') continue
const modes = (nc.variableSetModes ?? []).map((m) => {
const modeId = m.id ? guidToString(m.id) : 'default'
return { modeId, name: m.name ?? 'Mode' }
const modeId = guidToString(m.id)
return { modeId, name: m.name }
})
if (modes.length === 0) modes.push({ modeId: 'default', name: 'Default' })
@ -150,7 +149,8 @@ export function importNodeChanges(
if (Object.keys(valuesByMode).length === 0) {
const col = graph.variableCollections.get(collectionId)
const defaultMode = col?.defaultModeId ?? 'default'
valuesByMode[defaultMode] = type === 'BOOLEAN' ? false : type === 'STRING' ? '' : type === 'COLOR' ? { r: 0, g: 0, b: 0, a: 1 } : 0
const defaultValue = type === 'BOOLEAN' ? false : (type === 'STRING' ? '' : null)
valuesByMode[defaultMode] = defaultValue ?? (type === 'COLOR' ? { r: 0, g: 0, b: 0, a: 1 } : 0)
}
graph.addVariable({

View file

@ -88,7 +88,7 @@ export function populateAndApplyOverrides(
while (populateQueue.length > 0) {
const nodeId = populateQueue.pop()!
const node = graph.getNode(nodeId)
if (!node || node.type !== 'INSTANCE' || !node.componentId || node.childIds.length > 0) continue
if (node?.type !== 'INSTANCE' || !node.componentId || node.childIds.length > 0) continue
const comp = graph.getNode(node.componentId)
if (comp && comp.childIds.length > 0) {
graph.populateInstanceChildren(nodeId, node.componentId)
@ -245,7 +245,7 @@ export function populateAndApplyOverrides(
function repopulateInstance(nodeId: string, compId: string) {
const node = graph.getNode(nodeId)
if (!node || node.type !== 'INSTANCE') return
if (node?.type !== 'INSTANCE') return
for (const childId of [...node.childIds]) graph.deleteNode(childId)
graph.updateNode(nodeId, { componentId: compId })
@ -285,7 +285,7 @@ export function populateAndApplyOverrides(
if (ownAssignments) {
const valueByDef = new Map<string, ComponentPropAssignment['value']>()
for (const a of ownAssignments) {
if (a.defID) valueByDef.set(guidToString(a.defID), a.value)
valueByDef.set(guidToString(a.defID), a.value)
}
applyPropAssignments(node.id, valueByDef, propRefsMap)
}
@ -303,7 +303,7 @@ export function populateAndApplyOverrides(
const valueByDef = new Map<string, ComponentPropAssignment['value']>()
for (const a of assignments) {
if (a.defID) valueByDef.set(guidToString(a.defID), a.value)
valueByDef.set(guidToString(a.defID), a.value)
}
applyPropAssignments(node.id, valueByDef, propRefsMap)
}
@ -329,7 +329,7 @@ export function populateAndApplyOverrides(
const valueByDef = new Map<string, ComponentPropAssignment['value']>()
for (const a of ov.componentPropAssignments) {
if (a.defID) valueByDef.set(guidToString(a.defID), a.value)
valueByDef.set(guidToString(a.defID), a.value)
}
applyPropAssignments(targetId, valueByDef, propRefsMap)
}
@ -354,7 +354,6 @@ export function populateAndApplyOverrides(
const refs = findPropRefs(child.componentId, propRefsMap)
if (refs) {
for (const ref of refs) {
if (!ref.defID) continue
const val = valueByDef.get(guidToString(ref.defID))
if (!val) continue
@ -387,7 +386,7 @@ export function populateAndApplyOverrides(
while (o < scaled.length) {
const cmd = scaled[o++]
if (cmd === 0) continue
const coords = cmd === 1 || cmd === 2 ? 1 : cmd === 4 ? 3 : -1
const coords = cmd === 1 || cmd === 2 ? 1 : (cmd === 4 ? 3 : -1)
if (coords < 0) {
console.warn(`scaleGeometryBlobs: unknown path command ${cmd} at offset ${o - 1}`)
break
@ -414,8 +413,7 @@ export function populateAndApplyOverrides(
const nodeId = guidToNodeId.get(ncId)
if (!nodeId) continue
for (let i = 0; i < derived.length; i++) {
const d = derived[i]
for (const d of derived) {
const guids = d.guidPath?.guids
if (!guids?.length) continue
@ -549,16 +547,16 @@ export function populateAndApplyOverrides(
function syncNodeProps(source: SceneNode, target: SceneNode) {
const updates: Partial<SceneNode> = {}
if (source.text !== undefined && source.text !== target.text) updates.text = source.text
if (source.visible !== undefined && source.visible !== target.visible) updates.visible = source.visible
if (source.opacity !== undefined && source.opacity !== target.opacity) updates.opacity = source.opacity
if (source.fills !== undefined && source.fills !== target.fills) updates.fills = copyFills(source.fills)
if (source.strokes !== undefined && source.strokes !== target.strokes) updates.strokes = copyStrokes(source.strokes)
if (source.effects !== undefined && source.effects !== target.effects) updates.effects = copyEffects(source.effects)
if (source.styleRuns !== undefined && source.styleRuns !== target.styleRuns) updates.styleRuns = copyStyleRuns(source.styleRuns)
if (source.layoutGrow !== undefined && source.layoutGrow !== target.layoutGrow) updates.layoutGrow = source.layoutGrow
if (source.textAutoResize !== undefined && source.textAutoResize !== target.textAutoResize) updates.textAutoResize = source.textAutoResize
if (source.locked !== undefined && source.locked !== target.locked) updates.locked = source.locked
if (source.text !== target.text) updates.text = source.text
if (source.visible !== target.visible) updates.visible = source.visible
if (source.opacity !== target.opacity) updates.opacity = source.opacity
if (source.fills !== target.fills) updates.fills = copyFills(source.fills)
if (source.strokes !== target.strokes) updates.strokes = copyStrokes(source.strokes)
if (source.effects !== target.effects) updates.effects = copyEffects(source.effects)
if (source.styleRuns !== target.styleRuns) updates.styleRuns = copyStyleRuns(source.styleRuns)
if (source.layoutGrow !== target.layoutGrow) updates.layoutGrow = source.layoutGrow
if (source.textAutoResize !== target.textAutoResize) updates.textAutoResize = source.textAutoResize
if (source.locked !== target.locked) updates.locked = source.locked
if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates)
}

View file

@ -89,14 +89,14 @@ export function convertFills(paints?: Paint[]): Fill[] {
if (!paints) return []
return paints.map((p) => {
const base: Fill = {
type: (p.type ?? 'SOLID') as FillType,
type: p.type as FillType,
color: convertColor(p.color),
opacity: p.opacity ?? 1,
visible: p.visible ?? true,
blendMode: (p.blendMode ?? 'NORMAL') as BlendMode
}
if (p.type?.startsWith('GRADIENT') && p.stops) {
if (p.type.startsWith('GRADIENT') && p.stops) {
base.gradientStops = p.stops.map((s) => ({
color: convertColor(s.color),
position: s.position
@ -115,7 +115,7 @@ export function convertFills(paints?: Paint[]): Fill[] {
base.imageHash = img.hash
}
}
base.imageScaleMode = (p.imageScaleMode as ImageScaleMode) ?? 'FILL'
base.imageScaleMode = (p.imageScaleMode ?? 'FILL') as ImageScaleMode
if (p.transform) {
base.imageTransform = convertGradientTransform(p.transform)
}
@ -141,9 +141,9 @@ function convertStrokes(
visible: p.visible ?? true,
align: (align === 'INSIDE'
? 'INSIDE'
: align === 'OUTSIDE'
: (align === 'OUTSIDE'
? 'OUTSIDE'
: 'CENTER') as Stroke['align'],
: 'CENTER')),
cap: (cap ?? 'NONE') as StrokeCap,
join: (join ?? 'MITER') as StrokeJoin,
dashPattern: dashPattern ?? []
@ -153,13 +153,13 @@ function convertStrokes(
function convertEffects(effects?: KiwiEffect[]): Effect[] {
if (!effects) return []
return effects.map((e) => ({
type: e.type as Effect['type'],
type: e.type,
color: convertColor(e.color),
offset: e.offset ?? { x: 0, y: 0 },
radius: e.radius ?? 0,
spread: e.spread ?? 0,
visible: e.visible ?? true,
blendMode: (e.blendMode as BlendMode) ?? 'NORMAL'
blendMode: (e.blendMode ?? 'NORMAL') as BlendMode
}))
}
@ -335,8 +335,8 @@ function importStyleRuns(nc: NodeChange): StyleRun[] {
const style: CharacterStyleOverride = {}
if (override.fontName) {
style.fontFamily = override.fontName.family
style.fontWeight = styleToWeight(override.fontName.style ?? '')
style.italic = override.fontName.style?.toLowerCase().includes('italic') ?? false
style.fontWeight = styleToWeight(override.fontName.style)
style.italic = override.fontName.style.toLowerCase().includes('italic')
}
if (override.fontSize !== undefined) style.fontSize = override.fontSize
if (override.letterSpacing) {
@ -346,7 +346,7 @@ function importStyleRuns(nc: NodeChange): StyleRun[] {
const lh = convertLineHeight(override.lineHeight, override.fontSize ?? nc.fontSize)
if (lh != null) style.lineHeight = lh
}
const deco = override.textDecoration as string | undefined
const deco = override.textDecoration
if (deco) style.textDecoration = mapTextDecoration(deco)
if (Object.keys(style).length > 0) styleMap.set(id, style)
}
@ -385,13 +385,12 @@ function resolveVectorNetwork(
}
| undefined
if (!vectorData || vectorData.vectorNetworkBlob === undefined) return null
if (vectorData?.vectorNetworkBlob === undefined) return null
const idx = vectorData.vectorNetworkBlob
if (idx < 0 || idx >= blobs.length) return null
try {
const network = decodeVectorNetworkBlob(blobs[idx], vectorData.styleOverrideTable)
if (!network) return null
const ns = vectorData.normalizedSize
const nodeW = nc.size?.x ?? 0
@ -430,7 +429,7 @@ export function resolveGeometryPaths(
if (p.commandsBlob === undefined || p.commandsBlob < 0 || p.commandsBlob >= blobs.length)
continue
const blob = blobs[p.commandsBlob]
if (!blob || blob.length === 0) continue
if (blob.length === 0) continue
result.push({
windingRule: (p.windingRule === 'EVENODD' ? 'EVENODD' : 'NONZERO') as WindingRule,
commandsBlob: blob
@ -476,7 +475,7 @@ export function nodeChangeToProps(
rotation = Math.atan2(nc.transform.m10 * sx, nc.transform.m00 * sx) * (180 / Math.PI)
}
const dashPattern = (nc.dashPattern as number[]) ?? []
const dashPattern = nc.dashPattern ?? []
return {
nodeType,
@ -513,16 +512,16 @@ export function nodeChangeToProps(
fontSize: nc.fontSize ?? 14,
fontFamily: nc.fontName?.family ?? DEFAULT_FONT_FAMILY,
fontWeight: styleToWeight(nc.fontName?.style ?? ''),
italic: nc.fontName?.style?.toLowerCase().includes('italic') ?? false,
italic: nc.fontName?.style.toLowerCase().includes('italic') ?? false,
textAlignHorizontal:
(nc.textAlignHorizontal as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED') ?? 'LEFT',
textAlignVertical: (nc.textAlignVertical as TextAlignVertical) ?? 'TOP',
textAutoResize: (nc.textAutoResize as TextAutoResize) ?? 'NONE',
textCase: (nc.textCase as TextCase) ?? 'ORIGINAL',
(nc.textAlignHorizontal ?? 'LEFT') as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED',
textAlignVertical: (nc.textAlignVertical ?? 'TOP') as TextAlignVertical,
textAutoResize: (nc.textAutoResize ?? 'NONE') as TextAutoResize,
textCase: (nc.textCase ?? 'ORIGINAL') as TextCase,
textDecoration: mapTextDecoration(nc.textDecoration as string),
lineHeight: convertLineHeight(nc.lineHeight, nc.fontSize),
letterSpacing: convertLetterSpacing(nc.letterSpacing, nc.fontSize),
maxLines: (nc.maxLines as number) ?? null,
maxLines: (nc.maxLines ?? null) as number | null,
styleRuns: importStyleRuns(nc),
horizontalConstraint: mapConstraint(nc.horizontalConstraint as string),
verticalConstraint: mapConstraint(nc.verticalConstraint as string),
@ -537,9 +536,9 @@ export function nodeChangeToProps(
primaryAxisAlign: mapStackJustify(nc.stackPrimaryAlignItems ?? nc.stackJustify),
counterAxisAlign: mapStackCounterAlign(nc.stackCounterAlignItems ?? nc.stackCounterAlign),
layoutWrap: nc.stackWrap === 'WRAP' ? 'WRAP' : 'NO_WRAP',
counterAxisSpacing: (nc.stackCounterSpacing as number) ?? 0,
counterAxisSpacing: nc.stackCounterSpacing ?? 0,
layoutPositioning: nc.stackPositioning === 'ABSOLUTE' ? 'ABSOLUTE' : 'AUTO',
layoutGrow: (nc.stackChildPrimaryGrow as number) ?? 0,
layoutGrow: nc.stackChildPrimaryGrow ?? 0,
layoutAlignSelf: (nc.stackChildAlignSelf as string) === 'STRETCH' ? 'STRETCH' : 'AUTO',
vectorNetwork: resolveVectorNetwork(nc, blobs),
fillGeometry: resolveGeometryPaths(nc.fillGeometry, blobs),
@ -548,25 +547,25 @@ export function nodeChangeToProps(
strokeCap: (nc.strokeCap ?? 'NONE') as StrokeCap,
strokeJoin: (nc.strokeJoin ?? 'MITER') as StrokeJoin,
dashPattern,
borderTopWeight: (nc.borderTopWeight as number) ?? 0,
borderRightWeight: (nc.borderRightWeight as number) ?? 0,
borderBottomWeight: (nc.borderBottomWeight as number) ?? 0,
borderLeftWeight: (nc.borderLeftWeight as number) ?? 0,
independentStrokeWeights: (nc.borderStrokeWeightsIndependent as boolean) ?? false,
borderTopWeight: (nc.borderTopWeight ?? 0) as number,
borderRightWeight: (nc.borderRightWeight ?? 0) as number,
borderBottomWeight: (nc.borderBottomWeight ?? 0) as number,
borderLeftWeight: (nc.borderLeftWeight ?? 0) as number,
independentStrokeWeights: (nc.borderStrokeWeightsIndependent ?? false) as boolean,
strokeMiterLimit: DEFAULT_STROKE_MITER_LIMIT,
minWidth: (nc.minWidth as number) ?? null,
maxWidth: (nc.maxWidth as number) ?? null,
minHeight: (nc.minHeight as number) ?? null,
maxHeight: (nc.maxHeight as number) ?? null,
isMask: (nc.isMask as boolean) ?? false,
maskType: ((nc.maskType as string) ?? 'ALPHA') as 'ALPHA' | 'VECTOR' | 'LUMINANCE',
minWidth: (nc.minWidth ?? null) as number | null,
maxWidth: (nc.maxWidth ?? null) as number | null,
minHeight: (nc.minHeight ?? null) as number | null,
maxHeight: (nc.maxHeight ?? null) as number | null,
isMask: (nc.isMask ?? false) as boolean,
maskType: (nc.maskType ?? 'ALPHA') as 'ALPHA' | 'VECTOR' | 'LUMINANCE',
counterAxisAlignContent:
(nc.stackCounterAlignContent as string) === 'SPACE_BETWEEN' ? 'SPACE_BETWEEN' : 'AUTO',
itemReverseZIndex: (nc.stackReverseZIndex as boolean) ?? false,
strokesIncludedInLayout: (nc.strokesIncludedInLayout as boolean) ?? false,
itemReverseZIndex: (nc.stackReverseZIndex ?? false) as boolean,
strokesIncludedInLayout: (nc.strokesIncludedInLayout ?? false) as boolean,
expanded: true,
textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED',
autoRename: (nc.autoRename as boolean) ?? true,
autoRename: (nc.autoRename ?? true) as boolean,
boundVariables: extractBoundVariables(nc),
clipsContent: nc.frameMaskDisabled === false,
componentId: extractSymbolId(nc)
@ -596,7 +595,7 @@ export function sortChildren(
children.sort((a, b) => {
const aPos = nodeMap.get(a)?.parentIndex?.position ?? ''
const bPos = nodeMap.get(b)?.parentIndex?.position ?? ''
return aPos < bPos ? -1 : aPos > bPos ? 1 : 0
return aPos < bPos ? -1 : (aPos > bPos ? 1 : 0)
})
}
}
@ -680,9 +679,8 @@ export function convertOverrideToProps(ov: Record<string, unknown>): Partial<Sce
updates.strokes = updates.strokes ?? []
}
if (ov.strokeAlign != null && updates.strokes) {
const align = (
ov.strokeAlign === 'INSIDE' ? 'INSIDE' : ov.strokeAlign === 'OUTSIDE' ? 'OUTSIDE' : 'CENTER'
) as Stroke['align']
const align =
ov.strokeAlign === 'INSIDE' ? 'INSIDE' : (ov.strokeAlign === 'OUTSIDE' ? 'OUTSIDE' : 'CENTER')
for (const s of updates.strokes) s.align = align
}
if (ov.borderTopWeight != null) updates.borderTopWeight = ov.borderTopWeight as number

View file

@ -158,7 +158,6 @@ export function parseVarint(data: Uint8Array, pos: number): [number, number] {
while (pos < data.length) {
const byte = data[pos]
if (byte === undefined) break
pos++
value |= (byte & KIWI.VARINT_VALUE_MASK) << shift

View file

@ -59,7 +59,7 @@ function mapGridTrack(track: GridTrack): { type: GridTrackType; value: number }
return { type: GridTrackType.Fr, value: track.value }
case 'FIXED':
return { type: GridTrackType.Points, value: track.value }
case 'AUTO':
default:
return { type: GridTrackType.Auto, value: 0 }
}
}
@ -156,8 +156,7 @@ function buildYogaTree(graph: SceneGraph, frame: SceneNode): YogaNode {
)
const children = graph.getChildren(frame.id)
for (let i = 0; i < children.length; i++) {
const child = children[i]
for (const child of children) {
if (child.layoutPositioning === 'ABSOLUTE') continue
@ -352,6 +351,7 @@ function applyYogaLayout(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode
const yogaChild = yogaNode.getChild(yogaIndex)
yogaIndex++
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!yogaChild) continue
graph.updateNode(child.id, {

View file

@ -60,6 +60,7 @@ export class CaptureStack {
profile.selfTime = profile.endTime - profile.startTime - childrenTime
const parent = this.stack[this.stack.length - 1]
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (parent) {
parent.children.push(profile)
} else {

View file

@ -34,7 +34,6 @@ export class GPUTimer {
if (this.pending.length >= MAX_PENDING_QUERIES) return
const query = this.gl.createQuery()
if (!query) return
this.gl.beginQuery(this.ext.TIME_ELAPSED_EXT, query)
this.activeQuery = query

View file

@ -1,6 +1,6 @@
import type { SkiaRenderer } from './renderer'
import type { SceneGraph } from './scene-graph'
import type { CanvasKit } from 'canvaskit-wasm'
import type { CanvasKit, Canvas } from 'canvaskit-wasm'
export type ExportFormat = 'PNG' | 'JPG' | 'WEBP' | 'SVG'
@ -53,7 +53,7 @@ function renderToSurface(
height: number,
format: ExportFormat,
quality: number,
setup: (canvas: import('canvaskit-wasm').Canvas) => void
setup: (canvas: Canvas) => void
): Uint8Array | null {
const surface = ck.MakeSurface(width, height)
if (!surface) return null

View file

@ -110,7 +110,7 @@ const JSX_ENTITY: Record<string, string> = {
}
function escapeJSXText(text: string): string {
return text.replace(/[{}<>&]/g, (c) => JSX_ENTITY[c]!)
return text.replace(/[{}<>&]/g, (c) => JSX_ENTITY[c])
}
function formatProp(key: string, value: unknown): string {
@ -476,7 +476,7 @@ function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] {
if (shadow) classes.push(`shadow-[${shadow}]`)
} else if (effect.type === 'LAYER_BLUR' || effect.type === 'FOREGROUND_BLUR') {
classes.push(`blur-[${effect.radius}px]`)
} else if (effect.type === 'BACKGROUND_BLUR') {
} else {
classes.push(`backdrop-blur-[${effect.radius}px]`)
}
}

View file

@ -19,10 +19,10 @@ export function createElement(
...props,
children:
flatChildren.length === 1
? (flatChildren as ReactNode[])
: flatChildren.length > 0
? flatChildren
: (flatChildren.length > 0
? flatChildren
: undefined
: undefined)
}
}
}

View file

@ -5,7 +5,7 @@ import { isTreeNode } from './tree'
import type { SceneGraph, SceneNode, NodeType, LayoutMode, Stroke } from '../scene-graph'
import type { TreeNode } from './tree'
const TYPE_MAP: Record<string, NodeType> = {
const TYPE_MAP: Partial<Record<string, NodeType>> = {
frame: 'FRAME',
view: 'FRAME',
rectangle: 'RECTANGLE',
@ -83,7 +83,7 @@ export function renderTree(
tree: TreeNode,
options: RenderOptions = {}
): RenderResult {
const parentId = options.parentId ?? graph.getPages()[0]?.id ?? graph.rootId
const parentId = options.parentId ?? graph.getPages()[0].id
const result = renderNode(graph, tree, parentId)
@ -149,7 +149,7 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
}
if (typeof props.stroke === 'string') {
const strokeWidth = (props.strokeWidth as number) ?? 1
const strokeWidth = (props.strokeWidth as number | undefined) ?? 1
o.strokes = [parseStroke(props.stroke, strokeWidth)]
}
@ -264,7 +264,7 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
if (props.pointCount !== undefined) o.pointCount = props.pointCount as number
if (typeof props.shadow === 'string') {
const parts = (props.shadow as string).split(/\s+/)
const parts = props.shadow.split(/\s+/)
if (parts.length >= 4) {
const c = parseColor(parts.slice(3).join(' '))
o.effects = [
@ -272,8 +272,8 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
{
type: 'DROP_SHADOW',
color: c,
offset: { x: parseFloat(parts[0]!), y: parseFloat(parts[1]!) },
radius: parseFloat(parts[2]!),
offset: { x: parseFloat(parts[0]), y: parseFloat(parts[1]) },
radius: parseFloat(parts[2]),
spread: 0,
visible: true
}
@ -286,7 +286,7 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
...(o.effects ?? []),
{
type: 'LAYER_BLUR',
radius: props.blur as number,
radius: props.blur,
visible: true,
color: { ...TRANSPARENT },
offset: { x: 0, y: 0 },

View file

@ -31,8 +31,7 @@ import type { SnapGuide } from '../snap'
import type { TextEditor } from '../text-editor'
import type { Rect, Vector } from '../types'
import type { Canvas } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
import type { RenderOverlays } from './renderer'
import type { SkiaRenderer, RenderOverlays } from './renderer'
export function drawHoverHighlight(
r: SkiaRenderer,
@ -219,7 +218,7 @@ export function drawSelectionLabels(
const glyphIds = r.sizeFont.getGlyphIDs(sizeText)
const widths = r.sizeFont.getGlyphWidths(glyphIds)
let textWidth = 0
for (let i = 0; i < widths.length; i++) textWidth += widths[i]
for (const w of widths) textWidth += w
const pillW = textWidth + SIZE_PILL_PADDING_X * 2
const pillH = SIZE_PILL_HEIGHT
const pillX = smx - pillW / 2
@ -737,7 +736,7 @@ export function drawRemoteCursors(
const glyphIds = font.getGlyphIDs(cursor.name)
const widths = font.getGlyphWidths(glyphIds)
let textWidth = 0
for (let i = 0; i < widths.length; i++) textWidth += widths[i]
for (const w of widths) textWidth += w
r.auxFill.setColor(r.ck.Color4f(cr, g, b, 1))
const bgRect = r.ck.RRectXY(

View file

@ -33,8 +33,9 @@ import type { SceneNode, SceneGraph, Fill, Stroke } from '../scene-graph'
import type { SnapGuide } from '../snap'
import type { TextEditor } from '../text-editor'
import type { Color, Rect, Vector } from '../types'
import type { Image as CKImage, Path } from 'canvaskit-wasm'
import type {
Image as CKImage,
Path,
CanvasKit,
Surface,
Canvas,
@ -45,7 +46,8 @@ import type {
TypefaceFontProvider,
SkPicture,
ImageFilter,
MaskFilter
MaskFilter,
Paragraph
} from 'canvaskit-wasm'
import {
@ -399,7 +401,7 @@ export class SkiaRenderer {
this.fontsLoaded = true
this.invalidateAllPictures()
ensureCJKFallback().then((family) => {
void ensureCJKFallback().then((family) => {
if (family) this.invalidateAllPictures()
})
}
@ -564,7 +566,7 @@ export class SkiaRenderer {
const id = [...selectedIds][0]
const node = graph.getNode(id)
if (!node || node.type !== 'FRAME') return null
if (node?.type !== 'FRAME') return null
const parent = node.parentId ? graph.getNode(node.parentId) : null
const isTopLevel = !parent || parent.type === 'CANVAS' || parent.type === 'SECTION'
@ -730,7 +732,7 @@ export class SkiaRenderer {
this.worldViewport = prevViewport
this.scenePictureVersion = sceneVersion
this.scenePicturePageId = this.pageId
canvas.drawPicture(this.scenePicture!)
canvas.drawPicture(this.scenePicture)
}
invalidateVectorPath(nodeId: string): void {
@ -794,7 +796,7 @@ export class SkiaRenderer {
node: SceneNode,
color?: Float32Array,
{ halfLeading = false }: { halfLeading?: boolean } = {}
): import('canvaskit-wasm').Paragraph {
): Paragraph {
const ck = this.ck
const baseColor = color ?? ck.BLACK
const baseFontSize = node.fontSize || DEFAULT_FONT_SIZE
@ -969,11 +971,11 @@ export class SkiaRenderer {
this.penVertexFill.delete()
this.penVertexStroke.delete()
this.effectLayerPaint.delete()
for (const filter of this.imageFilterCache.values()) filter?.delete()
for (const filter of this.imageFilterCache.values()) filter.delete()
this.imageFilterCache.clear()
for (const filter of this.maskFilterCache.values()) filter?.delete()
for (const filter of this.maskFilterCache.values()) filter.delete()
this.maskFilterCache.clear()
for (const pic of this.nodePictureCache.values()) pic?.delete()
for (const pic of this.nodePictureCache.values()) pic.delete()
this.nodePictureCache.clear()
this.scenePicture?.delete()
this._flashPaint?.delete()

View file

@ -1,8 +1,7 @@
import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '../constants'
import type { SceneNode, SceneGraph } from '../scene-graph'
import type { Canvas } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
import type { RenderOverlays } from './renderer'
import type { Canvas, EmbindEnumEntity } from 'canvaskit-wasm'
import type { SkiaRenderer, RenderOverlays } from './renderer'
export function renderNode(
r: SkiaRenderer,
@ -133,7 +132,7 @@ export function renderSection(
const rrect = r.ck.RRectXY(rect, SECTION_CORNER_RADIUS, SECTION_CORNER_RADIUS)
for (let fi = 0; fi < node.fills.length; fi++) {
const fill = node.fills[fi]!
const fill = node.fills[fi]
if (!fill.visible) continue
r.applyFill(fill, node, graph, fi)
r.fillPaint.setAlphaf(fill.opacity)
@ -142,7 +141,7 @@ export function renderSection(
}
for (let si = 0; si < node.strokes.length; si++) {
const stroke = node.strokes[si]!
const stroke = node.strokes[si]
if (!stroke.visible) continue
const sc = r.resolveStrokeColor(stroke, si, node, graph)
r.strokePaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
@ -167,7 +166,7 @@ export function renderComponentSet(
const rrect = r.ck.RRectXY(rect, 5, 5)
for (let fi = 0; fi < node.fills.length; fi++) {
const fill = node.fills[fi]!
const fill = node.fills[fi]
if (!fill.visible) continue
r.applyFill(fill, node, graph, fi)
r.fillPaint.setAlphaf(fill.opacity)
@ -235,7 +234,7 @@ export function renderShapeUncached(
r.renderEffects(canvas, node, rect, hasRadius, 'behind')
for (let fi = 0; fi < node.fills.length; fi++) {
const fill = node.fills[fi]!
const fill = node.fills[fi]
if (!fill.visible) continue
r.applyFill(fill, node, graph, fi)
r.fillPaint.setAlphaf(fill.opacity)
@ -247,7 +246,7 @@ export function renderShapeUncached(
const sg = node.type === 'VECTOR' ? r.getStrokeGeometry(node) : null
const vectorPaths = !sg && node.type === 'VECTOR' ? r.getVectorPaths(node) : null
for (let si = 0; si < node.strokes.length; si++) {
const stroke = node.strokes[si]!
const stroke = node.strokes[si]
if (!stroke.visible) continue
const sc = r.resolveStrokeColor(stroke, si, node, graph)
@ -260,12 +259,12 @@ export function renderShapeUncached(
}
if (vectorPaths) {
const capMap: Record<string, import('canvaskit-wasm').EmbindEnumEntity> = {
const capMap: Record<string, EmbindEnumEntity> = {
NONE: r.ck.StrokeCap.Butt,
ROUND: r.ck.StrokeCap.Round,
SQUARE: r.ck.StrokeCap.Square
}
const joinMap: Record<string, import('canvaskit-wasm').EmbindEnumEntity> = {
const joinMap: Record<string, EmbindEnumEntity> = {
MITER: r.ck.StrokeJoin.Miter,
ROUND: r.ck.StrokeJoin.Round,
BEVEL: r.ck.StrokeJoin.Bevel
@ -294,7 +293,7 @@ export function renderShapeUncached(
r.strokePaint.setAlphaf(stroke.opacity)
if (stroke.cap) {
const capMap: Record<string, import('canvaskit-wasm').EmbindEnumEntity> = {
const capMap: Record<string, EmbindEnumEntity> = {
NONE: r.ck.StrokeCap.Butt,
ROUND: r.ck.StrokeCap.Round,
SQUARE: r.ck.StrokeCap.Square
@ -302,7 +301,7 @@ export function renderShapeUncached(
r.strokePaint.setStrokeCap(capMap[stroke.cap] ?? r.ck.StrokeCap.Butt)
}
if (stroke.join) {
const joinMap: Record<string, import('canvaskit-wasm').EmbindEnumEntity> = {
const joinMap: Record<string, EmbindEnumEntity> = {
MITER: r.ck.StrokeJoin.Miter,
ROUND: r.ck.StrokeJoin.Round,
BEVEL: r.ck.StrokeJoin.Bevel

View file

@ -1,5 +1,5 @@
import type { SceneNode, Stroke } from '../scene-graph'
import type { Canvas } from 'canvaskit-wasm'
import type { Canvas, Paint } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
export function drawNodeStroke(
@ -129,28 +129,28 @@ export function drawIndividualSideStrokes(
const tw = node.borderTopWeight
if (tw > 0) {
const y = inside ? tw / 2 : outside ? -tw / 2 : 0
const y = inside ? tw / 2 : (outside ? -tw / 2 : 0)
r.strokePaint.setStrokeWidth(tw)
canvas.drawLine(0, y, w, y, r.strokePaint)
}
const rw = node.borderRightWeight
if (rw > 0) {
const x = inside ? w - rw / 2 : outside ? w + rw / 2 : w
const x = inside ? w - rw / 2 : (outside ? w + rw / 2 : w)
r.strokePaint.setStrokeWidth(rw)
canvas.drawLine(x, 0, x, h, r.strokePaint)
}
const bw = node.borderBottomWeight
if (bw > 0) {
const y = inside ? h - bw / 2 : outside ? h + bw / 2 : h
const y = inside ? h - bw / 2 : (outside ? h + bw / 2 : h)
r.strokePaint.setStrokeWidth(bw)
canvas.drawLine(0, y, w, y, r.strokePaint)
}
const lw = node.borderLeftWeight
if (lw > 0) {
const x = inside ? lw / 2 : outside ? -lw / 2 : 0
const x = inside ? lw / 2 : (outside ? -lw / 2 : 0)
r.strokePaint.setStrokeWidth(lw)
canvas.drawLine(x, 0, x, h, r.strokePaint)
}
@ -160,7 +160,7 @@ export function strokeNodeShape(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
paint: import('canvaskit-wasm').Paint
paint: Paint
): void {
const rect = r.ck.LTRBRect(0, 0, node.width, node.height)

View file

@ -259,7 +259,7 @@ export const nodeCommand: RpcCommand<NodeArgs, NodeResult | { error: string }> =
fontFamily: node.fontFamily,
fontSize: node.fontSize,
fontWeight: node.fontWeight,
text: node.text?.length ? (node.text.length > 200 ? node.text.slice(0, 200) + '…' : node.text) : null,
text: node.text.length ? (node.text.length > 200 ? node.text.slice(0, 200) + '…' : node.text) : null,
parent: parent ? { id: parent.id, name: parent.name, type: parent.type } : null,
children: node.childIds.length,
boundVariables: boundVars
@ -277,15 +277,14 @@ export interface VariablesArgs {
function formatVariableValue(variable: Variable, graph: SceneGraph): string {
const modeId = graph.getActiveModeId(variable.collectionId)
const raw = variable.valuesByMode[modeId]
if (raw === undefined) return ''
if (typeof raw === 'object' && raw !== null && 'aliasId' in raw) {
const alias = graph.variables.get((raw as { aliasId: string }).aliasId)
return alias ? `${alias.name}` : `${(raw as { aliasId: string }).aliasId}`
if (typeof raw === 'object' && 'aliasId' in raw) {
const alias = graph.variables.get(raw.aliasId)
return alias ? `${alias.name}` : `${raw.aliasId}`
}
if (typeof raw === 'object' && raw !== null && 'r' in raw) {
return colorToHex(raw as Color).toLowerCase()
if (typeof raw === 'object' && 'r' in raw) {
return colorToHex(raw).toLowerCase()
}
return String(raw)

View file

@ -2,7 +2,7 @@ import { BLACK, DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from './consta
import { copyEffects, copyFills, copyStrokes, copyStyleRuns } from './copy'
export type { GUID, Color } from './types'
import type { Matrix, Vector } from './types'
import type { Matrix, Vector, Color, Rect } from './types'
export type HandleMirroring = 'NONE' | 'ANGLE' | 'ANGLE_AND_LENGTH'
export type WindingRule = 'NONZERO' | 'EVENODD'
@ -58,7 +58,6 @@ export type NodeType =
| 'CONNECTOR'
| 'SHAPE_WITH_TEXT'
import type { Color, Matrix, Rect } from './types'
export type FillType =
| 'SOLID'
@ -526,9 +525,18 @@ export class SceneGraph {
const collection = this.variableCollections.get(collectionId)
if (!collection) throw new Error(`Collection "${collectionId}" not found`)
const id = generateId()
const defaultValue =
value ??
(type === 'COLOR' ? { ...BLACK } : type === 'FLOAT' ? 0 : type === 'BOOLEAN' ? false : '')
let defaultValue: VariableValue
if (value !== undefined) {
defaultValue = value
} else if (type === 'COLOR') {
defaultValue = { ...BLACK }
} else if (type === 'FLOAT') {
defaultValue = 0
} else if (type === 'BOOLEAN') {
defaultValue = false
} else {
defaultValue = ''
}
const valuesByMode: Record<string, VariableValue> = {}
for (const mode of collection.modes) {
valuesByMode[mode.modeId] = structuredClone(defaultValue)
@ -592,8 +600,7 @@ export class SceneGraph {
if (!variable) return undefined
const resolvedModeId = modeId ?? this.getActiveModeId(variable.collectionId)
const value = variable.valuesByMode[resolvedModeId]
if (value === undefined) return undefined
if (typeof value === 'object' && value !== null && 'aliasId' in value) {
if (typeof value === 'object' && 'aliasId' in value) {
const seen = visited ?? new Set<string>()
seen.add(variableId)
return this.resolveVariable(value.aliasId, undefined, seen)
@ -603,7 +610,7 @@ export class SceneGraph {
resolveColorVariable(variableId: string): Color | undefined {
const value = this.resolveVariable(variableId)
if (value && typeof value === 'object' && 'r' in value) return value as Color
if (value && typeof value === 'object' && 'r' in value) return value
return undefined
}
@ -962,10 +969,10 @@ export class SceneGraph {
'borderLeftWeight'
]
private static copyProp<K extends keyof SceneNode>(
private static copyProp(
target: Partial<SceneNode> | SceneNode,
source: SceneNode,
key: K
key: keyof SceneNode
): void {
const val = source[key]
if (key === 'fills') {
@ -977,7 +984,7 @@ export class SceneGraph {
} else if (key === 'styleRuns') {
;(target as Record<string, unknown>)[key] = copyStyleRuns(val as StyleRun[])
} else {
target[key] = (Array.isArray(val) ? structuredClone(val) : val) as SceneNode[K]
;(target as Record<string, unknown>)[key] = Array.isArray(val) ? structuredClone(val) : val
}
}
@ -987,7 +994,7 @@ export class SceneGraph {
overrides: Partial<SceneNode> = {}
): SceneNode | null {
const component = this.nodes.get(componentId)
if (!component || component.type !== 'COMPONENT') return null
if (component?.type !== 'COMPONENT') return null
const props: Partial<SceneNode> = { name: component.name, componentId }
for (const key of SceneGraph.INSTANCE_SYNC_PROPS) {
@ -1030,7 +1037,7 @@ export class SceneGraph {
syncInstances(componentId: string): void {
const component = this.nodes.get(componentId)
if (!component || component.type !== 'COMPONENT') return
if (component?.type !== 'COMPONENT') return
for (const instance of this.getInstances(componentId)) {
// Sync instance-level props (unless overridden)
@ -1112,7 +1119,7 @@ export class SceneGraph {
detachInstance(instanceId: string): void {
const node = this.nodes.get(instanceId)
if (!node || node.type !== 'INSTANCE') return
if (node?.type !== 'INSTANCE') return
node.type = 'FRAME'
node.componentId = null
node.overrides = {}

View file

@ -42,7 +42,7 @@ function formatColor(color: Color, opacity = 1): string {
// --- Path data ---
export function geometryBlobToSVGPath(blob: Uint8Array): string {
if (!blob || blob.length === 0) return ''
if (blob.length === 0) return ''
const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength)
let o = 0
const parts: string[] = []
@ -301,11 +301,7 @@ function createFilterDef(effects: Effect[], ctx: SVGExportContext): { id: string
operator: 'over'
})
)
} else if (
effect.type === 'LAYER_BLUR' ||
effect.type === 'BACKGROUND_BLUR' ||
effect.type === 'FOREGROUND_BLUR'
) {
} else {
const stdDev = round(effect.radius / 2)
primitives.push(svg('feGaussianBlur', { stdDeviation: stdDev }))
}
@ -606,24 +602,24 @@ function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode {
'text-anchor':
node.textAlignHorizontal === 'CENTER'
? 'middle'
: node.textAlignHorizontal === 'RIGHT'
: (node.textAlignHorizontal === 'RIGHT'
? 'end'
: undefined,
: undefined),
'text-decoration':
node.textDecoration === 'UNDERLINE'
? 'underline'
: node.textDecoration === 'STRIKETHROUGH'
: (node.textDecoration === 'STRIKETHROUGH'
? 'line-through'
: undefined,
: undefined),
'letter-spacing': node.letterSpacing ? round(node.letterSpacing) : undefined
}
const x =
node.textAlignHorizontal === 'CENTER'
? round(node.width / 2)
: node.textAlignHorizontal === 'RIGHT'
: (node.textAlignHorizontal === 'RIGHT'
? round(node.width)
: 0
: 0)
const y = node.fontSize || 14
if (node.styleRuns.length > 0) {
@ -833,7 +829,7 @@ export function renderNodesToSVG(
? { ...node, x: round(offsetX), y: round(offsetY) }
: node
const rendered = renderNode(clone as SceneNode, ctx)
const rendered = renderNode(clone, ctx)
if (rendered) contentNodes.push(rendered)
}

View file

@ -7,6 +7,7 @@
import type { FigmaAPI } from '../figma-api'
import type { ToolDef, ParamDef, ParamType } from './schema'
import type * as valibot from 'valibot'
export interface AIAdapterOptions {
getFigma: () => FigmaAPI
@ -39,7 +40,7 @@ export function toolsToAI(
tools: ToolDef[],
options: AIAdapterOptions,
deps: {
v: typeof import('valibot')
v: typeof valibot
valibotSchema: (schema: any) => any
tool: (opts: any) => any
}
@ -77,7 +78,7 @@ export function toolsToAI(
return result
}
function paramToValibot(v: typeof import('valibot'), param: ParamDef): unknown {
function paramToValibot(v: typeof valibot, param: ParamDef): unknown {
const typeMap: Record<ParamType, () => unknown> = {
string: () => (param.enum ? v.picklist(param.enum as [string, ...string[]]) : v.string()),
number: () => {

View file

@ -67,10 +67,10 @@ function serializeNodeProps(raw: SceneNode): string {
for (const effect of raw.effects) {
const parts: string[] = [effect.type]
if (effect.radius !== undefined) parts.push(`r=${effect.radius}`)
if (effect.color) parts.push(`c=${colorToHex(effect.color)}`)
if (effect.offset) parts.push(`x=${effect.offset.x} y=${effect.offset.y}`)
if (effect.spread !== undefined) parts.push(`s=${effect.spread}`)
parts.push(`r=${effect.radius}`)
parts.push(`c=${colorToHex(effect.color)}`)
parts.push(`x=${effect.offset.x} y=${effect.offset.y}`)
parts.push(`s=${effect.spread}`)
lines.push(`effect: ${parts.join(' ')}`)
}
@ -156,12 +156,12 @@ export const analyzeColors = defineTool({
const boundVars = raw.boundVariables
for (const fill of raw.fills) {
if (fill.type === 'SOLID' && fill.visible) {
trackColor(colorMap, fill.color, boundVars?.['fills'] ? String(boundVars['fills']) : null)
trackColor(colorMap, fill.color, boundVars['fills'] ? String(boundVars['fills']) : null)
}
}
for (const stroke of raw.strokes) {
if (stroke.visible) {
trackColor(colorMap, stroke.color, boundVars?.['strokes'] ? String(boundVars['strokes']) : null)
trackColor(colorMap, stroke.color, boundVars['strokes'] ? String(boundVars['strokes']) : null)
}
}
return false
@ -415,7 +415,7 @@ export const analyzeClusters = defineTool({
let confidence = 100
if (nodes.length >= 2) {
const base = nodes[0]!
const base = nodes[0]
let score = 0
for (const n of nodes.slice(1)) {
const sizeDiff = Math.abs(n.width - base.width) + Math.abs(n.height - base.height)
@ -582,6 +582,7 @@ export const evalCode = defineTool({
code: { type: 'string', description: 'JavaScript code to execute', required: true }
},
execute: async (figma, { code }) => {
// eslint-disable-next-line no-empty-function
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
const wrapped = code.trim().startsWith('return') ? code : `return (async () => { ${code} })()`
const fn = new AsyncFunction('figma', wrapped)

View file

@ -36,7 +36,7 @@ export const createShape = defineTool({
POLYGON: () => figma.createPolygon(),
SECTION: () => figma.createSection()
}
const node = createMap[args.type]!()
const node = createMap[args.type]()
node.x = args.x
node.y = args.y
node.resize(args.width, args.height)

View file

@ -78,7 +78,7 @@ export const groupNodes = defineTool({
.map((id) => figma.getNodeById(id))
.filter((n): n is FigmaNodeProxy => n !== null)
if (nodes.length < 2) return { error: 'Need at least 2 nodes to group' }
const parent = nodes[0]!.parent ?? figma.currentPage
const parent = nodes[0].parent ?? figma.currentPage
const group = figma.group(nodes, parent)
return nodeSummary(group)
}
@ -304,7 +304,7 @@ export const arrangeNodes = defineTool({
}
if (nodes.length === 0) return { error: 'No nodes to arrange' }
const first = nodes[0]!
const first = nodes[0]
if (mode === 'row') {
let x = first.x
@ -330,7 +330,7 @@ export const arrangeNodes = defineTool({
let rowHeight = 0
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]!
const node = nodes[i]
if (i > 0 && i % cols === 0) {
x = startX
y += rowHeight + gap

View file

@ -103,14 +103,10 @@ export const pathScale = defineTool({
v.y = cy + (v.y - cy) * factor
}
for (const s of vn.segments) {
if (s.tangentStart) {
s.tangentStart.x *= factor
s.tangentStart.y *= factor
}
if (s.tangentEnd) {
s.tangentEnd.x *= factor
s.tangentEnd.y *= factor
}
s.tangentStart.x *= factor
s.tangentStart.y *= factor
s.tangentEnd.x *= factor
s.tangentEnd.y *= factor
}
figma.graph.updateNode(id, { vectorNetwork: vn } as any)
return { id, factor }
@ -142,14 +138,10 @@ export const pathFlip = defineTool({
else v.y = h - v.y
}
for (const s of vn.segments) {
if (s.tangentStart) {
if (axis === 'horizontal') s.tangentStart.x = -s.tangentStart.x
else s.tangentStart.y = -s.tangentStart.y
}
if (s.tangentEnd) {
if (axis === 'horizontal') s.tangentEnd.x = -s.tangentEnd.x
else s.tangentEnd.y = -s.tangentEnd.y
}
if (axis === 'horizontal') s.tangentStart.x = -s.tangentStart.x
else s.tangentStart.y = -s.tangentStart.y
if (axis === 'horizontal') s.tangentEnd.x = -s.tangentEnd.x
else s.tangentEnd.y = -s.tangentEnd.y
}
figma.graph.updateNode(id, { vectorNetwork: vn } as any)
return { id, axis }
@ -288,7 +280,7 @@ export const exportImage = defineTool({
args.ids && args.ids.length > 0
? args.ids
: figma.currentPage.children.map((n) => n.id)
const format = ((args.format as string) ?? 'PNG').toUpperCase() as 'PNG' | 'JPG' | 'WEBP'
const format = (args.format ?? 'PNG').toUpperCase() as 'PNG' | 'JPG' | 'WEBP'
const data = await figma.exportImage(ids, {
scale: args.scale ?? 1,
format

View file

@ -54,7 +54,7 @@ export function decodeVectorNetworkBlob(
vertices.push({
x,
y,
handleMirroring: (override?.handleMirroring as HandleMirroring) ?? 'NONE'
handleMirroring: (override?.handleMirroring as HandleMirroring | undefined) ?? 'NONE'
})
}
@ -301,6 +301,7 @@ function buildChains(segments: VectorSegment[], _vertexCount: number): number[][
let current = startVertex
const chain: number[] = []
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const segs = adj.get(current)
if (!segs) break
@ -367,7 +368,7 @@ export function geometryBlobToPath(
windingRule: WindingRule
): Path {
const path = new ck.Path()
if (!blob || !(blob.buffer instanceof ArrayBuffer)) return path
if (!(blob.buffer instanceof ArrayBuffer)) return path
const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength)
let o = 0

View file

@ -20,6 +20,7 @@ const sessions = new Map<string, { server: ReturnType<typeof createServer>; tran
async function getOrCreateSession(sessionId?: string) {
if (sessionId && sessions.has(sessionId)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by has() above
return sessions.get(sessionId)!
}
@ -79,7 +80,7 @@ app.all('/mcp', async (c) => {
return transport.handleRequest(c.req.raw)
})
const isBun = typeof globalThis.Bun !== 'undefined'
const isBun = 'Bun' in globalThis
if (isBun) {
Bun.serve({ fetch: app.fetch, port, hostname: host })

View file

@ -77,17 +77,19 @@ export function createServer(version: string, options: CreateServerOptions = {})
function makeFigma(): FigmaAPI {
if (!graph) throw new Error('No document loaded. Use open_file or new_document first.')
const api = new FigmaAPI(graph)
const g = graph
const api = new FigmaAPI(g)
if (currentPageId) api.currentPage = api.wrapNode(currentPageId)
api.exportImage = async (nodeIds, opts) => {
const ck = await getCanvasKit()
const surface = ck.MakeSurface(1, 1)!
const surface = ck.MakeSurface(1, 1)
if (!surface) throw new Error('Failed to create CanvasKit surface')
const renderer = new SkiaRenderer(ck, surface)
renderer.viewportWidth = 1
renderer.viewportHeight = 1
renderer.dpr = 1
const pageId = currentPageId ?? graph!.getPages()[0]?.id ?? graph!.rootId
return renderNodesToImage(ck, renderer, graph!, pageId, nodeIds, {
const pageId = currentPageId ?? g.getPages()[0].id
return renderNodesToImage(ck, renderer, g, pageId, nodeIds, {
scale: opts.scale ?? 1,
format: (opts.format ?? 'PNG') as ExportFormat
})