From e0878c1e687315dba696087f1b2006b2bf46ee25 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 28 Mar 2026 14:33:28 +0300 Subject: [PATCH] Rename format files to match io folders --- packages/core/package.json | 10 - packages/core/src/iconify.ts | 2 +- packages/core/src/index.ts | 6 +- .../formats/fig/export-worker.ts} | 2 +- .../formats/fig/export.ts} | 24 +- packages/core/src/io/formats/fig/write.ts | 2 +- packages/core/src/io/formats/pen/read.ts | 2 +- .../core/src/io/formats/raster/headless.ts | 2 +- packages/core/src/io/formats/raster/index.ts | 2 +- .../raster/{render-image.ts => render.ts} | 0 packages/core/src/io/formats/svg/defs.ts | 2 +- packages/core/src/io/formats/svg/export.ts | 4 +- .../{svg-node.ts => io/formats/svg/node.ts} | 0 .../formats/svg/parse-path.ts} | 2 +- packages/core/src/pen-convert.ts | 463 ------------ packages/core/src/pen-file.ts | 513 -------------- packages/core/src/render-image.ts | 1 - packages/core/src/render/export-jsx.ts | 661 ------------------ tests/engine/multi-selection.test.ts | 2 +- 19 files changed, 26 insertions(+), 1674 deletions(-) rename packages/core/src/{fig-export-worker.ts => io/formats/fig/export-worker.ts} (87%) rename packages/core/src/{fig-export.ts => io/formats/fig/export.ts} (91%) rename packages/core/src/io/formats/raster/{render-image.ts => render.ts} (100%) rename packages/core/src/{svg-node.ts => io/formats/svg/node.ts} (100%) rename packages/core/src/{svg-path-parse.ts => io/formats/svg/parse-path.ts} (98%) delete mode 100644 packages/core/src/pen-convert.ts delete mode 100644 packages/core/src/pen-file.ts delete mode 100644 packages/core/src/render-image.ts delete mode 100644 packages/core/src/render/export-jsx.ts diff --git a/packages/core/package.json b/packages/core/package.json index fa00e3ff6..93fbbd4ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -60,11 +60,6 @@ "bun": "./src/color.ts", "default": "./src/color.ts" }, - "./render-image": { - "types": "./src/render-image.ts", - "bun": "./src/render-image.ts", - "default": "./src/render-image.ts" - }, "./profiler": { "types": "./src/profiler/index.ts", "bun": "./src/profiler/index.ts", @@ -177,11 +172,6 @@ "import": "./dist/color.js", "default": "./dist/color.js" }, - "./render-image": { - "types": "./dist/render-image.d.ts", - "import": "./dist/render-image.js", - "default": "./dist/render-image.js" - }, "./profiler": { "types": "./dist/profiler/index.d.ts", "import": "./dist/profiler/index.js", diff --git a/packages/core/src/iconify.ts b/packages/core/src/iconify.ts index 3cdd5ad4a..0e623c94c 100644 --- a/packages/core/src/iconify.ts +++ b/packages/core/src/iconify.ts @@ -1,7 +1,7 @@ import { iconToSVG } from '@iconify/utils' import svgpath from 'svgpath' -import { parseSVGPath } from './svg-path-parse' +import { parseSVGPath } from './io/formats/svg/parse-path' import type { VectorNetwork, WindingRule } from './scene-graph' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 379e46b8e..7cd7c2f06 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -229,8 +229,8 @@ export { vectorNetworkToSVGPaths, type SVGExportOptions } from './io/formats/svg/export' -export { svg, renderSVGNode, type SVGNode } from './svg-node' -export { parseSVGPath } from './svg-path-parse' +export { svg, renderSVGNode, type SVGNode } from './io/formats/svg/node' +export { parseSVGPath } from './io/formats/svg/parse-path' export { fetchIcon, fetchIcons, @@ -241,7 +241,7 @@ export { type IconPath, type IconSearchResult } from './iconify' -export { exportFigFile, compressFigData, compressFigDataSync } from './fig-export' +export { exportFigFile, compressFigData, compressFigDataSync } from './io/formats/fig/export' export { FIG_KIWI_DEFAULT_VERSION, buildFigKiwi, diff --git a/packages/core/src/fig-export-worker.ts b/packages/core/src/io/formats/fig/export-worker.ts similarity index 87% rename from packages/core/src/fig-export-worker.ts rename to packages/core/src/io/formats/fig/export-worker.ts index e3eb000a1..bf88605a9 100644 --- a/packages/core/src/fig-export-worker.ts +++ b/packages/core/src/io/formats/fig/export-worker.ts @@ -1,4 +1,4 @@ -import { compressFigDataSync } from './fig-compress' +import { compressFigDataSync } from '@open-pencil/core/fig-compress' interface CompressMessage { schemaDeflated: Uint8Array diff --git a/packages/core/src/fig-export.ts b/packages/core/src/io/formats/fig/export.ts similarity index 91% rename from packages/core/src/fig-export.ts rename to packages/core/src/io/formats/fig/export.ts index 776ffc32f..c86a8db9e 100644 --- a/packages/core/src/fig-export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -1,10 +1,10 @@ import { deflateSync } from 'fflate' -import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from './constants' -import { compressFigDataSync } from './fig-compress' -import { renderThumbnail } from './io/formats/raster' -import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec' -import { stringToGuid } from './kiwi/kiwi-convert' +import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from '@open-pencil/core/constants' +import { compressFigDataSync } from '@open-pencil/core/fig-compress' +import { renderThumbnail } from '@open-pencil/core/io/formats/raster' +import { initCodec, getCompiledSchema, getSchemaBytes } from '@open-pencil/core/kiwi/codec' +import { stringToGuid } from '@open-pencil/core/kiwi/kiwi-convert' import { sceneNodeToKiwi, fractionalPosition, @@ -12,12 +12,12 @@ import { safeColor, makeDocumentNodeChange, makeCanvasNodeChange -} from './kiwi/kiwi-serialize' +} from '@open-pencil/core/kiwi/kiwi-serialize' -import type { NodeChange } from './kiwi/codec' -import type { SkiaRenderer } from './renderer' -import type { SceneGraph, VariableValue } from './scene-graph' -import type { GUID } from './types' +import type { NodeChange } from '@open-pencil/core/kiwi/codec' +import type { SkiaRenderer } from '@open-pencil/core/renderer' +import type { SceneGraph, VariableValue } from '@open-pencil/core/scene-graph' +import type { GUID } from '@open-pencil/core/types' import type { CanvasKit } from 'canvaskit-wasm' const THUMBNAIL_1X1 = Uint8Array.from( @@ -246,7 +246,7 @@ export async function exportFigFile( return compressFigData(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries, version) } -export { compressFigDataSync } from './fig-compress' +export { compressFigDataSync } from '@open-pencil/core/fig-compress' function canUseWorker(): boolean { return typeof Worker !== 'undefined' && IS_BROWSER @@ -260,7 +260,7 @@ function compressViaWorker( imageEntries: Array<{ name: string; data: Uint8Array }> ): Promise { return new Promise((resolve, reject) => { - const worker = new Worker(new URL('./fig-export-worker.ts', import.meta.url), { + const worker = new Worker(new URL('./export-worker.ts', import.meta.url), { type: 'module' }) diff --git a/packages/core/src/io/formats/fig/write.ts b/packages/core/src/io/formats/fig/write.ts index 21a770cdf..76890c209 100644 --- a/packages/core/src/io/formats/fig/write.ts +++ b/packages/core/src/io/formats/fig/write.ts @@ -1 +1 @@ -export { exportFigFile, compressFigData, compressFigDataSync } from '../../../fig-export' +export { exportFigFile, compressFigData, compressFigDataSync } from './export' diff --git a/packages/core/src/io/formats/pen/read.ts b/packages/core/src/io/formats/pen/read.ts index 23d534ad2..d96ac2eea 100644 --- a/packages/core/src/io/formats/pen/read.ts +++ b/packages/core/src/io/formats/pen/read.ts @@ -20,9 +20,9 @@ import { type PenNode, type VarContext } from '@open-pencil/core/io/formats/pen/convert' +import { parseSVGPath } from '@open-pencil/core/io/formats/svg/parse-path' import { SceneGraph } from '@open-pencil/core/scene-graph' import { populateInstanceChildren } from '@open-pencil/core/scene-graph-instances' -import { parseSVGPath } from '@open-pencil/core/svg-path-parse' import type { LayoutMode, diff --git a/packages/core/src/io/formats/raster/headless.ts b/packages/core/src/io/formats/raster/headless.ts index 2b9ffe07a..77e4c4fad 100644 --- a/packages/core/src/io/formats/raster/headless.ts +++ b/packages/core/src/io/formats/raster/headless.ts @@ -1,6 +1,6 @@ import { SkiaRenderer } from '@open-pencil/core/renderer' -import { renderNodesToImage, renderThumbnail, type ExportFormat } from './render-image' +import { renderNodesToImage, renderThumbnail, type ExportFormat } from './render' import type { SceneGraph } from '@open-pencil/core/scene-graph' import type { CanvasKit } from 'canvaskit-wasm' diff --git a/packages/core/src/io/formats/raster/index.ts b/packages/core/src/io/formats/raster/index.ts index 05481e850..674841fcd 100644 --- a/packages/core/src/io/formats/raster/index.ts +++ b/packages/core/src/io/formats/raster/index.ts @@ -4,5 +4,5 @@ export { renderThumbnail, type RasterExportFormat, type ExportFormat -} from './render-image' +} from './render' export { initCanvasKit, headlessRenderNodes, headlessRenderThumbnail } from './headless' diff --git a/packages/core/src/io/formats/raster/render-image.ts b/packages/core/src/io/formats/raster/render.ts similarity index 100% rename from packages/core/src/io/formats/raster/render-image.ts rename to packages/core/src/io/formats/raster/render.ts diff --git a/packages/core/src/io/formats/svg/defs.ts b/packages/core/src/io/formats/svg/defs.ts index 0768285ce..6ade7031b 100644 --- a/packages/core/src/io/formats/svg/defs.ts +++ b/packages/core/src/io/formats/svg/defs.ts @@ -1,6 +1,6 @@ import { colorToHex, colorToHex8 } from '@open-pencil/core/color' -import { svg, type SVGNode } from '@open-pencil/core/svg-node' +import { svg, type SVGNode } from './node' import { round } from './paths' import type { Effect, Fill, SceneGraph, SceneNode } from '@open-pencil/core/scene-graph' diff --git a/packages/core/src/io/formats/svg/export.ts b/packages/core/src/io/formats/svg/export.ts index 2b4bd51fa..60d0f9020 100644 --- a/packages/core/src/io/formats/svg/export.ts +++ b/packages/core/src/io/formats/svg/export.ts @@ -21,9 +21,10 @@ import { export { geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './paths' -import { svg, renderSVGNode } from '@open-pencil/core/svg-node' +import { svg, renderSVGNode } from './node' import type { SVGExportContext } from './defs' +import type { SVGNode } from './node' import type { SceneGraph, SceneNode, @@ -31,7 +32,6 @@ import type { Stroke, CharacterStyleOverride } from '@open-pencil/core/scene-graph' -import type { SVGNode } from '@open-pencil/core/svg-node' // --- Node rendering --- diff --git a/packages/core/src/svg-node.ts b/packages/core/src/io/formats/svg/node.ts similarity index 100% rename from packages/core/src/svg-node.ts rename to packages/core/src/io/formats/svg/node.ts diff --git a/packages/core/src/svg-path-parse.ts b/packages/core/src/io/formats/svg/parse-path.ts similarity index 98% rename from packages/core/src/svg-path-parse.ts rename to packages/core/src/io/formats/svg/parse-path.ts index afd1d8bbe..314171922 100644 --- a/packages/core/src/svg-path-parse.ts +++ b/packages/core/src/io/formats/svg/parse-path.ts @@ -6,7 +6,7 @@ import type { VectorSegment, VectorRegion, WindingRule -} from './scene-graph' +} from '@open-pencil/core/scene-graph' interface SubPath { startVertexIndex: number diff --git a/packages/core/src/pen-convert.ts b/packages/core/src/pen-convert.ts deleted file mode 100644 index 24979bc2b..000000000 --- a/packages/core/src/pen-convert.ts +++ /dev/null @@ -1,463 +0,0 @@ -import { parseColor } from './color' -import { generateId } from './scene-graph' - -import type { - Color, - Effect, - Fill, - LayoutAlign, - LayoutCounterAlign, - LayoutMode, - LayoutSizing, - NodeType, - SceneGraph, - SceneNode, - Stroke, - StrokeCap, - StrokeJoin, - TextAlignVertical, - Variable, - VariableCollection, - VariableCollectionMode, - VariableType, - VariableValue -} from './scene-graph' -import type { Vector } from './types' - -export interface PenDocument { - version: string - children: PenNode[] - themes?: Record - variables?: Record -} - -export interface PenVariable { - type: 'color' | 'string' | 'number' - value: PenVariableValue[] | PenVariableValue | string | number -} - -interface PenVariableValue { - value: string | number - theme?: Record -} - -interface PenStroke { - align: 'inside' | 'center' | 'outside' - thickness: number | { top?: number; right?: number; bottom?: number; left?: number } - fill?: string - join?: string - cap?: string -} - -interface PenEffect { - type: string - shadowType?: string - color?: string - offset?: Vector - blur?: number - spread?: number -} - -interface PenFillObject { - type: string - color: string - enabled?: boolean -} - -type PenFill = string | PenFillObject | PenFillObject[] - -export interface PenNode { - type: string - id: string - name?: string - x?: number - y?: number - width?: number | string - height?: number | string - fill?: PenFill - opacity?: number - enabled?: boolean - clip?: boolean - rotation?: number - flipX?: boolean - flipY?: boolean - reusable?: boolean - cornerRadius?: number | string | (number | string)[] - stroke?: PenStroke - effect?: PenEffect | PenEffect[] - layout?: string - gap?: number - padding?: number | number[] - justifyContent?: string - alignItems?: string - children?: PenNode[] - content?: string - fontFamily?: string - fontSize?: number - fontWeight?: string | number - lineHeight?: number - letterSpacing?: number - textAlign?: string - textAlignVertical?: string - textGrowth?: string - ref?: string - descendants?: Record> - slot?: string[] - geometry?: string - iconFontName?: string - iconFontFamily?: string - weight?: number - model?: string - theme?: Record -} - -export interface VarContext { - byName: Map - activeModeId: string - collectionId: string - modeByThemeName: Map - resolveColor(ref: string): Color - resolveNumber(ref: string): number - resolveString(ref: string): string - setActiveTheme(themeName: string): void -} - -function penVarTypeToSceneType(t: string): VariableType { - if (t === 'color') return 'COLOR' - if (t === 'number') return 'FLOAT' - return 'STRING' -} - -function penValueToSceneValue(raw: string | number, type: VariableType): VariableValue { - if (type === 'COLOR' && typeof raw === 'string') return parseColor(raw) - if (type === 'FLOAT' && typeof raw === 'number') return raw - if (type === 'STRING') return String(raw) - if (typeof raw === 'number') return raw - return String(raw) -} - -function defaultForType(type: VariableType): VariableValue { - if (type === 'COLOR') return { r: 0, g: 0, b: 0, a: 1 } - if (type === 'FLOAT') return 0 - if (type === 'BOOLEAN') return false - return '' -} - -export function isVarRef(val: unknown): val is string { - return typeof val === 'string' && val.startsWith('$--') -} - -function varName(ref: string): string { - return ref.replace(/^\$/, '') -} - -export function bindIfVar(node: SceneNode, field: string, val: unknown, ctx: VarContext): void { - if (!isVarRef(val)) return - const entry = ctx.byName.get(varName(val)) - if (entry) node.boundVariables[field] = entry.id -} - -export function buildVarContext( - graph: SceneGraph, - penVars: Record, - themes: Record -): VarContext { - const collectionId = generateId() - const modes: VariableCollectionMode[] = [] - const themeKeys = Object.keys(themes) - - if (themeKeys.length > 0) { - const themeKey = themeKeys[0] - for (const modeName of themes[themeKey]) { - modes.push({ modeId: generateId(), name: modeName }) - } - } - if (modes.length === 0) { - modes.push({ modeId: generateId(), name: 'Default' }) - } - - const collection: VariableCollection = { - id: collectionId, - name: 'Variables', - modes, - defaultModeId: modes[0].modeId, - variableIds: [] - } - graph.addCollection(collection) - - const modeByThemeValue = new Map() - if (themeKeys.length > 0) { - const themeKey = themeKeys[0] - for (const mode of modes) { - modeByThemeValue.set(`${themeKey}:${mode.name}`, mode.modeId) - } - } - - const byName = new Map() - - for (const [name, def] of Object.entries(penVars)) { - const varId = generateId() - const varType = penVarTypeToSceneType(def.type) - const valuesByMode: Record = {} - - if (Array.isArray(def.value)) { - for (const entry of def.value) { - if (entry.theme) { - const [tKey, tVal] = Object.entries(entry.theme)[0] - const modeId = modeByThemeValue.get(`${tKey}:${tVal}`) - if (modeId) valuesByMode[modeId] = penValueToSceneValue(entry.value, varType) - } else { - valuesByMode[modes[0].modeId] = penValueToSceneValue(entry.value, varType) - } - } - } else { - valuesByMode[modes[0].modeId] = penValueToSceneValue(def.value as string | number, varType) - } - - for (const mode of modes) { - if (!(mode.modeId in valuesByMode)) { - valuesByMode[mode.modeId] = valuesByMode[modes[0].modeId] ?? defaultForType(varType) - } - } - - const variable: Variable = { - id: varId, - name, - type: varType, - collectionId, - valuesByMode, - description: '', - hiddenFromPublishing: false - } - graph.addVariable(variable) - byName.set(name, { id: varId, variable }) - } - - let activeModeId = modes[0].modeId - - function resolveVal(ref: string): VariableValue | undefined { - const entry = byName.get(ref.replace(/^\$/, '')) - if (!entry) return undefined - return ( - entry.variable.valuesByMode[activeModeId] ?? Object.values(entry.variable.valuesByMode)[0] - ) - } - - return { - byName, - activeModeId, - collectionId, - modeByThemeName: modeByThemeValue, - resolveColor(ref: string): Color { - const val = resolveVal(ref) - if (val === undefined) return parseColor(ref) - if (typeof val === 'object' && 'r' in val) return val - if (typeof val === 'string') return parseColor(val) - return { r: 0, g: 0, b: 0, a: 1 } - }, - resolveNumber(ref: string): number { - const val = resolveVal(ref) - return typeof val === 'number' ? val : 0 - }, - resolveString(ref: string): string { - const val = resolveVal(ref) - return typeof val === 'string' ? val : '' - }, - setActiveTheme(themeName: string) { - const modeId = modeByThemeValue.get(`theme:${themeName}`) - if (modeId) { - activeModeId = modeId - graph.activeMode.set(collectionId, modeId) - } - } - } -} - -function parseFillColor(fill: string | PenFillObject, ctx: VarContext): Color { - const raw = typeof fill === 'string' ? fill : fill.color - return isVarRef(raw) ? ctx.resolveColor(raw) : parseColor(raw) -} - -export function convertFill(fill: PenFill | undefined, ctx: VarContext, node?: SceneNode): Fill[] { - if (fill === undefined) return [] - const fills = Array.isArray(fill) ? fill : [fill] - return fills.map((item, index) => { - const visible = typeof item === 'string' ? true : item.enabled !== false - const color = parseFillColor(item, ctx) - const result: Fill = { type: 'SOLID', visible, opacity: color.a, color } - if (node) bindIfVar(node, `fills[${index}]`, typeof item === 'string' ? item : item.color, ctx) - return result - }) -} - -function strokeWeight(stroke: PenStroke): number { - return typeof stroke.thickness === 'number' - ? stroke.thickness - : Math.max(...Object.values(stroke.thickness)) -} - -export function convertStroke( - stroke: PenStroke | undefined, - ctx: VarContext, - node?: SceneNode -): Stroke[] { - if (!stroke?.fill) return [] - const color = isVarRef(stroke.fill) ? ctx.resolveColor(stroke.fill) : parseColor(stroke.fill) - let align: Stroke['align'] = 'CENTER' - if (stroke.align === 'inside') align = 'INSIDE' - else if (stroke.align === 'outside') align = 'OUTSIDE' - - const result: Stroke = { - visible: true, - color, - opacity: color.a, - weight: strokeWeight(stroke), - align, - dashPattern: [] - } - if (node) { - bindIfVar(node, 'strokes[0]', stroke.fill, ctx) - if (typeof stroke.thickness === 'object') { - node.independentStrokeWeights = true - node.borderTopWeight = stroke.thickness.top ?? 0 - node.borderRightWeight = stroke.thickness.right ?? 0 - node.borderBottomWeight = stroke.thickness.bottom ?? 0 - node.borderLeftWeight = stroke.thickness.left ?? 0 - } - node.strokeJoin = mapStrokeJoin(stroke.join) - node.strokeCap = mapStrokeCap(stroke.cap) - } - return [result] -} - -function mapStrokeJoin(join: string | undefined): StrokeJoin { - if (join === 'round') return 'ROUND' - if (join === 'bevel') return 'BEVEL' - return 'MITER' -} - -function mapStrokeCap(cap: string | undefined): StrokeCap { - if (cap === 'round') return 'ROUND' - if (cap === 'square') return 'SQUARE' - return 'NONE' -} - -export function convertEffects(effect: PenEffect | PenEffect[] | undefined): Effect[] { - if (!effect) return [] - const effects = Array.isArray(effect) ? effect : [effect] - return effects.flatMap((item) => { - if (item.type !== 'shadow') return [] - const color = item.color ? parseColor(item.color) : { r: 0, g: 0, b: 0, a: 0.25 } - return [ - { - type: item.shadowType === 'inner' ? 'INNER_SHADOW' : 'DROP_SHADOW', - visible: true, - blendMode: 'NORMAL', - color, - offset: item.offset ?? { x: 0, y: 0 }, - radius: item.blur ?? 0, - spread: item.spread ?? 0 - } satisfies Effect - ] - }) -} - -export function applyCornerRadius( - node: SceneNode, - radius: PenNode['cornerRadius'], - ctx: VarContext -): void { - if (radius === undefined) return - if (Array.isArray(radius)) { - const values = radius.map((value) => parseSize(value, 0, ctx).value) - node.independentCorners = true - node.topLeftRadius = values[0] ?? 0 - node.topRightRadius = values[1] ?? 0 - node.bottomRightRadius = values[2] ?? 0 - node.bottomLeftRadius = values[3] ?? 0 - return - } - node.cornerRadius = parseSize(radius, 0, ctx).value -} - -export function applyPadding(node: SceneNode, padding: PenNode['padding']): void { - if (padding === undefined) return - if (Array.isArray(padding)) { - node.paddingTop = padding[0] ?? 0 - node.paddingRight = padding[1] ?? 0 - node.paddingBottom = padding[2] ?? 0 - node.paddingLeft = padding[3] ?? 0 - return - } - node.paddingTop = padding - node.paddingRight = padding - node.paddingBottom = padding - node.paddingLeft = padding -} - -export function parseSize(value: number | string | undefined, fallback: number, ctx?: VarContext) { - if (value === undefined) return { value: fallback, sizing: 'FIXED' as LayoutSizing } - if (typeof value === 'number') return { value, sizing: 'FIXED' as LayoutSizing } - if (value === 'fill_container') return { value: fallback, sizing: 'FILL' as LayoutSizing } - if (value === 'hug_content') return { value: fallback, sizing: 'HUG' as LayoutSizing } - if (isVarRef(value) && ctx) - return { value: ctx.resolveNumber(value), sizing: 'FIXED' as LayoutSizing } - const parsed = Number(value) - return { value: Number.isFinite(parsed) ? parsed : fallback, sizing: 'FIXED' as LayoutSizing } -} - -export function mapLayoutMode(pen: PenNode): LayoutMode { - if (pen.layout === 'row' || pen.layout === 'horizontal') return 'HORIZONTAL' - if (pen.layout === 'column' || pen.layout === 'vertical') return 'VERTICAL' - return 'NONE' -} - -export function mapJustifyContent(value: string | undefined): LayoutAlign { - if (value === 'center') return 'CENTER' - if (value === 'end') return 'MAX' - if (value === 'space-between') return 'SPACE_BETWEEN' - return 'MIN' -} - -export function mapAlignItems(value: string | undefined): LayoutCounterAlign { - if (value === 'center') return 'CENTER' - if (value === 'end') return 'MAX' - if (value === 'stretch') return 'STRETCH' - return 'MIN' -} - -export function mapTextAlign(value: string | undefined): SceneNode['textAlignHorizontal'] { - if (value === 'center') return 'CENTER' - if (value === 'right' || value === 'end') return 'RIGHT' - if (value === 'justified') return 'JUSTIFIED' - return 'LEFT' -} - -export function mapTextAlignVertical(value: string | undefined): TextAlignVertical { - if (value === 'center') return 'CENTER' - if (value === 'bottom' || value === 'end') return 'BOTTOM' - return 'TOP' -} - -export function mapFontWeight(value: string | number | undefined): number { - if (typeof value === 'number') return value - if (value === 'thin') return 100 - if (value === 'extralight') return 200 - if (value === 'light') return 300 - if (value === 'medium') return 500 - if (value === 'semibold') return 600 - if (value === 'bold') return 700 - if (value === 'extrabold') return 800 - if (value === 'black') return 900 - return 400 -} - -export function mapNodeType(pen: PenNode): NodeType { - if (pen.type === 'frame') return pen.reusable ? 'COMPONENT' : 'FRAME' - if (pen.type === 'rectangle') return 'RECTANGLE' - if (pen.type === 'ellipse') return 'ELLIPSE' - if (pen.type === 'text' || pen.type === 'icon_font') return 'TEXT' - if (pen.type === 'path') return 'VECTOR' - if (pen.type === 'ref') return 'INSTANCE' - return 'FRAME' -} diff --git a/packages/core/src/pen-file.ts b/packages/core/src/pen-file.ts deleted file mode 100644 index 1c92cec5b..000000000 --- a/packages/core/src/pen-file.ts +++ /dev/null @@ -1,513 +0,0 @@ -import { copyEffects, copyFills, copyStrokes } from './copy' -import { - applyCornerRadius, - applyPadding, - bindIfVar, - buildVarContext, - convertEffects, - convertFill, - convertStroke, - isVarRef, - mapAlignItems, - mapFontWeight, - mapJustifyContent, - mapLayoutMode, - mapNodeType, - mapTextAlign, - mapTextAlignVertical, - parseSize, - type PenDocument, - type PenNode, - type VarContext -} from './pen-convert' -import { SceneGraph } from './scene-graph' -import { populateInstanceChildren } from './scene-graph-instances' -import { parseSVGPath } from './svg-path-parse' - -import type { LayoutMode, LayoutSizing, SceneNode, VectorNetwork } from './scene-graph' - -function scaleVectorNetwork(vn: VectorNetwork, targetW: number, targetH: number): void { - if (vn.vertices.length === 0) return - let minX = Infinity - let maxX = -Infinity - let minY = Infinity - let maxY = -Infinity - for (const v of vn.vertices) { - minX = Math.min(minX, v.x) - maxX = Math.max(maxX, v.x) - minY = Math.min(minY, v.y) - maxY = Math.max(maxY, v.y) - } - const vnW = maxX - minX - const vnH = maxY - minY - if (vnW < 0.01 || vnH < 0.01) return - const sx = targetW / vnW - const sy = targetH / vnH - if (Math.abs(sx - 1) < 0.01 && Math.abs(sy - 1) < 0.01) return - for (const v of vn.vertices) { - v.x = (v.x - minX) * sx - v.y = (v.y - minY) * sy - } - for (const s of vn.segments) { - s.tangentStart = { x: s.tangentStart.x * sx, y: s.tangentStart.y * sy } - s.tangentEnd = { x: s.tangentEnd.x * sx, y: s.tangentEnd.y * sy } - } -} - -function resolveFontFamily(raw: string | undefined, ctx: VarContext): string { - if (!raw) return 'Inter' - if (isVarRef(raw)) return ctx.resolveString(raw) - return raw -} - -function buildBaseOverrides(pen: PenNode): Partial { - return { - id: pen.id, - name: pen.name ?? (pen.type === 'icon_font' ? (pen.iconFontName ?? 'Icon') : pen.type), - x: pen.x ?? 0, - y: pen.y ?? 0, - visible: pen.enabled !== false, - opacity: pen.opacity ?? 1, - rotation: pen.rotation ?? 0, - flipX: pen.flipX ?? false, - flipY: pen.flipY ?? false, - clipsContent: pen.clip ?? false, - boundVariables: {} - } -} - -function applyAutoLayout( - overrides: Partial, - layoutMode: LayoutMode, - pen: PenNode, - widthSizing: LayoutSizing, - heightSizing: LayoutSizing -): void { - overrides.layoutMode = layoutMode - overrides.primaryAxisAlign = mapJustifyContent(pen.justifyContent) - overrides.counterAxisAlign = mapAlignItems(pen.alignItems) - overrides.itemSpacing = pen.gap ?? 0 - - if (layoutMode === 'VERTICAL') { - overrides.primaryAxisSizing = heightSizing - overrides.counterAxisSizing = widthSizing - } else { - overrides.primaryAxisSizing = widthSizing - overrides.counterAxisSizing = heightSizing - } -} - -function applyTextProps(node: SceneNode, pen: PenNode, ctx: VarContext): void { - node.text = pen.type === 'icon_font' ? (pen.iconFontName ?? '') : (pen.content ?? '') - node.fontFamily = - pen.type === 'icon_font' - ? (pen.iconFontFamily ?? 'Material Symbols Sharp') - : resolveFontFamily(pen.fontFamily, ctx) - node.fontSize = pen.fontSize ?? 14 - node.fontWeight = mapFontWeight( - pen.fontWeight ?? (pen.type === 'icon_font' ? pen.weight : undefined) - ) - node.textAlignHorizontal = mapTextAlign(pen.textAlign) - node.textAlignVertical = mapTextAlignVertical(pen.textAlignVertical) - if (pen.lineHeight !== undefined) { - node.lineHeight = pen.lineHeight < 5 ? pen.lineHeight * node.fontSize : pen.lineHeight - } - if (pen.letterSpacing !== undefined) node.letterSpacing = pen.letterSpacing - node.textAutoResize = pen.textGrowth === 'fixed-width' ? 'HEIGHT' : 'WIDTH_AND_HEIGHT' - if (pen.fontFamily && isVarRef(pen.fontFamily)) { - bindIfVar(node, 'fontFamily', pen.fontFamily, ctx) - } -} - -function resolveSizing(pen: PenNode, ctx: VarContext) { - const isTextLike = pen.type === 'text' || pen.type === 'icon_font' - const defaultSize = isTextLike ? 20 : 100 - const defaultW = isTextLike && pen.width === undefined ? 10_000 : defaultSize - const w = parseSize(pen.width, defaultW, ctx) - const h = parseSize(pen.height, defaultSize, ctx) - const layout = mapLayoutMode(pen) - - if (pen.width === undefined && layout !== 'NONE') w.sizing = 'HUG' - if (pen.height === undefined && layout !== 'NONE') h.sizing = 'HUG' - - return { w, h, layout, isTextLike } -} - -function inheritLayoutFromComp(node: SceneNode, pen: PenNode, comp: SceneNode): void { - const wasRow = node.layoutMode === 'HORIZONTAL' - node.layoutMode = comp.layoutMode - node.primaryAxisAlign = comp.primaryAxisAlign - node.counterAxisAlign = comp.counterAxisAlign - const isRow = node.layoutMode === 'HORIZONTAL' - if (wasRow !== isRow) { - const oldP = node.primaryAxisSizing - node.primaryAxisSizing = node.counterAxisSizing - node.counterAxisSizing = oldP - } - const widthAxis = isRow ? 'primaryAxisSizing' : 'counterAxisSizing' - const heightAxis = isRow ? 'counterAxisSizing' : 'primaryAxisSizing' - if (pen.width === undefined) node[widthAxis] = comp[widthAxis] - if (pen.height === undefined) node[heightAxis] = comp[heightAxis] - if (pen.gap === undefined) node.itemSpacing = comp.itemSpacing - if (pen.padding === undefined) { - node.paddingTop = comp.paddingTop - node.paddingRight = comp.paddingRight - node.paddingBottom = comp.paddingBottom - node.paddingLeft = comp.paddingLeft - } - if (pen.clip === undefined) node.clipsContent = comp.clipsContent -} - -function applyRefVisuals( - node: SceneNode, - pen: PenNode, - compPen: PenNode | undefined, - ctx: VarContext -): void { - if (!compPen) return - if (pen.fill === undefined && compPen.fill !== undefined) - node.fills = convertFill(compPen.fill, ctx, node) - if (pen.stroke === undefined && compPen.stroke) - node.strokes = convertStroke(compPen.stroke, ctx, node) - if (pen.effect === undefined && compPen.effect) node.effects = convertEffects(compPen.effect) - if (pen.cornerRadius === undefined) applyCornerRadius(node, compPen.cornerRadius, ctx) -} - -function applyRefProps( - node: SceneNode, - pen: PenNode, - graph: SceneGraph, - componentIds: Map, - penSources: Map, - ctx: VarContext -): void { - if (!pen.ref) return - node.componentId = componentIds.get(pen.ref) ?? pen.ref - const comp = graph.getNode(node.componentId) - if (!comp) return - if (pen.width === undefined) node.width = comp.width - if (pen.height === undefined) node.height = comp.height - if (pen.layout === undefined) inheritLayoutFromComp(node, pen, comp) - applyRefVisuals(node, pen, penSources.get(pen.ref), ctx) -} - -function applyAllRefProps( - penNodes: PenNode[], - graph: SceneGraph, - componentIds: Map, - penSources: Map, - ctx: VarContext -): void { - for (const pen of penNodes) { - if (pen.type === 'ref') { - const node = graph.getNode(pen.id) - if (node) applyRefProps(node, pen, graph, componentIds, penSources, ctx) - } - if (pen.children) applyAllRefProps(pen.children, graph, componentIds, penSources, ctx) - } -} - -function applyTheme(theme: Record, ctx: VarContext): void { - const themeName = Object.values(theme)[0] - if (themeName) ctx.setActiveTheme(themeName) -} - -// eslint-disable-next-line complexity -- .pen node mapping touches many format-specific fields -function createSceneNode( - pen: PenNode, - parentId: string, - graph: SceneGraph, - ctx: VarContext, - componentIds: Map, - penSources: Map -): string | null { - if (pen.type === 'prompt') return null - if (pen.theme) applyTheme(pen.theme, ctx) - - const { w, h, layout, isTextLike } = resolveSizing(pen, ctx) - const overrides = buildBaseOverrides(pen) - overrides.width = w.value - overrides.height = h.value - - const parentLayout = graph.getNode(parentId)?.layoutMode ?? 'NONE' - if (layout !== 'NONE') { - const widthSizing = - parentLayout === 'NONE' && w.sizing === 'FILL' ? ('FIXED' as LayoutSizing) : w.sizing - const heightSizing = - parentLayout === 'NONE' && h.sizing === 'FILL' ? ('FIXED' as LayoutSizing) : h.sizing - applyAutoLayout(overrides, layout, pen, widthSizing, heightSizing) - } - - const node = graph.createNode(mapNodeType(pen), parentId, overrides) - - if (pen.fill !== undefined) node.fills = convertFill(pen.fill, ctx, node) - if (pen.stroke) node.strokes = convertStroke(pen.stroke, ctx, node) - node.effects = convertEffects(pen.effect) - applyCornerRadius(node, pen.cornerRadius, ctx) - applyPadding(node, pen.padding) - - if (isTextLike) { - applyTextProps(node, pen, ctx) - if (parentLayout === 'NONE' && pen.width === undefined && !pen.textGrowth) { - node.textAutoResize = 'NONE' - node.width = node.text.length * node.fontSize * 0.65 - node.height = node.fontSize * (node.lineHeight ? node.lineHeight / node.fontSize : 1.2) - } - } - - if (pen.type === 'path' && pen.geometry) { - node.vectorNetwork = parseSVGPath(pen.geometry) - scaleVectorNetwork(node.vectorNetwork, node.width, node.height) - } - - if (parentLayout !== 'NONE') { - const parentVertical = parentLayout === 'VERTICAL' - if (w.sizing === 'FILL') { - if (parentVertical) node.layoutAlignSelf = 'STRETCH' - else node.layoutGrow = 1 - } - if (h.sizing === 'FILL') { - if (parentVertical) node.layoutGrow = 1 - else node.layoutAlignSelf = 'STRETCH' - } - } - - if (pen.reusable) { - componentIds.set(pen.id, node.id) - penSources.set(pen.id, pen) - } - - if (pen.children) { - for (const child of pen.children) { - createSceneNode(child, node.id, graph, ctx, componentIds, penSources) - } - } - - return node.id -} - -function collectByNameType( - graph: SceneGraph, - parentId: string, - name: string, - type: string, - out: SceneNode[], - depth: number -): void { - if (depth > 2) return - const parent = graph.getNode(parentId) - if (!parent) return - for (const childId of parent.childIds) { - const child = graph.getNode(childId) - if (!child) continue - if (child.name === name && child.type === type) out.push(child) - collectByNameType(graph, childId, name, type, out, depth + 1) - } -} - -function findCloneByComponentId( - graph: SceneGraph, - parentId: string, - origId: string -): SceneNode | undefined { - const parent = graph.getNode(parentId) - if (!parent) return undefined - for (const childId of parent.childIds) { - const child = graph.getNode(childId) - if (!child) continue - if (child.componentId === origId) return child - const deep = findCloneByComponentId(graph, childId, origId) - if (deep) return deep - } - return undefined -} - -function findCloneByNameFallback( - graph: SceneGraph, - parentId: string, - origId: string -): SceneNode | undefined { - const orig = graph.getNode(origId) - if (!orig) return undefined - const matches: SceneNode[] = [] - collectByNameType(graph, parentId, orig.name, orig.type, matches, 0) - return matches.length === 1 ? matches[0] : undefined -} - -function applyOverrideProps( - target: SceneNode, - overrideData: Partial, - ctx: VarContext -): void { - if (overrideData.fill !== undefined) target.fills = convertFill(overrideData.fill, ctx, target) - if (overrideData.content !== undefined) target.text = overrideData.content - if (overrideData.x !== undefined) target.x = overrideData.x - if (overrideData.y !== undefined) target.y = overrideData.y - if (overrideData.enabled !== undefined) target.visible = overrideData.enabled - if (overrideData.width !== undefined) - target.width = parseSize(overrideData.width, target.width, ctx).value - if (overrideData.height !== undefined) - target.height = parseSize(overrideData.height, target.height, ctx).value - if (overrideData.rotation !== undefined) target.rotation = overrideData.rotation - if (overrideData.name !== undefined) target.name = overrideData.name -} - -function populateInstances(graph: SceneGraph): void { - for (const node of graph.getAllNodes()) { - if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) { - const component = graph.getNode(node.componentId) - if (component) populateInstanceChildren(graph, node.id, node.componentId) - } - } -} - -function applyDescendantOverrides( - graph: SceneGraph, - pen: PenNode, - ctx: VarContext, - componentIds: Map, - penSources: Map -): void { - if (pen.type !== 'ref' || !pen.descendants) return - const instanceNode = graph.getNode(pen.id) - if (!instanceNode) return - - for (const [origId, overrideData] of Object.entries(pen.descendants)) { - const clone = - findCloneByComponentId(graph, instanceNode.id, origId) ?? - findCloneByNameFallback(graph, instanceNode.id, origId) - - if (clone) { - if (overrideData.children) { - const toDelete = clone.childIds.slice() - for (const childId of toDelete) graph.deleteNode(childId) - for (const child of overrideData.children) { - createSceneNode(child, clone.id, graph, ctx, componentIds, penSources) - } - } - applyOverrideProps(clone, overrideData, ctx) - continue - } - - if (overrideData.type && overrideData.id) { - createSceneNode( - overrideData as PenNode, - instanceNode.id, - graph, - ctx, - componentIds, - penSources - ) - } - } -} - -function walkAndApplyOverrides( - nodes: PenNode[], - graph: SceneGraph, - ctx: VarContext, - componentIds: Map, - penSources: Map -): void { - for (const pen of nodes) { - applyDescendantOverrides(graph, pen, ctx, componentIds, penSources) - if (pen.children) walkAndApplyOverrides(pen.children, graph, ctx, componentIds, penSources) - } -} - -function collectComponentIds(nodes: PenNode[], map: Map): void { - for (const node of nodes) { - if (node.reusable) map.set(node.id, node.id) - if (node.children) collectComponentIds(node.children, map) - } -} - -function resolveNodeVars(node: SceneNode, graph: SceneGraph, ctx: VarContext): void { - for (const [key, varId] of Object.entries(node.boundVariables)) { - const variable = graph.variables.get(varId) - if (!variable) continue - const modeVal = - variable.valuesByMode[ctx.activeModeId] ?? Object.values(variable.valuesByMode)[0] - if (key.startsWith('fills[') && typeof modeVal === 'object' && 'r' in modeVal) { - const idx = Number.parseInt(key.match(/\d+/)?.[0] ?? '0', 10) - if (node.fills[idx]) node.fills[idx].color = modeVal - } else if (key.startsWith('strokes[') && typeof modeVal === 'object' && 'r' in modeVal) { - const idx = Number.parseInt(key.match(/\d+/)?.[0] ?? '0', 10) - if (node.strokes[idx]) node.strokes[idx].color = modeVal - } - } - for (const childId of node.childIds) { - const child = graph.getNode(childId) - if (child) resolveNodeVars(child, graph, ctx) - } -} - -function resolveThemeVariables(penNodes: PenNode[], graph: SceneGraph, ctx: VarContext): void { - for (const pen of penNodes) { - if (pen.theme) applyTheme(pen.theme, ctx) - const node = graph.getNode(pen.id) - if (node) resolveNodeVars(node, graph, ctx) - if (pen.children) resolveThemeVariables(pen.children, graph, ctx) - } -} - -function fixInstanceWidths(graph: SceneGraph): void { - for (const node of graph.getAllNodes()) { - if (node.type !== 'INSTANCE' || !node.componentId) continue - const comp = graph.getNode(node.componentId) - if (!comp) continue - if (node.width <= 100 && comp.width > 100) node.width = comp.width - if (node.height <= 100 && comp.height > 100) node.height = comp.height - if (comp.layoutGrow > 0) node.layoutGrow = comp.layoutGrow - if (comp.layoutAlignSelf !== 'AUTO') node.layoutAlignSelf = comp.layoutAlignSelf - node.fills = copyFills(node.fills) - node.strokes = copyStrokes(node.strokes) - node.effects = copyEffects(node.effects) - } -} - -function fixTextWidths(graph: SceneGraph): void { - for (const node of graph.getAllNodes()) { - if (node.type !== 'TEXT' || !node.text || node.text.length <= 1) continue - if (node.width >= node.fontSize * 2) continue - node.width = node.text.length * node.fontSize * 0.65 - } -} - -export function parsePenFile(json: string): SceneGraph { - const doc: PenDocument = JSON.parse(json) - const graph = new SceneGraph() - - for (const page of graph.getPages(true)) { - graph.deleteNode(page.id) - } - - const ctx = buildVarContext(graph, doc.variables ?? {}, doc.themes ?? {}) - const componentIds = new Map() - const penSources = new Map() - - collectComponentIds(doc.children, componentIds) - - const page = graph.addPage(doc.children[0]?.name ?? 'Page 1') - for (const child of doc.children) { - createSceneNode(child, page.id, graph, ctx, componentIds, penSources) - } - - applyAllRefProps(doc.children, graph, componentIds, penSources, ctx) - populateInstances(graph) - walkAndApplyOverrides(doc.children, graph, ctx, componentIds, penSources) - populateInstances(graph) - resolveThemeVariables(doc.children, graph, ctx) - fixInstanceWidths(graph) - fixTextWidths(graph) - - if (graph.getPages(true).length === 0) { - graph.addPage('Page 1') - } - - return graph -} - -export async function readPenFile(file: File): Promise { - return parsePenFile(await file.text()) -} diff --git a/packages/core/src/render-image.ts b/packages/core/src/render-image.ts deleted file mode 100644 index 17e31eb2f..000000000 --- a/packages/core/src/render-image.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './io/formats/raster/render-image' diff --git a/packages/core/src/render/export-jsx.ts b/packages/core/src/render/export-jsx.ts deleted file mode 100644 index f81514fa7..000000000 --- a/packages/core/src/render/export-jsx.ts +++ /dev/null @@ -1,661 +0,0 @@ -/* eslint-disable max-lines -- JSX export formats share helpers and node walking logic */ -import { colorToHex8, colorToCSSCompact } from '../color' -import { DEFAULT_FONT_FAMILY } from '../constants' -import { - pxToSpacing, - colorToTwClass, - fontSizeToTw, - fontWeightToTw, - borderRadiusToTw, - opacityToTw -} from './tailwind' - -import type { - SceneGraph, - SceneNode, - Fill, - Stroke, - Effect, - NodeType, - Color, - GridTrack -} from '../scene-graph' - -export type JSXFormat = 'openpencil' | 'tailwind' - -const NODE_TYPE_TO_TAG: Partial> = { - FRAME: 'Frame', - RECTANGLE: 'Rectangle', - ROUNDED_RECTANGLE: 'Rectangle', - ELLIPSE: 'Ellipse', - TEXT: 'Text', - LINE: 'Line', - STAR: 'Star', - POLYGON: 'Polygon', - VECTOR: 'Vector', - GROUP: 'Group', - SECTION: 'Section', - COMPONENT: 'Component', - COMPONENT_SET: 'Frame', - INSTANCE: 'Frame' -} - -const NODE_TYPE_TO_TW_TAG: Partial> = { - FRAME: 'div', - RECTANGLE: 'div', - ROUNDED_RECTANGLE: 'div', - ELLIPSE: 'div', - TEXT: 'p', - LINE: 'div', - STAR: 'div', - POLYGON: 'div', - VECTOR: 'div', - GROUP: 'div', - SECTION: 'section', - COMPONENT: 'div', - COMPONENT_SET: 'div', - INSTANCE: 'div' -} - -function formatColor(color: Color, opacity = 1): string { - return colorToHex8(color, opacity) -} - -function solidFillColor(fills: Fill[]): string | null { - const visible = fills.filter((f) => f.visible && f.type === 'SOLID') - if (visible.length !== 1) return null - return formatColor(visible[0].color, visible[0].opacity) -} - -function solidStroke(strokes: Stroke[]): { color: string; weight: number } | null { - const visible = strokes.filter((s) => s.visible) - if (visible.length !== 1) return null - return { - color: formatColor(visible[0].color, visible[0].opacity), - weight: visible[0].weight - } -} - -function formatShadow(e: Effect): string | null { - if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') return null - return `${e.offset.x} ${e.offset.y} ${e.radius} ${formatColor(e.color, e.color.a)}` -} - -function formatTailwindShadow(e: Effect): string | null { - if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') return null - const color = colorToCSSCompact(e.color) - const inset = e.type === 'INNER_SHADOW' ? 'inset_' : '' - const spread = e.spread !== 0 ? `_${e.spread}px` : '' - return `${inset}${e.offset.x}px_${e.offset.y}px_${e.radius}px${spread}_${color}` -} - -function formatTailwindAngle(degrees: number): string { - const rounded = Number(degrees.toFixed(2)) - const integer = Math.round(rounded) - const named = new Set([0, 1, 2, 3, 6, 12, 45, 90, 180]) - if (rounded === integer && named.has(integer)) return String(integer) - return `[${rounded}deg]` -} - -function formatTailwindFontFamily(fontFamily: string): string { - const escaped = fontFamily.replace(/\\/g, '\\\\').replace(/'/g, "\\'") - return `['${escaped}']` -} - -const JSX_ENTITY: Record = { - '{': '{', - '}': '}', - '<': '<', - '>': '>', - '&': '&' -} - -function escapeJSXText(text: string): string { - return text.replace(/[{}<>&]/g, (c) => JSX_ENTITY[c]) -} - -function formatProp(key: string, value: unknown): string { - if (typeof value === 'string') return `${key}="${value}"` - if (typeof value === 'number') return `${key}={${value}}` - if (typeof value === 'boolean') return value ? key : `${key}={false}` - return `${key}={${JSON.stringify(value)}}` -} - -function getNodeContext(node: SceneNode, graph: SceneGraph) { - const parent = node.parentId ? graph.getNode(node.parentId) : null - return { - isAutoLayout: node.layoutMode !== 'NONE', - isGrid: node.layoutMode === 'GRID', - isFlex: node.layoutMode === 'HORIZONTAL' || node.layoutMode === 'VERTICAL', - parentIsAutoLayout: parent ? parent.layoutMode !== 'NONE' : false, - parentIsGrid: parent ? parent.layoutMode === 'GRID' : false - } -} - -type PaddingEdges = { pt: number; pr: number; pb: number; pl: number } - -function collectPadding(node: SceneNode): PaddingEdges | null { - const { paddingTop: pt, paddingRight: pr, paddingBottom: pb, paddingLeft: pl } = node - if (pt === 0 && pr === 0 && pb === 0 && pl === 0) return null - return { pt, pr, pb, pl } -} - -function emitPadding( - edges: PaddingEdges, - uniform: (v: number) => T, - symmetric: (y: number, x: number) => T[], - individual: (edges: PaddingEdges) => T[] -): T[] { - const { pt, pr, pb, pl } = edges - if (pt === pr && pr === pb && pb === pl) return [uniform(pt)] - if (pt === pb && pl === pr) return symmetric(pt, pl) - return individual(edges) -} - -interface CornerRadii { - tl: number - tr: number - br: number - bl: number -} - -function collectCornerRadii(node: SceneNode): CornerRadii | null { - if (node.cornerRadius <= 0) return null - if (node.independentCorners) { - return { - tl: node.topLeftRadius, - tr: node.topRightRadius, - br: node.bottomRightRadius, - bl: node.bottomLeftRadius - } - } - const r = node.cornerRadius - return { tl: r, tr: r, br: r, bl: r } -} - -function formatTrack(t: GridTrack): string { - if (t.sizing === 'FR') return `${t.value}fr` - if (t.sizing === 'FIXED') return `${t.value}px` - return 'auto' -} - -function formatTracks(tracks: GridTrack[]): string { - return tracks.map(formatTrack).join(' ') -} - -// --- OpenPencil format helpers --- - -function collectGridSizingProps(node: SceneNode, props: [string, unknown][]): void { - props.push(['grid', true]) - if (node.gridTemplateColumns.length > 0) - props.push(['columns', formatTracks(node.gridTemplateColumns)]) - if (node.gridTemplateRows.length > 0) props.push(['rows', formatTracks(node.gridTemplateRows)]) - if (node.width > 0) props.push(['w', node.width]) - if (node.gridTemplateRows.length > 0 && node.height > 0) props.push(['h', node.height]) - if (node.gridColumnGap > 0) props.push(['columnGap', node.gridColumnGap]) - if (node.gridRowGap > 0) props.push(['rowGap', node.gridRowGap]) -} - -function collectFlexSizingProps(node: SceneNode, props: [string, unknown][]): void { - props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col']) - const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height' - const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width' - - if (node.primaryAxisSizing === 'FILL') props.push([primaryAxis === 'width' ? 'w' : 'h', 'fill']) - else if (node.primaryAxisSizing !== 'HUG') - props.push([primaryAxis === 'width' ? 'w' : 'h', node[primaryAxis]]) - - if (node.counterAxisSizing === 'FILL') props.push([crossAxis === 'width' ? 'w' : 'h', 'fill']) - else if (node.counterAxisSizing !== 'HUG') - props.push([crossAxis === 'width' ? 'w' : 'h', node[crossAxis]]) -} - -function collectGridPositionProps(node: SceneNode, props: [string, unknown][]): void { - if (!node.gridPosition) return - const pos = node.gridPosition - if (pos.column > 0) props.push(['colStart', pos.column]) - if (pos.row > 0) props.push(['rowStart', pos.row]) - if (pos.columnSpan > 1) props.push(['colSpan', pos.columnSpan]) - if (pos.rowSpan > 1) props.push(['rowSpan', pos.rowSpan]) -} - -function collectFlexAlignmentProps(node: SceneNode, props: [string, unknown][]): void { - if (node.itemSpacing > 0) props.push(['gap', node.itemSpacing]) - - if (node.layoutWrap === 'WRAP') { - props.push(['wrap', true]) - if (node.counterAxisSpacing > 0) props.push(['rowGap', node.counterAxisSpacing]) - } - - if (node.primaryAxisAlign === 'CENTER') props.push(['justify', 'center']) - else if (node.primaryAxisAlign === 'MAX') props.push(['justify', 'end']) - else if (node.primaryAxisAlign === 'SPACE_BETWEEN') props.push(['justify', 'between']) - - if (node.counterAxisAlign === 'CENTER') props.push(['items', 'center']) - else if (node.counterAxisAlign === 'MAX') props.push(['items', 'end']) - else if (node.counterAxisAlign === 'STRETCH') props.push(['items', 'stretch']) -} - -function collectAutoLayoutPaddingProps(node: SceneNode, props: [string, unknown][]): void { - const pad = collectPadding(node) - if (!pad) return - props.push( - ...emitPadding( - pad, - (v) => ['p', v] as [string, unknown], - (y, x) => - [ - ['py', y], - ['px', x] - ] as [string, unknown][], - ({ pt, pr, pb, pl }) => { - const r: [string, unknown][] = [] - if (pt > 0) r.push(['pt', pt]) - if (pr > 0) r.push(['pr', pr]) - if (pb > 0) r.push(['pb', pb]) - if (pl > 0) r.push(['pl', pl]) - return r - } - ) - ) -} - -function collectCornerRadiiProps(node: SceneNode, props: [string, unknown][]): void { - const corners = collectCornerRadii(node) - if (!corners) return - const { tl, tr, br, bl } = corners - if (tl === tr && tr === br && br === bl) { - props.push(['rounded', tl]) - } else { - if (tl > 0) props.push(['roundedTL', tl]) - if (tr > 0) props.push(['roundedTR', tr]) - if (br > 0) props.push(['roundedBR', br]) - if (bl > 0) props.push(['roundedBL', bl]) - } -} - -function collectAppearanceProps(node: SceneNode, props: [string, unknown][]): void { - const bg = solidFillColor(node.fills) - if (bg) props.push(['bg', bg]) - - const stroke = solidStroke(node.strokes) - if (stroke) { - props.push(['stroke', stroke.color]) - if (stroke.weight !== 1) props.push(['strokeWidth', stroke.weight]) - } - - collectCornerRadiiProps(node, props) - - if (node.cornerSmoothing > 0) props.push(['cornerSmoothing', node.cornerSmoothing]) - if (node.opacity < 1) props.push(['opacity', Math.round(node.opacity * 100) / 100]) - if (node.rotation !== 0) props.push(['rotate', Math.round(node.rotation * 100) / 100]) - if (node.blendMode !== 'PASS_THROUGH' && node.blendMode !== 'NORMAL') { - props.push(['blendMode', node.blendMode.toLowerCase()]) - } - if (node.clipsContent) props.push(['overflow', 'hidden']) - - for (const effect of node.effects) { - if (!effect.visible) continue - if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') { - const shadow = formatShadow(effect) - if (shadow) props.push(['shadow', shadow]) - } else if (effect.type === 'LAYER_BLUR' || effect.type === 'BACKGROUND_BLUR') { - props.push(['blur', effect.radius]) - } - } -} - -function collectPositionProps( - node: SceneNode, - ctx: ReturnType, - props: [string, unknown][] -): void { - if (ctx.parentIsAutoLayout || ctx.parentIsGrid) return - if (node.x !== 0) props.push(['x', node.x]) - if (node.y !== 0) props.push(['y', node.y]) -} - -function collectSizingProps( - node: SceneNode, - ctx: ReturnType, - graph: SceneGraph, - props: [string, unknown][] -): void { - if (ctx.isGrid) collectGridSizingProps(node, props) - else if (ctx.isFlex) collectFlexSizingProps(node, props) - else if (node.type === 'TEXT') collectTextSizingProps(node, graph, props) - else { - if (node.width > 0) props.push(['w', node.width]) - if (node.height > 0) props.push(['h', node.height]) - } - - if (!ctx.parentIsAutoLayout) return - if (node.layoutGrow > 0) props.push(['grow', node.layoutGrow]) - if (node.layoutAlignSelf === 'STRETCH') { - const parent = node.parentId ? graph.getNode(node.parentId) : null - if (parent && (parent.layoutMode === 'HORIZONTAL' || parent.layoutMode === 'VERTICAL')) { - const crossDim = parent.layoutMode === 'HORIZONTAL' ? 'h' : 'w' - if (!props.some(([k]) => k === crossDim)) props.push([crossDim, 'fill']) - } - } -} - -function collectTextSizingProps( - node: SceneNode, - graph: SceneGraph, - props: [string, unknown][] -): void { - const autoResize = node.textAutoResize - const emitH = autoResize === 'NONE' || autoResize === 'TRUNCATE' - // Don't emit fixed w when text stretches to fill parent — the layoutAlignSelf - // check below will emit w="fill" instead. Without this guard, w={computedPx} - // gets emitted first and blocks the fill detection. - const isFillWidth = - node.layoutAlignSelf === 'STRETCH' && - (() => { - const parent = node.parentId ? graph.getNode(node.parentId) : null - return parent?.layoutMode === 'VERTICAL' - })() - const isGrowWidth = - node.layoutGrow > 0 && - (() => { - const parent = node.parentId ? graph.getNode(node.parentId) : null - return parent?.layoutMode === 'HORIZONTAL' - })() - const emitW = autoResize !== 'WIDTH_AND_HEIGHT' && !isFillWidth && !isGrowWidth - if (emitW && node.width > 0) props.push(['w', node.width]) - if (emitH && node.height > 0) props.push(['h', node.height]) -} - -function collectTextNodeProps(node: SceneNode, props: [string, unknown][]): void { - if (node.fontSize !== 14) props.push(['size', node.fontSize]) - if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) - props.push(['font', node.fontFamily]) - if (node.fontWeight !== 400) { - if (node.fontWeight === 700) props.push(['weight', 'bold']) - else if (node.fontWeight === 500) props.push(['weight', 'medium']) - else props.push(['weight', node.fontWeight]) - } - if (node.textAlignHorizontal !== 'LEFT') { - props.push(['textAlign', node.textAlignHorizontal.toLowerCase()]) - } - if (node.lineHeight != null) props.push(['lineHeight', node.lineHeight]) - if (node.letterSpacing !== 0) props.push(['letterSpacing', node.letterSpacing]) - if (node.textDecoration !== 'NONE') - props.push(['textDecoration', node.textDecoration.toLowerCase()]) - if (node.textCase !== 'ORIGINAL') props.push(['textCase', node.textCase.toLowerCase()]) - if (node.maxLines != null) props.push(['maxLines', node.maxLines]) - if (node.textTruncation === 'ENDING' && node.maxLines == null) props.push(['truncate', true]) - const textColor = solidFillColor(node.fills) - if (textColor) { - const bgIdx = props.findIndex(([k]) => k === 'bg') - if (bgIdx !== -1) props.splice(bgIdx, 1) - props.push(['color', textColor]) - } -} - -function collectShapeNodeProps(node: SceneNode, props: [string, unknown][]): void { - if (node.type === 'STAR') { - if (node.pointCount !== 5) props.push(['points', node.pointCount]) - if (node.starInnerRadius !== 0.382) props.push(['innerRadius', node.starInnerRadius]) - } - if (node.type === 'POLYGON' && node.pointCount !== 3) { - props.push(['points', node.pointCount]) - } -} - -function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] { - const props: [string, unknown][] = [] - const ctx = getNodeContext(node, graph) - - if (node.name && node.name !== node.type) props.push(['name', node.name]) - - collectPositionProps(node, ctx, props) - collectSizingProps(node, ctx, graph, props) - if (ctx.parentIsGrid) collectGridPositionProps(node, props) - if (ctx.isFlex) collectFlexAlignmentProps(node, props) - if (ctx.isAutoLayout) collectAutoLayoutPaddingProps(node, props) - collectAppearanceProps(node, props) - if (node.type === 'TEXT') collectTextNodeProps(node, props) - collectShapeNodeProps(node, props) - - return props -} - -// --- Tailwind CSS v4 format helpers --- - -function twRounded(prefix: string, px: number): string { - const r = borderRadiusToTw(px) - return r ? `${prefix}-${r}` : prefix -} - -function gridTemplateTw(tracks: GridTrack[]): string { - const allEqual1Fr = tracks.every((t) => t.sizing === 'FR' && t.value === 1) - if (allEqual1Fr) return String(tracks.length) - return `[${tracks.map(formatTrack).join('_')}]` -} - -function collectTwGridClasses(node: SceneNode, classes: string[]): void { - classes.push('grid') - if (node.gridTemplateColumns.length > 0) - classes.push(`grid-cols-${gridTemplateTw(node.gridTemplateColumns)}`) - if (node.gridTemplateRows.length > 0) - classes.push(`grid-rows-${gridTemplateTw(node.gridTemplateRows)}`) - if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`) - if (node.gridTemplateRows.length > 0 && node.height > 0) - classes.push(`h-${pxToSpacing(node.height)}`) - if (node.gridColumnGap > 0) classes.push(`gap-x-${pxToSpacing(node.gridColumnGap)}`) - if (node.gridRowGap > 0) classes.push(`gap-y-${pxToSpacing(node.gridRowGap)}`) -} - -function collectTwFlexSizingClasses(node: SceneNode, classes: string[]): void { - classes.push('flex') - if (node.layoutMode === 'VERTICAL') classes.push('flex-col') - - const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height' - const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width' - const wProp = primaryAxis === 'width' ? 'w' : 'h' - const hProp = crossAxis === 'width' ? 'w' : 'h' - - if (node.primaryAxisSizing === 'FILL') classes.push(`${wProp}-full`) - else if (node.primaryAxisSizing !== 'HUG') - classes.push(`${wProp}-${pxToSpacing(node[primaryAxis])}`) - - if (node.counterAxisSizing === 'FILL') classes.push(`${hProp}-full`) - else if (node.counterAxisSizing !== 'HUG') - classes.push(`${hProp}-${pxToSpacing(node[crossAxis])}`) -} - -function collectTwGridPositionClasses(node: SceneNode, classes: string[]): void { - if (!node.gridPosition) return - const pos = node.gridPosition - if (pos.column > 0) classes.push(`col-start-${pos.column}`) - if (pos.row > 0) classes.push(`row-start-${pos.row}`) - if (pos.columnSpan > 1) classes.push(`col-span-${pos.columnSpan}`) - if (pos.rowSpan > 1) classes.push(`row-span-${pos.rowSpan}`) -} - -function collectTwFlexAlignmentClasses(node: SceneNode, classes: string[]): void { - if (node.itemSpacing > 0) classes.push(`gap-${pxToSpacing(node.itemSpacing)}`) - - if (node.layoutWrap === 'WRAP') { - classes.push('flex-wrap') - if (node.counterAxisSpacing > 0) classes.push(`gap-y-${pxToSpacing(node.counterAxisSpacing)}`) - } - - if (node.primaryAxisAlign === 'CENTER') classes.push('justify-center') - else if (node.primaryAxisAlign === 'MAX') classes.push('justify-end') - else if (node.primaryAxisAlign === 'SPACE_BETWEEN') classes.push('justify-between') - - if (node.counterAxisAlign === 'CENTER') classes.push('items-center') - else if (node.counterAxisAlign === 'MAX') classes.push('items-end') - else if (node.counterAxisAlign === 'STRETCH') classes.push('items-stretch') -} - -function collectTwPaddingClasses(node: SceneNode, classes: string[]): void { - const pad = collectPadding(node) - if (!pad) return - classes.push( - ...emitPadding( - pad, - (v) => `p-${pxToSpacing(v)}`, - (y, x) => [`py-${pxToSpacing(y)}`, `px-${pxToSpacing(x)}`], - ({ pt, pr, pb, pl }) => { - const r: string[] = [] - if (pt > 0) r.push(`pt-${pxToSpacing(pt)}`) - if (pr > 0) r.push(`pr-${pxToSpacing(pr)}`) - if (pb > 0) r.push(`pb-${pxToSpacing(pb)}`) - if (pl > 0) r.push(`pl-${pxToSpacing(pl)}`) - return r - } - ) - ) -} - -function collectTwCornerRadiiClasses(node: SceneNode, classes: string[]): void { - const corners = collectCornerRadii(node) - if (!corners) return - const { tl, tr, br, bl } = corners - if (tl === tr && tr === br && br === bl) { - classes.push(twRounded('rounded', tl)) - } else { - if (tl > 0) classes.push(twRounded('rounded-tl', tl)) - if (tr > 0) classes.push(twRounded('rounded-tr', tr)) - if (br > 0) classes.push(twRounded('rounded-br', br)) - if (bl > 0) classes.push(twRounded('rounded-bl', bl)) - } -} - -function collectTwAppearanceClasses(node: SceneNode, classes: string[]): void { - const bg = solidFillColor(node.fills) - if (bg && node.type !== 'TEXT') classes.push(`bg-${colorToTwClass(bg)}`) - - const stroke = solidStroke(node.strokes) - if (stroke) { - if (stroke.weight !== 1) classes.push(`border-${pxToSpacing(stroke.weight)}`) - else classes.push('border') - classes.push(`border-${colorToTwClass(stroke.color)}`) - } - - collectTwCornerRadiiClasses(node, classes) - - if (node.opacity < 1) classes.push(`opacity-${opacityToTw(node.opacity)}`) - if (node.rotation !== 0) classes.push(`rotate-${formatTailwindAngle(node.rotation)}`) - if (node.clipsContent) classes.push('overflow-hidden') - - for (const effect of node.effects) { - if (!effect.visible) continue - if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') { - const shadow = formatTailwindShadow(effect) - if (shadow) classes.push(`shadow-[${shadow}]`) - } else if (effect.type === 'LAYER_BLUR' || effect.type === 'FOREGROUND_BLUR') { - classes.push(`blur-[${effect.radius}px]`) - } else { - classes.push(`backdrop-blur-[${effect.radius}px]`) - } - } -} - -function collectTwTextClasses(node: SceneNode, classes: string[]): void { - classes.push(`text-${fontSizeToTw(node.fontSize)}`) - if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) { - classes.push(`font-${formatTailwindFontFamily(node.fontFamily)}`) - } - if (node.fontWeight !== 400) classes.push(`font-${fontWeightToTw(node.fontWeight)}`) - if (node.textAlignHorizontal !== 'LEFT') { - classes.push(`text-${node.textAlignHorizontal.toLowerCase()}`) - } - const textColor = solidFillColor(node.fills) - if (textColor) classes.push(`text-${colorToTwClass(textColor)}`) -} - -function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] { - const classes: string[] = [] - const ctx = getNodeContext(node, graph) - - if (ctx.isGrid) collectTwGridClasses(node, classes) - else if (ctx.isFlex) collectTwFlexSizingClasses(node, classes) - else { - if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`) - if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`) - } - - if (ctx.parentIsAutoLayout && node.layoutGrow > 0) classes.push('grow') - if (ctx.parentIsGrid) collectTwGridPositionClasses(node, classes) - if (ctx.isFlex) collectTwFlexAlignmentClasses(node, classes) - if (ctx.isAutoLayout) collectTwPaddingClasses(node, classes) - collectTwAppearanceClasses(node, classes) - if (node.type === 'TEXT') collectTwTextClasses(node, classes) - - return classes -} - -// --- JSX rendering --- - -function nodeToJSX(node: SceneNode, graph: SceneGraph, indent: number, format: JSXFormat): string { - const tagMap = format === 'tailwind' ? NODE_TYPE_TO_TW_TAG : NODE_TYPE_TO_TAG - const tag = tagMap[node.type] - if (!tag) return '' - - const prefix = ' '.repeat(indent) - let attrsStr: string - - if (format === 'tailwind') { - const classes = collectTailwindClasses(node, graph) - const nameAttr = node.name && node.name !== node.type ? ` data-name="${node.name}"` : '' - const classAttr = classes.length > 0 ? ` className="${classes.join(' ')}"` : '' - attrsStr = `${nameAttr}${classAttr}`.trim() - } else { - const props = collectProps(node, graph) - attrsStr = props.map(([k, v]) => formatProp(k, v)).join(' ') - } - - const opening = attrsStr ? `<${tag} ${attrsStr}` : `<${tag}` - const children = graph.getChildren(node.id) - - if (node.type === 'TEXT') { - const text = node.text - if (!text) return `${prefix}${opening} />` - const escaped = escapeJSXText(text) - if (!escaped.includes('\n')) { - return `${prefix}${opening}>${escaped}` - } - return [ - `${prefix}${opening}>`, - ...escaped.split('\n').map((l) => `${prefix} ${l}`), - `${prefix}` - ].join('\n') - } - - if (children.length === 0) return `${prefix}${opening} />` - - const childJSX = children - .filter((c) => c.visible) - .map((c) => nodeToJSX(c, graph, indent + 1, format)) - .filter(Boolean) - - if (childJSX.length === 0) return `${prefix}${opening} />` - - return [`${prefix}${opening}>`, ...childJSX, `${prefix}`].join('\n') -} - -export function sceneNodeToJSX( - nodeId: string, - graph: SceneGraph, - format: JSXFormat = 'openpencil' -): string { - const node = graph.getNode(nodeId) - if (!node) return '' - return nodeToJSX(node, graph, 0, format) -} - -export function selectionToJSX( - nodeIds: string[], - graph: SceneGraph, - format: JSXFormat = 'openpencil' -): string { - return nodeIds - .map((id) => sceneNodeToJSX(id, graph, format)) - .filter(Boolean) - .join('\n\n') -} diff --git a/tests/engine/multi-selection.test.ts b/tests/engine/multi-selection.test.ts index 1c633e749..ad7b9a803 100644 --- a/tests/engine/multi-selection.test.ts +++ b/tests/engine/multi-selection.test.ts @@ -264,7 +264,7 @@ describe('flip roundtrip via kiwi', () => { async function roundtrip(graph: SceneGraph) { const { initCodec } = await import('../../packages/core/src/kiwi/codec') const { parseFigFile } = await import('../../packages/core/src/kiwi/fig-file') - const { exportFigFile } = await import('../../packages/core/src/fig-export') + const { exportFigFile } = await import('../../packages/core/src/io/formats/fig/export') await initCodec() const buf = await exportFigFile(graph) return parseFigFile(buf)