297 lines
7.8 KiB
TypeScript
297 lines
7.8 KiB
TypeScript
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<AnyNode['type']>([
|
|
'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<AnyNodeId, AnyNode>,
|
|
rootId: AnyNodeId,
|
|
ids: Set<AnyNodeId>,
|
|
) {
|
|
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<AnyNodeId, AnyNode>,
|
|
id: AnyNodeId,
|
|
selectedIds: Set<AnyNodeId>,
|
|
) {
|
|
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<AnyNodeId, AnyNode>, 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<AnyNodeId, AnyNode>) {
|
|
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<AnyNodeId, AnyNodeId>,
|
|
rootIds: Set<AnyNodeId>,
|
|
nodes: Record<AnyNodeId, AnyNode>,
|
|
) {
|
|
const clone = JSON.parse(JSON.stringify(node)) as AnyNode
|
|
;(clone as Record<string, unknown>).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<string, unknown>).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<string, unknown>).wallId = nextWallId
|
|
} else {
|
|
delete (clone as Record<string, unknown>).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<string, unknown>) }
|
|
: {}
|
|
delete metadata.isNew
|
|
delete metadata.isTransient
|
|
;(clone as Record<string, unknown>).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<AnyNodeId>()
|
|
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<AnyNodeId, AnyNodeId>()
|
|
|
|
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,
|
|
}
|
|
}
|