Add furniture edit mode for home room (kind 11127)

Implement the first furniture editing pass for the Blobbi House home room:

- Add edit mode toggle (Move/X button) in home room top-right corner
- Item selection with blue ring highlight on tap
- Long-press (~1.2s) to activate drag mode with animated progress ring
- Mouse and touch drag support with pointer capture
- Wall items constrained to wall plane, floor items to floor plane
- Positions clamped to 0..1000 normalized space with edge padding
- Persist position changes to kind 11127 on drag-end via fetchFreshEvent
- Preserve all sibling rooms, scene data, and unknown keys

New files:
- useRoomItemEditor hook (edit state + persistence)

Extended files:
- house-content: updateRoomItemPosition + patchRoomItem helpers
- item-coordinates: inverse mapping (pixel delta -> normalized delta)
- RoomItemsLayer: edit mode with selection, long-press, drag interaction
- BlobbiHomeRoom: wired edit toggle, banner, and editor callbacks
This commit is contained in:
filemon
2026-04-08 23:38:37 -03:00
parent 418ba446db
commit 8d964d30da
7 changed files with 721 additions and 25 deletions
+147
View File
@@ -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<void>;
/** 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<string | null>(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,
};
}
+3
View File
@@ -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';
+361 -13
View File
@@ -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<HouseItemLayer, HouseItem[]>();
for (const item of items) {
@@ -88,11 +125,18 @@ export function RoomItemsLayer({ items }: RoomItemsLayerProps) {
return (
<div
key={layerId}
className="absolute inset-0 pointer-events-none"
className={`absolute inset-0 ${isEditing ? '' : 'pointer-events-none'}`}
style={{ zIndex: LAYER_Z[layerId] }}
>
{layerItems.map((item) => (
<RoomItem key={item.instanceId} item={item} />
<RoomItem
key={item.instanceId}
item={item}
isEditing={isEditing}
isSelected={edit?.selectedItemId === item.instanceId}
onSelect={edit?.onSelect}
onDragEnd={edit?.onDragEnd}
/>
))}
</div>
);
@@ -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 (
<div
className="absolute inset-0 pointer-events-none"
className={`absolute inset-0 ${isEditing ? '' : 'pointer-events-none'}`}
style={{ zIndex: LAYER_Z.overlay }}
>
{overlayItems.map((item) => (
<RoomItem key={item.instanceId} item={item} />
<RoomItem
key={item.instanceId}
item={item}
isEditing={isEditing}
isSelected={edit?.selectedItemId === item.instanceId}
onSelect={edit?.onSelect}
onDragEnd={edit?.onDragEnd}
/>
))}
</div>
);
@@ -144,13 +197,17 @@ export function RoomItemsLayer({ items }: RoomItemsLayerProps) {
function FloorItemLayer({
layerId,
items,
isEditing,
edit,
}: {
layerId: HouseItemLayer;
items: HouseItem[];
isEditing: boolean;
edit?: RoomItemsEditCallbacks;
}) {
return (
<div
className="absolute inset-x-0 pointer-events-none"
className={`absolute inset-x-0 ${isEditing ? '' : 'pointer-events-none'}`}
style={{
top: `${WALL_PERCENT}%`,
height: `${FLOOR_PERCENT}%`,
@@ -168,16 +225,39 @@ function FloorItemLayer({
}}
>
{items.map((item) => (
<RoomItem key={item.instanceId} item={item} />
<RoomItem
key={item.instanceId}
item={item}
isEditing={isEditing}
isSelected={edit?.selectedItemId === item.instanceId}
onSelect={edit?.onSelect}
onDragEnd={edit?.onDragEnd}
/>
))}
</div>
</div>
);
}
// ─── 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 (
<div
className="absolute"
style={{
left: pos.left,
top: pos.top,
width: size.width,
height: size.height,
transform: transforms.join(' '),
}}
data-item-id={item.instanceId}
>
{item.kind === 'builtin' && <BuiltinItemVisual id={item.id} />}
</div>
);
}
// ── Edit mode: interactive item ──
return (
<EditableRoomItem
item={item}
pos={pos}
size={size}
transforms={transforms}
isSelected={isSelected || false}
onSelect={onSelect}
onDragEnd={onDragEnd}
/>
);
}
// ─── 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<HTMLDivElement>(null);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
const [holdProgress, setHoldProgress] = useState(0); // 0..1
const holdAnimRef = useRef<number | null>(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 (
<div
className="absolute"
ref={elRef}
className={`absolute cursor-grab touch-none select-none transition-shadow duration-150 ${selectionRing} ${isDragging ? 'cursor-grabbing z-50 opacity-90' : ''}`}
style={{
left: pos.left,
top: pos.top,
width: size.width,
height: size.height,
transform: transforms.join(' '),
transform: [dragTranslate, ...transforms].filter(Boolean).join(' '),
willChange: isDragging ? 'transform' : undefined,
...holdGlow,
}}
data-item-id={item.instanceId}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
{item.kind === 'builtin' && (
<BuiltinItemVisual id={item.id} />
{item.kind === 'builtin' && <BuiltinItemVisual id={item.id} />}
{/* Hold progress ring (animated ring that fills over LONG_PRESS_MS) */}
{holdProgress > 0 && !isDragging && (
<div className="absolute inset-0 pointer-events-none">
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 100 100">
<rect
x="2"
y="2"
width="96"
height="96"
rx="8"
fill="none"
stroke="rgba(96, 165, 250, 0.5)"
strokeWidth="2"
strokeDasharray={`${holdProgress * 384} 384`}
strokeLinecap="round"
className="transition-none"
/>
</svg>
</div>
)}
</div>
);
+4 -1
View File
@@ -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';
@@ -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)),
};
}
+88
View File
@@ -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<Omit<HouseItem, 'id' | 'instanceId'>>,
): 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,
});
}
+55 -11
View File
@@ -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) {
<RoomSceneLayer scene={roomScene} />
{/* ── Room Items — layered around Blobbi (z 1-8, hero at 5) ── */}
<RoomItemsLayer items={homeItems} />
<RoomItemsLayer
items={homeItems}
editMode={itemEditor.editMode}
edit={{
selectedItemId: itemEditor.selectedItemId,
onSelect: itemEditor.selectItem,
onDragEnd: handleItemDragEnd,
isSaving: itemEditor.isSaving,
}}
/>
{/* ── Decor button (top-right, above room content) ── */}
<button
onClick={() => setShowCustomize(true)}
className="absolute top-2 right-2 z-30 size-8 flex items-center justify-center rounded-full bg-background/50 backdrop-blur-sm text-foreground/60 hover:text-foreground/90 hover:bg-background/70 transition-all shadow-sm"
aria-label="Customize room"
>
<Paintbrush className="size-3.5" />
</button>
{/* ── Top-right action buttons ── */}
<div className="absolute top-2 right-2 z-30 flex items-center gap-1.5">
{/* Furniture edit toggle */}
<button
onClick={() => itemEditor.setEditMode(!itemEditor.editMode)}
className={`size-8 flex items-center justify-center rounded-full backdrop-blur-sm transition-all shadow-sm ${
itemEditor.editMode
? 'bg-blue-500/80 text-white hover:bg-blue-500/90'
: 'bg-background/50 text-foreground/60 hover:text-foreground/90 hover:bg-background/70'
}`}
aria-label={itemEditor.editMode ? 'Exit furniture edit mode' : 'Edit furniture'}
>
{itemEditor.editMode ? <X className="size-3.5" /> : <Move className="size-3.5" />}
</button>
{/* Decor (scene customize) button — hide during furniture edit */}
{!itemEditor.editMode && (
<button
onClick={() => setShowCustomize(true)}
className="size-8 flex items-center justify-center rounded-full bg-background/50 backdrop-blur-sm text-foreground/60 hover:text-foreground/90 hover:bg-background/70 transition-all shadow-sm"
aria-label="Customize room"
>
<Paintbrush className="size-3.5" />
</button>
)}
</div>
{/* ── Edit mode banner ── */}
{itemEditor.editMode && (
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-30 px-3 py-1 rounded-full bg-blue-500/80 backdrop-blur-sm text-white text-xs font-medium shadow-lg flex items-center gap-1.5">
<Move className="size-3" />
{itemEditor.isSaving ? 'Saving...' : 'Hold item to move'}
</div>
)}
{/* ── Hero (Blobbi + stats) — z-index 5, between backFloor and frontFloor ── */}
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0 z-[5]" />