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:
parent
9d6a78fd7c
commit
1d79473bc6
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -26,3 +26,5 @@ public/canvaskit.wasm
|
|||
*.sln
|
||||
*.sw?
|
||||
test-results/
|
||||
# Git worktrees for parallel agent work
|
||||
.worktrees/
|
||||
|
|
|
|||
1
components.d.ts
vendored
1
components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue