openpencil/packages/core/src/editor/structure.ts
2026-03-31 16:44:35 +03:00

408 lines
13 KiB
TypeScript

import { computeAbsoluteBounds } from '../geometry'
import { computeLayout } from '../layout'
import type { LayoutMode, NodeType, SceneNode } from '../scene-graph'
import type { EditorContext } from './types'
export function createStructureActions(ctx: EditorContext) {
function defaultNodeName(type: NodeType): string {
return type.charAt(0) + type.slice(1).toLowerCase()
}
function isTopLevel(parentId: string | null): boolean {
return !parentId || parentId === ctx.graph.rootId || parentId === ctx.state.currentPageId
}
function doReorderChild(nodeId: string, parentId: string, insertIndex: number) {
const node = ctx.graph.getNode(nodeId)
if (!node) return
if (node.parentId !== parentId) {
const absPos = ctx.graph.getAbsolutePosition(nodeId)
const parentAbs = ctx.graph.getAbsolutePosition(parentId)
ctx.graph.updateNode(nodeId, { x: absPos.x - parentAbs.x, y: absPos.y - parentAbs.y })
}
ctx.graph.reorderChild(nodeId, parentId, insertIndex)
computeLayout(ctx.graph, parentId)
ctx.runLayoutForNode(parentId)
}
function reorderInAutoLayout(nodeId: string, parentId: string, insertIndex: number) {
const parent = ctx.graph.getNode(parentId)
if (!parent || parent.layoutMode === 'NONE') return
const node = ctx.graph.getNode(nodeId)
if (!node) return
const origParentId = node.parentId ?? ctx.state.currentPageId
const origX = node.x
const origY = node.y
const origIndex = ctx.graph.getNode(origParentId)?.childIds.indexOf(nodeId) ?? -1
doReorderChild(nodeId, parentId, insertIndex)
ctx.undo.push({
label: 'Reorder',
forward: () => {
doReorderChild(nodeId, parentId, insertIndex)
},
inverse: () => {
ctx.graph.reorderChild(nodeId, origParentId, origIndex >= 0 ? origIndex : 0)
ctx.graph.updateNode(nodeId, { x: origX, y: origY })
computeLayout(ctx.graph, origParentId)
ctx.runLayoutForNode(origParentId)
if (origParentId !== parentId) {
computeLayout(ctx.graph, parentId)
ctx.runLayoutForNode(parentId)
}
}
})
}
function reorderChildWithUndo(nodeId: string, newParentId: string, insertIndex: number) {
const node = ctx.graph.getNode(nodeId)
if (!node) return
const origParentId = node.parentId ?? ctx.state.currentPageId
const origIndex = ctx.graph.getNode(origParentId)?.childIds.indexOf(nodeId) ?? 0
const origX = node.x
const origY = node.y
ctx.graph.reorderChild(nodeId, newParentId, insertIndex)
ctx.runLayoutForNode(newParentId)
if (origParentId !== newParentId) ctx.runLayoutForNode(origParentId)
ctx.undo.push({
label: 'Reorder',
forward: () => {
ctx.graph.reorderChild(nodeId, newParentId, insertIndex)
ctx.runLayoutForNode(newParentId)
if (origParentId !== newParentId) ctx.runLayoutForNode(origParentId)
},
inverse: () => {
ctx.graph.reorderChild(nodeId, origParentId, origIndex)
ctx.graph.updateNode(nodeId, { x: origX, y: origY })
ctx.runLayoutForNode(origParentId)
if (origParentId !== newParentId) ctx.runLayoutForNode(newParentId)
}
})
}
function reparentNodes(nodeIds: string[], newParentId: string) {
const parent = ctx.graph.getNode(newParentId)
for (const id of nodeIds) {
const node = ctx.graph.getNode(id)
if (
node?.type === 'SECTION' &&
parent &&
parent.type !== 'CANVAS' &&
parent.type !== 'SECTION'
)
continue
ctx.graph.reparentNode(id, newParentId)
}
}
function wrapSelectionInContainer(
containerType: 'GROUP' | 'FRAME' | 'COMPONENT' | 'COMPONENT_SET',
selectedNodes: SceneNode[],
extraProps?: Partial<SceneNode>
) {
if (selectedNodes.length === 0) return null
const parentId = selectedNodes[0].parentId ?? ctx.state.currentPageId
const sameParent = selectedNodes.every(
(n) => (n.parentId ?? ctx.state.currentPageId) === parentId
)
if (!sameParent) return null
const parent = ctx.graph.getNode(parentId)
if (!parent) return null
const prevSelection = new Set(ctx.state.selectedIds)
const nodeIds = selectedNodes.map((n) => n.id)
const origPositions = selectedNodes.map((n) => ({ id: n.id, x: n.x, y: n.y }))
const {
x: minX,
y: minY,
width: bw,
height: bh
} = computeAbsoluteBounds(selectedNodes, (id) => ctx.graph.getAbsolutePosition(id))
const maxX = minX + bw
const maxY = minY + bh
const parentAbs = isTopLevel(parentId)
? { x: 0, y: 0 }
: ctx.graph.getAbsolutePosition(parentId)
const firstIndex = Math.min(...nodeIds.map((id) => parent.childIds.indexOf(id)))
const padding = containerType === 'COMPONENT_SET' ? 40 : 0
const containerNames: Record<string, string> = {
COMPONENT_SET: selectedNodes[0].name.split('/')[0]?.trim() || 'Component Set',
COMPONENT: 'Component',
GROUP: 'Group',
FRAME: 'Frame'
}
const containerNode = ctx.graph.createNode(containerType, parentId, {
name: containerNames[containerType] ?? containerType,
x: minX - parentAbs.x - padding,
y: minY - parentAbs.y - padding,
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2,
fills:
containerType === 'COMPONENT_SET'
? [
{
type: 'SOLID',
color: { r: 0.96, g: 0.96, b: 0.96, a: 1 },
opacity: 1,
visible: true
}
]
: [],
...extraProps
})
const containerId = containerNode.id
ctx.graph.insertChildAt(containerId, parentId, firstIndex)
for (const n of selectedNodes) {
ctx.graph.reparentNode(n.id, containerId)
}
ctx.state.selectedIds = new Set([containerId])
ctx.undo.push({
label: `Create ${containerType.toLowerCase().replace('_', ' ')}`,
forward: () => {
const c = ctx.graph.createNode(containerType, parentId, {
...containerNode,
...extraProps,
id: containerId
})
ctx.graph.insertChildAt(c.id, parentId, firstIndex)
for (const n of origPositions) ctx.graph.reparentNode(n.id, c.id)
ctx.state.selectedIds = new Set([c.id])
},
inverse: () => {
for (const orig of origPositions) {
ctx.graph.reparentNode(orig.id, parentId)
ctx.graph.updateNode(orig.id, { x: orig.x, y: orig.y })
}
ctx.graph.deleteNode(containerId)
ctx.state.selectedIds = prevSelection
}
})
return containerId
}
function wrapInAutoLayout(selectedNodes: SceneNode[]) {
if (selectedNodes.length === 0) return
const parentId = selectedNodes[0].parentId ?? ctx.state.currentPageId
const sameParent = selectedNodes.every(
(n) => (n.parentId ?? ctx.state.currentPageId) === parentId
)
if (!sameParent) return
const prevSelection = new Set(ctx.state.selectedIds)
const origPositions = selectedNodes.map((n) => ({ id: n.id, x: n.x, y: n.y, parentId }))
const bounds = computeAbsoluteBounds(selectedNodes, (id) => ctx.graph.getAbsolutePosition(id))
const parentAbs = isTopLevel(parentId)
? { x: 0, y: 0 }
: ctx.graph.getAbsolutePosition(parentId)
const direction: LayoutMode =
selectedNodes.length <= 1 || bounds.height > bounds.width ? 'VERTICAL' : 'HORIZONTAL'
const frame = ctx.graph.createNode('FRAME', parentId, {
name: 'Frame',
x: bounds.x - parentAbs.x,
y: bounds.y - parentAbs.y,
width: bounds.width,
height: bounds.height,
layoutMode: direction,
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
primaryAxisAlign: 'MIN',
counterAxisAlign: 'MIN',
fills: []
})
const frameId = frame.id
const sortedIds = selectedNodes
.map((n) => ({ id: n.id, pos: ctx.graph.getAbsolutePosition(n.id) }))
.sort((a, b) => a.pos.y - b.pos.y || a.pos.x - b.pos.x)
.map((n) => n.id)
for (const id of sortedIds) {
ctx.graph.reparentNode(id, frameId)
}
computeLayout(ctx.graph, frameId)
ctx.runLayoutForNode(frameId)
ctx.state.selectedIds = new Set([frameId])
ctx.undo.push({
label: 'Wrap in auto layout',
forward: () => {
const f = ctx.graph.createNode('FRAME', parentId, { ...frame, id: frameId })
for (const n of origPositions) ctx.graph.reparentNode(n.id, f.id)
computeLayout(ctx.graph, f.id)
ctx.runLayoutForNode(f.id)
ctx.state.selectedIds = new Set([f.id])
},
inverse: () => {
for (const orig of origPositions) {
ctx.graph.reparentNode(orig.id, orig.parentId)
ctx.graph.updateNode(orig.id, { x: orig.x, y: orig.y })
}
ctx.graph.deleteNode(frameId)
ctx.state.selectedIds = prevSelection
}
})
}
function groupSelected(selectedNodes: SceneNode[]) {
return wrapSelectionInContainer('GROUP', selectedNodes)
}
function ungroupSelected(selectedNode: SceneNode | undefined) {
if (selectedNode?.type !== 'GROUP') return
const node = selectedNode
const parentId = node.parentId ?? ctx.state.currentPageId
const parent = ctx.graph.getNode(parentId)
if (!parent) return
const groupIndex = parent.childIds.indexOf(node.id)
const childIds = [...node.childIds]
const prevSelection = new Set(ctx.state.selectedIds)
const origPositions = childIds.map((id) => {
const child = ctx.graph.getNode(id)
if (!child) return { id, x: 0, y: 0 }
return { id, x: child.x, y: child.y }
})
const groupId = node.id
const groupSnapshot = { ...node, childIds: [...node.childIds] }
for (let i = 0; i < childIds.length; i++) {
ctx.graph.reparentNode(childIds[i], parentId)
ctx.graph.insertChildAt(childIds[i], parentId, groupIndex + i)
}
ctx.graph.deleteNode(node.id)
ctx.state.selectedIds = new Set(childIds)
ctx.undo.push({
label: 'Ungroup',
forward: () => {
for (let i = 0; i < childIds.length; i++) {
ctx.graph.reparentNode(childIds[i], parentId)
ctx.graph.insertChildAt(childIds[i], parentId, groupIndex + i)
}
ctx.graph.deleteNode(groupId)
ctx.state.selectedIds = new Set(childIds)
},
inverse: () => {
const g = ctx.graph.createNode('GROUP', parentId, {
...groupSnapshot,
childIds: [],
id: groupId
})
ctx.graph.insertChildAt(g.id, parentId, groupIndex)
for (const orig of origPositions) {
ctx.graph.reparentNode(orig.id, g.id)
ctx.graph.updateNode(orig.id, { x: orig.x, y: orig.y })
}
ctx.state.selectedIds = prevSelection
}
})
}
function bringToFront() {
for (const id of ctx.state.selectedIds) {
const node = ctx.graph.getNode(id)
if (!node?.parentId) continue
const parent = ctx.graph.getNode(node.parentId)
if (!parent) continue
const idx = parent.childIds.indexOf(id)
if (idx === parent.childIds.length - 1) continue
ctx.graph.insertChildAt(id, node.parentId, parent.childIds.length)
}
ctx.requestRender()
}
function sendToBack() {
for (const id of ctx.state.selectedIds) {
const node = ctx.graph.getNode(id)
if (!node?.parentId) continue
const parent = ctx.graph.getNode(node.parentId)
if (!parent) continue
const idx = parent.childIds.indexOf(id)
if (idx === 0) continue
ctx.graph.insertChildAt(id, node.parentId, 0)
}
ctx.requestRender()
}
function toggleNodeVisibility(id: string) {
const node = ctx.graph.getNode(id)
if (!node) return
ctx.graph.updateNode(id, { visible: !node.visible })
}
function toggleNodeLock(id: string) {
const node = ctx.graph.getNode(id)
if (!node) return
ctx.graph.updateNode(id, { locked: !node.locked })
}
function toggleVisibility() {
for (const id of ctx.state.selectedIds) toggleNodeVisibility(id)
}
function toggleLock() {
for (const id of ctx.state.selectedIds) toggleNodeLock(id)
}
function moveToPage(pageId: string) {
const targetPage = ctx.graph.getNode(pageId)
if (targetPage?.type !== 'CANVAS') return
const ids = [...ctx.state.selectedIds]
for (const id of ids) {
ctx.graph.reparentNode(id, pageId)
}
ctx.state.selectedIds = new Set()
}
function renameNode(id: string, name: string) {
const node = ctx.graph.getNode(id)
if (!node) return
const trimmedName = name.trim()
ctx.graph.updateNode(id, { name: trimmedName || defaultNodeName(node.type) })
}
return {
isTopLevel,
reorderInAutoLayout,
reorderChildWithUndo,
reparentNodes,
wrapSelectionInContainer,
wrapInAutoLayout,
groupSelected,
ungroupSelected,
bringToFront,
sendToBack,
toggleNodeVisibility,
toggleNodeLock,
toggleVisibility,
toggleLock,
moveToPage,
renameNode
}
}