Pages support: multi-page documents like Figma

Document structure: Document (root) → Pages (CANVAS) → Nodes.
Each page has its own viewport (pan/zoom) and background color.

- CANVAS node type for pages
- SceneGraph: addPage(), getPages(), getAbsolutePosition stops at CANVAS
- Store: currentPageId, switchPage(), addPage(), deletePage(), renamePage()
- All operations (create, select, paste, hit test, render) scoped to current page
- Per-page viewport state saved/restored on page switch
- Pages list in LayersPanel with add/switch/double-click rename
- .fig import creates proper pages from DOCUMENT→CANVAS hierarchy
- Renderer takes pageId to render correct page's children
- Unit test for pages, updated E2E tests for page-scoped graph
This commit is contained in:
Danila Poyarkov 2026-02-28 13:12:27 +03:00
parent 9d6a78fd7c
commit 1d79473bc6
12 changed files with 267 additions and 74 deletions

2
.gitignore vendored
View file

@ -26,3 +26,5 @@ public/canvaskit.wasm
*.sln
*.sw?
test-results/
# Git worktrees for parallel agent work
.worktrees/

1
components.d.ts vendored
View file

@ -32,6 +32,7 @@ declare module 'vue' {
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideEye: typeof import('~icons/lucide/eye')['default']
IconLucideEyeOff: typeof import('~icons/lucide/eye-off')['default']
IconLucideFile: typeof import('~icons/lucide/file')['default']
IconLucideFlipHorizontal: typeof import('~icons/lucide/flip-horizontal')['default']
IconLucideFlipVertical: typeof import('~icons/lucide/flip-vertical')['default']
IconLucideRadius: typeof import('~icons/lucide/radius')['default']

View file

@ -48,17 +48,22 @@ function buildTree(parentId: string): LayerNode[] {
}))
}
const items = ref(buildTree(store.graph.rootId))
const items = ref(buildTree(store.state.currentPageId))
const treeKey = ref(0)
watch(
() => store.state.renderVersion,
[() => store.state.renderVersion, () => store.state.currentPageId],
() => {
items.value = buildTree(store.graph.rootId)
items.value = buildTree(store.state.currentPageId)
treeKey.value++
}
)
const pages = computed(() => {
void store.state.renderVersion
return store.graph.getPages()
})
const expanded = ref<string[]>([])
function onSelect(ev: CustomEvent) {
@ -71,6 +76,13 @@ function onSelect(ev: CustomEvent) {
}
}
function renamePage(pageId: string, currentName: string) {
const name = prompt('Rename page', currentName)
if (name && name !== currentName) {
store.renamePage(pageId, name)
}
}
function toggleExpand(id: string) {
const idx = expanded.value.indexOf(id)
if (idx >= 0) {
@ -201,6 +213,30 @@ function updateDropTarget(ev: PointerEvent) {
<template>
<aside class="flex w-60 flex-col overflow-y-auto border-r border-border bg-panel">
<!-- Pages -->
<div class="shrink-0 border-b border-border">
<div class="flex items-center justify-between px-3 py-1.5">
<span class="text-[11px] uppercase tracking-wider text-muted">Pages</span>
<button
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
title="Add page"
@click="store.addPage()"
>+</button>
</div>
<div class="px-1 pb-1">
<button
v-for="page in pages"
:key="page.id"
class="flex w-full cursor-pointer items-center gap-1.5 rounded border-none px-2 py-1 text-left text-xs"
:class="page.id === store.state.currentPageId ? 'bg-hover text-surface' : 'bg-transparent text-muted hover:bg-hover hover:text-surface'"
@click="store.switchPage(page.id)"
@dblclick="renamePage(page.id, page.name)"
>
<icon-lucide-file class="size-3 shrink-0" />
{{ page.name }}
</button>
</div>
</div>
<header class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted">Layers</header>
<div ref="listRef" class="relative flex-1 overflow-y-auto px-1">
<TreeRoot

View file

@ -308,7 +308,7 @@ export function useCanvasInput(canvasRef: Ref<HTMLCanvasElement | null>, store:
}
// Hit test nodes
const hit = store.graph.hitTest(cx, cy)
const hit = store.graph.hitTest(cx, cy, store.state.currentPageId)
if (hit) {
if (!store.state.selectedIds.has(hit.id) && !e.shiftKey) {
store.select([hit.id])
@ -526,7 +526,7 @@ export function useCanvasInput(canvasRef: Ref<HTMLCanvasElement | null>, store:
}
// Check if we're hovering over an auto-layout frame
const dropTarget = store.graph.hitTestFrame(cx, cy, store.state.selectedIds)
const dropTarget = store.graph.hitTestFrame(cx, cy, store.state.selectedIds, store.state.currentPageId)
const dropParent = dropTarget ? store.graph.getNode(dropTarget.id) : null
if (dropParent && dropParent.layoutMode !== 'NONE') {
@ -568,9 +568,9 @@ export function useCanvasInput(canvasRef: Ref<HTMLCanvasElement | null>, store:
// Snap against siblings in absolute coordinates
const firstId = [...d.originals.keys()][0]
const firstNode = store.graph.getNode(firstId)
const parentId = firstNode?.parentId ?? store.graph.rootId
const parentId = firstNode?.parentId ?? store.state.currentPageId
const siblings = store.graph.getChildren(parentId)
const parentAbs = parentId !== store.graph.rootId
const parentAbs = !store.isTopLevel(parentId)
? store.graph.getAbsolutePosition(parentId)
: { x: 0, y: 0 }
const absTargets = siblings.map((n) => ({
@ -639,7 +639,7 @@ export function useCanvasInput(canvasRef: Ref<HTMLCanvasElement | null>, store:
const maxY = Math.max(d.startY, cy)
const hits: string[] = []
for (const node of store.graph.getChildren(store.graph.rootId)) {
for (const node of store.graph.getChildren(store.state.currentPageId)) {
if (
node.x + node.width > minX &&
node.x < maxX &&
@ -745,13 +745,13 @@ export function useCanvasInput(canvasRef: Ref<HTMLCanvasElement | null>, store:
// Reparent to grandparent if dragged outside parent bounds
for (const id of store.state.selectedIds) {
const node = store.graph.getNode(id)
if (!node?.parentId || node.parentId === store.graph.rootId) continue
if (!node?.parentId || store.isTopLevel(node.parentId)) continue
const parent = store.graph.getNode(node.parentId)
if (!parent || parent.type !== 'FRAME') continue
const outsideX = node.x + node.width < 0 || node.x > parent.width
const outsideY = node.y + node.height < 0 || node.y > parent.height
if (outsideX || outsideY) {
const grandparentId = parent.parentId ?? store.graph.rootId
const grandparentId = parent.parentId ?? store.state.currentPageId
store.graph.reparentNode(id, grandparentId)
}
}
@ -812,7 +812,7 @@ export function useCanvasInput(canvasRef: Ref<HTMLCanvasElement | null>, store:
function onDblClick(e: MouseEvent) {
const { cx, cy } = getCoords(e)
const hit = store.graph.hitTest(cx, cy)
const hit = store.graph.hitTest(cx, cy, store.state.currentPageId)
if (hit && hit.type === 'TEXT') {
store.select([hit.id])
store.startTextEditing(hit.id)

View file

@ -57,6 +57,7 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
renderer.viewportHeight = canvasRef.value?.clientHeight ?? 0
renderer.showRulers = !new URLSearchParams(window.location.search).has('no-rulers')
renderer.pageColor = store.state.pageColor
renderer.pageId = store.state.currentPageId
renderer.render(store.graph, store.state.selectedIds, {
editingTextId: store.state.editingTextId,
marquee: store.state.marquee,

View file

@ -2,7 +2,7 @@ import type { EditorStore } from './stores/editor'
export function createDemoShapes(store: EditorStore) {
store.createShape('FRAME', 100, 80, 800, 500)
store.graph.updateNode(store.graph.getChildren(store.graph.rootId)[0].id, {
store.graph.updateNode(store.graph.getChildren(store.state.currentPageId)[0].id, {
name: 'Desktop',
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
strokes: [

View file

@ -72,6 +72,7 @@ export class SkiaRenderer {
viewportHeight = 0
showRulers = true
pageColor = CANVAS_BG_COLOR
pageId: string | null = null
private selColor(alpha = 1) {
return this.ck.Color4f(SELECTION_COLOR.r, SELECTION_COLOR.g, SELECTION_COLOR.b, alpha)
@ -144,9 +145,9 @@ export class SkiaRenderer {
canvas.translate(this.panX, this.panY)
canvas.scale(this.zoom, this.zoom)
const root = graph.getNode(graph.rootId)
if (root) {
for (const childId of root.childIds) {
const pageNode = graph.getNode(this.pageId ?? graph.rootId)
if (pageNode) {
for (const childId of pageNode.childIds) {
this.renderNode(canvas, graph, childId, overlays)
}
}
@ -306,7 +307,8 @@ export class SkiaRenderer {
// Frame name label — only for top-level frames (direct children of root)
if (nodes.length === 1) {
const node = nodes[0]
if (node.type === 'FRAME' && node.parentId === graph.rootId) {
const parentNode = node.parentId ? graph.getNode(node.parentId) : null
if (node.type === 'FRAME' && (!parentNode || parentNode.type === 'CANVAS')) {
const labelPaint = new this.ck.Paint()
labelPaint.setStyle(this.ck.PaintStyle.Fill)
labelPaint.setColor(this.selColor())
@ -355,14 +357,16 @@ export class SkiaRenderer {
const drawn = new Set<string>()
for (const id of selectedIds) {
const node = graph.getNode(id)
if (!node?.parentId || node.parentId === graph.rootId) continue
if (!node?.parentId) continue
const nodeParent = graph.getNode(node.parentId)
if (!nodeParent || nodeParent.type === 'CANVAS') continue
if (drawn.has(node.parentId) || selectedIds.has(node.parentId)) continue
const parent = graph.getNode(node.parentId)
if (!parent) continue
const parent = nodeParent
// Skip dashed outline for top-level frames (direct children of root)
if (parent.parentId === graph.rootId) continue
// Skip dashed outline for top-level frames (direct children of page)
const grandparent = parent.parentId ? graph.getNode(parent.parentId) : null
if (!grandparent || grandparent.type === 'CANVAS') continue
drawn.add(node.parentId)

View file

@ -31,6 +31,7 @@ export interface VectorNetwork {
}
export type NodeType =
| 'CANVAS'
| 'FRAME'
| 'RECTANGLE'
| 'ELLIPSE'
@ -191,7 +192,7 @@ function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}):
}
}
const CONTAINER_TYPES = new Set<NodeType>(['FRAME', 'GROUP', 'SECTION'])
const CONTAINER_TYPES = new Set<NodeType>(['CANVAS', 'FRAME', 'GROUP', 'SECTION'])
export class SceneGraph {
nodes = new Map<string, SceneNode>()
@ -205,6 +206,16 @@ export class SceneGraph {
})
this.rootId = root.id
this.nodes.set(root.id, root)
this.addPage('Page 1')
}
addPage(name: string): SceneNode {
return this.createNode('CANVAS', this.rootId, { name, width: 0, height: 0 })
}
getPages(): SceneNode[] {
return this.getChildren(this.rootId).filter((n) => n.type === 'CANVAS')
}
getNode(id: string): SceneNode | undefined {
@ -237,7 +248,7 @@ export class SceneGraph {
let ax = 0
let ay = 0
let current = this.nodes.get(id)
while (current && current.id !== this.rootId) {
while (current && current.id !== this.rootId && current.type !== 'CANVAS') {
ax += current.x
ay += current.y
current = current.parentId ? this.nodes.get(current.parentId) : undefined
@ -287,8 +298,11 @@ export class SceneGraph {
// Convert absolute position
const absPos = this.getAbsolutePosition(nodeId)
const newParentNode = this.nodes.get(newParentId)
const newParentAbs =
newParentId === this.rootId ? { x: 0, y: 0 } : this.getAbsolutePosition(newParentId)
newParentId === this.rootId || newParentNode?.type === 'CANVAS'
? { x: 0, y: 0 }
: this.getAbsolutePosition(newParentId)
// Remove from old parent
if (oldParent) {
@ -383,8 +397,8 @@ export class SceneGraph {
return null
}
hitTestFrame(px: number, py: number, excludeIds: Set<string>): SceneNode | null {
return this.hitTestFrameChildren(px, py, this.rootId, 0, 0, excludeIds)
hitTestFrame(px: number, py: number, excludeIds: Set<string>, scopeId?: string): SceneNode | null {
return this.hitTestFrameChildren(px, py, scopeId ?? this.rootId, 0, 0, excludeIds)
}
private hitTestFrameChildren(

View file

@ -57,8 +57,12 @@ function convertEffects(effects?: KiwiEffect[]): Effect[] {
}))
}
function mapNodeType(type?: string): NodeType {
function mapNodeType(type?: string): NodeType | 'DOCUMENT' {
switch (type) {
case 'DOCUMENT':
return 'DOCUMENT'
case 'CANVAS':
return 'CANVAS'
case 'FRAME':
return 'FRAME'
case 'RECTANGLE':
@ -148,7 +152,11 @@ function resolveVectorNetwork(nc: NodeChange, blobs: Uint8Array[]): VectorNetwor
export function importNodeChanges(nodeChanges: NodeChange[], blobs: Uint8Array[] = []): SceneGraph {
const graph = new SceneGraph()
// Build guid→nodeChange map and parent relationships
// Remove the default page created by constructor — we'll create pages from the file
for (const page of graph.getPages()) {
graph.deleteNode(page.id)
}
const changeMap = new Map<string, NodeChange>()
const parentMap = new Map<string, string>()
@ -163,19 +171,22 @@ export function importNodeChanges(nodeChanges: NodeChange[], blobs: Uint8Array[]
}
}
// Find root nodes (those whose parent is 0:0 or not in the set)
const roots: string[] = []
for (const [id] of changeMap) {
const parentId = parentMap.get(id)
if (!parentId || parentId === '0:0' || !changeMap.has(parentId)) {
roots.push(id)
function getChildren(ncId: string): string[] {
const children: string[] = []
for (const [childId, pid] of parentMap) {
if (pid === ncId) children.push(childId)
}
children.sort((a, b) => {
const aPos = changeMap.get(a)?.parentIndex?.position ?? ''
const bPos = changeMap.get(b)?.parentIndex?.position ?? ''
return aPos.localeCompare(bPos)
})
return children
}
// Recursively create nodes
const created = new Set<string>()
function createNode(ncId: string, graphParentId: string) {
function createSceneNode(ncId: string, graphParentId: string) {
if (created.has(ncId)) return
created.add(ncId)
@ -183,12 +194,13 @@ export function importNodeChanges(nodeChanges: NodeChange[], blobs: Uint8Array[]
if (!nc) return
const nodeType = mapNodeType(nc.type)
if (nodeType === 'DOCUMENT') return
const x = nc.transform?.m02 ?? 0
const y = nc.transform?.m12 ?? 0
const width = nc.size?.x ?? 100
const height = nc.size?.y ?? 100
// Extract rotation from transform matrix
let rotation = 0
if (nc.transform) {
rotation = Math.atan2(nc.transform.m10, nc.transform.m00) * (180 / Math.PI)
@ -239,26 +251,51 @@ export function importNodeChanges(nodeChanges: NodeChange[], blobs: Uint8Array[]
vectorNetwork: resolveVectorNetwork(nc, blobs)
})
// Create children (find all nodes whose parent is this node)
const children: string[] = []
for (const [childId, pid] of parentMap) {
if (pid === ncId) children.push(childId)
}
// Sort children by parentIndex position if available
children.sort((a, b) => {
const aPos = changeMap.get(a)?.parentIndex?.position ?? ''
const bPos = changeMap.get(b)?.parentIndex?.position ?? ''
return aPos.localeCompare(bPos)
})
for (const childId of children) {
createNode(childId, node.id)
for (const childId of getChildren(ncId)) {
createSceneNode(childId, node.id)
}
}
for (const rootId of roots) {
createNode(rootId, graph.rootId)
// Find the document node (type=DOCUMENT or guid 0:0)
let docId: string | null = null
for (const [id, nc] of changeMap) {
if (nc.type === 'DOCUMENT' || id === '0:0') {
docId = id
break
}
}
if (docId) {
// Import pages (CANVAS nodes) and their children
for (const canvasId of getChildren(docId)) {
const canvasNc = changeMap.get(canvasId)
if (!canvasNc) continue
if (canvasNc.type === 'CANVAS') {
const page = graph.addPage(canvasNc.name ?? 'Page')
created.add(canvasId)
for (const childId of getChildren(canvasId)) {
createSceneNode(childId, page.id)
}
} else {
createSceneNode(canvasId, graph.getPages()[0]?.id ?? graph.rootId)
}
}
} else {
// No document structure — treat all roots as children of the first page
const roots: string[] = []
for (const [id] of changeMap) {
const pid = parentMap.get(id)
if (!pid || !changeMap.has(pid)) roots.push(id)
}
const page = graph.getPages()[0] ?? graph.addPage('Page 1')
for (const rootId of roots) {
createSceneNode(rootId, page.id)
}
}
// Ensure at least one page exists
if (graph.getPages().length === 0) {
graph.addPage('Page 1')
}
return graph

View file

@ -58,14 +58,23 @@ const DEFAULT_FILLS: Record<string, Fill> = {
TEXT: BLACK_FILL
}
interface PageViewport {
panX: number
panY: number
zoom: number
pageColor: Color
}
export function createEditorStore() {
let graph = new SceneGraph()
const undo = new UndoManager()
const pageViewports = new Map<string, PageViewport>()
prefetchFigmaSchema()
const state = reactive({
activeTool: 'SELECT' as Tool,
currentPageId: graph.getPages()[0].id,
selectedIds: new Set<string>(),
marquee: null as { x: number; y: number; width: number; height: number } | null,
snapGuides: [] as SnapGuide[],
@ -111,13 +120,77 @@ export function createEditorStore() {
const layerTree = computed(() => {
void state.renderVersion
return graph.flattenTree()
return graph.flattenTree(state.currentPageId)
})
function requestRender() {
state.renderVersion++
}
function isTopLevel(parentId: string | null): boolean {
return !parentId || parentId === graph.rootId || parentId === state.currentPageId
}
function switchPage(pageId: string) {
const page = graph.getNode(pageId)
if (!page || page.type !== 'CANVAS') return
// Save current viewport
pageViewports.set(state.currentPageId, {
panX: state.panX,
panY: state.panY,
zoom: state.zoom,
pageColor: { ...state.pageColor }
})
// Switch
state.currentPageId = pageId
clearSelection()
// Restore viewport
const vp = pageViewports.get(pageId)
if (vp) {
state.panX = vp.panX
state.panY = vp.panY
state.zoom = vp.zoom
state.pageColor = { ...vp.pageColor }
} else {
state.panX = 0
state.panY = 0
state.zoom = 1
state.pageColor = { ...CANVAS_BG_COLOR }
}
requestRender()
}
function addPage(name?: string) {
const pages = graph.getPages()
const pageName = name ?? `Page ${pages.length + 1}`
const page = graph.addPage(pageName)
switchPage(page.id)
return page.id
}
function deletePage(pageId: string) {
const pages = graph.getPages()
if (pages.length <= 1) return
const idx = pages.findIndex((p) => p.id === pageId)
graph.deleteNode(pageId)
pageViewports.delete(pageId)
if (state.currentPageId === pageId) {
const newIdx = Math.min(idx, pages.length - 2)
const remaining = graph.getPages()
switchPage(remaining[newIdx].id)
}
requestRender()
}
function renamePage(pageId: string, name: string) {
graph.updateNode(pageId, { name })
requestRender()
}
function setTool(tool: Tool) {
state.activeTool = tool
}
@ -346,10 +419,14 @@ export function createEditorStore() {
graph = imported
computeAllLayouts(graph)
undo.clear()
pageViewports.clear()
state.selectedIds = new Set()
const firstPage = graph.getPages()[0]
state.currentPageId = firstPage?.id ?? graph.rootId
state.panX = 0
state.panY = 0
state.zoom = 1
state.pageColor = { ...CANVAS_BG_COLOR }
requestRender()
} catch (e) {
console.error('Failed to open .fig file:', e)
@ -467,8 +544,8 @@ export function createEditorStore() {
const nodes = selectedNodes.value
if (nodes.length === 0) return
const parentId = nodes[0].parentId ?? graph.rootId
const sameParent = nodes.every((n) => (n.parentId ?? graph.rootId) === parentId)
const parentId = nodes[0].parentId ?? state.currentPageId
const sameParent = nodes.every((n) => (n.parentId ?? state.currentPageId) === parentId)
if (!sameParent) return
const prevSelection = new Set(state.selectedIds)
@ -487,7 +564,7 @@ export function createEditorStore() {
}
const parentAbs =
parentId === graph.rootId ? { x: 0, y: 0 } : graph.getAbsolutePosition(parentId)
isTopLevel(parentId) ? { x: 0, y: 0 } : graph.getAbsolutePosition(parentId)
const frame = graph.createNode('FRAME', parentId, {
name: 'Frame',
@ -546,8 +623,8 @@ export function createEditorStore() {
const nodes = selectedNodes.value
if (nodes.length === 0) return
const parentId = nodes[0].parentId ?? graph.rootId
const sameParent = nodes.every((n) => (n.parentId ?? graph.rootId) === parentId)
const parentId = nodes[0].parentId ?? state.currentPageId
const sameParent = nodes.every((n) => (n.parentId ?? state.currentPageId) === parentId)
if (!sameParent) return
const parent = graph.getNode(parentId)
@ -571,7 +648,7 @@ export function createEditorStore() {
}
const parentAbs =
parentId === graph.rootId ? { x: 0, y: 0 } : graph.getAbsolutePosition(parentId)
isTopLevel(parentId) ? { x: 0, y: 0 } : graph.getAbsolutePosition(parentId)
// Insert group at the position of the topmost selected node
const firstIndex = Math.min(...nodeIds.map((id) => parent.childIds.indexOf(id)))
@ -623,7 +700,7 @@ export function createEditorStore() {
const node = selectedNode.value
if (!node || node.type !== 'GROUP') return
const parentId = node.parentId ?? graph.rootId
const parentId = node.parentId ?? state.currentPageId
const parent = graph.getNode(parentId)
if (!parent) return
@ -683,7 +760,7 @@ export function createEditorStore() {
parentId?: string
): string {
const fill = DEFAULT_FILLS[type] ?? DEFAULT_FILLS.RECTANGLE
const pid = parentId ?? graph.rootId
const pid = parentId ?? state.currentPageId
const node = graph.createNode(type, pid, {
x,
y,
@ -710,7 +787,7 @@ export function createEditorStore() {
}
function selectAll() {
const children = graph.getChildren(graph.rootId)
const children = graph.getChildren(state.currentPageId)
state.selectedIds = new Set(children.map((n) => n.id))
}
@ -722,7 +799,7 @@ export function createEditorStore() {
for (const id of state.selectedIds) {
const src = graph.getNode(id)
if (!src) continue
const parentId = src.parentId ?? graph.rootId
const parentId = src.parentId ?? state.currentPageId
const { id: _srcId, parentId: _srcParent, childIds: _srcChildren, ...srcRest } = src
const node = graph.createNode(src.type, parentId, {
...srcRest,
@ -777,7 +854,7 @@ export function createEditorStore() {
parseFigmaClipboard(html).then((figma) => {
if (figma) {
const created = importClipboardNodes(figma.nodes, graph, graph.rootId, 20, 20, figma.blobs)
const created = importClipboardNodes(figma.nodes, graph, state.currentPageId, 20, 20, figma.blobs)
if (created.length > 0) {
state.selectedIds = new Set(created)
requestRender()
@ -790,7 +867,7 @@ export function createEditorStore() {
nodes: Array<SceneNode & { children?: SceneNode[] }>,
parentId?: string
) {
const target = parentId ?? graph.rootId
const target = parentId ?? state.currentPageId
const prevSelection = new Set(state.selectedIds)
const newIds: string[] = []
const created: Array<{ id: string; parentId: string; snapshot: SceneNode }> = []
@ -840,7 +917,7 @@ export function createEditorStore() {
for (const id of state.selectedIds) {
const node = graph.getNode(id)
if (!node) continue
const parentId = node.parentId ?? graph.rootId
const parentId = node.parentId ?? state.currentPageId
const parent = graph.getNode(parentId)
const index = parent?.childIds.indexOf(id) ?? -1
entries.push({ id, parentId, snapshot: { ...node }, index })
@ -991,7 +1068,7 @@ export function createEditorStore() {
}
function zoomToFit() {
const nodes = graph.getChildren(graph.rootId)
const nodes = graph.getChildren(state.currentPageId)
if (nodes.length === 0) return
let minX = Infinity
@ -1069,7 +1146,12 @@ export function createEditorStore() {
screenToCanvas,
applyZoom,
pan,
zoomToFit
zoomToFit,
isTopLevel,
switchPage,
addPage,
deletePage,
renamePage
}
}

View file

@ -48,7 +48,7 @@ async function getSceneTree() {
children: node.childIds.map((cid: string) => nodeTree(cid)).filter(Boolean),
}
}
return nodeTree(graph.rootId)
return nodeTree(store.state.currentPageId)
})
}

View file

@ -2,8 +2,12 @@ import { describe, test, expect } from 'bun:test'
import { SceneGraph } from '../../src/engine/scene-graph'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
function rect(graph: SceneGraph, name: string, x = 0, y = 0, w = 50, h = 50) {
return graph.createNode('RECTANGLE', graph.rootId, { name, x, y, width: w, height: h }).id
return graph.createNode('RECTANGLE', pageId(graph), { name, x, y, width: w, height: h }).id
}
describe('SceneGraph', () => {
@ -26,7 +30,7 @@ describe('SceneGraph', () => {
test('reparent into frame', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', graph.rootId, { name: 'F', x: 50, y: 50, width: 400, height: 400 }).id
const frame = graph.createNode('FRAME', pageId(graph), { name: 'F', x: 50, y: 50, width: 400, height: 400 }).id
const r = rect(graph, 'R', 100, 100)
graph.reparentNode(r, frame)
const children = graph.getChildren(frame)
@ -38,10 +42,22 @@ describe('SceneGraph', () => {
rect(graph, 'A')
rect(graph, 'B')
rect(graph, 'C')
const names = graph.getChildren(graph.rootId).map(n => n.name)
const names = graph.getChildren(pageId(graph)).map(n => n.name)
expect(names).toEqual(['A', 'B', 'C'])
})
test('pages', () => {
const graph = new SceneGraph()
expect(graph.getPages()).toHaveLength(1)
expect(graph.getPages()[0].name).toBe('Page 1')
const page2 = graph.addPage('Page 2')
expect(graph.getPages()).toHaveLength(2)
expect(page2.name).toBe('Page 2')
rect(graph, 'Shape', 0, 0, 50, 50)
expect(graph.getChildren(pageId(graph))).toHaveLength(1)
expect(graph.getChildren(page2.id)).toHaveLength(0)
})
test('update node', () => {
const graph = new SceneGraph()
const id = rect(graph, 'R')