Merge branch 'port-tools'

This commit is contained in:
Danila Poyarkov 2026-03-02 19:51:11 +03:00
commit 6f88a2e47e
12 changed files with 1204 additions and 29 deletions

View file

@ -6,12 +6,26 @@
- Right-click context menu on layers panel — same actions as the canvas context menu
- Extract shared `NodeContextMenuContent` component to avoid menu duplication
- 40+ new AI/MCP tools ported from figma-use:
- Granular set tools: `set_rotation`, `set_opacity`, `set_radius`, `set_minmax`, `set_text`, `set_font`, `set_font_range`, `set_text_resize`, `set_visible`, `set_blend`, `set_locked`, `set_stroke_align`
- Node operations: `node_bounds`, `node_move`, `node_resize`, `node_ancestors`, `node_children`, `node_tree`, `node_bindings`, `node_replace_with`
- Variable CRUD: `get_variable`, `find_variables`, `create_variable`, `set_variable`, `delete_variable`, `bind_variable`
- Collection CRUD: `get_collection`, `create_collection`, `delete_collection`
- Boolean operations: `boolean_union`, `boolean_subtract`, `boolean_intersect`, `boolean_exclude`
- Vector path tools: `path_get`, `path_set`, `path_scale`, `path_flip`, `path_move`
- Create tools: `create_page`, `create_vector`, `create_slice`
- Viewport: `viewport_get`, `viewport_set`, `viewport_zoom_to_fit`, `page_bounds`
- Misc: `flatten_nodes`, `list_fonts`
### Build
- Auto-populate GitHub Release notes from CHANGELOG.md via `ffurrer2/extract-release-notes@v2`
- Skip already-published npm versions on CI re-runs instead of failing
### Internal
- Extract shared color constants (`BLACK`, `TRANSPARENT`, `DEFAULT_SHADOW_COLOR`) — replaces 8 inline literals across core
## [0.4.2] (2026-03-02)
### Fixes

View file

