Add .pen document import
Co-authored-by: Anton Soldatov <eddclyde@yandex.ru>
This commit is contained in:
parent
0001802dda
commit
e396df3224
|
|
@ -19,6 +19,7 @@
|
||||||
- Export selection or current page as `.fig` from the app export UI and app menu
|
- Export selection or current page as `.fig` from the app export UI and app menu
|
||||||
- New CLI commands: `open-pencil convert` for document conversion and `open-pencil formats` to inspect readable/writable/exportable formats
|
- New CLI commands: `open-pencil convert` for document conversion and `open-pencil formats` to inspect readable/writable/exportable formats
|
||||||
- CLI export now supports `.fig` output and routes PNG/JPG/WEBP/SVG/JSX/`.fig` through the shared IO layer
|
- CLI export now supports `.fig` output and routes PNG/JPG/WEBP/SVG/JSX/`.fig` through the shared IO layer
|
||||||
|
- `Open…` now supports `.pen` Pencil documents through the shared document reader pipeline while keeping `.fig` as the native save format
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,8 @@ export {
|
||||||
type OpenPencilClipboardData
|
type OpenPencilClipboardData
|
||||||
} from './clipboard'
|
} from './clipboard'
|
||||||
|
|
||||||
|
export { readPenFile, parsePenFile } from './pen-file'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
readFigFile,
|
readFigFile,
|
||||||
parseFigFile,
|
parseFigFile,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { exportFigFile } from '../fig-export'
|
import { exportFigFile } from '../fig-export'
|
||||||
import { headlessRenderNodes } from '../headless-render'
|
import { headlessRenderNodes } from '../headless-render'
|
||||||
import { parseFigFile } from '../kiwi'
|
import { parseFigFile } from '../kiwi'
|
||||||
|
import { parsePenFile } from '../pen-file'
|
||||||
import { sceneNodeToJSX, selectionToJSX } from '../render'
|
import { sceneNodeToJSX, selectionToJSX } from '../render'
|
||||||
import { renderNodesToImage } from '../render-image'
|
import { renderNodesToImage } from '../render-image'
|
||||||
import { renderNodesToSVG } from '../svg-export'
|
import { renderNodesToSVG } from '../svg-export'
|
||||||
|
|
@ -195,6 +196,26 @@ export const figFormat: IOFormatAdapter = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const penFormat: IOFormatAdapter = {
|
||||||
|
id: 'pen',
|
||||||
|
label: 'Pencil Document',
|
||||||
|
role: 'interchange-document',
|
||||||
|
category: 'document',
|
||||||
|
extensions: ['pen'],
|
||||||
|
mimeTypes: ['application/json', 'text/plain'],
|
||||||
|
support: {
|
||||||
|
readDocument: true
|
||||||
|
},
|
||||||
|
matchesFile(fileName, mimeType) {
|
||||||
|
return lowerExt(fileName) === 'pen' || mimeType === 'application/json'
|
||||||
|
},
|
||||||
|
async readDocument(input) {
|
||||||
|
const text = new TextDecoder().decode(input.data)
|
||||||
|
const graph = parsePenFile(text)
|
||||||
|
return { graph, sourceFormat: 'pen' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const pngFormat = rasterFormat('PNG')
|
export const pngFormat = rasterFormat('PNG')
|
||||||
export const jpgFormat = rasterFormat('JPG')
|
export const jpgFormat = rasterFormat('JPG')
|
||||||
export const webpFormat = rasterFormat('WEBP')
|
export const webpFormat = rasterFormat('WEBP')
|
||||||
|
|
@ -268,6 +289,7 @@ export const jsxFormat: IOFormatAdapter = {
|
||||||
|
|
||||||
export const BUILTIN_IO_FORMATS: IOFormatAdapter[] = [
|
export const BUILTIN_IO_FORMATS: IOFormatAdapter[] = [
|
||||||
figFormat,
|
figFormat,
|
||||||
|
penFormat,
|
||||||
pngFormat,
|
pngFormat,
|
||||||
jpgFormat,
|
jpgFormat,
|
||||||
webpFormat,
|
webpFormat,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export { extractExportGraph } from './subgraph'
|
||||||
export {
|
export {
|
||||||
BUILTIN_IO_FORMATS,
|
BUILTIN_IO_FORMATS,
|
||||||
figFormat,
|
figFormat,
|
||||||
|
penFormat,
|
||||||
pngFormat,
|
pngFormat,
|
||||||
jpgFormat,
|
jpgFormat,
|
||||||
webpFormat,
|
webpFormat,
|
||||||
|
|
|
||||||
463
packages/core/src/pen-convert.ts
Normal file
463
packages/core/src/pen-convert.ts
Normal file
|
|
@ -0,0 +1,463 @@
|
||||||
|
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'
|
||||||
|
}
|
||||||
513
packages/core/src/pen-file.ts
Normal file
513
packages/core/src/pen-file.ts
Normal file
|
|
@ -0,0 +1,513 @@
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import { IS_BROWSER, IS_TAURI } from '@/constants'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs'
|
import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs'
|
||||||
|
|
||||||
const fileDialog = useFileDialog({ accept: '.fig', multiple: false, reset: true })
|
const fileDialog = useFileDialog({ accept: '.fig,.pen', multiple: false, reset: true })
|
||||||
fileDialog.onChange((files) => {
|
fileDialog.onChange((files) => {
|
||||||
const file = files?.[0]
|
const file = files?.[0]
|
||||||
if (file) void openFileInNewTab(file)
|
if (file) void openFileInNewTab(file)
|
||||||
|
|
@ -28,7 +28,7 @@ export async function openFileDialog() {
|
||||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||||
const { readFile } = await import('@tauri-apps/plugin-fs')
|
const { readFile } = await import('@tauri-apps/plugin-fs')
|
||||||
const path = await open({
|
const path = await open({
|
||||||
filters: [{ name: 'Figma file', extensions: ['fig'] }],
|
filters: [{ name: 'Design file', extensions: ['fig', 'pen'] }],
|
||||||
multiple: false
|
multiple: false
|
||||||
})
|
})
|
||||||
if (!path) return
|
if (!path) return
|
||||||
|
|
@ -43,8 +43,12 @@ export async function openFileDialog() {
|
||||||
const [handle] = await window.showOpenFilePicker({
|
const [handle] = await window.showOpenFilePicker({
|
||||||
types: [
|
types: [
|
||||||
{
|
{
|
||||||
description: 'Figma file',
|
description: 'Design file',
|
||||||
accept: { 'application/octet-stream': ['.fig'] }
|
accept: {
|
||||||
|
'application/octet-stream': ['.fig'],
|
||||||
|
'application/json': ['.pen'],
|
||||||
|
'text/plain': ['.pen']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
@ -59,11 +63,16 @@ export async function openFileDialog() {
|
||||||
fileDialog.open()
|
fileDialog.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importFileDialog() {
|
||||||
|
await openFileDialog()
|
||||||
|
}
|
||||||
|
|
||||||
const store = useEditorStore()
|
const store = useEditorStore()
|
||||||
|
|
||||||
const MENU_ACTIONS: Partial<Record<string, () => void>> = {
|
const MENU_ACTIONS: Partial<Record<string, () => void>> = {
|
||||||
new: () => createTab(),
|
new: () => createTab(),
|
||||||
open: () => void openFileDialog(),
|
open: () => void openFileDialog(),
|
||||||
|
import: () => void importFileDialog(),
|
||||||
close: () => {
|
close: () => {
|
||||||
if (activeTab.value) closeTab(activeTab.value.id)
|
if (activeTab.value) closeTab(activeTab.value.id)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { shallowRef, computed, triggerRef } from 'vue'
|
import { shallowRef, computed, triggerRef } from 'vue'
|
||||||
|
|
||||||
|
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core'
|
||||||
|
|
||||||
import { createEditorStore, setActiveEditorStore } from './editor'
|
import { createEditorStore, setActiveEditorStore } from './editor'
|
||||||
|
|
||||||
import type { EditorStore } from './editor'
|
import type { EditorStore } from './editor'
|
||||||
|
|
@ -10,6 +12,8 @@ export interface Tab {
|
||||||
store: EditorStore
|
store: EditorStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const io = new IORegistry(BUILTIN_IO_FORMATS)
|
||||||
|
|
||||||
let nextTabId = 1
|
let nextTabId = 1
|
||||||
|
|
||||||
function generateTabId(): string {
|
function generateTabId(): string {
|
||||||
|
|
@ -82,23 +86,26 @@ export async function openFileInNewTab(
|
||||||
const current = activeTab.value
|
const current = activeTab.value
|
||||||
const isUntouched =
|
const isUntouched =
|
||||||
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
|
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||||
|
const { graph: imported } = await io.readDocument({
|
||||||
|
name: file.name,
|
||||||
|
mimeType: file.type || undefined,
|
||||||
|
data: bytes
|
||||||
|
})
|
||||||
|
const documentName = file.name.replace(/\.[^.]+$/i, '')
|
||||||
|
|
||||||
if (isUntouched) {
|
if (isUntouched) {
|
||||||
const { readFigFile } = await import('@open-pencil/core')
|
|
||||||
const imported = await readFigFile(file)
|
|
||||||
current.store.replaceGraph(imported)
|
current.store.replaceGraph(imported)
|
||||||
current.store.undo.clear()
|
current.store.undo.clear()
|
||||||
current.store.state.documentName = file.name.replace(/\.fig$/i, '')
|
current.store.state.documentName = documentName
|
||||||
current.store.state.selectedIds = new Set()
|
current.store.state.selectedIds = new Set()
|
||||||
const pageId = current.store.graph.getPages()[0]?.id ?? current.store.graph.rootId
|
const pageId = current.store.graph.getPages()[0]?.id ?? current.store.graph.rootId
|
||||||
await current.store.switchPage(pageId)
|
await current.store.switchPage(pageId)
|
||||||
} else {
|
} else {
|
||||||
const { readFigFile } = await import('@open-pencil/core')
|
|
||||||
const imported = await readFigFile(file)
|
|
||||||
const store = createEditorStore(imported)
|
const store = createEditorStore(imported)
|
||||||
createTab(store)
|
createTab(store)
|
||||||
store.undo.clear()
|
store.undo.clear()
|
||||||
store.state.documentName = file.name.replace(/\.fig$/i, '')
|
store.state.documentName = documentName
|
||||||
store.state.selectedIds = new Set()
|
store.state.selectedIds = new Set()
|
||||||
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
|
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
|
||||||
await store.switchPage(pageId)
|
await store.switchPage(pageId)
|
||||||
|
|
|
||||||
37
tests/engine/pen-file.test.ts
Normal file
37
tests/engine/pen-file.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
import { parsePenFile } from '@open-pencil/core'
|
||||||
|
|
||||||
|
const FIXTURE_DIR = join(import.meta.dir, '..', 'fixtures')
|
||||||
|
|
||||||
|
async function loadFixture(name: string): Promise<string> {
|
||||||
|
return Bun.file(join(FIXTURE_DIR, name)).text()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parsePenFile', () => {
|
||||||
|
test('imports variables and theme modes', async () => {
|
||||||
|
const graph = parsePenFile(await loadFixture('pencil_simple.pen'))
|
||||||
|
const collections = [...graph.variableCollections.values()]
|
||||||
|
|
||||||
|
expect(collections.length).toBe(1)
|
||||||
|
expect(collections[0].modes.map((mode) => mode.name)).toEqual(['Light', 'Dark'])
|
||||||
|
expect(graph.variables.size).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps reusable frames to components', async () => {
|
||||||
|
const graph = parsePenFile(await loadFixture('pencil_button.pen'))
|
||||||
|
const components = [...graph.getAllNodes()].filter((node) => node.type === 'COMPONENT')
|
||||||
|
|
||||||
|
expect(components.length).toBeGreaterThan(0)
|
||||||
|
expect(components[0]?.name).toContain('Button')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps path geometry to vector nodes', async () => {
|
||||||
|
const graph = parsePenFile(await loadFixture('pencil_button.pen'))
|
||||||
|
const vectors = [...graph.getAllNodes()].filter((node) => node.type === 'VECTOR')
|
||||||
|
|
||||||
|
expect(vectors.length).toBeGreaterThan(0)
|
||||||
|
expect(vectors.some((node) => (node.vectorNetwork?.vertices.length ?? 0) > 0)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
113
tests/fixtures/pencil_button.pen
vendored
Normal file
113
tests/fixtures/pencil_button.pen
vendored
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
{
|
||||||
|
"version": "2.8",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "frame",
|
||||||
|
"id": "T3Um0",
|
||||||
|
"x": 462.7680165404179,
|
||||||
|
"y": 258.2749319076538,
|
||||||
|
"name": "Button/Large/Default",
|
||||||
|
"reusable": true,
|
||||||
|
"height": 48,
|
||||||
|
"fill": "$--primary",
|
||||||
|
"cornerRadius": "$--radius-pill",
|
||||||
|
"gap": 6,
|
||||||
|
"padding": [
|
||||||
|
12,
|
||||||
|
24
|
||||||
|
],
|
||||||
|
"justifyContent": "center",
|
||||||
|
"alignItems": "center",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "frame",
|
||||||
|
"id": "fd4qq",
|
||||||
|
"name": "plus",
|
||||||
|
"width": 24,
|
||||||
|
"height": 24,
|
||||||
|
"stroke": {
|
||||||
|
"align": "inside",
|
||||||
|
"thickness": 1
|
||||||
|
},
|
||||||
|
"layout": "none",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "path",
|
||||||
|
"id": "NWqkO",
|
||||||
|
"x": 6,
|
||||||
|
"y": 6,
|
||||||
|
"name": "Vector",
|
||||||
|
"geometry": "M7.07104 3.53555l-3.53552 0-3.53552 0m3.53552-3.53555l0 3.53555 0 3.53549",
|
||||||
|
"width": 12,
|
||||||
|
"height": 12,
|
||||||
|
"stroke": {
|
||||||
|
"align": "center",
|
||||||
|
"thickness": 1.5,
|
||||||
|
"join": "round",
|
||||||
|
"cap": "round",
|
||||||
|
"fill": "$--primary-foreground"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "8RtfK",
|
||||||
|
"name": "Button",
|
||||||
|
"fill": "$--primary-foreground",
|
||||||
|
"content": "Button",
|
||||||
|
"lineHeight": 1.5555555555555556,
|
||||||
|
"textAlign": "center",
|
||||||
|
"textAlignVertical": "middle",
|
||||||
|
"fontFamily": "$--font-primary",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontWeight": "500"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"themes": {
|
||||||
|
"Mode": [
|
||||||
|
"Light",
|
||||||
|
"Dark"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"variables": {
|
||||||
|
"--primary": {
|
||||||
|
"type": "color",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"value": "#FF8400"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "#FF8400",
|
||||||
|
"theme": {
|
||||||
|
"Mode": "Dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"--radius-pill": {
|
||||||
|
"type": "number",
|
||||||
|
"value": 999
|
||||||
|
},
|
||||||
|
"--primary-foreground": {
|
||||||
|
"type": "color",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"value": "#111111"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "#111111",
|
||||||
|
"theme": {
|
||||||
|
"Mode": "Dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"--font-primary": {
|
||||||
|
"type": "string",
|
||||||
|
"value": "JetBrains Mono"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1049
tests/fixtures/pencil_simple.pen
vendored
Normal file
1049
tests/fixtures/pencil_simple.pen
vendored
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue