@mmmmzxe/react-360-viewer 0.1.0

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.
Files changed (44) hide show
  1. package/README.md +214 -0
  2. package/dist/index.d.ts +377 -0
  3. package/dist/index.js +1237 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +74 -0
  6. package/src/components/ui/Badge/index.tsx +44 -0
  7. package/src/components/ui/Button/index.tsx +68 -0
  8. package/src/components/ui/Card/index.tsx +74 -0
  9. package/src/components/ui/Item/index.tsx +136 -0
  10. package/src/components/ui/Label/index.tsx +20 -0
  11. package/src/components/ui/Popover/index.tsx +56 -0
  12. package/src/components/ui/Separator/index.tsx +30 -0
  13. package/src/components/ui/Spinner/index.tsx +11 -0
  14. package/src/components/utils/index.ts +6 -0
  15. package/src/constants/viewer360ClassNames.ts +41 -0
  16. package/src/constants/viewer360Config.ts +11 -0
  17. package/src/constants/viewer360Labels.ts +14 -0
  18. package/src/constants/viewer360MarkerLabels.ts +3 -0
  19. package/src/feature/Viewer360.test.tsx +47 -0
  20. package/src/feature/Viewer360.tsx +218 -0
  21. package/src/feature/Viewer360AddModeBanner.tsx +20 -0
  22. package/src/feature/Viewer360FrameIndicator.tsx +20 -0
  23. package/src/feature/Viewer360HotspotOverlay.tsx +70 -0
  24. package/src/feature/Viewer360LoadingOverlay.tsx +28 -0
  25. package/src/feature/Viewer360MarkerPin.tsx +115 -0
  26. package/src/feature/Viewer360Toolbar.tsx +75 -0
  27. package/src/helpers/adjustViewerZoom.test.ts +29 -0
  28. package/src/helpers/adjustViewerZoom.ts +64 -0
  29. package/src/helpers/computeDragFrameIndex.test.ts +20 -0
  30. package/src/helpers/computeDragFrameIndex.ts +23 -0
  31. package/src/helpers/computeViewerImageLayout.test.ts +48 -0
  32. package/src/helpers/computeViewerImageLayout.ts +114 -0
  33. package/src/helpers/computeViewerPanOffset.ts +18 -0
  34. package/src/helpers/markerHelpers.test.ts +38 -0
  35. package/src/helpers/markerHelpers.ts +33 -0
  36. package/src/helpers/viewer360PropsHelpers.ts +46 -0
  37. package/src/helpers/viewerHelpers.ts +74 -0
  38. package/src/hooks/useViewer360.ts +306 -0
  39. package/src/index.ts +68 -0
  40. package/src/types/Viewer360Hotspot.ts +66 -0
  41. package/src/types/Viewer360Marker.ts +51 -0
  42. package/src/types/Viewer360Props.ts +108 -0
  43. package/src/types/index.ts +30 -0
  44. package/src/utils/index.ts +6 -0
