From e97a4cfda4ba97ba5f14dccb1bee8d5171e7724d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 12 Mar 2026 15:13:30 +0300 Subject: [PATCH] Fix lint warnings: eliminate any types, split oversized files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all no-explicit-any with proper types or justified eslint-disable - Extract scene-graph-hit-test.ts (hit testing), scene-graph-instances.ts (component/instance sync) from scene-graph.ts - Extract renderer/text.ts (text rendering), renderer/pen-overlay.ts (vector pen tool overlay) from renderer.ts - Extract figma-api-proxy.ts (FigmaNodeProxy class) from figma-api.ts - Extract kiwi-convert-overrides.ts from kiwi-convert.ts - Add eslint-disable with justification for remaining large class files - Fix unused imports from splits - Warnings: 42 → 2 (only unavoidable no-implied-eval in vendored code) --- packages/core/src/figma-api-proxy.ts | 977 ++++++++++++++++++ packages/core/src/figma-api.ts | 975 +---------------- packages/core/src/kiwi/codec.ts | 1 + packages/core/src/kiwi/instance-overrides.ts | 4 +- .../core/src/kiwi/kiwi-convert-overrides.ts | 147 +++ packages/core/src/kiwi/kiwi-convert.ts | 151 +-- packages/core/src/kiwi/schema.ts | 1 + packages/core/src/layout.ts | 1 + packages/core/src/render/export-jsx.ts | 1 + packages/core/src/renderer/overlays.ts | 225 +--- packages/core/src/renderer/pen-overlay.ts | 228 ++++ packages/core/src/renderer/renderer.ts | 185 +--- packages/core/src/renderer/text.ts | 198 ++++ packages/core/src/rpc/commands.ts | 1 + packages/core/src/scene-graph-hit-test.ts | 143 +++ packages/core/src/scene-graph-instances.ts | 214 ++++ packages/core/src/scene-graph.ts | 348 +------ packages/core/src/tools/ai-adapter.ts | 18 +- packages/core/src/tools/analyze.ts | 1 + packages/core/src/tools/create.ts | 3 +- packages/core/src/tools/modify.ts | 38 +- packages/core/src/tools/schema.ts | 2 +- packages/core/src/tools/variables.ts | 7 +- packages/core/src/tools/vector.ts | 10 +- 24 files changed, 2027 insertions(+), 1852 deletions(-) create mode 100644 packages/core/src/figma-api-proxy.ts create mode 100644 packages/core/src/kiwi/kiwi-convert-overrides.ts create mode 100644 packages/core/src/renderer/pen-overlay.ts create mode 100644 packages/core/src/renderer/text.ts create mode 100644 packages/core/src/scene-graph-hit-test.ts create mode 100644 packages/core/src/scene-graph-instances.ts diff --git a/packages/core/src/figma-api-proxy.ts b/packages/core/src/figma-api-proxy.ts new file mode 100644 index 000000000..d703d2114 --- /dev/null +++ b/packages/core/src/figma-api-proxy.ts @@ -0,0 +1,977 @@ +/* eslint-disable max-lines -- Figma Plugin API proxy; FigmaAPI already in separate file */ +import type { + SceneGraph, + SceneNode, + NodeType, + Fill, + Stroke, + Effect, + LayoutMode, +} from './scene-graph' +import type { Rect } from './types' +import { copyFills, copyStrokes, copyEffects } from './copy' + +const MIXED = Symbol('mixed') + +export type FigmaFontName = { family: string; style: string } + +export function weightToStyleName(weight: number, italic: boolean): string { + const names: Record = { + 100: 'Thin', + 200: 'Extra Light', + 300: 'Light', + 400: 'Regular', + 500: 'Medium', + 600: 'Semi Bold', + 700: 'Bold', + 800: 'Extra Bold', + 900: 'Black' + } + const base = names[weight] ?? 'Regular' + return italic ? `${base} Italic` : base +} + +export function styleNameToWeight(style: string): { weight: number; italic: boolean } { + const lower = style.toLowerCase() + const italic = lower.includes('italic') + const clean = lower.replace(/italic/i, '').trim() + const map: Record = { + thin: 100, + 'extra light': 200, + 'ultra light': 200, + light: 300, + regular: 400, + '': 400, + medium: 500, + 'semi bold': 600, + 'demi bold': 600, + bold: 700, + 'extra bold': 800, + 'ultra bold': 800, + black: 900, + heavy: 900 + } + return { weight: map[clean] ?? 400, italic } +} + +export const INTERNAL_ID = Symbol('id') +export const INTERNAL_GRAPH = Symbol('graph') +export const INTERNAL_API = Symbol('api') + +export interface NodeProxyHost { + wrapNode(id: string): FigmaNodeProxy + readonly currentPageId: string +} + +export { MIXED } + +export class FigmaNodeProxy { + [INTERNAL_ID]: string; + [INTERNAL_GRAPH]: SceneGraph; + [INTERNAL_API]: NodeProxyHost + + constructor(id: string, graph: SceneGraph, api: NodeProxyHost) { + this[INTERNAL_ID] = id + this[INTERNAL_GRAPH] = graph + this[INTERNAL_API] = api + } + + private _raw(): SceneNode { + const n = this[INTERNAL_GRAPH].getNode(this[INTERNAL_ID]) + if (!n) throw new Error(`Node ${this[INTERNAL_ID]} has been removed`) + return n + } + + get id(): string { + return this[INTERNAL_ID] + } + + get type(): NodeType { + return this._raw().type + } + + get name(): string { + return this._raw().name + } + + set name(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { name: v }) + } + + get removed(): boolean { + return !this[INTERNAL_GRAPH].getNode(this[INTERNAL_ID]) + } + + // --- Geometry --- + + get x(): number { + return this._raw().x + } + + set x(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { x: v }) + } + + get y(): number { + return this._raw().y + } + + set y(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { y: v }) + } + + get width(): number { + return this._raw().width + } + + get height(): number { + return this._raw().height + } + + get rotation(): number { + return this._raw().rotation + } + + set rotation(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { rotation: v }) + } + + resize(width: number, height: number): void { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { width, height }) + } + + resizeWithoutConstraints(width: number, height: number): void { + this.resize(width, height) + } + + get absoluteTransform(): [[number, number, number], [number, number, number]] { + const pos = this[INTERNAL_GRAPH].getAbsolutePosition(this[INTERNAL_ID]) + return [ + [1, 0, pos.x], + [0, 1, pos.y] + ] + } + + get absoluteBoundingBox(): Rect { + return this[INTERNAL_GRAPH].getAbsoluteBounds(this[INTERNAL_ID]) + } + + get absoluteRenderBounds(): Rect { + return this.absoluteBoundingBox + } + + // --- Visual --- + + get fills(): readonly Fill[] { + return Object.freeze(copyFills(this._raw().fills)) + } + + set fills(v: readonly Fill[]) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { fills: [...v] }) + } + + get strokes(): readonly Stroke[] { + return Object.freeze(copyStrokes(this._raw().strokes)) + } + + set strokes(v: readonly Stroke[]) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes: [...v] }) + } + + get effects(): readonly Effect[] { + return Object.freeze(copyEffects(this._raw().effects)) + } + + set effects(v: readonly Effect[]) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { effects: [...v] }) + } + + get opacity(): number { + return this._raw().opacity + } + + set opacity(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { opacity: v }) + } + + get visible(): boolean { + return this._raw().visible + } + + set visible(v: boolean) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { visible: v }) + } + + get locked(): boolean { + return this._raw().locked + } + + set locked(v: boolean) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { locked: v }) + } + + get blendMode(): string { + return this._raw().blendMode + } + + set blendMode(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { blendMode: v as SceneNode['blendMode'] }) + } + + get clipsContent(): boolean { + return this._raw().clipsContent + } + + set clipsContent(v: boolean) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { clipsContent: v }) + } + + // --- Corner Radius --- + + get cornerRadius(): number | typeof MIXED { + const n = this._raw() + if (n.independentCorners) return MIXED + return n.cornerRadius + } + + set cornerRadius(v: number | typeof MIXED) { + if (v === MIXED) return + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + cornerRadius: v, + topLeftRadius: v, + topRightRadius: v, + bottomRightRadius: v, + bottomLeftRadius: v, + independentCorners: false + }) + } + + get topLeftRadius(): number { + return this._raw().topLeftRadius + } + + set topLeftRadius(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + topLeftRadius: v, + independentCorners: true + }) + } + + get topRightRadius(): number { + return this._raw().topRightRadius + } + + set topRightRadius(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + topRightRadius: v, + independentCorners: true + }) + } + + get bottomLeftRadius(): number { + return this._raw().bottomLeftRadius + } + + set bottomLeftRadius(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + bottomLeftRadius: v, + independentCorners: true + }) + } + + get bottomRightRadius(): number { + return this._raw().bottomRightRadius + } + + set bottomRightRadius(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + bottomRightRadius: v, + independentCorners: true + }) + } + + get cornerSmoothing(): number { + return this._raw().cornerSmoothing + } + + set cornerSmoothing(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { cornerSmoothing: v }) + } + + // --- Stroke details --- + + get strokeWeight(): number { + const s = this._raw().strokes + return s.length > 0 ? s[0].weight : 0 + } + + set strokeWeight(v: number) { + const n = this._raw() + if (n.strokes.length > 0) { + const strokes = copyStrokes(n.strokes) + strokes[0].weight = v + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes }) + } + } + + get strokeAlign(): string { + const s = this._raw().strokes + return s.length > 0 ? s[0].align : 'INSIDE' + } + + set strokeAlign(v: string) { + const n = this._raw() + if (n.strokes.length > 0) { + const strokes = copyStrokes(n.strokes) + strokes[0].align = v as Stroke['align'] + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes }) + } + } + + get dashPattern(): readonly number[] { + return Object.freeze([...this._raw().dashPattern]) + } + + set dashPattern(v: readonly number[]) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { dashPattern: [...v] }) + } + + get strokeCap(): string { + return this._raw().strokeCap + } + + set strokeCap(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeCap: v as SceneNode['strokeCap'] }) + } + + get strokeJoin(): string { + return this._raw().strokeJoin + } + + set strokeJoin(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeJoin: v as SceneNode['strokeJoin'] }) + } + + get strokeMiterLimit(): number { + return this._raw().strokeMiterLimit + } + + set strokeMiterLimit(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeMiterLimit: v }) + } + + get strokeTopWeight(): number { + return this._raw().borderTopWeight + } + + set strokeTopWeight(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + borderTopWeight: v, + independentStrokeWeights: true + }) + } + + get strokeBottomWeight(): number { + return this._raw().borderBottomWeight + } + + set strokeBottomWeight(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + borderBottomWeight: v, + independentStrokeWeights: true + }) + } + + get strokeLeftWeight(): number { + return this._raw().borderLeftWeight + } + + set strokeLeftWeight(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + borderLeftWeight: v, + independentStrokeWeights: true + }) + } + + get strokeRightWeight(): number { + return this._raw().borderRightWeight + } + + set strokeRightWeight(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + borderRightWeight: v, + independentStrokeWeights: true + }) + } + + // --- Text --- + + get characters(): string { + return this._raw().text + } + + set characters(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { text: v }) + } + + get fontSize(): number { + return this._raw().fontSize + } + + set fontSize(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { fontSize: v }) + } + + get fontName(): FigmaFontName { + const n = this._raw() + return { family: n.fontFamily, style: weightToStyleName(n.fontWeight, n.italic) } + } + + set fontName(v: FigmaFontName) { + const { weight, italic } = styleNameToWeight(v.style) + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + fontFamily: v.family, + fontWeight: weight, + italic + }) + } + + get fontWeight(): number { + return this._raw().fontWeight + } + + set fontWeight(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { fontWeight: v }) + } + + get textAlignHorizontal(): string { + return this._raw().textAlignHorizontal + } + + set textAlignHorizontal(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + textAlignHorizontal: v as SceneNode['textAlignHorizontal'] + }) + } + + get textAlignVertical(): string { + return this._raw().textAlignVertical + } + + set textAlignVertical(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + textAlignVertical: v as SceneNode['textAlignVertical'] + }) + } + + get textAutoResize(): string { + return this._raw().textAutoResize + } + + set textAutoResize(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + textAutoResize: v as SceneNode['textAutoResize'] + }) + } + + get letterSpacing(): number { + return this._raw().letterSpacing + } + + set letterSpacing(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { letterSpacing: v }) + } + + get lineHeight(): number | null { + return this._raw().lineHeight + } + + set lineHeight(v: number | null) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { lineHeight: v }) + } + + get textCase(): string { + return this._raw().textCase + } + + set textCase(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { textCase: v as SceneNode['textCase'] }) + } + + get textDecoration(): string { + return this._raw().textDecoration + } + + set textDecoration(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + textDecoration: v as SceneNode['textDecoration'] + }) + } + + get maxLines(): number | null { + return this._raw().maxLines + } + + set maxLines(v: number | null) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxLines: v }) + } + + get textTruncation(): string { + return this._raw().textTruncation + } + + set textTruncation(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + textTruncation: v as SceneNode['textTruncation'] + }) + } + + get autoRename(): boolean { + return this._raw().autoRename + } + + set autoRename(v: boolean) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { autoRename: v }) + } + + insertCharacters(start: number, characters: string): void { + const n = this._raw() + const text = n.text.slice(0, start) + characters + n.text.slice(start) + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { text }) + } + + deleteCharacters(start: number, end: number): void { + const n = this._raw() + const text = n.text.slice(0, start) + n.text.slice(end) + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { text }) + } + + // --- Auto-layout --- + + get layoutMode(): LayoutMode { + return this._raw().layoutMode + } + + set layoutMode(v: LayoutMode) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutMode: v }) + } + + get primaryAxisAlignItems(): string { + return this._raw().primaryAxisAlign + } + + set primaryAxisAlignItems(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + primaryAxisAlign: v as SceneNode['primaryAxisAlign'] + }) + } + + get counterAxisAlignItems(): string { + return this._raw().counterAxisAlign + } + + set counterAxisAlignItems(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + counterAxisAlign: v as SceneNode['counterAxisAlign'] + }) + } + + get itemSpacing(): number { + return this._raw().itemSpacing + } + + set itemSpacing(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { itemSpacing: v }) + } + + get counterAxisSpacing(): number { + return this._raw().counterAxisSpacing + } + + set counterAxisSpacing(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { counterAxisSpacing: v }) + } + + get paddingTop(): number { + return this._raw().paddingTop + } + + set paddingTop(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingTop: v }) + } + + get paddingRight(): number { + return this._raw().paddingRight + } + + set paddingRight(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingRight: v }) + } + + get paddingBottom(): number { + return this._raw().paddingBottom + } + + set paddingBottom(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingBottom: v }) + } + + get paddingLeft(): number { + return this._raw().paddingLeft + } + + set paddingLeft(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingLeft: v }) + } + + get layoutWrap(): string { + return this._raw().layoutWrap + } + + set layoutWrap(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutWrap: v as SceneNode['layoutWrap'] }) + } + + get primaryAxisSizingMode(): string { + return this._raw().primaryAxisSizing === 'HUG' ? 'AUTO' : this._raw().primaryAxisSizing + } + + set primaryAxisSizingMode(v: string) { + const mapped = v === 'AUTO' ? 'HUG' : v + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + primaryAxisSizing: mapped as SceneNode['primaryAxisSizing'] + }) + } + + get counterAxisSizingMode(): string { + return this._raw().counterAxisSizing === 'HUG' ? 'AUTO' : this._raw().counterAxisSizing + } + + set counterAxisSizingMode(v: string) { + const mapped = v === 'AUTO' ? 'HUG' : v + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + counterAxisSizing: mapped as SceneNode['counterAxisSizing'] + }) + } + + get counterAxisAlignContent(): string { + return this._raw().counterAxisAlignContent + } + + set counterAxisAlignContent(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + counterAxisAlignContent: v as SceneNode['counterAxisAlignContent'] + }) + } + + get itemReverseZIndex(): boolean { + return this._raw().itemReverseZIndex + } + + set itemReverseZIndex(v: boolean) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { itemReverseZIndex: v }) + } + + get strokesIncludedInLayout(): boolean { + return this._raw().strokesIncludedInLayout + } + + set strokesIncludedInLayout(v: boolean) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokesIncludedInLayout: v }) + } + + // --- Layout child props --- + + get layoutPositioning(): string { + return this._raw().layoutPositioning + } + + set layoutPositioning(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + layoutPositioning: v as SceneNode['layoutPositioning'] + }) + } + + get layoutGrow(): number { + return this._raw().layoutGrow + } + + set layoutGrow(v: number) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutGrow: v }) + } + + get layoutAlign(): string { + const n = this._raw() + if (n.layoutAlignSelf === 'AUTO') return 'INHERIT' + return n.layoutAlignSelf + } + + set layoutAlign(v: string) { + const mapped = v === 'INHERIT' ? 'AUTO' : v + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + layoutAlignSelf: mapped as SceneNode['layoutAlignSelf'] + }) + } + + get layoutSizingHorizontal(): string { + const n = this._raw() + const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined + const refLayout = parent && parent.layoutMode !== 'NONE' ? parent.layoutMode : n.layoutMode + if (refLayout === 'NONE') return 'FIXED' + return refLayout === 'HORIZONTAL' ? n.primaryAxisSizing : n.counterAxisSizing + } + + set layoutSizingHorizontal(v: string) { + const n = this._raw() + const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined + const refLayout = parent && parent.layoutMode !== 'NONE' ? parent.layoutMode : n.layoutMode + if (refLayout === 'VERTICAL') { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + counterAxisSizing: v as SceneNode['counterAxisSizing'] + }) + } else { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + primaryAxisSizing: v as SceneNode['primaryAxisSizing'] + }) + } + } + + get layoutSizingVertical(): string { + const n = this._raw() + const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined + const refLayout = parent && parent.layoutMode !== 'NONE' ? parent.layoutMode : n.layoutMode + if (refLayout === 'NONE') return 'FIXED' + return refLayout === 'VERTICAL' ? n.primaryAxisSizing : n.counterAxisSizing + } + + set layoutSizingVertical(v: string) { + const n = this._raw() + const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined + const refLayout = parent && parent.layoutMode !== 'NONE' ? parent.layoutMode : n.layoutMode + if (refLayout === 'HORIZONTAL') { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + counterAxisSizing: v as SceneNode['counterAxisSizing'] + }) + } else { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + primaryAxisSizing: v as SceneNode['primaryAxisSizing'] + }) + } + } + + // --- Constraints --- + + get constraints(): { horizontal: string; vertical: string } { + const n = this._raw() + return { horizontal: n.horizontalConstraint, vertical: n.verticalConstraint } + } + + set constraints(v: { horizontal: string; vertical: string }) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + horizontalConstraint: v.horizontal as SceneNode['horizontalConstraint'], + verticalConstraint: v.vertical as SceneNode['verticalConstraint'] + }) + } + + // --- Dimension constraints --- + + get minWidth(): number | null { + return this._raw().minWidth + } + + set minWidth(v: number | null) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { minWidth: v }) + } + + get maxWidth(): number | null { + return this._raw().maxWidth + } + + set maxWidth(v: number | null) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxWidth: v }) + } + + get minHeight(): number | null { + return this._raw().minHeight + } + + set minHeight(v: number | null) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { minHeight: v }) + } + + get maxHeight(): number | null { + return this._raw().maxHeight + } + + set maxHeight(v: number | null) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxHeight: v }) + } + + // --- Mask --- + + get isMask(): boolean { + return this._raw().isMask + } + + set isMask(v: boolean) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { isMask: v }) + } + + get maskType(): string { + return this._raw().maskType + } + + set maskType(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maskType: v as SceneNode['maskType'] }) + } + + // --- UI state --- + + get expanded(): boolean { + return this._raw().expanded + } + + set expanded(v: boolean) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { expanded: v }) + } + + // --- Components --- + + get mainComponent(): FigmaNodeProxy | null { + const n = this._raw() + if (!n.componentId) return null + const comp = this[INTERNAL_GRAPH].getNode(n.componentId) + if (!comp) return null + return this[INTERNAL_API].wrapNode(comp.id) + } + + createInstance(): FigmaNodeProxy { + const n = this._raw() + if (n.type !== 'COMPONENT') throw new Error('createInstance() can only be called on components') + const pageId = this[INTERNAL_API].currentPageId + const inst = this[INTERNAL_GRAPH].createInstance(n.id, pageId) + if (!inst) throw new Error('Failed to create instance') + return this[INTERNAL_API].wrapNode(inst.id) + } + + // --- Tree --- + + get parent(): FigmaNodeProxy | null { + const n = this._raw() + if (!n.parentId) return null + return this[INTERNAL_API].wrapNode(n.parentId) + } + + get children(): FigmaNodeProxy[] { + return this[INTERNAL_GRAPH] + .getChildren(this[INTERNAL_ID]) + .map((c) => this[INTERNAL_API].wrapNode(c.id)) + } + + appendChild(child: FigmaNodeProxy): void { + this[INTERNAL_GRAPH].reparentNode(child[INTERNAL_ID], this[INTERNAL_ID]) + } + + insertChild(index: number, child: FigmaNodeProxy): void { + this[INTERNAL_GRAPH].reparentNode(child[INTERNAL_ID], this[INTERNAL_ID]) + this[INTERNAL_GRAPH].reorderChild(child[INTERNAL_ID], this[INTERNAL_ID], index) + } + + clone(): FigmaNodeProxy { + const n = this._raw() + const parentId = n.parentId ?? this[INTERNAL_API].currentPageId + const cloned = this[INTERNAL_GRAPH].cloneTree(this[INTERNAL_ID], parentId) + if (!cloned) throw new Error(`Failed to clone node ${this[INTERNAL_ID]}`) + return this[INTERNAL_API].wrapNode(cloned.id) + } + + remove(): void { + this[INTERNAL_GRAPH].deleteNode(this[INTERNAL_ID]) + } + + findAll(callback?: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy[] { + const results: FigmaNodeProxy[] = [] + const walk = (id: string) => { + for (const child of this[INTERNAL_GRAPH].getChildren(id)) { + const proxy = this[INTERNAL_API].wrapNode(child.id) + if (!callback || callback(proxy)) results.push(proxy) + walk(child.id) + } + } + walk(this[INTERNAL_ID]) + return results + } + + findOne(callback: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy | null { + const walk = (id: string): FigmaNodeProxy | null => { + for (const child of this[INTERNAL_GRAPH].getChildren(id)) { + const proxy = this[INTERNAL_API].wrapNode(child.id) + if (callback(proxy)) return proxy + const found = walk(child.id) + if (found) return found + } + return null + } + return walk(this[INTERNAL_ID]) + } + + findChild(callback: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy | null { + for (const child of this[INTERNAL_GRAPH].getChildren(this[INTERNAL_ID])) { + const proxy = this[INTERNAL_API].wrapNode(child.id) + if (callback(proxy)) return proxy + } + return null + } + + findChildren(callback?: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy[] { + return this[INTERNAL_GRAPH] + .getChildren(this[INTERNAL_ID]) + .map((c) => this[INTERNAL_API].wrapNode(c.id)) + .filter((proxy) => !callback || callback(proxy)) + } + + findAllWithCriteria(criteria: { types?: string[] }): FigmaNodeProxy[] { + const types = criteria.types ? new Set(criteria.types) : null + return this.findAll((node) => !types || types.has(node.type)) + } + + // --- Serialization --- + + toJSON(): Record { + const n = this._raw() + const obj: Record = { + id: n.id, + type: n.type, + name: n.name, + x: n.x, + y: n.y, + width: n.width, + height: n.height + } + if (n.fills.length > 0) obj.fills = n.fills + if (n.strokes.length > 0) obj.strokes = n.strokes + if (n.effects.length > 0) obj.effects = n.effects + if (n.opacity !== 1) obj.opacity = n.opacity + if (n.cornerRadius > 0) obj.cornerRadius = n.cornerRadius + if (!n.visible) obj.visible = false + if (n.text) obj.characters = n.text + if (n.layoutMode !== 'NONE') { + obj.layoutMode = n.layoutMode + obj.itemSpacing = n.itemSpacing + } + const children = this[INTERNAL_GRAPH].getChildren(this[INTERNAL_ID]) + if (children.length > 0) { + obj.children = children.map((c) => this[INTERNAL_API].wrapNode(c.id).toJSON()) + } + return obj + } + + toString(): string { + const n = this._raw() + return `[${n.type} "${n.name}" ${n.id}]` + } + + [Symbol.for('nodejs.util.inspect.custom')](): string { + return this.toString() + } +} diff --git a/packages/core/src/figma-api.ts b/packages/core/src/figma-api.ts index 1ddd11509..b290d5261 100644 --- a/packages/core/src/figma-api.ts +++ b/packages/core/src/figma-api.ts @@ -1,11 +1,6 @@ import type { SceneGraph, - SceneNode, NodeType, - Fill, - Stroke, - Effect, - LayoutMode, Variable, VariableCollection, VariableType, @@ -14,48 +9,16 @@ import type { import type { Rect, Vector } from './types' import { copyFills, copyStrokes, copyEffects } from './copy' -const MIXED = Symbol('mixed') +import { + FigmaNodeProxy, + INTERNAL_ID, + MIXED, + type FigmaFontName, + type NodeProxyHost, +} from './figma-api-proxy' -type FigmaFontName = { family: string; style: string } - -function weightToStyleName(weight: number, italic: boolean): string { - const names: Record = { - 100: 'Thin', - 200: 'Extra Light', - 300: 'Light', - 400: 'Regular', - 500: 'Medium', - 600: 'Semi Bold', - 700: 'Bold', - 800: 'Extra Bold', - 900: 'Black' - } - const base = names[weight] ?? 'Regular' - return italic ? `${base} Italic` : base -} - -function styleNameToWeight(style: string): { weight: number; italic: boolean } { - const lower = style.toLowerCase() - const italic = lower.includes('italic') - const clean = lower.replace(/italic/i, '').trim() - const map: Record = { - thin: 100, - 'extra light': 200, - 'ultra light': 200, - light: 300, - regular: 400, - '': 400, - medium: 500, - 'semi bold': 600, - 'demi bold': 600, - bold: 700, - 'extra bold': 800, - 'ultra bold': 800, - black: 900, - heavy: 900 - } - return { weight: map[clean] ?? 400, italic } -} +export { FigmaNodeProxy } from './figma-api-proxy' +export type { FigmaFontName } from './figma-api-proxy' export function computeImageHash(data: Uint8Array): string { let h1 = 0x811c9dc5 >>> 0 @@ -76,922 +39,7 @@ export function computeImageHash(data: Uint8Array): string { return [h1, h2, h3, h4, h5].map(h => h.toString(16).padStart(8, '0')).join('') } -const INTERNAL_ID = Symbol('id') -const INTERNAL_GRAPH = Symbol('graph') -const INTERNAL_API = Symbol('api') - -class FigmaNodeProxy { - [INTERNAL_ID]: string; - [INTERNAL_GRAPH]: SceneGraph; - [INTERNAL_API]: FigmaAPI - - constructor(id: string, graph: SceneGraph, api: FigmaAPI) { - this[INTERNAL_ID] = id - this[INTERNAL_GRAPH] = graph - this[INTERNAL_API] = api - } - - private _raw(): SceneNode { - const n = this[INTERNAL_GRAPH].getNode(this[INTERNAL_ID]) - if (!n) throw new Error(`Node ${this[INTERNAL_ID]} has been removed`) - return n - } - - get id(): string { - return this[INTERNAL_ID] - } - - get type(): NodeType { - return this._raw().type - } - - get name(): string { - return this._raw().name - } - - set name(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { name: v }) - } - - get removed(): boolean { - return !this[INTERNAL_GRAPH].getNode(this[INTERNAL_ID]) - } - - // --- Geometry --- - - get x(): number { - return this._raw().x - } - - set x(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { x: v }) - } - - get y(): number { - return this._raw().y - } - - set y(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { y: v }) - } - - get width(): number { - return this._raw().width - } - - get height(): number { - return this._raw().height - } - - get rotation(): number { - return this._raw().rotation - } - - set rotation(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { rotation: v }) - } - - resize(width: number, height: number): void { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { width, height }) - } - - resizeWithoutConstraints(width: number, height: number): void { - this.resize(width, height) - } - - get absoluteTransform(): [[number, number, number], [number, number, number]] { - const pos = this[INTERNAL_GRAPH].getAbsolutePosition(this[INTERNAL_ID]) - return [ - [1, 0, pos.x], - [0, 1, pos.y] - ] - } - - get absoluteBoundingBox(): Rect { - return this[INTERNAL_GRAPH].getAbsoluteBounds(this[INTERNAL_ID]) - } - - get absoluteRenderBounds(): Rect { - return this.absoluteBoundingBox - } - - // --- Visual --- - - get fills(): readonly Fill[] { - return Object.freeze(copyFills(this._raw().fills)) - } - - set fills(v: readonly Fill[]) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { fills: [...v] }) - } - - get strokes(): readonly Stroke[] { - return Object.freeze(copyStrokes(this._raw().strokes)) - } - - set strokes(v: readonly Stroke[]) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes: [...v] }) - } - - get effects(): readonly Effect[] { - return Object.freeze(copyEffects(this._raw().effects)) - } - - set effects(v: readonly Effect[]) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { effects: [...v] }) - } - - get opacity(): number { - return this._raw().opacity - } - - set opacity(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { opacity: v }) - } - - get visible(): boolean { - return this._raw().visible - } - - set visible(v: boolean) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { visible: v }) - } - - get locked(): boolean { - return this._raw().locked - } - - set locked(v: boolean) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { locked: v }) - } - - get blendMode(): string { - return this._raw().blendMode - } - - set blendMode(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { blendMode: v as SceneNode['blendMode'] }) - } - - get clipsContent(): boolean { - return this._raw().clipsContent - } - - set clipsContent(v: boolean) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { clipsContent: v }) - } - - // --- Corner Radius --- - - get cornerRadius(): number | typeof MIXED { - const n = this._raw() - if (n.independentCorners) return MIXED - return n.cornerRadius - } - - set cornerRadius(v: number | typeof MIXED) { - if (v === MIXED) return - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - cornerRadius: v, - topLeftRadius: v, - topRightRadius: v, - bottomRightRadius: v, - bottomLeftRadius: v, - independentCorners: false - }) - } - - get topLeftRadius(): number { - return this._raw().topLeftRadius - } - - set topLeftRadius(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - topLeftRadius: v, - independentCorners: true - }) - } - - get topRightRadius(): number { - return this._raw().topRightRadius - } - - set topRightRadius(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - topRightRadius: v, - independentCorners: true - }) - } - - get bottomLeftRadius(): number { - return this._raw().bottomLeftRadius - } - - set bottomLeftRadius(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - bottomLeftRadius: v, - independentCorners: true - }) - } - - get bottomRightRadius(): number { - return this._raw().bottomRightRadius - } - - set bottomRightRadius(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - bottomRightRadius: v, - independentCorners: true - }) - } - - get cornerSmoothing(): number { - return this._raw().cornerSmoothing - } - - set cornerSmoothing(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { cornerSmoothing: v }) - } - - // --- Stroke details --- - - get strokeWeight(): number { - const s = this._raw().strokes - return s.length > 0 ? s[0].weight : 0 - } - - set strokeWeight(v: number) { - const n = this._raw() - if (n.strokes.length > 0) { - const strokes = copyStrokes(n.strokes) - strokes[0].weight = v - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes }) - } - } - - get strokeAlign(): string { - const s = this._raw().strokes - return s.length > 0 ? s[0].align : 'INSIDE' - } - - set strokeAlign(v: string) { - const n = this._raw() - if (n.strokes.length > 0) { - const strokes = copyStrokes(n.strokes) - strokes[0].align = v as Stroke['align'] - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes }) - } - } - - get dashPattern(): readonly number[] { - return Object.freeze([...this._raw().dashPattern]) - } - - set dashPattern(v: readonly number[]) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { dashPattern: [...v] }) - } - - get strokeCap(): string { - return this._raw().strokeCap - } - - set strokeCap(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeCap: v as SceneNode['strokeCap'] }) - } - - get strokeJoin(): string { - return this._raw().strokeJoin - } - - set strokeJoin(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeJoin: v as SceneNode['strokeJoin'] }) - } - - get strokeMiterLimit(): number { - return this._raw().strokeMiterLimit - } - - set strokeMiterLimit(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeMiterLimit: v }) - } - - get strokeTopWeight(): number { - return this._raw().borderTopWeight - } - - set strokeTopWeight(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - borderTopWeight: v, - independentStrokeWeights: true - }) - } - - get strokeBottomWeight(): number { - return this._raw().borderBottomWeight - } - - set strokeBottomWeight(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - borderBottomWeight: v, - independentStrokeWeights: true - }) - } - - get strokeLeftWeight(): number { - return this._raw().borderLeftWeight - } - - set strokeLeftWeight(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - borderLeftWeight: v, - independentStrokeWeights: true - }) - } - - get strokeRightWeight(): number { - return this._raw().borderRightWeight - } - - set strokeRightWeight(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - borderRightWeight: v, - independentStrokeWeights: true - }) - } - - // --- Text --- - - get characters(): string { - return this._raw().text - } - - set characters(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { text: v }) - } - - get fontSize(): number { - return this._raw().fontSize - } - - set fontSize(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { fontSize: v }) - } - - get fontName(): FigmaFontName { - const n = this._raw() - return { family: n.fontFamily, style: weightToStyleName(n.fontWeight, n.italic) } - } - - set fontName(v: FigmaFontName) { - const { weight, italic } = styleNameToWeight(v.style) - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - fontFamily: v.family, - fontWeight: weight, - italic - }) - } - - get fontWeight(): number { - return this._raw().fontWeight - } - - set fontWeight(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { fontWeight: v }) - } - - get textAlignHorizontal(): string { - return this._raw().textAlignHorizontal - } - - set textAlignHorizontal(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - textAlignHorizontal: v as SceneNode['textAlignHorizontal'] - }) - } - - get textAlignVertical(): string { - return this._raw().textAlignVertical - } - - set textAlignVertical(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - textAlignVertical: v as SceneNode['textAlignVertical'] - }) - } - - get textAutoResize(): string { - return this._raw().textAutoResize - } - - set textAutoResize(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - textAutoResize: v as SceneNode['textAutoResize'] - }) - } - - get letterSpacing(): number { - return this._raw().letterSpacing - } - - set letterSpacing(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { letterSpacing: v }) - } - - get lineHeight(): number | null { - return this._raw().lineHeight - } - - set lineHeight(v: number | null) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { lineHeight: v }) - } - - get textCase(): string { - return this._raw().textCase - } - - set textCase(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { textCase: v as SceneNode['textCase'] }) - } - - get textDecoration(): string { - return this._raw().textDecoration - } - - set textDecoration(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - textDecoration: v as SceneNode['textDecoration'] - }) - } - - get maxLines(): number | null { - return this._raw().maxLines - } - - set maxLines(v: number | null) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxLines: v }) - } - - get textTruncation(): string { - return this._raw().textTruncation - } - - set textTruncation(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - textTruncation: v as SceneNode['textTruncation'] - }) - } - - get autoRename(): boolean { - return this._raw().autoRename - } - - set autoRename(v: boolean) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { autoRename: v }) - } - - insertCharacters(start: number, characters: string): void { - const n = this._raw() - const text = n.text.slice(0, start) + characters + n.text.slice(start) - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { text }) - } - - deleteCharacters(start: number, end: number): void { - const n = this._raw() - const text = n.text.slice(0, start) + n.text.slice(end) - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { text }) - } - - // --- Auto-layout --- - - get layoutMode(): LayoutMode { - return this._raw().layoutMode - } - - set layoutMode(v: LayoutMode) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutMode: v }) - } - - get primaryAxisAlignItems(): string { - return this._raw().primaryAxisAlign - } - - set primaryAxisAlignItems(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - primaryAxisAlign: v as SceneNode['primaryAxisAlign'] - }) - } - - get counterAxisAlignItems(): string { - return this._raw().counterAxisAlign - } - - set counterAxisAlignItems(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - counterAxisAlign: v as SceneNode['counterAxisAlign'] - }) - } - - get itemSpacing(): number { - return this._raw().itemSpacing - } - - set itemSpacing(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { itemSpacing: v }) - } - - get counterAxisSpacing(): number { - return this._raw().counterAxisSpacing - } - - set counterAxisSpacing(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { counterAxisSpacing: v }) - } - - get paddingTop(): number { - return this._raw().paddingTop - } - - set paddingTop(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingTop: v }) - } - - get paddingRight(): number { - return this._raw().paddingRight - } - - set paddingRight(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingRight: v }) - } - - get paddingBottom(): number { - return this._raw().paddingBottom - } - - set paddingBottom(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingBottom: v }) - } - - get paddingLeft(): number { - return this._raw().paddingLeft - } - - set paddingLeft(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingLeft: v }) - } - - get layoutWrap(): string { - return this._raw().layoutWrap - } - - set layoutWrap(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutWrap: v as SceneNode['layoutWrap'] }) - } - - get primaryAxisSizingMode(): string { - return this._raw().primaryAxisSizing === 'HUG' ? 'AUTO' : this._raw().primaryAxisSizing - } - - set primaryAxisSizingMode(v: string) { - const mapped = v === 'AUTO' ? 'HUG' : v - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - primaryAxisSizing: mapped as SceneNode['primaryAxisSizing'] - }) - } - - get counterAxisSizingMode(): string { - return this._raw().counterAxisSizing === 'HUG' ? 'AUTO' : this._raw().counterAxisSizing - } - - set counterAxisSizingMode(v: string) { - const mapped = v === 'AUTO' ? 'HUG' : v - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - counterAxisSizing: mapped as SceneNode['counterAxisSizing'] - }) - } - - get counterAxisAlignContent(): string { - return this._raw().counterAxisAlignContent - } - - set counterAxisAlignContent(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - counterAxisAlignContent: v as SceneNode['counterAxisAlignContent'] - }) - } - - get itemReverseZIndex(): boolean { - return this._raw().itemReverseZIndex - } - - set itemReverseZIndex(v: boolean) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { itemReverseZIndex: v }) - } - - get strokesIncludedInLayout(): boolean { - return this._raw().strokesIncludedInLayout - } - - set strokesIncludedInLayout(v: boolean) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokesIncludedInLayout: v }) - } - - // --- Layout child props --- - - get layoutPositioning(): string { - return this._raw().layoutPositioning - } - - set layoutPositioning(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - layoutPositioning: v as SceneNode['layoutPositioning'] - }) - } - - get layoutGrow(): number { - return this._raw().layoutGrow - } - - set layoutGrow(v: number) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutGrow: v }) - } - - get layoutAlign(): string { - const n = this._raw() - if (n.layoutAlignSelf === 'AUTO') return 'INHERIT' - return n.layoutAlignSelf - } - - set layoutAlign(v: string) { - const mapped = v === 'INHERIT' ? 'AUTO' : v - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - layoutAlignSelf: mapped as SceneNode['layoutAlignSelf'] - }) - } - - get layoutSizingHorizontal(): string { - const n = this._raw() - const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined - const refLayout = parent && parent.layoutMode !== 'NONE' ? parent.layoutMode : n.layoutMode - if (refLayout === 'NONE') return 'FIXED' - return refLayout === 'HORIZONTAL' ? n.primaryAxisSizing : n.counterAxisSizing - } - - set layoutSizingHorizontal(v: string) { - const n = this._raw() - const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined - const refLayout = parent && parent.layoutMode !== 'NONE' ? parent.layoutMode : n.layoutMode - if (refLayout === 'VERTICAL') { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - counterAxisSizing: v as SceneNode['counterAxisSizing'] - }) - } else { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - primaryAxisSizing: v as SceneNode['primaryAxisSizing'] - }) - } - } - - get layoutSizingVertical(): string { - const n = this._raw() - const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined - const refLayout = parent && parent.layoutMode !== 'NONE' ? parent.layoutMode : n.layoutMode - if (refLayout === 'NONE') return 'FIXED' - return refLayout === 'VERTICAL' ? n.primaryAxisSizing : n.counterAxisSizing - } - - set layoutSizingVertical(v: string) { - const n = this._raw() - const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined - const refLayout = parent && parent.layoutMode !== 'NONE' ? parent.layoutMode : n.layoutMode - if (refLayout === 'HORIZONTAL') { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - counterAxisSizing: v as SceneNode['counterAxisSizing'] - }) - } else { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - primaryAxisSizing: v as SceneNode['primaryAxisSizing'] - }) - } - } - - // --- Constraints --- - - get constraints(): { horizontal: string; vertical: string } { - const n = this._raw() - return { horizontal: n.horizontalConstraint, vertical: n.verticalConstraint } - } - - set constraints(v: { horizontal: string; vertical: string }) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { - horizontalConstraint: v.horizontal as SceneNode['horizontalConstraint'], - verticalConstraint: v.vertical as SceneNode['verticalConstraint'] - }) - } - - // --- Dimension constraints --- - - get minWidth(): number | null { - return this._raw().minWidth - } - - set minWidth(v: number | null) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { minWidth: v }) - } - - get maxWidth(): number | null { - return this._raw().maxWidth - } - - set maxWidth(v: number | null) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxWidth: v }) - } - - get minHeight(): number | null { - return this._raw().minHeight - } - - set minHeight(v: number | null) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { minHeight: v }) - } - - get maxHeight(): number | null { - return this._raw().maxHeight - } - - set maxHeight(v: number | null) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxHeight: v }) - } - - // --- Mask --- - - get isMask(): boolean { - return this._raw().isMask - } - - set isMask(v: boolean) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { isMask: v }) - } - - get maskType(): string { - return this._raw().maskType - } - - set maskType(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maskType: v as SceneNode['maskType'] }) - } - - // --- UI state --- - - get expanded(): boolean { - return this._raw().expanded - } - - set expanded(v: boolean) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { expanded: v }) - } - - // --- Components --- - - get mainComponent(): FigmaNodeProxy | null { - const n = this._raw() - if (!n.componentId) return null - const comp = this[INTERNAL_GRAPH].getNode(n.componentId) - if (!comp) return null - return this[INTERNAL_API].wrapNode(comp.id) - } - - createInstance(): FigmaNodeProxy { - const n = this._raw() - if (n.type !== 'COMPONENT') throw new Error('createInstance() can only be called on components') - const pageId = this[INTERNAL_API].currentPageId - const inst = this[INTERNAL_GRAPH].createInstance(n.id, pageId) - if (!inst) throw new Error('Failed to create instance') - return this[INTERNAL_API].wrapNode(inst.id) - } - - // --- Tree --- - - get parent(): FigmaNodeProxy | null { - const n = this._raw() - if (!n.parentId) return null - return this[INTERNAL_API].wrapNode(n.parentId) - } - - get children(): FigmaNodeProxy[] { - return this[INTERNAL_GRAPH] - .getChildren(this[INTERNAL_ID]) - .map((c) => this[INTERNAL_API].wrapNode(c.id)) - } - - appendChild(child: FigmaNodeProxy): void { - this[INTERNAL_GRAPH].reparentNode(child[INTERNAL_ID], this[INTERNAL_ID]) - } - - insertChild(index: number, child: FigmaNodeProxy): void { - this[INTERNAL_GRAPH].reparentNode(child[INTERNAL_ID], this[INTERNAL_ID]) - this[INTERNAL_GRAPH].reorderChild(child[INTERNAL_ID], this[INTERNAL_ID], index) - } - - clone(): FigmaNodeProxy { - const n = this._raw() - const parentId = n.parentId ?? this[INTERNAL_API].currentPageId - const cloned = this[INTERNAL_GRAPH].cloneTree(this[INTERNAL_ID], parentId) - if (!cloned) throw new Error(`Failed to clone node ${this[INTERNAL_ID]}`) - return this[INTERNAL_API].wrapNode(cloned.id) - } - - remove(): void { - this[INTERNAL_GRAPH].deleteNode(this[INTERNAL_ID]) - } - - findAll(callback?: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy[] { - const results: FigmaNodeProxy[] = [] - const walk = (id: string) => { - for (const child of this[INTERNAL_GRAPH].getChildren(id)) { - const proxy = this[INTERNAL_API].wrapNode(child.id) - if (!callback || callback(proxy)) results.push(proxy) - walk(child.id) - } - } - walk(this[INTERNAL_ID]) - return results - } - - findOne(callback: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy | null { - const walk = (id: string): FigmaNodeProxy | null => { - for (const child of this[INTERNAL_GRAPH].getChildren(id)) { - const proxy = this[INTERNAL_API].wrapNode(child.id) - if (callback(proxy)) return proxy - const found = walk(child.id) - if (found) return found - } - return null - } - return walk(this[INTERNAL_ID]) - } - - findChild(callback: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy | null { - for (const child of this[INTERNAL_GRAPH].getChildren(this[INTERNAL_ID])) { - const proxy = this[INTERNAL_API].wrapNode(child.id) - if (callback(proxy)) return proxy - } - return null - } - - findChildren(callback?: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy[] { - return this[INTERNAL_GRAPH] - .getChildren(this[INTERNAL_ID]) - .map((c) => this[INTERNAL_API].wrapNode(c.id)) - .filter((proxy) => !callback || callback(proxy)) - } - - findAllWithCriteria(criteria: { types?: string[] }): FigmaNodeProxy[] { - const types = criteria.types ? new Set(criteria.types) : null - return this.findAll((node) => !types || types.has(node.type)) - } - - // --- Serialization --- - - toJSON(): Record { - const n = this._raw() - const obj: Record = { - id: n.id, - type: n.type, - name: n.name, - x: n.x, - y: n.y, - width: n.width, - height: n.height - } - if (n.fills.length > 0) obj.fills = n.fills - if (n.strokes.length > 0) obj.strokes = n.strokes - if (n.effects.length > 0) obj.effects = n.effects - if (n.opacity !== 1) obj.opacity = n.opacity - if (n.cornerRadius > 0) obj.cornerRadius = n.cornerRadius - if (!n.visible) obj.visible = false - if (n.text) obj.characters = n.text - if (n.layoutMode !== 'NONE') { - obj.layoutMode = n.layoutMode - obj.itemSpacing = n.itemSpacing - } - const children = this[INTERNAL_GRAPH].getChildren(this[INTERNAL_ID]) - if (children.length > 0) { - obj.children = children.map((c) => this[INTERNAL_API].wrapNode(c.id).toJSON()) - } - return obj - } - - toString(): string { - const n = this._raw() - return `[${n.type} "${n.name}" ${n.id}]` - } - - [Symbol.for('nodejs.util.inspect.custom')](): string { - return this.toString() - } -} - -export class FigmaAPI { +export class FigmaAPI implements NodeProxyHost { readonly graph: SceneGraph private _currentPageId: string private _selection: FigmaNodeProxy[] = [] @@ -1329,6 +377,3 @@ export class FigmaAPI { options: { scale?: number; format?: 'PNG' | 'JPG' | 'WEBP'; quality?: number } ) => Promise } - -export { FigmaNodeProxy } -export type { FigmaFontName } diff --git a/packages/core/src/kiwi/codec.ts b/packages/core/src/kiwi/codec.ts index 7db68ba41..ccad25b34 100644 --- a/packages/core/src/kiwi/codec.ts +++ b/packages/core/src/kiwi/codec.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- codec types and encode/decode logic are tightly coupled */ /** * Message Encoding/Decoding for Figma Multiplayer * diff --git a/packages/core/src/kiwi/instance-overrides.ts b/packages/core/src/kiwi/instance-overrides.ts index 770364607..17023b434 100644 --- a/packages/core/src/kiwi/instance-overrides.ts +++ b/packages/core/src/kiwi/instance-overrides.ts @@ -1,5 +1,7 @@ +/* eslint-disable max-lines -- override resolution is tightly coupled, splitting would hurt readability */ import type { SceneGraph, SceneNode, GeometryPath } from '../scene-graph' -import { guidToString, convertOverrideToProps, resolveGeometryPaths } from './kiwi-convert' +import { guidToString, resolveGeometryPaths } from './kiwi-convert' +import { convertOverrideToProps } from './kiwi-convert-overrides' import { copyFills, copyStrokes, copyEffects, copyStyleRuns, copyGeometryPaths } from '../copy' import type { GUID } from './codec' import type { Matrix, Vector } from '../types' diff --git a/packages/core/src/kiwi/kiwi-convert-overrides.ts b/packages/core/src/kiwi/kiwi-convert-overrides.ts new file mode 100644 index 000000000..7234d9806 --- /dev/null +++ b/packages/core/src/kiwi/kiwi-convert-overrides.ts @@ -0,0 +1,147 @@ +import { styleToWeight } from '../fonts' + +import type { SceneNode, ArcData, TextAutoResize } from '../scene-graph' +import type { NodeChange, Paint, Effect as KiwiEffect } from './codec' +import { + convertFills, + mapStackSizing, + mapStackJustify, + mapStackCounterAlign, + mapAlignSelf, + mapTextDecoration, + convertLineHeight, + convertLetterSpacing, + mapArcData, + importStyleRuns, + convertStrokes, + convertEffects, +} from './kiwi-convert' + +function applyOverridePaints(ov: Record, updates: Partial): void { + if (ov.textData != null) { + const td = ov.textData as { characters?: string } + if (td.characters != null) updates.text = td.characters + const runs = importStyleRuns(ov as unknown as NodeChange) + if (runs.length > 0) updates.styleRuns = runs + } + if (ov.fillPaints != null) updates.fills = convertFills(ov.fillPaints as Paint[]) + if (ov.strokePaints != null) + updates.strokes = convertStrokes( + ov.strokePaints as Paint[], + ov.strokeWeight as number | undefined, + ov.strokeAlign as string | undefined + ) + if (ov.effects != null) updates.effects = convertEffects(ov.effects as KiwiEffect[]) + if (ov.visible != null) updates.visible = ov.visible as boolean + if (ov.opacity != null) updates.opacity = ov.opacity as number + if (ov.name != null) updates.name = ov.name as string + if (ov.locked != null) updates.locked = ov.locked as boolean +} + +function applyOverrideGeometry(ov: Record, updates: Partial): void { + if (ov.size != null) { + const sz = ov.size as { x?: number; y?: number } + if (sz.x != null) updates.width = sz.x + if (sz.y != null) updates.height = sz.y + } + if (ov.cornerRadius != null) updates.cornerRadius = ov.cornerRadius as number + if (ov.rectangleTopLeftCornerRadius != null) + updates.topLeftRadius = ov.rectangleTopLeftCornerRadius as number + if (ov.rectangleTopRightCornerRadius != null) + updates.topRightRadius = ov.rectangleTopRightCornerRadius as number + if (ov.rectangleBottomRightCornerRadius != null) + updates.bottomRightRadius = ov.rectangleBottomRightCornerRadius as number + if (ov.rectangleBottomLeftCornerRadius != null) + updates.bottomLeftRadius = ov.rectangleBottomLeftCornerRadius as number + if (ov.rectangleCornerRadiiIndependent != null) + updates.independentCorners = ov.rectangleCornerRadiiIndependent as boolean + if (ov.arcData != null) + updates.arcData = mapArcData(ov.arcData as Partial | undefined) + if (ov.frameMaskDisabled != null) updates.clipsContent = ov.frameMaskDisabled === false +} + +function applyOverrideLayout(ov: Record, updates: Partial): void { + if (ov.stackSpacing != null) updates.itemSpacing = ov.stackSpacing as number + if (ov.stackPrimarySizing != null) + updates.primaryAxisSizing = mapStackSizing(ov.stackPrimarySizing as string) + if (ov.stackCounterSizing != null) + updates.counterAxisSizing = mapStackSizing(ov.stackCounterSizing as string) + if (ov.stackPrimaryAlignItems != null) + updates.primaryAxisAlign = mapStackJustify(ov.stackPrimaryAlignItems as string) + if (ov.stackCounterAlignItems != null) + updates.counterAxisAlign = mapStackCounterAlign(ov.stackCounterAlignItems as string) + if (ov.stackChildPrimaryGrow != null) updates.layoutGrow = ov.stackChildPrimaryGrow as number + if (ov.stackChildAlignSelf != null) + updates.layoutAlignSelf = mapAlignSelf(ov.stackChildAlignSelf as string) + if (ov.stackPositioning != null) + updates.layoutPositioning = + (ov.stackPositioning as string) === 'ABSOLUTE' ? 'ABSOLUTE' : 'AUTO' + if (ov.stackVerticalPadding != null) { + updates.paddingTop = ov.stackVerticalPadding as number + if (ov.stackPaddingBottom == null) updates.paddingBottom = ov.stackVerticalPadding as number + } + if (ov.stackHorizontalPadding != null) { + updates.paddingLeft = ov.stackHorizontalPadding as number + if (ov.stackPaddingRight == null) updates.paddingRight = ov.stackHorizontalPadding as number + } + if (ov.stackPaddingBottom != null) updates.paddingBottom = ov.stackPaddingBottom as number + if (ov.stackPaddingRight != null) updates.paddingRight = ov.stackPaddingRight as number +} + +function applyOverrideStrokes(ov: Record, updates: Partial): void { + if (ov.strokeWeight != null && !ov.strokePaints) { + updates.strokes = updates.strokes ?? [] + } + if (ov.strokeAlign != null && updates.strokes) { + 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 + if (ov.borderRightWeight != null) updates.borderRightWeight = ov.borderRightWeight as number + if (ov.borderBottomWeight != null) updates.borderBottomWeight = ov.borderBottomWeight as number + if (ov.borderLeftWeight != null) updates.borderLeftWeight = ov.borderLeftWeight as number + if (ov.borderStrokeWeightsIndependent != null) + updates.independentStrokeWeights = ov.borderStrokeWeightsIndependent as boolean +} + +function applyOverrideText(ov: Record, updates: Partial): void { + if (ov.fontName != null) { + const fn = ov.fontName as { family?: string; style?: string } + if (fn.family) updates.fontFamily = fn.family + if (fn.style) { + updates.fontWeight = styleToWeight(fn.style) + updates.italic = fn.style.toLowerCase().includes('italic') + } + } + if (ov.fontSize != null) updates.fontSize = ov.fontSize as number + if (ov.textAlignHorizontal != null) + updates.textAlignHorizontal = ov.textAlignHorizontal as SceneNode['textAlignHorizontal'] + if (ov.textAutoResize != null) updates.textAutoResize = ov.textAutoResize as TextAutoResize + if (ov.lineHeight != null) + updates.lineHeight = convertLineHeight( + ov.lineHeight as NodeChange['lineHeight'], + ov.fontSize as number | undefined + ) + if (ov.letterSpacing != null) + updates.letterSpacing = convertLetterSpacing( + ov.letterSpacing as NodeChange['letterSpacing'], + ov.fontSize as number | undefined + ) + if (ov.maxLines != null) updates.maxLines = ov.maxLines as number | null + if (ov.textTruncation != null) + updates.textTruncation = + (ov.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED' + if (ov.textDecoration != null) + updates.textDecoration = mapTextDecoration(ov.textDecoration as string) +} + +export function convertOverrideToProps(ov: Record): Partial { + const updates: Partial = {} + applyOverridePaints(ov, updates) + applyOverrideGeometry(ov, updates) + applyOverrideLayout(ov, updates) + applyOverrideStrokes(ov, updates) + applyOverrideText(ov, updates) + return updates +} diff --git a/packages/core/src/kiwi/kiwi-convert.ts b/packages/core/src/kiwi/kiwi-convert.ts index 9146e51f1..49d581ecb 100644 --- a/packages/core/src/kiwi/kiwi-convert.ts +++ b/packages/core/src/kiwi/kiwi-convert.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- kiwi↔scene conversion helpers are tightly coupled */ import { normalizeColor } from '../color' import { DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '../constants' import { styleToWeight } from '../fonts' @@ -126,7 +127,7 @@ export function convertFills(paints?: Paint[]): Fill[] { }) } -function convertStrokes( +export function convertStrokes( paints?: Paint[], weight?: number, align?: string, @@ -151,7 +152,7 @@ function convertStrokes( })) } -function convertEffects(effects?: KiwiEffect[]): Effect[] { +export function convertEffects(effects?: KiwiEffect[]): Effect[] { if (!effects) return [] return effects.map((e) => ({ type: e.type, @@ -204,7 +205,7 @@ function mapStackMode(mode?: string): LayoutMode { } } -function mapStackSizing(sizing?: string): LayoutSizing { +export function mapStackSizing(sizing?: string): LayoutSizing { switch (sizing) { case 'RESIZE_TO_FIT': case 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE': @@ -216,7 +217,7 @@ function mapStackSizing(sizing?: string): LayoutSizing { } } -function mapStackJustify(justify?: string): LayoutAlign { +export function mapStackJustify(justify?: string): LayoutAlign { switch (justify) { case 'CENTER': return 'CENTER' @@ -229,7 +230,7 @@ function mapStackJustify(justify?: string): LayoutAlign { } } -function mapStackCounterAlign(align?: string): LayoutCounterAlign { +export function mapStackCounterAlign(align?: string): LayoutCounterAlign { switch (align) { case 'CENTER': return 'CENTER' @@ -244,7 +245,7 @@ function mapStackCounterAlign(align?: string): LayoutCounterAlign { } } -function mapAlignSelf(align?: string): LayoutAlignSelf { +export function mapAlignSelf(align?: string): LayoutAlignSelf { switch (align) { case 'MIN': return 'MIN' @@ -276,7 +277,7 @@ function mapConstraint(c?: string): ConstraintType { } } -function mapTextDecoration(d?: string): TextDecoration { +export function mapTextDecoration(d?: string): TextDecoration { switch (d) { case 'UNDERLINE': return 'UNDERLINE' @@ -287,7 +288,7 @@ function mapTextDecoration(d?: string): TextDecoration { } } -function convertLineHeight( +export function convertLineHeight( lh?: { value: number; units: string }, fontSize?: number ): number | null { @@ -298,7 +299,7 @@ function convertLineHeight( return null } -function convertLetterSpacing( +export function convertLetterSpacing( ls?: { value: number; units: string }, fontSize?: number ): number { @@ -308,7 +309,7 @@ function convertLetterSpacing( return ls.value } -function mapArcData(data?: Partial): ArcData | null { +export function mapArcData(data?: Partial): ArcData | null { if (!data) return null return { startingAngle: data.startingAngle ?? 0, @@ -377,7 +378,7 @@ function collectStyleRuns( return runs } -function importStyleRuns(nc: NodeChange): StyleRun[] { +export function importStyleRuns(nc: NodeChange): StyleRun[] { const td = nc.textData if (!td?.characterStyleIDs || !td.styleOverrideTable) return [] @@ -641,131 +642,3 @@ function extractSymbolId(nc: NodeChange): string { return guidToString(sd.symbolID) } -function applyOverridePaints(ov: Record, updates: Partial): void { - if (ov.textData != null) { - const td = ov.textData as { characters?: string } - if (td.characters != null) updates.text = td.characters - const runs = importStyleRuns(ov as unknown as NodeChange) - if (runs.length > 0) updates.styleRuns = runs - } - if (ov.fillPaints != null) updates.fills = convertFills(ov.fillPaints as Paint[]) - if (ov.strokePaints != null) - updates.strokes = convertStrokes( - ov.strokePaints as Paint[], - ov.strokeWeight as number | undefined, - ov.strokeAlign as string | undefined - ) - if (ov.effects != null) updates.effects = convertEffects(ov.effects as KiwiEffect[]) - if (ov.visible != null) updates.visible = ov.visible as boolean - if (ov.opacity != null) updates.opacity = ov.opacity as number - if (ov.name != null) updates.name = ov.name as string - if (ov.locked != null) updates.locked = ov.locked as boolean -} - -function applyOverrideGeometry(ov: Record, updates: Partial): void { - if (ov.size != null) { - const sz = ov.size as { x?: number; y?: number } - if (sz.x != null) updates.width = sz.x - if (sz.y != null) updates.height = sz.y - } - if (ov.cornerRadius != null) updates.cornerRadius = ov.cornerRadius as number - if (ov.rectangleTopLeftCornerRadius != null) - updates.topLeftRadius = ov.rectangleTopLeftCornerRadius as number - if (ov.rectangleTopRightCornerRadius != null) - updates.topRightRadius = ov.rectangleTopRightCornerRadius as number - if (ov.rectangleBottomRightCornerRadius != null) - updates.bottomRightRadius = ov.rectangleBottomRightCornerRadius as number - if (ov.rectangleBottomLeftCornerRadius != null) - updates.bottomLeftRadius = ov.rectangleBottomLeftCornerRadius as number - if (ov.rectangleCornerRadiiIndependent != null) - updates.independentCorners = ov.rectangleCornerRadiiIndependent as boolean - if (ov.arcData != null) - updates.arcData = mapArcData(ov.arcData as Partial | undefined) - if (ov.frameMaskDisabled != null) updates.clipsContent = ov.frameMaskDisabled === false -} - -function applyOverrideLayout(ov: Record, updates: Partial): void { - if (ov.stackSpacing != null) updates.itemSpacing = ov.stackSpacing as number - if (ov.stackPrimarySizing != null) - updates.primaryAxisSizing = mapStackSizing(ov.stackPrimarySizing as string) - if (ov.stackCounterSizing != null) - updates.counterAxisSizing = mapStackSizing(ov.stackCounterSizing as string) - if (ov.stackPrimaryAlignItems != null) - updates.primaryAxisAlign = mapStackJustify(ov.stackPrimaryAlignItems as string) - if (ov.stackCounterAlignItems != null) - updates.counterAxisAlign = mapStackCounterAlign(ov.stackCounterAlignItems as string) - if (ov.stackChildPrimaryGrow != null) updates.layoutGrow = ov.stackChildPrimaryGrow as number - if (ov.stackChildAlignSelf != null) - updates.layoutAlignSelf = mapAlignSelf(ov.stackChildAlignSelf as string) - if (ov.stackPositioning != null) - updates.layoutPositioning = - (ov.stackPositioning as string) === 'ABSOLUTE' ? 'ABSOLUTE' : 'AUTO' - if (ov.stackVerticalPadding != null) { - updates.paddingTop = ov.stackVerticalPadding as number - if (ov.stackPaddingBottom == null) updates.paddingBottom = ov.stackVerticalPadding as number - } - if (ov.stackHorizontalPadding != null) { - updates.paddingLeft = ov.stackHorizontalPadding as number - if (ov.stackPaddingRight == null) updates.paddingRight = ov.stackHorizontalPadding as number - } - if (ov.stackPaddingBottom != null) updates.paddingBottom = ov.stackPaddingBottom as number - if (ov.stackPaddingRight != null) updates.paddingRight = ov.stackPaddingRight as number -} - -function applyOverrideStrokes(ov: Record, updates: Partial): void { - if (ov.strokeWeight != null && !ov.strokePaints) { - updates.strokes = updates.strokes ?? [] - } - if (ov.strokeAlign != null && updates.strokes) { - 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 - if (ov.borderRightWeight != null) updates.borderRightWeight = ov.borderRightWeight as number - if (ov.borderBottomWeight != null) updates.borderBottomWeight = ov.borderBottomWeight as number - if (ov.borderLeftWeight != null) updates.borderLeftWeight = ov.borderLeftWeight as number - if (ov.borderStrokeWeightsIndependent != null) - updates.independentStrokeWeights = ov.borderStrokeWeightsIndependent as boolean -} - -function applyOverrideText(ov: Record, updates: Partial): void { - if (ov.fontName != null) { - const fn = ov.fontName as { family?: string; style?: string } - if (fn.family) updates.fontFamily = fn.family - if (fn.style) { - updates.fontWeight = styleToWeight(fn.style) - updates.italic = fn.style.toLowerCase().includes('italic') - } - } - if (ov.fontSize != null) updates.fontSize = ov.fontSize as number - if (ov.textAlignHorizontal != null) - updates.textAlignHorizontal = ov.textAlignHorizontal as SceneNode['textAlignHorizontal'] - if (ov.textAutoResize != null) updates.textAutoResize = ov.textAutoResize as TextAutoResize - if (ov.lineHeight != null) - updates.lineHeight = convertLineHeight( - ov.lineHeight as NodeChange['lineHeight'], - ov.fontSize as number | undefined - ) - if (ov.letterSpacing != null) - updates.letterSpacing = convertLetterSpacing( - ov.letterSpacing as NodeChange['letterSpacing'], - ov.fontSize as number | undefined - ) - if (ov.maxLines != null) updates.maxLines = ov.maxLines as number | null - if (ov.textTruncation != null) - updates.textTruncation = - (ov.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED' - if (ov.textDecoration != null) - updates.textDecoration = mapTextDecoration(ov.textDecoration as string) -} - -export function convertOverrideToProps(ov: Record): Partial { - const updates: Partial = {} - applyOverridePaints(ov, updates) - applyOverrideGeometry(ov, updates) - applyOverrideLayout(ov, updates) - applyOverrideStrokes(ov, updates) - applyOverrideText(ov, updates) - return updates -} diff --git a/packages/core/src/kiwi/schema.ts b/packages/core/src/kiwi/schema.ts index b0330e28b..682f563e3 100644 --- a/packages/core/src/kiwi/schema.ts +++ b/packages/core/src/kiwi/schema.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- generated kiwi schema definition */ import { parseSchema } from './kiwi-schema' const schemaText = ` diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index c5e5f1a82..a91487228 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- layout tree building is a single recursive pass */ import Yoga, { Align, Direction, diff --git a/packages/core/src/render/export-jsx.ts b/packages/core/src/render/export-jsx.ts index 5322b8792..7a6fbbf5d 100644 --- a/packages/core/src/render/export-jsx.ts +++ b/packages/core/src/render/export-jsx.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- JSX export formats share helpers and node walking logic */ import { colorToHex8, colorToCSSCompact } from '../color' import { DEFAULT_FONT_FAMILY } from '../constants' import { diff --git a/packages/core/src/renderer/overlays.ts b/packages/core/src/renderer/overlays.ts index 8c9ca3328..d8b169f00 100644 --- a/packages/core/src/renderer/overlays.ts +++ b/packages/core/src/renderer/overlays.ts @@ -11,9 +11,7 @@ import { MARQUEE_FILL_ALPHA, SELECTION_DASH_ALPHA, LAYOUT_INDICATOR_STROKE, - PEN_HANDLE_RADIUS, - PEN_VERTEX_RADIUS, - PEN_CLOSE_RADIUS_BOOST, + TEXT_SELECTION_COLOR, TEXT_CARET_COLOR, TEXT_CARET_WIDTH, @@ -30,7 +28,7 @@ import type { SceneNode, SceneGraph } from '../scene-graph' import type { SnapGuide } from '../snap' import type { TextEditor } from '../text-editor' import type { Rect, Vector } from '../types' -import type { Canvas, Paint } from 'canvaskit-wasm' +import type { Canvas } from 'canvaskit-wasm' import type { SkiaRenderer, RenderOverlays } from './renderer' export function drawHoverHighlight( @@ -568,221 +566,4 @@ export function drawTextEditOverlay( } } -type ToScreenFn = (x: number, y: number) => Vector - -function buildPenPath( - r: SkiaRenderer, - canvas: Canvas, - penState: NonNullable, - toScreen: ToScreenFn -): void { - const { vertices, segments, dragTangent, cursorX, cursorY } = penState - - const path = new r.ck.Path() - for (const seg of segments) { - const s = toScreen(vertices[seg.start].x, vertices[seg.start].y) - const e = toScreen(vertices[seg.end].x, vertices[seg.end].y) - path.moveTo(s.x, s.y) - - const isLine = - seg.tangentStart.x === 0 && - seg.tangentStart.y === 0 && - seg.tangentEnd.x === 0 && - seg.tangentEnd.y === 0 - if (isLine) { - path.lineTo(e.x, e.y) - } else { - const cp1 = toScreen( - vertices[seg.start].x + seg.tangentStart.x, - vertices[seg.start].y + seg.tangentStart.y - ) - const cp2 = toScreen( - vertices[seg.end].x + seg.tangentEnd.x, - vertices[seg.end].y + seg.tangentEnd.y - ) - path.cubicTo(cp1.x, cp1.y, cp2.x, cp2.y, e.x, e.y) - } - } - - if (vertices.length > 0 && cursorX != null && cursorY != null) { - const last = toScreen(vertices[vertices.length - 1].x, vertices[vertices.length - 1].y) - const cursor = toScreen(cursorX, cursorY) - path.moveTo(last.x, last.y) - if (dragTangent) { - const cp1 = toScreen( - vertices[vertices.length - 1].x + dragTangent.x, - vertices[vertices.length - 1].y + dragTangent.y - ) - path.cubicTo(cp1.x, cp1.y, cursor.x, cursor.y, cursor.x, cursor.y) - } else { - path.lineTo(cursor.x, cursor.y) - } - } - - canvas.drawPath(path, r.penPathPaint) - path.delete() -} - -function drawPenHandlePoint( - canvas: Canvas, - x: number, - y: number, - vertexFill: Paint, - handlePaint: Paint -): void { - canvas.drawCircle(x, y, PEN_HANDLE_RADIUS, vertexFill) - canvas.drawCircle(x, y, PEN_HANDLE_RADIUS, handlePaint) -} - -function drawPenTangentHandles( - canvas: Canvas, - penState: NonNullable, - toScreen: ToScreenFn, - handlePaint: Paint, - vertexFill: Paint -): void { - const { vertices, segments, dragTangent } = penState - - for (const seg of segments) { - const ts = seg.tangentStart - const te = seg.tangentEnd - if (ts.x !== 0 || ts.y !== 0) { - const s = toScreen(vertices[seg.start].x, vertices[seg.start].y) - const cp = toScreen(vertices[seg.start].x + ts.x, vertices[seg.start].y + ts.y) - canvas.drawLine(s.x, s.y, cp.x, cp.y, handlePaint) - drawPenHandlePoint(canvas, cp.x, cp.y, vertexFill, handlePaint) - } - if (te.x !== 0 || te.y !== 0) { - const e = toScreen(vertices[seg.end].x, vertices[seg.end].y) - const cp = toScreen(vertices[seg.end].x + te.x, vertices[seg.end].y + te.y) - canvas.drawLine(e.x, e.y, cp.x, cp.y, handlePaint) - drawPenHandlePoint(canvas, cp.x, cp.y, vertexFill, handlePaint) - } - } - - if (dragTangent && vertices.length > 0) { - const last = vertices[vertices.length - 1] - const cp1 = toScreen(last.x + dragTangent.x, last.y + dragTangent.y) - const cp2 = toScreen(last.x - dragTangent.x, last.y - dragTangent.y) - canvas.drawLine(cp2.x, cp2.y, cp1.x, cp1.y, handlePaint) - drawPenHandlePoint(canvas, cp1.x, cp1.y, vertexFill, handlePaint) - drawPenHandlePoint(canvas, cp2.x, cp2.y, vertexFill, handlePaint) - } -} - -export function drawPenOverlay( - r: SkiaRenderer, - canvas: Canvas, - penState: RenderOverlays['penState'] -): void { - if (!penState || penState.vertices.length === 0) return - - const { vertices } = penState - const vertexFill = r.penVertexFill - const vertexStroke = r.penVertexStroke - - const toScreen: ToScreenFn = (x, y) => ({ - x: x * r.zoom + r.panX, - y: y * r.zoom + r.panY - }) - - buildPenPath(r, canvas, penState, toScreen) - drawPenTangentHandles(canvas, penState, toScreen, r.penHandlePaint, vertexFill) - - for (let i = 0; i < vertices.length; i++) { - const v = toScreen(vertices[i].x, vertices[i].y) - const radius = - i === 0 && penState.closingToFirst - ? PEN_VERTEX_RADIUS + PEN_CLOSE_RADIUS_BOOST - : PEN_VERTEX_RADIUS - canvas.drawCircle(v.x, v.y, radius, vertexFill) - canvas.drawCircle(v.x, v.y, radius, vertexStroke) - } -} - -export function drawRemoteCursors( - r: SkiaRenderer, - canvas: Canvas, - graph: SceneGraph, - cursors?: RenderOverlays['remoteCursors'] -): void { - if (!cursors || cursors.length === 0) return - - const CURSOR_SIZE = 9 - const LABEL_PADDING_X = 4 - const LABEL_PADDING_Y = 2 - const LABEL_FONT_SIZE = 10 - const LABEL_OFFSET_X = 12 - const LABEL_OFFSET_Y = 20 - - for (const cursor of cursors) { - const screenX = cursor.x * r.zoom + r.panX - const screenY = cursor.y * r.zoom + r.panY - const { r: cr, g, b } = cursor.color - - if (cursor.selection?.length) { - r.auxStroke.setColor(r.ck.Color4f(cr, g, b, 0.6)) - r.auxStroke.setStrokeWidth(1.5) - r.auxStroke.setPathEffect(null) - for (const nodeId of cursor.selection) { - const node = graph.getNode(nodeId) - if (!node) continue - const abs = graph.getAbsolutePosition(nodeId) - const sx = abs.x * r.zoom + r.panX - const sy = abs.y * r.zoom + r.panY - const sw = node.width * r.zoom - const sh = node.height * r.zoom - canvas.drawRect(r.ck.XYWHRect(sx, sy, sw, sh), r.auxStroke) - } - } - - const S = CURSOR_SIZE - const path = new r.ck.Path() - path.moveTo(screenX, screenY) - path.lineTo(screenX, screenY + S * 1.35) - path.lineTo(screenX + S * 0.38, screenY + S * 1.0) - path.lineTo(screenX + S * 0.72, screenY + S * 1.5) - path.lineTo(screenX + S * 0.92, screenY + S * 1.38) - path.lineTo(screenX + S * 0.58, screenY + S * 0.88) - path.lineTo(screenX + S * 1.0, screenY + S * 0.82) - path.close() - - r.auxStroke.setColor(r.ck.Color4f(1, 1, 1, 1)) - r.auxStroke.setStrokeWidth(2) - r.auxStroke.setPathEffect(null) - canvas.drawPath(path, r.auxStroke) - - r.auxFill.setColor(r.ck.Color4f(cr, g, b, 1)) - canvas.drawPath(path, r.auxFill) - path.delete() - - if (cursor.name) { - const font = r.labelFont - if (font) { - font.setSize(LABEL_FONT_SIZE) - const labelX = screenX + LABEL_OFFSET_X - const labelY = screenY + LABEL_OFFSET_Y - const glyphIds = font.getGlyphIDs(cursor.name) - const widths = font.getGlyphWidths(glyphIds) - let textWidth = 0 - for (const w of widths) textWidth += w - - r.auxFill.setColor(r.ck.Color4f(cr, g, b, 1)) - const bgRect = r.ck.RRectXY( - r.ck.XYWHRect( - labelX - LABEL_PADDING_X, - labelY - LABEL_FONT_SIZE - LABEL_PADDING_Y + 2, - textWidth + LABEL_PADDING_X * 2, - LABEL_FONT_SIZE + LABEL_PADDING_Y * 2 - ), - 4, - 4 - ) - canvas.drawRRect(bgRect, r.auxFill) - - r.auxFill.setColor(r.ck.Color4f(1, 1, 1, 1)) - canvas.drawText(cursor.name, labelX, labelY, r.auxFill, font) - } - } - } -} +export { drawPenOverlay, drawRemoteCursors } from './pen-overlay' diff --git a/packages/core/src/renderer/pen-overlay.ts b/packages/core/src/renderer/pen-overlay.ts new file mode 100644 index 000000000..88784afcc --- /dev/null +++ b/packages/core/src/renderer/pen-overlay.ts @@ -0,0 +1,228 @@ +import { + PEN_HANDLE_RADIUS, + PEN_VERTEX_RADIUS, + PEN_CLOSE_RADIUS_BOOST, +} from '../constants' +import type { SceneGraph } from '../scene-graph' +import type { Vector } from '../types' +import type { Canvas, Paint } from 'canvaskit-wasm' +import type { SkiaRenderer, RenderOverlays } from './renderer' + +type ToScreenFn = (x: number, y: number) => Vector + +function buildPenPath( + r: SkiaRenderer, + canvas: Canvas, + penState: NonNullable, + toScreen: ToScreenFn +): void { + const { vertices, segments, dragTangent, cursorX, cursorY } = penState + + const path = new r.ck.Path() + for (const seg of segments) { + const s = toScreen(vertices[seg.start].x, vertices[seg.start].y) + const e = toScreen(vertices[seg.end].x, vertices[seg.end].y) + path.moveTo(s.x, s.y) + + const isLine = + seg.tangentStart.x === 0 && + seg.tangentStart.y === 0 && + seg.tangentEnd.x === 0 && + seg.tangentEnd.y === 0 + if (isLine) { + path.lineTo(e.x, e.y) + } else { + const cp1 = toScreen( + vertices[seg.start].x + seg.tangentStart.x, + vertices[seg.start].y + seg.tangentStart.y + ) + const cp2 = toScreen( + vertices[seg.end].x + seg.tangentEnd.x, + vertices[seg.end].y + seg.tangentEnd.y + ) + path.cubicTo(cp1.x, cp1.y, cp2.x, cp2.y, e.x, e.y) + } + } + + if (vertices.length > 0 && cursorX != null && cursorY != null) { + const last = toScreen(vertices[vertices.length - 1].x, vertices[vertices.length - 1].y) + const cursor = toScreen(cursorX, cursorY) + path.moveTo(last.x, last.y) + if (dragTangent) { + const cp1 = toScreen( + vertices[vertices.length - 1].x + dragTangent.x, + vertices[vertices.length - 1].y + dragTangent.y + ) + path.cubicTo(cp1.x, cp1.y, cursor.x, cursor.y, cursor.x, cursor.y) + } else { + path.lineTo(cursor.x, cursor.y) + } + } + + canvas.drawPath(path, r.penPathPaint) + path.delete() +} + +function drawPenHandlePoint( + canvas: Canvas, + x: number, + y: number, + vertexFill: Paint, + handlePaint: Paint +): void { + canvas.drawCircle(x, y, PEN_HANDLE_RADIUS, vertexFill) + canvas.drawCircle(x, y, PEN_HANDLE_RADIUS, handlePaint) +} + +function drawPenTangentHandles( + canvas: Canvas, + penState: NonNullable, + toScreen: ToScreenFn, + handlePaint: Paint, + vertexFill: Paint +): void { + const { vertices, segments, dragTangent } = penState + + for (const seg of segments) { + const ts = seg.tangentStart + const te = seg.tangentEnd + if (ts.x !== 0 || ts.y !== 0) { + const s = toScreen(vertices[seg.start].x, vertices[seg.start].y) + const cp = toScreen(vertices[seg.start].x + ts.x, vertices[seg.start].y + ts.y) + canvas.drawLine(s.x, s.y, cp.x, cp.y, handlePaint) + drawPenHandlePoint(canvas, cp.x, cp.y, vertexFill, handlePaint) + } + if (te.x !== 0 || te.y !== 0) { + const e = toScreen(vertices[seg.end].x, vertices[seg.end].y) + const cp = toScreen(vertices[seg.end].x + te.x, vertices[seg.end].y + te.y) + canvas.drawLine(e.x, e.y, cp.x, cp.y, handlePaint) + drawPenHandlePoint(canvas, cp.x, cp.y, vertexFill, handlePaint) + } + } + + if (dragTangent && vertices.length > 0) { + const last = vertices[vertices.length - 1] + const cp1 = toScreen(last.x + dragTangent.x, last.y + dragTangent.y) + const cp2 = toScreen(last.x - dragTangent.x, last.y - dragTangent.y) + canvas.drawLine(cp2.x, cp2.y, cp1.x, cp1.y, handlePaint) + drawPenHandlePoint(canvas, cp1.x, cp1.y, vertexFill, handlePaint) + drawPenHandlePoint(canvas, cp2.x, cp2.y, vertexFill, handlePaint) + } +} + +export function drawPenOverlay( + r: SkiaRenderer, + canvas: Canvas, + penState: RenderOverlays['penState'] +): void { + if (!penState || penState.vertices.length === 0) return + + const { vertices } = penState + const vertexFill = r.penVertexFill + const vertexStroke = r.penVertexStroke + + const toScreen: ToScreenFn = (x, y) => ({ + x: x * r.zoom + r.panX, + y: y * r.zoom + r.panY + }) + + buildPenPath(r, canvas, penState, toScreen) + drawPenTangentHandles(canvas, penState, toScreen, r.penHandlePaint, vertexFill) + + for (let i = 0; i < vertices.length; i++) { + const v = toScreen(vertices[i].x, vertices[i].y) + const radius = + i === 0 && penState.closingToFirst + ? PEN_VERTEX_RADIUS + PEN_CLOSE_RADIUS_BOOST + : PEN_VERTEX_RADIUS + canvas.drawCircle(v.x, v.y, radius, vertexFill) + canvas.drawCircle(v.x, v.y, radius, vertexStroke) + } +} + +export function drawRemoteCursors( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + cursors?: RenderOverlays['remoteCursors'] +): void { + if (!cursors || cursors.length === 0) return + + const CURSOR_SIZE = 9 + const LABEL_PADDING_X = 4 + const LABEL_PADDING_Y = 2 + const LABEL_FONT_SIZE = 10 + const LABEL_OFFSET_X = 12 + const LABEL_OFFSET_Y = 20 + + for (const cursor of cursors) { + const screenX = cursor.x * r.zoom + r.panX + const screenY = cursor.y * r.zoom + r.panY + const { r: cr, g, b } = cursor.color + + if (cursor.selection?.length) { + r.auxStroke.setColor(r.ck.Color4f(cr, g, b, 0.6)) + r.auxStroke.setStrokeWidth(1.5) + r.auxStroke.setPathEffect(null) + for (const nodeId of cursor.selection) { + const node = graph.getNode(nodeId) + if (!node) continue + const abs = graph.getAbsolutePosition(nodeId) + const sx = abs.x * r.zoom + r.panX + const sy = abs.y * r.zoom + r.panY + const sw = node.width * r.zoom + const sh = node.height * r.zoom + canvas.drawRect(r.ck.XYWHRect(sx, sy, sw, sh), r.auxStroke) + } + } + + const S = CURSOR_SIZE + const path = new r.ck.Path() + path.moveTo(screenX, screenY) + path.lineTo(screenX, screenY + S * 1.35) + path.lineTo(screenX + S * 0.38, screenY + S * 1.0) + path.lineTo(screenX + S * 0.72, screenY + S * 1.5) + path.lineTo(screenX + S * 0.92, screenY + S * 1.38) + path.lineTo(screenX + S * 0.58, screenY + S * 0.88) + path.lineTo(screenX + S * 1.0, screenY + S * 0.82) + path.close() + + r.auxStroke.setColor(r.ck.Color4f(1, 1, 1, 1)) + r.auxStroke.setStrokeWidth(2) + r.auxStroke.setPathEffect(null) + canvas.drawPath(path, r.auxStroke) + + r.auxFill.setColor(r.ck.Color4f(cr, g, b, 1)) + canvas.drawPath(path, r.auxFill) + path.delete() + + if (cursor.name) { + const font = r.labelFont + if (font) { + font.setSize(LABEL_FONT_SIZE) + const labelX = screenX + LABEL_OFFSET_X + const labelY = screenY + LABEL_OFFSET_Y + const glyphIds = font.getGlyphIDs(cursor.name) + const widths = font.getGlyphWidths(glyphIds) + let textWidth = 0 + for (const w of widths) textWidth += w + + r.auxFill.setColor(r.ck.Color4f(cr, g, b, 1)) + const bgRect = r.ck.RRectXY( + r.ck.XYWHRect( + labelX - LABEL_PADDING_X, + labelY - LABEL_FONT_SIZE - LABEL_PADDING_Y + 2, + textWidth + LABEL_PADDING_X * 2, + LABEL_FONT_SIZE + LABEL_PADDING_Y * 2 + ), + 4, + 4 + ) + canvas.drawRRect(bgRect, r.auxFill) + + r.auxFill.setColor(r.ck.Color4f(1, 1, 1, 1)) + canvas.drawText(cursor.name, labelX, labelY, r.auxFill, font) + } + } + } +} diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index 48be89be7..7b5395d57 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- SkiaRenderer class; text, pen-overlay, fills, scene already extracted */ import { SELECTION_COLOR, COMPONENT_COLOR, @@ -26,7 +27,7 @@ import { RULER_TEXT_COLOR, DEFAULT_FONT_FAMILY } from '../constants' -import { isFontLoaded, getCJKFallbackFamily } from '../fonts' + import { RenderProfiler } from '../profiler' import type { SceneNode, SceneGraph, Fill, Stroke } from '../scene-graph' @@ -42,7 +43,6 @@ import type { Paint, Font, FontMgr, - FontWeight, TypefaceFontProvider, SkPicture, ImageFilter, @@ -65,9 +65,11 @@ import { drawFlashes as drawFlashesFn, drawLayoutInsertIndicator as drawLayoutInsertIndicatorFn, drawTextEditOverlay as drawTextEditOverlayFn, +} from './overlays' +import { drawPenOverlay as drawPenOverlayFn, drawRemoteCursors as drawRemoteCursorsFn -} from './overlays' +} from './pen-overlay' import { drawRulers as drawRulersFn } from './rulers' import { drawSectionTitles as drawSectionTitlesFn, @@ -115,6 +117,12 @@ import { getCachedMaskBlur as getCachedMaskBlurFn, applyClippedBlur as applyClippedBlurFn } from './effects' +import { + measureTextNode as measureTextNodeFn, + isNodeFontLoaded as isNodeFontLoadedFn, + buildTextPicture as buildTextPictureFn, + buildParagraph as buildParagraphFn +} from './text' export interface RenderOverlays { hoveredNodeId?: string | null @@ -754,184 +762,23 @@ export class SkiaRenderer { } measureTextNode(node: SceneNode, maxWidth?: number): { width: number; height: number } | null { - if (!this.fontsLoaded || !this.fontProvider || !this.isNodeFontLoaded(node)) return null - if (node.type !== 'TEXT' || !node.text) return null - - const paragraph = this.buildParagraph(node) - const layoutWidth = - maxWidth !== undefined - ? maxWidth - : (node.textAutoResize === 'WIDTH_AND_HEIGHT' - ? 1e6 - : node.width || 1e6) - paragraph.layout(layoutWidth) - const width = paragraph.getLongestLine() - const height = paragraph.getHeight() - paragraph.delete() - return { width: Math.ceil(width), height: Math.ceil(height) } + return measureTextNodeFn(this, node, maxWidth) } isNodeFontLoaded(node: SceneNode): boolean { - const families = new Set() - families.add(node.fontFamily || DEFAULT_FONT_FAMILY) - for (const run of node.styleRuns) { - if (run.style.fontFamily) families.add(run.style.fontFamily) - } - return [...families].every((f) => isFontLoaded(f)) + return isNodeFontLoadedFn(this, node) } buildTextPicture(node: SceneNode): Uint8Array | null { - if (!this.fontsLoaded || !this.fontProvider || !this.isNodeFontLoaded(node)) return null - if (node.type !== 'TEXT' || !node.text) return null - - const ck = this.ck - const recorder = new ck.PictureRecorder() - const bounds = ck.LTRBRect(0, 0, node.width || 1e6, node.height || 1e6) - const recCanvas = recorder.beginRecording(bounds) - - const paragraph = this.buildParagraph(node, undefined, { halfLeading: true }) - recCanvas.drawParagraph(paragraph, 0, 0) - paragraph.delete() - - const picture = recorder.finishRecordingAsPicture() - recorder.delete() - - const bytes = picture.serialize() - picture.delete() - return bytes ?? null + return buildTextPictureFn(this, node) } buildParagraph( node: SceneNode, color?: Float32Array, - { halfLeading = false }: { halfLeading?: boolean } = {} + opts?: { halfLeading?: boolean } ): Paragraph { - const ck = this.ck - const baseColor = color ?? ck.BLACK - const baseFontSize = node.fontSize || DEFAULT_FONT_SIZE - const cjkFallback = getCJKFallbackFamily() - - const truncateOpts = this.buildTruncateOpts(node, baseFontSize) - - const fontFamilies = (primary: string) => - cjkFallback ? [primary, cjkFallback] : [primary] - - const paraStyle = new ck.ParagraphStyle({ - textAlign: this.getTextAlign(node.textAlignHorizontal), - ...truncateOpts, - textStyle: { - color: baseColor, - fontFamilies: fontFamilies(node.fontFamily || DEFAULT_FONT_FAMILY), - fontSize: baseFontSize, - fontStyle: { - weight: { value: node.fontWeight || 400 } as FontWeight, - slant: node.italic ? ck.FontSlant.Italic : ck.FontSlant.Upright - }, - letterSpacing: node.letterSpacing || 0, - decoration: this.textDecorationValue(node.textDecoration), - heightMultiplier: node.lineHeight ? node.lineHeight / baseFontSize : undefined, - halfLeading - } - }) - - if (!this.fontProvider) throw new Error('Font provider not initialized') - const builder = ck.ParagraphBuilder.MakeFromFontProvider(paraStyle, this.fontProvider) - - if (node.styleRuns.length === 0) { - builder.addText(node.text) - } else { - this.addStyledRuns(builder, node, baseColor, baseFontSize, fontFamilies, halfLeading) - } - - const paragraph = builder.build() - paragraph.layout(node.width || 1e6) - builder.delete() - return paragraph - } - - private buildTruncateOpts( - node: SceneNode, - baseFontSize: number - ): { maxLines?: number; ellipsis?: string } { - if (node.textTruncation !== 'ENDING') return {} - - const opts: { maxLines?: number; ellipsis: string } = { ellipsis: '…' } - if (node.maxLines != null && node.maxLines > 0) { - opts.maxLines = node.maxLines - } else if (node.height > 0) { - const lineH = node.lineHeight || baseFontSize * 1.2 - opts.maxLines = Math.max(1, Math.floor(node.height / lineH)) - } - return opts - } - - private addStyledRuns( - builder: ReturnType, - node: SceneNode, - baseColor: Float32Array, - baseFontSize: number, - fontFamilies: (primary: string) => string[], - halfLeading: boolean - ): void { - const ck = this.ck - const text = node.text - let pos = 0 - - for (const run of node.styleRuns) { - if (pos < run.start) { - builder.addText(text.slice(pos, run.start)) - } - const s = run.style - const runLineHeight = s.lineHeight !== undefined ? s.lineHeight : node.lineHeight - const runFontSize = s.fontSize ?? baseFontSize - - builder.pushStyle( - new ck.TextStyle({ - color: baseColor, - fontFamilies: fontFamilies(s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY)), - fontSize: runFontSize, - fontStyle: { - weight: { value: (s.fontWeight ?? node.fontWeight) || 400 } as FontWeight, - slant: (s.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright - }, - letterSpacing: s.letterSpacing ?? (node.letterSpacing || 0), - decoration: this.textDecorationValue(s.textDecoration ?? node.textDecoration), - heightMultiplier: runLineHeight ? runLineHeight / runFontSize : undefined, - halfLeading - }) - ) - builder.addText(text.slice(run.start, run.start + run.length)) - builder.pop() - pos = run.start + run.length - } - - if (pos < text.length) { - builder.addText(text.slice(pos)) - } - } - - private textDecorationValue(decoration: string): number { - switch (decoration) { - case 'UNDERLINE': - return this.ck.UnderlineDecoration - case 'STRIKETHROUGH': - return this.ck.LineThroughDecoration - default: - return this.ck.NoDecoration - } - } - - private getTextAlign(align: string) { - switch (align) { - case 'CENTER': - return this.ck.TextAlign.Center - case 'RIGHT': - return this.ck.TextAlign.Right - case 'JUSTIFIED': - return this.ck.TextAlign.Justify - default: - return this.ck.TextAlign.Left - } + return buildParagraphFn(this, node, color, opts) } resolveFillColor(fill: Fill, fillIndex: number, node: SceneNode, graph: SceneGraph): Color { diff --git a/packages/core/src/renderer/text.ts b/packages/core/src/renderer/text.ts new file mode 100644 index 000000000..a602816fd --- /dev/null +++ b/packages/core/src/renderer/text.ts @@ -0,0 +1,198 @@ +import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from '../constants' +import { isFontLoaded, getCJKFallbackFamily } from '../fonts' + +import type { SceneNode } from '../scene-graph' +import type { CanvasKit, FontWeight, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm' + +interface TextRenderer { + ck: CanvasKit + fontProvider: TypefaceFontProvider | null + fontsLoaded: boolean +} + +export function isNodeFontLoaded(_r: TextRenderer, node: SceneNode): boolean { + const families = new Set() + families.add(node.fontFamily || DEFAULT_FONT_FAMILY) + for (const run of node.styleRuns) { + if (run.style.fontFamily) families.add(run.style.fontFamily) + } + return [...families].every((f) => isFontLoaded(f)) +} + +export function measureTextNode( + r: TextRenderer, + node: SceneNode, + maxWidth?: number +): { width: number; height: number } | null { + if (!r.fontsLoaded || !r.fontProvider || !isNodeFontLoaded(r, node)) return null + if (node.type !== 'TEXT' || !node.text) return null + + const paragraph = buildParagraph(r, node) + const layoutWidth = + maxWidth !== undefined + ? maxWidth + : (node.textAutoResize === 'WIDTH_AND_HEIGHT' + ? 1e6 + : node.width || 1e6) + paragraph.layout(layoutWidth) + const width = paragraph.getLongestLine() + const height = paragraph.getHeight() + paragraph.delete() + return { width: Math.ceil(width), height: Math.ceil(height) } +} + +export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array | null { + if (!r.fontsLoaded || !r.fontProvider || !isNodeFontLoaded(r, node)) return null + if (node.type !== 'TEXT' || !node.text) return null + + const ck = r.ck + const recorder = new ck.PictureRecorder() + const bounds = ck.LTRBRect(0, 0, node.width || 1e6, node.height || 1e6) + const recCanvas = recorder.beginRecording(bounds) + + const paragraph = buildParagraph(r, node, undefined, { halfLeading: true }) + recCanvas.drawParagraph(paragraph, 0, 0) + paragraph.delete() + + const picture = recorder.finishRecordingAsPicture() + recorder.delete() + + const bytes = picture.serialize() + picture.delete() + return bytes ?? null +} + +function buildTruncateOpts( + node: SceneNode, + baseFontSize: number +): { maxLines?: number; ellipsis?: string } { + if (node.textTruncation !== 'ENDING') return {} + + const opts: { maxLines?: number; ellipsis: string } = { ellipsis: '…' } + if (node.maxLines != null && node.maxLines > 0) { + opts.maxLines = node.maxLines + } else if (node.height > 0) { + const lineH = node.lineHeight || baseFontSize * 1.2 + opts.maxLines = Math.max(1, Math.floor(node.height / lineH)) + } + return opts +} + +function getTextAlign(ck: CanvasKit, align: string) { + switch (align) { + case 'CENTER': + return ck.TextAlign.Center + case 'RIGHT': + return ck.TextAlign.Right + case 'JUSTIFIED': + return ck.TextAlign.Justify + default: + return ck.TextAlign.Left + } +} + +function textDecorationValue(ck: CanvasKit, decoration: string): number { + switch (decoration) { + case 'UNDERLINE': + return ck.UnderlineDecoration + case 'STRIKETHROUGH': + return ck.LineThroughDecoration + default: + return ck.NoDecoration + } +} + +function addStyledRuns( + r: TextRenderer, + builder: ReturnType, + node: SceneNode, + baseColor: Float32Array, + baseFontSize: number, + fontFamilies: (primary: string) => string[], + halfLeading: boolean +): void { + const ck = r.ck + const text = node.text + let pos = 0 + + for (const run of node.styleRuns) { + if (pos < run.start) { + builder.addText(text.slice(pos, run.start)) + } + const s = run.style + const runLineHeight = s.lineHeight !== undefined ? s.lineHeight : node.lineHeight + const runFontSize = s.fontSize ?? baseFontSize + + builder.pushStyle( + new ck.TextStyle({ + color: baseColor, + fontFamilies: fontFamilies(s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY)), + fontSize: runFontSize, + fontStyle: { + weight: { value: (s.fontWeight ?? node.fontWeight) || 400 } as FontWeight, + slant: (s.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright + }, + letterSpacing: s.letterSpacing ?? (node.letterSpacing || 0), + decoration: textDecorationValue(ck, s.textDecoration ?? node.textDecoration), + heightMultiplier: runLineHeight ? runLineHeight / runFontSize : undefined, + halfLeading + }) + ) + builder.addText(text.slice(run.start, run.start + run.length)) + builder.pop() + pos = run.start + run.length + } + + if (pos < text.length) { + builder.addText(text.slice(pos)) + } +} + +export function buildParagraph( + r: TextRenderer, + node: SceneNode, + color?: Float32Array, + { halfLeading = false }: { halfLeading?: boolean } = {} +): Paragraph { + const ck = r.ck + const baseColor = color ?? ck.BLACK + const baseFontSize = node.fontSize || DEFAULT_FONT_SIZE + const cjkFallback = getCJKFallbackFamily() + + const truncateOpts = buildTruncateOpts(node, baseFontSize) + + const fontFamilies = (primary: string) => + cjkFallback ? [primary, cjkFallback] : [primary] + + const paraStyle = new ck.ParagraphStyle({ + textAlign: getTextAlign(ck, node.textAlignHorizontal), + ...truncateOpts, + textStyle: { + color: baseColor, + fontFamilies: fontFamilies(node.fontFamily || DEFAULT_FONT_FAMILY), + fontSize: baseFontSize, + fontStyle: { + weight: { value: node.fontWeight || 400 } as FontWeight, + slant: node.italic ? ck.FontSlant.Italic : ck.FontSlant.Upright + }, + letterSpacing: node.letterSpacing || 0, + decoration: textDecorationValue(ck, node.textDecoration), + heightMultiplier: node.lineHeight ? node.lineHeight / baseFontSize : undefined, + halfLeading + } + }) + + if (!r.fontProvider) throw new Error('Font provider not initialized') + const builder = ck.ParagraphBuilder.MakeFromFontProvider(paraStyle, r.fontProvider) + + if (node.styleRuns.length === 0) { + builder.addText(node.text) + } else { + addStyledRuns(r, builder, node, baseColor, baseFontSize, fontFamilies, halfLeading) + } + + const paragraph = builder.build() + paragraph.layout(node.width || 1e6) + builder.delete() + return paragraph +} diff --git a/packages/core/src/rpc/commands.ts b/packages/core/src/rpc/commands.ts index 710044ccf..3287738ef 100644 --- a/packages/core/src/rpc/commands.ts +++ b/packages/core/src/rpc/commands.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- RPC commands are cohesive; splitting adds indirection */ import { colorToHex, colorDistance as colorDist } from '../color' import { queryByXPath } from '../xpath' import type { Color } from '../types' diff --git a/packages/core/src/scene-graph-hit-test.ts b/packages/core/src/scene-graph-hit-test.ts new file mode 100644 index 000000000..ab484732b --- /dev/null +++ b/packages/core/src/scene-graph-hit-test.ts @@ -0,0 +1,143 @@ +import type { SceneGraph, SceneNode, NodeType } from './scene-graph' + +const CONTAINER_TYPES = new Set([ + 'CANVAS', 'FRAME', 'GROUP', 'SECTION', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE' +]) +const OPAQUE_CONTAINER_TYPES = new Set(['COMPONENT', 'INSTANCE']) + +function hasVisibleFillOrStroke(node: SceneNode): boolean { + return node.fills.some((f) => f.visible) || node.strokes.some((s) => s.visible) +} + +function containsPoint(px: number, py: number, ax: number, ay: number, node: SceneNode): boolean { + return px >= ax && px <= ax + node.width && py >= ay && py <= ay + node.height +} + +function hitTestOpaqueContainer( + graph: SceneGraph, + px: number, + py: number, + child: SceneNode, + childId: string, + ax: number, + ay: number, + deep: boolean +): SceneNode | null { + if (!containsPoint(px, py, ax, ay, child)) return null + const childHit = hitTestChildren(graph, px, py, childId, ax, ay, deep) + if (childHit) return child + if (hasVisibleFillOrStroke(child)) return child + return null +} + +function hitTestTransparentContainer( + graph: SceneGraph, + px: number, + py: number, + child: SceneNode, + childId: string, + ax: number, + ay: number, + deep: boolean +): SceneNode | null { + const deepHit = hitTestChildren(graph, px, py, childId, ax, ay, deep) + if (deepHit) return deepHit + if (child.type === 'GROUP') return null + if (containsPoint(px, py, ax, ay, child) && hasVisibleFillOrStroke(child)) return child + return null +} + +function hitTestChildren( + graph: SceneGraph, + px: number, + py: number, + parentId: string, + offsetX: number, + offsetY: number, + deep = false +): SceneNode | null { + const parent = graph.nodes.get(parentId) + if (!parent) return null + + if (parent.clipsContent) { + if (!containsPoint(px, py, offsetX, offsetY, parent)) return null + } + + for (let i = parent.childIds.length - 1; i >= 0; i--) { + const childId = parent.childIds[i] + const child = graph.nodes.get(childId) + if (!child || !child.visible) continue + + const ax = offsetX + child.x + const ay = offsetY + child.y + + if (CONTAINER_TYPES.has(child.type)) { + if (OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) { + const hit = hitTestOpaqueContainer(graph, px, py, child, childId, ax, ay, deep) + if (hit) return hit + continue + } + + const hit = hitTestTransparentContainer(graph, px, py, child, childId, ax, ay, deep) + if (hit) return hit + continue + } + + if (containsPoint(px, py, ax, ay, child)) return child + } + return null +} + +export function hitTest(graph: SceneGraph, px: number, py: number, scopeId?: string): SceneNode | null { + const scope = scopeId ?? graph.rootId + return hitTestChildren(graph, px, py, scope, 0, 0, false) +} + +export function hitTestDeep(graph: SceneGraph, px: number, py: number, scopeId?: string): SceneNode | null { + const scope = scopeId ?? graph.rootId + return hitTestChildren(graph, px, py, scope, 0, 0, true) +} + +function hitTestFrameChildren( + graph: SceneGraph, + px: number, + py: number, + parentId: string, + offsetX: number, + offsetY: number, + excludeIds: Set +): SceneNode | null { + const parent = graph.nodes.get(parentId) + if (!parent) return null + + let best: SceneNode | null = null + + for (const childId of parent.childIds) { + if (excludeIds.has(childId)) continue + const child = graph.nodes.get(childId) + if (!child || !child.visible) continue + + const ax = offsetX + child.x + const ay = offsetY + child.y + + if (!CONTAINER_TYPES.has(child.type)) continue + if (px < ax || px > ax + child.width || py < ay || py > ay + child.height) continue + + best = child + + const deeper = hitTestFrameChildren(graph, px, py, childId, ax, ay, excludeIds) + if (deeper) best = deeper + } + + return best +} + +export function hitTestFrame( + graph: SceneGraph, + px: number, + py: number, + excludeIds: Set, + scopeId?: string +): SceneNode | null { + return hitTestFrameChildren(graph, px, py, scopeId ?? graph.rootId, 0, 0, excludeIds) +} diff --git a/packages/core/src/scene-graph-instances.ts b/packages/core/src/scene-graph-instances.ts new file mode 100644 index 000000000..33a959e75 --- /dev/null +++ b/packages/core/src/scene-graph-instances.ts @@ -0,0 +1,214 @@ +import { copyEffects, copyFills, copyStrokes, copyStyleRuns } from './copy' + +import type { SceneGraph, SceneNode, Fill, Stroke, Effect, StyleRun } from './scene-graph' + +const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [ + 'width', + 'height', + 'fills', + 'strokes', + 'effects', + 'opacity', + 'cornerRadius', + 'topLeftRadius', + 'topRightRadius', + 'bottomRightRadius', + 'bottomLeftRadius', + 'independentCorners', + 'layoutMode', + 'layoutWrap', + 'primaryAxisAlign', + 'counterAxisAlign', + 'primaryAxisSizing', + 'counterAxisSizing', + 'itemSpacing', + 'counterAxisSpacing', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'gridTemplateColumns', + 'gridTemplateRows', + 'gridColumnGap', + 'gridRowGap', + 'gridPosition', + 'clipsContent', + 'independentStrokeWeights', + 'borderTopWeight', + 'borderRightWeight', + 'borderBottomWeight', + 'borderLeftWeight' +] + +function copyProp( + target: Partial | SceneNode, + source: SceneNode, + key: keyof SceneNode +): void { + const val = source[key] + if (key === 'fills') { + ;(target as Record)[key] = copyFills(val as Fill[]) + } else if (key === 'strokes') { + ;(target as Record)[key] = copyStrokes(val as Stroke[]) + } else if (key === 'effects') { + ;(target as Record)[key] = copyEffects(val as Effect[]) + } else if (key === 'styleRuns') { + ;(target as Record)[key] = copyStyleRuns(val as StyleRun[]) + } else { + ;(target as Record)[key] = Array.isArray(val) ? structuredClone(val) : val + } +} + +function cloneChildrenWithMapping(graph: SceneGraph, sourceParentId: string, destParentId: string): void { + const sourceParent = graph.nodes.get(sourceParentId) + if (!sourceParent) return + + for (const childId of sourceParent.childIds) { + const src = graph.nodes.get(childId) + if (!src) continue + + const { id: _, parentId: _p, childIds: _c, ...rest } = src + const clone = graph.createNode(src.type, destParentId, { + ...rest, + componentId: childId + }) + + if (src.childIds.length > 0) { + cloneChildrenWithMapping(graph, childId, clone.id) + } + } +} + +function syncChildren( + graph: SceneGraph, + compParentId: string, + instParentId: string, + overrides: Record +): void { + const compParent = graph.nodes.get(compParentId) + const instParent = graph.nodes.get(instParentId) + if (!compParent || !instParent) return + + const instChildMap = new Map() + for (const childId of instParent.childIds) { + const child = graph.nodes.get(childId) + if (child?.componentId) instChildMap.set(child.componentId, child) + } + + for (const compChildId of compParent.childIds) { + if (!instChildMap.has(compChildId)) { + const src = graph.nodes.get(compChildId) + if (!src) continue + const { id: _, parentId: _p, childIds: _c, ...rest } = src + const clone = graph.createNode(src.type, instParentId, { + ...rest, + componentId: compChildId + }) + if (src.childIds.length > 0) { + cloneChildrenWithMapping(graph, compChildId, clone.id) + } + instChildMap.set(compChildId, clone) + } + } + + for (const compChildId of compParent.childIds) { + const compChild = graph.nodes.get(compChildId) + const instChild = instChildMap.get(compChildId) + if (!compChild || !instChild) continue + + for (const key of INSTANCE_SYNC_PROPS) { + const overrideKey = `${instChild.id}:${key}` + if (overrideKey in overrides) continue + copyProp(instChild, compChild, key) + } + + for (const key of ['name', 'text', 'fontSize', 'fontWeight', 'fontFamily'] as const) { + const overrideKey = `${instChild.id}:${key}` + if (overrideKey in overrides) continue + copyProp(instChild, compChild, key) + } + + if (compChild.childIds.length > 0) { + syncChildren(graph, compChildId, instChild.id, overrides) + } + } + + const compChildOrder = compParent.childIds + instParent.childIds.sort((a, b) => { + const nodeA = graph.nodes.get(a) + const nodeB = graph.nodes.get(b) + const idxA = nodeA?.componentId ? compChildOrder.indexOf(nodeA.componentId) : -1 + const idxB = nodeB?.componentId ? compChildOrder.indexOf(nodeB.componentId) : -1 + return idxA - idxB + }) +} + +export function createInstance( + graph: SceneGraph, + componentId: string, + parentId: string, + overrides: Partial = {} +): SceneNode | null { + const component = graph.nodes.get(componentId) + if (component?.type !== 'COMPONENT') return null + + const props: Partial = { name: component.name, componentId } + for (const key of INSTANCE_SYNC_PROPS) { + copyProp(props, component, key) + } + + const instance = graph.createNode('INSTANCE', parentId, { ...props, ...overrides }) + + cloneChildrenWithMapping(graph, component.id, instance.id) + + return instance +} + +export function populateInstanceChildren(graph: SceneGraph, instanceId: string, componentId: string): void { + const instance = graph.nodes.get(instanceId) + const component = graph.nodes.get(componentId) + if (!instance || !component || instance.type !== 'INSTANCE') return + cloneChildrenWithMapping(graph, componentId, instanceId) +} + +export function syncInstances(graph: SceneGraph, componentId: string): void { + const component = graph.nodes.get(componentId) + if (component?.type !== 'COMPONENT') return + + for (const instance of getInstances(graph, componentId)) { + for (const key of INSTANCE_SYNC_PROPS) { + if (key in instance.overrides) continue + copyProp(instance, component, key) + } + + syncChildren(graph, component.id, instance.id, instance.overrides) + } +} + +export function detachInstance(graph: SceneGraph, instanceId: string): void { + const node = graph.nodes.get(instanceId) + if (node?.type !== 'INSTANCE') return + if (node.componentId) { + graph.instanceIndex.get(node.componentId)?.delete(instanceId) + } + node.type = 'FRAME' + node.componentId = null + node.overrides = {} +} + +export function getMainComponent(graph: SceneGraph, instanceId: string): SceneNode | undefined { + const node = graph.nodes.get(instanceId) + if (!node?.componentId) return undefined + return graph.nodes.get(node.componentId) +} + +export function getInstances(graph: SceneGraph, componentId: string): SceneNode[] { + const ids = graph.instanceIndex.get(componentId) + if (!ids) return [] + const instances: SceneNode[] = [] + for (const id of ids) { + const node = graph.nodes.get(id) + if (node) instances.push(node) + } + return instances +} diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index 336a097ab..a396a897b 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -1,7 +1,20 @@ +/* eslint-disable max-lines -- core class; instance, hit-test methods already extracted */ import { createNanoEvents } from 'nanoevents' import { BLACK, DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from './constants' -import { copyEffects, copyFills, copyStrokes, copyStyleRuns } from './copy' +import { + createInstance as createInstanceFn, + populateInstanceChildren as populateInstanceChildrenFn, + syncInstances as syncInstancesFn, + detachInstance as detachInstanceFn, + getMainComponent as getMainComponentFn, + getInstances as getInstancesFn +} from './scene-graph-instances' +import { + hitTest as hitTestFn, + hitTestDeep as hitTestDeepFn, + hitTestFrame as hitTestFrameFn +} from './scene-graph-hit-test' export type { GUID, Color } from './types' import type { Matrix, Vector, Color, Rect } from './types' @@ -466,7 +479,7 @@ export class SceneGraph { rootId: string readonly emitter: Emitter = createNanoEvents() private absPosCache = new Map() - private instanceIndex = new Map>() + instanceIndex = new Map>() constructor() { const root = createDefaultNode('FRAME', { @@ -840,104 +853,11 @@ export class SceneGraph { } hitTest(px: number, py: number, scopeId?: string): SceneNode | null { - const scope = scopeId ?? this.rootId - return this.hitTestChildren(px, py, scope, 0, 0, false) + return hitTestFn(this, px, py, scopeId) } hitTestDeep(px: number, py: number, scopeId?: string): SceneNode | null { - const scope = scopeId ?? this.rootId - return this.hitTestChildren(px, py, scope, 0, 0, true) - } - - private static readonly OPAQUE_CONTAINER_TYPES = new Set(['COMPONENT', 'INSTANCE']) - - private static hasVisibleFillOrStroke(node: SceneNode): boolean { - return ( - node.fills.some((f) => f.visible) || - node.strokes.some((s) => s.visible) - ) - } - - private static containsPoint( - px: number, - py: number, - ax: number, - ay: number, - node: SceneNode - ): boolean { - return px >= ax && px <= ax + node.width && py >= ay && py <= ay + node.height - } - - private hitTestOpaqueContainer( - px: number, - py: number, - child: SceneNode, - childId: string, - ax: number, - ay: number, - deep: boolean - ): SceneNode | null { - if (!SceneGraph.containsPoint(px, py, ax, ay, child)) return null - const childHit = this.hitTestChildren(px, py, childId, ax, ay, deep) - if (childHit) return child - if (SceneGraph.hasVisibleFillOrStroke(child)) return child - return null - } - - private hitTestTransparentContainer( - px: number, - py: number, - child: SceneNode, - childId: string, - ax: number, - ay: number, - deep: boolean - ): SceneNode | null { - const deepHit = this.hitTestChildren(px, py, childId, ax, ay, deep) - if (deepHit) return deepHit - if (child.type === 'GROUP') return null - if (SceneGraph.containsPoint(px, py, ax, ay, child) && SceneGraph.hasVisibleFillOrStroke(child)) return child - return null - } - - private hitTestChildren( - px: number, - py: number, - parentId: string, - offsetX: number, - offsetY: number, - deep = false - ): SceneNode | null { - const parent = this.nodes.get(parentId) - if (!parent) return null - - if (parent.clipsContent) { - if (!SceneGraph.containsPoint(px, py, offsetX, offsetY, parent)) return null - } - - for (let i = parent.childIds.length - 1; i >= 0; i--) { - const childId = parent.childIds[i] - const child = this.nodes.get(childId) - if (!child || !child.visible) continue - - const ax = offsetX + child.x - const ay = offsetY + child.y - - if (CONTAINER_TYPES.has(child.type)) { - if (SceneGraph.OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) { - const hit = this.hitTestOpaqueContainer(px, py, child, childId, ax, ay, deep) - if (hit) return hit - continue - } - - const hit = this.hitTestTransparentContainer(px, py, child, childId, ax, ay, deep) - if (hit) return hit - continue - } - - if (SceneGraph.containsPoint(px, py, ax, ay, child)) return child - } - return null + return hitTestDeepFn(this, px, py, scopeId) } hitTestFrame( @@ -946,41 +866,7 @@ export class SceneGraph { excludeIds: Set, scopeId?: string ): SceneNode | null { - return this.hitTestFrameChildren(px, py, scopeId ?? this.rootId, 0, 0, excludeIds) - } - - private hitTestFrameChildren( - px: number, - py: number, - parentId: string, - offsetX: number, - offsetY: number, - excludeIds: Set - ): SceneNode | null { - const parent = this.nodes.get(parentId) - if (!parent) return null - - // Deepest matching frame wins - let best: SceneNode | null = null - - for (const childId of parent.childIds) { - if (excludeIds.has(childId)) continue - const child = this.nodes.get(childId) - if (!child || !child.visible) continue - - const ax = offsetX + child.x - const ay = offsetY + child.y - - if (!CONTAINER_TYPES.has(child.type)) continue - if (px < ax || px > ax + child.width || py < ay || py > ay + child.height) continue - - best = child - - const deeper = this.hitTestFrameChildren(px, py, childId, ax, ay, excludeIds) - if (deeper) best = deeper - } - - return best + return hitTestFrameFn(this, px, py, excludeIds, scopeId) } cloneTree( @@ -1001,218 +887,32 @@ export class SceneGraph { return clone } - private static readonly INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [ - 'width', - 'height', - 'fills', - 'strokes', - 'effects', - 'opacity', - 'cornerRadius', - 'topLeftRadius', - 'topRightRadius', - 'bottomRightRadius', - 'bottomLeftRadius', - 'independentCorners', - 'layoutMode', - 'layoutWrap', - 'primaryAxisAlign', - 'counterAxisAlign', - 'primaryAxisSizing', - 'counterAxisSizing', - 'itemSpacing', - 'counterAxisSpacing', - 'paddingTop', - 'paddingRight', - 'paddingBottom', - 'paddingLeft', - 'gridTemplateColumns', - 'gridTemplateRows', - 'gridColumnGap', - 'gridRowGap', - 'gridPosition', - 'clipsContent', - 'independentStrokeWeights', - 'borderTopWeight', - 'borderRightWeight', - 'borderBottomWeight', - 'borderLeftWeight' - ] - - private static copyProp( - target: Partial | SceneNode, - source: SceneNode, - key: keyof SceneNode - ): void { - const val = source[key] - if (key === 'fills') { - ;(target as Record)[key] = copyFills(val as Fill[]) - } else if (key === 'strokes') { - ;(target as Record)[key] = copyStrokes(val as Stroke[]) - } else if (key === 'effects') { - ;(target as Record)[key] = copyEffects(val as Effect[]) - } else if (key === 'styleRuns') { - ;(target as Record)[key] = copyStyleRuns(val as StyleRun[]) - } else { - ;(target as Record)[key] = Array.isArray(val) ? structuredClone(val) : val - } - } - createInstance( componentId: string, parentId: string, overrides: Partial = {} ): SceneNode | null { - const component = this.nodes.get(componentId) - if (component?.type !== 'COMPONENT') return null - - const props: Partial = { name: component.name, componentId } - for (const key of SceneGraph.INSTANCE_SYNC_PROPS) { - SceneGraph.copyProp(props, component, key) - } - - const instance = this.createNode('INSTANCE', parentId, { ...props, ...overrides }) - - this.cloneChildrenWithMapping(component.id, instance.id) - - return instance + return createInstanceFn(this, componentId, parentId, overrides) } populateInstanceChildren(instanceId: string, componentId: string): void { - const instance = this.nodes.get(instanceId) - const component = this.nodes.get(componentId) - if (!instance || !component || instance.type !== 'INSTANCE') return - this.cloneChildrenWithMapping(componentId, instanceId) - } - - private cloneChildrenWithMapping(sourceParentId: string, destParentId: string): void { - const sourceParent = this.nodes.get(sourceParentId) - if (!sourceParent) return - - for (const childId of sourceParent.childIds) { - const src = this.nodes.get(childId) - if (!src) continue - - const { id: _, parentId: _p, childIds: _c, ...rest } = src - const clone = this.createNode(src.type, destParentId, { - ...rest, - componentId: childId - }) - - if (src.childIds.length > 0) { - this.cloneChildrenWithMapping(childId, clone.id) - } - } + populateInstanceChildrenFn(this, instanceId, componentId) } syncInstances(componentId: string): void { - const component = this.nodes.get(componentId) - if (component?.type !== 'COMPONENT') return - - for (const instance of this.getInstances(componentId)) { - // Sync instance-level props (unless overridden) - for (const key of SceneGraph.INSTANCE_SYNC_PROPS) { - if (key in instance.overrides) continue - SceneGraph.copyProp(instance, component, key) - } - - // Sync children: match by componentId - this.syncChildren(component.id, instance.id, instance.overrides) - } - } - - private syncChildren( - compParentId: string, - instParentId: string, - overrides: Record - ): void { - const compParent = this.nodes.get(compParentId) - const instParent = this.nodes.get(instParentId) - if (!compParent || !instParent) return - - const instChildMap = new Map() - for (const childId of instParent.childIds) { - const child = this.nodes.get(childId) - if (child?.componentId) instChildMap.set(child.componentId, child) - } - - // Add new children from component that don't exist in instance - for (const compChildId of compParent.childIds) { - if (!instChildMap.has(compChildId)) { - const src = this.nodes.get(compChildId) - if (!src) continue - const { id: _, parentId: _p, childIds: _c, ...rest } = src - const clone = this.createNode(src.type, instParentId, { - ...rest, - componentId: compChildId - }) - if (src.childIds.length > 0) { - this.cloneChildrenWithMapping(compChildId, clone.id) - } - instChildMap.set(compChildId, clone) - } - } - - // Sync existing children - for (const compChildId of compParent.childIds) { - const compChild = this.nodes.get(compChildId) - const instChild = instChildMap.get(compChildId) - if (!compChild || !instChild) continue - - for (const key of SceneGraph.INSTANCE_SYNC_PROPS) { - const overrideKey = `${instChild.id}:${key}` - if (overrideKey in overrides) continue - SceneGraph.copyProp(instChild, compChild, key) - } - - for (const key of ['name', 'text', 'fontSize', 'fontWeight', 'fontFamily'] as const) { - const overrideKey = `${instChild.id}:${key}` - if (overrideKey in overrides) continue - SceneGraph.copyProp(instChild, compChild, key) - } - - if (compChild.childIds.length > 0) { - this.syncChildren(compChildId, instChild.id, overrides) - } - } - - // Reorder instance children to match component order - const compChildOrder = compParent.childIds - instParent.childIds.sort((a, b) => { - const nodeA = this.nodes.get(a) - const nodeB = this.nodes.get(b) - const idxA = nodeA?.componentId ? compChildOrder.indexOf(nodeA.componentId) : -1 - const idxB = nodeB?.componentId ? compChildOrder.indexOf(nodeB.componentId) : -1 - return idxA - idxB - }) + syncInstancesFn(this, componentId) } detachInstance(instanceId: string): void { - const node = this.nodes.get(instanceId) - if (node?.type !== 'INSTANCE') return - if (node.componentId) { - this.instanceIndex.get(node.componentId)?.delete(instanceId) - } - node.type = 'FRAME' - node.componentId = null - node.overrides = {} + detachInstanceFn(this, instanceId) } getMainComponent(instanceId: string): SceneNode | undefined { - const node = this.nodes.get(instanceId) - if (!node?.componentId) return undefined - return this.nodes.get(node.componentId) + return getMainComponentFn(this, instanceId) } getInstances(componentId: string): SceneNode[] { - const ids = this.instanceIndex.get(componentId) - if (!ids) return [] - const instances: SceneNode[] = [] - for (const id of ids) { - const node = this.nodes.get(id) - if (node) instances.push(node) - } - return instances + return getInstancesFn(this, componentId) } flattenTree(parentId?: string, depth = 0): Array<{ node: SceneNode; depth: number }> { diff --git a/packages/core/src/tools/ai-adapter.ts b/packages/core/src/tools/ai-adapter.ts index c8f7c322c..adc33e438 100644 --- a/packages/core/src/tools/ai-adapter.ts +++ b/packages/core/src/tools/ai-adapter.ts @@ -41,11 +41,15 @@ export function toolsToAI( options: AIAdapterOptions, deps: { v: typeof valibot - valibotSchema: (schema: any) => any - tool: (opts: any) => any + // eslint-disable-next-line typescript-eslint/no-explicit-any -- valibot schema type erasure at adapter boundary + valibotSchema: (schema: any) => unknown + // eslint-disable-next-line typescript-eslint/no-explicit-any -- Vercel AI tool() has complex overloads that can't be expressed without any + tool: (...args: any[]) => unknown } +// eslint-disable-next-line typescript-eslint/no-explicit-any -- return type must be any to satisfy Vercel AI SDK's ToolSet which uses any internally ): Record { const { v, valibotSchema, tool } = deps + // eslint-disable-next-line typescript-eslint/no-explicit-any -- matches return type const result: Record = {} for (const def of tools) { @@ -56,11 +60,12 @@ export function toolsToAI( const toolOpts: Record = { description: def.description, - inputSchema: valibotSchema(v.object(shape as any)), + // eslint-disable-next-line typescript-eslint/no-explicit-any -- valibot v.object() requires typed ObjectEntries, but shape is built dynamically + inputSchema: valibotSchema(v.object(shape as Record)), execute: async (args: Record) => { options.onBeforeExecute?.(def) try { - const execResult = await def.execute(options.getFigma(), args as any) + const execResult = await def.execute(options.getFigma(), args) if (def.mutates && options.onFlashNodes) { const ids = extractNodeIds(execResult) if (ids.length > 0) options.onFlashNodes(ids) @@ -100,7 +105,8 @@ function paramToValibot(v: typeof valibot, param: ParamDef): unknown { const pipes: unknown[] = [v.number()] if (param.min !== undefined) pipes.push(v.minValue(param.min)) if (param.max !== undefined) pipes.push(v.maxValue(param.max)) - return pipes.length > 1 ? v.pipe(...(pipes as [any, any, ...any[]])) : v.number() + // eslint-disable-next-line typescript-eslint/no-explicit-any -- valibot pipe() requires specific tuple types, but pipes are built dynamically + return pipes.length > 1 ? v.pipe(...(pipes as [any, any, ...unknown[]])) : v.number() }, boolean: () => v.boolean(), color: () => v.pipe(v.string(), v.description('Color value (hex like #ff0000 or #ff000080)')), @@ -110,10 +116,12 @@ function paramToValibot(v: typeof valibot, param: ParamDef): unknown { let schema = typeMap[param.type]() if (param.description && param.type !== 'color') { + // eslint-disable-next-line typescript-eslint/no-explicit-any -- valibot pipe() requires BaseSchema, but schema is dynamically typed schema = v.pipe(schema as any, v.description(param.description)) } if (!param.required) { + // eslint-disable-next-line typescript-eslint/no-explicit-any -- valibot optional() requires BaseSchema; default value type is dynamic schema = v.optional(schema as any, param.default as any) } diff --git a/packages/core/src/tools/analyze.ts b/packages/core/src/tools/analyze.ts index 9d1579e09..16ce58d7f 100644 --- a/packages/core/src/tools/analyze.ts +++ b/packages/core/src/tools/analyze.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- analysis tools share helpers and collection logic */ import { createTwoFilesPatch } from 'diff' import { colorDistance, colorToHex, parseColor } from '../color' diff --git a/packages/core/src/tools/create.ts b/packages/core/src/tools/create.ts index 21d1a09bb..05af69d3c 100644 --- a/packages/core/src/tools/create.ts +++ b/packages/core/src/tools/create.ts @@ -1,4 +1,5 @@ import { parseColor } from '../color' +import type { SceneNode } from '../scene-graph' import { defineTool, nodeSummary } from './schema' @@ -136,7 +137,7 @@ export const createVector = defineTool({ node.y = args.y if (args.name) node.name = args.name if (args.path) { - figma.graph.updateNode(node.id, { vectorNetwork: JSON.parse(args.path) } as any) + figma.graph.updateNode(node.id, { vectorNetwork: JSON.parse(args.path) as SceneNode['vectorNetwork'] }) } if (args.fill) { node.fills = [{ type: 'SOLID', color: parseColor(args.fill), opacity: 1, visible: true }] diff --git a/packages/core/src/tools/modify.ts b/packages/core/src/tools/modify.ts index af054b20e..c3cbfb16e 100644 --- a/packages/core/src/tools/modify.ts +++ b/packages/core/src/tools/modify.ts @@ -1,5 +1,7 @@ +/* eslint-disable max-lines -- property setters share common patterns, splitting would scatter related tools */ import { parseColor } from '../color' import { DEFAULT_SHADOW_COLOR } from '../constants' +import type { Effect, StyleRun } from '../scene-graph' import { defineTool } from './schema' @@ -78,19 +80,19 @@ export const setEffects = defineTool({ if (!node) return { error: `Node "${args.id}" not found` } const isBlur = args.type === 'FOREGROUND_BLUR' || args.type === 'BACKGROUND_BLUR' - const effect: Record = { - type: args.type, + const color = isBlur + ? { r: 0, g: 0, b: 0, a: 0 } + : (args.color ? parseColor(args.color) : { ...DEFAULT_SHADOW_COLOR }) + const effect: Effect = { + type: args.type as Effect['type'], visible: true, - radius: args.radius ?? 4 + radius: args.radius ?? 4, + color, + offset: isBlur ? { x: 0, y: 0 } : { x: args.offset_x ?? 0, y: args.offset_y ?? 4 }, + spread: isBlur ? 0 : (args.spread ?? 0) } - if (!isBlur) { - effect.color = args.color ? parseColor(args.color) : { ...DEFAULT_SHADOW_COLOR } - effect.offset = { x: args.offset_x ?? 0, y: args.offset_y ?? 4 } - effect.spread = args.spread ?? 0 - } - - node.effects = [...node.effects, effect as any] + node.effects = [...node.effects, effect] return { id: args.id, effects: node.effects.length } } }) @@ -197,8 +199,8 @@ export const setLayout = defineTool({ node.layoutMode = args.direction as 'HORIZONTAL' | 'VERTICAL' node.itemSpacing = args.spacing ?? 0 - node.primaryAxisAlignItems = (args.align ?? 'MIN') as any - node.counterAxisAlignItems = (args.counter_align ?? 'MIN') as any + node.primaryAxisAlignItems = args.align ?? 'MIN' + node.counterAxisAlignItems = args.counter_align ?? 'MIN' const ph = args.padding_horizontal ?? args.padding ?? 0 const pv = args.padding_vertical ?? args.padding ?? 0 @@ -384,13 +386,13 @@ export const setFontRange = defineTool({ execute: (figma, args) => { const node = figma.getNodeById(args.id) if (!node) return { error: `Node "${args.id}" not found` } - const run: Record = { start: args.start, end: args.end } - if (args.family) run.fontFamily = args.family - if (args.size) run.fontSize = args.size - if (args.style) run.fontStyle = args.style - if (args.color) run.color = parseColor(args.color) + const style: StyleRun['style'] = {} + if (args.family) style.fontFamily = args.family + if (args.size) style.fontSize = args.size + if (args.style) style.fontWeight = args.style === 'Bold' ? 700 : undefined + const run: StyleRun = { start: args.start, length: args.end - args.start, style } figma.graph.updateNode(node.id, { - styleRuns: [...(figma.graph.getNode(node.id)?.styleRuns ?? []), run as any] + styleRuns: [...(figma.graph.getNode(node.id)?.styleRuns ?? []), run] }) return { id: args.id, range: { start: args.start, end: args.end } } } diff --git a/packages/core/src/tools/schema.ts b/packages/core/src/tools/schema.ts index 2a3cf0ef6..246c2565b 100644 --- a/packages/core/src/tools/schema.ts +++ b/packages/core/src/tools/schema.ts @@ -25,7 +25,7 @@ export interface ToolDef { description: string mutates?: boolean params: Record - execute: (figma: FigmaAPI, args: Record) => unknown + execute: (figma: FigmaAPI, args: Record) => unknown } type ResolvedType = T extends 'string' diff --git a/packages/core/src/tools/variables.ts b/packages/core/src/tools/variables.ts index 20aa0224e..48e513130 100644 --- a/packages/core/src/tools/variables.ts +++ b/packages/core/src/tools/variables.ts @@ -1,4 +1,5 @@ import { parseColor } from '../color' +import type { VariableType, VariableValue } from '../scene-graph' import { defineTool } from './schema' @@ -84,9 +85,9 @@ export const createVariable = defineTool({ } const v = figma.createVariable( args.name, - args.type as any, + args.type as VariableType, args.collection_id, - parsedValue as any + parsedValue as VariableValue | undefined ) return v } @@ -113,7 +114,7 @@ export const setVariable = defineTool({ else if (v.type === 'FLOAT') parsedValue = Number(args.value) else if (v.type === 'BOOLEAN') parsedValue = args.value === 'true' else parsedValue = args.value - figma.setVariableValue(args.id, args.mode, parsedValue as any) + figma.setVariableValue(args.id, args.mode, parsedValue as VariableValue) return { id: args.id, mode: args.mode, value: parsedValue } } }) diff --git a/packages/core/src/tools/vector.ts b/packages/core/src/tools/vector.ts index ec5d9fda9..c5dac4d42 100644 --- a/packages/core/src/tools/vector.ts +++ b/packages/core/src/tools/vector.ts @@ -1,3 +1,5 @@ +import type { SceneNode } from '../scene-graph' + import { defineTool, nodeSummary } from './schema' const CHUNK_SIZE = 0x8000 @@ -92,7 +94,7 @@ export const pathSet = defineTool({ const raw = figma.graph.getNode(args.id) if (!raw) return { error: `Node "${args.id}" not found` } const network = JSON.parse(args.path) - figma.graph.updateNode(args.id, { vectorNetwork: network } as any) + figma.graph.updateNode(args.id, { vectorNetwork: network } as Partial) return { id: args.id } } }) @@ -122,7 +124,7 @@ export const pathScale = defineTool({ s.tangentEnd.x *= factor s.tangentEnd.y *= factor } - figma.graph.updateNode(id, { vectorNetwork: vn } as any) + figma.graph.updateNode(id, { vectorNetwork: vn } as Partial) return { id, factor } } }) @@ -157,7 +159,7 @@ export const pathFlip = defineTool({ if (axis === 'horizontal') s.tangentEnd.x = -s.tangentEnd.x else s.tangentEnd.y = -s.tangentEnd.y } - figma.graph.updateNode(id, { vectorNetwork: vn } as any) + figma.graph.updateNode(id, { vectorNetwork: vn } as Partial) return { id, axis } } }) @@ -180,7 +182,7 @@ export const pathMove = defineTool({ v.x += dx v.y += dy } - figma.graph.updateNode(id, { vectorNetwork: vn } as any) + figma.graph.updateNode(id, { vectorNetwork: vn } as Partial) return { id, dx, dy } } })