diff --git a/src/blobbi/house/hooks/useRoomItemEditor.ts b/src/blobbi/house/hooks/useRoomItemEditor.ts new file mode 100644 index 00000000..9b883161 --- /dev/null +++ b/src/blobbi/house/hooks/useRoomItemEditor.ts @@ -0,0 +1,147 @@ +// src/blobbi/house/hooks/useRoomItemEditor.ts + +/** + * useRoomItemEditor — Manages furniture edit mode and persists item + * position changes to kind 11127. + * + * ── Responsibilities ───────────────────────────────────────────────── + * + * 1. Toggle edit mode on/off + * 2. Track the currently selected item (instanceId) + * 3. Persist position changes via fetchFreshEvent + updateRoomItemPosition + * 4. Optimistic cache update via updateHouseEvent + * + * ── Design constraints ─────────────────────────────────────────────── + * + * - Only one item selected at a time (no multi-select) + * - Publish happens once on drag-end, not on every movement + * - Uses the same fetchFreshEvent → patch → publish → optimistic + * cache pattern as useRoomSceneEditor + * - The hook is intentionally room-agnostic (receives roomId), + * but for now is only used in the home room + */ + +import { useCallback, useState } from 'react'; +import { useNostr } from '@nostrify/react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; +import { toast } from '@/hooks/useToast'; +import { + KIND_BLOBBI_HOUSE, + buildHouseDTag, + buildHouseTags, +} from '../lib/house-constants'; +import { updateRoomItemPosition } from '../lib/house-content'; +import type { HouseItemPosition } from '../lib/house-types'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface UseRoomItemEditorResult { + /** Whether edit mode is active. */ + editMode: boolean; + /** Toggle edit mode. Clears selection when exiting. */ + setEditMode: (on: boolean) => void; + /** The instanceId of the selected item, or null. */ + selectedItemId: string | null; + /** Select an item by instanceId. Pass null to deselect. */ + selectItem: (instanceId: string | null) => void; + /** Persist a new position for an item. Called on drag-end. */ + commitPosition: (instanceId: string, position: HouseItemPosition) => Promise; + /** Whether a save/publish is in flight. */ + isSaving: boolean; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export function useRoomItemEditor( + roomId: string, + houseEvent: NostrEvent | null, + updateHouseEvent: (event: NostrEvent) => void, +): UseRoomItemEditorResult { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { mutateAsync: publishEvent } = useNostrPublish(); + + const [editMode, setEditModeRaw] = useState(false); + const [selectedItemId, setSelectedItemId] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + // ── Toggle edit mode ── + const setEditMode = useCallback((on: boolean) => { + setEditModeRaw(on); + if (!on) { + setSelectedItemId(null); + } + }, []); + + // ── Select an item ── + const selectItem = useCallback((instanceId: string | null) => { + setSelectedItemId(instanceId); + }, []); + + // ── Persist position ── + const commitPosition = useCallback(async ( + instanceId: string, + position: HouseItemPosition, + ) => { + if (!user?.pubkey) return; + + setIsSaving(true); + try { + // Fetch fresh event for safe read-modify-write + const prev = await fetchFreshEvent(nostr, { + kinds: [KIND_BLOBBI_HOUSE], + authors: [user.pubkey], + '#d': [buildHouseDTag(user.pubkey)], + }); + + const existingContent = prev?.content ?? houseEvent?.content ?? ''; + const existingTags = prev?.tags ?? buildHouseTags(user.pubkey); + + // Patch the single item's position in house content + const updatedContent = updateRoomItemPosition( + existingContent, + roomId, + instanceId, + position, + ); + + // If nothing changed (item or room not found), bail + if (updatedContent === existingContent) return; + + // Publish to kind 11127 + const event = await publishEvent({ + kind: KIND_BLOBBI_HOUSE, + content: updatedContent, + tags: existingTags, + prev: prev ?? undefined, + }); + + // Optimistic cache update + updateHouseEvent(event); + } catch (err) { + if (import.meta.env.DEV) { + console.error('[useRoomItemEditor] Failed to save item position:', err); + } + toast({ + title: 'Failed to save', + description: 'Item position could not be saved. Please try again.', + variant: 'destructive', + }); + } finally { + setIsSaving(false); + } + }, [user?.pubkey, nostr, publishEvent, updateHouseEvent, roomId, houseEvent?.content]); + + return { + editMode, + setEditMode, + selectedItemId, + selectItem, + commitPosition, + isSaving, + }; +} diff --git a/src/blobbi/house/index.ts b/src/blobbi/house/index.ts index 1b846596..33db5ab8 100644 --- a/src/blobbi/house/index.ts +++ b/src/blobbi/house/index.ts @@ -41,6 +41,8 @@ export { patchHouseRoomScene, resetHouseRoomScene, getRoomSceneFromHouse, + updateRoomItemPosition, + patchRoomItem, } from './lib/house-content'; // ── Migration ── @@ -52,3 +54,4 @@ export { // ── Hooks ── export { useBlobbiHouse, type UseBlobbiHouseResult } from './hooks/useBlobbiHouse'; +export { useRoomItemEditor, type UseRoomItemEditorResult } from './hooks/useRoomItemEditor'; diff --git a/src/blobbi/house/items/RoomItemsLayer.tsx b/src/blobbi/house/items/RoomItemsLayer.tsx index 70278ab7..f1de850c 100644 --- a/src/blobbi/house/items/RoomItemsLayer.tsx +++ b/src/blobbi/house/items/RoomItemsLayer.tsx @@ -25,9 +25,22 @@ * z 5: (Blobbi hero — not rendered here) * z 6: frontFloor — floor in front of Blobbi (plants, tables) * z 8: overlay — floating above everything + * + * ── Edit mode ──────────────────────────────────────────────────────── + * + * When `editMode` is true and editor callbacks are provided, items + * become interactive: + * - Tap to select + * - Long-press (~2s) + drag to move + * - Position changes are reported via onDragEnd + * - Selected items get a visual highlight ring + * + * Outside edit mode, items are fully passive (pointer-events-none). */ -import type { HouseItem, HouseItemLayer } from '../lib/house-types'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { HouseItem, HouseItemLayer, HouseItemPlane, HouseItemPosition } from '../lib/house-types'; import { WALL_PERCENT, FLOOR_PERSPECTIVE, @@ -36,6 +49,11 @@ import { } from '@/blobbi/rooms/scene/components/RoomSceneLayer'; import { getCatalogItem } from './item-catalog'; import { toScreenPosition, toScreenSize } from './item-coordinates'; +import { + wallPixelDeltaToNormalized, + floorPixelDeltaToNormalized, + clampNormalized, +} from './item-coordinates'; import { BuiltinItemVisual } from './BuiltinItemVisual'; // ─── Layer Configuration ────────────────────────────────────────────────────── @@ -57,18 +75,37 @@ const WALL_LAYERS: HouseItemLayer[] = ['wallBack', 'wallDecor']; /** Floor layers: rendered inside a perspective-transformed container. */ const FLOOR_LAYERS: HouseItemLayer[] = ['backFloor', 'frontFloor']; +// ─── Edit Mode Types ────────────────────────────────────────────────────────── + +export interface RoomItemsEditCallbacks { + /** The instanceId of the selected item, or null. */ + selectedItemId: string | null; + /** Called when the user taps an item to select it. */ + onSelect: (instanceId: string) => void; + /** Called when a drag finishes with the new normalized position. */ + onDragEnd: (instanceId: string, position: HouseItemPosition) => void; + /** Whether a position save is in flight (dims the UI). */ + isSaving: boolean; +} + // ─── Props ──────────────────────────────────────────────────────────────────── interface RoomItemsLayerProps { /** The items to render (from house.layout.rooms[roomId].items). */ items: HouseItem[]; + /** If true + edit callbacks provided, items become interactive. */ + editMode?: boolean; + /** Edit-mode callbacks. Required when editMode is true. */ + edit?: RoomItemsEditCallbacks; } // ─── Component ──────────────────────────────────────────────────────────────── -export function RoomItemsLayer({ items }: RoomItemsLayerProps) { +export function RoomItemsLayer({ items, editMode = false, edit }: RoomItemsLayerProps) { if (items.length === 0) return null; + const isEditing = editMode && !!edit; + // Group visible items by layer const byLayer = new Map(); for (const item of items) { @@ -88,11 +125,18 @@ export function RoomItemsLayer({ items }: RoomItemsLayerProps) { return (
{layerItems.map((item) => ( - + ))}
); @@ -107,6 +151,8 @@ export function RoomItemsLayer({ items }: RoomItemsLayerProps) { key={layerId} layerId={layerId} items={layerItems} + isEditing={isEditing} + edit={edit} /> ); })} @@ -117,11 +163,18 @@ export function RoomItemsLayer({ items }: RoomItemsLayerProps) { if (!overlayItems || overlayItems.length === 0) return null; return (
{overlayItems.map((item) => ( - + ))}
); @@ -144,13 +197,17 @@ export function RoomItemsLayer({ items }: RoomItemsLayerProps) { function FloorItemLayer({ layerId, items, + isEditing, + edit, }: { layerId: HouseItemLayer; items: HouseItem[]; + isEditing: boolean; + edit?: RoomItemsEditCallbacks; }) { return (
{items.map((item) => ( - + ))}
); } +// ─── Long-Press Threshold ───────────────────────────────────────────────────── + +/** How long (ms) the user must hold before drag activates. */ +const LONG_PRESS_MS = 1200; + +/** Max px movement during hold allowed before cancelling the hold. */ +const HOLD_MOVE_TOLERANCE = 8; + // ─── Single Item Renderer ───────────────────────────────────────────────────── -function RoomItem({ item }: { item: HouseItem }) { +interface RoomItemProps { + item: HouseItem; + isEditing: boolean; + isSelected?: boolean; + onSelect?: (instanceId: string) => void; + onDragEnd?: (instanceId: string, position: HouseItemPosition) => void; +} + +function RoomItem({ item, isEditing, isSelected, onSelect, onDragEnd }: RoomItemProps) { const catalog = getCatalogItem(item.id); if (!catalog) return null; @@ -188,20 +268,288 @@ function RoomItem({ item }: { item: HouseItem }) { if (item.scale !== 1) transforms.push(`scale(${item.scale})`); if (item.rotation !== 0) transforms.push(`rotate(${item.rotation}deg)`); + // ── Non-edit mode: passive rendering ── + if (!isEditing) { + return ( +
+ {item.kind === 'builtin' && } +
+ ); + } + + // ── Edit mode: interactive item ── + return ( + + ); +} + +// ─── Editable Room Item (interactive) ───────────────────────────────────────── + +interface EditableRoomItemProps { + item: HouseItem; + pos: { left: string; top: string }; + size: { width: string; height: string }; + transforms: string[]; + isSelected: boolean; + onSelect?: (instanceId: string) => void; + onDragEnd?: (instanceId: string, position: HouseItemPosition) => void; +} + +function EditableRoomItem({ + item, + pos, + size, + transforms, + isSelected, + onSelect, + onDragEnd, +}: EditableRoomItemProps) { + const elRef = useRef(null); + const longPressTimerRef = useRef | null>(null); + const pointerStartRef = useRef<{ x: number; y: number } | null>(null); + const [holdProgress, setHoldProgress] = useState(0); // 0..1 + const holdAnimRef = useRef(null); + + // ── Drag state ── + const [isDragging, setIsDragging] = useState(false); + const [dragOffset, setDragOffset] = useState({ dx: 0, dy: 0 }); + const dragStartPointerRef = useRef<{ x: number; y: number } | null>(null); + const isDraggingRef = useRef(false); + + // Clean up timers on unmount + useEffect(() => { + return () => { + if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current); + if (holdAnimRef.current) cancelAnimationFrame(holdAnimRef.current); + }; + }, []); + + // ── Hold progress animation ── + const startHoldAnimation = useCallback(() => { + const startTime = performance.now(); + const animate = () => { + const elapsed = performance.now() - startTime; + const progress = Math.min(elapsed / LONG_PRESS_MS, 1); + setHoldProgress(progress); + if (progress < 1) { + holdAnimRef.current = requestAnimationFrame(animate); + } + }; + holdAnimRef.current = requestAnimationFrame(animate); + }, []); + + const stopHoldAnimation = useCallback(() => { + if (holdAnimRef.current) { + cancelAnimationFrame(holdAnimRef.current); + holdAnimRef.current = null; + } + setHoldProgress(0); + }, []); + + // ── Resolve the reference container for coordinate conversion ── + const getContainerRef = useCallback((): HTMLElement | null => { + const el = elRef.current; + if (!el) return null; + + if (item.plane === 'wall') { + // Wall items: the layer div is positioned over the full room viewport. + // The room viewport is the parent with position:relative (the flex container). + // Walk up: item → layer div → room container + return el.parentElement?.parentElement ?? null; + } + + // Floor items: positioned inside the tilted inner div. + // Walk up: item → tilted inner div + return el.parentElement ?? null; + }, [item.plane]); + + // ── Convert a pixel delta to normalized delta ── + const pixelDeltaToNormalized = useCallback((dxPx: number, dyPx: number): { dx: number; dy: number } => { + const container = getContainerRef(); + if (!container) return { dx: 0, dy: 0 }; + + const rect = container.getBoundingClientRect(); + + if (item.plane === 'wall') { + return wallPixelDeltaToNormalized(dxPx, dyPx, rect.width, rect.height); + } + return floorPixelDeltaToNormalized(dxPx, dyPx, rect.width, rect.height); + }, [item.plane, getContainerRef]); + + // ── Cancel long-press ── + const cancelLongPress = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + pointerStartRef.current = null; + stopHoldAnimation(); + }, [stopHoldAnimation]); + + // ── Pointer down: start long-press timer ── + const handlePointerDown = useCallback((e: React.PointerEvent) => { + // Only primary button + if (e.button !== 0) return; + + e.stopPropagation(); + + // Select immediately on pointer down + onSelect?.(item.instanceId); + + pointerStartRef.current = { x: e.clientX, y: e.clientY }; + startHoldAnimation(); + + // Start long-press timer + longPressTimerRef.current = setTimeout(() => { + longPressTimerRef.current = null; + // Activate drag mode + setIsDragging(true); + isDraggingRef.current = true; + dragStartPointerRef.current = { x: e.clientX, y: e.clientY }; + setDragOffset({ dx: 0, dy: 0 }); + + // Capture pointer for drag tracking + elRef.current?.setPointerCapture(e.pointerId); + }, LONG_PRESS_MS); + }, [item.instanceId, onSelect, startHoldAnimation]); + + // ── Pointer move: cancel hold if moved too far, or track drag ── + const handlePointerMove = useCallback((e: React.PointerEvent) => { + // During long-press hold: check if user moved too far + if (pointerStartRef.current && !isDraggingRef.current) { + const dx = e.clientX - pointerStartRef.current.x; + const dy = e.clientY - pointerStartRef.current.y; + if (Math.abs(dx) > HOLD_MOVE_TOLERANCE || Math.abs(dy) > HOLD_MOVE_TOLERANCE) { + cancelLongPress(); + } + return; + } + + // During drag: track movement + if (isDraggingRef.current && dragStartPointerRef.current) { + e.preventDefault(); + const dxPx = e.clientX - dragStartPointerRef.current.x; + const dyPx = e.clientY - dragStartPointerRef.current.y; + setDragOffset({ dx: dxPx, dy: dyPx }); + } + }, [cancelLongPress]); + + // ── Pointer up: commit drag or just finish ── + const handlePointerUp = useCallback((e: React.PointerEvent) => { + cancelLongPress(); + + if (isDraggingRef.current && dragStartPointerRef.current) { + const dxPx = e.clientX - dragStartPointerRef.current.x; + const dyPx = e.clientY - dragStartPointerRef.current.y; + + // Convert pixel delta to normalized delta + const normDelta = pixelDeltaToNormalized(dxPx, dyPx); + + // Compute new normalized position + const newPos = clampNormalized( + item.position.x + normDelta.dx, + item.position.y + normDelta.dy, + 20, // 20-unit padding from edges + ); + + // Report to parent + onDragEnd?.(item.instanceId, newPos); + + // Release capture + try { elRef.current?.releasePointerCapture(e.pointerId); } catch { /* ok */ } + } + + // Reset drag state + setIsDragging(false); + isDraggingRef.current = false; + dragStartPointerRef.current = null; + setDragOffset({ dx: 0, dy: 0 }); + }, [cancelLongPress, pixelDeltaToNormalized, item.position, item.instanceId, onDragEnd]); + + // ── Pointer cancel (e.g. scroll gesture takes over) ── + const handlePointerCancel = useCallback(() => { + cancelLongPress(); + setIsDragging(false); + isDraggingRef.current = false; + dragStartPointerRef.current = null; + setDragOffset({ dx: 0, dy: 0 }); + }, [cancelLongPress]); + + // ── Build transform with drag offset ── + const dragTranslate = isDragging + ? `translate(${dragOffset.dx}px, ${dragOffset.dy}px)` + : ''; + + // Selection ring + hold progress indicator + const selectionRing = isSelected + ? 'ring-2 ring-blue-400/70 ring-offset-1 ring-offset-transparent rounded-md' + : ''; + + // Hold progress glow — subtle radial pulse while holding + const holdGlow = holdProgress > 0 && !isDragging + ? { + boxShadow: `0 0 ${8 + holdProgress * 16}px ${2 + holdProgress * 6}px rgba(96, 165, 250, ${0.15 + holdProgress * 0.45})`, + } + : {}; + return (
- {item.kind === 'builtin' && ( - + {item.kind === 'builtin' && } + + {/* Hold progress ring (animated ring that fills over LONG_PRESS_MS) */} + {holdProgress > 0 && !isDragging && ( +
+ + + +
)}
); diff --git a/src/blobbi/house/items/index.ts b/src/blobbi/house/items/index.ts index da3bb4c2..e1992db5 100644 --- a/src/blobbi/house/items/index.ts +++ b/src/blobbi/house/items/index.ts @@ -1,6 +1,6 @@ // src/blobbi/house/items/index.ts — barrel export -export { RoomItemsLayer } from './RoomItemsLayer'; +export { RoomItemsLayer, type RoomItemsEditCallbacks } from './RoomItemsLayer'; export { BuiltinItemVisual } from './BuiltinItemVisual'; export { BUILTIN_ITEMS, getCatalogItem, type CatalogItem } from './item-catalog'; export { @@ -8,5 +8,8 @@ export { toWallPosition, toFloorPosition, toScreenSize, + wallPixelDeltaToNormalized, + floorPixelDeltaToNormalized, + clampNormalized, type ScreenPosition, } from './item-coordinates'; diff --git a/src/blobbi/house/items/item-coordinates.ts b/src/blobbi/house/items/item-coordinates.ts index 121ed82d..5f2c976e 100644 --- a/src/blobbi/house/items/item-coordinates.ts +++ b/src/blobbi/house/items/item-coordinates.ts @@ -102,3 +102,66 @@ export function toScreenSize( const hPercent = (height / 1000) * 100; return { width: `${wPercent}%`, height: `${hPercent}%` }; } + +// ─── Screen → Normalized (Inverse Mapping) ──────────────────────────────────── + +/** + * Convert a pixel delta (dx, dy) in the room viewport into a + * normalized-coordinate delta for a wall item. + * + * The caller provides the room container's pixel dimensions. + * Wall items map x across the full width and y across the top WALL_PERCENT%. + */ +export function wallPixelDeltaToNormalized( + dx: number, + dy: number, + containerWidth: number, + containerHeight: number, +): { dx: number; dy: number } { + const wallHeight = containerHeight * (WALL_PERCENT / 100); + return { + dx: (dx / containerWidth) * 1000, + dy: (dy / wallHeight) * 1000, + }; +} + +/** + * Convert a pixel delta (dx, dy) in the **floor container** into a + * normalized-coordinate delta for a floor item. + * + * The caller provides the floor container's pixel dimensions (the actual + * tilted inner div that floor items are positioned inside). Because the + * floor container already has the perspective transform applied, pointer + * events inside it map naturally — no inverse-perspective math is needed. + * + * IMPORTANT: The dx/dy here should come from measuring pointer movement + * relative to the floor container element, NOT the full room viewport. + */ +export function floorPixelDeltaToNormalized( + dx: number, + dy: number, + floorContainerWidth: number, + floorContainerHeight: number, +): { dx: number; dy: number } { + return { + dx: (dx / floorContainerWidth) * 1000, + dy: (dy / floorContainerHeight) * 1000, + }; +} + +/** + * Clamp a normalized position to the valid 0..1000 range with optional padding. + * Padding shrinks the valid area by `pad` units on each side. + */ +export function clampNormalized( + x: number, + y: number, + pad = 0, +): HouseItemPosition { + const min = pad; + const max = 1000 - pad; + return { + x: Math.max(min, Math.min(max, x)), + y: Math.max(min, Math.min(max, y)), + }; +} diff --git a/src/blobbi/house/lib/house-content.ts b/src/blobbi/house/lib/house-content.ts index e0860584..69bf5b05 100644 --- a/src/blobbi/house/lib/house-content.ts +++ b/src/blobbi/house/lib/house-content.ts @@ -296,3 +296,91 @@ export function getRoomSceneFromHouse( const house = parseHouseContent(content); return house?.layout.rooms[roomId]?.scene; } + +// ─── Item Update Helpers ────────────────────────────────────────────────────── + +/** + * Update a single room item's position in the house content. + * + * Safety guarantees: + * 1. All other top-level keys preserved (version, meta, unknown) + * 2. All other rooms preserved + * 3. Scene within the target room preserved + * 4. All other items in the target room preserved + * 5. roomOrder preserved + * 6. Non-position fields on the target item preserved + * + * Returns the updated JSON string, or the input unchanged if the + * room or item was not found. + */ +export function updateRoomItemPosition( + existingContent: string, + roomId: string, + instanceId: string, + position: { x: number; y: number }, +): string { + const { data, house } = safeParseHouse(existingContent); + + const room = house.layout.rooms[roomId]; + if (!room) return existingContent; + + const itemIndex = room.items.findIndex((i) => i.instanceId === instanceId); + if (itemIndex === -1) return existingContent; + + // Clone items array, update position on the target item + const updatedItems = room.items.map((item, i) => + i === itemIndex + ? { ...item, position: { x: Math.round(position.x), y: Math.round(position.y) } } + : item, + ); + + const updatedRoom: HouseRoom = { ...room, items: updatedItems }; + const updatedRooms = { ...house.layout.rooms, [roomId]: updatedRoom }; + const updatedLayout: HouseLayout = { ...house.layout, rooms: updatedRooms }; + + return JSON.stringify({ + ...data, + version: house.version, + meta: house.meta, + layout: updatedLayout, + }); +} + +/** + * Generic room item patch — update any fields on a single item. + * + * This is the future-ready version for rotation, scale, visibility, etc. + * For now it's used internally. The `patch` is a partial HouseItem + * (without id/instanceId which are identity fields). + * + * Same safety guarantees as `updateRoomItemPosition`. + */ +export function patchRoomItem( + existingContent: string, + roomId: string, + instanceId: string, + patch: Partial>, +): string { + const { data, house } = safeParseHouse(existingContent); + + const room = house.layout.rooms[roomId]; + if (!room) return existingContent; + + const itemIndex = room.items.findIndex((i) => i.instanceId === instanceId); + if (itemIndex === -1) return existingContent; + + const updatedItems = room.items.map((item, i) => + i === itemIndex ? { ...item, ...patch } : item, + ); + + const updatedRoom: HouseRoom = { ...room, items: updatedItems }; + const updatedRooms = { ...house.layout.rooms, [roomId]: updatedRoom }; + const updatedLayout: HouseLayout = { ...house.layout, rooms: updatedRooms }; + + return JSON.stringify({ + ...data, + version: house.version, + meta: house.meta, + layout: updatedLayout, + }); +} diff --git a/src/blobbi/rooms/components/BlobbiHomeRoom.tsx b/src/blobbi/rooms/components/BlobbiHomeRoom.tsx index 501a6834..20563a86 100644 --- a/src/blobbi/rooms/components/BlobbiHomeRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiHomeRoom.tsx @@ -12,8 +12,8 @@ * Sleep/wake has been moved to BlobbiRestRoom. */ -import { useMemo, useState } from 'react'; -import { Camera, Footprints, Music, Mic, Paintbrush } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { Camera, Footprints, Music, Mic, Paintbrush, Move, X } from 'lucide-react'; import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; import { InlineMusicPlayer, InlineSingCard } from '@/blobbi/actions'; @@ -29,6 +29,8 @@ import { RoomCustomizeSheet, } from '../scene'; import { RoomItemsLayer } from '@/blobbi/house/items'; +import { useRoomItemEditor } from '@/blobbi/house/hooks/useRoomItemEditor'; +import type { HouseItemPosition } from '@/blobbi/house/lib/house-types'; interface BlobbiHomeRoomProps { ctx: BlobbiRoomContext; @@ -99,6 +101,13 @@ export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) { [house], ); + // ── Furniture Edit Mode ── + const itemEditor = useRoomItemEditor('home', houseEvent, updateHouseEvent); + + const handleItemDragEnd = useCallback((instanceId: string, position: HouseItemPosition) => { + itemEditor.commitPosition(instanceId, position); + }, [itemEditor]); + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; const handleCarouselUse = (id: string) => { @@ -117,16 +126,51 @@ export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) { {/* ── Room Items — layered around Blobbi (z 1-8, hero at 5) ── */} - + - {/* ── Decor button (top-right, above room content) ── */} - + {/* ── Top-right action buttons ── */} +
+ {/* Furniture edit toggle */} + + + {/* Decor (scene customize) button — hide during furniture edit */} + {!itemEditor.editMode && ( + + )} +
+ + {/* ── Edit mode banner ── */} + {itemEditor.editMode && ( +
+ + {itemEditor.isSaving ? 'Saving...' : 'Hold item to move'} +
+ )} {/* ── Hero (Blobbi + stats) — z-index 5, between backFloor and frontFloor ── */}