@adea-ai/ui 0.10.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/components.json +19 -0
- package/package.json +56 -0
- package/src/components/account-drawer.tsx +133 -0
- package/src/components/on-screen-controls.tsx +156 -0
- package/src/components/prop-catalog.tsx +583 -0
- package/src/components/scene-settings.tsx +202 -0
- package/src/components/theme-provider.tsx +30 -0
- package/src/components/theme-toggle.tsx +73 -0
- package/src/components/ui/badge.tsx +49 -0
- package/src/components/ui/button.tsx +60 -0
- package/src/components/ui/card.tsx +18 -0
- package/src/components/ui/dialog.tsx +129 -0
- package/src/components/ui/drawer.tsx +209 -0
- package/src/components/ui/dropdown-menu.tsx +258 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +224 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/label.tsx +20 -0
- package/src/components/ui/radio-group.tsx +38 -0
- package/src/components/ui/separator.tsx +21 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/tabs.tsx +75 -0
- package/src/components/ui/toggle-group.tsx +87 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/tooltip.tsx +56 -0
- package/src/components/version-dialog.tsx +366 -0
- package/src/components/workspace-brand.tsx +18 -0
- package/src/components/workspace-logo.tsx +17 -0
- package/src/index.tsx +55 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/version-notes.ts +30 -0
- package/src/styles/auth-shell.css +67 -0
- package/src/styles/conventional-workspace.css +2846 -0
- package/src/styles/globals.css +93 -0
- package/src/styles/theme.css +94 -0
- package/src/styles/workspace-shell.css +145 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
memo,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type PointerEvent as ReactPointerEvent,
|
|
10
|
+
} from "react";
|
|
11
|
+
import {
|
|
12
|
+
Archive,
|
|
13
|
+
Armchair,
|
|
14
|
+
Bath,
|
|
15
|
+
Baby,
|
|
16
|
+
BedDouble,
|
|
17
|
+
ChefHat,
|
|
18
|
+
ChevronLeft,
|
|
19
|
+
ChevronRight,
|
|
20
|
+
Dumbbell,
|
|
21
|
+
Gamepad2,
|
|
22
|
+
Image,
|
|
23
|
+
LampDesk,
|
|
24
|
+
Leaf,
|
|
25
|
+
PackageOpen,
|
|
26
|
+
PanelTop,
|
|
27
|
+
SquareDashed,
|
|
28
|
+
Sprout,
|
|
29
|
+
Store,
|
|
30
|
+
Tv,
|
|
31
|
+
Utensils,
|
|
32
|
+
type LucideIcon,
|
|
33
|
+
} from "lucide-react";
|
|
34
|
+
import * as THREE from "three";
|
|
35
|
+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
36
|
+
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
|
37
|
+
import { Card } from "./ui/card";
|
|
38
|
+
import { cn } from "../lib/utils";
|
|
39
|
+
|
|
40
|
+
export type PropCatalogItem = {
|
|
41
|
+
id: string;
|
|
42
|
+
label: string;
|
|
43
|
+
assetUrl: string;
|
|
44
|
+
category: string;
|
|
45
|
+
/** Restrict placement to a floor or a wall surface. */
|
|
46
|
+
placementSurface?: "floor" | "wall";
|
|
47
|
+
/** Authored wall-mount height for wall-only props. */
|
|
48
|
+
wallMountHeight?: number;
|
|
49
|
+
/** Render the placement footprint as a circle instead of an AABB. */
|
|
50
|
+
footprintShape?: "rectangle" | "circle";
|
|
51
|
+
/** Small authored lift used to keep floor props off coplanar surfaces. */
|
|
52
|
+
floorLift?: number;
|
|
53
|
+
/** Allow other floor props to be placed on this surface. */
|
|
54
|
+
allowItemsOnTop?: boolean;
|
|
55
|
+
/** Allow this floor prop to be placed underneath existing furniture. */
|
|
56
|
+
canOverlapFurniture?: boolean;
|
|
57
|
+
/** Keep rugs from covering this floor decoration. */
|
|
58
|
+
blocksRugOverlap?: boolean;
|
|
59
|
+
/** Top surface height in authored units (tables, counters). */
|
|
60
|
+
surfaceHeight?: number;
|
|
61
|
+
/** Small item that stacks on surfaces with surfaceHeight. */
|
|
62
|
+
placeableOnTop?: boolean;
|
|
63
|
+
/** Yaw that presents the asset's authored front to the viewer. */
|
|
64
|
+
frontYaw?: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type PropCatalogCategory = {
|
|
68
|
+
id: string;
|
|
69
|
+
label: string;
|
|
70
|
+
icon: LucideIcon;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const defaultPropCatalogCategories: readonly PropCatalogCategory[] = [
|
|
74
|
+
{ id: "seating", label: "Seating", icon: Armchair },
|
|
75
|
+
{ id: "tables", label: "Tables", icon: PanelTop },
|
|
76
|
+
{ id: "bedroom", label: "Bedroom", icon: BedDouble },
|
|
77
|
+
{ id: "storage", label: "Storage", icon: Archive },
|
|
78
|
+
{ id: "lighting", label: "Lighting", icon: LampDesk },
|
|
79
|
+
{ id: "electronics", label: "Electronics", icon: Tv },
|
|
80
|
+
{ id: "entertainment", label: "Entertainment", icon: Gamepad2 },
|
|
81
|
+
{ id: "recreation", label: "Recreation", icon: Dumbbell },
|
|
82
|
+
{ id: "kitchen", label: "Kitchen", icon: ChefHat },
|
|
83
|
+
{ id: "bathroom", label: "Bathroom", icon: Bath },
|
|
84
|
+
{ id: "rugs", label: "Rugs", icon: SquareDashed },
|
|
85
|
+
{ id: "retail", label: "Retail", icon: Store },
|
|
86
|
+
{ id: "fitness", label: "Fitness", icon: Dumbbell },
|
|
87
|
+
{ id: "kids", label: "Kids", icon: Baby },
|
|
88
|
+
{ id: "wall-art", label: "Wall Art", icon: Image },
|
|
89
|
+
{ id: "plants", label: "Plants", icon: Sprout },
|
|
90
|
+
{ id: "food-and-drinks", label: "Food & Drinks", icon: Utensils },
|
|
91
|
+
{ id: "other", label: "Other", icon: PackageOpen },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
function disposeObject(object: THREE.Object3D): void {
|
|
95
|
+
object.traverse((child) => {
|
|
96
|
+
if (!(child instanceof THREE.Mesh)) return;
|
|
97
|
+
child.geometry.dispose();
|
|
98
|
+
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
99
|
+
for (const material of materials) material.dispose();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- Thumbnail infrastructure ------------------------------------------------
|
|
104
|
+
|
|
105
|
+
// Shared singleton renderer + canvas for all thumbnails.
|
|
106
|
+
let thumbnailRenderer: THREE.WebGLRenderer | null = null;
|
|
107
|
+
let thumbnailRendererCanvas: HTMLCanvasElement | null = null;
|
|
108
|
+
// Shared singleton GLTFLoader so the browser can reuse connections and
|
|
109
|
+
// the loader can cache parsed resources internally. Exported so the
|
|
110
|
+
// room designer can reuse the same loader instance and avoid duplicate
|
|
111
|
+
// GLB fetches/parse work.
|
|
112
|
+
let sharedLoader: GLTFLoader | null = null;
|
|
113
|
+
export function getSharedLoader(): GLTFLoader {
|
|
114
|
+
if (!sharedLoader) {
|
|
115
|
+
sharedLoader = new GLTFLoader();
|
|
116
|
+
// Interior catalog GLBs are Meshopt-compressed. Configure the shared
|
|
117
|
+
// loader before thumbnails or the room designer can request any asset.
|
|
118
|
+
sharedLoader.setMeshoptDecoder(MeshoptDecoder);
|
|
119
|
+
}
|
|
120
|
+
return sharedLoader;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Cache of rendered thumbnail data URLs keyed by asset URL + yaw.
|
|
124
|
+
// Survives category switches and drawer re-opens so thumbnails only
|
|
125
|
+
// render once per session.
|
|
126
|
+
const thumbnailDataCache = new Map<string, string>();
|
|
127
|
+
// Track in-flight load+render promises so concurrent mounts share one.
|
|
128
|
+
const thumbnailPromiseCache = new Map<string, Promise<string | null>>();
|
|
129
|
+
// Count mounted consumers so category switches can abandon queued thumbnail
|
|
130
|
+
// renders instead of keeping the main thread busy with an invisible catalog.
|
|
131
|
+
const thumbnailConsumerCounts = new Map<string, number>();
|
|
132
|
+
|
|
133
|
+
function thumbnailCacheKey(item: PropCatalogItem): string {
|
|
134
|
+
return `${item.assetUrl}|${item.frontYaw ?? 0}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type ThumbnailRenderJob = {
|
|
138
|
+
item: PropCatalogItem;
|
|
139
|
+
source: THREE.Object3D;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function renderThumbnailToDataURL(job: ThumbnailRenderJob): string {
|
|
143
|
+
if (!thumbnailRendererCanvas) thumbnailRendererCanvas = document.createElement("canvas");
|
|
144
|
+
if (!thumbnailRenderer) {
|
|
145
|
+
thumbnailRenderer = new THREE.WebGLRenderer({
|
|
146
|
+
canvas: thumbnailRendererCanvas,
|
|
147
|
+
alpha: true,
|
|
148
|
+
antialias: true,
|
|
149
|
+
powerPreference: "low-power",
|
|
150
|
+
});
|
|
151
|
+
thumbnailRenderer.setPixelRatio(1);
|
|
152
|
+
thumbnailRenderer.setSize(96, 96, false);
|
|
153
|
+
thumbnailRenderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
154
|
+
}
|
|
155
|
+
const scene = new THREE.Scene();
|
|
156
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x223044, 2.4));
|
|
157
|
+
const key = new THREE.DirectionalLight(0xffffff, 2.8);
|
|
158
|
+
key.position.set(2, 4, 3);
|
|
159
|
+
scene.add(key);
|
|
160
|
+
const camera = new THREE.PerspectiveCamera(28, 1, 0.01, 100);
|
|
161
|
+
const model = job.source;
|
|
162
|
+
model.rotation.y = job.item.frontYaw ?? 0;
|
|
163
|
+
model.updateMatrixWorld(true);
|
|
164
|
+
const bounds = new THREE.Box3().setFromObject(model);
|
|
165
|
+
const size = bounds.getSize(new THREE.Vector3());
|
|
166
|
+
const center = bounds.getCenter(new THREE.Vector3());
|
|
167
|
+
model.position.sub(center);
|
|
168
|
+
const extent = Math.max(size.x, size.y, size.z, 0.25);
|
|
169
|
+
camera.position.set(extent * 2.25, extent * 1.55, -extent * 2.25);
|
|
170
|
+
camera.lookAt(0, Math.max(size.y * 0.12, 0), 0);
|
|
171
|
+
scene.add(model);
|
|
172
|
+
thumbnailRenderer.render(scene, camera);
|
|
173
|
+
// JPEG is ~5x faster to encode than PNG and produces smaller data URLs,
|
|
174
|
+
// which speeds up both the toDataURL call and the subsequent img.src load.
|
|
175
|
+
const dataUrl = thumbnailRendererCanvas.toDataURL("image/jpeg", 0.85);
|
|
176
|
+
scene.remove(model);
|
|
177
|
+
disposeObject(model);
|
|
178
|
+
return dataUrl;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Yield to the event loop so the browser can process user input between
|
|
183
|
+
* thumbnail renders. Without this, a queue of N renders executes as one
|
|
184
|
+
* synchronous batch (microtask drain) and blocks the main thread.
|
|
185
|
+
*/
|
|
186
|
+
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Sequential render queue. Each render waits for one animation frame before
|
|
190
|
+
* executing, keeping the UI responsive even when many thumbnails are queued.
|
|
191
|
+
*/
|
|
192
|
+
let renderQueue: Promise<unknown> = Promise.resolve();
|
|
193
|
+
|
|
194
|
+
function scheduleRender(render: () => string | null): Promise<string | null> {
|
|
195
|
+
const result = renderQueue.then(async () => {
|
|
196
|
+
await nextFrame();
|
|
197
|
+
return render();
|
|
198
|
+
});
|
|
199
|
+
renderQueue = result.catch(() => undefined);
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Load a GLB and render a thumbnail, returning a data URL.
|
|
205
|
+
* Results are cached so repeated mounts (category switches, drawer re-opens)
|
|
206
|
+
* skip the fetch + render entirely. Concurrent mounts for the same asset
|
|
207
|
+
* share a single in-flight promise.
|
|
208
|
+
*
|
|
209
|
+
* GLB loads are concurrency-limited to avoid firing 50+ simultaneous HTTP
|
|
210
|
+
* requests when a category with many items becomes visible. The render
|
|
211
|
+
* queue (scheduleRender) already serializes the sync WebGL renders, but
|
|
212
|
+
* the async GLB fetches should still be bounded. Four concurrent loads fill
|
|
213
|
+
* the visible catalog quickly without starting dozens of requests at once.
|
|
214
|
+
*/
|
|
215
|
+
const MAX_CONCURRENT_THUMBNAIL_LOADS = 4;
|
|
216
|
+
let activeThumbnailLoads = 0;
|
|
217
|
+
const pendingThumbnailLoads: Array<() => void> = [];
|
|
218
|
+
|
|
219
|
+
function dequeueThumbnailLoad(): void {
|
|
220
|
+
if (activeThumbnailLoads >= MAX_CONCURRENT_THUMBNAIL_LOADS) return;
|
|
221
|
+
const next = pendingThumbnailLoads.shift();
|
|
222
|
+
if (!next) return;
|
|
223
|
+
activeThumbnailLoads++;
|
|
224
|
+
next();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
type ThumbnailHandle = {
|
|
228
|
+
promise: Promise<string | null>;
|
|
229
|
+
release: () => void;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
function acquireThumbnail(item: PropCatalogItem): ThumbnailHandle {
|
|
233
|
+
const key = thumbnailCacheKey(item);
|
|
234
|
+
const cached = thumbnailDataCache.get(key);
|
|
235
|
+
if (cached) return { promise: Promise.resolve(cached), release: () => undefined };
|
|
236
|
+
|
|
237
|
+
thumbnailConsumerCounts.set(key, (thumbnailConsumerCounts.get(key) ?? 0) + 1);
|
|
238
|
+
let promise = thumbnailPromiseCache.get(key);
|
|
239
|
+
if (!promise) {
|
|
240
|
+
promise = new Promise<string | null>((resolve, reject) => {
|
|
241
|
+
const run = () => {
|
|
242
|
+
if (!thumbnailConsumerCounts.has(key)) {
|
|
243
|
+
activeThumbnailLoads--;
|
|
244
|
+
dequeueThumbnailLoad();
|
|
245
|
+
thumbnailPromiseCache.delete(key);
|
|
246
|
+
resolve(null);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const finishLoad = () => {
|
|
250
|
+
activeThumbnailLoads--;
|
|
251
|
+
dequeueThumbnailLoad();
|
|
252
|
+
};
|
|
253
|
+
getSharedLoader()
|
|
254
|
+
.loadAsync(item.assetUrl)
|
|
255
|
+
.then(
|
|
256
|
+
({ scene: source }) => {
|
|
257
|
+
finishLoad();
|
|
258
|
+
// The GLB parse is async, but the WebGL render + toDataURL is sync.
|
|
259
|
+
// Schedule the sync part in an animation frame so it doesn't block.
|
|
260
|
+
return scheduleRender(() => {
|
|
261
|
+
if (!thumbnailConsumerCounts.has(key)) {
|
|
262
|
+
thumbnailPromiseCache.delete(key);
|
|
263
|
+
disposeObject(source);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const dataUrl = renderThumbnailToDataURL({ item, source });
|
|
267
|
+
thumbnailDataCache.set(key, dataUrl);
|
|
268
|
+
thumbnailPromiseCache.delete(key);
|
|
269
|
+
return dataUrl;
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
(error) => {
|
|
273
|
+
finishLoad();
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
.then(resolve, reject);
|
|
278
|
+
};
|
|
279
|
+
pendingThumbnailLoads.push(run);
|
|
280
|
+
dequeueThumbnailLoad();
|
|
281
|
+
});
|
|
282
|
+
thumbnailPromiseCache.set(key, promise);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let released = false;
|
|
286
|
+
return {
|
|
287
|
+
promise,
|
|
288
|
+
release: () => {
|
|
289
|
+
if (released) return;
|
|
290
|
+
released = true;
|
|
291
|
+
const consumers = thumbnailConsumerCounts.get(key) ?? 0;
|
|
292
|
+
if (consumers <= 1) thumbnailConsumerCounts.delete(key);
|
|
293
|
+
else thumbnailConsumerCounts.set(key, consumers - 1);
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const ModelThumbnail = memo(function ModelThumbnail({
|
|
299
|
+
item,
|
|
300
|
+
deferMs = 0,
|
|
301
|
+
}: {
|
|
302
|
+
item: PropCatalogItem;
|
|
303
|
+
/** Delay expensive GLB preview work so nearby controls remain responsive. */
|
|
304
|
+
deferMs?: number;
|
|
305
|
+
}) {
|
|
306
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
307
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
308
|
+
const [visible, setVisible] = useState(false);
|
|
309
|
+
const [failed, setFailed] = useState(false);
|
|
310
|
+
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
const wrapper = wrapperRef.current;
|
|
313
|
+
if (!wrapper || typeof IntersectionObserver === "undefined") {
|
|
314
|
+
setVisible(true);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// Use a rootMargin so items slightly below the fold start loading
|
|
318
|
+
// before the user scrolls to them, making scroll feel instant.
|
|
319
|
+
const scrollRoot = wrapper.closest("[data-model-thumbnail-root]");
|
|
320
|
+
const observer = new IntersectionObserver(
|
|
321
|
+
([entry]) => {
|
|
322
|
+
setVisible(Boolean(entry?.isIntersecting));
|
|
323
|
+
},
|
|
324
|
+
{ root: scrollRoot, rootMargin: "200px" }
|
|
325
|
+
);
|
|
326
|
+
observer.observe(wrapper);
|
|
327
|
+
return () => observer.disconnect();
|
|
328
|
+
}, []);
|
|
329
|
+
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (!visible) return;
|
|
332
|
+
const canvas = canvasRef.current;
|
|
333
|
+
if (!canvas) return;
|
|
334
|
+
let cancelled = false;
|
|
335
|
+
|
|
336
|
+
let thumbnail: ThumbnailHandle | undefined;
|
|
337
|
+
const load = () => {
|
|
338
|
+
if (cancelled) return;
|
|
339
|
+
thumbnail = acquireThumbnail(item);
|
|
340
|
+
void thumbnail.promise
|
|
341
|
+
.then((dataUrl) => {
|
|
342
|
+
if (cancelled || !dataUrl) return;
|
|
343
|
+
const ctx = canvas.getContext("2d");
|
|
344
|
+
if (!ctx) return;
|
|
345
|
+
const img = document.createElement("img");
|
|
346
|
+
img.onload = () => {
|
|
347
|
+
if (cancelled) return;
|
|
348
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
349
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
350
|
+
};
|
|
351
|
+
img.src = dataUrl;
|
|
352
|
+
})
|
|
353
|
+
.catch(() => {
|
|
354
|
+
if (!cancelled) setFailed(true);
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
const timer = window.setTimeout(load, deferMs);
|
|
358
|
+
|
|
359
|
+
return () => {
|
|
360
|
+
cancelled = true;
|
|
361
|
+
window.clearTimeout(timer);
|
|
362
|
+
thumbnail?.release();
|
|
363
|
+
};
|
|
364
|
+
}, [deferMs, item, visible]);
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<div
|
|
368
|
+
ref={wrapperRef}
|
|
369
|
+
className="relative flex aspect-square items-center justify-center overflow-hidden rounded-lg bg-muted/60"
|
|
370
|
+
>
|
|
371
|
+
<canvas
|
|
372
|
+
ref={canvasRef}
|
|
373
|
+
width={96}
|
|
374
|
+
height={96}
|
|
375
|
+
className={cn("h-full w-full transition-opacity", failed ? "opacity-0" : "opacity-100")}
|
|
376
|
+
aria-hidden="true"
|
|
377
|
+
/>
|
|
378
|
+
{failed ? <Leaf className="size-7 text-muted-foreground" aria-hidden="true" /> : null}
|
|
379
|
+
<span className="pointer-events-none absolute inset-x-1 bottom-1 truncate rounded bg-background/55 px-1.5 py-1 text-center text-[10px] font-medium text-foreground opacity-0 transition-opacity group-hover:opacity-100">
|
|
380
|
+
{item.label}
|
|
381
|
+
</span>
|
|
382
|
+
</div>
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
export type PropCatalogProps = {
|
|
387
|
+
items: readonly PropCatalogItem[];
|
|
388
|
+
selectedId?: string | null;
|
|
389
|
+
categories?: readonly PropCatalogCategory[];
|
|
390
|
+
className?: string;
|
|
391
|
+
onItemPointerDown?: (item: PropCatalogItem, event: ReactPointerEvent<HTMLButtonElement>) => void;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
export function PropCatalog({
|
|
395
|
+
items,
|
|
396
|
+
selectedId = null,
|
|
397
|
+
categories = defaultPropCatalogCategories,
|
|
398
|
+
className,
|
|
399
|
+
onItemPointerDown,
|
|
400
|
+
}: PropCatalogProps) {
|
|
401
|
+
const [activeCategory, setActiveCategory] = useState(categories[0]?.id ?? "seating");
|
|
402
|
+
const [hoveredCategory, setHoveredCategory] = useState<{ id: string; left: number } | null>(null);
|
|
403
|
+
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
|
404
|
+
const [canScrollRight, setCanScrollRight] = useState(false);
|
|
405
|
+
const categoryScrollRef = useRef<HTMLDivElement>(null);
|
|
406
|
+
const categoryBarRef = useRef<HTMLDivElement>(null);
|
|
407
|
+
const visibleItems = useMemo(() => {
|
|
408
|
+
return items.filter((item) => item.category === activeCategory);
|
|
409
|
+
}, [activeCategory, items]);
|
|
410
|
+
|
|
411
|
+
const updateCategoryScroll = () => {
|
|
412
|
+
const element = categoryScrollRef.current;
|
|
413
|
+
if (!element) return;
|
|
414
|
+
setCanScrollLeft(element.scrollLeft > 1);
|
|
415
|
+
setCanScrollRight(element.scrollLeft + element.clientWidth < element.scrollWidth - 1);
|
|
416
|
+
setHoveredCategory(null);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
updateCategoryScroll();
|
|
421
|
+
const element = categoryScrollRef.current;
|
|
422
|
+
if (!element) return;
|
|
423
|
+
const observer =
|
|
424
|
+
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateCategoryScroll);
|
|
425
|
+
observer?.observe(element);
|
|
426
|
+
element.addEventListener("scroll", updateCategoryScroll, { passive: true });
|
|
427
|
+
return () => {
|
|
428
|
+
observer?.disconnect();
|
|
429
|
+
element.removeEventListener("scroll", updateCategoryScroll);
|
|
430
|
+
};
|
|
431
|
+
}, [categories.length]);
|
|
432
|
+
|
|
433
|
+
const scrollCategories = (direction: "left" | "right") => {
|
|
434
|
+
const element = categoryScrollRef.current;
|
|
435
|
+
if (!element) return;
|
|
436
|
+
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
|
|
437
|
+
element.scrollTo({ left: direction === "left" ? 0 : maxScrollLeft, behavior: "auto" });
|
|
438
|
+
// The left arrow becomes visible after the first rightward scroll, which
|
|
439
|
+
// can reduce the viewport by one button width. Re-read the final extent
|
|
440
|
+
// after that layout update so the last category is never stranded.
|
|
441
|
+
if (direction === "right") {
|
|
442
|
+
requestAnimationFrame(() =>
|
|
443
|
+
requestAnimationFrame(() => {
|
|
444
|
+
const finalMaxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
|
|
445
|
+
element.scrollTo({ left: finalMaxScrollLeft, behavior: "auto" });
|
|
446
|
+
})
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const setCategoryHover = (id: string, target: HTMLElement) => {
|
|
452
|
+
const bar = categoryBarRef.current;
|
|
453
|
+
if (!bar) return;
|
|
454
|
+
const targetRect = target.getBoundingClientRect();
|
|
455
|
+
const barRect = bar.getBoundingClientRect();
|
|
456
|
+
setHoveredCategory({ id, left: targetRect.left + targetRect.width / 2 - barRect.left });
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<div className={cn("flex min-h-0 flex-col gap-3", className)} aria-label="Prop catalog">
|
|
461
|
+
<div
|
|
462
|
+
ref={categoryBarRef}
|
|
463
|
+
className="relative shrink-0 pb-1"
|
|
464
|
+
role="tablist"
|
|
465
|
+
aria-label="Prop categories"
|
|
466
|
+
>
|
|
467
|
+
{canScrollLeft ? (
|
|
468
|
+
<span
|
|
469
|
+
aria-hidden="true"
|
|
470
|
+
className="absolute left-0 top-0 z-10 size-8 rounded-md bg-background"
|
|
471
|
+
/>
|
|
472
|
+
) : null}
|
|
473
|
+
<button
|
|
474
|
+
type="button"
|
|
475
|
+
tabIndex={canScrollLeft ? 0 : -1}
|
|
476
|
+
aria-hidden={!canScrollLeft}
|
|
477
|
+
className={cn(
|
|
478
|
+
"absolute left-0 top-0 z-20 flex size-8 items-center justify-center rounded-md bg-background text-muted-foreground shadow-sm hover:bg-muted hover:text-foreground",
|
|
479
|
+
!canScrollLeft ? "pointer-events-none invisible" : null
|
|
480
|
+
)}
|
|
481
|
+
aria-label="Scroll categories left"
|
|
482
|
+
onClick={() => scrollCategories("left")}
|
|
483
|
+
>
|
|
484
|
+
<ChevronLeft className="size-5" aria-hidden="true" />
|
|
485
|
+
</button>
|
|
486
|
+
<div
|
|
487
|
+
ref={categoryScrollRef}
|
|
488
|
+
className="w-full overflow-x-auto overflow-y-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
489
|
+
>
|
|
490
|
+
<div className="flex min-w-max gap-1">
|
|
491
|
+
{categories.map(({ id, label, icon: Icon }) => (
|
|
492
|
+
<button
|
|
493
|
+
key={id}
|
|
494
|
+
type="button"
|
|
495
|
+
role="tab"
|
|
496
|
+
aria-label={label}
|
|
497
|
+
aria-selected={activeCategory === id}
|
|
498
|
+
className={cn(
|
|
499
|
+
"group/category relative flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background/30 text-muted-foreground transition-colors hover:border-primary/70 hover:bg-accent/70 hover:text-accent-foreground",
|
|
500
|
+
activeCategory === id ? "border-primary bg-accent text-accent-foreground" : null
|
|
501
|
+
)}
|
|
502
|
+
onClick={() => setActiveCategory(id)}
|
|
503
|
+
onMouseEnter={(event) => setCategoryHover(id, event.currentTarget)}
|
|
504
|
+
onMouseLeave={() => setHoveredCategory(null)}
|
|
505
|
+
onFocus={(event) => setCategoryHover(id, event.currentTarget)}
|
|
506
|
+
onBlur={() => setHoveredCategory(null)}
|
|
507
|
+
>
|
|
508
|
+
<Icon className="size-4" aria-hidden="true" />
|
|
509
|
+
</button>
|
|
510
|
+
))}
|
|
511
|
+
</div>
|
|
512
|
+
</div>
|
|
513
|
+
{canScrollRight ? (
|
|
514
|
+
<span
|
|
515
|
+
aria-hidden="true"
|
|
516
|
+
className="absolute right-0 top-0 z-10 size-8 rounded-md bg-background"
|
|
517
|
+
/>
|
|
518
|
+
) : null}
|
|
519
|
+
<button
|
|
520
|
+
type="button"
|
|
521
|
+
tabIndex={canScrollRight ? 0 : -1}
|
|
522
|
+
aria-hidden={!canScrollRight}
|
|
523
|
+
className={cn(
|
|
524
|
+
"absolute right-0 top-0 z-20 flex size-8 items-center justify-center rounded-md bg-background text-muted-foreground shadow-sm hover:bg-muted hover:text-foreground",
|
|
525
|
+
!canScrollRight ? "pointer-events-none invisible" : null
|
|
526
|
+
)}
|
|
527
|
+
aria-label="Scroll categories right"
|
|
528
|
+
onClick={() => scrollCategories("right")}
|
|
529
|
+
>
|
|
530
|
+
<ChevronRight className="size-5" aria-hidden="true" />
|
|
531
|
+
</button>
|
|
532
|
+
{hoveredCategory ? (
|
|
533
|
+
<div
|
|
534
|
+
className="pointer-events-none absolute top-full z-20 mt-1 rounded bg-background/90 px-2 py-1 text-[10px] font-medium text-foreground shadow-md backdrop-blur-sm"
|
|
535
|
+
style={{ left: hoveredCategory.left, transform: "translateX(-50%)" }}
|
|
536
|
+
>
|
|
537
|
+
{categories.find((category) => category.id === hoveredCategory.id)?.label}
|
|
538
|
+
</div>
|
|
539
|
+
) : null}
|
|
540
|
+
</div>
|
|
541
|
+
{visibleItems.length > 0 ? (
|
|
542
|
+
<div
|
|
543
|
+
className="min-h-0 flex-1 overflow-y-auto pr-1"
|
|
544
|
+
role="tabpanel"
|
|
545
|
+
data-model-thumbnail-root
|
|
546
|
+
>
|
|
547
|
+
<div className="grid grid-cols-3 gap-2">
|
|
548
|
+
{visibleItems.map((item) => (
|
|
549
|
+
<Card
|
|
550
|
+
key={item.id}
|
|
551
|
+
className={cn(
|
|
552
|
+
"group overflow-hidden border-border bg-card/60 p-1 transition-colors",
|
|
553
|
+
selectedId === item.id
|
|
554
|
+
? "border-primary bg-accent/70"
|
|
555
|
+
: "hover:border-primary/50 hover:bg-accent/40"
|
|
556
|
+
)}
|
|
557
|
+
>
|
|
558
|
+
<button
|
|
559
|
+
type="button"
|
|
560
|
+
aria-label={`Add ${item.label}`}
|
|
561
|
+
className="block w-full rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
562
|
+
onPointerDown={(event) => {
|
|
563
|
+
if (event.button !== 0) return;
|
|
564
|
+
event.preventDefault();
|
|
565
|
+
event.stopPropagation();
|
|
566
|
+
onItemPointerDown?.(item, event);
|
|
567
|
+
}}
|
|
568
|
+
>
|
|
569
|
+
<ModelThumbnail item={item} />
|
|
570
|
+
<span className="sr-only">{item.label}</span>
|
|
571
|
+
</button>
|
|
572
|
+
</Card>
|
|
573
|
+
))}
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
) : (
|
|
577
|
+
<p className="rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-muted-foreground">
|
|
578
|
+
No props in this category yet.
|
|
579
|
+
</p>
|
|
580
|
+
)}
|
|
581
|
+
</div>
|
|
582
|
+
);
|
|
583
|
+
}
|