Fix imported fig rendering and file open regressions

This commit is contained in:
Danila Poyarkov 2026-03-24 16:52:45 +03:00
parent 0a770248a2
commit 07af72ab24
36 changed files with 596 additions and 286 deletions

View file

@ -12,6 +12,9 @@
### Fixes
- Fix imported `.fig` file open and page-switch regressions — loaded documents now keep graph/store state in sync, remap imported canvas/page children correctly, and recompute imported auto-layout descendants when switching pages
- Fix imported text rendering in browser and headless export — preserve stored bounds until fonts are ready, restore missing font-loaded guards, use natural width for `WIDTH_AND_HEIGHT` text, and clip text to node bounds
- Fix browser/headless rendering mismatch for imported toolbar/instance content by correcting runtime imported layout recomputation instead of diverging browser rendering behavior
- Fix `set_layout` tool not defaulting to HUG sizing when enabling auto-layout — frames now shrink/grow to fit children instead of keeping fixed dimensions
- Fix save crash when COLOR variable is missing alpha channel
- Fix console error spam on deployed web app from automation WebSocket reconnect loop

View file

@ -1,11 +1,10 @@
import { CANVAS_BG_COLOR, IS_BROWSER } from '../constants'
import { computeLayout, setTextMeasurer } from '../layout'
import { loadFont as defaultLoadFont } from '../fonts'
import { prefetchFigmaSchema } from '../clipboard'
import { CANVAS_BG_COLOR, IS_BROWSER } from '../constants'
import { loadFont as defaultLoadFont } from '../fonts'
import { computeLayout, setTextMeasurer } from '../layout'
import { SceneGraph } from '../scene-graph'
import { TextEditor } from '../text-editor'
import { UndoManager } from '../undo'
import { createAlignmentActions } from './alignment'
import { createClipboardActions } from './clipboard'
import { createComponentActions } from './components'
@ -19,10 +18,10 @@ import { createUndoActions } from './undo'
import { createVariableActions } from './variables'
import { createViewportActions } from './viewport'
import type { SceneNode } from '../scene-graph'
import type { SkiaRenderer } from '../renderer/renderer'
import type { CanvasKit } from 'canvaskit-wasm'
import type { SceneNode } from '../scene-graph'
import type { EditorContext, EditorOptions, EditorState } from './types'
import type { CanvasKit } from 'canvaskit-wasm'
export function createDefaultEditorState(pageId: string): EditorState {
return {
@ -54,12 +53,15 @@ export function createDefaultEditorState(pageId: string): EditorState {
export function createEditor(options?: EditorOptions) {
let _graph = options?.graph ?? new SceneGraph()
const skipInitialGraphSetup = options?.skipInitialGraphSetup ?? false
const undo = new UndoManager()
const _loadFont = options?.loadFont ?? defaultLoadFont
const _getViewportSize = options?.getViewportSize ?? (() => {
if (IS_BROWSER) return { width: window.innerWidth, height: window.innerHeight }
return { width: 800, height: 600 }
})
const _getViewportSize =
options?.getViewportSize ??
(() => {
if (IS_BROWSER) return { width: window.innerWidth, height: window.innerHeight }
return { width: 800, height: 600 }
})
let _ck: CanvasKit | null = null
let _renderer: SkiaRenderer | null = null
let _textEditor: TextEditor | null = null
@ -153,12 +155,18 @@ export function createEditor(options?: EditorOptions) {
]
}
subscribeToGraph()
if (!skipInitialGraphSetup) {
subscribeToGraph()
}
// Build the shared context
const ctx: EditorContext = {
get graph() { return _graph },
set graph(g) { _graph = g },
get graph() {
return _graph
},
set graph(g) {
_graph = g
},
undo,
state,
loadFont: _loadFont,
@ -204,9 +212,15 @@ export function createEditor(options?: EditorOptions) {
}
return {
get graph() { return _graph },
get renderer() { return _renderer },
get textEditor() { return _textEditor },
get graph() {
return _graph
},
get renderer() {
return _renderer
},
get textEditor() {
return _textEditor
},
undo,
state,
@ -221,6 +235,7 @@ export function createEditor(options?: EditorOptions) {
requestRepaint,
setCanvasKit,
replaceGraph,
subscribeToGraph,
// Selection
...selection,
@ -263,7 +278,8 @@ export function createEditor(options?: EditorOptions) {
// Clipboard — bridge functions that need selectedNodes
duplicateSelected: () => clipboard.duplicateSelected(selection.getSelectedNodes()),
writeCopyData: (data: DataTransfer) => clipboard.writeCopyData(data, selection.getSelectedNodes()),
writeCopyData: (data: DataTransfer) =>
clipboard.writeCopyData(data, selection.getSelectedNodes()),
pasteFromHTML: clipboard.pasteFromHTML,
deleteSelected: clipboard.deleteSelected,
storeImage: clipboard.storeImage,

View file

@ -1,5 +1,6 @@
import { CANVAS_BG_COLOR } from '../constants'
import { collectFontKeys } from '../fonts'
import { computeAllLayouts } from '../layout'
import type { Color } from '../types'
import type { EditorContext } from './types'
@ -42,10 +43,16 @@ export function createPageActions(ctx: EditorContext) {
ctx.state.pageColor = { ...CANVAS_BG_COLOR }
}
const toLoad = collectFontKeys(ctx.graph, ctx.graph.getChildren(pageId).map((n) => n.id))
const toLoad = collectFontKeys(
ctx.graph,
ctx.graph.getChildren(pageId).map((n) => n.id)
)
if (toLoad.length > 0) {
await Promise.all(toLoad.map(([family, style]) => ctx.loadFont(family, style)))
}
if (ctx.getRenderer()) {
computeAllLayouts(ctx.graph, pageId)
}
ctx.requestRender()
}

View file

@ -1,9 +1,9 @@
import type { Color, Rect, Vector } from '../types'
import type { SkiaRenderer } from '../renderer/renderer'
import type { SceneGraph, VectorSegment, VectorVertex } from '../scene-graph'
import type { SnapGuide } from '../snap'
import type { SkiaRenderer } from '../renderer/renderer'
import type { UndoManager } from '../undo'
import type { TextEditor } from '../text-editor'
import type { Color, Rect, Vector } from '../types'
import type { UndoManager } from '../undo'
import type { CanvasKit } from 'canvaskit-wasm'
export type Tool =
@ -101,6 +101,7 @@ export interface EditorOptions {
state?: EditorState
loadFont?: (family: string, style: string) => Promise<ArrayBuffer | null>
getViewportSize?: () => { width: number; height: number }
skipInitialGraphSetup?: boolean
}
export interface EditorContext {

View file

@ -1,6 +1,5 @@
import type { VariableType, VariableValue } from '../scene-graph'
import { SceneGraph } from '../scene-graph'
import { populateAndApplyOverrides } from './instance-overrides'
import {
guidToString,
nodeChangeToProps,
@ -8,12 +7,14 @@ import {
setVariableColorResolver,
VARIABLE_BINDING_FIELDS_INVERSE
} from './kiwi-convert'
import { populateAndApplyOverrides } from './instance-overrides'
import type { VariableType, VariableValue } from '../scene-graph'
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from './codec'
import type { InstanceNodeChange } from './instance-overrides'
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from './codec'
function buildVariableColorResolver(changeMap: Map<string, NodeChange>): (guid: GUID) => Color | null {
function buildVariableColorResolver(
changeMap: Map<string, NodeChange>
): (guid: GUID) => Color | null {
// Collect variable data: GUID → entries
const varEntries = new Map<string, VariableDataValuesEntry[]>()
const varSetId = new Map<string, string>()
@ -44,7 +45,7 @@ function buildVariableColorResolver(changeMap: Map<string, NodeChange>): (guid:
const setId = varSetId.get(id)
const defaultMode = setId ? defaultModes.get(setId) : undefined
let entry = defaultMode
? entries.find(e => guidToString(e.modeID) === defaultMode)
? entries.find((e) => guidToString(e.modeID) === defaultMode)
: undefined
if (!entry) entry = entries[0]
@ -124,10 +125,7 @@ function resolveDefaultValue(type: VariableType): VariableValue {
return 0
}
function importCollections(
changeMap: Map<string, NodeChange>,
graph: SceneGraph
): void {
function importCollections(changeMap: Map<string, NodeChange>, graph: SceneGraph): void {
for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE_SET') continue
@ -155,7 +153,9 @@ function importVariableEntries(
for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE') continue
const collectionId = nc.variableSetID?.guid ? guidToString(nc.variableSetID.guid) : (parentMap.get(id) ?? '')
const collectionId = nc.variableSetID?.guid
? guidToString(nc.variableSetID.guid)
: (parentMap.get(id) ?? '')
if (!graph.variableCollections.has(collectionId)) {
const parentNc = changeMap.get(collectionId)
@ -204,6 +204,7 @@ function importPages(
parentMap: Map<string, string>,
childrenMap: Map<string, string[]>,
created: Set<string>,
canvasIdToPageId: Map<string, string>,
createSceneNode: (ncId: string, graphParentId: string) => void
): void {
let docId: string | null = null
@ -220,6 +221,7 @@ function importPages(
if (!canvasNc) continue
if (canvasNc.type === 'CANVAS') {
const page = graph.addPage(canvasNc.name ?? 'Page')
canvasIdToPageId.set(canvasId, page.id)
if (canvasNc.internalOnly) page.internalOnly = true
created.add(canvasId)
for (const childId of childrenMap.get(canvasId) ?? []) {
@ -260,10 +262,7 @@ function importVariableBindings(
}
}
function remapComponentIds(
graph: SceneGraph,
guidToNodeId: Map<string, string>
): void {
function remapComponentIds(graph: SceneGraph, guidToNodeId: Map<string, string>): void {
for (const node of graph.getAllNodes()) {
if (node.type !== 'INSTANCE' || !node.componentId) continue
const remapped = guidToNodeId.get(node.componentId)
@ -290,6 +289,7 @@ export function importNodeChanges(
const { changeMap, parentMap, childrenMap } = buildChangeMaps(nodeChanges)
const canvasIdToPageId = new Map<string, string>()
const created = new Set<string>()
const guidToNodeId = new Map<string, string>()
const getChildren = (ncId: string): string[] => childrenMap.get(ncId) ?? []
@ -304,7 +304,8 @@ export function importNodeChanges(
const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE' || nc.type === 'VARIABLE_SET') return
const node = graph.createNode(nodeType, graphParentId, props)
const parentId = canvasIdToPageId.get(graphParentId) ?? graphParentId
const node = graph.createNode(nodeType, parentId, props)
guidToNodeId.set(ncId, node.id)
for (const childId of getChildren(ncId)) {
@ -312,7 +313,7 @@ export function importNodeChanges(
}
}
importPages(graph, changeMap, parentMap, childrenMap, created, createSceneNode)
importPages(graph, changeMap, parentMap, childrenMap, created, canvasIdToPageId, createSceneNode)
importCollections(changeMap, graph)
importVariableEntries(changeMap, parentMap, graph)

View file

@ -1,6 +1,7 @@
import type { SceneGraph, SceneNode } from '../../scene-graph'
import { copyFills, copyStrokes, copyEffects, copyStyleRuns } from '../../copy'
import type { SceneGraph, SceneNode } from '../../scene-graph'
/**
* Copy appearance props from source to target (text, visibility, fills, etc.).
* Only writes properties that actually differ.
@ -15,7 +16,8 @@ export function syncNodeProps(graph: SceneGraph, source: SceneNode, target: Scen
if (source.effects !== target.effects) updates.effects = copyEffects(source.effects)
if (source.styleRuns !== target.styleRuns) updates.styleRuns = copyStyleRuns(source.styleRuns)
if (source.layoutGrow !== target.layoutGrow) updates.layoutGrow = source.layoutGrow
if (source.textAutoResize !== target.textAutoResize) updates.textAutoResize = source.textAutoResize
if (source.textAutoResize !== target.textAutoResize)
updates.textAutoResize = source.textAutoResize
if (source.locked !== target.locked) updates.locked = source.locked
if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates)
}
@ -63,7 +65,11 @@ export function syncChildrenDeep(
const tgtNode = graph.getNode(tgt.childIds[i])
if (!srcNode || !tgtNode || srcNode.type !== tgtNode.type) continue
if (srcNode.type === 'INSTANCE' && swappedInstances.has(src.childIds[i]) && srcNode.componentId !== tgtNode.componentId) {
if (
srcNode.type === 'INSTANCE' &&
swappedInstances.has(src.childIds[i]) &&
srcNode.componentId !== tgtNode.componentId
) {
recloneChildren(graph, src.childIds[i], tgtNode, swappedInstances)
continue
}
@ -106,7 +112,10 @@ function expandSeedsToParents(graph: SceneGraph, seeds: Set<string>): Set<string
}
/** BFS from expanded seeds through clone chains to find all nodes needing sync. */
function buildNeedsSyncSet(expandedSeeds: Set<string>, clonesOf: Map<string, string[]>): Set<string> {
function buildNeedsSyncSet(
expandedSeeds: Set<string>,
clonesOf: Map<string, string[]>
): Set<string> {
const needsSync = new Set<string>()
const queue = [...expandedSeeds]
for (let id = queue.pop(); id !== undefined; id = queue.pop()) {
@ -142,9 +151,7 @@ export function propagateOverridesTransitively(
const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf)
// Merge seeds + protect into a single skip set for syncChildrenDeep
const skip = protect && protect.size > 0
? new Set([...seeds, ...protect])
: seeds
const skip = protect && protect.size > 0 ? new Set([...seeds, ...protect]) : seeds
const visited = new Set<string>()
const syncQueue = [...expandedSeeds]

View file

@ -28,11 +28,86 @@ import {
DEFAULT_FONT_FAMILY,
IS_BROWSER
} from '../constants'
import { computeAbsoluteBounds } from '../geometry'
import { RenderProfiler } from '../profiler'
import { drawAiOverlays as drawAiOverlaysFn } from './ai-overlays'
import {
getCachedDropShadow as getCachedDropShadowFn,
getCachedBlur as getCachedBlurFn,
getCachedDecalBlur as getCachedDecalBlurFn,
getCachedMaskBlur as getCachedMaskBlurFn,
applyClippedBlur as applyClippedBlurFn
} from './effects'
import {
drawNodeFill as drawNodeFillFn,
applyFill as applyFillFn,
applyGradientFill as applyGradientFillFn,
applyImageFill as applyImageFillFn,
drawArc as drawArcFn
} from './fills'
import { LabelCache } from './label-cache'
import {
drawSectionTitles as drawSectionTitlesFn,
drawComponentLabels as drawComponentLabelsFn
} from './labels'
import {
drawHoverHighlight as drawHoverHighlightFn,
drawEnteredContainer as drawEnteredContainerFn,
drawSelection as drawSelectionFn,
drawNodeSelection as drawNodeSelectionFn,
drawSelectionLabels as drawSelectionLabelsFn,
drawParentFrameOutlines as drawParentFrameOutlinesFn,
drawNodeOutline as drawNodeOutlineFn,
drawGroupBounds as drawGroupBoundsFn,
getRotatedCorners as getRotatedCornersFn,
drawHandle as drawHandleFn,
drawSnapGuides as drawSnapGuidesFn,
drawMarquee as drawMarqueeFn,
drawFlashes as drawFlashesFn,
drawLayoutInsertIndicator as drawLayoutInsertIndicatorFn,
drawTextEditOverlay as drawTextEditOverlayFn
} from './overlays'
import {
drawPenOverlay as drawPenOverlayFn,
drawRemoteCursors as drawRemoteCursorsFn
} from './pen-overlay'
import { drawRulers as drawRulersFn } from './rulers'
import {
renderNode as renderNodeFn,
renderSection as renderSectionFn,
renderComponentSet as renderComponentSetFn,
renderShape as renderShapeFn,
renderShapeUncached as renderShapeUncachedFn,
renderEffects as renderEffectsFn,
renderText as renderTextFn
} from './scene'
import {
makeNodeShapePath as makeNodeShapePathFn,
makePolygonPath as makePolygonPathFn,
makeRRect as makeRRectFn,
makeRRectWithSpread as makeRRectWithSpreadFn,
makeRRectWithOffset as makeRRectWithOffsetFn,
clipNodeShape as clipNodeShapeFn,
getVectorPaths as getVectorPathsFn,
getFillGeometry as getFillGeometryFn,
getStrokeGeometry as getStrokeGeometryFn
} from './shapes'
import {
drawNodeStroke as drawNodeStrokeFn,
drawStrokeWithAlign as drawStrokeWithAlignFn,
drawRRectStrokeWithAlign as drawRRectStrokeWithAlignFn,
drawIndividualSideStrokes as drawIndividualSideStrokesFn,
strokeNodeShape as strokeNodeShapeFn
} from './strokes'
import {
measureTextNode as measureTextNodeFn,
isNodeFontLoaded as isNodeFontLoadedFn,
buildTextPicture as buildTextPictureFn,
buildParagraph as buildParagraphFn
} from './text'
import type { SceneNode, SceneGraph, Fill, Stroke } from '../scene-graph'
import type { EditorState } from '../editor/types'
import type { SceneNode, SceneGraph, Fill, Stroke } from '../scene-graph'
import type { SnapGuide } from '../snap'
import type { TextEditor } from '../text-editor'
import type { Color, Rect, Vector } from '../types'
@ -52,82 +127,6 @@ import type {
Paragraph
} from 'canvaskit-wasm'
import {
drawHoverHighlight as drawHoverHighlightFn,
drawEnteredContainer as drawEnteredContainerFn,
drawSelection as drawSelectionFn,
drawNodeSelection as drawNodeSelectionFn,
drawSelectionLabels as drawSelectionLabelsFn,
drawParentFrameOutlines as drawParentFrameOutlinesFn,
drawNodeOutline as drawNodeOutlineFn,
drawGroupBounds as drawGroupBoundsFn,
getRotatedCorners as getRotatedCornersFn,
drawHandle as drawHandleFn,
drawSnapGuides as drawSnapGuidesFn,
drawMarquee as drawMarqueeFn,
drawFlashes as drawFlashesFn,
drawLayoutInsertIndicator as drawLayoutInsertIndicatorFn,
drawTextEditOverlay as drawTextEditOverlayFn,
} from './overlays'
import { drawAiOverlays as drawAiOverlaysFn } from './ai-overlays'
import {
drawPenOverlay as drawPenOverlayFn,
drawRemoteCursors as drawRemoteCursorsFn
} from './pen-overlay'
import { drawRulers as drawRulersFn } from './rulers'
import {
drawSectionTitles as drawSectionTitlesFn,
drawComponentLabels as drawComponentLabelsFn
} from './labels'
import { LabelCache } from './label-cache'
import {
renderNode as renderNodeFn,
renderSection as renderSectionFn,
renderComponentSet as renderComponentSetFn,
renderShape as renderShapeFn,
renderShapeUncached as renderShapeUncachedFn,
renderEffects as renderEffectsFn,
renderText as renderTextFn
} from './scene'
import {
drawNodeFill as drawNodeFillFn,
applyFill as applyFillFn,
applyGradientFill as applyGradientFillFn,
applyImageFill as applyImageFillFn,
drawArc as drawArcFn
} from './fills'
import {
drawNodeStroke as drawNodeStrokeFn,
drawStrokeWithAlign as drawStrokeWithAlignFn,
drawRRectStrokeWithAlign as drawRRectStrokeWithAlignFn,
drawIndividualSideStrokes as drawIndividualSideStrokesFn,
strokeNodeShape as strokeNodeShapeFn
} from './strokes'
import {
makeNodeShapePath as makeNodeShapePathFn,
makePolygonPath as makePolygonPathFn,
makeRRect as makeRRectFn,
makeRRectWithSpread as makeRRectWithSpreadFn,
makeRRectWithOffset as makeRRectWithOffsetFn,
clipNodeShape as clipNodeShapeFn,
getVectorPaths as getVectorPathsFn,
getFillGeometry as getFillGeometryFn,
getStrokeGeometry as getStrokeGeometryFn
} from './shapes'
import {
getCachedDropShadow as getCachedDropShadowFn,
getCachedBlur as getCachedBlurFn,
getCachedDecalBlur as getCachedDecalBlurFn,
getCachedMaskBlur as getCachedMaskBlurFn,
applyClippedBlur as applyClippedBlurFn
} from './effects'
import {
measureTextNode as measureTextNodeFn,
isNodeFontLoaded as isNodeFontLoadedFn,
buildTextPicture as buildTextPictureFn,
buildParagraph as buildParagraphFn
} from './text'
export interface RenderOverlays {
hoveredNodeId?: string | null
enteredContainerId?: string | null
@ -501,7 +500,9 @@ export class SkiaRenderer {
}
get hasActiveFlashes(): boolean {
return this._flashes.length > 0 || this._aiActiveNodes.size > 0 || this._aiDoneFlashes.length > 0
return (
this._flashes.length > 0 || this._aiActiveNodes.size > 0 || this._aiDoneFlashes.length > 0
)
}
hitTestSectionTitle(graph: SceneGraph, canvasX: number, canvasY: number): SceneNode | null {
@ -673,7 +674,14 @@ export class SkiaRenderer {
this.worldViewport = prevViewport
}
renderFromEditorState(state: EditorState, graph: SceneGraph, textEditor: unknown, viewportWidth: number, viewportHeight: number, showRulers = true): void {
renderFromEditorState(
state: EditorState,
graph: SceneGraph,
textEditor: unknown,
viewportWidth: number,
viewportHeight: number,
showRulers = true
): void {
this.dpr = IS_BROWSER ? window.devicePixelRatio || 1 : 1
this.panX = state.panX
this.panY = state.panY
@ -683,21 +691,30 @@ export class SkiaRenderer {
this.showRulers = showRulers
this.pageColor = state.pageColor
this.pageId = state.currentPageId
this.render(graph, state.selectedIds, {
hoveredNodeId: state.hoveredNodeId,
enteredContainerId: state.enteredContainerId,
editingTextId: state.editingTextId,
textEditor: textEditor as RenderOverlays['textEditor'],
marquee: state.marquee,
snapGuides: state.snapGuides,
rotationPreview: state.rotationPreview,
dropTargetId: state.dropTargetId,
layoutInsertIndicator: state.layoutInsertIndicator,
penState: state.penState
? { ...state.penState, cursorX: state.penCursorX ?? undefined, cursorY: state.penCursorY ?? undefined } as RenderOverlays['penState']
: null,
remoteCursors: state.remoteCursors
}, state.sceneVersion)
this.render(
graph,
state.selectedIds,
{
hoveredNodeId: state.hoveredNodeId,
enteredContainerId: state.enteredContainerId,
editingTextId: state.editingTextId,
textEditor: textEditor as RenderOverlays['textEditor'],
marquee: state.marquee,
snapGuides: state.snapGuides,
rotationPreview: state.rotationPreview,
dropTargetId: state.dropTargetId,
layoutInsertIndicator: state.layoutInsertIndicator,
penState: state.penState
? ({
...state.penState,
cursorX: state.penCursorX ?? undefined,
cursorY: state.penCursorY ?? undefined
} as RenderOverlays['penState'])
: null,
remoteCursors: state.remoteCursors
},
state.sceneVersion
)
}
render(
@ -812,9 +829,24 @@ export class SkiaRenderer {
const prevViewport = this.worldViewport
this.worldViewport = { x: -1e6, y: -1e6, w: 2e6, h: 2e6 }
const recorder = new this.ck.PictureRecorder()
const bounds = this.ck.LTRBRect(-1e6, -1e6, 1e6, 1e6)
const recCanvas = recorder.beginRecording(bounds)
const pageNode = graph.getNode(this.pageId ?? graph.rootId)
const sceneNodes = pageNode
? pageNode.childIds
.map((childId) => graph.getNode(childId))
.filter((node): node is SceneNode => node != null)
: []
const sceneBounds =
sceneNodes.length > 0
? computeAbsoluteBounds(sceneNodes, (id) => graph.getAbsolutePosition(id))
: { x: 0, y: 0, width: 1, height: 1 }
const padding = 1024
const bounds = this.ck.LTRBRect(
sceneBounds.x - padding,
sceneBounds.y - padding,
sceneBounds.x + sceneBounds.width + padding,
sceneBounds.y + sceneBounds.height + padding
)
const recCanvas = recorder.beginRecording(bounds)
if (pageNode) {
for (const childId of pageNode.childIds) {
this.renderNode(recCanvas, graph, childId, {}, 0, 0)
@ -944,15 +976,28 @@ export class SkiaRenderer {
// --- Delegation methods ---
private drawHoverHighlight(canvas: Canvas, graph: SceneGraph, hoveredNodeId?: string | null): void {
private drawHoverHighlight(
canvas: Canvas,
graph: SceneGraph,
hoveredNodeId?: string | null
): void {
drawHoverHighlightFn(this, canvas, graph, hoveredNodeId)
}
private drawEnteredContainer(canvas: Canvas, graph: SceneGraph, enteredContainerId?: string | null): void {
private drawEnteredContainer(
canvas: Canvas,
graph: SceneGraph,
enteredContainerId?: string | null
): void {
drawEnteredContainerFn(this, canvas, graph, enteredContainerId)
}
private drawSelection(canvas: Canvas, graph: SceneGraph, selectedIds: Set<string>, overlays: RenderOverlays): void {
private drawSelection(
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>,
overlays: RenderOverlays
): void {
drawSelectionFn(this, canvas, graph, selectedIds, overlays)
}
@ -997,7 +1042,10 @@ export class SkiaRenderer {
drawAiOverlaysFn(this, canvas, graph)
}
private drawLayoutInsertIndicator(canvas: Canvas, indicator?: RenderOverlays['layoutInsertIndicator']): void {
private drawLayoutInsertIndicator(
canvas: Canvas,
indicator?: RenderOverlays['layoutInsertIndicator']
): void {
drawLayoutInsertIndicatorFn(this, canvas, indicator)
}
@ -1009,7 +1057,11 @@ export class SkiaRenderer {
drawPenOverlayFn(this, canvas, penState)
}
private drawRemoteCursors(canvas: Canvas, graph: SceneGraph, cursors?: RenderOverlays['remoteCursors']): void {
private drawRemoteCursors(
canvas: Canvas,
graph: SceneGraph,
cursors?: RenderOverlays['remoteCursors']
): void {
drawRemoteCursorsFn(this, canvas, graph, cursors)
}
@ -1025,7 +1077,14 @@ export class SkiaRenderer {
drawComponentLabelsFn(this, canvas, graph)
}
renderNode(canvas: Canvas, graph: SceneGraph, nodeId: string, overlays: RenderOverlays, parentAbsX = 0, parentAbsY = 0): void {
renderNode(
canvas: Canvas,
graph: SceneGraph,
nodeId: string,
overlays: RenderOverlays,
parentAbsX = 0,
parentAbsY = 0
): void {
renderNodeFn(this, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY)
}
@ -1045,7 +1104,14 @@ export class SkiaRenderer {
renderShapeUncachedFn(this, canvas, node, graph)
}
renderEffects(canvas: Canvas, node: SceneNode, rect: Float32Array, hasRadius: boolean, pass: 'behind' | 'front', shadowShapeChild?: SceneNode | null): void {
renderEffects(
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
pass: 'behind' | 'front',
shadowShapeChild?: SceneNode | null
): void {
renderEffectsFn(this, canvas, node, rect, hasRadius, pass, shadowShapeChild)
}
@ -1077,15 +1143,30 @@ export class SkiaRenderer {
drawNodeStrokeFn(this, canvas, node, rect, hasRadius)
}
drawStrokeWithAlign(canvas: Canvas, node: SceneNode, rect: Float32Array, hasRadius: boolean, align: 'INSIDE' | 'CENTER' | 'OUTSIDE'): void {
drawStrokeWithAlign(
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
align: 'INSIDE' | 'CENTER' | 'OUTSIDE'
): void {
drawStrokeWithAlignFn(this, canvas, node, rect, hasRadius, align)
}
drawRRectStrokeWithAlign(canvas: Canvas, rrect: Float32Array, node: SceneNode, stroke: Stroke): void {
drawRRectStrokeWithAlign(
canvas: Canvas,
rrect: Float32Array,
node: SceneNode,
stroke: Stroke
): void {
drawRRectStrokeWithAlignFn(this, canvas, rrect, node, stroke)
}
drawIndividualSideStrokes(canvas: Canvas, node: SceneNode, align: 'INSIDE' | 'CENTER' | 'OUTSIDE'): void {
drawIndividualSideStrokes(
canvas: Canvas,
node: SceneNode,
align: 'INSIDE' | 'CENTER' | 'OUTSIDE'
): void {
drawIndividualSideStrokesFn(this, canvas, node, align)
}
@ -1145,7 +1226,13 @@ export class SkiaRenderer {
return getCachedMaskBlurFn(this, sigma)
}
applyClippedBlur(canvas: Canvas, node: SceneNode, rect: Float32Array, hasRadius: boolean, sigma: number): void {
applyClippedBlur(
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
sigma: number
): void {
applyClippedBlurFn(this, canvas, node, rect, hasRadius, sigma)
}
}

View file

@ -1,15 +1,11 @@
import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '../constants'
import type { SceneNode, SceneGraph } from '../scene-graph'
import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm'
import type { Color } from '../types'
import type { SkiaRenderer, RenderOverlays } from './renderer'
import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm'
function isCulled(
r: SkiaRenderer,
node: SceneNode,
absX: number,
absY: number
): boolean {
function isCulled(r: SkiaRenderer, node: SceneNode, absX: number, absY: number): boolean {
const canCull =
node.childIds.length === 0 ||
((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') &&
@ -47,10 +43,7 @@ function applyNodeTransforms(
}
if (node.flipX || node.flipY) {
canvas.translate(
node.flipX ? node.width : 0,
node.flipY ? node.height : 0
)
canvas.translate(node.flipX ? node.width : 0, node.flipY ? node.height : 0)
canvas.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1)
}
}
@ -95,8 +88,13 @@ function renderChildren(
node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE'
if (isClippableContainer && node.clipsContent && node.childIds.length > 0) {
canvas.save()
const hasRadius = node.cornerRadius > 0 || (node.independentCorners &&
(node.topLeftRadius > 0 || node.topRightRadius > 0 || node.bottomRightRadius > 0 || node.bottomLeftRadius > 0))
const hasRadius =
node.cornerRadius > 0 ||
(node.independentCorners &&
(node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomRightRadius > 0 ||
node.bottomLeftRadius > 0))
if (hasRadius) {
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
} else {
@ -219,10 +217,7 @@ export function renderComponentSet(
r.auxStroke.setStrokeWidth(r.COMPONENT_SET_BORDER_WIDTH / r.zoom)
r.auxStroke.setColor(r.compColor())
r.auxStroke.setPathEffect(
r.ck.PathEffect.MakeDash(
[r.COMPONENT_SET_DASH / r.zoom, r.COMPONENT_SET_DASH_GAP / r.zoom],
0
)
r.ck.PathEffect.MakeDash([r.COMPONENT_SET_DASH / r.zoom, r.COMPONENT_SET_DASH_GAP / r.zoom], 0)
)
canvas.drawRRect(rrect, r.auxStroke)
r.auxStroke.setPathEffect(null)
@ -283,17 +278,23 @@ function getShadowShapeChild(node: SceneNode, graph: SceneGraph): SceneNode | nu
function getCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity {
switch (cap) {
case 'ROUND': return r.ck.StrokeCap.Round
case 'SQUARE': return r.ck.StrokeCap.Square
default: return r.ck.StrokeCap.Butt
case 'ROUND':
return r.ck.StrokeCap.Round
case 'SQUARE':
return r.ck.StrokeCap.Square
default:
return r.ck.StrokeCap.Butt
}
}
function getJoinEntity(r: SkiaRenderer, join: string | undefined): EmbindEnumEntity {
switch (join) {
case 'ROUND': return r.ck.StrokeJoin.Round
case 'BEVEL': return r.ck.StrokeJoin.Bevel
default: return r.ck.StrokeJoin.Miter
case 'ROUND':
return r.ck.StrokeJoin.Round
case 'BEVEL':
return r.ck.StrokeJoin.Bevel
default:
return r.ck.StrokeJoin.Miter
}
}
@ -530,7 +531,9 @@ export function renderEffects(
innerPath.delete()
} else if (hasRadius) {
const innerPath = new r.ck.Path()
innerPath.addRRect(r.makeRRectWithOffset(node, effect.offset.x + sp, effect.offset.y + sp, -sp))
innerPath.addRRect(
r.makeRRectWithOffset(node, effect.offset.x + sp, effect.offset.y + sp, -sp)
)
bigPath.op(innerPath, r.ck.PathOp.Difference)
innerPath.delete()
} else {
@ -558,22 +561,25 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): vo
const text = node.text
if (!text) return
canvas.save()
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
if (node.textPicture) {
const pic = r.ck.MakePicture(node.textPicture)
if (pic) {
canvas.drawPicture(pic)
pic.delete()
canvas.restore()
return
}
}
if (r.fontsLoaded && r.fontProvider) {
const paragraph = r.buildParagraph(node, r.fillPaint.getColor(), { halfLeading: true })
const paragraph = r.buildParagraph(node, r.fillPaint.getColor())
canvas.drawParagraph(paragraph, 0, 0)
paragraph.delete()
} else if (r.textFont) {
canvas.save()
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
canvas.drawText(text, 0, node.fontSize || r.DEFAULT_FONT_SIZE, r.fillPaint, r.textFont)
canvas.restore()
}
canvas.restore()
}

View file

@ -24,14 +24,11 @@ export function measureTextNode(
node: SceneNode,
maxWidth?: number
): { width: number; height: number } | null {
if (!r.fontsLoaded || !r.fontProvider) return null
if (!r.fontsLoaded || !r.fontProvider || !isNodeFontLoaded(r, node)) return null
if (node.type !== 'TEXT' || !node.text) return null
const paragraph = buildParagraph(r, node)
let layoutWidth = node.width || 1e6
if (maxWidth !== undefined) layoutWidth = maxWidth
else if (node.textAutoResize === 'WIDTH_AND_HEIGHT') layoutWidth = 1e6
paragraph.layout(layoutWidth)
paragraph.layout(resolveParagraphLayoutWidth(node, maxWidth))
const width = paragraph.getLongestLine()
const height = paragraph.getHeight()
paragraph.delete()
@ -39,7 +36,7 @@ export function measureTextNode(
}
export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array | null {
if (!r.fontsLoaded || !r.fontProvider) return null
if (!r.fontsLoaded || !r.fontProvider || !isNodeFontLoaded(r, node)) return null
if (node.type !== 'TEXT' || !node.text) return null
const ck = r.ck
@ -47,7 +44,7 @@ export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array |
const bounds = ck.LTRBRect(0, 0, node.width || 1e6, node.height || 1e6)
const recCanvas = recorder.beginRecording(bounds)
const paragraph = buildParagraph(r, node, undefined, { halfLeading: true })
const paragraph = buildParagraph(r, node)
recCanvas.drawParagraph(paragraph, 0, 0)
paragraph.delete()
@ -59,6 +56,12 @@ export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array |
return bytes ?? null
}
function resolveParagraphLayoutWidth(node: SceneNode, maxWidth?: number): number {
if (maxWidth !== undefined) return maxWidth
if (node.textAutoResize === 'WIDTH_AND_HEIGHT') return 1e6
return node.width || 1e6
}
function buildTruncateOpts(
node: SceneNode,
baseFontSize: number
@ -202,7 +205,12 @@ export function buildParagraph(
}
const paragraph = builder.build()
paragraph.layout(node.width || 1e6)
if (node.textAutoResize === 'WIDTH_AND_HEIGHT') {
paragraph.layout(1e6)
paragraph.layout(Math.max(node.width || 1, Math.ceil(paragraph.getLongestLine())))
} else {
paragraph.layout(resolveParagraphLayoutWidth(node))
}
builder.delete()
return paragraph
}

View file

@ -59,7 +59,11 @@ function copyProp(
}
}
function cloneChildrenWithMapping(graph: SceneGraph, sourceParentId: string, destParentId: string): void {
function cloneChildrenWithMapping(
graph: SceneGraph,
sourceParentId: string,
destParentId: string
): void {
const sourceParent = graph.nodes.get(sourceParentId)
if (!sourceParent) return
@ -164,7 +168,11 @@ export function createInstance(
return instance
}
export function populateInstanceChildren(graph: SceneGraph, instanceId: string, componentId: string): void {
export function populateInstanceChildren(
graph: SceneGraph,
instanceId: string,
componentId: string
): void {
const instance = graph.nodes.get(instanceId)
const component = graph.nodes.get(componentId)
if (!instance || !component || instance.type !== 'INSTANCE') return

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useAppearance } from '../shared/useAppearance'
import { useAppearance } from '../controls/useAppearance'
const ctx = useAppearance()
</script>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useLayout } from '../shared/useLayout'
import { useLayout } from '../controls/useLayout'
const ctx = useLayout()
</script>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { MIXED, useNodeProps } from '../shared/useNodeProps'
import { MIXED, useNodeProps } from '../controls/useNodeProps'
const {
updateProp,
@ -14,8 +14,12 @@ const {
store
} = useNodeProps()
const xValue = computed(() => (isMulti.value ? multiProp('x').value : Math.round(node.value?.x ?? 0)))
const yValue = computed(() => (isMulti.value ? multiProp('y').value : Math.round(node.value?.y ?? 0)))
const xValue = computed(() =>
isMulti.value ? multiProp('x').value : Math.round(node.value?.x ?? 0)
)
const yValue = computed(() =>
isMulti.value ? multiProp('y').value : Math.round(node.value?.y ?? 0)
)
const wValue = multiProp('width')
const hValue = multiProp('height')
const rotationValue = computed(() =>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useEditor } from '../shared/editorContext'
import { useEditor } from '../context/editorContext'
import { providePropertyList } from './context'
import type { Fill, Stroke, Effect, SceneNode } from '@open-pencil/core'
@ -25,7 +25,9 @@ const emit = defineEmits<{
const editor = useEditor()
const selectedNodes = computed(() => editor.getSelectedNodes())
const activeNode = computed<SceneNode | null>(() => editor.getSelectedNode() ?? selectedNodes.value[0] ?? null)
const activeNode = computed<SceneNode | null>(
() => editor.getSelectedNode() ?? selectedNodes.value[0] ?? null
)
const isMulti = computed(() => selectedNodes.value.length > 1)
const active = computed(() => selectedNodes.value.length > 0)
@ -55,7 +57,11 @@ function add(defaults: ArrayItemType) {
emit('add', defaults)
for (const n of targetNodes()) {
const arr = isMulti.value ? [defaults] : [...n[propKey], defaults]
editor.updateNodeWithUndo(n.id, { [propKey]: arr } as Partial<SceneNode>, isMulti.value ? `Set ${propKey}` : `Add ${propKey}`)
editor.updateNodeWithUndo(
n.id,
{ [propKey]: arr } as Partial<SceneNode>,
isMulti.value ? `Set ${propKey}` : `Add ${propKey}`
)
}
}
@ -64,7 +70,9 @@ function remove(index: number) {
for (const n of targetNodes()) {
editor.updateNodeWithUndo(
n.id,
{ [propKey]: (n[propKey] as ArrayItemType[]).filter((_, i) => i !== index) } as Partial<SceneNode>,
{
[propKey]: (n[propKey] as ArrayItemType[]).filter((_, i) => i !== index)
} as Partial<SceneNode>,
`Remove ${propKey}`
)
}
@ -95,7 +103,11 @@ function toggleVisibility(index: number) {
if (!arr[index]) continue
const newArr = [...n[propKey]] as Array<{ visible: boolean }>
newArr[index] = { ...newArr[index], visible: !arr[index].visible }
editor.updateNodeWithUndo(n.id, { [propKey]: newArr } as Partial<SceneNode>, `Toggle ${propKey} visibility`)
editor.updateNodeWithUndo(
n.id,
{ [propKey]: newArr } as Partial<SceneNode>,
`Toggle ${propKey} visibility`
)
}
}

View file

@ -2,7 +2,7 @@
import { ref, computed } from 'vue'
import { EDITOR_TOOLS } from '@open-pencil/core/editor'
import { useEditor } from '../shared/editorContext'
import { useEditor } from '../context/editorContext'
import { provideToolbar } from './context'
import type { EditorToolDef, Tool } from '@open-pencil/core/editor'

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useTypography } from '../shared/useTypography'
import { useTypography } from '../controls/useTypography'
import type { AcceptableValue } from 'reka-ui'

View file

@ -1,9 +1,9 @@
import { computed } from 'vue'
import { useEditor } from '../shared/editorContext'
import { usePageList } from '../shared/usePageList'
import { useEditor } from '../context/editorContext'
import { usePageList } from '../PageList/usePageList'
import { useSelectionCapabilities } from '../selection/useSelectionCapabilities'
import { useSelectionState } from '../shared/useSelectionState'
import { useSelectionState } from '../selection/useSelectionState'
import type { Component, ComputedRef } from 'vue'
@ -191,7 +191,9 @@ export function useEditorCommands() {
}
}
const otherPages = computed(() => pages.value.filter((page) => page.id !== editor.state.currentPageId))
const otherPages = computed(() =>
pages.value.filter((page) => page.id !== editor.state.currentPageId)
)
function getCommand(id: EditorCommandId) {
return commands[id]

View file

@ -1,8 +1,8 @@
import { computed } from 'vue'
import { useEditor } from '../context/editorContext'
import { useSelectionState } from '../selection/useSelectionState'
import { useEditorCommands } from './useEditorCommands'
import { useSelectionState } from '../shared/useSelectionState'
import { useEditor } from '../shared/editorContext'
export interface MenuActionNode {
separator?: false
@ -85,7 +85,9 @@ export function useMenuModel() {
...(hasSelection.value ? [commandMenuItem('selection.wrapInAutoLayout', '⇧A')] : []),
{ separator: true },
commandMenuItem('selection.createComponent', '⌥⌘K'),
...(canCreateComponentSet.value ? [commandMenuItem('selection.createComponentSet', '⇧⌘K')] : []),
...(canCreateComponentSet.value
? [commandMenuItem('selection.createComponentSet', '⇧⌘K')]
: []),
...(isComponent.value && selectedNode.value
? [
{
@ -108,8 +110,8 @@ export function useMenuModel() {
})
const selectionLabelMenu = computed(() => ({
visibility: editor.getSelectedNode()?.visible ?? true ? 'Hide' : 'Show',
lock: editor.getSelectedNode()?.locked ?? false ? 'Unlock' : 'Lock'
visibility: (editor.getSelectedNode()?.visible ?? true) ? 'Hide' : 'Show',
lock: (editor.getSelectedNode()?.locked ?? false) ? 'Unlock' : 'Lock'
}))
return {

View file

@ -1,7 +1,5 @@
import { computed, type ComputedRef } from 'vue'
import { useEditor } from '../context/editorContext'
/**
* Creates a computed ref that re-evaluates when the scene graph changes.
*
@ -10,10 +8,11 @@ import { useEditor } from '../context/editorContext'
* requestRender(). Vue tracks the read automatically, so the computed
* re-evaluates in the same tick as the change. Zero latency.
*/
export function useSceneComputed<T>(fn: () => T): ComputedRef<T> {
const editor = useEditor()
export function useSceneComputed<T>(fn: () => T, sceneVersion?: () => number): ComputedRef<T> {
return computed(() => {
void editor.state.sceneVersion
if (sceneVersion) {
void sceneVersion()
}
return fn()
})
}

View file

@ -3,11 +3,16 @@ import { onMounted } from 'vue'
import { useHead } from '@unhead/vue'
import { TooltipProvider } from 'reka-ui'
import { provideEditor } from '@open-pencil/vue'
import AppToast from '@/components/AppToast.vue'
import { useEditorStore } from '@/stores/editor'
import { toast } from '@/utils/toast'
useHead({ titleTemplate: (title) => (title ? `${title} — OpenPencil` : 'OpenPencil') })
const store = useEditorStore()
provideEditor(store)
onMounted(() => {
toast.setupGlobalErrorHandler()
})

View file

@ -3,7 +3,7 @@ import { ToastProvider, ToastRoot, ToastDescription, ToastViewport, ToastClose }
import { useClipboard } from '@vueuse/core'
import Tip from '@/components/Tip.vue'
import Tip from '@/components/ui/Tip.vue'
import { toast } from '@/utils/toast'
import { toastRoot } from '@/components/ui/toast'

View file

@ -6,11 +6,13 @@ import { useClipboard } from '@vueuse/core'
import { computed, ref } from 'vue'
import { selectionToJSX } from '@open-pencil/core'
import { useEditor, useSceneComputed } from '@open-pencil/vue'
import { useSceneComputed } from '@open-pencil/vue'
import { useEditorStore } from '@/stores/editor'
import type { JSXFormat } from '@open-pencil/core'
const store = useEditor()
const store = useEditorStore()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
const jsxFormat = ref<JSXFormat>('openpencil')
@ -18,11 +20,14 @@ function toggleFormat() {
jsxFormat.value = jsxFormat.value === 'openpencil' ? 'tailwind' : 'openpencil'
}
const jsxCode = useSceneComputed(() => {
const ids = [...store.state.selectedIds]
if (ids.length === 0) return ''
return selectionToJSX(ids, store.graph, jsxFormat.value)
})
const jsxCode = useSceneComputed(
() => {
const ids = [...store.state.selectedIds]
if (ids.length === 0) return ''
return selectionToJSX(ids, store.graph, jsxFormat.value)
},
() => store.state.sceneVersion
)
const highlightedLines = computed(() => {
if (!jsxCode.value) return []

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import AppSelect from './AppSelect.vue'
import Tip from './Tip.vue'
import AppSelect from './ui/AppSelect.vue'
import Tip from './ui/Tip.vue'
import HsvColorArea from './HsvColorArea.vue'
import ScrubInput from './ScrubInput.vue'
import { colorToCSS } from '@open-pencil/core'

View file

@ -2,8 +2,9 @@
import { computed, shallowRef, watch } from 'vue'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import AppSelect from './AppSelect.vue'
import { useEditor } from '@open-pencil/vue'
import AppSelect from './ui/AppSelect.vue'
import { useEditorStore } from '@/stores/editor'
import type { Fill, ImageScaleMode } from '@open-pencil/core'
@ -17,7 +18,7 @@ const IMAGE_SCALE_MODES: { value: ImageScaleMode; label: string }[] = [
const { fill } = defineProps<{ fill: Fill }>()
const emit = defineEmits<{ update: [fill: Fill] }>()
const store = useEditor()
const store = useEditorStore()
const imageBlob = shallowRef<Blob | null>(null)
const imagePreviewUrl = useObjectUrl(imageBlob)

View file

@ -1,19 +1,14 @@
<script setup lang="ts">
import { TreeRoot, TreeItem, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
import {
LayerTreeRoot,
LayerTreeItem,
useInlineRename,
useLayerDrag,
useEditor
} from '@open-pencil/vue'
import { LayerTreeRoot, LayerTreeItem, useInlineRename, useLayerDrag } from '@open-pencil/vue'
import { useEditorStore } from '@/stores/editor'
import { nodeIcon, COMPONENT_TYPES } from '@/utils/layer-icons'
import CanvasMenu from './CanvasMenu.vue'
import Tip from './Tip.vue'
import Tip from './ui/Tip.vue'
const INDENT = 16
const store = useEditor()
const store = useEditorStore()
const rename = useInlineRename((id, name) => store.renameNode(id, name))
const { draggingId, instruction, instructionTargetId } = useLayerDrag(store, INDENT)

View file

@ -13,6 +13,10 @@ const pageActions = ref<{
renamePage: (pageId: string, name: string) => void
} | null>(null)
function setPageActions(renamePage: (pageId: string, name: string) => void) {
pageActions.value = { renamePage }
}
function setPageInputRef(pageId: string, el: HTMLInputElement | null) {
if (el) pageInputRefs.set(pageId, el)
else pageInputRefs.delete(pageId)
@ -27,6 +31,14 @@ function startRename(pg: { id: string; name: string }) {
rename.start(pg.id, pg.name)
activeRenameId.value = pg.id
}
function handlePageDblClick(
pg: { id: string; name: string },
renamePage: (pageId: string, name: string) => void
) {
setPageActions(renamePage)
startRename(pg)
}
</script>
<template>
@ -62,7 +74,11 @@ function startRename(pg: { id: string; name: string }) {
@keydown="rename.onKeydown"
/>
</div>
<div v-else-if="isDivider(pg)" class="my-1 flex items-center px-2" @dblclick="startRename(pg)">
<div
v-else-if="isDivider(pg)"
class="my-1 flex items-center px-2"
@dblclick="startRename(pg)"
>
<div class="h-px flex-1 bg-border" />
</div>
<button
@ -75,10 +91,7 @@ function startRename(pg: { id: string; name: string }) {
: 'bg-transparent text-muted hover:bg-hover hover:text-surface'
"
@click="switchPage(pg.id)"
@dblclick="
pageActions = { renamePage }
startRename(pg)
"
@dblclick="handlePageDblClick(pg, renamePage)"
>
<icon-lucide-file class="size-3 shrink-0" />
<span class="truncate">{{ pg.name }}</span>

View file

@ -2,13 +2,13 @@
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { useAIChat } from '@/composables/use-chat'
import { useEditor } from '@open-pencil/vue'
import { useEditorStore } from '@/stores/editor'
import ChatPanel from './ChatPanel.vue'
import CodePanel from './CodePanel.vue'
import DesignPanel from './DesignPanel.vue'
const store = useEditor()
const store = useEditorStore()
const { activeTab } = useAIChat()
</script>

View file

@ -2,7 +2,7 @@
import { computed } from 'vue'
import { TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import Tip from '@/components/Tip.vue'
import Tip from '@/components/ui/Tip.vue'
import { useTabsStore, createTab } from '@/stores/tabs'
const { tabs, activeTabId, switchTab, closeTab } = useTabsStore()

View file

@ -3,9 +3,10 @@ import { computed } from 'vue'
import ColorInput from '@/components/ColorInput.vue'
import { sectionWrapper } from '@/components/ui/section'
import { useEditor } from '@open-pencil/vue'
const editor = useEditor()
import { useEditorStore } from '@/stores/editor'
const editor = useEditorStore()
const pageColor = computed(() => editor.state.pageColor)
</script>

View file

@ -1,15 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import Tip from '@/components/Tip.vue'
import Tip from '@/components/ui/Tip.vue'
import { sectionWrapper } from '@/components/ui/section'
import { useEditor, useSceneComputed } from '@open-pencil/vue'
import { useSceneComputed } from '@open-pencil/vue'
import { useEditorStore } from '@/stores/editor'
const emit = defineEmits<{ openDialog: [] }>()
const editor = useEditor()
const collectionCount = useSceneComputed(() => editor.getCollectionCount())
const variableCount = useSceneComputed(() => editor.getVariableCount())
const editor = useEditorStore()
const collectionCount = useSceneComputed(
() => editor.getCollectionCount(),
() => editor.state.sceneVersion
)
const variableCount = useSceneComputed(
() => editor.getVariableCount(),
() => editor.state.sceneVersion
)
const hasVariables = computed(() => variableCount.value > 0)
</script>

View file

@ -1,5 +1,6 @@
import { computed } from 'vue'
import { openFileDialog } from '@/composables/use-menu'
import { useEditorStore } from '@/stores/editor'
import { useEditorCommands, useMenuModel } from '@open-pencil/vue'
@ -20,14 +21,15 @@ export function useAppMenu(mod: string) {
shortcut: `${mod}N`,
action: () => import('@/stores/tabs').then((m) => m.createTab())
},
{ label: 'Open…', shortcut: `${mod}O` },
{ label: 'Open…', shortcut: `${mod}O`, action: () => void openFileDialog() },
{ separator: true as const },
{ label: 'Save', shortcut: `${mod}S` },
{ label: 'Save as…', shortcut: `${mod}⇧S` },
{ label: 'Save', shortcut: `${mod}S`, action: () => void store.saveFigFile() },
{ label: 'Save as…', shortcut: `${mod}⇧S`, action: () => void store.saveFigFileAs() },
{ separator: true as const },
{
label: 'Export selection…',
shortcut: `${mod}⇧E`,
action: () => void store.exportSelection(1, 'PNG'),
disabled: store.state.selectedIds.size === 0
},
{ separator: true as const },

View file

@ -1,7 +1,7 @@
import { useFileDialog } from '@vueuse/core'
import { onUnmounted } from 'vue'
import { IS_TAURI } from '@/constants'
import { IS_BROWSER, IS_TAURI } from '@/constants'
import { useEditorStore } from '@/stores/editor'
import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs'
@ -11,6 +11,18 @@ fileDialog.onChange((files) => {
if (file) void openFileInNewTab(file)
})
if (IS_BROWSER) {
;(
window as Window & { __OPEN_PENCIL_OPEN_FILE__?: (path: string) => Promise<void> }
).__OPEN_PENCIL_OPEN_FILE__ = async (path: string) => {
const response = await fetch(path)
const blob = await response.blob()
const name = path.split('/').pop() ?? 'file.fig'
const file = new File([blob], name, { type: 'application/octet-stream' })
await openFileInNewTab(file, undefined, path)
}
}
export async function openFileDialog() {
if (IS_TAURI) {
const { open } = await import('@tauri-apps/plugin-dialog')

View file

@ -1,11 +1,10 @@
import { useDebounceFn } from '@vueuse/core'
import { shallowReactive, shallowRef, computed, watch } from 'vue'
import { shallowReactive, shallowRef, computed, watch, triggerRef } from 'vue'
import { IS_TAURI, CANVAS_BG_COLOR } from '@/constants'
import { IS_TAURI } from '@/constants'
import { loadFont } from '@/engine/fonts'
import { toast } from '@/utils/toast'
import {
computeAllLayouts,
createDefaultEditorState,
createEditor,
exportFigFile,
@ -21,8 +20,8 @@ export type { Tool } from '@open-pencil/core'
export type { EditorToolDef as ToolDef } from '@open-pencil/core'
export { EDITOR_TOOLS as TOOLS, TOOL_SHORTCUTS } from '@open-pencil/core'
export function createEditorStore() {
const graph = new SceneGraph()
export function createEditorStore(initialGraph?: SceneGraph) {
const graph = initialGraph ?? new SceneGraph()
const state = shallowReactive<
EditorState & {
@ -49,7 +48,11 @@ export function createEditorStore() {
cursorCanvasY: null
})
const editor = createEditor({ graph, state, loadFont })
const editor = createEditor({ graph, state, loadFont, skipInitialGraphSetup: !!initialGraph })
if (initialGraph) {
editor.subscribeToGraph()
}
// ─── Vue computed refs ────────────────────────────────────────
@ -167,7 +170,6 @@ export function createEditorStore() {
const imported = await readFigFile(file)
await yieldToUI()
editor.replaceGraph(imported)
computeAllLayouts(editor.graph)
editor.undo.clear()
fileHandle = handle ?? null
filePath = path ?? null
@ -176,12 +178,7 @@ export function createEditorStore() {
state.selectedIds = new Set()
const firstPage = editor.graph.getPages()[0] as SceneNode | undefined
const pageId = firstPage?.id ?? editor.graph.rootId
state.currentPageId = pageId
state.panX = 0
state.panY = 0
state.zoom = 1
state.pageColor = { ...CANVAS_BG_COLOR }
await editor.loadFontsForNodes(editor.graph.getChildren(pageId).map((n) => n.id))
await editor.switchPage(pageId)
editor.requestRender()
void startWatchingFile()
} catch (e) {
@ -286,12 +283,10 @@ export function createEditorStore() {
const file = new File([blob], state.documentName + '.fig')
const imported = await readFigFile(file)
editor.replaceGraph(imported)
computeAllLayouts(editor.graph)
} else if (fileHandle) {
const file = await fileHandle.getFile()
const imported = await readFigFile(file)
editor.replaceGraph(imported)
computeAllLayouts(editor.graph)
} else {
return
}
@ -500,7 +495,7 @@ export function createEditorStore() {
// ─── Public API ───────────────────────────────────────────────
// Spread all core Editor methods, then override getters and add app-specific.
return {
const store = {
...editor,
state,
selectedNodes,
@ -523,6 +518,23 @@ export function createEditorStore() {
mobileCut,
mobilePaste
}
Object.defineProperties(store, {
graph: {
enumerable: true,
get: () => editor.graph
},
renderer: {
enumerable: true,
get: () => editor.renderer
},
textEditor: {
enumerable: true,
get: () => editor.textEditor
}
})
return store
}
export type EditorStore = ReturnType<typeof createEditorStore>
@ -531,6 +543,7 @@ const storeRef = shallowRef<EditorStore>()
export function setActiveEditorStore(store: EditorStore) {
storeRef.value = store
triggerRef(storeRef)
}
export function getActiveEditorStore(): EditorStore {

View file

@ -1,8 +1,9 @@
import { shallowRef, computed } from 'vue'
import { shallowRef, computed, triggerRef } from 'vue'
import { createEditorStore, setActiveEditorStore } from './editor'
import type { EditorStore } from './editor'
import type { SceneGraph } from '@open-pencil/core'
export interface Tab {
id: string
@ -34,8 +35,8 @@ export function getActiveStore(): EditorStore {
return tab.store
}
export function createTab(store?: EditorStore): Tab {
const s = store ?? createEditorStore()
export function createTab(store?: EditorStore, initialGraph?: SceneGraph): Tab {
const s = store ?? createEditorStore(initialGraph)
const tab: Tab = { id: generateTabId(), store: s }
tabsRef.value = [...tabsRef.value, tab]
activateTab(tab)
@ -45,6 +46,7 @@ export function createTab(store?: EditorStore): Tab {
function activateTab(tab: Tab) {
activeTabId.value = tab.id
setActiveEditorStore(tab.store)
triggerRef(tabsRef)
window.__OPEN_PENCIL_STORE__ = tab.store
}
@ -74,19 +76,32 @@ export function closeTab(tabId: string) {
export async function openFileInNewTab(
file: File,
handle?: FileSystemFileHandle,
path?: string
_handle?: FileSystemFileHandle,
_path?: string
): Promise<void> {
const current = activeTab.value
const isUntouched =
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
if (isUntouched) {
await current.store.openFigFile(file, handle, path)
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.selectedIds = new Set()
const pageId = current.store.graph.getPages()[0]?.id ?? current.store.graph.rootId
await current.store.switchPage(pageId)
} else {
const store = createEditorStore()
const { readFigFile } = await import('@open-pencil/core')
const imported = await readFigFile(file)
const store = createEditorStore(imported)
createTab(store)
await store.openFigFile(file, handle, path)
store.undo.clear()
store.state.documentName = file.name.replace(/\.fig$/i, '')
store.state.selectedIds = new Set()
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
await store.switchPage(pageId)
}
}

View file

@ -5,7 +5,7 @@ import { useRoute } from 'vue-router'
import { useHead } from '@unhead/vue'
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
import { provideEditor, useViewportKind } from '@open-pencil/vue'
import { useViewportKind } from '@open-pencil/vue'
import { useKeyboard } from '@/composables/use-keyboard'
import { useMenu } from '@/composables/use-menu'
import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
@ -32,7 +32,6 @@ const showChrome = !('no-chrome' in params)
const firstTab = createTab()
const store = useEditorStore()
provideEditor(store)
const { isMobile } = useViewportKind()
if (route.meta.demo && !('test' in params)) {

View file

@ -1,6 +1,8 @@
import { describe, test, expect } from 'bun:test'
import { SceneGraph, type SceneNode, type GridTrack, computeLayout, computeAllLayouts, setTextMeasurer, FigmaAPI } from '@open-pencil/core'
import { SceneGraph, type SceneNode, type GridTrack, computeLayout, computeAllLayouts, setTextMeasurer, FigmaAPI, readFigFile } from '@open-pencil/core'
import { createEditorStore } from '@/stores/editor'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
@ -36,6 +38,13 @@ function rect(
})
}
async function loadFixtureGraph(name: string) {
const path = new URL(`../fixtures/${name}`, import.meta.url)
const buffer = await Bun.file(path).arrayBuffer()
const file = new File([buffer], name, { type: 'application/octet-stream' })
return readFigFile(file)
}
describe('Auto Layout', () => {
describe('horizontal basic', () => {
test('positions children left-to-right', () => {
@ -970,6 +979,75 @@ describe('Auto Layout', () => {
})
describe('text measurement', () => {
test('opening imported fig keeps stored text bounds before CanvasKit measurement', async () => {
const graph = await loadFixtureGraph('gold-preview.fig')
const store = createEditorStore(graph)
const title = [...store.graph.getAllNodes()].find(
(node) => node.type === 'TEXT' && node.text === "World's largest"
)
const subtitle = [...store.graph.getAllNodes()].find(
(node) =>
node.type === 'TEXT' && node.text === 'Preline UI Figma - crafted with Tailwind CSS styles'
)
const description = [...store.graph.getAllNodes()].find(
(node) =>
node.type === 'TEXT' &&
node.text.startsWith('Preline UI Figma is the largest free design system for Figma')
)
if (!title || !subtitle || !description) {
throw new Error('Expected imported text nodes in gold-preview.fig')
}
expect(title.width).toBe(444)
expect(title.height).toBe(73)
expect(subtitle.width).toBe(439)
expect(subtitle.height).toBe(22)
expect(description.width).toBe(878)
expect(description.height).toBe(60)
await store.switchPage(store.graph.getPages()[0].id)
expect(store.graph.getNode(title.id)?.width).toBe(444)
expect(store.graph.getNode(title.id)?.height).toBe(73)
expect(store.graph.getNode(subtitle.id)?.width).toBe(439)
expect(store.graph.getNode(subtitle.id)?.height).toBe(22)
expect(store.graph.getNode(description.id)?.width).toBe(878)
expect(store.graph.getNode(description.id)?.height).toBe(60)
})
test('imported nested instance layout recomputes hidden sibling offsets', async () => {
const graph = await loadFixtureGraph('gold-preview.fig')
const previewRoot = graph.getChildren(graph.getPages()[0].id)[0]
const wysiwygEditor = graph.getChildren(previewRoot.id).find((node) => node.name === '_WYSIWYG-editor')
const toolbarVariant = wysiwygEditor
? graph.getChildren(wysiwygEditor.id).find((node) => node.name === '_on-text-WYSIWYG-toolbar')
: undefined
const toolbarRow = toolbarVariant
? graph.getChildren(toolbarVariant.id).find((node) => node.name === 'Toolbar')
: undefined
const hiddenInput = toolbarRow
? graph.getChildren(toolbarRow.id).find((node) => node.name === 'Input')
: undefined
const visibleToolbar = toolbarRow
? graph.getChildren(toolbarRow.id).find(
(node) => node.name === 'Toolbar' && node.id !== toolbarRow.id
)
: undefined
if (!toolbarRow || !hiddenInput || !visibleToolbar) {
throw new Error('Expected imported WYSIWYG toolbar nodes in gold-preview.fig')
}
expect(hiddenInput.visible).toBe(false)
expect(visibleToolbar.x).toBe(298)
computeAllLayouts(graph, graph.getPages()[0].id)
expect(graph.getNode(visibleToolbar.id)?.x).toBe(8)
expect(graph.getNode(visibleToolbar.id)?.y).toBe(8)
})
test('WIDTH_AND_HEIGHT text uses measured width in centered layout', () => {
const graph = new SceneGraph()
const pid = pageId(graph)