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
|
||||
- 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
|
||||
- `Open…` now supports `.pen` Pencil documents through the shared document reader pipeline while keeping `.fig` as the native save format
|
||||
|
||||
### Fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -297,6 +297,8 @@ export {
|
|||
type OpenPencilClipboardData
|
||||
} from './clipboard'
|
||||
|
||||
export { readPenFile, parsePenFile } from './pen-file'
|
||||
|
||||
export {
|
||||
readFigFile,
|
||||
parseFigFile,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { exportFigFile } from '../fig-export'
|
||||
import { headlessRenderNodes } from '../headless-render'
|
||||
import { parseFigFile } from '../kiwi'
|
||||
import { parsePenFile } from '../pen-file'
|
||||
import { sceneNodeToJSX, selectionToJSX } from '../render'
|
||||
import { renderNodesToImage } from '../render-image'
|
||||
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 jpgFormat = rasterFormat('JPG')
|
||||
export const webpFormat = rasterFormat('WEBP')
|
||||
|
|
@ -268,6 +289,7 @@ export const jsxFormat: IOFormatAdapter = {
|
|||
|
||||
export const BUILTIN_IO_FORMATS: IOFormatAdapter[] = [
|
||||
figFormat,
|
||||
penFormat,
|
||||
pngFormat,
|
||||
jpgFormat,
|
||||
webpFormat,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export { extractExportGraph } from './subgraph'
|
|||
export {
|
||||
BUILTIN_IO_FORMATS,
|
||||
figFormat,
|
||||
penFormat,
|
||||
pngFormat,
|
||||
jpgFormat,
|
||||
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 { 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) => {
|
||||
const file = files?.[0]
|
||||
if (file) void openFileInNewTab(file)
|
||||
|
|
@ -28,7 +28,7 @@ export async function openFileDialog() {
|
|||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const { readFile } = await import('@tauri-apps/plugin-fs')
|
||||
const path = await open({
|
||||
filters: [{ name: 'Figma file', extensions: ['fig'] }],
|
||||
filters: [{ name: 'Design file', extensions: ['fig', 'pen'] }],
|
||||
multiple: false
|
||||
})
|
||||
if (!path) return
|
||||
|
|
@ -43,8 +43,12 @@ export async function openFileDialog() {
|
|||
const [handle] = await window.showOpenFilePicker({
|
||||
types: [
|
||||
{
|
||||
description: 'Figma file',
|
||||
accept: { 'application/octet-stream': ['.fig'] }
|
||||
description: 'Design file',
|
||||
accept: {
|
||||
'application/octet-stream': ['.fig'],
|
||||
'application/json': ['.pen'],
|
||||
'text/plain': ['.pen']
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
@ -59,11 +63,16 @@ export async function openFileDialog() {
|
|||
fileDialog.open()
|
||||
}
|
||||
|
||||
export async function importFileDialog() {
|
||||
await openFileDialog()
|
||||
}
|
||||
|
||||
const store = useEditorStore()
|
||||
|
||||
const MENU_ACTIONS: Partial<Record<string, () => void>> = {
|
||||
new: () => createTab(),
|
||||
open: () => void openFileDialog(),
|
||||
import: () => void importFileDialog(),
|
||||
close: () => {
|
||||
if (activeTab.value) closeTab(activeTab.value.id)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { shallowRef, computed, triggerRef } from 'vue'
|
||||
|
||||
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core'
|
||||
|
||||
import { createEditorStore, setActiveEditorStore } from './editor'
|
||||
|
||||
import type { EditorStore } from './editor'
|
||||
|
|
@ -10,6 +12,8 @@ export interface Tab {
|
|||
store: EditorStore
|
||||
}
|
||||
|
||||
const io = new IORegistry(BUILTIN_IO_FORMATS)
|
||||
|
||||
let nextTabId = 1
|
||||
|
||||
function generateTabId(): string {
|
||||
|
|
@ -82,23 +86,26 @@ export async function openFileInNewTab(
|
|||
const current = activeTab.value
|
||||
const isUntouched =
|
||||
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) {
|
||||
const { readFigFile } = await import('@open-pencil/core')
|
||||
const imported = await readFigFile(file)
|
||||
current.store.replaceGraph(imported)
|
||||
current.store.undo.clear()
|
||||
current.store.state.documentName = file.name.replace(/\.fig$/i, '')
|
||||
current.store.state.documentName = documentName
|
||||
current.store.state.selectedIds = new Set()
|
||||
const pageId = current.store.graph.getPages()[0]?.id ?? current.store.graph.rootId
|
||||
await current.store.switchPage(pageId)
|
||||
} else {
|
||||
const { readFigFile } = await import('@open-pencil/core')
|
||||
const imported = await readFigFile(file)
|
||||
const store = createEditorStore(imported)
|
||||
createTab(store)
|
||||
store.undo.clear()
|
||||
store.state.documentName = file.name.replace(/\.fig$/i, '')
|
||||
store.state.documentName = documentName
|
||||
store.state.selectedIds = new Set()
|
||||
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
|
||||
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