Files
editor/apps/editor/components/tools/item/use-draft-node.ts
T
2026-02-17 12:29:00 +09:00

206 lines
7.1 KiB
TypeScript

import { type AnyNodeId, type AssetInput, ItemNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useMemo, useRef } from 'react'
import type { Vector3 } from 'three'
import { stripTransient } from './placement-math'
interface OriginalState {
position: [number, number, number]
rotation: [number, number, number]
side: ItemNode['side']
parentId: string | null
metadata: ItemNode['metadata']
}
export interface DraftNodeHandle {
/** Current draft item, or null */
readonly current: ItemNode | null
/** Whether the current draft was adopted (move mode) vs created (create mode) */
readonly isAdopted: boolean
/** Create a new draft item at the given position. Returns the created node or null. */
create: (gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number]) => ItemNode | null
/** Take ownership of an existing scene node as the draft (for move mode). */
adopt: (node: ItemNode) => void
/** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */
commit: (finalUpdate: Partial<ItemNode>) => string | null
/** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */
destroy: () => void
}
/**
* Hook that manages the lifecycle of a transient (draft) item node.
* Handles temporal pause/resume for undo/redo isolation.
*
* Supports two modes:
* - Create mode (via `create()`): draft is a new transient node. Commit = delete+recreate (undo removes node).
* - Move mode (via `adopt()`): draft is an existing node. Commit = update in place (undo reverts position).
*/
export function useDraftNode(): DraftNodeHandle {
const draftRef = useRef<ItemNode | null>(null)
const adoptedRef = useRef(false)
const originalStateRef = useRef<OriginalState | null>(null)
const create = useCallback((gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number]): ItemNode | null => {
const currentLevelId = useViewer.getState().selection.levelId
if (!currentLevelId) return null
const node = ItemNode.parse({
position: [gridPosition.x, gridPosition.y, gridPosition.z],
rotation: rotation ?? [0, 0, 0],
name: asset.name,
asset,
parentId: currentLevelId,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, currentLevelId)
draftRef.current = node
adoptedRef.current = false
originalStateRef.current = null
return node
}, [])
const adopt = useCallback((node: ItemNode): void => {
// Save original state so destroy() can restore it
const meta = (typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata))
? node.metadata as Record<string, unknown>
: {}
originalStateRef.current = {
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
side: node.side,
parentId: node.parentId,
metadata: node.metadata,
}
draftRef.current = node
adoptedRef.current = true
// Mark as transient so it renders as a draft
useScene.getState().updateNode(node.id, {
metadata: { ...meta, isTransient: true },
})
}, [])
const commit = useCallback((finalUpdate: Partial<ItemNode>): string | null => {
const draft = draftRef.current
if (!draft) return null
if (adoptedRef.current) {
// Move mode: update in place (single undoable action)
const { parentId: newParentId, ...updateProps } = finalUpdate
const parentId = newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId
const original = originalStateRef.current!
// Restore original state while paused — so the undo baseline is clean
useScene.getState().updateNode(draft.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
metadata: original.metadata,
})
// Resume → tracked update (undo reverts to original)
useScene.temporal.getState().resume()
useScene.getState().updateNode(draft.id, {
position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation,
side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
parentId: parentId as string,
})
useScene.temporal.getState().pause()
const id = draft.id
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
return id
}
// Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause
const { parentId: newParentId, ...updateProps } = finalUpdate
const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
if (!parentId) return null
// Delete draft while paused (invisible to undo)
useScene.getState().deleteNode(draft.id)
draftRef.current = null
// Briefly resume → create fresh node (the single undoable action)
useScene.temporal.getState().resume()
const finalNode = ItemNode.parse({
name: draft.name,
asset: draft.asset,
position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation,
side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
})
useScene.getState().createNode(finalNode, parentId)
// Re-pause for next draft cycle
useScene.temporal.getState().pause()
adoptedRef.current = false
originalStateRef.current = null
return finalNode.id
}, [])
const destroy = useCallback(() => {
if (!draftRef.current) return
if (adoptedRef.current && originalStateRef.current) {
// Move mode: restore original state instead of deleting
const original = originalStateRef.current
const id = draftRef.current.id
useScene.getState().updateNode(id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
metadata: original.metadata,
})
// Also reset the Three.js mesh directly — the store update triggers a React
// re-render but the mesh position was mutated by useFrame and may not reset
// until the next render cycle, leaving a visual glitch.
const mesh = sceneRegistry.nodes.get(id as AnyNodeId)
if (mesh) {
mesh.position.set(original.position[0], original.position[1], original.position[2])
mesh.rotation.y = original.rotation[1] ?? 0
mesh.visible = true
}
} else {
// Create mode: delete the transient node
useScene.getState().deleteNode(draftRef.current.id)
}
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
}, [])
return useMemo(
() => ({
get current() {
return draftRef.current
},
get isAdopted() {
return adoptedRef.current
},
create,
adopt,
commit,
destroy,
}),
[create, adopt, commit, destroy],
)
}