Rename format files to match io folders
This commit is contained in:
parent
decee0216e
commit
e0878c1e68
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { compressFigDataSync } from './fig-compress'
|
||||
import { compressFigDataSync } from '@open-pencil/core/fig-compress'
|
||||
|
||||
interface CompressMessage {
|
||||
schemaDeflated: Uint8Array
|
||||
|
|
@ -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<Uint8Array> {
|
||||
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'
|
||||
})
|
||||
|
||||
|
|
@ -1 +1 @@
|
|||
export { exportFigFile, compressFigData, compressFigDataSync } from '../../../fig-export'
|
||||
export { exportFigFile, compressFigData, compressFigDataSync } from './export'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -4,5 +4,5 @@ export {
|
|||
renderThumbnail,
|
||||
type RasterExportFormat,
|
||||
type ExportFormat
|
||||
} from './render-image'
|
||||
} from './render'
|
||||
export { initCanvasKit, headlessRenderNodes, headlessRenderThumbnail } from './headless'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
VectorSegment,
|
||||
VectorRegion,
|
||||
WindingRule
|
||||
} from './scene-graph'
|
||||
} from '@open-pencil/core/scene-graph'
|
||||
|
||||
interface SubPath {
|
||||
startVertexIndex: number
|
||||
|
|
@ -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<string, string[]>
|
||||
variables?: Record<string, PenVariable>
|
||||
}
|
||||
|
||||
export interface PenVariable {
|
||||
type: 'color' | 'string' | 'number'
|
||||
value: PenVariableValue[] | PenVariableValue | string | number
|
||||
}
|
||||
|
||||
interface PenVariableValue {
|
||||
value: string | number
|
||||
theme?: Record<string, string>
|
||||
}
|
||||
|
||||
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<string, Partial<PenNode>>
|
||||
slot?: string[]
|
||||
geometry?: string
|
||||
iconFontName?: string
|
||||
iconFontFamily?: string
|
||||
weight?: number
|
||||
model?: string
|
||||
theme?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface VarContext {
|
||||
byName: Map<string, { id: string; variable: Variable }>
|
||||
activeModeId: string
|
||||
collectionId: string
|
||||
modeByThemeName: Map<string, string>
|
||||
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<string, PenVariable>,
|
||||
themes: Record<string, string[]>
|
||||
): 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<string, string>()
|
||||
if (themeKeys.length > 0) {
|
||||
const themeKey = themeKeys[0]
|
||||
for (const mode of modes) {
|
||||
modeByThemeValue.set(`${themeKey}:${mode.name}`, mode.modeId)
|
||||
}
|
||||
}
|
||||
|
||||
const byName = new Map<string, { id: string; variable: Variable }>()
|
||||
|
||||
for (const [name, def] of Object.entries(penVars)) {
|
||||
const varId = generateId()
|
||||
const varType = penVarTypeToSceneType(def.type)
|
||||
const valuesByMode: Record<string, VariableValue> = {}
|
||||
|
||||
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'
|
||||
}
|
||||
|
|
@ -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<SceneNode> {
|
||||
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<SceneNode>,
|
||||
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<string, string>,
|
||||
penSources: Map<string, PenNode>,
|
||||
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<string, string>,
|
||||
penSources: Map<string, PenNode>,
|
||||
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<string, string>, 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<string, string>,
|
||||
penSources: Map<string, PenNode>
|
||||
): 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<PenNode>,
|
||||
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<string, string>,
|
||||
penSources: Map<string, PenNode>
|
||||
): 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<string, string>,
|
||||
penSources: Map<string, PenNode>
|
||||
): 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<string, string>): 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<string, string>()
|
||||
const penSources = new Map<string, PenNode>()
|
||||
|
||||
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<SceneGraph> {
|
||||
return parsePenFile(await file.text())
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './io/formats/raster/render-image'
|
||||
|
|
@ -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<Record<NodeType, string>> = {
|
||||
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<Record<NodeType, string>> = {
|
||||
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<string, string> = {
|
||||
'{': '{',
|
||||
'}': '}',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&'
|
||||
}
|
||||
|
||||
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<T>(
|
||||
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<typeof getNodeContext>,
|
||||
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<typeof getNodeContext>,
|
||||
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}</${tag}>`
|
||||
}
|
||||
return [
|
||||
`${prefix}${opening}>`,
|
||||
...escaped.split('\n').map((l) => `${prefix} ${l}`),
|
||||
`${prefix}</${tag}>`
|
||||
].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}</${tag}>`].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')
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue