shelf: v2 — cubby default, withBottom, item hosting, paintable surface
Schema v2 adds style/rows/columns/withBack/withSides/withBottom/bracketStyle and a `children: ItemNode[]` field for item hosting. Schema-level defaults preserve the v1 wall-shelf visual so existing scenes load unchanged; the placement tool spreads `shelfDefinition.defaults()` for fresh shelves (cubby 3x2 at 1m × 0.5m × 1.8m, thickness 0.05m, back/sides/bottom on). Four style geometries (wall-shelf / bookshelf / open-rack / cubby) share the dimensional schema. `shelfRowSurfaceYs` exposes one host surface per row, plus the bottom-board top when `withBottom` is on for cubby / bookshelf. Material is a single paintable surface (same shape walls / slabs / stairs use); `DEFAULT_SHELF_MATERIAL` aligned with `DEFAULT_WALL_MATERIAL` so unpainted shelves read as the canonical off-white. Preview clones each cached material before mutating `transparent / opacity` on the ghost — without the clone the mutation leaked into the cached `getShelfMaterial` instance every committed shelf was using, rendering them all see-through after the first placement preview rendered. Store hardening: `migrateNodes` patches missing `children: []` on v1 shelves, and `updateNodesAction` reparenting tolerates a missing children array on the new parent. `MaterialTarget` enum adds `'shelf'` so paint mode picks up the kind. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
56e022a093
commit
924567293a
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { type ShelfNode, useScene } from '@pascal-app/core'
|
||||
import { type AnyNodeId, type ShelfNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Layers } from 'lucide-react'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface ShelfTreeNodeProps {
|
||||
@@ -16,10 +17,11 @@ interface ShelfTreeNodeProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar tree entry for shelf. Mirrors spawn-tree-node's shape so the
|
||||
* existing tree-node-wrapper / selection / hover / rename plumbing all work
|
||||
* unchanged. Phase 4 derives this row generically from
|
||||
* `definition.presentation` — until then, one file per kind.
|
||||
* Sidebar tree entry for shelf. Mirrors `item-tree-node`'s shape so the
|
||||
* shelf's hosted items list as collapsible children — same pattern items
|
||||
* use for their nested items. The shelf has its own `children: ItemNode[`id`]`
|
||||
* field on the schema; items reparent into it via `def.surfaces` + the
|
||||
* placement coordinator's shelf strategy.
|
||||
*/
|
||||
export const ShelfTreeNode = memo(function ShelfTreeNode({
|
||||
nodeId,
|
||||
@@ -27,12 +29,37 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
|
||||
isLast,
|
||||
}: ShelfTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
const children = useScene(
|
||||
useShallow((s) => (s.nodes[nodeId] as ShelfNode | undefined)?.children ?? []),
|
||||
)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
// Expand when a descendant is selected — same imperative subscription
|
||||
// the item tree-node uses, so we don't re-render when unrelated
|
||||
// selection-state ticks.
|
||||
useEffect(() => {
|
||||
return useViewer.subscribe((state) => {
|
||||
const { selectedIds } = state.selection
|
||||
if (selectedIds.length === 0) return
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId]
|
||||
while (current?.parentId) {
|
||||
if (current.parentId === nodeId) {
|
||||
setExpanded(true)
|
||||
return
|
||||
}
|
||||
current = nodes[current.parentId as AnyNodeId]
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [nodeId])
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -49,13 +76,24 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
|
||||
[nodeId, setSelection],
|
||||
)
|
||||
|
||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
||||
const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
|
||||
const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
|
||||
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
|
||||
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||
|
||||
const hasChildren = children.length > 0
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={<Layers size={14} />}
|
||||
expanded={expanded}
|
||||
hasChildren={hasChildren}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/shelf.png" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
@@ -65,16 +103,26 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
|
||||
defaultName="Shelf"
|
||||
isEditing={isEditing}
|
||||
nodeId={nodeId}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||
onMouseEnter={() => setHoveredId(nodeId)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{hasChildren &&
|
||||
children.map((childId, index) => (
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
isLast={index === children.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user