Fix lint warnings: eliminate any types, split oversized files

- 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)
This commit is contained in:
Danila Poyarkov 2026-03-12 15:13:30 +03:00
parent 3b32b4d80c
commit e97a4cfda4
24 changed files with 2027 additions and 1852 deletions

View file

@ -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<number, string> = {
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<string, number> = {
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<string, unknown> {
const n = this._raw()
const obj: Record<string, unknown> = {
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()
}
}

View file

@ -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<number, string> = {
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<string, number> = {
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<string, unknown> {
const n = this._raw()
const obj: Record<string, unknown> = {
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<Uint8Array | null>
}
export { FigmaNodeProxy }
export type { FigmaFontName }

View file

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- codec types and encode/decode logic are tightly coupled */
/**
* Message Encoding/Decoding for Figma Multiplayer
*

View file

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

View file

@ -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<string, unknown>, updates: Partial<SceneNode>): 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<string, unknown>, updates: Partial<SceneNode>): 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<ArcData> | undefined)
if (ov.frameMaskDisabled != null) updates.clipsContent = ov.frameMaskDisabled === false
}
function applyOverrideLayout(ov: Record<string, unknown>, updates: Partial<SceneNode>): 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<string, unknown>, updates: Partial<SceneNode>): 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<string, unknown>, updates: Partial<SceneNode>): 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<string, unknown>): Partial<SceneNode> {
const updates: Partial<SceneNode> = {}
applyOverridePaints(ov, updates)
applyOverrideGeometry(ov, updates)
applyOverrideLayout(ov, updates)
applyOverrideStrokes(ov, updates)
applyOverrideText(ov, updates)
return updates
}

View file

@ -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>): ArcData | null {
export function mapArcData(data?: Partial<ArcData>): 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<string, unknown>, updates: Partial<SceneNode>): 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<string, unknown>, updates: Partial<SceneNode>): 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<ArcData> | undefined)
if (ov.frameMaskDisabled != null) updates.clipsContent = ov.frameMaskDisabled === false
}
function applyOverrideLayout(ov: Record<string, unknown>, updates: Partial<SceneNode>): 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<string, unknown>, updates: Partial<SceneNode>): 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<string, unknown>, updates: Partial<SceneNode>): 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<string, unknown>): Partial<SceneNode> {
const updates: Partial<SceneNode> = {}
applyOverridePaints(ov, updates)
applyOverrideGeometry(ov, updates)
applyOverrideLayout(ov, updates)
applyOverrideStrokes(ov, updates)
applyOverrideText(ov, updates)
return updates
}

View file

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- generated kiwi schema definition */
import { parseSchema } from './kiwi-schema'
const schemaText = `

View file

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- layout tree building is a single recursive pass */
import Yoga, {
Align,
Direction,

View file

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

View file

@ -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<RenderOverlays['penState']>,
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<RenderOverlays['penState']>,
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'

View file

@ -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<RenderOverlays['penState']>,
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<RenderOverlays['penState']>,
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)
}
}
}
}

View file

@ -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<string>()
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<CanvasKit['ParagraphBuilder']['MakeFromFontProvider']>,
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 {

View file

@ -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<string>()
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<CanvasKit['ParagraphBuilder']['MakeFromFontProvider']>,
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
}

View file

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

View file

@ -0,0 +1,143 @@
import type { SceneGraph, SceneNode, NodeType } from './scene-graph'
const CONTAINER_TYPES = new Set<NodeType>([
'CANVAS', 'FRAME', 'GROUP', 'SECTION', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE'
])
const OPAQUE_CONTAINER_TYPES = new Set<NodeType>(['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<string>
): 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<string>,
scopeId?: string
): SceneNode | null {
return hitTestFrameChildren(graph, px, py, scopeId ?? graph.rootId, 0, 0, excludeIds)
}

View file

@ -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> | SceneNode,
source: SceneNode,
key: keyof SceneNode
): void {
const val = source[key]
if (key === 'fills') {
;(target as Record<string, unknown>)[key] = copyFills(val as Fill[])
} else if (key === 'strokes') {
;(target as Record<string, unknown>)[key] = copyStrokes(val as Stroke[])
} else if (key === 'effects') {
;(target as Record<string, unknown>)[key] = copyEffects(val as Effect[])
} else if (key === 'styleRuns') {
;(target as Record<string, unknown>)[key] = copyStyleRuns(val as StyleRun[])
} else {
;(target as Record<string, unknown>)[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<string, unknown>
): void {
const compParent = graph.nodes.get(compParentId)
const instParent = graph.nodes.get(instParentId)
if (!compParent || !instParent) return
const instChildMap = new Map<string, SceneNode>()
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> = {}
): SceneNode | null {
const component = graph.nodes.get(componentId)
if (component?.type !== 'COMPONENT') return null
const props: Partial<SceneNode> = { 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
}

View file

@ -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<SceneGraphEvents> = createNanoEvents()
private absPosCache = new Map<string, Vector>()
private instanceIndex = new Map<string, Set<string>>()
instanceIndex = new Map<string, Set<string>>()
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<NodeType>(['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<string>,
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<string>
): 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> | SceneNode,
source: SceneNode,
key: keyof SceneNode
): void {
const val = source[key]
if (key === 'fills') {
;(target as Record<string, unknown>)[key] = copyFills(val as Fill[])
} else if (key === 'strokes') {
;(target as Record<string, unknown>)[key] = copyStrokes(val as Stroke[])
} else if (key === 'effects') {
;(target as Record<string, unknown>)[key] = copyEffects(val as Effect[])
} else if (key === 'styleRuns') {
;(target as Record<string, unknown>)[key] = copyStyleRuns(val as StyleRun[])
} else {
;(target as Record<string, unknown>)[key] = Array.isArray(val) ? structuredClone(val) : val
}
}
createInstance(
componentId: string,
parentId: string,
overrides: Partial<SceneNode> = {}
): SceneNode | null {
const component = this.nodes.get(componentId)
if (component?.type !== 'COMPONENT') return null
const props: Partial<SceneNode> = { 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<string, unknown>
): void {
const compParent = this.nodes.get(compParentId)
const instParent = this.nodes.get(instParentId)
if (!compParent || !instParent) return
const instChildMap = new Map<string, SceneNode>()
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 }> {

View file

@ -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<string, any> {
const { v, valibotSchema, tool } = deps
// eslint-disable-next-line typescript-eslint/no-explicit-any -- matches return type
const result: Record<string, any> = {}
for (const def of tools) {
@ -56,11 +60,12 @@ export function toolsToAI(
const toolOpts: Record<string, unknown> = {
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<string, any>)),
execute: async (args: Record<string, unknown>) => {
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)
}

View file

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

View file

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

View file

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

View file

@ -25,7 +25,7 @@ export interface ToolDef {
description: string
mutates?: boolean
params: Record<string, ParamDef>
execute: (figma: FigmaAPI, args: Record<string, any>) => unknown
execute: (figma: FigmaAPI, args: Record<string, unknown>) => unknown
}
type ResolvedType<T extends ParamType> = T extends 'string'

View file

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

View file

@ -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<SceneNode>)
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<SceneNode>)
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<SceneNode>)
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<SceneNode>)
return { id, dx, dy }
}
})