@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
@@ -0,0 +1,20 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import { cn } from '@/components/utils';
6
+
7
+ function Label({ className, ...props }: React.ComponentProps<'label'>): React.ReactNode {
8
+ return (
9
+ <label
10
+ data-slot="label"
11
+ className={cn(
12
+ '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',
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ );
18
+ }
19
+
20
+ export { Label };
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import type { JSX } from 'react';
5
+
6
+ import { Popover as PopoverPrimitive } from 'radix-ui';
7
+
8
+ import { cn } from '@/components/utils';
9
+
10
+ function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>): JSX.Element {
11
+ return <PopoverPrimitive.Root data-slot="popover" {...props} />;
12
+ }
13
+
14
+ function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>): JSX.Element {
15
+ return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
16
+ }
17
+
18
+ function PopoverContent({
19
+ className,
20
+ align = 'center',
21
+ sideOffset = 4,
22
+ ...props
23
+ }: React.ComponentProps<typeof PopoverPrimitive.Content>): JSX.Element {
24
+ return (
25
+ <PopoverPrimitive.Portal>
26
+ <PopoverPrimitive.Content
27
+ data-slot="popover-content"
28
+ align={align}
29
+ sideOffset={sideOffset}
30
+ className={cn(
31
+ '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',
32
+ className
33
+ )}
34
+ {...props}
35
+ />
36
+ </PopoverPrimitive.Portal>
37
+ );
38
+ }
39
+
40
+ function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>): JSX.Element {
41
+ return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
42
+ }
43
+
44
+ function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>): JSX.Element {
45
+ return <div data-slot="popover-header" className={cn('flex flex-col gap-1 text-sm', className)} {...props} />;
46
+ }
47
+
48
+ function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>): JSX.Element {
49
+ return <div data-slot="popover-title" className={cn('font-medium', className)} {...props} />;
50
+ }
51
+
52
+ function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>): JSX.Element {
53
+ return <p data-slot="popover-description" className={cn('text-muted-foreground', className)} {...props} />;
54
+ }
55
+
56
+ export { Popover, PopoverAnchor, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import type { JSX } from 'react';
5
+
6
+ import { Separator as SeparatorPrimitive } from 'radix-ui';
7
+
8
+ import { cn } from '@/components/utils';
9
+
10
+ function Separator({
11
+ className,
12
+ orientation = 'horizontal',
13
+ decorative = true,
14
+ ...props
15
+ }: React.ComponentProps<typeof SeparatorPrimitive.Root>): JSX.Element {
16
+ return (
17
+ <SeparatorPrimitive.Root
18
+ data-slot="separator"
19
+ decorative={decorative}
20
+ orientation={orientation}
21
+ className={cn(
22
+ 'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
23
+ className
24
+ )}
25
+ {...props}
26
+ />
27
+ );
28
+ }
29
+
30
+ export { Separator };
@@ -0,0 +1,11 @@
1
+ import type { JSX } from 'react';
2
+
3
+ import { Loader2Icon } from 'lucide-react';
4
+
5
+ import { cn } from '@/components/utils';
6
+
7
+ function Spinner({ className, ...props }: React.ComponentProps<'svg'>): JSX.Element {
8
+ return <Loader2Icon role="status" aria-label="Loading" className={cn('size-4 animate-spin', className)} {...props} />;
9
+ }
10
+
11
+ export { Spinner };
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,41 @@
1
+ import type { Viewer360ClassNames } from '../types/Viewer360Props';
2
+ import type { Viewer360MarkerPinClassNames } from '../types/Viewer360Marker';
3
+
4
+ export const viewer360ClassNames: Required<Viewer360ClassNames> = {
5
+ root: 'overflow-hidden rounded-lg border bg-card text-card-foreground',
6
+ viewport: 'relative aspect-[16/10] w-full touch-none select-none bg-muted',
7
+ canvas: 'absolute inset-0 size-full',
8
+ overlay: 'pointer-events-none absolute inset-0 overflow-hidden',
9
+ loading: 'absolute inset-0 flex items-center justify-center bg-muted/80',
10
+ loadingText: 'text-sm text-muted-foreground',
11
+ frameIndicator:
12
+ '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',
13
+ hotspotModeBanner:
14
+ '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',
15
+ toolbar: 'flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3',
16
+ dragHint: 'hidden text-xs text-muted-foreground sm:block',
17
+ controls: 'ms-auto flex items-center gap-1.5',
18
+ controlButton: '',
19
+ controlButtonActive: '',
20
+ controlButtonDisabled: '',
21
+ zoomDisplay: 'flex min-w-[3rem] items-center justify-center gap-1 rounded-md border bg-background px-2 py-1 text-xs font-medium',
22
+ divider: 'mx-1 h-6 w-px bg-border',
23
+ };
24
+
25
+ export const viewer360MarkerPinClassNames: Required<Viewer360MarkerPinClassNames> = {
26
+ root: 'pointer-events-auto absolute z-30 -translate-x-1/2 -translate-y-1/2',
27
+ ping: 'absolute inline-flex size-6 -translate-x-1/4 -translate-y-1/4 animate-ping rounded-full bg-destructive opacity-60',
28
+ 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',
29
+ 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',
30
+ tooltipHeader: 'flex items-start justify-between gap-2',
31
+ tooltipBody: 'flex min-w-0 flex-col gap-1',
32
+ tooltipTitle: 'text-sm font-medium',
33
+ tooltipDescription: 'mt-2 line-clamp-3 text-xs text-muted-foreground',
34
+ deleteButton: '',
35
+ };
36
+
37
+ /** @deprecated Use `viewer360ClassNames` */
38
+ export const defaultViewer360ClassNames = viewer360ClassNames;
39
+
40
+ /** @deprecated Use `viewer360MarkerPinClassNames` */
41
+ export const defaultViewer360MarkerPinClassNames = viewer360MarkerPinClassNames;
@@ -0,0 +1,11 @@
1
+ export const viewer360Config = {
2
+ minZoom: 1,
3
+ maxZoom: 3,
4
+ zoomStep: 0.15,
5
+ dragSensitivity: 8,
6
+ autoRotate: false,
7
+ autoRotateIntervalMs: 100,
8
+ autoRotateDirection: 'forward' as const,
9
+ };
10
+
11
+ export const defaultViewer360Config = viewer360Config;
@@ -0,0 +1,14 @@
1
+ import type { Viewer360Labels } from '../types/Viewer360Props';
2
+
3
+ export const defaultViewer360Labels: Required<Viewer360Labels> = {
4
+ loading: 'Loading images…',
5
+ dragHint: 'Drag to rotate • Scroll to zoom',
6
+ frameIndicator: ({ current, total, label }) => (label ? `${label} (${current}/${total})` : `${current} / ${total}`),
7
+ zoom: (percent) => `${percent}%`,
8
+ hotspotModeActive: 'Click on the image to place a hotspot',
9
+ addHotspot: 'Add hotspot',
10
+ zoomIn: 'Zoom in',
11
+ zoomOut: 'Zoom out',
12
+ resetView: 'Reset view',
13
+ deleteMarker: 'Remove marker',
14
+ };
@@ -0,0 +1,3 @@
1
+ export const defaultViewer360MarkerPinLabels = {
2
+ delete: 'Remove marker',
3
+ } as const;
@@ -0,0 +1,47 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { Viewer360 } from './Viewer360';
5
+ import type { Viewer360Frame } from '../types';
6
+
7
+ const frames: Viewer360Frame[] = [
8
+ { id: '1', src: 'https://example.com/1.jpg', label: 'Front' },
9
+ { id: '2', src: 'https://example.com/2.jpg', label: 'Side' },
10
+ ];
11
+
12
+ describe('Viewer360', () => {
13
+ it('renders loading state and toolbar labels', () => {
14
+ render(
15
+ <Viewer360
16
+ frames={frames}
17
+ labels={{
18
+ loading: 'Loading test frames',
19
+ dragHint: 'Drag test hint',
20
+ }}
21
+ />
22
+ );
23
+
24
+ expect(screen.getByText('Loading test frames')).toBeInTheDocument();
25
+ expect(screen.getByText('Drag test hint')).toBeInTheDocument();
26
+ });
27
+
28
+ it('calls onFrameChange from auto-rotate when enabled', () => {
29
+ vi.useFakeTimers();
30
+
31
+ const onFrameChange = vi.fn();
32
+
33
+ render(
34
+ <Viewer360
35
+ frames={frames}
36
+ currentFrameIndex={0}
37
+ onFrameChange={onFrameChange}
38
+ config={{ autoRotate: true, autoRotateIntervalMs: 50 }}
39
+ />
40
+ );
41
+
42
+ vi.advanceTimersByTime(60);
43
+ expect(onFrameChange).toHaveBeenCalledWith(1);
44
+
45
+ vi.useRealTimers();
46
+ });
47
+ });
@@ -0,0 +1,218 @@
1
+ import type { JSX } from 'react';
2
+ import { useEffect, useMemo, useState } from 'react';
3
+
4
+ import { buildViewer360ThemeStyle, mergeViewer360ClassNames, mergeViewer360Labels } from '../helpers/viewer360PropsHelpers';
5
+ import { useViewer360 } from '../hooks/useViewer360';
6
+ import type { Viewer360OverlayRenderProps, Viewer360Props, Viewer360ToolbarRenderProps } from '../types';
7
+ import { Card } from '@/components/ui/Card';
8
+ import { cn } from '@/components/utils';
9
+
10
+ import { Viewer360AddModeBanner } from './Viewer360AddModeBanner';
11
+ import { Viewer360FrameIndicator } from './Viewer360FrameIndicator';
12
+ import { Viewer360HotspotOverlay } from './Viewer360HotspotOverlay';
13
+ import { Viewer360LoadingOverlay } from './Viewer360LoadingOverlay';
14
+ import { Viewer360Toolbar } from './Viewer360Toolbar';
15
+
16
+ export function Viewer360<TData = unknown>({
17
+ frames,
18
+ currentFrameIndex: controlledFrameIndex,
19
+ defaultFrameIndex = 0,
20
+ onFrameChange,
21
+ config,
22
+ className,
23
+ classNames,
24
+ style,
25
+ theme,
26
+ labels,
27
+ aspectRatio = '16 / 10',
28
+ showZoomControls = true,
29
+ showResetControl = true,
30
+ showFrameIndicator = true,
31
+ showDragHint = true,
32
+ showHotspotModeControl = false,
33
+ hotspotPin,
34
+ hotspots = [],
35
+ renderHotspot,
36
+ renderLoading,
37
+ renderFrameIndicator,
38
+ renderHotspotModeBanner,
39
+ renderToolbar,
40
+ onHotspotClick,
41
+ hotspotMode: controlledHotspotMode,
42
+ defaultHotspotMode = false,
43
+ onHotspotModeChange,
44
+ onHotspotAdd,
45
+ children,
46
+ }: Viewer360Props<TData>): JSX.Element {
47
+ // ----------------------------------------------------------------------------------------------------
48
+ // MARK: States & Constants
49
+ // ----------------------------------------------------------------------------------------------------
50
+ const mergedLabels = useMemo(() => mergeViewer360Labels(labels), [labels]);
51
+ const mergedClassNames = useMemo(() => mergeViewer360ClassNames(classNames), [classNames]);
52
+ const themeStyle = useMemo(() => buildViewer360ThemeStyle(theme), [theme]);
53
+
54
+ const [internalFrameIndex, setInternalFrameIndex] = useState(defaultFrameIndex);
55
+ const [internalHotspotMode, setInternalHotspotMode] = useState(defaultHotspotMode);
56
+
57
+ const currentFrameIndex = controlledFrameIndex ?? internalFrameIndex;
58
+ const hotspotMode = controlledHotspotMode ?? internalHotspotMode;
59
+
60
+ // ----------------------------------------------------------------------------------------------------
61
+ // MARK: Functions
62
+ // ----------------------------------------------------------------------------------------------------
63
+ function handleFrameChange(index: number): void {
64
+ if (controlledFrameIndex === undefined) {
65
+ setInternalFrameIndex(index);
66
+ }
67
+
68
+ onFrameChange?.(index);
69
+ }
70
+
71
+ function handleHotspotModeChange(active: boolean): void {
72
+ if (controlledHotspotMode === undefined) {
73
+ setInternalHotspotMode(active);
74
+ }
75
+
76
+ onHotspotModeChange?.(active);
77
+ }
78
+
79
+ const {
80
+ canvasRef,
81
+ containerRef,
82
+ currentFrame,
83
+ currentFrameHotspots,
84
+ imagesLoaded,
85
+ isHotspotMode,
86
+ isResetDisabled,
87
+ maxZoom,
88
+ minZoom,
89
+ viewerCursorClass,
90
+ zoom,
91
+ getHotspotScreenPosition,
92
+ handleCanvasClick,
93
+ handlePointerDown,
94
+ handlePointerMove,
95
+ handlePointerUp,
96
+ handleWheel,
97
+ handleResetView,
98
+ handleZoomIn,
99
+ handleZoomOut,
100
+ } = useViewer360<TData>({
101
+ frames,
102
+ hotspots,
103
+ currentFrameIndex,
104
+ onFrameChange: handleFrameChange,
105
+ config,
106
+ hotspotMode,
107
+ onHotspotAdd,
108
+ });
109
+
110
+ useEffect(() => {
111
+ if (controlledHotspotMode === undefined) return;
112
+ if (controlledHotspotMode !== internalHotspotMode) {
113
+ setInternalHotspotMode(controlledHotspotMode);
114
+ }
115
+ }, [controlledHotspotMode, internalHotspotMode]);
116
+
117
+ const frameLabel = currentFrame?.label ?? frames[currentFrameIndex]?.label;
118
+ const overlayProps: Viewer360OverlayRenderProps = {
119
+ currentFrameIndex,
120
+ frameCount: frames.length,
121
+ frameLabel,
122
+ isHotspotMode,
123
+ labels: mergedLabels,
124
+ };
125
+ const toolbarProps: Viewer360ToolbarRenderProps = {
126
+ zoom,
127
+ minZoom,
128
+ maxZoom,
129
+ isResetDisabled,
130
+ isHotspotMode,
131
+ showHotspotModeControl,
132
+ showZoomControls,
133
+ showResetControl,
134
+ showDragHint,
135
+ labels: mergedLabels,
136
+ onZoomIn: handleZoomIn,
137
+ onZoomOut: handleZoomOut,
138
+ onResetView: handleResetView,
139
+ onHotspotModeChange: handleHotspotModeChange,
140
+ };
141
+ const showDefaultToolbar = showZoomControls || showResetControl || showHotspotModeControl || showDragHint;
142
+
143
+ // ----------------------------------------------------------------------------------------------------
144
+ // MARK: Main Component UI
145
+ // ----------------------------------------------------------------------------------------------------
146
+ return (
147
+ <Card className={cn(mergedClassNames.root, 'gap-0 py-0 shadow-none ring-0', className)} style={{ ...themeStyle, ...style }}>
148
+ <div
149
+ ref={containerRef}
150
+ className={cn(mergedClassNames.viewport, viewerCursorClass)}
151
+ style={{ aspectRatio }}
152
+ onPointerDown={handlePointerDown}
153
+ onPointerMove={handlePointerMove}
154
+ onPointerUp={handlePointerUp}
155
+ onPointerLeave={handlePointerUp}
156
+ onWheel={handleWheel}
157
+ onClick={handleCanvasClick}
158
+ >
159
+ <canvas ref={canvasRef} className={mergedClassNames.canvas} />
160
+
161
+ <div className={mergedClassNames.overlay}>
162
+ {currentFrameHotspots.map((hotspot) => {
163
+ const position = getHotspotScreenPosition(hotspot);
164
+
165
+ return (
166
+ <Viewer360HotspotOverlay
167
+ key={hotspot.id}
168
+ hotspot={hotspot}
169
+ leftPercent={position.leftPercent}
170
+ topPercent={position.topPercent}
171
+ hotspotPin={hotspotPin}
172
+ renderHotspot={renderHotspot}
173
+ onHotspotClick={onHotspotClick}
174
+ />
175
+ );
176
+ })}
177
+ {children}
178
+ </div>
179
+
180
+ {!imagesLoaded &&
181
+ (renderLoading ? (
182
+ renderLoading()
183
+ ) : (
184
+ <Viewer360LoadingOverlay
185
+ className={mergedClassNames.loading}
186
+ textClassName={mergedClassNames.loadingText}
187
+ label={mergedLabels.loading}
188
+ />
189
+ ))}
190
+
191
+ {showFrameIndicator &&
192
+ frames.length > 0 &&
193
+ (renderFrameIndicator ? (
194
+ renderFrameIndicator(overlayProps)
195
+ ) : (
196
+ <Viewer360FrameIndicator
197
+ className={mergedClassNames.frameIndicator}
198
+ label={mergedLabels.frameIndicator({
199
+ current: currentFrameIndex + 1,
200
+ total: frames.length,
201
+ label: frameLabel,
202
+ })}
203
+ />
204
+ ))}
205
+
206
+ {isHotspotMode &&
207
+ onHotspotAdd &&
208
+ (renderHotspotModeBanner ? (
209
+ renderHotspotModeBanner({ labels: mergedLabels })
210
+ ) : (
211
+ <Viewer360AddModeBanner className={mergedClassNames.hotspotModeBanner} label={mergedLabels.hotspotModeActive} />
212
+ ))}
213
+ </div>
214
+
215
+ {renderToolbar ? renderToolbar(toolbarProps) : showDefaultToolbar ? <Viewer360Toolbar {...toolbarProps} /> : null}
216
+ </Card>
217
+ );
218
+ }
@@ -0,0 +1,20 @@
1
+ import type { JSX } from 'react';
2
+
3
+ import { Badge } from '@/components/ui/Badge';
4
+ import { cn } from '@/components/utils';
5
+
6
+ type Viewer360AddModeBannerProps = {
7
+ className?: string;
8
+ label: string;
9
+ };
10
+
11
+ export function Viewer360AddModeBanner({ className, label }: Viewer360AddModeBannerProps): JSX.Element {
12
+ // ----------------------------------------------------------------------------------------------------
13
+ // MARK: Main Component UI
14
+ // ----------------------------------------------------------------------------------------------------
15
+ return (
16
+ <Badge variant="outline" className={cn('pointer-events-none', className)}>
17
+ {label}
18
+ </Badge>
19
+ );
20
+ }
@@ -0,0 +1,20 @@
1
+ import type { JSX } from 'react';
2
+
3
+ import { Badge } from '@/components/ui/Badge';
4
+ import { cn } from '@/components/utils';
5
+
6
+ type Viewer360FrameIndicatorProps = {
7
+ className?: string;
8
+ label: string;
9
+ };
10
+
11
+ export function Viewer360FrameIndicator({ className, label }: Viewer360FrameIndicatorProps): JSX.Element {
12
+ // ----------------------------------------------------------------------------------------------------
13
+ // MARK: Main Component UI
14
+ // ----------------------------------------------------------------------------------------------------
15
+ return (
16
+ <Badge variant="outline" className={cn('pointer-events-none shadow-sm', className)}>
17
+ {label}
18
+ </Badge>
19
+ );
20
+ }
@@ -0,0 +1,70 @@
1
+ import type { JSX, MouseEvent, ReactNode } from 'react';
2
+
3
+ import { hotspotToViewer360Marker } from '../helpers/markerHelpers';
4
+ import type { Viewer360Hotspot, Viewer360HotspotPinOptions, Viewer360HotspotRenderProps } from '../types';
5
+ import { Button } from '@/components/ui/Button';
6
+ import { Item } from '@/components/ui/Item';
7
+ import { cn } from '@/components/utils';
8
+
9
+ import { Viewer360MarkerPin } from './Viewer360MarkerPin';
10
+
11
+ type Viewer360HotspotOverlayProps<TData = unknown> = {
12
+ hotspot: Viewer360Hotspot<TData>;
13
+ leftPercent: number;
14
+ topPercent: number;
15
+ hotspotPin?: Viewer360HotspotPinOptions<TData>;
16
+ renderHotspot?: (props: Viewer360HotspotRenderProps<TData>) => ReactNode;
17
+ onHotspotClick?: (hotspot: Viewer360Hotspot<TData>, event: MouseEvent<HTMLDivElement>) => void;
18
+ };
19
+
20
+ export function Viewer360HotspotOverlay<TData = unknown>({
21
+ hotspot,
22
+ leftPercent,
23
+ topPercent,
24
+ hotspotPin,
25
+ renderHotspot,
26
+ onHotspotClick,
27
+ }: Viewer360HotspotOverlayProps<TData>): JSX.Element {
28
+ // ----------------------------------------------------------------------------------------------------
29
+ // MARK: Main Component UI
30
+ // ----------------------------------------------------------------------------------------------------
31
+ if (renderHotspot) {
32
+ return (
33
+ <Item size="xs" variant="default" className="pointer-events-auto w-auto border-transparent p-0">
34
+ {renderHotspot({ hotspot, leftPercent, topPercent })}
35
+ </Item>
36
+ );
37
+ }
38
+
39
+ if (hotspotPin) {
40
+ const marker = hotspotPin.getMarker?.(hotspot) ?? hotspotToViewer360Marker(hotspot);
41
+
42
+ return (
43
+ <Viewer360MarkerPin
44
+ marker={marker}
45
+ hotspot={hotspot}
46
+ leftPercent={leftPercent}
47
+ topPercent={topPercent}
48
+ onDelete={hotspotPin.onDelete}
49
+ isDeletePending={hotspotPin.deletingMarkerId === hotspot.id}
50
+ renderTag={hotspotPin.renderTag}
51
+ classNames={hotspotPin.classNames}
52
+ labels={hotspotPin.labels}
53
+ />
54
+ );
55
+ }
56
+
57
+ return (
58
+ <Button
59
+ type="button"
60
+ variant="destructive"
61
+ size="icon-xs"
62
+ className={cn(
63
+ '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'
64
+ )}
65
+ style={{ left: `${leftPercent}%`, top: `${topPercent}%` }}
66
+ aria-label={`Hotspot ${hotspot.id}`}
67
+ onClick={(event) => onHotspotClick?.(hotspot, event as unknown as MouseEvent<HTMLDivElement>)}
68
+ />
69
+ );
70
+ }
@@ -0,0 +1,28 @@
1
+ import type { JSX } from 'react';
2
+
3
+ import { Item } from '@/components/ui/Item';
4
+ import { Label } from '@/components/ui/Label';
5
+ import { Spinner } from '@/components/ui/Spinner';
6
+ import { cn } from '@/components/utils';
7
+
8
+ type Viewer360LoadingOverlayProps = {
9
+ className?: string;
10
+ textClassName?: string;
11
+ label: string;
12
+ };
13
+
14
+ export function Viewer360LoadingOverlay({ className, textClassName, label }: Viewer360LoadingOverlayProps): JSX.Element {
15
+ // ----------------------------------------------------------------------------------------------------
16
+ // MARK: Main Component UI
17
+ // ----------------------------------------------------------------------------------------------------
18
+ return (
19
+ <Item
20
+ size="sm"
21
+ variant="muted"
22
+ className={cn('pointer-events-none w-auto justify-center border-transparent bg-muted/80', className)}
23
+ >
24
+ <Spinner className="size-5 text-muted-foreground" />
25
+ <Label className={cn('font-normal text-muted-foreground', textClassName)}>{label}</Label>
26
+ </Item>
27
+ );
28
+ }