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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
Reference in New Issue
Block a user