package/dist/index.js ADDED
@@ -0,0 +1,1237 @@
1
+ "use client";
2
+
3
+ // src/feature/Viewer360.tsx
4
+ import { useEffect as useEffect2, useMemo as useMemo2, useState as useState3 } from "react";
5
+
6
+ // src/constants/viewer360Labels.ts
7
+ var defaultViewer360Labels = {
8
+ loading: "Loading images\u2026",
9
+ dragHint: "Drag to rotate \u2022 Scroll to zoom",
10
+ frameIndicator: ({ current, total, label }) => label ? `${label} (${current}/${total})` : `${current} / ${total}`,
11
+ zoom: (percent) => `${percent}%`,
12
+ hotspotModeActive: "Click on the image to place a hotspot",
13
+ addHotspot: "Add hotspot",
14
+ zoomIn: "Zoom in",
15
+ zoomOut: "Zoom out",
16
+ resetView: "Reset view",
17
+ deleteMarker: "Remove marker"
18
+ };
19
+
20
+ // src/constants/viewer360ClassNames.ts
21
+ var viewer360ClassNames = {
22
+ root: "overflow-hidden rounded-lg border bg-card text-card-foreground",
23
+ viewport: "relative aspect-[16/10] w-full touch-none select-none bg-muted",
24
+ canvas: "absolute inset-0 size-full",
25
+ overlay: "pointer-events-none absolute inset-0 overflow-hidden",
26
+ loading: "absolute inset-0 flex items-center justify-center bg-muted/80",
27
+ loadingText: "text-sm text-muted-foreground",
28
+ frameIndicator: "pointer-events-none absolute bottom-4 start-1/2 z-20 -translate-x-1/2 rounded-full border bg-background px-4 py-1.5 text-xs font-medium shadow-sm",
29
+ hotspotModeBanner: "pointer-events-none absolute top-4 start-1/2 z-20 -translate-x-1/2 rounded-full border border-amber-200 bg-amber-50 px-4 py-1.5 text-xs font-medium text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400",
30
+ toolbar: "flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3",
31
+ dragHint: "hidden text-xs text-muted-foreground sm:block",
32
+ controls: "ms-auto flex items-center gap-1.5",
33
+ controlButton: "",
34
+ controlButtonActive: "",
35
+ controlButtonDisabled: "",
36
+ zoomDisplay: "flex min-w-[3rem] items-center justify-center gap-1 rounded-md border bg-background px-2 py-1 text-xs font-medium",
37
+ divider: "mx-1 h-6 w-px bg-border"
38
+ };
39
+ var viewer360MarkerPinClassNames = {
40
+ root: "pointer-events-auto absolute z-30 -translate-x-1/2 -translate-y-1/2",
41
+ ping: "absolute inline-flex size-6 -translate-x-1/4 -translate-y-1/4 animate-ping rounded-full bg-destructive opacity-60",
42
+ dot: "relative flex size-4 items-center justify-center rounded-full border-2 border-background bg-destructive shadow-md transition-transform duration-200 hover:scale-125 focus:outline-none",
43
+ tooltip: "absolute bottom-6 left-1/2 z-40 w-64 -translate-x-1/2 rounded-lg border bg-popover p-3 text-popover-foreground shadow-md",
44
+ tooltipHeader: "flex items-start justify-between gap-2",
45
+ tooltipBody: "flex min-w-0 flex-col gap-1",
46
+ tooltipTitle: "text-sm font-medium",
47
+ tooltipDescription: "mt-2 line-clamp-3 text-xs text-muted-foreground",
48
+ deleteButton: ""
49
+ };
50
+ var defaultViewer360ClassNames = viewer360ClassNames;
51
+ var defaultViewer360MarkerPinClassNames = viewer360MarkerPinClassNames;
52
+
53
+ // src/components/utils/index.ts
54
+ import { clsx } from "clsx";
55
+ import { twMerge } from "tailwind-merge";
56
+ function cn(...inputs) {
57
+ return twMerge(clsx(inputs));
58
+ }
59
+
60
+ // src/helpers/viewer360PropsHelpers.ts
61
+ function mergeViewer360Labels(labels) {
62
+ return {
63
+ loading: labels?.loading ?? defaultViewer360Labels.loading,
64
+ dragHint: labels?.dragHint ?? defaultViewer360Labels.dragHint,
65
+ frameIndicator: labels?.frameIndicator ?? defaultViewer360Labels.frameIndicator,
66
+ zoom: labels?.zoom ?? defaultViewer360Labels.zoom,
67
+ hotspotModeActive: labels?.hotspotModeActive ?? defaultViewer360Labels.hotspotModeActive,
68
+ addHotspot: labels?.addHotspot ?? defaultViewer360Labels.addHotspot,
69
+ zoomIn: labels?.zoomIn ?? defaultViewer360Labels.zoomIn,
70
+ zoomOut: labels?.zoomOut ?? defaultViewer360Labels.zoomOut,
71
+ resetView: labels?.resetView ?? defaultViewer360Labels.resetView,
72
+ deleteMarker: labels?.deleteMarker ?? defaultViewer360Labels.deleteMarker
73
+ };
74
+ }
75
+ function mergeViewer360ClassNames(classNames) {
76
+ return {
77
+ root: cn(viewer360ClassNames.root, classNames?.root),
78
+ viewport: cn(viewer360ClassNames.viewport, classNames?.viewport),
79
+ canvas: cn(viewer360ClassNames.canvas, classNames?.canvas),
80
+ overlay: cn(viewer360ClassNames.overlay, classNames?.overlay),
81
+ loading: cn(viewer360ClassNames.loading, classNames?.loading),
82
+ loadingText: cn(viewer360ClassNames.loadingText, classNames?.loadingText),
83
+ frameIndicator: cn(viewer360ClassNames.frameIndicator, classNames?.frameIndicator),
84
+ hotspotModeBanner: cn(viewer360ClassNames.hotspotModeBanner, classNames?.hotspotModeBanner),
85
+ toolbar: cn(viewer360ClassNames.toolbar, classNames?.toolbar),
86
+ dragHint: cn(viewer360ClassNames.dragHint, classNames?.dragHint),
87
+ controls: cn(viewer360ClassNames.controls, classNames?.controls),
88
+ controlButton: cn(viewer360ClassNames.controlButton, classNames?.controlButton),
89
+ controlButtonActive: cn(viewer360ClassNames.controlButtonActive, classNames?.controlButtonActive),
90
+ controlButtonDisabled: cn(viewer360ClassNames.controlButtonDisabled, classNames?.controlButtonDisabled),
91
+ zoomDisplay: cn(viewer360ClassNames.zoomDisplay, classNames?.zoomDisplay),
92
+ divider: cn(viewer360ClassNames.divider, classNames?.divider)
93
+ };
94
+ }
95
+ function buildViewer360ThemeStyle(theme) {
96
+ return theme ? theme : {};
97
+ }
98
+
99
+ // src/hooks/useViewer360.ts
100
+ import { useEffect, useMemo, useRef, useState } from "react";
101
+
102
+ // src/helpers/adjustViewerZoom.ts
103
+ function resolveViewer360Config(config) {
104
+ return {
105
+ minZoom: config?.minZoom ?? 1,
106
+ maxZoom: config?.maxZoom ?? 3,
107
+ zoomStep: config?.zoomStep ?? 0.15,
108
+ dragSensitivity: config?.dragSensitivity ?? 8,
109
+ autoRotate: config?.autoRotate ?? false,
110
+ autoRotateIntervalMs: config?.autoRotateIntervalMs ?? 100,
111
+ autoRotateDirection: config?.autoRotateDirection ?? "forward"
112
+ };
113
+ }
114
+ function clampZoom(zoom, config) {
115
+ return Math.min(config.maxZoom, Math.max(config.minZoom, zoom));
116
+ }
117
+ function applyWheelZoom(currentZoom, deltaY, currentPan, config) {
118
+ const delta = deltaY > 0 ? -config.zoomStep : config.zoomStep;
119
+ const zoom = clampZoom(currentZoom + delta, config);
120
+ return {
121
+ zoom,
122
+ pan: zoom === config.minZoom ? { panX: 0, panY: 0 } : currentPan
123
+ };
124
+ }
125
+ function stepZoomIn(currentZoom, config) {
126
+ return clampZoom(currentZoom + config.zoomStep, config);
127
+ }
128
+ function stepZoomOut(currentZoom, currentPan, config) {
129
+ const zoom = clampZoom(currentZoom - config.zoomStep, config);
130
+ return {
131
+ zoom,
132
+ pan: zoom === config.minZoom ? { panX: 0, panY: 0 } : currentPan
133
+ };
134
+ }
135
+ function isResetDisabled(zoom, pan, config) {
136
+ return zoom === config.minZoom && pan.panX === 0 && pan.panY === 0;
137
+ }
138
+ function getViewerCursorClass(isHotspotMode, zoom, isDragging, minZoom) {
139
+ if (isHotspotMode) return "cursor-crosshair";
140
+ if (isDragging) return "cursor-grabbing";
141
+ if (zoom > minZoom) return "cursor-grab";
142
+ return "cursor-ew-resize";
143
+ }
144
+
145
+ // src/helpers/computeDragFrameIndex.ts
146
+ function clampFrameIndex(index, frameCount) {
147
+ if (frameCount === 0) return 0;
148
+ return (index % frameCount + frameCount) % frameCount;
149
+ }
150
+ function computeDragFrameIndex(dragStart, clientX, frameCount, dragSensitivity) {
151
+ const deltaX = clientX - dragStart.pointerX;
152
+ const frameDelta = Math.round(-deltaX / dragSensitivity);
153
+ if (frameDelta === 0) return null;
154
+ return clampFrameIndex(dragStart.frameIndex + frameDelta, frameCount);
155
+ }
156
+
157
+ // src/helpers/computeViewerImageLayout.ts
158
+ function computeViewerImageLayout({
159
+ containerWidth,
160
+ containerHeight,
161
+ imageWidth,
162
+ imageHeight,
163
+ pan = { panX: 0, panY: 0 }
164
+ }) {
165
+ const imgAspect = imageWidth / imageHeight;
166
+ const containerAspect = containerWidth / containerHeight;
167
+ let drawWidth;
168
+ let drawHeight;
169
+ if (imgAspect > containerAspect) {
170
+ drawWidth = containerWidth;
171
+ drawHeight = containerWidth / imgAspect;
172
+ } else {
173
+ drawHeight = containerHeight;
174
+ drawWidth = containerHeight * imgAspect;
175
+ }
176
+ const offsetX = (containerWidth - drawWidth) / 2 + pan.panX;
177
+ const offsetY = (containerHeight - drawHeight) / 2 + pan.panY;
178
+ return {
179
+ width: containerWidth,
180
+ height: containerHeight,
181
+ centerX: containerWidth / 2,
182
+ centerY: containerHeight / 2,
183
+ drawWidth,
184
+ drawHeight,
185
+ offsetX,
186
+ offsetY
187
+ };
188
+ }
189
+ function computeHotspotScreenPosition(hotspotX, hotspotY, layout, zoom) {
190
+ const baseOffsetX = (layout.width - layout.drawWidth) / 2;
191
+ const baseOffsetY = (layout.height - layout.drawHeight) / 2;
192
+ const containerX = hotspotX / 100 * layout.width;
193
+ const containerY = hotspotY / 100 * layout.height;
194
+ const imageLocalX = (containerX - baseOffsetX) / layout.drawWidth;
195
+ const imageLocalY = (containerY - baseOffsetY) / layout.drawHeight;
196
+ const imagePointX = layout.offsetX + imageLocalX * layout.drawWidth;
197
+ const imagePointY = layout.offsetY + imageLocalY * layout.drawHeight;
198
+ const screenX = layout.centerX + zoom * (imagePointX - layout.centerX);
199
+ const screenY = layout.centerY + zoom * (imagePointY - layout.centerY);
200
+ return {
201
+ leftPercent: screenX / layout.width * 100,
202
+ topPercent: screenY / layout.height * 100
203
+ };
204
+ }
205
+ function computeHotspotPositionFromClick(clientX, clientY, containerRect, layout, zoom) {
206
+ const clickX = clientX - containerRect.left;
207
+ const clickY = clientY - containerRect.top;
208
+ const unzoomedX = layout.centerX + (clickX - layout.centerX) / zoom;
209
+ const unzoomedY = layout.centerY + (clickY - layout.centerY) / zoom;
210
+ const baseOffsetX = (layout.width - layout.drawWidth) / 2;
211
+ const baseOffsetY = (layout.height - layout.drawHeight) / 2;
212
+ const imageLocalX = Math.min(1, Math.max(0, (unzoomedX - layout.offsetX) / layout.drawWidth));
213
+ const imageLocalY = Math.min(1, Math.max(0, (unzoomedY - layout.offsetY) / layout.drawHeight));
214
+ const storedX = baseOffsetX + imageLocalX * layout.drawWidth;
215
+ const storedY = baseOffsetY + imageLocalY * layout.drawHeight;
216
+ return {
217
+ positionX: Math.min(100, Math.max(0, storedX / layout.width * 100)),
218
+ positionY: Math.min(100, Math.max(0, storedY / layout.height * 100))
219
+ };
220
+ }
221
+
222
+ // src/helpers/computeViewerPanOffset.ts
223
+ function computeViewerPanOffset(panStart, clientX, clientY) {
224
+ const deltaX = clientX - panStart.pointerX;
225
+ const deltaY = clientY - panStart.pointerY;
226
+ return {
227
+ panX: panStart.panX + deltaX,
228
+ panY: panStart.panY + deltaY
229
+ };
230
+ }
231
+
232
+ // src/helpers/viewerHelpers.ts
233
+ function drawFrameOnCanvas({ canvas, container, image, zoom, pan }) {
234
+ if (!image.complete || !image.naturalWidth) return;
235
+ const rect = container.getBoundingClientRect();
236
+ const dpr = window.devicePixelRatio || 1;
237
+ canvas.width = rect.width * dpr;
238
+ canvas.height = rect.height * dpr;
239
+ canvas.style.width = `${rect.width}px`;
240
+ canvas.style.height = `${rect.height}px`;
241
+ const ctx = canvas.getContext("2d");
242
+ if (!ctx) return;
243
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
244
+ ctx.clearRect(0, 0, rect.width, rect.height);
245
+ const layout = computeViewerImageLayout({
246
+ containerWidth: rect.width,
247
+ containerHeight: rect.height,
248
+ imageWidth: image.naturalWidth,
249
+ imageHeight: image.naturalHeight,
250
+ pan
251
+ });
252
+ ctx.save();
253
+ ctx.translate(layout.centerX, layout.centerY);
254
+ ctx.scale(zoom, zoom);
255
+ ctx.translate(-layout.centerX, -layout.centerY);
256
+ ctx.drawImage(image, layout.offsetX, layout.offsetY, layout.drawWidth, layout.drawHeight);
257
+ ctx.restore();
258
+ }
259
+ function getFramesSignature(frames) {
260
+ return frames.map((frame) => frame.id).join("-");
261
+ }
262
+ function preloadFrameImage(frame) {
263
+ return new Promise((resolve, reject) => {
264
+ const img = new Image();
265
+ img.onload = () => resolve(img);
266
+ img.onerror = () => reject(new Error(`Failed to load frame: ${frame.src}`));
267
+ img.src = frame.src;
268
+ });
269
+ }
270
+ async function preloadViewerFrames(frames) {
271
+ const results = await Promise.allSettled(frames.map(preloadFrameImage));
272
+ return results.map((result) => result.status === "fulfilled" ? result.value : new Image());
273
+ }
274
+ function hasLoadedViewerFrame(images) {
275
+ return images.some((image) => image.complete && image.naturalWidth > 0);
276
+ }
277
+ function filterHotspotsByFrame(hotspots, frameIndex) {
278
+ return hotspots.filter((hotspot) => hotspot.frameIndex === frameIndex);
279
+ }
280
+
281
+ // src/hooks/useViewer360.ts
282
+ function useViewer360({
283
+ frames,
284
+ hotspots = [],
285
+ currentFrameIndex,
286
+ onFrameChange,
287
+ config,
288
+ hotspotMode: hotspotModeProp = false,
289
+ onHotspotAdd
290
+ }) {
291
+ const resolvedConfig = useMemo(() => resolveViewer360Config(config), [config]);
292
+ const { minZoom, maxZoom } = resolvedConfig;
293
+ const canvasRef = useRef(null);
294
+ const containerRef = useRef(null);
295
+ const imagesRef = useRef([]);
296
+ const dragStartRef = useRef(null);
297
+ const panStartRef = useRef(null);
298
+ const framesSignature = getFramesSignature(frames);
299
+ const [loadedSignature, setLoadedSignature] = useState(null);
300
+ const [zoom, setZoom] = useState(minZoom);
301
+ const [pan, setPan] = useState({ panX: 0, panY: 0 });
302
+ const [isDragging, setIsDragging] = useState(false);
303
+ const isHotspotMode = hotspotModeProp;
304
+ const imagesLoaded = loadedSignature === framesSignature;
305
+ const currentFrameHotspots = filterHotspotsByFrame(hotspots, currentFrameIndex);
306
+ const currentFrame = frames[currentFrameIndex];
307
+ const viewerCursorClass = getViewerCursorClass(isHotspotMode, zoom, isDragging, minZoom);
308
+ const resetDisabled = isResetDisabled(zoom, pan, resolvedConfig);
309
+ useEffect(() => {
310
+ let cancelled = false;
311
+ imagesRef.current = [];
312
+ async function loadFrames() {
313
+ const loadedImages = await preloadViewerFrames(frames);
314
+ if (!cancelled && hasLoadedViewerFrame(loadedImages)) {
315
+ imagesRef.current = loadedImages;
316
+ setLoadedSignature(framesSignature);
317
+ }
318
+ }
319
+ void loadFrames();
320
+ return () => {
321
+ cancelled = true;
322
+ };
323
+ }, [frames, framesSignature]);
324
+ useEffect(() => {
325
+ if (!imagesLoaded) return;
326
+ function renderFrame() {
327
+ const canvas = canvasRef.current;
328
+ const container = containerRef.current;
329
+ const img = imagesRef.current[currentFrameIndex];
330
+ if (!canvas || !container) return;
331
+ drawFrameOnCanvas({ canvas, container, image: img, zoom, pan });
332
+ }
333
+ renderFrame();
334
+ window.addEventListener("resize", renderFrame);
335
+ return () => window.removeEventListener("resize", renderFrame);
336
+ }, [imagesLoaded, currentFrameIndex, zoom, pan]);
337
+ useEffect(() => {
338
+ if (!resolvedConfig.autoRotate || frames.length <= 1 || isDragging || isHotspotMode || zoom > minZoom) {
339
+ return;
340
+ }
341
+ const direction = resolvedConfig.autoRotateDirection === "backward" ? -1 : 1;
342
+ const interval = window.setInterval(() => {
343
+ onFrameChange((currentFrameIndex + direction + frames.length) % frames.length);
344
+ }, resolvedConfig.autoRotateIntervalMs);
345
+ return () => window.clearInterval(interval);
346
+ }, [
347
+ resolvedConfig.autoRotate,
348
+ resolvedConfig.autoRotateDirection,
349
+ resolvedConfig.autoRotateIntervalMs,
350
+ frames.length,
351
+ currentFrameIndex,
352
+ isDragging,
353
+ isHotspotMode,
354
+ zoom,
355
+ minZoom,
356
+ onFrameChange
357
+ ]);
358
+ function getCurrentImageLayout() {
359
+ const container = containerRef.current;
360
+ const image = imagesRef.current[currentFrameIndex];
361
+ if (!container || !image || !image.complete || !image.naturalWidth) return null;
362
+ const rect = container.getBoundingClientRect();
363
+ return computeViewerImageLayout({
364
+ containerWidth: rect.width,
365
+ containerHeight: rect.height,
366
+ imageWidth: image.naturalWidth,
367
+ imageHeight: image.naturalHeight,
368
+ pan
369
+ });
370
+ }
371
+ function getHotspotScreenPosition(hotspot) {
372
+ const layout = getCurrentImageLayout();
373
+ if (!layout) {
374
+ return { leftPercent: hotspot.positionX, topPercent: hotspot.positionY };
375
+ }
376
+ return computeHotspotScreenPosition(hotspot.positionX, hotspot.positionY, layout, zoom);
377
+ }
378
+ function handlePointerDown(event) {
379
+ if (isHotspotMode) return;
380
+ event.currentTarget.setPointerCapture(event.pointerId);
381
+ if (zoom > minZoom) {
382
+ panStartRef.current = { pointerX: event.clientX, pointerY: event.clientY, panX: pan.panX, panY: pan.panY };
383
+ } else {
384
+ dragStartRef.current = { pointerX: event.clientX, frameIndex: currentFrameIndex };
385
+ }
386
+ setIsDragging(true);
387
+ }
388
+ function handlePointerMove(event) {
389
+ if (!isDragging) return;
390
+ if (panStartRef.current && zoom > minZoom) {
391
+ setPan(computeViewerPanOffset(panStartRef.current, event.clientX, event.clientY));
392
+ return;
393
+ }
394
+ if (dragStartRef.current && zoom <= minZoom) {
395
+ const nextFrameIndex = computeDragFrameIndex(
396
+ dragStartRef.current,
397
+ event.clientX,
398
+ frames.length,
399
+ resolvedConfig.dragSensitivity
400
+ );
401
+ if (nextFrameIndex !== null) {
402
+ onFrameChange(nextFrameIndex);
403
+ }
404
+ }
405
+ }
406
+ function handlePointerUp(event) {
407
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
408
+ event.currentTarget.releasePointerCapture(event.pointerId);
409
+ }
410
+ dragStartRef.current = null;
411
+ panStartRef.current = null;
412
+ setIsDragging(false);
413
+ }
414
+ function handleWheel(event) {
415
+ event.preventDefault();
416
+ const { zoom: nextZoom, pan: nextPan } = applyWheelZoom(zoom, event.deltaY, pan, resolvedConfig);
417
+ setZoom(nextZoom);
418
+ setPan(nextPan);
419
+ }
420
+ function handleCanvasClick(event) {
421
+ if (!isHotspotMode || isDragging || !onHotspotAdd) return;
422
+ const frame = frames[currentFrameIndex];
423
+ if (!frame) return;
424
+ const layout = getCurrentImageLayout();
425
+ if (!layout) return;
426
+ const { positionX, positionY } = computeHotspotPositionFromClick(
427
+ event.clientX,
428
+ event.clientY,
429
+ event.currentTarget.getBoundingClientRect(),
430
+ layout,
431
+ zoom
432
+ );
433
+ onHotspotAdd({
434
+ frameIndex: currentFrameIndex,
435
+ frameId: frame.id,
436
+ positionX,
437
+ positionY
438
+ });
439
+ }
440
+ function handleResetView() {
441
+ setZoom(minZoom);
442
+ setPan({ panX: 0, panY: 0 });
443
+ }
444
+ function handleZoomIn() {
445
+ setZoom(stepZoomIn(zoom, resolvedConfig));
446
+ }
447
+ function handleZoomOut() {
448
+ const { zoom: nextZoom, pan: nextPan } = stepZoomOut(zoom, pan, resolvedConfig);
449
+ setZoom(nextZoom);
450
+ setPan(nextPan);
451
+ }
452
+ return {
453
+ canvasRef,
454
+ containerRef,
455
+ currentFrame,
456
+ currentFrameHotspots,
457
+ imagesLoaded,
458
+ isHotspotMode,
459
+ isResetDisabled: resetDisabled,
460
+ maxZoom,
461
+ minZoom,
462
+ viewerCursorClass,
463
+ zoom,
464
+ getCurrentImageLayout,
465
+ getHotspotScreenPosition,
466
+ handleCanvasClick,
467
+ handlePointerDown,
468
+ handlePointerMove,
469
+ handlePointerUp,
470
+ handleResetView,
471
+ handleWheel,
472
+ handleZoomIn,
473
+ handleZoomOut
474
+ };
475
+ }
476
+
477
+ // src/components/ui/Card/index.tsx
478
+ import { jsx } from "react/jsx-runtime";
479
+ function Card({ className, size = "default", ...props }) {
480
+ return /* @__PURE__ */ jsx(
481
+ "div",
482
+ {
483
+ "data-slot": "card",
484
+ "data-size": size,
485
+ className: cn(
486
+ "ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-6 text-sm shadow-xs ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col",
487
+ className
488
+ ),
489
+ ...props
490
+ }
491
+ );
492
+ }
493
+ function CardFooter({ className, ...props }) {
494
+ return /* @__PURE__ */ jsx(
495
+ "div",
496
+ {
497
+ "data-slot": "card-footer",
498
+ className: cn(
499
+ "rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4 flex items-center",
500
+ className
501
+ ),
502
+ ...props
503
+ }
504
+ );
505
+ }
506
+
507
+ // src/components/ui/Badge/index.tsx
508
+ import { cva } from "class-variance-authority";
509
+ import { Slot } from "radix-ui";
510
+ import { jsx as jsx2 } from "react/jsx-runtime";
511
+ var badgeVariants = cva(
512
+ "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-colors overflow-hidden group/badge",
513
+ {
514
+ variants: {
515
+ variant: {
516
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
517
+ secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
518
+ destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
519
+ outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
520
+ ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
521
+ link: "text-primary underline-offset-4 hover:underline",
522
+ info: "bg-blue-500 text-white hover:bg-blue-600",
523
+ warning: "bg-amber-500 text-white hover:bg-amber-600",
524
+ success: "bg-green-600 text-white hover:bg-green-700"
525
+ }
526
+ },
527
+ defaultVariants: {
528
+ variant: "default"
529
+ }
530
+ }
531
+ );
532
+ function Badge({
533
+ className,
534
+ variant = "default",
535
+ asChild = false,
536
+ ...props
537
+ }) {
538
+ const Comp = asChild ? Slot.Root : "span";
539
+ return /* @__PURE__ */ jsx2(Comp, { "data-slot": "badge", "data-variant": variant, className: cn(badgeVariants({ variant }), className), ...props });
540
+ }
541
+
542
+ // src/feature/Viewer360AddModeBanner.tsx
543
+ import { jsx as jsx3 } from "react/jsx-runtime";
544
+ function Viewer360AddModeBanner({ className, label }) {
545
+ return /* @__PURE__ */ jsx3(Badge, { variant: "outline", className: cn("pointer-events-none", className), children: label });
546
+ }
547
+
548
+ // src/feature/Viewer360FrameIndicator.tsx
549
+ import { jsx as jsx4 } from "react/jsx-runtime";
550
+ function Viewer360FrameIndicator({ className, label }) {
551
+ return /* @__PURE__ */ jsx4(Badge, { variant: "outline", className: cn("pointer-events-none shadow-sm", className), children: label });
552
+ }
553
+
554
+ // src/helpers/markerHelpers.ts
555
+ function hotspotToViewer360Marker(hotspot) {
556
+ const data = hotspot.data;
557
+ if (data && typeof data === "object" && "title" in data && typeof data.title === "string") {
558
+ const marker = data;
559
+ return {
560
+ id: marker.id ?? hotspot.id,
561
+ title: marker.title,
562
+ description: marker.description
563
+ };
564
+ }
565
+ return {
566
+ id: hotspot.id,
567
+ title: hotspot.id
568
+ };
569
+ }
570
+ function toViewer360Hotspots(markers, mapData) {
571
+ return markers.map((marker) => ({
572
+ id: marker.id,
573
+ frameIndex: marker.frameIndex,
574
+ positionX: marker.positionX,
575
+ positionY: marker.positionY,
576
+ data: mapData ? mapData(marker) : marker
577
+ }));
578
+ }
579
+
580
+ // src/components/ui/Button/index.tsx
581
+ import { cva as cva2 } from "class-variance-authority";
582
+ import { Slot as Slot2 } from "radix-ui";
583
+ import { jsx as jsx5 } from "react/jsx-runtime";
584
+ var buttonVariants = cva2(
585
+ "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
586
+ {
587
+ variants: {
588
+ variant: {
589
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
590
+ outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
591
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
592
+ ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
593
+ destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
594
+ link: "text-primary underline-offset-4 hover:underline"
595
+ },
596
+ size: {
597
+ default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
598
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5",
599
+ sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5",
600
+ lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-3 has-data-[icon=inline-start]:ps-3",
601
+ icon: "size-9",
602
+ "icon-xs": "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md",
603
+ "icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
604
+ "icon-lg": "size-10"
605
+ }
606
+ },
607
+ defaultVariants: {
608
+ variant: "default",
609
+ size: "default"
610
+ }
611
+ }
612
+ );
613
+ function Button({
614
+ className,
615
+ variant = "default",
616
+ size = "default",
617
+ asChild = false,
618
+ ...props
619
+ }) {
620
+ const Comp = asChild ? Slot2.Root : "button";
621
+ return /* @__PURE__ */ jsx5(
622
+ Comp,
623
+ {
624
+ "data-slot": "button",
625
+ "data-variant": variant,
626
+ "data-size": size,
627
+ className: cn(buttonVariants({ variant, size, className })),
628
+ ...props
629
+ }
630
+ );
631
+ }
632
+
633
+ // src/components/ui/Item/index.tsx
634
+ import { cva as cva3 } from "class-variance-authority";
635
+ import { Slot as Slot3 } from "radix-ui";
636
+
637
+ // src/components/ui/Separator/index.tsx
638
+ import { Separator as SeparatorPrimitive } from "radix-ui";
639
+ import { jsx as jsx6 } from "react/jsx-runtime";
640
+ function Separator({
641
+ className,
642
+ orientation = "horizontal",
643
+ decorative = true,
644
+ ...props
645
+ }) {
646
+ return /* @__PURE__ */ jsx6(
647
+ SeparatorPrimitive.Root,
648
+ {
649
+ "data-slot": "separator",
650
+ decorative,
651
+ orientation,
652
+ className: cn(
653
+ "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
654
+ className
655
+ ),
656
+ ...props
657
+ }
658
+ );
659
+ }
660
+
661
+ // src/components/ui/Item/index.tsx
662
+ import { jsx as jsx7 } from "react/jsx-runtime";
663
+ var itemVariants = cva3(
664
+ "[a]:hover:bg-muted rounded-md border text-sm w-full group/item focus-visible:border-ring focus-visible:ring-ring/50 flex items-center flex-wrap outline-none transition-colors duration-100 focus-visible:ring-[3px] [a]:transition-colors",
665
+ {
666
+ variants: {
667
+ variant: {
668
+ default: "border-transparent",
669
+ outline: "border-border",
670
+ muted: "bg-muted/50 border-transparent"
671
+ },
672
+ size: {
673
+ default: "gap-3.5 px-4 py-3.5",
674
+ sm: "gap-2.5 px-3 py-2.5",
675
+ xs: "gap-2 px-2.5 py-2 [[data-slot=dropdown-menu-content]_&]:p-0"
676
+ }
677
+ },
678
+ defaultVariants: {
679
+ variant: "default",
680
+ size: "default"
681
+ }
682
+ }
683
+ );
684
+ function Item({
685
+ className,
686
+ variant = "default",
687
+ size = "default",
688
+ asChild = false,
689
+ ...props
690
+ }) {
691
+ const Comp = asChild ? Slot3.Root : "div";
692
+ return /* @__PURE__ */ jsx7(
693
+ Comp,
694
+ {
695
+ "data-slot": "item",
696
+ "data-variant": variant,
697
+ "data-size": size,
698
+ className: cn(itemVariants({ variant, size, className })),
699
+ ...props
700
+ }
701
+ );
702
+ }
703
+ var itemMediaVariants = cva3(
704
+ "gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start flex shrink-0 items-center justify-center [&_svg]:pointer-events-none",
705
+ {
706
+ variants: {
707
+ variant: {
708
+ default: "bg-transparent",
709
+ icon: "[&_svg]:size-4",
710
+ image: "size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover"
711
+ }
712
+ },
713
+ defaultVariants: {
714
+ variant: "default"
715
+ }
716
+ }
717
+ );
718
+ function ItemContent({ className, ...props }) {
719
+ return /* @__PURE__ */ jsx7(
720
+ "div",
721
+ {
722
+ "data-slot": "item-content",
723
+ className: cn("gap-1 group-data-[size=xs]/item:gap-0 flex flex-1 flex-col [&+[data-slot=item-content]]:flex-none", className),
724
+ ...props
725
+ }
726
+ );
727
+ }
728
+ function ItemTitle({ className, ...props }) {
729
+ return /* @__PURE__ */ jsx7(
730
+ "div",
731
+ {
732
+ "data-slot": "item-title",
733
+ className: cn("gap-2 text-sm leading-snug font-medium underline-offset-4 line-clamp-1 flex w-fit items-center", className),
734
+ ...props
735
+ }
736
+ );
737
+ }
738
+ function ItemDescription({ className, ...props }) {
739
+ return /* @__PURE__ */ jsx7(
740
+ "p",
741
+ {
742
+ "data-slot": "item-description",
743
+ className: cn(
744
+ "text-muted-foreground text-left text-sm leading-normal group-data-[size=xs]/item:text-xs [&>a:hover]:text-primary line-clamp-2 font-normal [&>a]:underline [&>a]:underline-offset-4",
745
+ className
746
+ ),
747
+ ...props
748
+ }
749
+ );
750
+ }
751
+ function ItemActions({ className, ...props }) {
752
+ return /* @__PURE__ */ jsx7("div", { "data-slot": "item-actions", className: cn("gap-2 flex items-center", className), ...props });
753
+ }
754
+
755
+ // src/feature/Viewer360MarkerPin.tsx
756
+ import { useState as useState2 } from "react";
757
+ import { Trash2 } from "lucide-react";
758
+
759
+ // src/constants/viewer360MarkerLabels.ts
760
+ var defaultViewer360MarkerPinLabels = {
761
+ delete: "Remove marker"
762
+ };
763
+
764
+ // src/components/ui/Popover/index.tsx
765
+ import { Popover as PopoverPrimitive } from "radix-ui";
766
+ import { jsx as jsx8 } from "react/jsx-runtime";
767
+ function Popover({ ...props }) {
768
+ return /* @__PURE__ */ jsx8(PopoverPrimitive.Root, { "data-slot": "popover", ...props });
769
+ }
770
+ function PopoverTrigger({ ...props }) {
771
+ return /* @__PURE__ */ jsx8(PopoverPrimitive.Trigger, { "data-slot": "popover-trigger", ...props });
772
+ }
773
+ function PopoverContent({
774
+ className,
775
+ align = "center",
776
+ sideOffset = 4,
777
+ ...props
778
+ }) {
779
+ return /* @__PURE__ */ jsx8(PopoverPrimitive.Portal, { children: /* @__PURE__ */ jsx8(
780
+ PopoverPrimitive.Content,
781
+ {
782
+ "data-slot": "popover-content",
783
+ align,
784
+ sideOffset,
785
+ className: cn(
786
+ "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-4 rounded-md p-4 text-sm shadow-md ring-1 duration-100 z-50 w-72 origin-(--radix-popover-content-transform-origin) outline-hidden",
787
+ className
788
+ ),
789
+ ...props
790
+ }
791
+ ) });
792
+ }
793
+
794
+ // src/feature/Viewer360MarkerPin.tsx
795
+ import { jsx as jsx9, jsxs } from "react/jsx-runtime";
796
+ function Viewer360MarkerPin({
797
+ marker,
798
+ hotspot,
799
+ leftPercent,
800
+ topPercent,
801
+ onDelete,
802
+ isDeletePending = false,
803
+ renderTag,
804
+ classNames,
805
+ labels
806
+ }) {
807
+ const [isOpen, setIsOpen] = useState2(false);
808
+ const deleteLabel = labels?.delete ?? defaultViewer360MarkerPinLabels.delete;
809
+ return /* @__PURE__ */ jsx9(Popover, { open: isOpen, onOpenChange: setIsOpen, children: /* @__PURE__ */ jsxs(
810
+ Item,
811
+ {
812
+ size: "xs",
813
+ variant: "default",
814
+ className: cn(viewer360MarkerPinClassNames.root, classNames?.root, "w-auto border-transparent p-0"),
815
+ style: { left: `${leftPercent}%`, top: `${topPercent}%` },
816
+ onMouseEnter: () => setIsOpen(true),
817
+ onMouseLeave: () => setIsOpen(false),
818
+ children: [
819
+ /* @__PURE__ */ jsx9(
820
+ Badge,
821
+ {
822
+ variant: "destructive",
823
+ className: cn(viewer360MarkerPinClassNames.ping, classNames?.ping, "absolute size-6 border-0 bg-destructive opacity-60"),
824
+ "aria-hidden": "true"
825
+ }
826
+ ),
827
+ /* @__PURE__ */ jsx9(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx9(
828
+ Button,
829
+ {
830
+ type: "button",
831
+ variant: "destructive",
832
+ size: "icon-xs",
833
+ className: cn(
834
+ viewer360MarkerPinClassNames.dot,
835
+ classNames?.dot,
836
+ "size-4 min-h-4 min-w-4 rounded-full border-2 border-background bg-destructive p-0 shadow-md hover:scale-125 hover:bg-destructive"
837
+ ),
838
+ "aria-label": marker.title
839
+ }
840
+ ) }),
841
+ /* @__PURE__ */ jsx9(
842
+ PopoverContent,
843
+ {
844
+ className: cn(viewer360MarkerPinClassNames.tooltip, classNames?.tooltip, "w-64 p-0"),
845
+ side: "top",
846
+ align: "center",
847
+ onOpenAutoFocus: (event) => event.preventDefault(),
848
+ children: /* @__PURE__ */ jsxs(
849
+ Item,
850
+ {
851
+ size: "sm",
852
+ variant: "default",
853
+ className: cn(viewer360MarkerPinClassNames.tooltipHeader, classNames?.tooltipHeader, "w-full border-transparent"),
854
+ children: [
855
+ /* @__PURE__ */ jsxs(ItemContent, { className: cn(viewer360MarkerPinClassNames.tooltipBody, classNames?.tooltipBody), children: [
856
+ /* @__PURE__ */ jsx9(ItemTitle, { className: cn(viewer360MarkerPinClassNames.tooltipTitle, classNames?.tooltipTitle), children: marker.title }),
857
+ renderTag?.({ marker, hotspot }),
858
+ marker.description && /* @__PURE__ */ jsx9(ItemDescription, { className: cn(viewer360MarkerPinClassNames.tooltipDescription, classNames?.tooltipDescription), children: marker.description })
859
+ ] }),
860
+ onDelete && /* @__PURE__ */ jsx9(ItemActions, { children: /* @__PURE__ */ jsx9(
861
+ Button,
862
+ {
863
+ variant: "ghost",
864
+ size: "icon-sm",
865
+ className: classNames?.deleteButton,
866
+ disabled: isDeletePending,
867
+ "aria-label": deleteLabel,
868
+ onClick: (event) => {
869
+ event.stopPropagation();
870
+ onDelete(marker.id);
871
+ },
872
+ children: /* @__PURE__ */ jsx9(Trash2, { className: "size-4" })
873
+ }
874
+ ) })
875
+ ]
876
+ }
877
+ )
878
+ }
879
+ )
880
+ ]
881
+ }
882
+ ) });
883
+ }
884
+
885
+ // src/feature/Viewer360HotspotOverlay.tsx
886
+ import { jsx as jsx10 } from "react/jsx-runtime";
887
+ function Viewer360HotspotOverlay({
888
+ hotspot,
889
+ leftPercent,
890
+ topPercent,
891
+ hotspotPin,
892
+ renderHotspot,
893
+ onHotspotClick
894
+ }) {
895
+ if (renderHotspot) {
896
+ return /* @__PURE__ */ jsx10(Item, { size: "xs", variant: "default", className: "pointer-events-auto w-auto border-transparent p-0", children: renderHotspot({ hotspot, leftPercent, topPercent }) });
897
+ }
898
+ if (hotspotPin) {
899
+ const marker = hotspotPin.getMarker?.(hotspot) ?? hotspotToViewer360Marker(hotspot);
900
+ return /* @__PURE__ */ jsx10(
901
+ Viewer360MarkerPin,
902
+ {
903
+ marker,
904
+ hotspot,
905
+ leftPercent,
906
+ topPercent,
907
+ onDelete: hotspotPin.onDelete,
908
+ isDeletePending: hotspotPin.deletingMarkerId === hotspot.id,
909
+ renderTag: hotspotPin.renderTag,
910
+ classNames: hotspotPin.classNames,
911
+ labels: hotspotPin.labels
912
+ }
913
+ );
914
+ }
915
+ return /* @__PURE__ */ jsx10(
916
+ Button,
917
+ {
918
+ type: "button",
919
+ variant: "destructive",
920
+ size: "icon-xs",
921
+ className: cn(
922
+ "pointer-events-auto absolute z-30 size-4 min-h-4 min-w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-destructive p-0 shadow-md hover:bg-destructive"
923
+ ),
924
+ style: { left: `${leftPercent}%`, top: `${topPercent}%` },
925
+ "aria-label": `Hotspot ${hotspot.id}`,
926
+ onClick: (event) => onHotspotClick?.(hotspot, event)
927
+ }
928
+ );
929
+ }
930
+
931
+ // src/components/ui/Label/index.tsx
932
+ import { jsx as jsx11 } from "react/jsx-runtime";
933
+ function Label({ className, ...props }) {
934
+ return /* @__PURE__ */ jsx11(
935
+ "label",
936
+ {
937
+ "data-slot": "label",
938
+ className: cn(
939
+ "gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
940
+ className
941
+ ),
942
+ ...props
943
+ }
944
+ );
945
+ }
946
+
947
+ // src/components/ui/Spinner/index.tsx
948
+ import { Loader2Icon } from "lucide-react";
949
+ import { jsx as jsx12 } from "react/jsx-runtime";
950
+ function Spinner({ className, ...props }) {
951
+ return /* @__PURE__ */ jsx12(Loader2Icon, { role: "status", "aria-label": "Loading", className: cn("size-4 animate-spin", className), ...props });
952
+ }
953
+
954
+ // src/feature/Viewer360LoadingOverlay.tsx
955
+ import { jsx as jsx13, jsxs as jsxs2 } from "react/jsx-runtime";
956
+ function Viewer360LoadingOverlay({ className, textClassName, label }) {
957
+ return /* @__PURE__ */ jsxs2(
958
+ Item,
959
+ {
960
+ size: "sm",
961
+ variant: "muted",
962
+ className: cn("pointer-events-none w-auto justify-center border-transparent bg-muted/80", className),
963
+ children: [
964
+ /* @__PURE__ */ jsx13(Spinner, { className: "size-5 text-muted-foreground" }),
965
+ /* @__PURE__ */ jsx13(Label, { className: cn("font-normal text-muted-foreground", textClassName), children: label })
966
+ ]
967
+ }
968
+ );
969
+ }
970
+
971
+ // src/feature/Viewer360Toolbar.tsx
972
+ import { Crosshair, Minus, Plus, RotateCcw, ZoomIn } from "lucide-react";
973
+ import { Fragment, jsx as jsx14, jsxs as jsxs3 } from "react/jsx-runtime";
974
+ function Viewer360Toolbar({
975
+ showDragHint,
976
+ showHotspotModeControl,
977
+ showZoomControls,
978
+ showResetControl,
979
+ labels,
980
+ isHotspotMode,
981
+ zoom,
982
+ minZoom,
983
+ maxZoom,
984
+ isResetDisabled: isResetDisabled2,
985
+ onHotspotModeChange,
986
+ onZoomIn,
987
+ onZoomOut,
988
+ onResetView
989
+ }) {
990
+ return /* @__PURE__ */ jsxs3(CardFooter, { className: cn(viewer360ClassNames.toolbar, "gap-2 border-t px-4 py-3 pt-3"), children: [
991
+ showDragHint && /* @__PURE__ */ jsx14(Label, { className: cn(viewer360ClassNames.dragHint, "font-normal text-muted-foreground"), children: labels.dragHint }),
992
+ /* @__PURE__ */ jsxs3("div", { className: viewer360ClassNames.controls, children: [
993
+ showHotspotModeControl && /* @__PURE__ */ jsxs3(Fragment, { children: [
994
+ /* @__PURE__ */ jsxs3(Button, { variant: isHotspotMode ? "default" : "outline", size: "sm", onClick: () => onHotspotModeChange(!isHotspotMode), children: [
995
+ /* @__PURE__ */ jsx14(Crosshair, { className: "me-1.5 size-4" }),
996
+ labels.addHotspot
997
+ ] }),
998
+ /* @__PURE__ */ jsx14(Separator, { orientation: "vertical", className: cn(viewer360ClassNames.divider, "h-6") })
999
+ ] }),
1000
+ showZoomControls && /* @__PURE__ */ jsxs3(Fragment, { children: [
1001
+ /* @__PURE__ */ jsx14(Button, { variant: "outline", size: "icon-sm", disabled: zoom <= minZoom, "aria-label": labels.zoomOut, onClick: onZoomOut, children: /* @__PURE__ */ jsx14(Minus, { className: "size-4" }) }),
1002
+ /* @__PURE__ */ jsxs3(Badge, { variant: "outline", className: cn(viewer360ClassNames.zoomDisplay, "h-8 gap-1 px-2 py-1"), children: [
1003
+ /* @__PURE__ */ jsx14(ZoomIn, { className: "size-3 text-muted-foreground" }),
1004
+ labels.zoom(Math.round(zoom * 100))
1005
+ ] }),
1006
+ /* @__PURE__ */ jsx14(Button, { variant: "outline", size: "icon-sm", disabled: zoom >= maxZoom, "aria-label": labels.zoomIn, onClick: onZoomIn, children: /* @__PURE__ */ jsx14(Plus, { className: "size-4" }) })
1007
+ ] }),
1008
+ showResetControl && /* @__PURE__ */ jsx14(Button, { variant: "outline", size: "icon-sm", disabled: isResetDisabled2, "aria-label": labels.resetView, onClick: onResetView, children: /* @__PURE__ */ jsx14(RotateCcw, { className: "size-4" }) })
1009
+ ] })
1010
+ ] });
1011
+ }
1012
+
1013
+ // src/feature/Viewer360.tsx
1014
+ import { jsx as jsx15, jsxs as jsxs4 } from "react/jsx-runtime";
1015
+ function Viewer360({
1016
+ frames,
1017
+ currentFrameIndex: controlledFrameIndex,
1018
+ defaultFrameIndex = 0,
1019
+ onFrameChange,
1020
+ config,
1021
+ className,
1022
+ classNames,
1023
+ style,
1024
+ theme,
1025
+ labels,
1026
+ aspectRatio = "16 / 10",
1027
+ showZoomControls = true,
1028
+ showResetControl = true,
1029
+ showFrameIndicator = true,
1030
+ showDragHint = true,
1031
+ showHotspotModeControl = false,
1032
+ hotspotPin,
1033
+ hotspots = [],
1034
+ renderHotspot,
1035
+ renderLoading,
1036
+ renderFrameIndicator,
1037
+ renderHotspotModeBanner,
1038
+ renderToolbar,
1039
+ onHotspotClick,
1040
+ hotspotMode: controlledHotspotMode,
1041
+ defaultHotspotMode = false,
1042
+ onHotspotModeChange,
1043
+ onHotspotAdd,
1044
+ children
1045
+ }) {
1046
+ const mergedLabels = useMemo2(() => mergeViewer360Labels(labels), [labels]);
1047
+ const mergedClassNames = useMemo2(() => mergeViewer360ClassNames(classNames), [classNames]);
1048
+ const themeStyle = useMemo2(() => buildViewer360ThemeStyle(theme), [theme]);
1049
+ const [internalFrameIndex, setInternalFrameIndex] = useState3(defaultFrameIndex);
1050
+ const [internalHotspotMode, setInternalHotspotMode] = useState3(defaultHotspotMode);
1051
+ const currentFrameIndex = controlledFrameIndex ?? internalFrameIndex;
1052
+ const hotspotMode = controlledHotspotMode ?? internalHotspotMode;
1053
+ function handleFrameChange(index) {
1054
+ if (controlledFrameIndex === void 0) {
1055
+ setInternalFrameIndex(index);
1056
+ }
1057
+ onFrameChange?.(index);
1058
+ }
1059
+ function handleHotspotModeChange(active) {
1060
+ if (controlledHotspotMode === void 0) {
1061
+ setInternalHotspotMode(active);
1062
+ }
1063
+ onHotspotModeChange?.(active);
1064
+ }
1065
+ const {
1066
+ canvasRef,
1067
+ containerRef,
1068
+ currentFrame,
1069
+ currentFrameHotspots,
1070
+ imagesLoaded,
1071
+ isHotspotMode,
1072
+ isResetDisabled: isResetDisabled2,
1073
+ maxZoom,
1074
+ minZoom,
1075
+ viewerCursorClass,
1076
+ zoom,
1077
+ getHotspotScreenPosition,
1078
+ handleCanvasClick,
1079
+ handlePointerDown,
1080
+ handlePointerMove,
1081
+ handlePointerUp,
1082
+ handleWheel,
1083
+ handleResetView,
1084
+ handleZoomIn,
1085
+ handleZoomOut
1086
+ } = useViewer360({
1087
+ frames,
1088
+ hotspots,
1089
+ currentFrameIndex,
1090
+ onFrameChange: handleFrameChange,
1091
+ config,
1092
+ hotspotMode,
1093
+ onHotspotAdd
1094
+ });
1095
+ useEffect2(() => {
1096
+ if (controlledHotspotMode === void 0) return;
1097
+ if (controlledHotspotMode !== internalHotspotMode) {
1098
+ setInternalHotspotMode(controlledHotspotMode);
1099
+ }
1100
+ }, [controlledHotspotMode, internalHotspotMode]);
1101
+ const frameLabel = currentFrame?.label ?? frames[currentFrameIndex]?.label;
1102
+ const overlayProps = {
1103
+ currentFrameIndex,
1104
+ frameCount: frames.length,
1105
+ frameLabel,
1106
+ isHotspotMode,
1107
+ labels: mergedLabels
1108
+ };
1109
+ const toolbarProps = {
1110
+ zoom,
1111
+ minZoom,
1112
+ maxZoom,
1113
+ isResetDisabled: isResetDisabled2,
1114
+ isHotspotMode,
1115
+ showHotspotModeControl,
1116
+ showZoomControls,
1117
+ showResetControl,
1118
+ showDragHint,
1119
+ labels: mergedLabels,
1120
+ onZoomIn: handleZoomIn,
1121
+ onZoomOut: handleZoomOut,
1122
+ onResetView: handleResetView,
1123
+ onHotspotModeChange: handleHotspotModeChange
1124
+ };
1125
+ const showDefaultToolbar = showZoomControls || showResetControl || showHotspotModeControl || showDragHint;
1126
+ return /* @__PURE__ */ jsxs4(Card, { className: cn(mergedClassNames.root, "gap-0 py-0 shadow-none ring-0", className), style: { ...themeStyle, ...style }, children: [
1127
+ /* @__PURE__ */ jsxs4(
1128
+ "div",
1129
+ {
1130
+ ref: containerRef,
1131
+ className: cn(mergedClassNames.viewport, viewerCursorClass),
1132
+ style: { aspectRatio },
1133
+ onPointerDown: handlePointerDown,
1134
+ onPointerMove: handlePointerMove,
1135
+ onPointerUp: handlePointerUp,
1136
+ onPointerLeave: handlePointerUp,
1137
+ onWheel: handleWheel,
1138
+ onClick: handleCanvasClick,
1139
+ children: [
1140
+ /* @__PURE__ */ jsx15("canvas", { ref: canvasRef, className: mergedClassNames.canvas }),
1141
+ /* @__PURE__ */ jsxs4("div", { className: mergedClassNames.overlay, children: [
1142
+ currentFrameHotspots.map((hotspot) => {
1143
+ const position = getHotspotScreenPosition(hotspot);
1144
+ return /* @__PURE__ */ jsx15(
1145
+ Viewer360HotspotOverlay,
1146
+ {
1147
+ hotspot,
1148
+ leftPercent: position.leftPercent,
1149
+ topPercent: position.topPercent,
1150
+ hotspotPin,
1151
+ renderHotspot,
1152
+ onHotspotClick
1153
+ },
1154
+ hotspot.id
1155
+ );
1156
+ }),
1157
+ children
1158
+ ] }),
1159
+ !imagesLoaded && (renderLoading ? renderLoading() : /* @__PURE__ */ jsx15(
1160
+ Viewer360LoadingOverlay,
1161
+ {
1162
+ className: mergedClassNames.loading,
1163
+ textClassName: mergedClassNames.loadingText,
1164
+ label: mergedLabels.loading
1165
+ }
1166
+ )),
1167
+ showFrameIndicator && frames.length > 0 && (renderFrameIndicator ? renderFrameIndicator(overlayProps) : /* @__PURE__ */ jsx15(
1168
+ Viewer360FrameIndicator,
1169
+ {
1170
+ className: mergedClassNames.frameIndicator,
1171
+ label: mergedLabels.frameIndicator({
1172
+ current: currentFrameIndex + 1,
1173
+ total: frames.length,
1174
+ label: frameLabel
1175
+ })
1176
+ }
1177
+ )),
1178
+ isHotspotMode && onHotspotAdd && (renderHotspotModeBanner ? renderHotspotModeBanner({ labels: mergedLabels }) : /* @__PURE__ */ jsx15(Viewer360AddModeBanner, { className: mergedClassNames.hotspotModeBanner, label: mergedLabels.hotspotModeActive }))
1179
+ ]
1180
+ }
1181
+ ),
1182
+ renderToolbar ? renderToolbar(toolbarProps) : showDefaultToolbar ? /* @__PURE__ */ jsx15(Viewer360Toolbar, { ...toolbarProps }) : null
1183
+ ] });
1184
+ }
1185
+
1186
+ // src/constants/viewer360Config.ts
1187
+ var viewer360Config = {
1188
+ minZoom: 1,
1189
+ maxZoom: 3,
1190
+ zoomStep: 0.15,
1191
+ dragSensitivity: 8,
1192
+ autoRotate: false,
1193
+ autoRotateIntervalMs: 100,
1194
+ autoRotateDirection: "forward"
1195
+ };
1196
+ var defaultViewer360Config = viewer360Config;
1197
+ export {
1198
+ Button,
1199
+ Viewer360,
1200
+ Viewer360AddModeBanner,
1201
+ Viewer360FrameIndicator,
1202
+ Viewer360LoadingOverlay,
1203
+ Viewer360MarkerPin,
1204
+ Viewer360Toolbar,
1205
+ applyWheelZoom,
1206
+ buttonVariants,
1207
+ clampFrameIndex,
1208
+ clampZoom,
1209
+ cn,
1210
+ computeDragFrameIndex,
1211
+ computeHotspotPositionFromClick,
1212
+ computeHotspotScreenPosition,
1213
+ computeViewerImageLayout,
1214
+ computeViewerPanOffset,
1215
+ defaultViewer360ClassNames,
1216
+ defaultViewer360Config,
1217
+ defaultViewer360Labels,
1218
+ defaultViewer360MarkerPinClassNames,
1219
+ defaultViewer360MarkerPinLabels,
1220
+ drawFrameOnCanvas,
1221
+ filterHotspotsByFrame,
1222
+ getFramesSignature,
1223
+ getViewerCursorClass,
1224
+ hasLoadedViewerFrame,
1225
+ hotspotToViewer360Marker,
1226
+ isResetDisabled,
1227
+ preloadFrameImage,
1228
+ preloadViewerFrames,
1229
+ resolveViewer360Config,
1230
+ stepZoomIn,
1231
+ stepZoomOut,
1232
+ toViewer360Hotspots,
1233
+ useViewer360,
1234
+ viewer360ClassNames,
1235
+ viewer360MarkerPinClassNames
1236
+ };
1237
+ //# sourceMappingURL=index.js.map