import { AnyNode, type AnyNodeId, generateId, type LevelNode, type StairNode, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' type ClipboardPayload = { copiedAt: number nodes: AnyNode[] rootIds: AnyNodeId[] } type PasteResult = { pastedIds: AnyNodeId[] skippedIds: AnyNodeId[] } const COPYABLE_ROOT_TYPES = new Set([ 'wall', 'fence', 'column', 'item', 'slab', 'ceiling', 'roof', 'stair', 'spawn', 'zone', ]) let clipboardPayload: ClipboardPayload | null = null const subscribers = new Set<() => void>() function notifySubscribers() { for (const subscriber of subscribers) { subscriber() } } export function subscribeEditorClipboard(subscriber: () => void) { subscribers.add(subscriber) return () => { subscribers.delete(subscriber) } } export function getEditorClipboardSnapshot() { return clipboardPayload } export function hasEditorClipboard() { return !!clipboardPayload && clipboardPayload.rootIds.length > 0 } function extractIdPrefix(id: string) { const underscoreIndex = id.indexOf('_') return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex) } function collectSubtreeIds( nodes: Record, rootId: AnyNodeId, ids: Set, ) { if (ids.has(rootId)) return const node = nodes[rootId] if (!node) return ids.add(rootId) if ('children' in node && Array.isArray(node.children)) { for (const childId of node.children as AnyNodeId[]) { collectSubtreeIds(nodes, childId, ids) } } } function hasSelectedAncestor( nodes: Record, id: AnyNodeId, selectedIds: Set, ) { let parentId = nodes[id]?.parentId as AnyNodeId | null while (parentId) { if (selectedIds.has(parentId)) return true parentId = nodes[parentId]?.parentId as AnyNodeId | null } return false } function isLevelChildRoot(nodes: Record, node: AnyNode) { const parentId = node.parentId as AnyNodeId | null if (!parentId) return true return nodes[parentId]?.type === 'level' } function getPasteTargetLevel(targetLevelId?: AnyNodeId) { const scene = useScene.getState() const resolvedLevelId = targetLevelId ?? (useViewer.getState().selection.levelId as AnyNodeId | null) if (!resolvedLevelId) return null const level = scene.nodes[resolvedLevelId] return level?.type === 'level' ? level : null } function getNextLevelId(level: LevelNode, nodes: Record) { const parentId = level.parentId as AnyNodeId | null if (!parentId) return null const building = nodes[parentId] if (building?.type !== 'building') return null const siblingLevels = building.children .map((childId) => nodes[childId as AnyNodeId]) .filter((node): node is LevelNode => node?.type === 'level') return ( siblingLevels .filter((candidate) => candidate.level > level.level) .sort((a, b) => a.level - b.level)[0]?.id ?? null ) } function remapNodeReferences( node: AnyNode, oldId: AnyNodeId, targetLevel: LevelNode, idMap: Map, rootIds: Set, nodes: Record, ) { const clone = JSON.parse(JSON.stringify(node)) as AnyNode ;(clone as Record).id = idMap.get(oldId) if (rootIds.has(oldId)) { clone.parentId = targetLevel.id } else if (clone.parentId && typeof clone.parentId === 'string') { clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId } if ('children' in clone && Array.isArray(clone.children)) { ;(clone as Record).children = (clone.children as AnyNodeId[]) .map((childId) => idMap.get(childId)) .filter((childId): childId is AnyNodeId => !!childId) } if ('wallId' in clone && typeof clone.wallId === 'string') { const nextWallId = idMap.get(clone.wallId as AnyNodeId) if (nextWallId) { ;(clone as Record).wallId = nextWallId } else { delete (clone as Record).wallId } } if (clone.type === 'stair') { const nextLevelId = getNextLevelId(targetLevel, nodes) ;(clone as StairNode).fromLevelId = targetLevel.id ;(clone as StairNode).toLevelId = nextLevelId } const metadata = clone.metadata && typeof clone.metadata === 'object' && !Array.isArray(clone.metadata) ? { ...(clone.metadata as Record) } : {} delete metadata.isNew delete metadata.isTransient ;(clone as Record).metadata = metadata return AnyNode.parse(clone) } function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null { const scene = useScene.getState() const selectedIdSet = new Set(ids) const rootIds = ids.filter((id) => { const node = scene.nodes[id] return ( node && COPYABLE_ROOT_TYPES.has(node.type) && isLevelChildRoot(scene.nodes, node) && !hasSelectedAncestor(scene.nodes, id, selectedIdSet) ) }) if (rootIds.length === 0) { return null } const subtreeIds = new Set() for (const rootId of rootIds) { collectSubtreeIds(scene.nodes, rootId, subtreeIds) } return { copiedAt: Date.now(), nodes: [...subtreeIds] .map((id) => scene.nodes[id]) .filter((node): node is AnyNode => !!node) .map((node) => JSON.parse(JSON.stringify(node)) as AnyNode), rootIds, } } export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) { const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[]) const payload = buildClipboardPayload(ids) if (!payload) return false clipboardPayload = payload notifySubscribers() return true } /** * Clone the given nodes (subtrees included, ids remapped) onto the target / * active level in place — the same copy + paste pipeline in one step, WITHOUT * touching the user's editor clipboard. Selects the clones. Used by the group * action menu's Duplicate. */ export function duplicateNodesToLevel( ids: AnyNodeId[], targetLevelId?: AnyNodeId, ): PasteResult | null { const payload = buildClipboardPayload(ids) if (!payload) return null return applyClipboardPayloadToLevel(payload, targetLevelId) } export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteResult | null { if (!clipboardPayload) return null return applyClipboardPayloadToLevel(clipboardPayload, targetLevelId) } function applyClipboardPayloadToLevel( payload: ClipboardPayload, targetLevelId?: AnyNodeId, ): PasteResult | null { const targetLevel = getPasteTargetLevel(targetLevelId) if (!targetLevel) return null const scene = useScene.getState() const idMap = new Map() for (const node of payload.nodes) { idMap.set(node.id as AnyNodeId, generateId(extractIdPrefix(node.id)) as AnyNodeId) } const rootIdSet = new Set(payload.rootIds) const pastedNodes: AnyNode[] = [] const skippedIds: AnyNodeId[] = [] for (const node of payload.nodes) { try { pastedNodes.push( remapNodeReferences(node, node.id as AnyNodeId, targetLevel, idMap, rootIdSet, scene.nodes), ) } catch (error) { console.error('Failed to paste copied node', node.id, error) skippedIds.push(node.id as AnyNodeId) } } if (pastedNodes.length === 0) { return { pastedIds: [], skippedIds } } scene.createNodes( pastedNodes.map((node) => ({ node, parentId: (node.parentId as AnyNodeId | null) ?? undefined, })), ) const pastedNodeIds = new Set(pastedNodes.map((node) => node.id as AnyNodeId)) const pastedRootIds = payload.rootIds .map((rootId) => idMap.get(rootId)) .filter((id): id is AnyNodeId => !!id && pastedNodeIds.has(id)) useViewer.getState().setSelection({ levelId: targetLevel.id, selectedIds: pastedRootIds, }) return { pastedIds: pastedRootIds, skippedIds, } }