Merge branch 'port-tools'
This commit is contained in:
commit
6f88a2e47e
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export * from './constants'
|
|||
|
||||
export {
|
||||
SceneGraph,
|
||||
generateId,
|
||||
type SceneNode,
|
||||
type NodeType,
|
||||
type Fill,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue