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 ### 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 `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 save crash when COLOR variable is missing alpha channel
- Fix console error spam on deployed web app from automation WebSocket reconnect loop - 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 { 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 { SceneGraph } from '../scene-graph'
import { TextEditor } from '../text-editor' import { TextEditor } from '../text-editor'
import { UndoManager } from '../undo' import { UndoManager } from '../undo'
import { createAlignmentActions } from './alignment' import { createAlignmentActions } from './alignment'
import { createClipboardActions } from './clipboard' import { createClipboardActions } from './clipboard'
import { createComponentActions } from './components' import { createComponentActions } from './components'
@ -19,10 +18,10 @@ import { createUndoActions } from './undo'
import { createVariableActions } from './variables' import { createVariableActions } from './variables'
import { createViewportActions } from './viewport' import { createViewportActions } from './viewport'
import type { SceneNode } from '../scene-graph'
import type { SkiaRenderer } from '../renderer/renderer' 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 { EditorContext, EditorOptions, EditorState } from './types'
import type { CanvasKit } from 'canvaskit-wasm'
export function createDefaultEditorState(pageId: string): EditorState { export function createDefaultEditorState(pageId: string): EditorState {
return { return {
@ -54,12 +53,15 @@ export function createDefaultEditorState(pageId: string): EditorState {
export function createEditor(options?: EditorOptions) { export function createEditor(options?: EditorOptions) {
let _graph = options?.graph ?? new SceneGraph() let _graph = options?.graph ?? new SceneGraph()
const skipInitialGraphSetup = options?.skipInitialGraphSetup ?? false
const undo = new UndoManager() const undo = new UndoManager()
const _loadFont = options?.loadFont ?? defaultLoadFont const _loadFont = options?.loadFont ?? defaultLoadFont
const _getViewportSize = options?.getViewportSize ?? (() => { const _getViewportSize =
if (IS_BROWSER) return { width: window.innerWidth, height: window.innerHeight } options?.getViewportSize ??
return { width: 800, height: 600 } (() => {
}) if (IS_BROWSER) return { width: window.innerWidth, height: window.innerHeight }
return { width: 800, height: 600 }
})
let _ck: CanvasKit | null = null let _ck: CanvasKit | null = null
let _renderer: SkiaRenderer | null = null let _renderer: SkiaRenderer | null = null
let _textEditor: TextEditor | null = null let _textEditor: TextEditor | null = null
@ -153,12 +155,18 @@ export function createEditor(options?: EditorOptions) {
] ]
} }
subscribeToGraph() if (!skipInitialGraphSetup) {
subscribeToGraph()
}
// Build the shared context // Build the shared context
const ctx: EditorContext = { const ctx: EditorContext = {
get graph() { return _graph }, get graph() {
set graph(g) { _graph = g }, return _graph
},
set graph(g) {
_graph = g
},
undo, undo,
state, state,
loadFont: _loadFont, loadFont: _loadFont,
@ -204,9 +212,15 @@ export function createEditor(options?: EditorOptions) {
} }
return { return {
get graph() { return _graph }, get graph() {
get renderer() { return _renderer }, return _graph
get textEditor() { return _textEditor }, },
get renderer() {
return _renderer
},
get textEditor() {
return _textEditor
},
undo, undo,
state, state,
@ -221,6 +235,7 @@ export function createEditor(options?: EditorOptions) {
requestRepaint, requestRepaint,
setCanvasKit, setCanvasKit,
replaceGraph, replaceGraph,
subscribeToGraph,
// Selection // Selection
...selection, ...selection,
@ -263,7 +278,8 @@ export function createEditor(options?: EditorOptions) {
// Clipboard — bridge functions that need selectedNodes // Clipboard — bridge functions that need selectedNodes
duplicateSelected: () => clipboard.duplicateSelected(selection.getSelectedNodes()), duplicateSelected: () => clipboard.duplicateSelected(selection.getSelectedNodes()),
writeCopyData: (data: DataTransfer) => clipboard.writeCopyData(data, selection.getSelectedNodes()), writeCopyData: (data: DataTransfer) =>
clipboard.writeCopyData(data, selection.getSelectedNodes()),
pasteFromHTML: clipboard.pasteFromHTML, pasteFromHTML: clipboard.pasteFromHTML,
deleteSelected: clipboard.deleteSelected, deleteSelected: clipboard.deleteSelected,
storeImage: clipboard.storeImage, storeImage: clipboard.storeImage,

View file

@ -1,5 +1,6 @@
import { CANVAS_BG_COLOR } from '../constants' import { CANVAS_BG_COLOR } from '../constants'
import { collectFontKeys } from '../fonts' import { collectFontKeys } from '../fonts'
import { computeAllLayouts } from '../layout'
import type { Color } from '../types' import type { Color } from '../types'
import type { EditorContext } from './types' import type { EditorContext } from './types'
@ -42,10 +43,16 @@ export function createPageActions(ctx: EditorContext) {
ctx.state.pageColor = { ...CANVAS_BG_COLOR } 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) { if (toLoad.length > 0) {
await Promise.all(toLoad.map(([family, style]) => ctx.loadFont(family, style))) await Promise.all(toLoad.map(([family, style]) => ctx.loadFont(family, style)))
} }
if (ctx.getRenderer()) {
computeAllLayouts(ctx.graph, pageId)
}
ctx.requestRender() 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 { SceneGraph, VectorSegment, VectorVertex } from '../scene-graph'
import type { SnapGuide } from '../snap' import type { SnapGuide } from '../snap'
import type { SkiaRenderer } from '../renderer/renderer'
import type { UndoManager } from '../undo'
import type { TextEditor } from '../text-editor' import type { TextEditor } from '../text-editor'
import type { Color, Rect, Vector } from '../types'
import type { UndoManager } from '../undo'
import type { CanvasKit } from 'canvaskit-wasm' import type { CanvasKit } from 'canvaskit-wasm'
export type Tool = export type Tool =
@ -101,6 +101,7 @@ export interface EditorOptions {
state?: EditorState state?: EditorState
loadFont?: (family: string, style: string) => Promise<ArrayBuffer | null> loadFont?: (family: string, style: string) => Promise<ArrayBuffer | null>
getViewportSize?: () => { width: number; height: number } getViewportSize?: () => { width: number; height: number }
skipInitialGraphSetup?: boolean
} }
export interface EditorContext { export interface EditorContext {

View file

@ -1,6 +1,5 @@
import type { VariableType, VariableValue } from '../scene-graph'
import { SceneGraph } from '../scene-graph' import { SceneGraph } from '../scene-graph'
import { populateAndApplyOverrides } from './instance-overrides'
import { import {
guidToString, guidToString,
nodeChangeToProps, nodeChangeToProps,
@ -8,12 +7,14 @@ import {
setVariableColorResolver, setVariableColorResolver,
VARIABLE_BINDING_FIELDS_INVERSE VARIABLE_BINDING_FIELDS_INVERSE
} from './kiwi-convert' } 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 { InstanceNodeChange } from './instance-overrides'
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from './codec' function buildVariableColorResolver(
changeMap: Map<string, NodeChange>
function buildVariableColorResolver(changeMap: Map<string, NodeChange>): (guid: GUID) => Color | null { ): (guid: GUID) => Color | null {
// Collect variable data: GUID → entries // Collect variable data: GUID → entries
const varEntries = new Map<string, VariableDataValuesEntry[]>() const varEntries = new Map<string, VariableDataValuesEntry[]>()
const varSetId = new Map<string, string>() const varSetId = new Map<string, string>()
@ -44,7 +45,7 @@ function buildVariableColorResolver(changeMap: Map<string, NodeChange>): (guid:
const setId = varSetId.get(id) const setId = varSetId.get(id)
const defaultMode = setId ? defaultModes.get(setId) : undefined const defaultMode = setId ? defaultModes.get(setId) : undefined
let entry = defaultMode let entry = defaultMode
? entries.find(e => guidToString(e.modeID) === defaultMode) ? entries.find((e) => guidToString(e.modeID) === defaultMode)
: undefined : undefined
if (!entry) entry = entries[0] if (!entry) entry = entries[0]
@ -124,10 +125,7 @@ function resolveDefaultValue(type: VariableType): VariableValue {
return 0 return 0
} }
function importCollections( function importCollections(changeMap: Map<string, NodeChange>, graph: SceneGraph): void {
changeMap: Map<string, NodeChange>,
graph: SceneGraph
): void {
for (const [id, nc] of changeMap) { for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE_SET') continue if (nc.type !== 'VARIABLE_SET') continue
@ -155,7 +153,9 @@ function importVariableEntries(
for (const [id, nc] of changeMap) { for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE') continue 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)) { if (!graph.variableCollections.has(collectionId)) {
const parentNc = changeMap.get(collectionId) const parentNc = changeMap.get(collectionId)
@ -204,6 +204,7 @@ function importPages(
parentMap: Map<string, string>, parentMap: Map<string, string>,
childrenMap: Map<string, string[]>, childrenMap: Map<string, string[]>,
created: Set<string>, created: Set<string>,
canvasIdToPageId: Map<string, string>,
createSceneNode: (ncId: string, graphParentId: string) => void createSceneNode: (ncId: string, graphParentId: string) => void
): void { ): void {
let docId: string | null = null let docId: string | null = null
@ -220,6 +221,7 @@ function importPages(
if (!canvasNc) continue if (!canvasNc) continue
if (canvasNc.type === 'CANVAS') { if (canvasNc.type === 'CANVAS') {
const page = graph.addPage(canvasNc.name ?? 'Page') const page = graph.addPage(canvasNc.name ?? 'Page')
canvasIdToPageId.set(canvasId, page.id)
if (canvasNc.internalOnly) page.internalOnly = true if (canvasNc.internalOnly) page.internalOnly = true
created.add(canvasId) created.add(canvasId)
for (const childId of childrenMap.get(canvasId) ?? []) { for (const childId of childrenMap.get(canvasId) ?? []) {
@ -260,10 +262,7 @@ function importVariableBindings(
} }
} }
function remapComponentIds( function remapComponentIds(graph: SceneGraph, guidToNodeId: Map<string, string>): void {
graph: SceneGraph,
guidToNodeId: Map<string, string>
): void {
for (const node of graph.getAllNodes()) { for (const node of graph.getAllNodes()) {
if (node.type !== 'INSTANCE' || !node.componentId) continue if (node.type !== 'INSTANCE' || !node.componentId) continue
const remapped = guidToNodeId.get(node.componentId) const remapped = guidToNodeId.get(node.componentId)
@ -290,6 +289,7 @@ export function importNodeChanges(
const { changeMap, parentMap, childrenMap } = buildChangeMaps(nodeChanges) const { changeMap, parentMap, childrenMap } = buildChangeMaps(nodeChanges)
const canvasIdToPageId = new Map<string, string>()
const created = new Set<string>() const created = new Set<string>()
const guidToNodeId = new Map<string, string>() const guidToNodeId = new Map<string, string>()
const getChildren = (ncId: string): string[] => childrenMap.get(ncId) ?? [] const getChildren = (ncId: string): string[] => childrenMap.get(ncId) ?? []
@ -304,7 +304,8 @@ export function importNodeChanges(
const { nodeType, ...props } = nodeChangeToProps(nc, blobs) const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE' || nc.type === 'VARIABLE_SET') return 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) guidToNodeId.set(ncId, node.id)
for (const childId of getChildren(ncId)) { 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) importCollections(changeMap, graph)
importVariableEntries(changeMap, parentMap, 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 { copyFills, copyStrokes, copyEffects, copyStyleRuns } from '../../copy'
import type { SceneGraph, SceneNode } from '../../scene-graph'
/** /**
* Copy appearance props from source to target (text, visibility, fills, etc.). * Copy appearance props from source to target (text, visibility, fills, etc.).
* Only writes properties that actually differ. * 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.effects !== target.effects) updates.effects = copyEffects(source.effects)
if (source.styleRuns !== target.styleRuns) updates.styleRuns = copyStyleRuns(source.styleRuns) if (source.styleRuns !== target.styleRuns) updates.styleRuns = copyStyleRuns(source.styleRuns)
if (source.layoutGrow !== target.layoutGrow) updates.layoutGrow = source.layoutGrow 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 (source.locked !== target.locked) updates.locked = source.locked
if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates) 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]) const tgtNode = graph.getNode(tgt.childIds[i])
if (!srcNode || !tgtNode || srcNode.type !== tgtNode.type) continue 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) recloneChildren(graph, src.childIds[i], tgtNode, swappedInstances)
continue 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. */ /** 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 needsSync = new Set<string>()
const queue = [...expandedSeeds] const queue = [...expandedSeeds]
for (let id = queue.pop(); id !== undefined; id = queue.pop()) { for (let id = queue.pop(); id !== undefined; id = queue.pop()) {
@ -142,9 +151,7 @@ export function propagateOverridesTransitively(
const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf) const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf)
// Merge seeds + protect into a single skip set for syncChildrenDeep // Merge seeds + protect into a single skip set for syncChildrenDeep
const skip = protect && protect.size > 0 const skip = protect && protect.size > 0 ? new Set([...seeds, ...protect]) : seeds
? new Set([...seeds, ...protect])
: seeds
const visited = new Set<string>() const visited = new Set<string>()
const syncQueue = [...expandedSeeds] const syncQueue = [...expandedSeeds]

View file

@ -28,11 +28,86 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
IS_BROWSER IS_BROWSER
} from '../constants' } from '../constants'
import { computeAbsoluteBounds } from '../geometry'
import { RenderProfiler } from '../profiler' 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 { EditorState } from '../editor/types'
import type { SceneNode, SceneGraph, Fill, Stroke } from '../scene-graph'
import type { SnapGuide } from '../snap' import type { SnapGuide } from '../snap'
import type { TextEditor } from '../text-editor' import type { TextEditor } from '../text-editor'
import type { Color, Rect, Vector } from '../types' import type { Color, Rect, Vector } from '../types'
@ -52,82 +127,6 @@ import type {
Paragraph Paragraph
} from 'canvaskit-wasm' } 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 { export interface RenderOverlays {
hoveredNodeId?: string | null hoveredNodeId?: string | null
enteredContainerId?: string | null enteredContainerId?: string | null
@ -501,7 +500,9 @@ export class SkiaRenderer {
} }
get hasActiveFlashes(): boolean { 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 { hitTestSectionTitle(graph: SceneGraph, canvasX: number, canvasY: number): SceneNode | null {
@ -673,7 +674,14 @@ export class SkiaRenderer {
this.worldViewport = prevViewport 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.dpr = IS_BROWSER ? window.devicePixelRatio || 1 : 1
this.panX = state.panX this.panX = state.panX
this.panY = state.panY this.panY = state.panY
@ -683,21 +691,30 @@ export class SkiaRenderer {
this.showRulers = showRulers this.showRulers = showRulers
this.pageColor = state.pageColor this.pageColor = state.pageColor
this.pageId = state.currentPageId this.pageId = state.currentPageId
this.render(graph, state.selectedIds, { this.render(
hoveredNodeId: state.hoveredNodeId, graph,
enteredContainerId: state.enteredContainerId, state.selectedIds,
editingTextId: state.editingTextId, {
textEditor: textEditor as RenderOverlays['textEditor'], hoveredNodeId: state.hoveredNodeId,
marquee: state.marquee, enteredContainerId: state.enteredContainerId,
snapGuides: state.snapGuides, editingTextId: state.editingTextId,
rotationPreview: state.rotationPreview, textEditor: textEditor as RenderOverlays['textEditor'],
dropTargetId: state.dropTargetId, marquee: state.marquee,
layoutInsertIndicator: state.layoutInsertIndicator, snapGuides: state.snapGuides,
penState: state.penState rotationPreview: state.rotationPreview,
? { ...state.penState, cursorX: state.penCursorX ?? undefined, cursorY: state.penCursorY ?? undefined } as RenderOverlays['penState'] dropTargetId: state.dropTargetId,
: null, layoutInsertIndicator: state.layoutInsertIndicator,
remoteCursors: state.remoteCursors penState: state.penState
}, state.sceneVersion) ? ({
...state.penState,
cursorX: state.penCursorX ?? undefined,
cursorY: state.penCursorY ?? undefined
} as RenderOverlays['penState'])
: null,
remoteCursors: state.remoteCursors
},
state.sceneVersion
)
} }
render( render(
@ -812,9 +829,24 @@ export class SkiaRenderer {
const prevViewport = this.worldViewport const prevViewport = this.worldViewport
this.worldViewport = { x: -1e6, y: -1e6, w: 2e6, h: 2e6 } this.worldViewport = { x: -1e6, y: -1e6, w: 2e6, h: 2e6 }
const recorder = new this.ck.PictureRecorder() 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 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) { if (pageNode) {
for (const childId of pageNode.childIds) { for (const childId of pageNode.childIds) {
this.renderNode(recCanvas, graph, childId, {}, 0, 0) this.renderNode(recCanvas, graph, childId, {}, 0, 0)
@ -944,15 +976,28 @@ export class SkiaRenderer {
// --- Delegation methods --- // --- 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) 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) 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) drawSelectionFn(this, canvas, graph, selectedIds, overlays)
} }
@ -997,7 +1042,10 @@ export class SkiaRenderer {
drawAiOverlaysFn(this, canvas, graph) drawAiOverlaysFn(this, canvas, graph)
} }
private drawLayoutInsertIndicator(canvas: Canvas, indicator?: RenderOverlays['layoutInsertIndicator']): void { private drawLayoutInsertIndicator(
canvas: Canvas,
indicator?: RenderOverlays['layoutInsertIndicator']
): void {
drawLayoutInsertIndicatorFn(this, canvas, indicator) drawLayoutInsertIndicatorFn(this, canvas, indicator)
} }
@ -1009,7 +1057,11 @@ export class SkiaRenderer {
drawPenOverlayFn(this, canvas, penState) 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) drawRemoteCursorsFn(this, canvas, graph, cursors)
} }
@ -1025,7 +1077,14 @@ export class SkiaRenderer {
drawComponentLabelsFn(this, canvas, graph) 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) renderNodeFn(this, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY)
} }
@ -1045,7 +1104,14 @@ export class SkiaRenderer {
renderShapeUncachedFn(this, canvas, node, graph) 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) renderEffectsFn(this, canvas, node, rect, hasRadius, pass, shadowShapeChild)
} }
@ -1077,15 +1143,30 @@ export class SkiaRenderer {
drawNodeStrokeFn(this, canvas, node, rect, hasRadius) 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) 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) 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) drawIndividualSideStrokesFn(this, canvas, node, align)
} }
@ -1145,7 +1226,13 @@ export class SkiaRenderer {
return getCachedMaskBlurFn(this, sigma) 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) 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 { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '../constants'
import type { SceneNode, SceneGraph } from '../scene-graph' import type { SceneNode, SceneGraph } from '../scene-graph'
import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm'
import type { Color } from '../types' import type { Color } from '../types'
import type { SkiaRenderer, RenderOverlays } from './renderer' import type { SkiaRenderer, RenderOverlays } from './renderer'
import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm'
function isCulled( function isCulled(r: SkiaRenderer, node: SceneNode, absX: number, absY: number): boolean {
r: SkiaRenderer,
node: SceneNode,
absX: number,
absY: number
): boolean {
const canCull = const canCull =
node.childIds.length === 0 || node.childIds.length === 0 ||
((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') && ((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') &&
@ -47,10 +43,7 @@ function applyNodeTransforms(
} }
if (node.flipX || node.flipY) { if (node.flipX || node.flipY) {
canvas.translate( canvas.translate(node.flipX ? node.width : 0, node.flipY ? node.height : 0)
node.flipX ? node.width : 0,
node.flipY ? node.height : 0
)
canvas.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1) 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' node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE'
if (isClippableContainer && node.clipsContent && node.childIds.length > 0) { if (isClippableContainer && node.clipsContent && node.childIds.length > 0) {
canvas.save() canvas.save()
const hasRadius = node.cornerRadius > 0 || (node.independentCorners && const hasRadius =
(node.topLeftRadius > 0 || node.topRightRadius > 0 || node.bottomRightRadius > 0 || node.bottomLeftRadius > 0)) node.cornerRadius > 0 ||
(node.independentCorners &&
(node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomRightRadius > 0 ||
node.bottomLeftRadius > 0))
if (hasRadius) { if (hasRadius) {
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true) canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
} else { } else {
@ -219,10 +217,7 @@ export function renderComponentSet(
r.auxStroke.setStrokeWidth(r.COMPONENT_SET_BORDER_WIDTH / r.zoom) r.auxStroke.setStrokeWidth(r.COMPONENT_SET_BORDER_WIDTH / r.zoom)
r.auxStroke.setColor(r.compColor()) r.auxStroke.setColor(r.compColor())
r.auxStroke.setPathEffect( r.auxStroke.setPathEffect(
r.ck.PathEffect.MakeDash( r.ck.PathEffect.MakeDash([r.COMPONENT_SET_DASH / r.zoom, r.COMPONENT_SET_DASH_GAP / r.zoom], 0)
[r.COMPONENT_SET_DASH / r.zoom, r.COMPONENT_SET_DASH_GAP / r.zoom],
0
)
) )
canvas.drawRRect(rrect, r.auxStroke) canvas.drawRRect(rrect, r.auxStroke)
r.auxStroke.setPathEffect(null) r.auxStroke.setPathEffect(null)
@ -283,17 +278,23 @@ function getShadowShapeChild(node: SceneNode, graph: SceneGraph): SceneNode | nu
function getCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity { function getCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity {
switch (cap) { switch (cap) {
case 'ROUND': return r.ck.StrokeCap.Round case 'ROUND':
case 'SQUARE': return r.ck.StrokeCap.Square return r.ck.StrokeCap.Round
default: return r.ck.StrokeCap.Butt case 'SQUARE':
return r.ck.StrokeCap.Square
default:
return r.ck.StrokeCap.Butt
} }
} }
function getJoinEntity(r: SkiaRenderer, join: string | undefined): EmbindEnumEntity { function getJoinEntity(r: SkiaRenderer, join: string | undefined): EmbindEnumEntity {
switch (join) { switch (join) {
case 'ROUND': return r.ck.StrokeJoin.Round case 'ROUND':
case 'BEVEL': return r.ck.StrokeJoin.Bevel return r.ck.StrokeJoin.Round
default: return r.ck.StrokeJoin.Miter case 'BEVEL':
return r.ck.StrokeJoin.Bevel
default:
return r.ck.StrokeJoin.Miter
} }
} }
@ -530,7 +531,9 @@ export function renderEffects(
innerPath.delete() innerPath.delete()
} else if (hasRadius) { } else if (hasRadius) {
const innerPath = new r.ck.Path() 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) bigPath.op(innerPath, r.ck.PathOp.Difference)
innerPath.delete() innerPath.delete()
} else { } else {
@ -558,22 +561,25 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): vo
const text = node.text const text = node.text
if (!text) return if (!text) return
canvas.save()
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
if (node.textPicture) { if (node.textPicture) {
const pic = r.ck.MakePicture(node.textPicture) const pic = r.ck.MakePicture(node.textPicture)
if (pic) { if (pic) {
canvas.drawPicture(pic) canvas.drawPicture(pic)
pic.delete() pic.delete()
canvas.restore()
return return
} }
} }
if (r.fontsLoaded && r.fontProvider) { 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) canvas.drawParagraph(paragraph, 0, 0)
paragraph.delete() paragraph.delete()
} else if (r.textFont) { } 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.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, node: SceneNode,
maxWidth?: number maxWidth?: number
): { width: number; height: number } | null { ): { 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 if (node.type !== 'TEXT' || !node.text) return null
const paragraph = buildParagraph(r, node) const paragraph = buildParagraph(r, node)
let layoutWidth = node.width || 1e6 paragraph.layout(resolveParagraphLayoutWidth(node, maxWidth))
if (maxWidth !== undefined) layoutWidth = maxWidth
else if (node.textAutoResize === 'WIDTH_AND_HEIGHT') layoutWidth = 1e6
paragraph.layout(layoutWidth)
const width = paragraph.getLongestLine() const width = paragraph.getLongestLine()
const height = paragraph.getHeight() const height = paragraph.getHeight()
paragraph.delete() paragraph.delete()
@ -39,7 +36,7 @@ export function measureTextNode(
} }
export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array | null { 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 if (node.type !== 'TEXT' || !node.text) return null
const ck = r.ck 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 bounds = ck.LTRBRect(0, 0, node.width || 1e6, node.height || 1e6)
const recCanvas = recorder.beginRecording(bounds) const recCanvas = recorder.beginRecording(bounds)
const paragraph = buildParagraph(r, node, undefined, { halfLeading: true }) const paragraph = buildParagraph(r, node)
recCanvas.drawParagraph(paragraph, 0, 0) recCanvas.drawParagraph(paragraph, 0, 0)
paragraph.delete() paragraph.delete()
@ -59,6 +56,12 @@ export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array |
return bytes ?? null 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( function buildTruncateOpts(
node: SceneNode, node: SceneNode,
baseFontSize: number baseFontSize: number
@ -202,7 +205,12 @@ export function buildParagraph(
} }
const paragraph = builder.build() 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() builder.delete()
return paragraph 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) const sourceParent = graph.nodes.get(sourceParentId)
if (!sourceParent) return if (!sourceParent) return
@ -164,7 +168,11 @@ export function createInstance(
return instance 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 instance = graph.nodes.get(instanceId)
const component = graph.nodes.get(componentId) const component = graph.nodes.get(componentId)
if (!instance || !component || instance.type !== 'INSTANCE') return if (!instance || !component || instance.type !== 'INSTANCE') return

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useEditor } from '../shared/editorContext' import { useEditor } from '../context/editorContext'
import { providePropertyList } from './context' import { providePropertyList } from './context'
import type { Fill, Stroke, Effect, SceneNode } from '@open-pencil/core' import type { Fill, Stroke, Effect, SceneNode } from '@open-pencil/core'
@ -25,7 +25,9 @@ const emit = defineEmits<{
const editor = useEditor() const editor = useEditor()
const selectedNodes = computed(() => editor.getSelectedNodes()) 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 isMulti = computed(() => selectedNodes.value.length > 1)
const active = computed(() => selectedNodes.value.length > 0) const active = computed(() => selectedNodes.value.length > 0)
@ -55,7 +57,11 @@ function add(defaults: ArrayItemType) {
emit('add', defaults) emit('add', defaults)
for (const n of targetNodes()) { for (const n of targetNodes()) {
const arr = isMulti.value ? [defaults] : [...n[propKey], defaults] 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()) { for (const n of targetNodes()) {
editor.updateNodeWithUndo( editor.updateNodeWithUndo(
n.id, 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}` `Remove ${propKey}`
) )
} }
@ -95,7 +103,11 @@ function toggleVisibility(index: number) {
if (!arr[index]) continue if (!arr[index]) continue
const newArr = [...n[propKey]] as Array<{ visible: boolean }> const newArr = [...n[propKey]] as Array<{ visible: boolean }>
newArr[index] = { ...newArr[index], visible: !arr[index].visible } 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 { ref, computed } from 'vue'
import { EDITOR_TOOLS } from '@open-pencil/core/editor' import { EDITOR_TOOLS } from '@open-pencil/core/editor'
import { useEditor } from '../shared/editorContext' import { useEditor } from '../context/editorContext'
import { provideToolbar } from './context' import { provideToolbar } from './context'
import type { EditorToolDef, Tool } from '@open-pencil/core/editor' import type { EditorToolDef, Tool } from '@open-pencil/core/editor'

View file

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

View file

@ -1,9 +1,9 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useEditor } from '../shared/editorContext' import { useEditor } from '../context/editorContext'
import { usePageList } from '../shared/usePageList' import { usePageList } from '../PageList/usePageList'
import { useSelectionCapabilities } from '../selection/useSelectionCapabilities' import { useSelectionCapabilities } from '../selection/useSelectionCapabilities'
import { useSelectionState } from '../shared/useSelectionState' import { useSelectionState } from '../selection/useSelectionState'
import type { Component, ComputedRef } from 'vue' 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) { function getCommand(id: EditorCommandId) {
return commands[id] return commands[id]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { TabsList, TabsRoot, TabsTrigger } from 'reka-ui' 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' import { useTabsStore, createTab } from '@/stores/tabs'
const { tabs, activeTabId, switchTab, closeTab } = useTabsStore() const { tabs, activeTabId, switchTab, closeTab } = useTabsStore()

View file

@ -3,9 +3,10 @@ import { computed } from 'vue'
import ColorInput from '@/components/ColorInput.vue' import ColorInput from '@/components/ColorInput.vue'
import { sectionWrapper } from '@/components/ui/section' 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) const pageColor = computed(() => editor.state.pageColor)
</script> </script>

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { useFileDialog } from '@vueuse/core' import { useFileDialog } from '@vueuse/core'
import { onUnmounted } from 'vue' import { onUnmounted } from 'vue'
import { IS_TAURI } from '@/constants' import { IS_BROWSER, IS_TAURI } from '@/constants'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs' import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs'
@ -11,6 +11,18 @@ fileDialog.onChange((files) => {
if (file) void openFileInNewTab(file) 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() { export async function openFileDialog() {
if (IS_TAURI) { if (IS_TAURI) {
const { open } = await import('@tauri-apps/plugin-dialog') const { open } = await import('@tauri-apps/plugin-dialog')

View file

@ -1,11 +1,10 @@
import { useDebounceFn } from '@vueuse/core' 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 { loadFont } from '@/engine/fonts'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { import {
computeAllLayouts,
createDefaultEditorState, createDefaultEditorState,
createEditor, createEditor,
exportFigFile, exportFigFile,
@ -21,8 +20,8 @@ export type { Tool } from '@open-pencil/core'
export type { EditorToolDef as ToolDef } 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 { EDITOR_TOOLS as TOOLS, TOOL_SHORTCUTS } from '@open-pencil/core'
export function createEditorStore() { export function createEditorStore(initialGraph?: SceneGraph) {
const graph = new SceneGraph() const graph = initialGraph ?? new SceneGraph()
const state = shallowReactive< const state = shallowReactive<
EditorState & { EditorState & {
@ -49,7 +48,11 @@ export function createEditorStore() {
cursorCanvasY: null cursorCanvasY: null
}) })
const editor = createEditor({ graph, state, loadFont }) const editor = createEditor({ graph, state, loadFont, skipInitialGraphSetup: !!initialGraph })
if (initialGraph) {
editor.subscribeToGraph()
}
// ─── Vue computed refs ──────────────────────────────────────── // ─── Vue computed refs ────────────────────────────────────────
@ -167,7 +170,6 @@ export function createEditorStore() {
const imported = await readFigFile(file) const imported = await readFigFile(file)
await yieldToUI() await yieldToUI()
editor.replaceGraph(imported) editor.replaceGraph(imported)
computeAllLayouts(editor.graph)
editor.undo.clear() editor.undo.clear()
fileHandle = handle ?? null fileHandle = handle ?? null
filePath = path ?? null filePath = path ?? null
@ -176,12 +178,7 @@ export function createEditorStore() {
state.selectedIds = new Set() state.selectedIds = new Set()
const firstPage = editor.graph.getPages()[0] as SceneNode | undefined const firstPage = editor.graph.getPages()[0] as SceneNode | undefined
const pageId = firstPage?.id ?? editor.graph.rootId const pageId = firstPage?.id ?? editor.graph.rootId
state.currentPageId = pageId await editor.switchPage(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))
editor.requestRender() editor.requestRender()
void startWatchingFile() void startWatchingFile()
} catch (e) { } catch (e) {
@ -286,12 +283,10 @@ export function createEditorStore() {
const file = new File([blob], state.documentName + '.fig') const file = new File([blob], state.documentName + '.fig')
const imported = await readFigFile(file) const imported = await readFigFile(file)
editor.replaceGraph(imported) editor.replaceGraph(imported)
computeAllLayouts(editor.graph)
} else if (fileHandle) { } else if (fileHandle) {
const file = await fileHandle.getFile() const file = await fileHandle.getFile()
const imported = await readFigFile(file) const imported = await readFigFile(file)
editor.replaceGraph(imported) editor.replaceGraph(imported)
computeAllLayouts(editor.graph)
} else { } else {
return return
} }
@ -500,7 +495,7 @@ export function createEditorStore() {
// ─── Public API ─────────────────────────────────────────────── // ─── Public API ───────────────────────────────────────────────
// Spread all core Editor methods, then override getters and add app-specific. // Spread all core Editor methods, then override getters and add app-specific.
return { const store = {
...editor, ...editor,
state, state,
selectedNodes, selectedNodes,
@ -523,6 +518,23 @@ export function createEditorStore() {
mobileCut, mobileCut,
mobilePaste 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> export type EditorStore = ReturnType<typeof createEditorStore>
@ -531,6 +543,7 @@ const storeRef = shallowRef<EditorStore>()
export function setActiveEditorStore(store: EditorStore) { export function setActiveEditorStore(store: EditorStore) {
storeRef.value = store storeRef.value = store
triggerRef(storeRef)
} }
export function getActiveEditorStore(): EditorStore { 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 { createEditorStore, setActiveEditorStore } from './editor'
import type { EditorStore } from './editor' import type { EditorStore } from './editor'
import type { SceneGraph } from '@open-pencil/core'
export interface Tab { export interface Tab {
id: string id: string
@ -34,8 +35,8 @@ export function getActiveStore(): EditorStore {
return tab.store return tab.store
} }
export function createTab(store?: EditorStore): Tab { export function createTab(store?: EditorStore, initialGraph?: SceneGraph): Tab {
const s = store ?? createEditorStore() const s = store ?? createEditorStore(initialGraph)
const tab: Tab = { id: generateTabId(), store: s } const tab: Tab = { id: generateTabId(), store: s }
tabsRef.value = [...tabsRef.value, tab] tabsRef.value = [...tabsRef.value, tab]
activateTab(tab) activateTab(tab)
@ -45,6 +46,7 @@ export function createTab(store?: EditorStore): Tab {
function activateTab(tab: Tab) { function activateTab(tab: Tab) {
activeTabId.value = tab.id activeTabId.value = tab.id
setActiveEditorStore(tab.store) setActiveEditorStore(tab.store)
triggerRef(tabsRef)
window.__OPEN_PENCIL_STORE__ = tab.store window.__OPEN_PENCIL_STORE__ = tab.store
} }
@ -74,19 +76,32 @@ export function closeTab(tabId: string) {
export async function openFileInNewTab( export async function openFileInNewTab(
file: File, file: File,
handle?: FileSystemFileHandle, _handle?: FileSystemFileHandle,
path?: string _path?: string
): Promise<void> { ): Promise<void> {
const current = activeTab.value const current = activeTab.value
const isUntouched = const isUntouched =
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
if (isUntouched) { 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 { } else {
const store = createEditorStore() const { readFigFile } = await import('@open-pencil/core')
const imported = await readFigFile(file)
const store = createEditorStore(imported)
createTab(store) 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 { useHead } from '@unhead/vue'
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui' 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 { useKeyboard } from '@/composables/use-keyboard'
import { useMenu } from '@/composables/use-menu' import { useMenu } from '@/composables/use-menu'
import { useCollab, COLLAB_KEY } from '@/composables/use-collab' import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
@ -32,7 +32,6 @@ const showChrome = !('no-chrome' in params)
const firstTab = createTab() const firstTab = createTab()
const store = useEditorStore() const store = useEditorStore()
provideEditor(store)
const { isMobile } = useViewportKind() const { isMobile } = useViewportKind()
if (route.meta.demo && !('test' in params)) { if (route.meta.demo && !('test' in params)) {

View file

@ -1,6 +1,8 @@
import { describe, test, expect } from 'bun:test' 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) { function pageId(graph: SceneGraph) {
return graph.getPages()[0].id 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('Auto Layout', () => {
describe('horizontal basic', () => { describe('horizontal basic', () => {
test('positions children left-to-right', () => { test('positions children left-to-right', () => {
@ -970,6 +979,75 @@ describe('Auto Layout', () => {
}) })
describe('text measurement', () => { 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', () => { test('WIDTH_AND_HEIGHT text uses measured width in centered layout', () => {
const graph = new SceneGraph() const graph = new SceneGraph()
const pid = pageId(graph) const pid = pageId(graph)