@ -1,12 +1,13 @@
import { inflateSync, deflateSync } from 'fflate'
import { BLACK } from './constants'
import { styleToWeight } from './fonts'
import {
sceneNodeToKiwi,
buildFigKiwi,
parseFigKiwiChunks,
decompressFigKiwiDataAsync
} from './kiwi-serialize'
import { styleToWeight } from './fonts'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec'
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi/kiwi-schema'
import { decodeVectorNetworkBlob } from './vector'
@ -104,10 +105,24 @@ function decodeVectorData(nc: KiwiNodeChange, blobs: Uint8Array[]): VectorNetwor
}
const NON_VISUAL_TYPES = new Set([
'DOCUMENT', 'CANVAS', 'VARIABLE_SET', 'VARIABLE', 'VARIABLE_COLLECTION',
'STYLE', 'STYLE_SET', 'INTERNAL_ONLY_NODE', 'WIDGET', 'STAMP', 'STICKY',
'SHAPE_WITH_TEXT', 'CONNECTOR', 'CODE_BLOCK', 'TABLE_NODE', 'TABLE_CELL',
'SECTION_OVERLAY', 'SLIDE',
'DOCUMENT',
'CANVAS',
'VARIABLE_SET',
'VARIABLE',
'VARIABLE_COLLECTION',
'STYLE',
'STYLE_SET',
'INTERNAL_ONLY_NODE',
'WIDGET',
'STAMP',
'STICKY',
'SHAPE_WITH_TEXT',
'CONNECTOR',
'CODE_BLOCK',
'TABLE_NODE',
'TABLE_CELL',
'SECTION_OVERLAY',
'SLIDE'
])
export function figmaNodesBounds(
@ -170,7 +185,11 @@ export function importClipboardNodes(
for (const [id, nc] of guidMap) {
if (NON_VISUAL_TYPES.has(nc.type ?? '')) continue
const parentId = parentMap.get(id)
if (!parentId || !guidMap.has(parentId) || NON_VISUAL_TYPES.has(guidMap.get(parentId)?.type ?? '')) {
if (
!parentId ||
!guidMap.has(parentId) ||
NON_VISUAL_TYPES.has(guidMap.get(parentId)?.type ?? '')
) {
topLevel.push(id)
}
}
@ -195,7 +214,7 @@ export function importClipboardNodes(
.filter((p) => p.type === 'SOLID' && p.color)
.map((p) => ({
type: 'SOLID' as const,
color: p.color ?? { r: 0, g: 0, b: 0, a: 1 },
color: p.color ?? { ...BLACK },
opacity: p.opacity ?? 1,
visible: p.visible ?? true
}))
@ -203,7 +222,7 @@ export function importClipboardNodes(
const strokes: Stroke[] = (nc.strokePaints ?? [])
.filter((p) => p.type === 'SOLID' && p.color)
.map((p) => ({
color: p.color ?? { r: 0, g: 0, b: 0, a: 1 },
color: p.color ?? { ...BLACK },
weight: nc.strokeWeight ?? 1,
opacity: p.opacity ?? 1,
visible: p.visible ?? true,
@ -261,9 +280,7 @@ export function importClipboardNodes(
(nc.stackPositioning as string) === 'ABSOLUTE' ? ('ABSOLUTE' as const) : ('AUTO' as const),
layoutGrow: (nc.stackChildPrimaryGrow as number) ?? 0,
layoutAlignSelf:
(nc.stackChildAlignSelf as string) === 'STRETCH'
? ('STRETCH' as const)
: ('AUTO' as const),
(nc.stackChildAlignSelf as string) === 'STRETCH' ? ('STRETCH' as const) : ('AUTO' as const),
clipsContent: nc.frameMaskDisabled === false,
textAutoResize: 'NONE' as const,
fontWeight: nc.fontWeight ?? styleToWeight(nc.fontName?.style ?? ''),
@ -329,10 +346,7 @@ function mapCounterAlign(align?: string): LayoutCounterAlign {
return 'MIN'
}
function mapLetterSpacing(
ls?: { value: number; units: string },
fontSize?: number
): number {
function mapLetterSpacing(ls?: { value: number; units: string }, fontSize?: number): number {
if (!ls) return 0
if (ls.units === 'PIXELS') return ls.value
if (ls.units === 'PERCENT') return (ls.value / 100) * (fontSize ?? 14)

View file

@ -1,12 +1,14 @@
import { parse, formatHex, converter } from 'culori'
import { BLACK } from './constants'
import type { Color } from './types'
const toRgb = converter('rgb')
export function parseColor(input: string): Color {
const parsed = parse(input)
if (!parsed) return { r: 0, g: 0, b: 0, a: 1 }
if (!parsed) return { ...BLACK }
const rgb = toRgb(parsed)
return {
r: rgb?.r ?? 0,

View file

@ -2,6 +2,9 @@ import type { Color } from './types'
export const IS_TAURI = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
export const BLACK: Color = { r: 0, g: 0, b: 0, a: 1 }
export const TRANSPARENT: Color = { r: 0, g: 0, b: 0, a: 0 }
export const DEFAULT_SHADOW_COLOR: Color = { r: 0, g: 0, b: 0, a: 0.25 }
export const SELECTION_COLOR = { r: 0.23, g: 0.51, b: 0.96, a: 1 } satisfies Color
export const COMPONENT_COLOR = { r: 0.592, g: 0.278, b: 1, a: 1 } satisfies Color
export const SNAP_COLOR = { r: 1.0, g: 0.0, b: 0.56, a: 1 } satisfies Color
@ -71,7 +74,7 @@ export const RULER_TARGET_PIXEL_SPACING = 100
export const RULER_MAJOR_TOLERANCE = 0.01
export const TEXT_SELECTION_COLOR = { r: 0.26, g: 0.52, b: 0.96, a: 0.3 }
export const TEXT_CARET_COLOR = { r: 0, g: 0, b: 0, a: 1 }
export const TEXT_CARET_COLOR = BLACK
export const TEXT_CARET_WIDTH = 1
export interface ModelOption {

View file

@ -1,6 +1,6 @@
import { zipSync, deflateSync } from 'fflate'
import { IS_TAURI } from './constants'
import { CANVAS_BG_COLOR, IS_TAURI } from './constants'
import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi } from './kiwi-serialize'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec'
import { renderThumbnail } from './render-image'
@ -72,7 +72,7 @@ export async function exportFigFile(
strokeAlign: 'CENTER',
strokeJoin: 'MITER',
backgroundOpacity: 1,
backgroundColor: { r: 0.96, g: 0.96, b: 0.96, a: 1 },
backgroundColor: { ...CANVAS_BG_COLOR },
backgroundEnabled: true
})

View file

@ -8,7 +8,9 @@ import type {
Effect,
LayoutMode,
Variable,
VariableCollection
VariableCollection,
VariableType,
VariableValue
} from './scene-graph'
import type { Rect } from './types'
@ -1163,6 +1165,100 @@ export class FigmaAPI {
return this.graph.variableCollections.get(id) ?? null
}
// --- Variable/Collection CRUD ---
createVariable(
name: string,
type: VariableType,
collectionId: string,
value?: VariableValue
): Variable {
return this.graph.createVariable(name, type, collectionId, value)
}
setVariableValue(variableId: string, modeId: string, value: VariableValue): void {
const variable = this.graph.variables.get(variableId)
if (!variable) throw new Error(`Variable "${variableId}" not found`)
variable.valuesByMode[modeId] = value
}
deleteVariable(id: string): void {
this.graph.removeVariable(id)
}
createVariableCollection(name: string): VariableCollection {
return this.graph.createCollection(name)
}
deleteVariableCollection(id: string): void {
this.graph.removeCollection(id)
}
bindVariable(nodeId: string, field: string, variableId: string): void {
this.graph.bindVariable(nodeId, field, variableId)
}
unbindVariable(nodeId: string, field: string): void {
this.graph.unbindVariable(nodeId, field)
}
// --- Boolean Operations ---
booleanOperation(
operation: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE',
nodeIds: string[]
): FigmaNodeProxy {
if (nodeIds.length < 2) throw new Error('Need at least 2 nodes for boolean operation')
const nodes = nodeIds.map((id) => this.graph.getNode(id))
if (nodes.some((n) => !n)) throw new Error('One or more nodes not found')
const first = nodes[0]!
const parentId = first.parentId ?? this._currentPageId
const group = this.graph.createNode('GROUP', parentId, {
name: `Boolean ${operation.toLowerCase()}`,
x: first.x,
y: first.y,
width: first.width,
height: first.height
})
for (const id of nodeIds) {
this.graph.reparentNode(id, group.id)
}
return this.wrapNode(group.id)
}
// --- Flatten ---
flattenNode(nodeIds: string[]): FigmaNodeProxy {
if (nodeIds.length === 0) throw new Error('Need at least 1 node to flatten')
const first = this.graph.getNode(nodeIds[0])
if (!first) throw new Error('Node not found')
const parentId = first.parentId ?? this._currentPageId
const vector = this.graph.createNode('VECTOR', parentId, {
name: 'Flatten',
x: first.x,
y: first.y,
width: first.width,
height: first.height,
fills: structuredClone(first.fills)
})
for (const id of nodeIds) {
this.graph.deleteNode(id)
}
return this.wrapNode(vector.id)
}
// --- Viewport ---
private _viewport = { x: 0, y: 0, zoom: 1 }
get viewport(): { center: { x: number; y: number }; zoom: number } {
return { center: { x: this._viewport.x, y: this._viewport.y }, zoom: this._viewport.zoom }
}
set viewport(v: { center: { x: number; y: number }; zoom: number }) {
this._viewport = { x: v.center.x, y: v.center.y, zoom: v.zoom }
}
// --- Stubs ---
async loadFontAsync(_fontName: FigmaFontName): Promise<void> {

View file

@ -4,6 +4,7 @@ export * from './constants'
export {
SceneGraph,
generateId,
type SceneNode,
type NodeType,
type Fill,

View file

@ -1,4 +1,4 @@
import { DEFAULT_STROKE_MITER_LIMIT } from '../constants'
import { BLACK, DEFAULT_STROKE_MITER_LIMIT } from '../constants'
import { styleToWeight } from '../fonts'
import { SceneGraph } from '../scene-graph'
import { decodeVectorNetworkBlob } from '../vector'
@ -40,7 +40,7 @@ function guidToString(guid: GUID): string {
}
function convertColor(color?: { r: number; g: number; b: number; a: number }): Color {
if (!color) return { r: 0, g: 0, b: 0, a: 1 }
if (!color) return { ...BLACK }
return { r: color.r, g: color.g, b: color.b, a: color.a }
}

View file

@ -1,4 +1,5 @@
import { parseColor, colorToFill } from '../color'
import { TRANSPARENT } from '../constants'
import { isTreeNode } from './tree'
import type { SceneGraph, SceneNode, NodeType, LayoutMode, Stroke } from '../scene-graph'
@ -287,7 +288,7 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
type: 'LAYER_BLUR',
radius: props.blur as number,
visible: true,
color: { r: 0, g: 0, b: 0, a: 0 },
color: { ...TRANSPARENT },
offset: { x: 0, y: 0 },
spread: 0
}

View file

@ -2083,7 +2083,6 @@ export class SkiaRenderer {
canvas.drawCircle(v.x, v.y, radius, vertexFill)
canvas.drawCircle(v.x, v.y, radius, vertexStroke)
}
}
// --- Remote Cursors ---
@ -2330,7 +2329,6 @@ export class SkiaRenderer {
sy2,
'vertical'
)
}
}

View file

@ -1,4 +1,4 @@
import { DEFAULT_STROKE_MITER_LIMIT } from './constants'
import { BLACK, DEFAULT_STROKE_MITER_LIMIT } from './constants'
export type { GUID, Color } from './types'
@ -295,7 +295,7 @@ export interface VariableCollection {
let nextLocalID = 1
function generateId(): string {
export function generateId(): string {
return `0:${nextLocalID++}`
}
@ -467,6 +467,49 @@ export class SceneGraph {
}
}
createVariable(
name: string,
type: VariableType,
collectionId: string,
value?: VariableValue
): Variable {
const collection = this.variableCollections.get(collectionId)
if (!collection) throw new Error(`Collection "${collectionId}" not found`)
const id = generateId()
const defaultValue =
value ??
(type === 'COLOR' ? { ...BLACK } : type === 'FLOAT' ? 0 : type === 'BOOLEAN' ? false : '')
const valuesByMode: Record<string, VariableValue> = {}
for (const mode of collection.modes) {
valuesByMode[mode.modeId] = structuredClone(defaultValue)
}
const variable: Variable = {
id,
name,
type,
collectionId,
valuesByMode,
description: '',
hiddenFromPublishing: false
}
this.addVariable(variable)
return variable
}
createCollection(name: string): VariableCollection {
const id = generateId()
const modeId = generateId()
const collection: VariableCollection = {
id,
name,
modes: [{ modeId, name: 'Mode 1' }],
defaultModeId: modeId,
variableIds: []
}
this.addCollection(collection)
return collection
}
removeCollection(id: string): void {
const collection = this.variableCollections.get(id)
if (collection) {

File diff suppressed because it is too large Load diff