* fix(core): start new layers with Figma's defaults in the editor and plugin API The plugin API created bare nodes: frames, components, and shapes without fills, and lines and vectors without strokes, so scripts written for Figma drew nothing. Drawn lines also had a black fill instead of a stroke and were invisible. Both paths now share newLayerDefaults, recorded from Figma desktop 126: frames and components white with frames clipping their content, shapes #D9D9D9, lines and vectors a black 1 px stroke, text black. A stroke a script adds gets the 1 px default weight, and an empty vector has no render bounds. * fix(core): combine variants as Figma does from the canvas and from scripts The plugin API and the editor command each built component sets their own way, both with 40 px of padding and a grey fill. Figma's command pads the variants by 20 and outlines the set with a 1 px dashed #8A38F5 stroke; its plugin API wraps them exactly with no fill or stroke. One variantSetProps now places and styles the set for both, with a canvas or script style, and applyVariantProperties derives variant properties for both. * fix(core): report group children in their container's space in the plugin API Figma's plugin API places children of groups and booleans relative to the nearest real container and refits a group whenever a script changes one of its children. Ours reported group-relative positions and never refit, so scripts placing layers inside groups landed them in the wrong place. x, y, and relativeTransform now map through the groups around a node, and geometry changes, appendChild, insertChild, and remove refit the surrounding groups. The refit moves to Scene Graph as fitEnclosingGroups, shared by the canvas (with undo) and the plugin API. * test(core): pass script-style strokes and typed components in parity tests * test(e2e): expect Figma's default shape grey in the scene freshness spec * fix(vue): draw lines by length and angle as Figma does The Line tool sized a line as the box spanned by the drag. With the stroke a new line now gets, that box drew as a rectangle outline. A line now starts at the press point with the drag length as its width, no height, and the drag angle as its rotation, as Figma's Line tool makes it; Shift snaps the angle to 45° steps, as the docs already described, and a click makes a 100 px horizontal line. * fix(core): give each new layer its own copy of the default paints The defaults spread each paint shallowly, so every layer shared the colour object of the module-level default and editing one layer's colour in place changed the next new layer. Copy the paints with the Scene Graph copy helpers. * fix(core): group, ungroup, and combine layers through shared code in the plugin API The plugin API wrapped layers, ungrouped, made booleans, and made components from layers with its own code. Ungroup moved the children to the top of the stack, booleans were named "Boolean union", and a component made from a frame cloned its children under new ids. These now run through the editor's shared wrap, ungroup, and boolean functions, with the placement and defaults recorded in Figma desktop 126: a group or boolean without an index goes on top, ungrouped children take the group's place, booleans are named after the operation and filled with the default grey, a frame becomes a component in its place with its children, and any other layer is wrapped in a white component named after it. Undoing a wrap in the editor now returns each layer to its own place in the stack. * fix(core): group, frame, combine, and make components from the canvas as Figma does Recorded in Figma desktop 126: a container made from the canvas takes the topmost selected layer's place, Frame selection adds no fill and does not clip, a component wrapped around layers is white and takes a single layer's name, and a boolean is filled like its topmost operand, or its base for Subtract, without strokes. The canvas commands and the plugin API now share the wrap parent check, stack ordering, component rules, and boolean paints, and the plugin API's createComponentFromNode converts groups in place as Figma does. Undoing a boolean returns each operand to its own place in the stack. * refactor(core): reuse translate when centering pasted layers
463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
import { recordInstanceOverride, slotPropertyId } from '@open-pencil/scene-graph'
|
|
import type {
|
|
SceneGraph,
|
|
SceneNode,
|
|
NodeType,
|
|
Fill,
|
|
Stroke,
|
|
LayoutMode
|
|
} from '@open-pencil/scene-graph'
|
|
import {
|
|
getFillOkHCL,
|
|
getStrokeOkHCL,
|
|
setNodeFillOkHCL,
|
|
setNodeStrokeOkHCL
|
|
} from '@open-pencil/scene-graph/color'
|
|
import type { OkHCLColor, OkHCLPayload } from '@open-pencil/scene-graph/color'
|
|
import type { Rect } from '@open-pencil/scene-graph/primitives'
|
|
|
|
import { assertNodeEditable } from '#core/editor/capabilities'
|
|
import type { FigmaEffect } from '#core/figma-api/effects'
|
|
|
|
import { fitGroupsAround } from './accessor-utils'
|
|
import { installBasicNodeProxyAccessors } from './accessors/basic'
|
|
import { installLayoutNodeProxyAccessors } from './accessors/layout'
|
|
import { installStrokeNodeProxyAccessors } from './accessors/strokes'
|
|
import { installTextNodeProxyAccessors } from './accessors/text'
|
|
import { installVariableModeNodeProxyAccessors } from './accessors/variables'
|
|
import {
|
|
installVectorNodeProxyAccessors,
|
|
type FigmaVectorNetwork,
|
|
type FigmaVectorPath
|
|
} from './accessors/vector'
|
|
import { installVisualNodeProxyAccessors } from './accessors/visual'
|
|
import { installComponentPropertyAccessors } from './components'
|
|
import type { FigmaFontName } from './fonts'
|
|
import type { FigmaFrameNode, FigmaInstanceNode } from './node-types'
|
|
import { getPageBackgrounds, setPageBackgrounds } from './page-backgrounds'
|
|
import * as PluginData from './plugin-data'
|
|
import { nodeProxyToJSON } from './serialization'
|
|
import { installSlotAccessors, prepareSlotMove, prepareSlotRemoval } from './slots'
|
|
import * as TextProxy from './text'
|
|
import * as Traversal from './traversal'
|
|
import type { FigmaTransform } from './types'
|
|
|
|
const MIXED = Symbol('mixed')
|
|
|
|
export { styleNameToWeight, weightToStyleName, type FigmaFont, type FigmaFontName } from './fonts'
|
|
|
|
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
|
|
|
|
declare readonly id: string
|
|
/** A slot frame reads as `'SLOT'`, as Figma's `SlotNode` does. */
|
|
declare readonly type: NodeType | 'SLOT'
|
|
declare name: string
|
|
declare readonly removed: boolean
|
|
declare x: number
|
|
declare y: number
|
|
declare readonly width: number
|
|
declare readonly height: number
|
|
declare rotation: number
|
|
declare readonly relativeTransform: FigmaTransform
|
|
declare resize: (width: number, height: number) => void
|
|
declare resizeWithoutConstraints: (width: number, height: number) => void
|
|
declare rescale: (scale: number) => void
|
|
declare readonly absoluteTransform: FigmaTransform
|
|
declare readonly absoluteBoundingBox: Rect
|
|
declare readonly absoluteRenderBounds: Rect | null
|
|
|
|
declare fills: readonly Fill[]
|
|
declare strokes: readonly Stroke[]
|
|
declare effects: readonly FigmaEffect[]
|
|
declare opacity: number
|
|
declare visible: boolean
|
|
declare locked: boolean
|
|
declare blendMode: string
|
|
declare clipsContent: boolean
|
|
declare cornerRadius: number | typeof MIXED
|
|
declare topLeftRadius: number
|
|
declare topRightRadius: number
|
|
declare bottomLeftRadius: number
|
|
declare bottomRightRadius: number
|
|
declare cornerSmoothing: number
|
|
|
|
declare layoutMode: LayoutMode
|
|
declare layoutDirection: string
|
|
declare primaryAxisAlignItems: string
|
|
declare counterAxisAlignItems: string
|
|
declare itemSpacing: number
|
|
declare counterAxisSpacing: number
|
|
declare paddingTop: number
|
|
declare paddingRight: number
|
|
declare paddingBottom: number
|
|
declare paddingLeft: number
|
|
declare layoutWrap: string
|
|
declare primaryAxisSizingMode: string
|
|
declare counterAxisSizingMode: string
|
|
declare counterAxisAlignContent: string
|
|
declare itemReverseZIndex: boolean
|
|
declare strokesIncludedInLayout: boolean
|
|
declare layoutPositioning: string
|
|
declare layoutGrow: number
|
|
declare layoutAlign: string
|
|
declare layoutSizingHorizontal: string
|
|
declare layoutSizingVertical: string
|
|
declare constraints: { horizontal: string; vertical: string }
|
|
declare minWidth: number | null
|
|
declare maxWidth: number | null
|
|
declare minHeight: number | null
|
|
declare maxHeight: number | null
|
|
declare vectorPaths: readonly FigmaVectorPath[]
|
|
declare vectorNetwork: FigmaVectorNetwork
|
|
declare setVectorNetworkAsync: (vectorNetwork: FigmaVectorNetwork) => Promise<void>
|
|
declare handleMirroring: SceneNode['handleMirroring'] | typeof MIXED
|
|
declare readonly explicitVariableModes: Readonly<Record<string, string>>
|
|
declare readonly resolvedVariableModes: Readonly<Record<string, string>>
|
|
|
|
declare strokeWeight: number
|
|
declare strokeAlign: string
|
|
declare dashPattern: readonly number[]
|
|
declare strokeCap: string
|
|
declare strokeJoin: string
|
|
declare strokeMiterLimit: number
|
|
declare strokeTopWeight: number
|
|
declare strokeBottomWeight: number
|
|
declare strokeLeftWeight: number
|
|
declare strokeRightWeight: number
|
|
|
|
declare characters: string
|
|
declare fontSize: number
|
|
declare fontName: FigmaFontName
|
|
declare fontWeight: number
|
|
declare textAlignHorizontal: string
|
|
declare textAlignVertical: string
|
|
declare textDirection: string
|
|
declare textAutoResize: string
|
|
declare letterSpacing: number
|
|
declare lineHeight: number | null
|
|
declare textCase: string
|
|
declare textDecoration: string
|
|
declare maxLines: number | null
|
|
declare textTruncation: string
|
|
declare autoRename: boolean
|
|
|
|
constructor(id: string, graph: SceneGraph, api: NodeProxyHost) {
|
|
this[INTERNAL_ID] = id
|
|
this[INTERNAL_GRAPH] = graph
|
|
this[INTERNAL_API] = api
|
|
if (graph.getNode(id)?.type === 'VECTOR') {
|
|
installVectorNodeProxyAccessors(
|
|
this,
|
|
{ id: INTERNAL_ID, graph: INTERNAL_GRAPH, api: INTERNAL_API },
|
|
MIXED
|
|
)
|
|
}
|
|
}
|
|
|
|
private _update(changes: Partial<SceneNode>): void {
|
|
assertNodeEditable(this[INTERNAL_GRAPH], this[INTERNAL_ID])
|
|
const graph = this[INTERNAL_GRAPH]
|
|
const id = this[INTERNAL_ID]
|
|
graph.updateNode(id, changes)
|
|
recordInstanceOverride(graph, id, Object.keys(changes))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
insertCharacters(start: number, characters: string): void {
|
|
TextProxy.insertCharacters(this[INTERNAL_GRAPH], this._raw(), start, characters)
|
|
}
|
|
|
|
deleteCharacters(start: number, end: number): void {
|
|
TextProxy.deleteCharacters(this[INTERNAL_GRAPH], this._raw(), start, end)
|
|
}
|
|
|
|
get isMask(): boolean {
|
|
return this._raw().isMask
|
|
}
|
|
|
|
set isMask(v: boolean) {
|
|
this._update({ isMask: v })
|
|
}
|
|
|
|
get maskType(): string {
|
|
return this._raw().maskType
|
|
}
|
|
|
|
set maskType(v: string) {
|
|
this._update({ maskType: v as SceneNode['maskType'] })
|
|
}
|
|
|
|
// --- UI state ---
|
|
|
|
get expanded(): boolean {
|
|
return this._raw().expanded
|
|
}
|
|
|
|
set expanded(v: boolean) {
|
|
this._update({ expanded: v })
|
|
}
|
|
|
|
// --- Components ---
|
|
|
|
get backgrounds(): readonly Fill[] {
|
|
return getPageBackgrounds(this._raw())
|
|
}
|
|
|
|
set backgrounds(value: readonly Fill[]) {
|
|
setPageBackgrounds(this[INTERNAL_GRAPH], this._raw(), value)
|
|
}
|
|
|
|
/** The async form Figma requires in dynamic-page mode; same result as mainComponent. */
|
|
async getMainComponentAsync(): Promise<FigmaNodeProxy | null> {
|
|
return this.mainComponent
|
|
}
|
|
|
|
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(): FigmaInstanceNode {
|
|
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')
|
|
// `wrapNode` cannot know the node's type; this one just built an instance.
|
|
return this[INTERNAL_API].wrapNode(inst.id) as FigmaInstanceNode
|
|
}
|
|
|
|
/** Turns this instance into a frame that keeps its current content, like Figma's. */
|
|
detachInstance(): FigmaFrameNode {
|
|
const n = this._raw()
|
|
if (n.type !== 'INSTANCE') throw new Error('detachInstance() can only be called on instances')
|
|
assertNodeEditable(this[INTERNAL_GRAPH], this[INTERNAL_ID])
|
|
this[INTERNAL_GRAPH].detachInstance(n.id)
|
|
// The node is a frame once detached, which `wrapNode` has no way to tell.
|
|
return this[INTERNAL_API].wrapNode(n.id) as FigmaFrameNode
|
|
}
|
|
|
|
/** Points this instance at another component, as Figma's swapComponent does. */
|
|
swapComponent(component: FigmaNodeProxy): void {
|
|
const n = this._raw()
|
|
if (n.type !== 'INSTANCE') throw new Error('swapComponent() can only be called on instances')
|
|
const target = this[INTERNAL_GRAPH].getNode(component[INTERNAL_ID])
|
|
if (target?.type !== 'COMPONENT') throw new Error('swapComponent() needs a component')
|
|
assertNodeEditable(this[INTERNAL_GRAPH], this[INTERNAL_ID])
|
|
this[INTERNAL_GRAPH].swapInstanceComponent(n.id, target.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 {
|
|
assertNodeEditable(this[INTERNAL_GRAPH], this[INTERNAL_ID])
|
|
assertNodeEditable(this[INTERNAL_GRAPH], child[INTERNAL_ID])
|
|
prepareSlotMove(this[INTERNAL_GRAPH], this[INTERNAL_ID], child[INTERNAL_ID], 'appendChild')
|
|
this._reparentAndFit(child[INTERNAL_ID])
|
|
}
|
|
|
|
/** Moves a child here, keeping its place on the canvas, and refits the groups it left and joined. */
|
|
private _reparentAndFit(childId: string): void {
|
|
const scene = this[INTERNAL_GRAPH]
|
|
const previousParentId = scene.getNode(childId)?.parentId
|
|
scene.reparentNode(childId, this[INTERNAL_ID])
|
|
fitGroupsAround(scene, previousParentId)
|
|
fitGroupsAround(scene, this[INTERNAL_ID])
|
|
}
|
|
|
|
insertChild(index: number, child: FigmaNodeProxy): void {
|
|
assertNodeEditable(this[INTERNAL_GRAPH], this[INTERNAL_ID])
|
|
assertNodeEditable(this[INTERNAL_GRAPH], child[INTERNAL_ID])
|
|
prepareSlotMove(this[INTERNAL_GRAPH], this[INTERNAL_ID], child[INTERNAL_ID], 'insertChild')
|
|
this._reparentAndFit(child[INTERNAL_ID])
|
|
this[INTERNAL_GRAPH].reorderChild(child[INTERNAL_ID], this[INTERNAL_ID], index)
|
|
}
|
|
|
|
clone(): FigmaNodeProxy {
|
|
assertNodeEditable(this[INTERNAL_GRAPH], this[INTERNAL_ID])
|
|
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]}`)
|
|
// A slot's copy is a plain frame: the slot binding belongs to the original alone.
|
|
if (slotPropertyId(cloned))
|
|
this[INTERNAL_GRAPH].updateNode(cloned.id, {
|
|
componentPropertyReferences: cloned.componentPropertyReferences.filter(
|
|
(reference) => reference.field !== 'SLOT_CONTENT'
|
|
)
|
|
})
|
|
return this[INTERNAL_API].wrapNode(cloned.id)
|
|
}
|
|
|
|
remove(): void {
|
|
assertNodeEditable(this[INTERNAL_GRAPH], this[INTERNAL_ID])
|
|
prepareSlotRemoval(this[INTERNAL_GRAPH], this[INTERNAL_ID])
|
|
const parentId = this._raw().parentId
|
|
this[INTERNAL_GRAPH].deleteNode(this[INTERNAL_ID])
|
|
fitGroupsAround(this[INTERNAL_GRAPH], parentId)
|
|
}
|
|
|
|
findAll(callback?: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy[] {
|
|
return Traversal.findAll(this[INTERNAL_GRAPH], this[INTERNAL_API], this[INTERNAL_ID], callback)
|
|
}
|
|
|
|
findOne(callback: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy | null {
|
|
return Traversal.findOne(this[INTERNAL_GRAPH], this[INTERNAL_API], this[INTERNAL_ID], callback)
|
|
}
|
|
|
|
findChild(callback: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy | null {
|
|
return Traversal.findChild(
|
|
this[INTERNAL_GRAPH],
|
|
this[INTERNAL_API],
|
|
this[INTERNAL_ID],
|
|
callback
|
|
)
|
|
}
|
|
|
|
findChildren(callback?: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy[] {
|
|
return Traversal.findChildren(
|
|
this[INTERNAL_GRAPH],
|
|
this[INTERNAL_API],
|
|
this[INTERNAL_ID],
|
|
callback
|
|
)
|
|
}
|
|
|
|
findAllWithCriteria(criteria: { types?: string[] }): FigmaNodeProxy[] {
|
|
return Traversal.findAllWithCriteria(
|
|
this[INTERNAL_GRAPH],
|
|
this[INTERNAL_API],
|
|
this[INTERNAL_ID],
|
|
criteria
|
|
)
|
|
}
|
|
|
|
// --- Plugin data ---
|
|
|
|
getPluginData(key: string): string {
|
|
return PluginData.getPluginData(this._raw(), key)
|
|
}
|
|
|
|
setPluginData(key: string, value: string): void {
|
|
assertNodeEditable(this[INTERNAL_GRAPH], this[INTERNAL_ID])
|
|
PluginData.setPluginData(this[INTERNAL_GRAPH], this._raw(), key, value)
|
|
}
|
|
|
|
getPluginDataKeys(): string[] {
|
|
return PluginData.getPluginDataKeys(this._raw())
|
|
}
|
|
|
|
getSharedPluginData(namespace: string, key: string): string {
|
|
return PluginData.getSharedPluginData(this._raw(), namespace, key)
|
|
}
|
|
|
|
setSharedPluginData(namespace: string, key: string, value: string): void {
|
|
assertNodeEditable(this[INTERNAL_GRAPH], this[INTERNAL_ID])
|
|
PluginData.setSharedPluginData(this[INTERNAL_GRAPH], this._raw(), namespace, key, value)
|
|
}
|
|
|
|
getSharedPluginDataKeys(namespace: string): string[] {
|
|
return PluginData.getSharedPluginDataKeys(this._raw(), namespace)
|
|
}
|
|
|
|
getFillOkHCL(index = 0): OkHCLPayload | null {
|
|
return getFillOkHCL(this._raw(), index)
|
|
}
|
|
|
|
setFillOkHCL(color: OkHCLColor, index = 0): void {
|
|
this._update(
|
|
setNodeFillOkHCL(this._raw(), index, color, this[INTERNAL_GRAPH].documentColorSpace)
|
|
)
|
|
}
|
|
|
|
getStrokeOkHCL(index = 0): OkHCLPayload | null {
|
|
return getStrokeOkHCL(this._raw(), index)
|
|
}
|
|
|
|
setStrokeOkHCL(color: OkHCLColor, index = 0): void {
|
|
this._update(
|
|
setNodeStrokeOkHCL(this._raw(), index, color, this[INTERNAL_GRAPH].documentColorSpace)
|
|
)
|
|
}
|
|
|
|
// --- Serialization ---
|
|
|
|
toJSON(maxDepth?: number, currentDepth = 0): Record<string, unknown> {
|
|
return nodeProxyToJSON(
|
|
this[INTERNAL_GRAPH],
|
|
this[INTERNAL_API],
|
|
this[INTERNAL_ID],
|
|
maxDepth,
|
|
currentDepth
|
|
)
|
|
}
|
|
|
|
toString(): string {
|
|
const n = this._raw()
|
|
return `[${n.type} "${n.name}" ${n.id}]`
|
|
}
|
|
|
|
[Symbol.for('nodejs.util.inspect.custom')](): string {
|
|
return this.toString()
|
|
}
|
|
}
|
|
|
|
installBasicNodeProxyAccessors(FigmaNodeProxy.prototype, {
|
|
id: INTERNAL_ID,
|
|
graph: INTERNAL_GRAPH,
|
|
api: INTERNAL_API
|
|
})
|
|
|
|
installVisualNodeProxyAccessors(
|
|
FigmaNodeProxy.prototype,
|
|
{ id: INTERNAL_ID, graph: INTERNAL_GRAPH, api: INTERNAL_API },
|
|
MIXED
|
|
)
|
|
|
|
const proxyInternals = {
|
|
id: INTERNAL_ID,
|
|
graph: INTERNAL_GRAPH,
|
|
api: INTERNAL_API
|
|
}
|
|
|
|
installStrokeNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)
|
|
installTextNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)
|
|
installLayoutNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)
|
|
installVariableModeNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)
|
|
installComponentPropertyAccessors(FigmaNodeProxy.prototype, proxyInternals)
|
|
installSlotAccessors(FigmaNodeProxy.prototype, proxyInternals)
|