fix: share Figma's creation and grouping between editor and plugin API (#919)

* 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
This commit is contained in:
Danila Poyarkov 2026-10-06 12:28:52 +00:00 committed by GitHub
parent 00da4678ad
commit b0e0b321e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1177 additions and 403 deletions

View file

@ -67,6 +67,10 @@
### Changed
- In scripts, children of groups and booleans report `x`, `y`, and `relativeTransform` in their container's space, as in Figma, and a group refits whenever a script moves, resizes, rotates, adds, or removes one of its children; a group left without children is removed. The canvas and the plugin API share the refit.
- Group, Frame selection, Create component, and boolean operations match Figma from the canvas and from scripts, through one shared implementation. From the canvas, the new layer takes the topmost selected layer's place in the layer list, 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. In scripts, `figma.ungroup()` leaves the children in the group's place instead of moving them to the top, booleans are named Union, Subtract, Intersect, or Exclude with the default grey fill, and `figma.createComponentFromNode()` turns a frame or group into a component that keeps its children instead of copying them, and wraps any other layer. Undoing a group or boolean returns each layer to its own place in the layer list.
- Combine as variants matches Figma: the set pads its variants by 20 px and has a dashed purple stroke instead of a grey fill and 40 px of padding, and `figma.combineAsVariants()` in scripts wraps the components exactly with no fill or stroke, as Figma's plugin API does. Both share one implementation.
- New layers start as in Figma, whether drawn or created by a script: frames are white and clip their content, components are white, shapes are #D9D9D9, and lines and vectors get a black 1 px stroke. The Line tool draws a line from the start point to the cursor by its length and angle, as in Figma, instead of an invisible box, and Shift snaps it to 45° steps. A stroke a script adds through the plugin API now gets Figma's 1 px default weight instead of none.
- `createInstance()` and `detachInstance()` in the plugin API return the node type they built, so reading `componentProperties`, `setProperties`, or `isExposedInstance` from a new instance no longer needs a cast; `FigmaInstanceNode` is exported alongside the other node types.
- Name the tool and list every invalid argument with where it is when AI chat, the CLI, or WebMCP calls a tool wrongly, as MCP clients already saw, as in `Invalid arguments for create_shape:` followed by `× Invalid type: Expected ("FRAME" | …) but received "CIRCLE"` and `→ at type`. Design JSX component properties and gradient stops report their problems the same way. Previously only the first problem was named, without the tool or the argument.
- HTML and Tailwind JSX export write variable-bound colors, spacing, radii, borders, sizes, type sizes, and opacity as the tokens they come from, such as `var(--color-primary)` or `bg-primary`, and put layers set to another mode in it with an attribute such as `data-theme="dark"`. Values CSS would not resolve as the canvas draws them stay literal, and standalone HTML includes the stylesheet for the tokens it uses.

View file

@ -73,6 +73,8 @@ export const PARENT_OUTLINE_DASH = 4
export const DEFAULT_FONT_FAMILY = 'Inter'
export const DEFAULT_FONT_SIZE = 14
export const DEFAULT_STROKE_MITER_LIMIT = 4
/** Figma's stroke weight for a node that has not been given one. */
export const DEFAULT_STROKE_WEIGHT = 1
export const LABEL_FONT_SIZE = 11
export const SIZE_FONT_SIZE = 10
@ -506,9 +508,10 @@ export const CJK_FALLBACK_FAMILIES_LINUX = [
export const CJK_GOOGLE_FONTS = ['Noto Sans SC', 'Noto Sans TC', 'Noto Sans JP', 'Noto Sans KR']
/** Figma's #D9D9D9 for new shapes. */
export const DEFAULT_SHAPE_FILL: Fill = {
type: 'SOLID',
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
color: { r: 217 / 255, g: 217 / 255, b: 217 / 255, a: 1 },
opacity: 1,
visible: true
}

View file

@ -17,12 +17,7 @@ export function createClipboardPlacementActions(ctx: EditorContext) {
const items = nodeIds.map((id) => ctx.graph.getNode(id)).filter(isNotNil)
const bounds = computeBounds(items)
if (bounds.width === 0 && bounds.height === 0 && items.length === 0) return
const dx = cx - (bounds.x + bounds.width / 2)
const dy = cy - (bounds.y + bounds.height / 2)
for (const id of nodeIds) {
const node = ctx.graph.getNode(id)
if (node) ctx.graph.updateNode(id, { x: node.x + dx, y: node.y + dy })
}
translate(nodeIds, cx - (bounds.x + bounds.width / 2), cy - (bounds.y + bounds.height / 2))
}
/**

View file

@ -1,12 +1,12 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import { createComponentPropertyId } from '@open-pencil/scene-graph'
import { deriveSlashVariantProperties } from '@open-pencil/scene-graph/variant-properties'
import { becomesComponent, componentWrapProps } from './components/create'
import { createComponentFocusActions } from './components/focus'
import { createComponentInstanceActions } from './components/instances'
import { createComponentPropertyActions } from './components/properties'
import { createSlotActions } from './components/slots'
import { createSlotAuthoringActions } from './components/slots/authoring'
import { applyVariantProperties, variantSetProps } from './components/variant-set'
import { createVariantActions } from './components/variants'
import type { EditorContext } from './types'
@ -29,7 +29,7 @@ export function createComponentActions(ctx: EditorContext) {
if (node.type === 'COMPONENT') return
if (node.type === 'FRAME' || node.type === 'GROUP') {
if (becomesComponent(node)) {
ctx.graph.updateNode(node.id, { type: 'COMPONENT' })
ctx.setSelectedIds(new Set([node.id]))
ctx.undo.push({
@ -47,7 +47,7 @@ export function createComponentActions(ctx: EditorContext) {
}
}
wrapSelectionInContainer('COMPONENT', selectedNodes)
wrapSelectionInContainer('COMPONENT', selectedNodes, componentWrapProps(selectedNodes))
}
function createComponentSetFromComponents(
@ -60,16 +60,14 @@ export function createComponentActions(ctx: EditorContext) {
) {
if (selectedNodes.length < 2) return
if (!selectedNodes.every((n) => n.type === 'COMPONENT')) return
const containerId = wrapSelectionInContainer('COMPONENT_SET', selectedNodes)
const parentId = selectedNodes[0].parentId ?? ctx.state.currentPageId
const containerId = wrapSelectionInContainer(
'COMPONENT_SET',
selectedNodes,
variantSetProps(ctx.graph, selectedNodes, parentId, 'canvas')
)
if (!containerId) return
const derived = deriveSlashVariantProperties(selectedNodes, createComponentPropertyId)
if (!derived) return
for (const [nodeId, changes] of derived.variants) {
ctx.graph.updateNode(nodeId, changes)
}
ctx.graph.updateNode(containerId, { componentPropertyDefinitions: derived.definitions })
applyVariantProperties(ctx.graph, selectedNodes, containerId)
}
const focusActions = createComponentFocusActions(ctx)

View file

@ -0,0 +1,20 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import { newLayerDefaults } from '#core/editor/shapes/defaults'
/**
* Whether making a component from this layer turns the layer itself into the component. Figma
* converts a frame or a group in place, from the canvas and from the plugin API, and wraps any
* other layer.
*/
export function becomesComponent(node: SceneNode): boolean {
return node.type === 'FRAME' || node.type === 'GROUP'
}
/** Look of a component wrapped around layers: white like a new component, named after one layer. */
export function componentWrapProps(nodes: readonly SceneNode[]): Partial<SceneNode> {
return {
...newLayerDefaults('COMPONENT'),
...(nodes.length === 1 ? { name: nodes[0].name } : {})
}
}

View file

@ -0,0 +1,60 @@
import {
createComponentPropertyId,
type SceneGraph,
type SceneNode,
type Stroke
} from '@open-pencil/scene-graph'
import { getAxisAlignedBoundsInParent } from '@open-pencil/scene-graph/coordinate'
import { copyStrokes } from '@open-pencil/scene-graph/copy'
import { deriveSlashVariantProperties } from '@open-pencil/scene-graph/variant-properties'
/**
* How a new component set looks. Figma's Combine as variants command (`canvas`) pads the variants
* by 20 and outlines the set with a dashed purple stroke; its plugin API (`script`) wraps them
* exactly, with no fill or stroke.
*/
export type VariantSetStyle = 'canvas' | 'script'
const CANVAS_PADDING = 20
const CANVAS_STROKE: Stroke = {
type: 'SOLID',
color: { r: 138 / 255, g: 56 / 255, b: 245 / 255, a: 1 },
opacity: 1,
visible: true,
weight: 1,
align: 'INSIDE',
dashPattern: [10, 5]
}
/** Name, place, and look of a set made from `components` under `parentId`, as Figma makes it. */
export function variantSetProps(
graph: SceneGraph,
components: readonly SceneNode[],
parentId: string,
style: VariantSetStyle
): Partial<SceneNode> {
const bounds = getAxisAlignedBoundsInParent(components, parentId, graph)
const padding = style === 'canvas' ? CANVAS_PADDING : 0
return {
name: components[0]?.name.split('/')[0]?.trim() || 'Component Set',
x: bounds.x - padding,
y: bounds.y - padding,
width: bounds.width + padding * 2,
height: bounds.height + padding * 2,
fills: [],
strokes: style === 'canvas' ? copyStrokes([CANVAS_STROKE]) : [],
cornerRadius: style === 'canvas' ? 5 : 0
}
}
/** Turns slash-separated names such as `Button/Primary` into the set's variant property. */
export function applyVariantProperties(
graph: SceneGraph,
components: readonly SceneNode[],
setId: string
): void {
const derived = deriveSlashVariantProperties([...components], createComponentPropertyId)
if (!derived) return
for (const [nodeId, changes] of derived.variants) graph.updateNode(nodeId, changes)
graph.updateNode(setId, { componentPropertyDefinitions: derived.definitions })
}

View file

@ -1,7 +1,7 @@
import type { Vector } from '@open-pencil/scene-graph/primitives'
import { collectNodePositions, pushPositionUndo } from './history/position'
import { fitEnclosingGroups } from './structure/group-bounds'
import { fitEnclosingGroupsWithUndo } from './structure/group-bounds'
import type { EditorContext } from './types'
const NUDGE_COMMIT_DELAY = 300
@ -21,7 +21,7 @@ export function createNudgeActions(ctx: EditorContext) {
// Groups and booleans fit their nudged children, in the same undo step.
ctx.undo.runBatch('Nudge', () => {
pushPositionUndo(ctx, 'Nudge', originals, finals)
fitEnclosingGroups(ctx, parentIds)
fitEnclosingGroupsWithUndo(ctx, parentIds)
})
ctx.requestRender()
}

View file

@ -1,38 +1,13 @@
import type { Fill, NodeType, SceneNode } from '@open-pencil/scene-graph'
import {
BLACK,
DEFAULT_FRAME_FILL,
DEFAULT_SHAPE_FILL,
SECTION_DEFAULT_FILL,
SECTION_DEFAULT_STROKE
} from '#core/constants'
import type { NodeType, SceneNode } from '@open-pencil/scene-graph'
import { prepareSlotEdits } from './components/slots'
import { adoptCoveredLayers } from './shapes/adopt'
import { newLayerDefaults } from './shapes/defaults'
import { createFramePresetActions } from './shapes/frame-presets'
import { createPenActions } from './shapes/pen'
import type { EditorContext } from './types'
export type { PenDragOptions } from './shapes/pen'
const BLACK_FILL: Fill = {
type: 'SOLID',
color: BLACK,
opacity: 1,
visible: true
}
const DEFAULT_FILLS: Record<string, Fill> = {
FRAME: DEFAULT_FRAME_FILL,
SECTION: SECTION_DEFAULT_FILL,
RECTANGLE: DEFAULT_SHAPE_FILL,
ELLIPSE: DEFAULT_SHAPE_FILL,
POLYGON: DEFAULT_SHAPE_FILL,
STAR: DEFAULT_SHAPE_FILL,
LINE: BLACK_FILL,
TEXT: BLACK_FILL
}
export function createShapeActions(ctx: EditorContext) {
function createShape(
type: NodeType,
@ -43,31 +18,19 @@ export function createShapeActions(ctx: EditorContext) {
parentId?: string,
name?: string
): string {
const fill = DEFAULT_FILLS[type] ?? DEFAULT_FILLS.RECTANGLE
const pid = parentId ?? ctx.state.currentPageId
// Inside an instance only a slot takes new layers, and the instance claims it first.
if (!prepareSlotEdits(ctx, [pid])) {
throw new Error('Cannot add a layer to the locked part of an instance')
}
const overrides: Partial<SceneNode> = {
...newLayerDefaults(type),
x,
y,
width: w,
height: h,
fills: [{ ...fill }],
...(name ? { name } : {})
}
if (type === 'SECTION') {
overrides.strokes = [{ ...SECTION_DEFAULT_STROKE }]
overrides.cornerRadius = 5
}
if (type === 'POLYGON') {
overrides.pointCount = 3
}
if (type === 'STAR') {
overrides.pointCount = 5
overrides.starInnerRadius = 0.38
}
const node = ctx.graph.createNode(type, pid, overrides)
const id = node.id
const snapshot = { ...node }

View file

@ -0,0 +1,47 @@
import type { Fill, NodeType, SceneNode, Stroke } from '@open-pencil/scene-graph'
import { copyFills, copyStrokes } from '@open-pencil/scene-graph/copy'
import {
BLACK,
DEFAULT_FRAME_FILL,
DEFAULT_SHAPE_FILL,
DEFAULT_STROKE_WEIGHT,
SECTION_DEFAULT_FILL,
SECTION_DEFAULT_STROKE
} from '#core/constants'
const BLACK_FILL: Fill = { type: 'SOLID', color: BLACK, opacity: 1, visible: true }
const BLACK_STROKE: Stroke = { ...BLACK_FILL, weight: DEFAULT_STROKE_WEIGHT, align: 'CENTER' }
/**
* What a new layer starts with, as Figma gives it from a drawing tool and from the plugin API:
* frames and components white (frames clipping their content), shapes light grey, lines and
* vectors a black 1 px stroke, and text black.
*/
export function newLayerDefaults(type: NodeType): Partial<SceneNode> {
switch (type) {
case 'FRAME':
return { fills: copyFills([DEFAULT_FRAME_FILL]), clipsContent: true }
case 'COMPONENT':
return { fills: copyFills([DEFAULT_FRAME_FILL]) }
case 'SECTION':
return {
fills: copyFills([SECTION_DEFAULT_FILL]),
strokes: copyStrokes([SECTION_DEFAULT_STROKE]),
cornerRadius: 5
}
case 'RECTANGLE':
case 'ELLIPSE':
case 'STAR':
return { fills: copyFills([DEFAULT_SHAPE_FILL]) }
case 'POLYGON':
return { fills: copyFills([DEFAULT_SHAPE_FILL]), pointCount: 3 }
case 'LINE':
case 'VECTOR':
return { fills: [], strokes: copyStrokes([BLACK_STROKE]) }
case 'TEXT':
return { fills: copyFills([BLACK_FILL]) }
default:
return {}
}
}

View file

@ -1,8 +1,7 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import { DEFAULT_FRAME_FILL } from '#core/constants'
import { acceptingParent, acceptsChildren, prepareSlotEdits } from '#core/editor/components/slots'
import { fitEnclosingGroups } from '#core/editor/structure/group-bounds'
import { fitEnclosingGroupsWithUndo } from '#core/editor/structure/group-bounds'
import { wrapInAutoLayout as wrapInAutoLayoutImpl } from './structure/auto-layout-wrap'
import {
@ -70,10 +69,9 @@ export function createStructureActions(ctx: EditorContext) {
return wrapSelectionInContainer('GROUP', selectedNodes)
}
/** Figma frames a selection without a fill or clipping, unlike a drawn frame. */
function frameSelection(selectedNodes: SceneNode[]) {
return wrapSelectionInContainer('FRAME', selectedNodes, {
fills: [structuredClone(DEFAULT_FRAME_FILL)]
})
return wrapSelectionInContainer('FRAME', selectedNodes)
}
/**
@ -165,7 +163,7 @@ export function createStructureActions(ctx: EditorContext) {
isTopLevel,
acceptsChildren: (parentId: string) => acceptsChildren(ctx, parentId),
acceptingParent: (parentId: string) => acceptingParent(ctx, parentId),
fitEnclosingGroups: (parentIds: Iterable<string>) => fitEnclosingGroups(ctx, parentIds),
fitEnclosingGroups: (parentIds: Iterable<string>) => fitEnclosingGroupsWithUndo(ctx, parentIds),
...reorderActions,
reparentNodes,
wrapSelectionInContainer,

View file

@ -1,17 +1,14 @@
import type { LayoutMode, SceneNode } from '@open-pencil/scene-graph'
import { getAxisAlignedBoundsInParent } from '@open-pencil/scene-graph/coordinate'
import { prepareSlotEdits } from '#core/editor/components/slots'
import { sharedParentId } from '#core/editor/structure/container-wrap'
import { wrapParentId } from '#core/editor/structure/container-wrap'
import type { EditorContext } from '#core/editor/types'
import { computeLayout } from '#core/layout'
/** Wrap sibling layers in a new auto layout frame; returns the frame, or null if they are not siblings. */
export function wrapInAutoLayout(ctx: EditorContext, selectedNodes: SceneNode[]): string | null {
const parentId = sharedParentId(ctx, selectedNodes)
const parentId = wrapParentId(ctx, selectedNodes)
if (!parentId) return null
// The locked part of an instance takes no new containers.
if (!prepareSlotEdits(ctx, [parentId])) return null
const prevSelection = new Set(ctx.state.selectedIds)
const origPositions = selectedNodes.map((n) => ({ id: n.id, x: n.x, y: n.y, parentId }))

View file

@ -1,15 +1,50 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import { getAxisAlignedBoundsInParent } from '@open-pencil/scene-graph/coordinate'
import { copyFills, copyStrokes } from '@open-pencil/scene-graph/copy'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { copyFills } from '@open-pencil/scene-graph/copy'
import { canMakeBooleanSourceNode } from '#core/canvas/boolean'
import { restoreSubtree, snapshotSubtree } from '#core/editor/clipboard/subtree-history'
import { newLayerDefaults } from '#core/editor/shapes/defaults'
import type { EditorContext } from '#core/editor/types'
import { canvasWrapIndex, inStackOrder, wrapNodes } from './container-wrap'
import { selectedNodesInSharedParent } from './selection'
export type BooleanOperation = 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE'
/**
* Wraps sibling layers in a boolean operation named after it, as Figma names one from the canvas
* and from the plugin API. Shared by the editor command and the plugin API; `props` gives the look.
*/
export function createBooleanOperation(
graph: SceneGraph,
nodes: readonly SceneNode[],
parentId: string,
operation: BooleanOperation,
index: number | undefined,
props: Partial<SceneNode> = {}
): SceneNode {
return wrapNodes(graph, 'BOOLEAN_OPERATION', nodes, parentId, index, {
name: operationLabel(operation),
booleanOperation: operation,
...props
})
}
/**
* Paints of a new boolean operation, listed bottom to top. From the canvas Figma fills it like its
* topmost operand, or like the base for Subtract, without strokes; from the plugin API it gets the
* default shape grey.
*/
export function booleanOperationPaints(
operation: BooleanOperation,
nodes: readonly SceneNode[],
style: 'canvas' | 'script'
): Partial<SceneNode> {
if (style === 'script') return { fills: newLayerDefaults('RECTANGLE').fills, strokes: [] }
const source = operation === 'SUBTRACT' ? nodes.at(0) : nodes.at(-1)
return { fills: copyFills(source?.fills ?? []), strokes: [] }
}
export function booleanOperationSelected(
ctx: EditorContext,
selectedNodes: SceneNode[],
@ -17,42 +52,43 @@ export function booleanOperationSelected(
) {
const selection = selectedNodesInSharedParent(ctx, selectedNodes)
if (!selection || selection.topLevel.length < 2) return null
const { topLevel, parentId, parent } = selection
if (topLevel.some((node) => !canMakeBooleanSourceNode(node, ctx.graph))) return null
const { parentId, parent } = selection
if (selection.topLevel.some((node) => !canMakeBooleanSourceNode(node, ctx.graph))) return null
const operands = inStackOrder(ctx.graph, selection.topLevel, parentId)
const prevSelection = new Set(ctx.state.selectedIds)
const childIds = topLevel.map((node) => node.id)
const childSnapshots = childIds.map((id) => ({ id, subtree: snapshotSubtree(ctx.graph, id) }))
const origPositions = topLevel.map((node) => ({ id: node.id, x: node.x, y: node.y }))
const firstIndex = Math.min(...childIds.map((id) => parent.childIds.indexOf(id)))
const bounds = getAxisAlignedBoundsInParent(topLevel, parentId, ctx.graph)
const booleanNode = ctx.graph.createNode('BOOLEAN_OPERATION', parentId, {
name: operationLabel(operation),
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
fills: copyFills(topLevel[0].fills),
strokes: copyStrokes(topLevel[0].strokes),
booleanOperation: operation
})
const childSnapshots = operands.map((node) => ({
id: node.id,
subtree: snapshotSubtree(ctx.graph, node.id)
}))
const origPositions = operands.map((node) => ({
id: node.id,
x: node.x,
y: node.y,
index: parent.childIds.indexOf(node.id)
}))
const index = canvasWrapIndex(parent, operands)
const booleanNode = createBooleanOperation(
ctx.graph,
operands,
parentId,
operation,
index,
booleanOperationPaints(operation, operands, 'canvas')
)
const booleanId = booleanNode.id
ctx.graph.insertChildAt(booleanId, parentId, firstIndex)
for (const id of childIds) ctx.graph.reparentNode(id, booleanId)
ctx.setSelectedIds(new Set([booleanId]))
ctx.undo.push({
label: operationLabel(operation),
forward: () => {
const restored = ctx.graph.createNode('BOOLEAN_OPERATION', parentId, {
const nodes = origPositions.flatMap((pos) => ctx.graph.getNode(pos.id) ?? [])
createBooleanOperation(ctx.graph, nodes, parentId, operation, index, {
...booleanNode,
childIds: [],
id: booleanId
})
ctx.graph.insertChildAt(restored.id, parentId, firstIndex)
for (const id of childIds) ctx.graph.reparentNode(id, restored.id)
ctx.setSelectedIds(new Set([restored.id]))
ctx.setSelectedIds(new Set([booleanId]))
},
inverse: () => {
for (const { id, subtree } of childSnapshots) {
@ -61,13 +97,12 @@ export function booleanOperationSelected(
if (!ctx.graph.getNode(id)) restoreSubtree(ctx.graph, root, parentId, subtree)
else ctx.graph.reparentNode(id, parentId)
}
for (let i = 0; i < childIds.length; i++) {
const id = childIds[i]
const pos = origPositions[i]
ctx.graph.insertChildAt(id, parentId, firstIndex + i)
ctx.graph.updateNode(id, { x: pos.x, y: pos.y })
}
ctx.graph.deleteNode(booleanId)
// Back to their own places in the stack, lowest first so each index is still free.
for (const pos of origPositions) {
ctx.graph.insertChildAt(pos.id, parentId, pos.index)
ctx.graph.updateNode(pos.id, { x: pos.x, y: pos.y })
}
ctx.setSelectedIds(prevSelection)
}
})

View file

@ -1,87 +1,119 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { getAxisAlignedBoundsInParent } from '@open-pencil/scene-graph/coordinate'
import { prepareSlotEdits } from '#core/editor/components/slots'
import type { EditorContext } from '#core/editor/types'
/** The parent all these layers share, or null when they are not siblings. */
export function sharedParentId(ctx: EditorContext, nodes: readonly SceneNode[]): string | null {
export type WrapContainerType =
| 'GROUP'
| 'FRAME'
| 'COMPONENT'
| 'COMPONENT_SET'
| 'BOOLEAN_OPERATION'
const CONTAINER_NAMES: Record<WrapContainerType, string> = {
BOOLEAN_OPERATION: 'Boolean',
COMPONENT_SET: 'Component Set',
COMPONENT: 'Component',
GROUP: 'Group',
FRAME: 'Frame'
}
/**
* The parent these layers share when a new container may go there, or null when they are not
* siblings or sit in the locked part of an instance.
*/
export function wrapParentId(ctx: EditorContext, nodes: readonly SceneNode[]): string | null {
const first = nodes.at(0)
if (!first) return null
const parentId = first.parentId ?? ctx.state.currentPageId
return nodes.every((node) => (node.parentId ?? ctx.state.currentPageId) === parentId)
? parentId
: null
if (nodes.some((node) => (node.parentId ?? ctx.state.currentPageId) !== parentId)) return null
return prepareSlotEdits(ctx, [parentId]) ? parentId : null
}
/**
* Wraps sibling layers in a new container that spans them, keeping them where they are on the
* canvas. The container goes to `index` among the parent's remaining children, or on top when
* `index` is omitted. The editor's wrap commands and the plugin API's `group`, boolean
* operations, and `createComponentFromNode` all wrap through here; `props` gives the look.
*/
export function wrapNodes(
graph: SceneGraph,
type: WrapContainerType,
nodes: readonly SceneNode[],
parentId: string,
index: number | undefined,
props: Partial<SceneNode> = {}
): SceneNode {
const bounds = getAxisAlignedBoundsInParent(nodes, parentId, graph)
const container = graph.createNode(type, parentId, {
name: CONTAINER_NAMES[type],
...bounds,
fills: [],
...props
})
for (const node of inStackOrder(graph, nodes, parentId)) {
graph.reparentNode(node.id, container.id)
}
if (index !== undefined) graph.insertChildAt(container.id, parentId, index)
return container
}
/** Sibling layers bottom to top, whatever order they were selected in. */
export function inStackOrder(
graph: SceneGraph,
nodes: readonly SceneNode[],
parentId: string
): SceneNode[] {
const order = graph.getNode(parentId)?.childIds ?? []
return nodes.toSorted((a, b) => order.indexOf(a.id) - order.indexOf(b.id))
}
/**
* Where Figma's canvas commands put a container made from `nodes`: in the topmost one's place,
* counted among the children left once they move into it.
*/
export function canvasWrapIndex(parent: SceneNode, nodes: readonly SceneNode[]): number {
return Math.max(...nodes.map((node) => parent.childIds.indexOf(node.id))) - (nodes.length - 1)
}
export function wrapSelectionInContainer(
ctx: EditorContext,
containerType: 'GROUP' | 'FRAME' | 'COMPONENT' | 'COMPONENT_SET',
containerType: WrapContainerType,
selectedNodes: SceneNode[],
extraProps?: Partial<SceneNode>
) {
const parentId = sharedParentId(ctx, selectedNodes)
if (!parentId) return null
const parent = ctx.graph.getNode(parentId)
if (!parent) return null
// The locked part of an instance takes no new containers.
if (!prepareSlotEdits(ctx, [parentId])) return null
const parentId = wrapParentId(ctx, selectedNodes)
const parent = parentId ? ctx.graph.getNode(parentId) : undefined
if (!parentId || !parent) return null
const prevSelection = new Set(ctx.state.selectedIds)
const nodeIds = selectedNodes.map((n) => n.id)
const origPositions = selectedNodes.map((n) => ({ id: n.id, x: n.x, y: n.y }))
const origPositions = selectedNodes
.map((n) => ({ id: n.id, x: n.x, y: n.y, index: parent.childIds.indexOf(n.id) }))
.toSorted((a, b) => a.index - b.index)
const index = canvasWrapIndex(parent, selectedNodes)
const bounds = getAxisAlignedBoundsInParent(selectedNodes, parentId, ctx.graph)
const firstIndex = Math.min(...nodeIds.map((id) => parent.childIds.indexOf(id)))
const padding = containerType === 'COMPONENT_SET' ? 40 : 0
const containerNames: Record<string, string> = {
COMPONENT_SET: selectedNodes[0].name.split('/')[0]?.trim() || 'Component Set',
COMPONENT: 'Component',
GROUP: 'Group',
FRAME: 'Frame'
}
const containerNode = ctx.graph.createNode(containerType, parentId, {
name: containerNames[containerType] ?? containerType,
x: bounds.x - padding,
y: bounds.y - padding,
width: bounds.width + padding * 2,
height: bounds.height + padding * 2,
fills:
containerType === 'COMPONENT_SET'
? [
{
type: 'SOLID',
color: { r: 0.96, g: 0.96, b: 0.96, a: 1 },
opacity: 1,
visible: true
}
]
: [],
...extraProps
})
const containerNode = wrapNodes(
ctx.graph,
containerType,
selectedNodes,
parentId,
index,
extraProps
)
const containerId = containerNode.id
ctx.graph.insertChildAt(containerId, parentId, firstIndex)
for (const n of selectedNodes) {
ctx.graph.reparentNode(n.id, containerId)
}
ctx.setSelectedIds(new Set([containerId]))
ctx.undo.push({
label: `Create ${containerType.toLowerCase().replace('_', ' ')}`,
forward: () => {
const c = ctx.graph.createNode(containerType, parentId, {
const nodes = origPositions.flatMap((n) => ctx.graph.getNode(n.id) ?? [])
wrapNodes(ctx.graph, containerType, nodes, parentId, index, {
...containerNode,
...extraProps,
childIds: [],
id: containerId
})
ctx.graph.insertChildAt(c.id, parentId, firstIndex)
for (const n of origPositions) ctx.graph.reparentNode(n.id, c.id)
ctx.setSelectedIds(new Set([c.id]))
ctx.setSelectedIds(new Set([containerId]))
},
inverse: () => {
for (const orig of origPositions) {
@ -89,6 +121,8 @@ export function wrapSelectionInContainer(
ctx.graph.updateNode(orig.id, { x: orig.x, y: orig.y })
}
ctx.graph.deleteNode(containerId)
// Back to their own places in the stack, lowest first so each index is still free.
for (const orig of origPositions) ctx.graph.insertChildAt(orig.id, parentId, orig.index)
ctx.setSelectedIds(prevSelection)
}
})

View file

@ -1,103 +1,17 @@
import type { NodeType, SceneNode } from '@open-pencil/scene-graph'
import { getAxisAlignedBoundsInParent } from '@open-pencil/scene-graph/coordinate'
import { fitEnclosingGroups, redoGroupFit, undoGroupFit } from '@open-pencil/scene-graph'
import type { EditorContext } from '#core/editor/types'
/** Containers whose bounds follow their children, as in Figma. */
const FITTED_TYPES = new Set<NodeType>(['GROUP', 'BOOLEAN_OPERATION'])
type Placement = Pick<SceneNode, 'x' | 'y' | 'width' | 'height'>
function placement(node: SceneNode): Placement {
return { x: node.x, y: node.y, width: node.width, height: node.height }
}
/** Groups and booleans holding the parents, innermost first. */
function fittedAncestors(ctx: EditorContext, parentIds: Iterable<string>): SceneNode[] {
const found = new Map<string, { node: SceneNode; depth: number }>()
for (const parentId of parentIds) {
const chain: SceneNode[] = []
for (let node = ctx.graph.getNode(parentId); node && FITTED_TYPES.has(node.type);) {
chain.push(node)
node = ctx.graph.getNode(node.parentId ?? '')
}
for (const [index, node] of chain.entries()) {
found.set(node.id, {
node,
depth: Math.max(found.get(node.id)?.depth ?? 0, chain.length - index)
})
}
}
return [...found.values()].sort((a, b) => b.depth - a.depth).map(({ node }) => node)
}
interface FitRecord {
before: Map<string, Placement>
after: Map<string, Placement>
removed: Array<{ node: SceneNode; index: number }>
}
/** Moves the group to its children's bounds, shifting them back so they stay put. */
function fitGroup(ctx: EditorContext, group: SceneNode, children: SceneNode[], record: FitRecord) {
if (!group.parentId || group.rotation !== 0 || group.flipX || group.flipY) return
const bounds = getAxisAlignedBoundsInParent(children, group.parentId, ctx.graph)
const dx = bounds.x - group.x
const dy = bounds.y - group.y
if (dx === 0 && dy === 0 && bounds.width === group.width && bounds.height === group.height) {
return
}
for (const node of [group, ...children]) {
if (!record.before.has(node.id)) record.before.set(node.id, placement(node))
}
ctx.graph.updateNode(group.id, bounds)
for (const child of children) {
ctx.graph.updateNode(child.id, { x: child.x - dx, y: child.y - dy })
}
for (const node of [group, ...children]) {
const current = ctx.graph.getNode(node.id)
if (current) record.after.set(node.id, placement(current))
}
}
/** A group left without layers goes away; an empty boolean stays, as in Figma. */
function removeEmptyGroup(ctx: EditorContext, group: SceneNode, record: FitRecord) {
if (group.type !== 'GROUP' || !group.parentId) return
const index = ctx.graph.getNode(group.parentId)?.childIds.indexOf(group.id) ?? -1
record.removed.push({ node: structuredClone(group), index })
ctx.graph.deleteNode(group.id)
}
/**
* Refits the groups and booleans around these parents to their children after a move, keeping
* every child where it is on the canvas. Rotated or flipped containers keep their bounds.
* Refits the groups and booleans around these parents after a move, as one undo step. The fit
* itself is shared with the plugin API; see `fitEnclosingGroups` in Scene Graph.
*/
export function fitEnclosingGroups(ctx: EditorContext, parentIds: Iterable<string>) {
const record: FitRecord = { before: new Map(), after: new Map(), removed: [] }
for (const group of fittedAncestors(ctx, parentIds)) {
const children = ctx.graph.getChildren(group.id)
if (children.length === 0) removeEmptyGroup(ctx, group, record)
else fitGroup(ctx, group, children, record)
}
const { before, after, removed } = record
if (before.size === 0 && removed.length === 0) return
const apply = (placements: Map<string, Placement>) => {
for (const [id, value] of placements) ctx.graph.updateNode(id, value)
}
export function fitEnclosingGroupsWithUndo(ctx: EditorContext, parentIds: Iterable<string>) {
const fit = fitEnclosingGroups(ctx.graph, parentIds)
if (!fit) return
ctx.undo.push({
label: 'Fit groups',
forward: () => {
apply(after)
for (const { node } of removed) ctx.graph.deleteNode(node.id)
},
inverse: () => {
// Outer groups were removed after inner ones, so they come back first.
for (const { node, index } of removed.toReversed()) {
const parentId = node.parentId ?? ctx.state.currentPageId
ctx.graph.createNode(node.type, parentId, { ...structuredClone(node), childIds: [] })
if (index >= 0) ctx.graph.insertChildAt(node.id, parentId, index)
}
apply(before)
}
forward: () => redoGroupFit(ctx.graph, fit),
inverse: () => undoGroupFit(ctx.graph, fit)
})
}

View file

@ -1,7 +1,26 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { EditorContext } from '#core/editor/types'
/**
* Moves a container's children into its parent, in the container's place in the stack, and
* deletes the container. Returns the children, or null when the container has no parent. Shared
* by the editor's Ungroup command and the plugin API's `ungroup`.
*/
export function ungroupNode(graph: SceneGraph, nodeId: string): string[] | null {
const node = graph.getNode(nodeId)
const parent = node?.parentId ? graph.getNode(node.parentId) : undefined
if (!node || !parent) return null
const index = parent.childIds.indexOf(node.id)
const childIds = [...node.childIds]
childIds.forEach((id, i) => {
graph.reparentNode(id, parent.id)
graph.insertChildAt(id, parent.id, index + i)
})
graph.deleteNode(node.id)
return childIds
}
export function ungroupSelected(ctx: EditorContext, selectedNode: SceneNode | undefined) {
if (selectedNode?.type !== 'GROUP') return
@ -11,9 +30,8 @@ export function ungroupSelected(ctx: EditorContext, selectedNode: SceneNode | un
if (!parent) return
const groupIndex = parent.childIds.indexOf(node.id)
const childIds = [...node.childIds]
const prevSelection = new Set(ctx.state.selectedIds)
const origPositions = childIds.map((id) => {
const origPositions = node.childIds.map((id) => {
const child = ctx.graph.getNode(id)
if (!child) return { id, x: 0, y: 0 }
return { id, x: child.x, y: child.y }
@ -21,24 +39,15 @@ export function ungroupSelected(ctx: EditorContext, selectedNode: SceneNode | un
const groupId = node.id
const groupSnapshot = { ...node, childIds: [...node.childIds] }
for (let i = 0; i < childIds.length; i++) {
ctx.graph.reparentNode(childIds[i], parentId)
ctx.graph.insertChildAt(childIds[i], parentId, groupIndex + i)
const ungroup = () => {
const childIds = ungroupNode(ctx.graph, groupId)
if (childIds) ctx.setSelectedIds(new Set(childIds))
}
ctx.graph.deleteNode(node.id)
ctx.setSelectedIds(new Set(childIds))
ungroup()
ctx.undo.push({
label: 'Ungroup',
forward: () => {
for (let i = 0; i < childIds.length; i++) {
ctx.graph.reparentNode(childIds[i], parentId)
ctx.graph.insertChildAt(childIds[i], parentId, groupIndex + i)
}
ctx.graph.deleteNode(groupId)
ctx.setSelectedIds(new Set(childIds))
},
forward: ungroup,
inverse: () => {
const g = ctx.graph.createNode('GROUP', parentId, {
...groupSnapshot,

View file

@ -1,4 +1,8 @@
import { recordInstanceOverride } from '@open-pencil/scene-graph'
import {
fitEnclosingGroups,
FITTED_CONTAINER_TYPES,
recordInstanceOverride
} from '@open-pencil/scene-graph'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { assertNodeEditable } from '#core/editor/capabilities'
@ -30,6 +34,23 @@ export function assertProxyEditable(target: ProxyThis, internals: NodeProxyInter
assertNodeEditable(graph(target, internals), nodeId(target, internals))
}
/** Fields whose change can move a node's bounds, so the groups around it refit. */
const GEOMETRY_FIELDS: ReadonlySet<string> = new Set([
'x',
'y',
'width',
'height',
'rotation',
'flipX',
'flipY'
])
/** Refits the groups around a node's parent, as Figma does after a script changes it. */
export function fitGroupsAround(graph: SceneGraph, parentId: string | null | undefined): void {
const parent = parentId ? graph.getNode(parentId) : undefined
if (parent && FITTED_CONTAINER_TYPES.has(parent.type)) fitEnclosingGroups(graph, [parent.id])
}
export function updateNode(
target: ProxyThis,
internals: NodeProxyInternals,
@ -46,4 +67,7 @@ export function updateNode(
if (Object.keys(applied).length === 0) return
g.updateNode(id, applied)
recordInstanceOverride(g, id, Object.keys(applied))
if (Object.keys(applied).some((key) => GEOMETRY_FIELDS.has(key))) {
fitGroupsAround(g, g.getNode(id)?.parentId)
}
}

View file

@ -1,16 +1,21 @@
import {
getNodeLocalMatrix,
getParentToContainerMatrix,
getWorldMatrix,
FITTED_CONTAINER_TYPES,
TRANSFORM_FIELDS as NODE_TRANSFORM_FIELDS,
findInstanceAncestor,
rescaleNodeTree,
slotPropertyId,
type SceneGraph,
type SceneNode
} from '@open-pencil/scene-graph'
import type { Rect } from '@open-pencil/scene-graph/primitives'
import Matrix from '@open-pencil/scene-graph/matrix'
import type { Rect, Vector } from '@open-pencil/scene-graph/primitives'
import { assertNodeEditable } from '#core/editor/capabilities'
import {
fitGroupsAround,
graph,
nodeId,
raw,
@ -49,6 +54,37 @@ function figmaTransform(matrix: number[]): FigmaTransform {
]
}
function inGroup(node: SceneNode, scene: SceneGraph): boolean {
const parent = node.parentId ? scene.getNode(node.parentId) : undefined
return parent !== undefined && FITTED_CONTAINER_TYPES.has(parent.type)
}
/** Where Figma's plugin API places a node: in its container's space, looking through groups. */
function containerPosition(node: SceneNode, scene: SceneGraph): Vector {
const [x, y] = Matrix.mapPoints(getParentToContainerMatrix(node, scene), [node.x, node.y])
return { x, y }
}
function setPosition(
target: ProxyThis,
internals: NodeProxyInternals,
axis: 'x' | 'y',
value: number
) {
assertEditable(target, internals)
const scene = graph(target, internals)
const node = raw(target, internals)
if (!inGroup(node, scene)) {
scene.updateNode(node.id, { [axis]: value })
return
}
const desired = { ...containerPosition(node, scene), [axis]: value }
const toParent = Matrix.invert(getParentToContainerMatrix(node, scene)) ?? Matrix.identity()
const [x, y] = Matrix.mapPoints(toParent, [desired.x, desired.y])
scene.updateNode(node.id, { x, y })
fitGroupsAround(scene, node.parentId)
}
export function installBasicNodeProxyAccessors(
prototype: object,
internals: NodeProxyInternals
@ -80,20 +116,18 @@ export function installBasicNodeProxyAccessors(
},
x: {
get(this: ProxyThis): number {
return raw(this, internals).x
return containerPosition(raw(this, internals), graph(this, internals)).x
},
set(this: ProxyThis, value: number) {
assertEditable(this, internals)
graph(this, internals).updateNode(nodeId(this, internals), { x: value })
setPosition(this, internals, 'x', value)
}
},
y: {
get(this: ProxyThis): number {
return raw(this, internals).y
return containerPosition(raw(this, internals), graph(this, internals)).y
},
set(this: ProxyThis, value: number) {
assertEditable(this, internals)
graph(this, internals).updateNode(nodeId(this, internals), { y: value })
setPosition(this, internals, 'y', value)
}
},
width: {
@ -117,12 +151,21 @@ export function installBasicNodeProxyAccessors(
},
set(this: ProxyThis, value: number) {
assertEditable(this, internals)
graph(this, internals).updateNode(nodeId(this, internals), { rotation: value })
const scene = graph(this, internals)
scene.updateNode(nodeId(this, internals), { rotation: value })
fitGroupsAround(scene, raw(this, internals).parentId)
}
},
relativeTransform: {
get(this: ProxyThis): FigmaTransform {
const node = raw(this, internals)
const scene = graph(this, internals)
// Children of groups report a transform into the container, as Figma does.
if (inGroup(node, scene)) {
return figmaTransform(
Matrix.multiply(getParentToContainerMatrix(node, scene), getNodeLocalMatrix(node))
)
}
const sourceTransform = node.source.fig.rawTransform
if (sourceTransform && preservesRawTransform(node)) {
return figmaTransform([

View file

@ -1,5 +1,6 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { DEFAULT_STROKE_WEIGHT } from '#core/constants'
import {
nodeId,
raw,
@ -41,8 +42,8 @@ export function installStrokeNodeProxyAccessors(
Object.defineProperties(prototype, {
strokeWeight: {
get(this: ProxyThis): number {
const strokes = raw(this, internals).strokes
return strokes.length > 0 ? strokes[0].weight : 0
// Figma reports its default weight of 1 for a node without strokes.
return raw(this, internals).strokes[0]?.weight ?? DEFAULT_STROKE_WEIGHT
},
set(this: ProxyThis, value: number) {
setFirstStrokeWeight(graph(this, internals), raw(this, internals), value)

View file

@ -2,6 +2,7 @@ import type { Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
import { normalizeColor } from '@open-pencil/scene-graph/color'
import { copyFills, copyStrokes } from '@open-pencil/scene-graph/copy'
import { DEFAULT_STROKE_WEIGHT } from '#core/constants'
import {
raw,
updateNode,
@ -57,11 +58,14 @@ export function installVisualNodeProxyAccessors(
get(this: ProxyThis): readonly Stroke[] {
return Object.freeze(copyStrokes(raw(this, internals).strokes))
},
set(this: ProxyThis, value: readonly Stroke[]) {
// Figma paints carry no weight; a stroke takes the node's weight, 1 until one is set.
set(this: ProxyThis, value: readonly (Omit<Stroke, 'weight'> & { weight?: number })[]) {
const weight = raw(this, internals).strokes[0]?.weight ?? DEFAULT_STROKE_WEIGHT
updateNode(this, internals, {
strokes: value.map((stroke) => ({
...stroke,
...paintDefaults(stroke),
weight: stroke.weight ?? weight,
color: normalizeColor(stroke.color)
}))
})

View file

@ -13,8 +13,11 @@ import {
removeComponentProperty,
createComponentPropertyId
} from '@open-pencil/scene-graph'
import { computeAbsoluteBounds } from '@open-pencil/scene-graph/geometry'
import { deriveSlashVariantProperties } from '@open-pencil/scene-graph/variant-properties'
import { cloneNodeProps } from '@open-pencil/scene-graph/copy'
import { becomesComponent, componentWrapProps } from '#core/editor/components/create'
import { applyVariantProperties, variantSetProps } from '#core/editor/components/variant-set'
import { wrapNodes } from '#core/editor/structure/container-wrap'
import type { NodeProxyInternals, ProxyThis } from './accessor-utils'
import { graph, raw, updateNode } from './accessor-utils'
@ -28,8 +31,6 @@ import {
type InstanceSwapPreferredValue = { type: 'COMPONENT' | 'COMPONENT_SET'; key: string }
const COMPONENT_SET_PADDING = 40
interface FigmaComponentPropertyDefinition {
type: ComponentPropertyType
defaultValue: string | boolean
@ -508,35 +509,43 @@ export function combineComponentsAsVariants(
const parent = graph.getNode(parentId)
if (!parent) throw new Error('Parent node not found')
const bounds = computeAbsoluteBounds(components, (id) => graph.getAbsolutePosition(id))
const parentPosition =
parentId === graph.rootId || parent.type === 'CANVAS'
? { x: 0, y: 0 }
: graph.getAbsolutePosition(parentId)
const componentSet = graph.createNode('COMPONENT_SET', parentId, {
name: components[0].name.split('/')[0]?.trim() || 'Component Set',
x: bounds.x - parentPosition.x - COMPONENT_SET_PADDING,
y: bounds.y - parentPosition.y - COMPONENT_SET_PADDING,
width: bounds.width + COMPONENT_SET_PADDING * 2,
height: bounds.height + COMPONENT_SET_PADDING * 2,
fills: [
{
type: 'SOLID',
color: { r: 0.96, g: 0.96, b: 0.96, a: 1 },
opacity: 1,
visible: true
}
]
})
for (const component of components) graph.reparentNode(component.id, componentSet.id)
if (index !== undefined) graph.reorderChild(componentSet.id, parentId, index)
const derived = deriveSlashVariantProperties(components, createComponentPropertyId)
if (derived) {
for (const [nodeId, changes] of derived.variants) graph.updateNode(nodeId, changes)
graph.updateNode(componentSet.id, { componentPropertyDefinitions: derived.definitions })
}
// The plugin API wraps the variants exactly; see `variantSetProps`.
const componentSet = wrapNodes(
graph,
'COMPONENT_SET',
components,
parentId,
index,
variantSetProps(graph, components, parentId, 'script')
)
applyVariantProperties(graph, components, componentSet.id)
return componentSet
}
/**
* Makes a component from a layer as Figma's `createComponentFromNode` does: a frame or group
* becomes a new component with its look and children in its place in the stack, and any other
* layer is wrapped; see `becomesComponent`. Unlike the canvas command, the component takes a new id.
*/
export function componentFromNode(graph: SceneGraph, node: SceneNode, parentId: string): SceneNode {
const index = graph.getNode(parentId)?.childIds.indexOf(node.id) ?? -1
if (!becomesComponent(node)) {
return wrapNodes(
graph,
'COMPONENT',
[node],
parentId,
index < 0 ? undefined : index,
componentWrapProps([node])
)
}
const component = graph.createNode('COMPONENT', parentId, {
...cloneNodeProps(node, null),
type: 'COMPONENT'
})
for (const childId of node.childIds) graph.reparentNode(childId, component.id)
if (index >= 0) graph.insertChildAt(component.id, parentId, index)
graph.deleteNode(node.id)
return component
}

View file

@ -9,8 +9,7 @@ import type {
VariableType,
VariableValue
} from '@open-pencil/scene-graph'
import { getAxisAlignedBoundsInParent } from '@open-pencil/scene-graph/coordinate'
import { copyFills, copyStrokes, copyEffects } from '@open-pencil/scene-graph/copy'
import { copyFills } from '@open-pencil/scene-graph/copy'
import { computeBounds } from '@open-pencil/scene-graph/geometry'
import { computeImageHash } from '@open-pencil/scene-graph/images'
import type { Rect, Vector } from '@open-pencil/scene-graph/primitives'
@ -19,11 +18,15 @@ import type { SkiaRenderer } from '#core/canvas'
import { canMakeBooleanSourceNode } from '#core/canvas/boolean'
import { flattenNodesToVectorProps } from '#core/canvas/flatten'
import { IS_BROWSER } from '#core/constants'
import { newLayerDefaults } from '#core/editor/shapes/defaults'
import { booleanOperationPaints, createBooleanOperation } from '#core/editor/structure/boolean'
import { wrapNodes } from '#core/editor/structure/container-wrap'
import { ungroupNode } from '#core/editor/structure/group'
import type { RasterCodec } from '#core/io/formats/raster'
import { reconcileVariableLayouts } from '#core/layout/variables'
import { documentFontStatus, type DocumentFontStatus } from '#core/text/font/status'
import { combineComponentsAsVariants, exposeInstanceSwap } from './components'
import { combineComponentsAsVariants, componentFromNode, exposeInstanceSwap } from './components'
import type {
FigmaBooleanOperationNode,
FigmaComponentNode,
@ -151,8 +154,12 @@ export class FigmaAPI implements NodeProxyHost {
// --- Node Creation ---
/** New layers start as the editor's tools make them, which is how Figma's plugin API makes them. */
private _createNode(type: NodeType): FigmaNodeProxy {
const node = this.graph.createNode(type, this._currentPageId)
const defaults = newLayerDefaults(type)
// Figma's plugin API makes a line 100 wide with no height.
if (type === 'LINE') defaults.height = 0
const node = this.graph.createNode(type, this._currentPageId, defaults)
return this.wrapNode(node.id)
}
@ -227,74 +234,21 @@ export class FigmaAPI implements NodeProxyHost {
): FigmaGroupNode {
const parentId = this._nodeId(parent)
const members = nodes.map((node) => this._rawNode(node))
const groupNode = this.graph.createNode(
'GROUP',
parentId,
members.length > 0 ? getAxisAlignedBoundsInParent(members, parentId, this.graph) : undefined
)
for (const n of nodes) {
this.graph.reparentNode(this._nodeId(n), groupNode.id)
}
if (index != null) this.graph.reorderChild(groupNode.id, parentId, index)
const groupNode = wrapNodes(this.graph, 'GROUP', members, parentId, index)
return this.wrapNode(groupNode.id) as FigmaGroupNode
}
ungroup(node: FigmaNodeProxy): FigmaNodeProxy[]
ungroup(node: SceneNode & ChildrenMixin): Array<SceneNode>
ungroup(node: (SceneNode & ChildrenMixin) | FigmaNodeProxy): Array<SceneNode> | FigmaNodeProxy[] {
const nodeId = this._nodeId(node)
const raw = this.graph.getNode(nodeId)
if (!raw || raw.childIds.length === 0) return []
const parentId = raw.parentId ?? this._currentPageId
const children = Array.from(raw.childIds)
for (const childId of children) {
this.graph.reparentNode(childId, parentId)
}
this.graph.deleteNode(nodeId)
const children = ungroupNode(this.graph, this._nodeId(node)) ?? []
return children.map((id) => this.wrapNode(id))
}
createComponentFromNode(node: FigmaNodeProxy): FigmaNodeProxy {
const raw = this.graph.getNode(node[INTERNAL_ID])
if (!raw) throw new Error('Node not found')
const parentId = raw.parentId ?? this._currentPageId
const comp = this.graph.createNode('COMPONENT', parentId)
this.graph.updateNode(comp.id, {
name: raw.name,
width: raw.width,
height: raw.height,
x: raw.x,
y: raw.y,
fills: copyFills(raw.fills),
strokes: copyStrokes(raw.strokes),
effects: copyEffects(raw.effects),
cornerRadius: raw.cornerRadius,
topLeftRadius: raw.topLeftRadius,
topRightRadius: raw.topRightRadius,
bottomRightRadius: raw.bottomRightRadius,
bottomLeftRadius: raw.bottomLeftRadius,
independentCorners: raw.independentCorners,
opacity: raw.opacity,
layoutMode: raw.layoutMode,
primaryAxisAlign: raw.primaryAxisAlign,
counterAxisAlign: raw.counterAxisAlign,
primaryAxisSizing: raw.primaryAxisSizing,
counterAxisSizing: raw.counterAxisSizing,
itemSpacing: raw.itemSpacing,
paddingTop: raw.paddingTop,
paddingRight: raw.paddingRight,
paddingBottom: raw.paddingBottom,
paddingLeft: raw.paddingLeft,
pluginData: structuredClone(raw.pluginData),
pluginRelaunchData: structuredClone(raw.pluginRelaunchData),
boundVariables: { ...raw.boundVariables },
variableModes: { ...raw.variableModes }
})
for (const childId of raw.childIds) {
this.graph.cloneTree(childId, comp.id)
}
this.graph.deleteNode(node[INTERNAL_ID])
return this.wrapNode(comp.id)
return this.wrapNode(componentFromNode(this.graph, raw, raw.parentId ?? this._currentPageId).id)
}
combineAsVariants(
@ -399,15 +353,14 @@ export class FigmaAPI implements NodeProxyHost {
if (nodes.length < 2) throw new Error('Need at least 2 nodes for boolean operation')
const parentId = this._nodeId(parent)
const members = nodes.map((node) => this._rawNode(node))
const group = this.graph.createNode('BOOLEAN_OPERATION', parentId, {
name: `Boolean ${operation.toLowerCase()}`,
...getAxisAlignedBoundsInParent(members, parentId, this.graph),
booleanOperation: operation
})
for (const node of nodes) {
this.graph.reparentNode(this._nodeId(node), group.id)
}
if (index != null) this.graph.reorderChild(group.id, parentId, index)
const group = createBooleanOperation(
this.graph,
members,
parentId,
operation,
index,
booleanOperationPaints(operation, members, 'script')
)
return this.wrapNode(group.id) as FigmaBooleanOperationNode
}

View file

@ -19,6 +19,7 @@ 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'
@ -286,14 +287,23 @@ export class FigmaNodeProxy {
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[INTERNAL_GRAPH].reparentNode(child[INTERNAL_ID], this[INTERNAL_ID])
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[INTERNAL_GRAPH].reparentNode(child[INTERNAL_ID], this[INTERNAL_ID])
this._reparentAndFit(child[INTERNAL_ID])
this[INTERNAL_GRAPH].reorderChild(child[INTERNAL_ID], this[INTERNAL_ID], index)
}
@ -316,7 +326,9 @@ export class FigmaNodeProxy {
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[] {

View file

@ -57,6 +57,15 @@ function hasRenderableGeometry(node: SceneNode): boolean {
if (INTRINSIC_CONTAINER_TYPES.has(node.type)) return true
if (node.type === 'GROUP') return false
if (node.type === 'TEXT') return node.text.length > 0 && node.fills.some((fill) => fill.visible)
// A vector without a path draws nothing, whatever its paints.
if (
node.type === 'VECTOR' &&
!node.vectorNetwork?.vertices.length &&
node.fillGeometry.length === 0 &&
node.strokeGeometry.length === 0
) {
return false
}
if (node.fills.some((fill) => fill.visible) || node.strokes.some((stroke) => stroke.visible)) {
return true
}

View file

@ -0,0 +1,67 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import { FigmaAPI } from '@open-pencil/core/figma-api'
// Recorded in Figma desktop 126: Combine as variants from the canvas pads the set and outlines it;
// the plugin API's combineAsVariants wraps the components exactly.
function components(editor: ReturnType<typeof createEditor>) {
const page = editor.state.currentPageId
const a = editor.graph.createNode('COMPONENT', page, { name: 'v=a', x: 200, width: 60, height: 60 })
const b = editor.graph.createNode('COMPONENT', page, {
name: 'v=b',
x: 300,
y: 50,
width: 60,
height: 60
})
return [a, b]
}
describe('combine as variants', () => {
test('the canvas command pads the set by 20 and outlines it with a dashed purple stroke', () => {
const editor = createEditor()
try {
const [a, b] = components(editor)
editor.select([a.id, b.id])
editor.createComponentSetFromComponents()
const set = editor.graph.getNode(a.parentId ?? '')
expect(set).toMatchObject({ type: 'COMPONENT_SET', x: 180, y: -20, width: 200, height: 150 })
expect(set?.fills).toEqual([])
expect(set?.cornerRadius).toBe(5)
expect(set?.strokes[0]).toMatchObject({ weight: 1, align: 'INSIDE', dashPattern: [10, 5] })
expect(editor.graph.getNode(a.id)).toMatchObject({ x: 20, y: 20 })
expect(editor.graph.getNode(b.id)).toMatchObject({ x: 120, y: 70 })
} finally {
editor.dispose()
}
})
test('the plugin API wraps the components exactly', () => {
const editor = createEditor()
try {
const figma = new FigmaAPI(editor.graph)
figma.currentPage = figma.wrapNode(editor.state.currentPageId)
const a = figma.createComponent()
a.name = 'v=a'
a.resize(60, 60)
a.x = 200
const b = figma.createComponent()
b.name = 'v=b'
b.resize(60, 60)
b.x = 300
b.y = 50
const set = figma.combineAsVariants([a, b], figma.currentPage)
expect([set.x, set.y, set.width, set.height]).toEqual([200, 0, 160, 110])
expect(set.fills).toEqual([])
expect(set.strokes).toEqual([])
expect(set.children.map((child) => [child.x, child.y])).toEqual([
[0, 0],
[100, 50]
])
} finally {
editor.dispose()
}
})
})

View file

@ -0,0 +1,54 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
// Recorded in Figma desktop 126 with Create component from the canvas.
const WHITE = { r: 1, g: 1, b: 1, a: 1 }
function setup() {
const editor = createEditor()
const pageId = editor.state.currentPageId
const [a, b, c, d] = ['a', 'b', 'c', 'd'].map((name, i) =>
editor.graph.createNode('RECTANGLE', pageId, { name, x: i * 100, width: 50, height: 50 })
)
const order = () => editor.graph.getChildren(pageId).map((node) => node.name)
const created = () => editor.graph.getNode([...editor.state.selectedIds][0] ?? '')
return { editor, a, b, c, d, order, created }
}
describe('createComponentFromSelection', () => {
test('wraps one layer in a white component named after it, in its place', () => {
const { editor, b, order, created } = setup()
editor.select([b.id])
editor.createComponentFromSelection()
const component = created()
expect(component?.type).toBe('COMPONENT')
expect(component?.name).toBe('b')
expect(component?.fills.map((fill) => fill.color)).toEqual([WHITE])
expect(component?.childIds).toEqual([b.id])
expect(order()).toEqual(['a', 'b', 'c', 'd'])
})
test('wraps several layers in a white component in the topmost one’s place', () => {
const { editor, a, c, order, created } = setup()
editor.select([a.id, c.id])
editor.createComponentFromSelection()
const component = created()
expect(component?.name).toBe('Component')
expect(component?.fills.map((fill) => fill.color)).toEqual([WHITE])
expect(order()).toEqual(['b', 'Component', 'd'])
})
test('turns a group into the component in place, keeping its look', () => {
const { editor, b, c, order, created } = setup()
editor.select([b.id, c.id])
editor.groupSelected()
editor.createComponentFromSelection()
const component = created()
expect(component?.type).toBe('COMPONENT')
expect(component?.fills).toEqual([])
expect(component?.childIds).toEqual([b.id, c.id])
expect(order()).toEqual(['a', 'Group', 'd'])
})
})

View file

@ -118,6 +118,36 @@ describe('booleanOperationSelected', () => {
expect(editor.graph.getNode(booleanId)?.booleanOperation).toBe('EXCLUDE')
expect(editor.state.selectedIds).toEqual(new Set([booleanId]))
})
// Recorded in Figma desktop 126 with Union, Subtract, Intersect, and Exclude from the canvas.
test('takes the fill of the topmost operand, or the base for Subtract, and no strokes', () => {
const solid = (r: number, g: number, b: number) => [
{ type: 'SOLID' as const, color: { r, g, b, a: 1 }, opacity: 1, visible: true }
]
const operands = (editor: ReturnType<typeof createEditor>) => {
const pageId = editor.state.currentPageId
const base = editor.graph.createNode('RECTANGLE', pageId, { fills: solid(0, 1, 0) })
const top = editor.graph.createNode('RECTANGLE', pageId, {
x: 20,
fills: solid(0, 0, 1),
strokes: [{ ...solid(0, 0, 0)[0], weight: 1, align: 'CENTER' }]
})
// Selected top first: the stack, not the selection order, decides.
editor.select([top.id, base.id])
return { base, top }
}
for (const operation of ['UNION', 'SUBTRACT', 'INTERSECT', 'EXCLUDE'] as const) {
const editor = createEditor()
const { base, top } = operands(editor)
editor.booleanOperationSelected(operation)
const [booleanId] = [...editor.state.selectedIds]
const booleanNode = editor.graph.getNode(booleanId)
const source = operation === 'SUBTRACT' ? base : top
expect(booleanNode?.fills).toEqual(source.fills)
expect(booleanNode?.strokes).toEqual([])
expect(booleanNode?.childIds).toEqual([base.id, top.id])
}
})
})
describe('container placement inside a rotated frame', () => {

View file

@ -1,6 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { DEFAULT_FRAME_FILL } from '@open-pencil/core'
import { createEditor } from '@open-pencil/core/editor'
describe('frameSelection', () => {
@ -35,7 +34,9 @@ describe('frameSelection', () => {
expect(frame?.y).toBe(30)
expect(frame?.width).toBe(110)
expect(frame?.height).toBe(110)
expect(frame?.fills).toEqual([DEFAULT_FRAME_FILL])
// Recorded in Figma desktop 126: Frame selection adds no fill and does not clip.
expect(frame?.fills).toEqual([])
expect(frame?.clipsContent).toBe(false)
expect(first.parentId).toBe(frameId)
expect(second.parentId).toBe(frameId)
expect(editor.graph.getAbsolutePosition(first.id)).toEqual(beforeFirst)
@ -72,7 +73,7 @@ describe('frameSelection', () => {
const frame = editor.graph.getNode(frameId)
expect(frame?.type).toBe('FRAME')
expect(frame?.fills).toEqual([DEFAULT_FRAME_FILL])
expect(frame?.fills).toEqual([])
expect(editor.graph.getNode(first.id)?.parentId).toBe(frameId)
expect(editor.graph.getNode(second.id)?.parentId).toBe(frameId)
expect(editor.state.selectedIds).toEqual(new Set([frameId]))

View file

@ -51,4 +51,26 @@ describe('z-order actions', () => {
editor.redoAction()
expect(childNames(editor, pageId)).toEqual(['A', 'C', 'B', 'D'])
})
test('grouping takes the topmost layer’s place, and ungrouping and undo keep the stack', () => {
// Recorded in Figma desktop 126: grouping A and C of A, B, C, D gives B, Group, D.
const { editor, pageId, nodes } = setupEditor()
editor.select([nodes[2]?.id ?? '', nodes[0]?.id ?? ''])
editor.groupSelected()
expect(childNames(editor, pageId)).toEqual(['B', 'Group', 'D'])
const [groupId] = [...editor.state.selectedIds]
expect(editor.graph.getChildren(groupId ?? '').map((node) => node.name)).toEqual(['A', 'C'])
editor.undoAction()
expect(childNames(editor, pageId)).toEqual(['A', 'B', 'C', 'D'])
editor.redoAction()
expect(childNames(editor, pageId)).toEqual(['B', 'Group', 'D'])
editor.ungroupSelected()
expect(childNames(editor, pageId)).toEqual(['B', 'A', 'C', 'D'])
editor.undoAction()
expect(childNames(editor, pageId)).toEqual(['B', 'Group', 'D'])
})
})

View file

@ -0,0 +1,97 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import { FigmaAPI } from '@open-pencil/core/figma-api'
import { SceneGraph } from '@open-pencil/scene-graph'
// Recorded with the same script in Figma desktop 126: new layers from the plugin API and from the
// drawing tools start alike.
const WHITE = [1, 1, 1]
const GREY = [0.85, 0.85, 0.85]
const BLACK = [0, 0, 0]
function solid(paints: readonly { type: string; color?: { r: number; g: number; b: number } }[]) {
return paints.map((paint) =>
paint.color ? [paint.color.r, paint.color.g, paint.color.b].map((v) => Math.round(v * 100) / 100) : paint.type
)
}
function api() {
return new FigmaAPI(new SceneGraph())
}
describe('new layer defaults', () => {
test('frames and components are white, and frames clip their content', () => {
const figma = api()
const frame = figma.createFrame()
const component = figma.createComponent()
expect(solid(frame.fills)).toEqual([WHITE])
expect(frame.clipsContent).toBe(true)
expect(solid(component.fills)).toEqual([WHITE])
expect(component.clipsContent).toBe(false)
})
test('shapes are light grey', () => {
const figma = api()
for (const shape of [
figma.createRectangle(),
figma.createEllipse(),
figma.createPolygon(),
figma.createStar()
]) {
expect(solid(shape.fills)).toEqual([GREY])
expect(shape.strokes).toEqual([])
}
})
test('lines and vectors have a black 1 px stroke, and a line has no height', () => {
const figma = api()
const line = figma.createLine()
const vector = figma.createVector()
expect([line.width, line.height]).toEqual([100, 0])
for (const node of [line, vector]) {
expect(node.fills).toEqual([])
expect(solid(node.strokes)).toEqual([BLACK])
expect(node.strokeWeight).toBe(1)
}
})
test('a stroke a script adds takes the 1 px default weight', () => {
const rect = api().createRectangle()
expect(rect.strokeWeight).toBe(1)
// Scripts pass Figma paints, which carry no weight.
Reflect.set(rect, 'strokes', [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }])
expect(rect.strokeWeight).toBe(1)
})
test('each layer gets its own copy of the default paints', () => {
const editor = createEditor()
try {
const page = editor.state.currentPageId
const first = editor.graph.getNode(editor.createShape('RECTANGLE', 0, 0, 10, 10, page))
const color = first?.fills[0]?.color
if (color) color.r = 1
const second = editor.graph.getNode(editor.createShape('RECTANGLE', 0, 0, 10, 10, page))
expect(solid(second?.fills ?? [])).toEqual([GREY])
} finally {
editor.dispose()
}
})
test('the drawing tools use the same defaults', () => {
const editor = createEditor()
try {
const page = editor.state.currentPageId
const frame = editor.graph.getNode(editor.createShape('FRAME', 0, 0, 100, 100, page))
const line = editor.graph.getNode(editor.createShape('LINE', 0, 0, 100, 0, page))
expect(frame?.clipsContent).toBe(true)
expect(solid(frame?.fills ?? [])).toEqual([WHITE])
expect(line?.fills).toEqual([])
expect(solid(line?.strokes ?? [])).toEqual([BLACK])
expect(line?.strokes[0]?.weight).toBe(1)
} finally {
editor.dispose()
}
})
})

View file

@ -0,0 +1,82 @@
import { describe, expect, test } from 'bun:test'
import { FigmaAPI } from '@open-pencil/core/figma-api'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { Rect } from '@open-pencil/scene-graph/primitives'
// Recorded with the same script in Figma desktop 126: children of groups report positions in their
// container's space, and groups refit whenever a script moves, resizes, adds, or removes a child.
function setup() {
const figma = new FigmaAPI(new SceneGraph())
const frame = figma.createFrame()
frame.resize(800, 800)
frame.x = 1000
const rect = (x: number, y: number) => {
const node = figma.createRectangle()
node.resize(60, 60)
frame.appendChild(node)
node.x = x
node.y = y
return node
}
const a = rect(100, 100)
const b = rect(200, 200)
const group = figma.group([a, b], frame)
return { figma, frame, group, a, b, rect }
}
const box = (node: Rect) => [
node.x,
node.y,
node.width,
node.height
]
describe('group children in the plugin API', () => {
test('report positions and transforms in the container', () => {
const { group, a } = setup()
expect(box(group)).toEqual([100, 100, 160, 160])
expect([a.x, a.y]).toEqual([100, 100])
expect(a.relativeTransform).toEqual([
[1, 0, 100],
[0, 1, 100]
])
})
test('refit the group when a child moves or resizes', () => {
const { group, a, b } = setup()
a.x = 50
expect(box(group)).toEqual([50, 100, 210, 160])
expect([a.x, a.y, b.x, b.y]).toEqual([50, 100, 200, 200])
b.resize(100, 60)
expect(box(group)).toEqual([50, 100, 250, 160])
})
test('moving the group moves its children', () => {
const { group, a, b } = setup()
group.x = 300
expect([a.x, a.y, b.x, b.y]).toEqual([300, 100, 400, 200])
})
test('nested groups report through every level', () => {
const { figma, group, b } = setup()
const inner = figma.group([b], group)
expect([inner.x, inner.y, inner.width]).toEqual([200, 200, 60])
expect([b.x, b.y]).toEqual([200, 200])
})
test('adding, removing, and taking out children refit the group, and an emptied group goes', () => {
const { frame, group, a, b, rect } = setup()
const c = rect(500, 500)
group.appendChild(c)
expect(box(group)).toEqual([100, 100, 460, 460])
expect([c.x, c.y]).toEqual([500, 500])
frame.appendChild(c)
expect(box(group)).toEqual([100, 100, 160, 160])
b.remove()
expect(box(group)).toEqual([100, 100, 60, 60])
a.remove()
expect(group.removed).toBe(true)
})
})

View file

@ -0,0 +1,114 @@
import { describe, expect, test } from 'bun:test'
import { FigmaAPI, type FigmaNodeProxy } from '@open-pencil/core/figma-api'
import { SceneGraph } from '@open-pencil/scene-graph'
// Recorded with the same script in Figma desktop 126.
function setup() {
const figma = new FigmaAPI(new SceneGraph())
const host = figma.createFrame()
host.resize(800, 600)
const rect = (name: string, x: number) => {
const node = figma.createRectangle()
node.name = name
node.resize(50, 50)
node.x = x
host.appendChild(node)
return node
}
const order = () => host.children.map((child: FigmaNodeProxy) => child.name)
return { figma, host, rect, order }
}
describe('plugin API structure parity', () => {
test('a group without an index goes on top of its parent', () => {
const { figma, host, rect, order } = setup()
rect('a', 0)
const b = rect('b', 100)
const c = rect('c', 200)
rect('d', 300)
const group = figma.group([b, c], host)
expect(group.name).toBe('Group')
expect(order()).toEqual(['a', 'd', 'Group'])
})
test('ungroup puts the children in the group’s place in the stack', () => {
const { figma, host, rect, order } = setup()
rect('a', 0)
const b = rect('b', 100)
const c = rect('c', 200)
rect('d', 300)
const group = figma.group([b, c], host, 1)
expect(order()).toEqual(['a', 'Group', 'd'])
expect(figma.ungroup(group).map((child) => child.name)).toEqual(['b', 'c'])
expect(order()).toEqual(['a', 'b', 'c', 'd'])
})
test('a boolean operation is named after it and filled with the default grey', () => {
const { figma, host, rect, order } = setup()
rect('a', 0)
const b = rect('b', 100)
const c = rect('c', 120)
// Scripts pass Figma paints, which carry no alpha.
Reflect.set(b, 'fills', [{ type: 'SOLID', color: { r: 0, g: 1, b: 0 } }])
const union = figma.union([b, c], host)
expect(union.name).toBe('Union')
expect(order()).toEqual(['a', 'Union'])
const raw = figma.graph.getNode(union.id)
expect(raw?.fills[0]?.color.r).toBeCloseTo(217 / 255)
expect(raw?.childIds).toEqual([b.id, c.id])
expect(figma.subtract([rect('e', 0), rect('f', 10)], host).name).toBe('Subtract')
})
test('a component from a layer wraps it in a white component named after it', () => {
const { figma, rect, order } = setup()
rect('a', 0)
const r = rect('r', 100)
rect('z', 300)
const component = figma.createComponentFromNode(r)
expect(component.type).toBe('COMPONENT')
expect(component.name).toBe('r')
expect([component.x, component.y, component.width, component.height]).toEqual([100, 0, 50, 50])
expect(component.fills[0]?.color).toMatchObject({ r: 1, g: 1, b: 1 })
expect(order()).toEqual(['a', 'r', 'z'])
expect(r.removed).toBe(false)
expect(component.children[0]?.id).toBe(r.id)
expect([r.x, r.y]).toEqual([0, 0])
})
test('a component from a frame keeps its place, look, and children', () => {
const { figma, host, rect, order } = setup()
rect('a', 0)
const frame = figma.createFrame()
frame.name = 'f'
frame.x = 100
frame.cornerRadius = 4
host.appendChild(frame)
const inner = figma.createEllipse()
frame.appendChild(inner)
rect('z', 300)
const component = figma.createComponentFromNode(frame)
expect(order()).toEqual(['a', 'f', 'z'])
expect(component.cornerRadius).toBe(4)
expect(component.clipsContent).toBe(true)
expect(component.children.map((child: FigmaNodeProxy) => child.id)).toEqual([inner.id])
expect(frame.removed).toBe(true)
})
test('a component from a group takes its place and children, with no fill', () => {
const { figma, host, rect, order } = setup()
rect('a', 0)
const b = rect('b', 100)
const c = rect('c', 200)
rect('d', 300)
const group = figma.group([b, c], host, 1)
group.name = 'grp'
const component = figma.createComponentFromNode(group)
expect(order()).toEqual(['a', 'grp', 'd'])
expect(component.fills).toEqual([])
expect(component.children.map((child: FigmaNodeProxy) => child.id)).toEqual([b.id, c.id])
expect([b.x, c.x]).toEqual([0, 100])
expect(group.removed).toBe(true)
})
})

View file

@ -17,13 +17,13 @@ Local assets are grouped by source page. Published library assets remain availab
Select a frame or group and press <kbd>⌥</kbd><kbd>⌘</kbd><kbd>K</kbd> (<kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>K</kbd>). The selection becomes a reusable component.
If you select multiple nodes, they're wrapped in a new component positioned at their bounding box.
Any other layer, or several layers, is wrapped in a new white component at their bounding box, in the topmost layer's place in the layer list; a single wrapped layer gives the component its name.
Components display a purple label with a diamond icon above them.
## Component Sets and Variants
Select two or more components and press <kbd>⇧</kbd><kbd>⌘</kbd><kbd>K</kbd> (<kbd>Shift</kbd> + <kbd>Ctrl</kbd> + <kbd>K</kbd>) to combine them into a component set — a container with a dashed purple border and 40 px padding around its children.
Select two or more components and press <kbd>⇧</kbd><kbd>⌘</kbd><kbd>K</kbd> (<kbd>Shift</kbd> + <kbd>Ctrl</kbd> + <kbd>K</kbd>) to combine them into a component set — a container with a dashed purple border and 20 px padding around its children, as in Figma. Sets made by scripts with `figma.combineAsVariants()` wrap their components exactly, as Figma's plugin API does.
Each component in a set can define values across multiple variant dimensions, such as `Size=Small`, `State=Hover`, and `Theme=Dark`. OpenPencil supports sparse combinations, so a set does not need every possible combination. The top-left variant is the default and is used as the fallback when an update no longer contains an exact combination.

View file

@ -61,7 +61,7 @@ Component actions are displayed in purple to match the component color theme.
| Action | Shortcut (Mac) | Shortcut (Win/Linux) | Available on |
|--------|----------------|----------------------|--------------|
| Create component | <kbd>⌥</kbd><kbd>⌘</kbd><kbd>K</kbd> | <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>K</kbd> | Frames, groups, multi-selection |
| Create component | <kbd>⌥</kbd><kbd>⌘</kbd><kbd>K</kbd> | <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>K</kbd> | Any selection |
| Create component set | <kbd>⇧</kbd><kbd>⌘</kbd><kbd>K</kbd> | <kbd>Shift</kbd> + <kbd>Ctrl</kbd> + <kbd>K</kbd> | 2+ selected components |
| Create instance | — | — | Components (no shortcut) |
| Go to main component | — | — | Instances |

View file

@ -74,7 +74,7 @@ Click **+** to add an effect. Each effect row is collapsible with inline control
## Frames and Sections
**Frames** are containers. Drag shapes into a frame to make them children, or start drawing inside one: a new shape goes into the frame under the point where you start, except locked frames, and joins the end of an auto-layout flow. Drawing a frame over existing nodes puts the unlocked ones it fully covers inside it. Frames can clip their content (off by default) and support [auto layout](./auto-layout).
**Frames** are containers. Drag shapes into a frame to make them children, or start drawing inside one: a new shape goes into the frame under the point where you start, except locked frames, and joins the end of an auto-layout flow. Drawing a frame over existing nodes puts the unlocked ones it fully covers inside it. New frames clip their content, as in Figma (turn off **Clip content** to let children show outside), and frames support [auto layout](./auto-layout).
Select the Frame tool to browse collapsible presets for phones, tablets, desktops, presentations, watches, paper, social media, Figma Community assets, and archived devices in the Design panel. Choosing a preset creates a named frame centered in the viewport and returns to the Select tool. With an existing frame selected, use its Frame preset dropdown to resize it without changing its name.

View file

@ -1,5 +1,6 @@
import type { SceneGraph, SceneNode } from './index'
import Matrix, { type Mat3 } from './matrix'
import { FITTED_CONTAINER_TYPES } from './node-defaults'
import type { Rect, Vector } from './primitives'
export function getWorldMatrix(node: SceneNode, graph: Pick<SceneGraph, 'getNode'>): Mat3 {
@ -259,3 +260,24 @@ export function localTransformFromWorld(
export function isTranslationOnly(matrix: Mat3): boolean {
return matrix[0] === 1 && matrix[1] === 0 && matrix[3] === 0 && matrix[4] === 1
}
/**
* Maps a node's parent space into its container's: through the groups and booleans around it,
* which set no coordinate space of their own. Figma's plugin API reports `x`, `y`, and
* `relativeTransform` of their children in the container's space. The identity when the parent is
* not a group.
*/
export function getParentToContainerMatrix(
node: SceneNode,
graph: Pick<SceneGraph, 'getNode'>
): Mat3 {
let matrix = Matrix.identity()
for (
let parent = node.parentId ? graph.getNode(node.parentId) : undefined;
parent && FITTED_CONTAINER_TYPES.has(parent.type);
parent = parent.parentId ? graph.getNode(parent.parentId) : undefined
) {
matrix = Matrix.multiply(getNodeLocalMatrix(parent), matrix)
}
return matrix
}

View file

@ -0,0 +1,103 @@
import { getAxisAlignedBoundsInParent } from './coordinate'
import type { SceneGraph } from './index'
import { FITTED_CONTAINER_TYPES } from './node-defaults'
import type { SceneNode } from './types'
type Placement = Pick<SceneNode, 'x' | 'y' | 'width' | 'height'>
/** What `fitEnclosingGroups` changed, so a caller can undo and redo it. */
export interface GroupFit {
before: Map<string, Placement>
after: Map<string, Placement>
/** Emptied groups, outermost last, with their index in their parent. */
removed: Array<{ node: SceneNode; index: number }>
}
function placement(node: SceneNode): Placement {
return { x: node.x, y: node.y, width: node.width, height: node.height }
}
/** Groups and booleans holding the parents, innermost first. */
function fittedAncestors(graph: SceneGraph, parentIds: Iterable<string>): SceneNode[] {
const found = new Map<string, { node: SceneNode; depth: number }>()
for (const parentId of parentIds) {
const chain: SceneNode[] = []
for (let node = graph.getNode(parentId); node && FITTED_CONTAINER_TYPES.has(node.type);) {
chain.push(node)
node = node.parentId ? graph.getNode(node.parentId) : undefined
}
for (const [index, node] of chain.entries()) {
found.set(node.id, {
node,
depth: Math.max(found.get(node.id)?.depth ?? 0, chain.length - index)
})
}
}
return [...found.values()].sort((a, b) => b.depth - a.depth).map(({ node }) => node)
}
/** Moves the group to its children's bounds, shifting them back so they stay put. */
function fitGroup(graph: SceneGraph, group: SceneNode, children: SceneNode[], fit: GroupFit) {
if (!group.parentId || group.rotation !== 0 || group.flipX || group.flipY) return
const bounds = getAxisAlignedBoundsInParent(children, group.parentId, graph)
const dx = bounds.x - group.x
const dy = bounds.y - group.y
if (dx === 0 && dy === 0 && bounds.width === group.width && bounds.height === group.height) {
return
}
for (const node of [group, ...children]) {
if (!fit.before.has(node.id)) fit.before.set(node.id, placement(node))
}
graph.updateNode(group.id, bounds)
for (const child of children) {
graph.updateNode(child.id, { x: child.x - dx, y: child.y - dy })
}
for (const node of [group, ...children]) {
const current = graph.getNode(node.id)
if (current) fit.after.set(node.id, placement(current))
}
}
/** A group left without layers goes away; an empty boolean stays, as in Figma. */
function removeEmptyGroup(graph: SceneGraph, group: SceneNode, fit: GroupFit) {
if (group.type !== 'GROUP' || !group.parentId) return
const index = graph.getNode(group.parentId)?.childIds.indexOf(group.id) ?? -1
fit.removed.push({ node: structuredClone(group), index })
graph.deleteNode(group.id)
}
/**
* Refits the groups and booleans around these parents to their children, keeping every child where
* it is on the canvas, and removes groups left empty, as Figma does after a layer inside one moves,
* resizes, or leaves. Rotated or flipped containers keep their bounds. Returns null when nothing
* changed.
*/
export function fitEnclosingGroups(
graph: SceneGraph,
parentIds: Iterable<string>
): GroupFit | null {
const fit: GroupFit = { before: new Map(), after: new Map(), removed: [] }
for (const group of fittedAncestors(graph, parentIds)) {
const children = graph.getChildren(group.id)
if (children.length === 0) removeEmptyGroup(graph, group, fit)
else fitGroup(graph, group, children, fit)
}
return fit.before.size === 0 && fit.removed.length === 0 ? null : fit
}
/** Reapplies a fit after `undoGroupFit`. */
export function redoGroupFit(graph: SceneGraph, fit: GroupFit): void {
for (const [id, value] of fit.after) graph.updateNode(id, value)
for (const { node } of fit.removed) graph.deleteNode(node.id)
}
/** Brings back the groups a fit removed and their earlier bounds. */
export function undoGroupFit(graph: SceneGraph, fit: GroupFit): void {
// Outer groups were removed after inner ones, so they come back first.
for (const { node, index } of fit.removed.toReversed()) {
const parentId = node.parentId ?? graph.rootId
graph.createNode(node.type, parentId, { ...structuredClone(node), childIds: [] })
if (index >= 0) graph.insertChildAt(node.id, parentId, index)
}
for (const [id, value] of fit.before) graph.updateNode(id, value)
}

View file

@ -20,7 +20,7 @@ export * from './slots/content'
export * from './slots/authoring'
export * from './slots/limits'
export * from './copy'
export { createDefaultNode } from './node-defaults'
export { createDefaultNode, FITTED_CONTAINER_TYPES } from './node-defaults'
export {
copyInstanceComponentProps,
findInstanceAncestor,
@ -42,6 +42,7 @@ export * from './snap'
export * from './export-format'
export * from './export-scale'
export * from './coordinate'
export * from './group-bounds'
export * from './constants'
export * from './geometry'
export * from './guides'

View file

@ -185,6 +185,12 @@ export function createDefaultNode(
} satisfies CompleteNodeFields
}
/** Containers whose bounds follow their children and that set no coordinate space, as in Figma. */
export const FITTED_CONTAINER_TYPES: ReadonlySet<NodeType> = new Set<NodeType>([
'GROUP',
'BOOLEAN_OPERATION'
])
export const CONTAINER_TYPES = new Set<NodeType>([
'CANVAS',
'FRAME',

View file

@ -34,7 +34,7 @@ function startDraw(
const nodeId = editor.createShape(type, start.x, start.y, 0, 0, parentId)
if (type === 'TEXT') editor.graph.updateNode(nodeId, { text: '' })
editor.select([nodeId])
setDrag(createDraw(editor, nodeId, start.x, start.y, toLocal))
setDrag(createDraw(editor, nodeId, start.x, start.y, toLocal, type === 'LINE'))
}
export function startTextDraw(
@ -58,10 +58,24 @@ export function startShapeDraw(
startDraw(nodeType, 'Create shape', cx, cy, editor, setDrag)
}
const LINE_ANGLE_STEP = 45
/** A line from the start point to the cursor: its length, no height, and the angle as rotation. */
function lineGeometry(d: DragDraw, dx: number, dy: number, shiftKey: boolean): Partial<SceneNode> {
let angle = (Math.atan2(dy, dx) * 180) / Math.PI
// Shift snaps the angle to steps of 45°.
if (shiftKey) angle = Math.round(angle / LINE_ANGLE_STEP) * LINE_ANGLE_STEP
return { x: d.startX, y: d.startY, width: Math.hypot(dx, dy), height: 0, rotation: angle }
}
export function handleDrawMove(d: DragDraw, cx: number, cy: number, shiftKey: boolean) {
const point = d.toLocal(cx, cy)
let w = point.x - d.startX
let h = point.y - d.startY
if (d.line) {
d.update(lineGeometry(d, w, h, shiftKey))
return
}
if (shiftKey) {
const size = Math.max(Math.abs(w), Math.abs(h))
@ -86,6 +100,9 @@ function settleDrawnSize(preview: ReturnType<Editor['beginNodePreview']>, node:
height: clicked ? DEFAULT_TEXT_HEIGHT : node.height,
textAutoResize: clicked ? 'WIDTH_AND_HEIGHT' : 'NONE'
})
} else if (node.type === 'LINE' && node.width < 2) {
preview.update(node.id, { width: 100, height: 0, rotation: 0 })
return true
} else if (clicked) {
preview.update(node.id, { width: 100, height: 100 })
}
@ -97,7 +114,8 @@ function createDraw(
nodeId: string,
startX: number,
startY: number,
toLocal: DragDraw['toLocal']
toLocal: DragDraw['toLocal'],
line: boolean
): DragDraw {
const graph = editor.graph
const preview = editor.beginNodePreview('Draw dimensions')
@ -152,6 +170,7 @@ function createDraw(
startX,
startY,
toLocal,
line,
nodeId,
update: (changes) => {
if (!finished) preview.update(nodeId, changes)

View file

@ -22,6 +22,8 @@ export interface DragDraw {
startY: number
/** Maps a canvas point into the parent's axes. */
toLocal: (x: number, y: number) => Vector
/** A line is drawn by its length and angle, as in Figma, not as a box. */
line?: boolean
nodeId: string
update: (changes: Partial<SceneNode>) => void
commit: () => void

View file

@ -21,7 +21,7 @@ function node(type: SceneNode['type'], parentId: string, props: Partial<SceneNod
return editor.graph.createNode(type, parentId, { width: 40, height: 40, ...props })
}
function draw(tool: Tool, from: [number, number], to: [number, number] = from) {
function draw(tool: Tool, from: [number, number], to: [number, number] = from, shiftKey = false) {
editor.setTool(tool)
let drag: DragState | null = null
startShapeDraw(from[0], from[1], editor, (d) => {
@ -29,7 +29,7 @@ function draw(tool: Tool, from: [number, number], to: [number, number] = from) {
})
const drawing = drag as DragState | null
if (drawing?.type !== 'draw') throw new Error('Expected a drawing')
if (to !== from) handleDrawMove(drawing, to[0], to[1], false)
if (to !== from) handleDrawMove(drawing, to[0], to[1], shiftKey)
drawing.commit()
const created = editor.graph.getNode(drawing.nodeId)
if (!created) throw new Error('Expected a drawn layer')
@ -112,3 +112,25 @@ describe('drawing into containers', () => {
expect(covered.parentId).toBe(page)
})
})
describe('drawing lines', () => {
test('a line runs from the start point to the cursor, with no height', () => {
setup()
const line = draw('LINE', [60, 80], [300, 160])
expect(line).toMatchObject({ type: 'LINE', x: 60, y: 80, height: 0 })
expect(line.width).toBeCloseTo(Math.hypot(240, 80))
expect(line.rotation).toBeCloseTo((Math.atan2(80, 240) * 180) / Math.PI)
expect(line.strokes[0]).toMatchObject({ weight: 1, color: { r: 0, g: 0, b: 0, a: 1 } })
})
test('Shift snaps the angle to 45° steps', () => {
setup()
expect(draw('LINE', [0, 0], [100, 30], true).rotation).toBe(0)
expect(draw('LINE', [0, 0], [100, 80], true).rotation).toBe(45)
})
test('a click makes a 100 px horizontal line', () => {
setup()
expect(draw('LINE', [10, 10])).toMatchObject({ width: 100, height: 0, rotation: 0 })
})
})

View file

@ -109,7 +109,7 @@ for (const renderer of ['retained', 'tiled'] as const) {
expect(samples.length).toBeGreaterThan(4)
expect(samples.some((sample) => sample.zoom < 0.3)).toBe(true)
for (const sample of samples) {
expect(sample.pixel, JSON.stringify(sample)).toEqual([212, 212, 212, 255])
expect(sample.pixel, JSON.stringify(sample)).toEqual([217, 217, 217, 255])
}
await expect(page).toHaveScreenshot('fresh-scene-picture.png', {
clip: { x: x - 20, y: y - 20, width: 260, height: 200 },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB