diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c626f262..535654c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - Snap vector points, moved layers, and resized edges to nearby geometry, sibling layer bounds, canvas and frame layout guides, and whole-pixel coordinates with visible alignment guides, fractional-coordinate preservation when pixel snapping is off, and persistent geometry, object, and pixel-grid controls in General settings and the Preferences menu. - Run Pi through AI SDK HarnessAgent as a configurable desktop provider with multiple saved model profiles, secure credentials, existing MCP design tools, and per-profile thinking and permission settings. - Open multiple selected design files in separate tabs. +- Let Figma API scripts and automation combine components into variant sets. +- Add deterministic two-browser collaboration coverage for bidirectional edits, awareness, departure cleanup, partitioned-peer convergence, and reconnect synchronization without public network dependencies. (#530) - Import, render, edit, resize, select, and export Figma text-on-path layers while preserving their curved glyph layout. - Show Figma-style temporary distance measurements between selected and Option/Alt-hovered layers. (#491) - Add a single CodeMirror editor for live Design JSX and HTML/CSS canvas previews, with Tailwind JSX viewing, completion, diagnostics, line numbers, bounded execution, and session-level undo. (#130) diff --git a/packages/core/src/editor/components.ts b/packages/core/src/editor/components.ts index 4539a7e16..50070c37d 100644 --- a/packages/core/src/editor/components.ts +++ b/packages/core/src/editor/components.ts @@ -1,4 +1,5 @@ -import type { ComponentPropertyDefinition, SceneNode } from '@open-pencil/scene-graph' +import type { SceneNode } from '@open-pencil/scene-graph' +import { deriveSlashVariantProperties } from '@open-pencil/scene-graph/variant-properties' import { randomHex } from '#core/random' @@ -61,43 +62,13 @@ export function createComponentActions(ctx: EditorContext) { const containerId = wrapSelectionInContainer('COMPONENT_SET', selectedNodes) if (!containerId) return - const slashCounts = selectedNodes.map((n) => (n.name.match(/\//g) ?? []).length) - const hasConsistentSlashes = - slashCounts.every((c) => c === slashCounts[0]) && slashCounts[0] > 0 + const derived = deriveSlashVariantProperties(selectedNodes, () => `prop:${randomHex(8)}`) + if (!derived) return - if (hasConsistentSlashes) { - const propCount = slashCounts[0] - const propDefs: ComponentPropertyDefinition[] = [] - const propValues = new Map>() - - for (let i = 0; i < propCount; i++) { - const propId = `prop:${randomHex(8)}` - const propName = i === 0 ? 'Variant' : `Property ${i + 1}` - propDefs.push({ id: propId, name: propName, type: 'VARIANT', defaultValue: '' }) - propValues.set(propName, new Set()) - } - - for (const node of selectedNodes) { - const parts = node.name.split('/').slice(1) - const values: Record = {} - for (let i = 0; i < propDefs.length; i++) { - const value = parts[i]?.trim() ?? '' - values[propDefs[i].name] = value - propValues.get(propDefs[i].name)?.add(value) - } - ctx.graph.updateNode(node.id, { - componentPropertyValues: values, - name: Object.values(values).join(', ') - }) - } - - for (const def of propDefs) { - def.variantOptions = [...(propValues.get(def.name) ?? [])] - if (!def.defaultValue && def.variantOptions[0]) def.defaultValue = def.variantOptions[0] - } - - ctx.graph.updateNode(containerId, { componentPropertyDefinitions: propDefs }) + for (const [nodeId, changes] of derived.variants) { + ctx.graph.updateNode(nodeId, changes) } + ctx.graph.updateNode(containerId, { componentPropertyDefinitions: derived.definitions }) } const focusActions = createComponentFocusActions(ctx) diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts new file mode 100644 index 000000000..73dde2475 --- /dev/null +++ b/packages/core/src/figma-api/components.ts @@ -0,0 +1,63 @@ +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import { computeAbsoluteBounds } from '@open-pencil/scene-graph/geometry' +import { deriveSlashVariantProperties } from '@open-pencil/scene-graph/variant-properties' + +import { randomHex } from '#core/random' + +const COMPONENT_SET_PADDING = 40 + +function requireDistinctComponents(graph: SceneGraph, nodeIds: ReadonlyArray): SceneNode[] { + if (nodeIds.length === 0) throw new Error('Need at least 1 component to combine as variants') + if (new Set(nodeIds).size !== nodeIds.length) { + throw new Error('combineAsVariants requires distinct COMPONENT nodes') + } + + const nodes = nodeIds.map((id) => graph.getNode(id)) + if (!nodes.every((node): node is SceneNode => node?.type === 'COMPONENT')) { + throw new Error('combineAsVariants requires COMPONENT nodes') + } + return nodes +} + +export function combineComponentsAsVariants( + graph: SceneGraph, + nodeIds: ReadonlyArray, + parentId: string, + index?: number +): SceneNode { + const components = requireDistinctComponents(graph, nodeIds) + 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, () => `prop:${randomHex(8)}`) + if (derived) { + for (const [nodeId, changes] of derived.variants) graph.updateNode(nodeId, changes) + graph.updateNode(componentSet.id, { componentPropertyDefinitions: derived.definitions }) + } + + return componentSet +} diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index 75a8fe13c..35b798210 100644 --- a/packages/core/src/figma-api/index.ts +++ b/packages/core/src/figma-api/index.ts @@ -1,5 +1,4 @@ import type { - ComponentPropertyDefinition, SceneGraph, SceneNode as CoreSceneNode, NodeType, @@ -9,7 +8,7 @@ import type { VariableValue } from '@open-pencil/scene-graph' import { copyFills, copyStrokes, copyEffects } from '@open-pencil/scene-graph/copy' -import { computeAbsoluteBounds, computeBounds } from '@open-pencil/scene-graph/geometry' +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,12 +18,13 @@ import { canMakeBooleanSourceNode } from '#core/canvas/boolean' import { flattenNodesToVectorProps } from '#core/canvas/flatten' import { IS_BROWSER } from '#core/constants' import type { RasterExportFormat } from '#core/io/formats/raster' -import { randomHex } from '#core/random' import { documentFontStatus, type DocumentFontStatus } from '#core/text/font/status' +import { combineComponentsAsVariants } from './components' import type { FigmaBooleanOperationNode, FigmaComponentNode, + FigmaComponentSetNode, FigmaEllipseNode, FigmaFrameNode, FigmaGroupNode, @@ -51,6 +51,7 @@ export { FigmaNodeProxy } from './proxy' export type { FigmaBooleanOperationNode, FigmaComponentNode, + FigmaComponentSetNode, FigmaEllipseNode, FigmaFrameNode, FigmaGroupNode, @@ -267,105 +268,28 @@ export class FigmaAPI implements NodeProxyHost { return this.wrapNode(comp.id) } - private _isTopLevel(parentId: string | null): boolean { - return !parentId || parentId === this.graph.rootId || parentId === this._currentPageId - } - - private _wrapNodesInComponentSet(rawNodes: CoreSceneNode[]): FigmaNodeProxy | null { - const parentId = rawNodes[0].parentId ?? this._currentPageId - const sameParent = rawNodes.every((n) => (n.parentId ?? this._currentPageId) === parentId) - if (!sameParent) return null - - const parent = this.graph.getNode(parentId) - if (!parent) return null - - const nodeIds = rawNodes.map((n) => n.id) - const { - x: minX, - y: minY, - width: bw, - height: bh - } = computeAbsoluteBounds(rawNodes, (id) => this.graph.getAbsolutePosition(id)) - const maxX = minX + bw - const maxY = minY + bh - - const parentAbs = this._isTopLevel(parentId) - ? { x: 0, y: 0 } - : this.graph.getAbsolutePosition(parentId) - const firstIndex = Math.min(...nodeIds.map((id) => parent.childIds.indexOf(id))) - const padding = 40 - - const containerNode = this.graph.createNode('COMPONENT_SET', parentId, { - name: rawNodes[0].name.split('/')[0]?.trim() || 'Component Set', - x: minX - parentAbs.x - padding, - y: minY - parentAbs.y - padding, - width: maxX - minX + padding * 2, - height: maxY - minY + padding * 2, - fills: [{ type: 'SOLID', color: { r: 0.96, g: 0.96, b: 0.96, a: 1 }, opacity: 1, visible: true }] - }) - - this.graph.insertChildAt(containerNode.id, parentId, firstIndex) - for (const id of nodeIds) { - this.graph.reparentNode(id, containerNode.id) - } - - return this.wrapNode(containerNode.id) - } - - /** - * Wraps components sharing a common parent into a COMPONENT_SET, deriving - * variant properties from `Category/Value` name segments (mirrors the - * editor's createComponentSetFromComponents, minus undo/selection state). - */ - combineAsVariants(nodes: ReadonlyArray): FigmaNodeProxy { - if (nodes.length < 2) throw new Error('Need at least 2 components to combine as variants') - - const rawNodes = nodes.map((n) => this.graph.getNode(n[INTERNAL_ID])) - if (!rawNodes.every((n): n is CoreSceneNode => n?.type === 'COMPONENT')) { - throw new Error('combineAsVariants requires COMPONENT nodes') - } - - const container = this._wrapNodesInComponentSet(rawNodes) - if (!container) throw new Error('Components must share the same parent') - - const slashCounts = rawNodes.map((n) => (n.name.match(/\//g) ?? []).length) - const hasConsistentSlashes = slashCounts.every((c) => c === slashCounts[0]) && slashCounts[0] > 0 - - if (hasConsistentSlashes) { - const propCount = slashCounts[0] - const propDefs: ComponentPropertyDefinition[] = [] - const propValues = new Map>() - - for (let i = 0; i < propCount; i++) { - const propId = `prop:${randomHex(8)}` - const propName = i === 0 ? 'Variant' : `Property ${i + 1}` - propDefs.push({ id: propId, name: propName, type: 'VARIANT', defaultValue: '' }) - propValues.set(propName, new Set()) - } - - for (const node of rawNodes) { - const parts = node.name.split('/').slice(1) - const values: Record = {} - for (let i = 0; i < propDefs.length; i++) { - const value = parts[i]?.trim() ?? '' - values[propDefs[i].name] = value - propValues.get(propDefs[i].name)?.add(value) - } - this.graph.updateNode(node.id, { - componentPropertyValues: values, - name: Object.values(values).join(', ') - }) - } - - for (const def of propDefs) { - def.variantOptions = [...(propValues.get(def.name) ?? [])] - if (!def.defaultValue && def.variantOptions[0]) def.defaultValue = def.variantOptions[0] - } - - this.graph.updateNode(container[INTERNAL_ID], { componentPropertyDefinitions: propDefs }) - } - - return container + combineAsVariants( + nodes: ReadonlyArray, + parent: FigmaNodeProxy, + index?: number + ): FigmaComponentSetNode + combineAsVariants( + nodes: ReadonlyArray, + parent: BaseNode & ChildrenMixin, + index?: number + ): ComponentSetNode + combineAsVariants( + nodes: ReadonlyArray, + parent: (BaseNode & ChildrenMixin) | FigmaNodeProxy, + index?: number + ): FigmaComponentSetNode { + const componentSet = combineComponentsAsVariants( + this.graph, + nodes.map((node) => this._nodeId(node)), + this._nodeId(parent), + index + ) + return this.wrapNode(componentSet.id) as FigmaComponentSetNode } // --- Variables --- diff --git a/packages/core/src/figma-api/node-types.ts b/packages/core/src/figma-api/node-types.ts index 4faaaaeba..89b3d4472 100644 --- a/packages/core/src/figma-api/node-types.ts +++ b/packages/core/src/figma-api/node-types.ts @@ -11,6 +11,7 @@ export type FigmaVectorNode = FigmaNodeProxy & VectorNode export type FigmaPolygonNode = FigmaNodeProxy & PolygonNode export type FigmaStarNode = FigmaNodeProxy & StarNode export type FigmaComponentNode = FigmaNodeProxy & ComponentNode +export type FigmaComponentSetNode = FigmaNodeProxy & ComponentSetNode export type FigmaSectionNode = FigmaNodeProxy & SectionNode export type FigmaGroupNode = FigmaNodeProxy & GroupNode export type FigmaBooleanOperationNode = FigmaNodeProxy & BooleanOperationNode diff --git a/packages/core/src/tools/create/components.ts b/packages/core/src/tools/create/components.ts index 8f850642e..459bbe7b9 100644 --- a/packages/core/src/tools/create/components.ts +++ b/packages/core/src/tools/create/components.ts @@ -1,5 +1,5 @@ -import type { FigmaNodeProxy } from '#core/figma-api' -import { defineTool, nodeSummary } from '#core/tools/schema' +import type { FigmaComponentNode } from '#core/figma-api' +import { defineTool, nodeSummary, requireNodes } from '#core/tools/schema' export const createComponent = defineTool({ name: 'create_component', @@ -45,16 +45,15 @@ export const combineAsVariants = defineTool({ ids: { type: 'string[]', description: 'Component node IDs to combine', required: true } }, execute: (figma, { ids }) => { - const nodes = ids - .map((id) => figma.getNodeById(id)) - .filter((node): node is FigmaNodeProxy => node !== null) - if (nodes.length !== ids.length) return { error: 'One or more node IDs were not found' } + const nodes = requireNodes(figma, ids) + if (!nodes) return { error: 'One or more node IDs were not found' } if (nodes.length < 2) return { error: 'Need at least 2 components to combine as variants' } - if (!nodes.every((node) => node.type === 'COMPONENT')) { + if (!nodes.every((node): node is FigmaComponentNode => node.type === 'COMPONENT')) { return { error: 'combineAsVariants requires COMPONENT nodes' } } + const parent = nodes[0].parent ?? figma.currentPage try { - const componentSet = figma.combineAsVariants(nodes) + const componentSet = figma.combineAsVariants(nodes, parent) return nodeSummary(componentSet) } catch (error) { return { error: error instanceof Error ? error.message : String(error) } diff --git a/packages/core/src/tools/schema.ts b/packages/core/src/tools/schema.ts index 97853f73b..46922a64f 100644 --- a/packages/core/src/tools/schema.ts +++ b/packages/core/src/tools/schema.ts @@ -71,6 +71,16 @@ export function requireNode(figma: FigmaAPI, id: string): ReturnType): FigmaNodeProxy[] | null { + const nodes: FigmaNodeProxy[] = [] + for (const id of ids) { + const node = figma.getNodeById(id) + if (!node) return null + nodes.push(node) + } + return nodes +} + export function nodeNotFound(id: string): { error: string } { return { error: `Node "${id}" not found` } } diff --git a/packages/core/src/tools/structure/hierarchy.ts b/packages/core/src/tools/structure/hierarchy.ts index db76b4e8f..dbde91b16 100644 --- a/packages/core/src/tools/structure/hierarchy.ts +++ b/packages/core/src/tools/structure/hierarchy.ts @@ -1,5 +1,4 @@ -import type { FigmaNodeProxy } from '#core/figma-api' -import { defineTool, nodeSummary } from '#core/tools/schema' +import { defineTool, nodeSummary, requireNodes } from '#core/tools/schema' export const reparentNode = defineTool({ name: 'reparent_node', @@ -27,10 +26,8 @@ export const groupNodes = defineTool({ ids: { type: 'string[]', description: 'Node IDs to group', required: true } }, execute: (figma, { ids }) => { - const nodes = ids - .map((id) => figma.getNodeById(id)) - .filter((node): node is FigmaNodeProxy => node !== null) - if (nodes.length < 2) return { error: 'Need at least 2 nodes to group' } + const nodes = requireNodes(figma, ids) + if (!nodes || nodes.length < 2) return { error: 'Need at least 2 nodes to group' } const parent = nodes[0].parent ?? figma.currentPage const group = figma.group(nodes, parent) return nodeSummary(group) diff --git a/packages/scene-graph/package.json b/packages/scene-graph/package.json index 35d18df0b..df0ff7416 100644 --- a/packages/scene-graph/package.json +++ b/packages/scene-graph/package.json @@ -64,6 +64,12 @@ "import": "./dist/variant-name.js", "default": "./dist/variant-name.js" }, + "./variant-properties": { + "types": "./dist/variant-properties.d.ts", + "bun": "./src/variant-properties.ts", + "import": "./dist/variant-properties.js", + "default": "./dist/variant-properties.js" + }, "./vector-network": { "types": "./dist/vector-network.d.ts", "bun": "./src/vector-network.ts", diff --git a/packages/scene-graph/src/variant-properties.ts b/packages/scene-graph/src/variant-properties.ts new file mode 100644 index 000000000..e14abe467 --- /dev/null +++ b/packages/scene-graph/src/variant-properties.ts @@ -0,0 +1,48 @@ +import type { ComponentPropertyDefinition, SceneNode } from './types' + +export interface DerivedVariantProperties { + definitions: ComponentPropertyDefinition[] + variants: Map> +} + +export function deriveSlashVariantProperties( + components: ReadonlyArray>, + createPropertyId: () => string +): DerivedVariantProperties | null { + const slashCounts = components.map((component) => (component.name.match(/\//g) ?? []).length) + const slashCount = slashCounts[0] ?? 0 + if (slashCount === 0 || !slashCounts.every((count) => count === slashCount)) return null + + const definitions: ComponentPropertyDefinition[] = Array.from( + { length: slashCount }, + (_, index) => ({ + id: createPropertyId(), + name: index === 0 ? 'Variant' : `Property ${index + 1}`, + type: 'VARIANT', + defaultValue: '' + }) + ) + const options = new Map(definitions.map((definition) => [definition.name, new Set()])) + const variants = new Map>() + + for (const component of components) { + const parts = component.name.split('/').slice(1) + const componentPropertyValues: Record = {} + for (const [index, definition] of definitions.entries()) { + const value = parts[index]?.trim() ?? '' + componentPropertyValues[definition.name] = value + options.get(definition.name)?.add(value) + } + variants.set(component.id, { + componentPropertyValues, + name: Object.values(componentPropertyValues).join(', ') + }) + } + + for (const definition of definitions) { + definition.variantOptions = [...(options.get(definition.name) ?? [])] + definition.defaultValue = definition.variantOptions[0] ?? '' + } + + return { definitions, variants } +} diff --git a/packages/scene-graph/tsdown.config.ts b/packages/scene-graph/tsdown.config.ts index 4baee7a1a..3f61c0934 100644 --- a/packages/scene-graph/tsdown.config.ts +++ b/packages/scene-graph/tsdown.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ undo: './src/undo.ts', variables: './src/variables.ts', 'variant-name': './src/variant-name.ts', + 'variant-properties': './src/variant-properties.ts', 'vector-network': './src/vector-network.ts', types: './src/types.ts', primitives: './src/primitives.ts', diff --git a/tests/engine/figma/api/create/combine-as-variants.test.ts b/tests/engine/figma/api/create/combine-as-variants.test.ts index 1c7d918bd..e8a886581 100644 --- a/tests/engine/figma/api/create/combine-as-variants.test.ts +++ b/tests/engine/figma/api/create/combine-as-variants.test.ts @@ -12,12 +12,12 @@ describe('combineAsVariants', () => { b.name = 'Button/Secondary' b.resize(100, 40) - const set = api.combineAsVariants([a, b]) + const set = api.combineAsVariants([a, b], api.currentPage) expect(set.type).toBe('COMPONENT_SET') expect(set.name).toBe('Button') expect(set.children.length).toBe(2) - expect(set.children.map((c) => c.name).sort()).toEqual(['Primary', 'Secondary']) + expect(set.children.map((child) => child.name).sort()).toEqual(['Primary', 'Secondary']) }) test('derives variant property definitions from name segments', () => { @@ -29,7 +29,7 @@ describe('combineAsVariants', () => { b.name = 'State/Hover' b.resize(100, 40) - const set = api.combineAsVariants([a, b]) + const set = api.combineAsVariants([a, b], api.currentPage) const raw = api.graph.getNode(set.id) expect(raw?.componentPropertyDefinitions?.length).toBe(1) @@ -40,16 +40,50 @@ describe('combineAsVariants', () => { ]) }) - test('rejects fewer than 2 nodes', () => { + test('accepts one component and an explicit destination', () => { const api = createAPI() + const destination = api.createFrame() + const component = api.createComponent() + + const set = api.combineAsVariants([component], destination) + + expect(set.parent?.id).toBe(destination.id) + expect(set.children.map((child) => child.id)).toEqual([component.id]) + }) + + test('uses the requested insertion index and preserves absolute positions', () => { + const api = createAPI() + const before = api.createFrame() const a = api.createComponent() - expect(() => api.combineAsVariants([a])).toThrow() + a.x = 120 + a.y = 80 + const b = api.createComponent() + b.x = 260 + b.y = 160 + const originalPositions = [a.absoluteTransform, b.absoluteTransform] + + const set = api.combineAsVariants([a, b], api.currentPage, 0) + + expect(api.currentPage.children[0].id).toBe(set.id) + expect(before.parent?.id).toBe(api.currentPage.id) + expect([a.absoluteTransform, b.absoluteTransform]).toEqual(originalPositions) + }) + + test('rejects an empty list', () => { + const api = createAPI() + expect(() => api.combineAsVariants([], api.currentPage)).toThrow() + }) + + test('rejects duplicate component references', () => { + const api = createAPI() + const component = api.createComponent() + expect(() => api.combineAsVariants([component, component], api.currentPage)).toThrow('distinct') }) test('rejects non-component nodes', () => { const api = createAPI() - const a = api.createComponent() - const b = api.createFrame() - expect(() => api.combineAsVariants([a, b])).toThrow() + const component = api.createComponent() + const frame = api.createFrame() + expect(() => api.combineAsVariants([component, frame], api.currentPage)).toThrow() }) })