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:
parent
3b32b4d80c
commit
e97a4cfda4
977
packages/core/src/figma-api-proxy.ts
Normal file
977
packages/core/src/figma-api-proxy.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines -- codec types and encode/decode logic are tightly coupled */
|
||||
/**
|
||||
* Message Encoding/Decoding for Figma Multiplayer
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
147
packages/core/src/kiwi/kiwi-convert-overrides.ts
Normal file
147
packages/core/src/kiwi/kiwi-convert-overrides.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines -- generated kiwi schema definition */
|
||||
import { parseSchema } from './kiwi-schema'
|
||||
|
||||
const schemaText = `
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines -- layout tree building is a single recursive pass */
|
||||
import Yoga, {
|
||||
Align,
|
||||
Direction,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
228
packages/core/src/renderer/pen-overlay.ts
Normal file
228
packages/core/src/renderer/pen-overlay.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
198
packages/core/src/renderer/text.ts
Normal file
198
packages/core/src/renderer/text.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
143
packages/core/src/scene-graph-hit-test.ts
Normal file
143
packages/core/src/scene-graph-hit-test.ts
Normal 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)
|
||||
}
|
||||
214
packages/core/src/scene-graph-instances.ts
Normal file
214
packages/core/src/scene-graph-instances.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 }> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 }]
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue