@mmmmzxe/react-360-viewer 0.1.4 → 0.1.6
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 +621 -60
- package/dist/index.d.ts +5 -2
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/dist/styles.css +2 -0
- package/package.json +13 -3
- package/src/feature/Viewer360.tsx +6 -0
- package/src/generated/injectStyles.ts +18 -0
- package/src/generated/viewer360.min.css +2 -0
- package/src/index.ts +3 -0
- package/src/styles.css +78 -0
- package/src/types/Viewer360Hotspot.ts +1 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @mmmmzxe/react-360-viewer
|
|
2
2
|
|
|
3
|
-
A standalone, configurable 360° image viewer for React. Drag to rotate through frames, scroll or use controls to zoom, optionally auto-rotate, and place interactive hotspots on any frame.
|
|
3
|
+
A standalone, configurable 360° image viewer for React. Drag to rotate through frames, scroll or use toolbar controls to zoom, pan when zoomed in, optionally auto-rotate, and place interactive hotspots on any frame.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -8,10 +8,12 @@ A standalone, configurable 360° image viewer for React. Drag to rotate through
|
|
|
8
8
|
- Zoom via scroll wheel or toolbar controls
|
|
9
9
|
- Pan when zoomed in
|
|
10
10
|
- Optional auto-rotate
|
|
11
|
-
-
|
|
11
|
+
- Built-in hotspot marker pins with **hover tooltips**
|
|
12
|
+
- Frame indicator badge (e.g. `Interior · 1 / 3`)
|
|
13
|
+
- Hotspot add mode for placing new markers
|
|
12
14
|
- shadcn-style UI with Tailwind CSS design tokens
|
|
13
15
|
- TypeScript-first API
|
|
14
|
-
-
|
|
16
|
+
- Headless `useViewer360` hook for custom UIs
|
|
15
17
|
|
|
16
18
|
## Installation
|
|
17
19
|
|
|
@@ -21,17 +23,112 @@ npm install @mmmmzxe/react-360-viewer
|
|
|
21
23
|
bun add @mmmmzxe/react-360-viewer
|
|
22
24
|
```
|
|
23
25
|
|
|
24
|
-
Peer dependencies
|
|
26
|
+
### Peer dependencies
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
| Package | Version |
|
|
29
|
+
|---------|---------|
|
|
30
|
+
| `react` | `>=18` |
|
|
31
|
+
| `react-dom` | `>=18` |
|
|
32
|
+
| `lucide-react` | `>=0.400.0` |
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
### Styles — zero setup
|
|
35
|
+
|
|
36
|
+
**You do not need to import any CSS file or add `@source`.** Styles inject automatically when `<Viewer360 />` mounts:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { Viewer360 } from '@mmmmzxe/react-360-viewer';
|
|
40
|
+
// That's it — hover tooltips, frame indicator, cursors all work
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires **`@mmmmzxe/react-360-viewer@0.1.6` or later**. Older versions need `@source` or a manual CSS import.
|
|
44
|
+
|
|
45
|
+
#### Why `@source` seemed to "fix" it
|
|
46
|
+
|
|
47
|
+
`@source` tells **your app's Tailwind** to scan the package and generate CSS in your build. That works, but it is optional if you use v0.1.6+.
|
|
48
|
+
|
|
49
|
+
| Approach | Who generates CSS | Setup |
|
|
50
|
+
|----------|-------------------|-------|
|
|
51
|
+
| **Auto-inject (v0.1.6+)** | The package itself | None |
|
|
52
|
+
| `@source` in your CSS | Your app's Tailwind | Add `@source` line |
|
|
53
|
+
| `import '.../styles.css'` | Pre-built file | One import in layout |
|
|
54
|
+
|
|
55
|
+
#### Troubleshooting (Next.js / styles not showing)
|
|
56
|
+
|
|
57
|
+
1. **Update the package** — run `npm install @mmmmzxe/react-360-viewer@latest` (need v0.1.6+).
|
|
58
|
+
2. **Use a client component** — wrap the viewer in `'use client'` (Next.js App Router).
|
|
59
|
+
3. **Clear cache** — delete `.next` and restart the dev server.
|
|
60
|
+
4. **Manual fallback** — if auto-inject still fails:
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
import { injectViewer360Styles } from '@mmmmzxe/react-360-viewer';
|
|
64
|
+
|
|
65
|
+
useEffect(() => { injectViewer360Styles(); }, []);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
#### Optional: manual CSS import
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
import '@mmmmzxe/react-360-viewer/styles.css';
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
#### Optional: Tailwind `@source`
|
|
29
75
|
|
|
30
76
|
```css
|
|
31
77
|
@source "../node_modules/@mmmmzxe/react-360-viewer/src/**/*.{ts,tsx}";
|
|
32
78
|
```
|
|
33
79
|
|
|
34
|
-
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## How it works
|
|
83
|
+
|
|
84
|
+
### Component layout
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
┌─────────────────────────────────────────────┐
|
|
88
|
+
│ Card (root) │
|
|
89
|
+
│ ┌─────────────────────────────────────────┐│
|
|
90
|
+
│ │ Viewport (canvas + overlays) ││
|
|
91
|
+
│ │ • Canvas — draws the current frame ││
|
|
92
|
+
│ │ • Overlay — hotspot markers ││
|
|
93
|
+
│ │ • Frame indicator — bottom-left badge ││
|
|
94
|
+
│ │ • Add-mode banner — top center ││
|
|
95
|
+
│ │ • Loading overlay ││
|
|
96
|
+
│ └─────────────────────────────────────────┘│
|
|
97
|
+
│ ┌─────────────────────────────────────────┐│
|
|
98
|
+
│ │ Toolbar — zoom, reset, add hotspot ││
|
|
99
|
+
│ └─────────────────────────────────────────┘│
|
|
100
|
+
└─────────────────────────────────────────────┘
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Interaction model
|
|
104
|
+
|
|
105
|
+
| Action | Behavior |
|
|
106
|
+
|--------|----------|
|
|
107
|
+
| **Drag horizontally** | Rotates through frames (wraps around) |
|
|
108
|
+
| **Scroll wheel** | Zoom in / out |
|
|
109
|
+
| **Drag while zoomed** | Pans the image |
|
|
110
|
+
| **Reset button** | Returns zoom and pan to default |
|
|
111
|
+
| **Auto-rotate** | Advances frames automatically when enabled |
|
|
112
|
+
| **Hotspot mode** | Cursor becomes crosshair; click places a hotspot |
|
|
113
|
+
| **Hover hotspot dot** | Shows tooltip with title, description, optional delete |
|
|
114
|
+
| **Click hotspot dot** | Fires `onHotspotClick` if provided |
|
|
115
|
+
|
|
116
|
+
### Coordinate system
|
|
117
|
+
|
|
118
|
+
Hotspots use **percentage-based positions** (`positionX`, `positionY` from `0` to `100`) relative to the image area, tied to a specific `frameIndex`. Coordinates stay aligned when zooming or panning.
|
|
119
|
+
|
|
120
|
+
When add mode is active and you click the viewport, `onHotspotAdd` receives:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
{
|
|
124
|
+
frameIndex: number;
|
|
125
|
+
frameId: string;
|
|
126
|
+
positionX: number; // 0–100
|
|
127
|
+
positionY: number; // 0–100
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
35
132
|
|
|
36
133
|
## Quick start
|
|
37
134
|
|
|
@@ -42,7 +139,7 @@ import { Viewer360, type Viewer360Frame } from '@mmmmzxe/react-360-viewer';
|
|
|
42
139
|
const frames: Viewer360Frame[] = [
|
|
43
140
|
{ id: '1', src: '/images/frame-01.jpg', label: 'Front' },
|
|
44
141
|
{ id: '2', src: '/images/frame-02.jpg', label: 'Front-right' },
|
|
45
|
-
|
|
142
|
+
{ id: '3', src: '/images/frame-03.jpg', label: 'Side' },
|
|
46
143
|
];
|
|
47
144
|
|
|
48
145
|
export function ProductViewer() {
|
|
@@ -53,27 +150,131 @@ export function ProductViewer() {
|
|
|
53
150
|
frames={frames}
|
|
54
151
|
currentFrameIndex={frameIndex}
|
|
55
152
|
onFrameChange={setFrameIndex}
|
|
56
|
-
config={{ dragSensitivity: 8
|
|
153
|
+
config={{ dragSensitivity: 8 }}
|
|
57
154
|
/>
|
|
58
155
|
);
|
|
59
156
|
}
|
|
60
157
|
```
|
|
61
158
|
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Frames
|
|
162
|
+
|
|
163
|
+
Each frame is an image source with an optional label shown in the frame indicator:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
type Viewer360Frame = {
|
|
167
|
+
id: string; // unique identifier
|
|
168
|
+
src: string; // image URL
|
|
169
|
+
label?: string; // e.g. "Interior", "Motor" — shown in frame badge
|
|
170
|
+
};
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The frame indicator defaults to `Label · 1 / 3` when a label is set, or `1 / 3` without one. It appears at the **bottom-left** of the viewport.
|
|
174
|
+
|
|
175
|
+
### Frame indicator
|
|
176
|
+
|
|
177
|
+
**Default (recommended)** — positioning is handled automatically:
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
<Viewer360
|
|
181
|
+
frames={frames}
|
|
182
|
+
showFrameIndicator
|
|
183
|
+
labels={{
|
|
184
|
+
frameIndicator: ({ current, total, label }) =>
|
|
185
|
+
label ? `${label} · ${current} / ${total}` : `${current} / ${total}`,
|
|
186
|
+
}}
|
|
187
|
+
/>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Custom render** — you must pass positioning classes yourself:
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
import {
|
|
194
|
+
Viewer360,
|
|
195
|
+
Viewer360FrameIndicator,
|
|
196
|
+
viewer360ClassNames,
|
|
197
|
+
} from '@mmmmzxe/react-360-viewer';
|
|
198
|
+
|
|
199
|
+
<Viewer360
|
|
200
|
+
frames={frames}
|
|
201
|
+
renderFrameIndicator={({ currentFrameIndex, frameCount, frameLabel, labels, frameIndicatorClassName }) => (
|
|
202
|
+
<Viewer360FrameIndicator
|
|
203
|
+
className={frameIndicatorClassName}
|
|
204
|
+
label={labels.frameIndicator({
|
|
205
|
+
current: currentFrameIndex + 1,
|
|
206
|
+
total: frameCount,
|
|
207
|
+
label: frameLabel,
|
|
208
|
+
})}
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
/>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
> **Important:** Always pass `className={frameIndicatorClassName}` so the badge appears at the bottom-left. Without it, the badge floats at the top (as in your screenshot).
|
|
215
|
+
|
|
216
|
+
Override position via `classNames`:
|
|
217
|
+
|
|
218
|
+
```tsx
|
|
219
|
+
<Viewer360
|
|
220
|
+
classNames={{
|
|
221
|
+
frameIndicator: 'absolute bottom-4 start-4 z-20 whitespace-nowrap ...',
|
|
222
|
+
}}
|
|
223
|
+
/>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
62
228
|
## Hotspots & markers
|
|
63
229
|
|
|
64
|
-
|
|
230
|
+
### Data model
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
type Viewer360Hotspot<TData = unknown> = {
|
|
234
|
+
id: string;
|
|
235
|
+
frameIndex: number;
|
|
236
|
+
positionX: number; // 0–100
|
|
237
|
+
positionY: number; // 0–100
|
|
238
|
+
data?: TData; // optional typed payload
|
|
239
|
+
};
|
|
240
|
+
```
|
|
65
241
|
|
|
66
|
-
### Built-in marker pins
|
|
242
|
+
### Built-in marker pins (default)
|
|
67
243
|
|
|
68
|
-
|
|
244
|
+
Every hotspot automatically renders as a red marker pin with a **hover tooltip**. No extra setup required:
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
<Viewer360 frames={frames} hotspots={hotspots} />
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Hover the dot to see the title and description. Tap or focus the dot on touch devices.
|
|
251
|
+
|
|
252
|
+
### Converting markers to hotspots
|
|
253
|
+
|
|
254
|
+
Use `toViewer360Hotspots` to map your app's marker records:
|
|
69
255
|
|
|
70
256
|
```tsx
|
|
71
257
|
import { Viewer360, toViewer360Hotspots } from '@mmmmzxe/react-360-viewer';
|
|
72
258
|
|
|
73
259
|
const hotspots = toViewer360Hotspots([
|
|
74
|
-
{
|
|
260
|
+
{
|
|
261
|
+
id: '1',
|
|
262
|
+
frameIndex: 0,
|
|
263
|
+
positionX: 42,
|
|
264
|
+
positionY: 58,
|
|
265
|
+
title: 'Scratch',
|
|
266
|
+
description: 'Front bumper',
|
|
267
|
+
},
|
|
75
268
|
]);
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
If `hotspot.data` contains `{ title: string, description?: string }`, the built-in pin uses those for the tooltip. Otherwise it falls back to `hotspot.id` as the title.
|
|
76
272
|
|
|
273
|
+
### Hotspot pin options
|
|
274
|
+
|
|
275
|
+
Pass `hotspotPin` to add delete, custom tags, or styling. The built-in pin UI is always used unless you provide `renderHotspot`.
|
|
276
|
+
|
|
277
|
+
```tsx
|
|
77
278
|
<Viewer360
|
|
78
279
|
frames={frames}
|
|
79
280
|
hotspots={hotspots}
|
|
@@ -81,101 +282,451 @@ const hotspots = toViewer360Hotspots([
|
|
|
81
282
|
hotspotPin={{
|
|
82
283
|
onDelete: (id) => removeMarker(id),
|
|
83
284
|
deletingMarkerId: pendingDeleteId,
|
|
285
|
+
getMarker: (hotspot) => ({
|
|
286
|
+
id: hotspot.id,
|
|
287
|
+
title: hotspot.data?.title ?? 'Damage',
|
|
288
|
+
description: hotspot.data?.description,
|
|
289
|
+
}),
|
|
290
|
+
renderTag: ({ marker, hotspot }) => (
|
|
291
|
+
<Badge>{hotspot.data?.severity}</Badge>
|
|
292
|
+
),
|
|
293
|
+
classNames: {
|
|
294
|
+
dot: 'size-5 bg-red-600',
|
|
295
|
+
tooltip: 'w-72',
|
|
296
|
+
},
|
|
297
|
+
labels: {
|
|
298
|
+
delete: 'Remove damage',
|
|
299
|
+
},
|
|
84
300
|
}}
|
|
85
301
|
onHotspotAdd={(position) => saveMarker(position)}
|
|
86
302
|
/>
|
|
87
303
|
```
|
|
88
304
|
|
|
89
|
-
|
|
305
|
+
| `hotspotPin` option | Type | Description |
|
|
306
|
+
|---------------------|------|-------------|
|
|
307
|
+
| `onDelete` | `(id: string) => void` | Shows delete button in tooltip |
|
|
308
|
+
| `deletingMarkerId` | `string \| null` | Disables delete while pending |
|
|
309
|
+
| `getMarker` | `(hotspot) => Viewer360Marker` | Custom title/description mapping |
|
|
310
|
+
| `renderTag` | `(props) => ReactNode` | Extra content in tooltip (e.g. severity badge) |
|
|
311
|
+
| `classNames` | `Viewer360MarkerPinClassNames` | Override pin/dot/tooltip styles |
|
|
312
|
+
| `labels` | `Viewer360MarkerPinLabels` | Override delete button label |
|
|
90
313
|
|
|
91
|
-
###
|
|
314
|
+
### Hotspot add mode
|
|
92
315
|
|
|
93
|
-
|
|
94
|
-
import { Viewer360, type Viewer360Hotspot } from '@mmmmzxe/react-360-viewer';
|
|
95
|
-
|
|
96
|
-
type DamageHotspot = { title: string; severity: 'low' | 'high' };
|
|
316
|
+
Enable placing new hotspots by clicking the image:
|
|
97
317
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
];
|
|
318
|
+
```tsx
|
|
319
|
+
const [isAddMode, setIsAddMode] = useState(false);
|
|
101
320
|
|
|
102
321
|
<Viewer360
|
|
103
322
|
frames={frames}
|
|
104
323
|
hotspots={hotspots}
|
|
105
|
-
showHotspotModeControl
|
|
324
|
+
showHotspotModeControl // shows "Add hotspot" button in toolbar
|
|
325
|
+
hotspotMode={isAddMode} // controlled add mode
|
|
326
|
+
onHotspotModeChange={setIsAddMode}
|
|
106
327
|
onHotspotAdd={(position) => {
|
|
107
328
|
// position: { frameIndex, frameId, positionX, positionY }
|
|
108
|
-
|
|
329
|
+
openAddDialog(position);
|
|
109
330
|
}}
|
|
331
|
+
/>
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
When add mode is active:
|
|
335
|
+
- Cursor becomes a crosshair
|
|
336
|
+
- A banner appears: *"Click on the image to place a hotspot"*
|
|
337
|
+
- Clicking the viewport calls `onHotspotAdd` with normalized coordinates
|
|
338
|
+
- Drag and auto-rotate are disabled in add mode
|
|
339
|
+
|
|
340
|
+
### Fully custom hotspot UI
|
|
341
|
+
|
|
342
|
+
Use `renderHotspot` only when you need complete control over marker markup:
|
|
343
|
+
|
|
344
|
+
```tsx
|
|
345
|
+
<Viewer360
|
|
346
|
+
frames={frames}
|
|
347
|
+
hotspots={hotspots}
|
|
110
348
|
renderHotspot={({ hotspot, leftPercent, topPercent }) => (
|
|
111
349
|
<button
|
|
112
|
-
|
|
350
|
+
className="absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-red-500"
|
|
351
|
+
style={{ left: `${leftPercent}%`, top: `${topPercent}%` }}
|
|
113
352
|
onClick={() => alert(hotspot.data?.title)}
|
|
114
353
|
/>
|
|
115
354
|
)}
|
|
116
355
|
/>
|
|
117
356
|
```
|
|
118
357
|
|
|
119
|
-
When
|
|
358
|
+
> When `renderHotspot` is provided, the built-in marker pin is **not** used.
|
|
359
|
+
|
|
360
|
+
### Click handler
|
|
361
|
+
|
|
362
|
+
```tsx
|
|
363
|
+
<Viewer360
|
|
364
|
+
hotspots={hotspots}
|
|
365
|
+
onHotspotClick={(hotspot, event) => {
|
|
366
|
+
console.log('Clicked', hotspot.id);
|
|
367
|
+
}}
|
|
368
|
+
/>
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
## Controlled vs uncontrolled state
|
|
374
|
+
|
|
375
|
+
| State | Controlled prop | Uncontrolled default | Callback |
|
|
376
|
+
|-------|-------------------|----------------------|----------|
|
|
377
|
+
| Frame index | `currentFrameIndex` | `defaultFrameIndex` (0) | `onFrameChange` |
|
|
378
|
+
| Hotspot add mode | `hotspotMode` | `defaultHotspotMode` (false) | `onHotspotModeChange` |
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
// Controlled — parent owns state
|
|
382
|
+
const [frameIndex, setFrameIndex] = useState(0);
|
|
383
|
+
|
|
384
|
+
<Viewer360
|
|
385
|
+
currentFrameIndex={frameIndex}
|
|
386
|
+
onFrameChange={setFrameIndex}
|
|
387
|
+
/>
|
|
388
|
+
|
|
389
|
+
// Uncontrolled — component manages its own frame index
|
|
390
|
+
<Viewer360 frames={frames} defaultFrameIndex={2} />
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
---
|
|
120
394
|
|
|
121
395
|
## Configuration
|
|
122
396
|
|
|
397
|
+
### Viewer config
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
type Viewer360Config = {
|
|
401
|
+
minZoom?: number; // default: 1
|
|
402
|
+
maxZoom?: number; // default: 3
|
|
403
|
+
zoomStep?: number; // default: 0.15
|
|
404
|
+
dragSensitivity?: number; // default: 8 (pixels per frame)
|
|
405
|
+
autoRotate?: boolean; // default: false
|
|
406
|
+
autoRotateIntervalMs?: number; // default: 100
|
|
407
|
+
autoRotateDirection?: 'forward' | 'backward'; // default: 'forward'
|
|
408
|
+
};
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Auto-rotate pauses while dragging, in hotspot add mode, or when zoomed above `minZoom`.
|
|
412
|
+
|
|
413
|
+
```tsx
|
|
414
|
+
<Viewer360
|
|
415
|
+
config={{
|
|
416
|
+
minZoom: 1,
|
|
417
|
+
maxZoom: 4,
|
|
418
|
+
zoomStep: 0.2,
|
|
419
|
+
dragSensitivity: 10,
|
|
420
|
+
autoRotate: true,
|
|
421
|
+
autoRotateIntervalMs: 150,
|
|
422
|
+
autoRotateDirection: 'forward',
|
|
423
|
+
}}
|
|
424
|
+
/>
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Viewer360 props
|
|
428
|
+
|
|
123
429
|
| Prop | Type | Default | Description |
|
|
124
430
|
|------|------|---------|-------------|
|
|
125
|
-
| `frames` | `Viewer360Frame[]` | required | Image sources |
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `config
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
431
|
+
| `frames` | `Viewer360Frame[]` | **required** | Image sources |
|
|
432
|
+
| `currentFrameIndex` | `number` | — | Controlled frame index |
|
|
433
|
+
| `defaultFrameIndex` | `number` | `0` | Initial frame when uncontrolled |
|
|
434
|
+
| `onFrameChange` | `(index) => void` | — | Called when frame changes |
|
|
435
|
+
| `hotspots` | `Viewer360Hotspot[]` | `[]` | Markers to display |
|
|
436
|
+
| `config` | `Viewer360Config` | see above | Zoom, drag, auto-rotate |
|
|
437
|
+
| `aspectRatio` | `string` | `'16 / 10'` | CSS `aspect-ratio` for viewport |
|
|
438
|
+
| `className` | `string` | — | Extra classes on root card |
|
|
439
|
+
| `classNames` | `Viewer360ClassNames` | — | Override internal element classes |
|
|
440
|
+
| `style` | `CSSProperties` | — | Inline styles on root |
|
|
441
|
+
| `theme` | `Viewer360Theme` | — | CSS variable overrides |
|
|
442
|
+
| `labels` | `Viewer360Labels` | English defaults | Localized strings |
|
|
133
443
|
| `showZoomControls` | `boolean` | `true` | Toolbar zoom buttons |
|
|
134
444
|
| `showResetControl` | `boolean` | `true` | Reset zoom/pan button |
|
|
135
445
|
| `showFrameIndicator` | `boolean` | `true` | Frame counter badge |
|
|
136
|
-
| `showDragHint` | `boolean` | `true` |
|
|
137
|
-
| `
|
|
138
|
-
| `
|
|
139
|
-
| `
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
446
|
+
| `showDragHint` | `boolean` | `true` | "Drag to rotate" hint in toolbar |
|
|
447
|
+
| `showHotspotModeControl` | `boolean` | `false` | "Add hotspot" toolbar button |
|
|
448
|
+
| `hotspotMode` | `boolean` | — | Controlled add mode |
|
|
449
|
+
| `defaultHotspotMode` | `boolean` | `false` | Initial add mode |
|
|
450
|
+
| `onHotspotModeChange` | `(active) => void` | — | Add mode toggle callback |
|
|
451
|
+
| `onHotspotAdd` | `(position) => void` | — | Called when placing a hotspot |
|
|
452
|
+
| `onHotspotClick` | `(hotspot, event) => void` | — | Called when clicking a hotspot |
|
|
453
|
+
| `hotspotPin` | `Viewer360HotspotPinOptions` | — | Pin tooltip/delete/tag options |
|
|
142
454
|
| `renderHotspot` | `function` | — | Fully custom hotspot UI |
|
|
143
|
-
| `renderToolbar` | `function` | — |
|
|
455
|
+
| `renderToolbar` | `function` | — | Custom toolbar |
|
|
144
456
|
| `renderLoading` | `function` | — | Custom loading overlay |
|
|
145
457
|
| `renderFrameIndicator` | `function` | — | Custom frame badge |
|
|
146
458
|
| `renderHotspotModeBanner` | `function` | — | Custom add-mode banner |
|
|
459
|
+
| `children` | `ReactNode` | — | Extra content inside overlay |
|
|
460
|
+
|
|
461
|
+
### Labels
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
type Viewer360Labels = {
|
|
465
|
+
loading?: string;
|
|
466
|
+
dragHint?: string;
|
|
467
|
+
frameIndicator?: (params: { current: number; total: number; label?: string }) => string;
|
|
468
|
+
zoom?: (percent: number) => string;
|
|
469
|
+
hotspotModeActive?: string;
|
|
470
|
+
addHotspot?: string;
|
|
471
|
+
zoomIn?: string;
|
|
472
|
+
zoomOut?: string;
|
|
473
|
+
resetView?: string;
|
|
474
|
+
deleteMarker?: string;
|
|
475
|
+
};
|
|
476
|
+
```
|
|
147
477
|
|
|
148
|
-
|
|
478
|
+
Default `frameIndicator` format: `Interior · 1 / 3`.
|
|
479
|
+
|
|
480
|
+
### Class names
|
|
149
481
|
|
|
150
|
-
|
|
482
|
+
Import defaults and extend:
|
|
151
483
|
|
|
152
484
|
```tsx
|
|
153
|
-
import {
|
|
485
|
+
import { viewer360ClassNames, viewer360MarkerPinClassNames } from '@mmmmzxe/react-360-viewer';
|
|
154
486
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
487
|
+
<Viewer360
|
|
488
|
+
classNames={{
|
|
489
|
+
root: 'shadow-lg',
|
|
490
|
+
viewport: 'bg-black',
|
|
491
|
+
frameIndicator: `${viewer360ClassNames.frameIndicator} whitespace-nowrap`,
|
|
492
|
+
}}
|
|
493
|
+
/>
|
|
160
494
|
```
|
|
161
495
|
|
|
162
|
-
|
|
496
|
+
| Key | Element |
|
|
497
|
+
|-----|---------|
|
|
498
|
+
| `root` | Outer card |
|
|
499
|
+
| `viewport` | Image area |
|
|
500
|
+
| `canvas` | Canvas element |
|
|
501
|
+
| `overlay` | Hotspot overlay container |
|
|
502
|
+
| `loading` | Loading overlay |
|
|
503
|
+
| `loadingText` | Loading text |
|
|
504
|
+
| `frameIndicator` | Frame badge |
|
|
505
|
+
| `hotspotModeBanner` | Add-mode banner |
|
|
506
|
+
| `toolbar` | Bottom toolbar |
|
|
507
|
+
| `dragHint` | Drag hint text |
|
|
508
|
+
| `controls` | Toolbar controls group |
|
|
509
|
+
| `zoomDisplay` | Zoom percentage badge |
|
|
510
|
+
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
## Customization & render props
|
|
514
|
+
|
|
515
|
+
Replace any built-in UI section with your own components:
|
|
516
|
+
|
|
517
|
+
```tsx
|
|
518
|
+
<Viewer360
|
|
519
|
+
frames={frames}
|
|
520
|
+
renderToolbar={(props) => <MyCustomToolbar {...props} />}
|
|
521
|
+
renderLoading={() => <MySpinner />}
|
|
522
|
+
renderFrameIndicator={(props) => (
|
|
523
|
+
<Viewer360FrameIndicator
|
|
524
|
+
className={viewer360ClassNames.frameIndicator}
|
|
525
|
+
label={props.labels.frameIndicator({
|
|
526
|
+
current: props.currentFrameIndex + 1,
|
|
527
|
+
total: props.frameCount,
|
|
528
|
+
label: props.frameLabel,
|
|
529
|
+
})}
|
|
530
|
+
/>
|
|
531
|
+
)}
|
|
532
|
+
renderHotspotModeBanner={({ labels }) => (
|
|
533
|
+
<div className="absolute top-4 left-1/2 -translate-x-1/2">
|
|
534
|
+
{labels.hotspotModeActive}
|
|
535
|
+
</div>
|
|
536
|
+
)}
|
|
537
|
+
/>
|
|
538
|
+
```
|
|
163
539
|
|
|
164
|
-
|
|
540
|
+
### Theming
|
|
165
541
|
|
|
166
|
-
|
|
542
|
+
Pass CSS variables via `theme` or style the root with your own tokens:
|
|
167
543
|
|
|
168
544
|
```tsx
|
|
169
545
|
<Viewer360
|
|
170
546
|
theme={{
|
|
171
547
|
'--viewer-bg': '#111827',
|
|
548
|
+
'--viewer-border': '#374151',
|
|
172
549
|
'--viewer-text': '#f9fafb',
|
|
550
|
+
'--viewer-muted': '#9ca3af',
|
|
173
551
|
'--viewer-accent': '#3b82f6',
|
|
552
|
+
'--viewer-accent-foreground': '#ffffff',
|
|
553
|
+
'--viewer-control-bg': '#1f2937',
|
|
554
|
+
'--viewer-control-border': '#374151',
|
|
555
|
+
'--viewer-hotspot-banner-bg': '#fef3c7',
|
|
556
|
+
'--viewer-hotspot-banner-border': '#fcd34d',
|
|
557
|
+
'--viewer-hotspot-banner-text': '#92400e',
|
|
174
558
|
}}
|
|
175
559
|
/>
|
|
176
560
|
```
|
|
177
561
|
|
|
178
|
-
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
## Real-world integration example
|
|
565
|
+
|
|
566
|
+
Typical pattern for a vehicle damage viewer:
|
|
567
|
+
|
|
568
|
+
```tsx
|
|
569
|
+
import { Viewer360 } from '@mmmmzxe/react-360-viewer';
|
|
570
|
+
|
|
571
|
+
export function Vehicle360Viewer({ frames, markers, frameIndex, onFrameChange }) {
|
|
572
|
+
const hotspots = markers.map((m) => ({
|
|
573
|
+
id: String(m.id),
|
|
574
|
+
frameIndex: m.frameIndex,
|
|
575
|
+
positionX: m.positionX,
|
|
576
|
+
positionY: m.positionY,
|
|
577
|
+
data: m,
|
|
578
|
+
}));
|
|
579
|
+
|
|
580
|
+
return (
|
|
581
|
+
<Viewer360
|
|
582
|
+
frames={frames.map((f, i) => ({
|
|
583
|
+
id: String(f.id),
|
|
584
|
+
src: f.url,
|
|
585
|
+
label: f.label,
|
|
586
|
+
}))}
|
|
587
|
+
hotspots={hotspots}
|
|
588
|
+
currentFrameIndex={frameIndex}
|
|
589
|
+
onFrameChange={onFrameChange}
|
|
590
|
+
showHotspotModeControl
|
|
591
|
+
showFrameIndicator
|
|
592
|
+
hotspotMode={isAddMode}
|
|
593
|
+
onHotspotModeChange={setIsAddMode}
|
|
594
|
+
onHotspotAdd={handleAdd}
|
|
595
|
+
hotspotPin={{
|
|
596
|
+
onDelete: (id) => deleteMarker(Number(id)),
|
|
597
|
+
deletingMarkerId: pendingId ? String(pendingId) : null,
|
|
598
|
+
getMarker: (h) => ({
|
|
599
|
+
id: h.id,
|
|
600
|
+
title: h.data?.title ?? 'Damage',
|
|
601
|
+
description: h.data?.description,
|
|
602
|
+
}),
|
|
603
|
+
renderTag: (props) => <DamageTag {...props} />,
|
|
604
|
+
}}
|
|
605
|
+
labels={{
|
|
606
|
+
addHotspot: 'Add damage',
|
|
607
|
+
frameIndicator: ({ current, total, label }) =>
|
|
608
|
+
label ? `${label} · ${current} / ${total}` : `${current} / ${total}`,
|
|
609
|
+
}}
|
|
610
|
+
/>
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## Headless usage
|
|
618
|
+
|
|
619
|
+
Use `useViewer360` when you need full control over markup and layout:
|
|
620
|
+
|
|
621
|
+
```tsx
|
|
622
|
+
import { useViewer360 } from '@mmmmzxe/react-360-viewer';
|
|
623
|
+
|
|
624
|
+
function CustomViewer() {
|
|
625
|
+
const [frameIndex, setFrameIndex] = useState(0);
|
|
626
|
+
|
|
627
|
+
const {
|
|
628
|
+
canvasRef,
|
|
629
|
+
containerRef,
|
|
630
|
+
currentFrame,
|
|
631
|
+
currentFrameHotspots,
|
|
632
|
+
imagesLoaded,
|
|
633
|
+
zoom,
|
|
634
|
+
minZoom,
|
|
635
|
+
maxZoom,
|
|
636
|
+
isResetDisabled,
|
|
637
|
+
viewerCursorClass,
|
|
638
|
+
getHotspotScreenPosition,
|
|
639
|
+
handlePointerDown,
|
|
640
|
+
handlePointerMove,
|
|
641
|
+
handlePointerUp,
|
|
642
|
+
handleWheel,
|
|
643
|
+
handleCanvasClick,
|
|
644
|
+
handleZoomIn,
|
|
645
|
+
handleZoomOut,
|
|
646
|
+
handleResetView,
|
|
647
|
+
} = useViewer360({
|
|
648
|
+
frames,
|
|
649
|
+
currentFrameIndex: frameIndex,
|
|
650
|
+
onFrameChange: setFrameIndex,
|
|
651
|
+
hotspots,
|
|
652
|
+
config: { maxZoom: 4 },
|
|
653
|
+
hotspotMode: false,
|
|
654
|
+
onHotspotAdd: (pos) => console.log(pos),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
return (
|
|
658
|
+
<div
|
|
659
|
+
ref={containerRef}
|
|
660
|
+
className={viewerCursorClass}
|
|
661
|
+
onPointerDown={handlePointerDown}
|
|
662
|
+
onPointerMove={handlePointerMove}
|
|
663
|
+
onPointerUp={handlePointerUp}
|
|
664
|
+
onWheel={handleWheel}
|
|
665
|
+
onClick={handleCanvasClick}
|
|
666
|
+
>
|
|
667
|
+
<canvas ref={canvasRef} />
|
|
668
|
+
{!imagesLoaded && <p>Loading…</p>}
|
|
669
|
+
{currentFrameHotspots.map((h) => {
|
|
670
|
+
const pos = getHotspotScreenPosition(h);
|
|
671
|
+
return <div key={h.id} style={{ left: `${pos.leftPercent}%`, top: `${pos.topPercent}%` }} />;
|
|
672
|
+
})}
|
|
673
|
+
</div>
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
### Hook return values
|
|
679
|
+
|
|
680
|
+
| Value | Description |
|
|
681
|
+
|-------|-------------|
|
|
682
|
+
| `canvasRef` | Attach to `<canvas>` |
|
|
683
|
+
| `containerRef` | Attach to viewport container |
|
|
684
|
+
| `currentFrame` | Active `Viewer360Frame` |
|
|
685
|
+
| `currentFrameHotspots` | Hotspots for current frame |
|
|
686
|
+
| `imagesLoaded` | All frame images preloaded |
|
|
687
|
+
| `zoom` | Current zoom level |
|
|
688
|
+
| `minZoom` / `maxZoom` | Zoom bounds |
|
|
689
|
+
| `isResetDisabled` | Whether reset has no effect |
|
|
690
|
+
| `isHotspotMode` | Add mode active |
|
|
691
|
+
| `viewerCursorClass` | Cursor class for viewport |
|
|
692
|
+
| `getHotspotScreenPosition` | Maps hotspot to screen % |
|
|
693
|
+
| `getCurrentImageLayout` | Current image layout metrics |
|
|
694
|
+
| `handlePointerDown/Move/Up` | Drag handlers |
|
|
695
|
+
| `handleWheel` | Zoom on scroll |
|
|
696
|
+
| `handleCanvasClick` | Click handler (add mode) |
|
|
697
|
+
| `handleZoomIn/Out` | Step zoom |
|
|
698
|
+
| `handleResetView` | Reset zoom and pan |
|
|
699
|
+
|
|
700
|
+
---
|
|
701
|
+
|
|
702
|
+
## Exported utilities
|
|
703
|
+
|
|
704
|
+
| Export | Purpose |
|
|
705
|
+
|--------|---------|
|
|
706
|
+
| `toViewer360Hotspots` | Convert marker records to hotspot array |
|
|
707
|
+
| `hotspotToViewer360Marker` | Extract title/description from hotspot data |
|
|
708
|
+
| `filterHotspotsByFrame` | Filter hotspots by frame index |
|
|
709
|
+
| `computeHotspotScreenPosition` | Map stored coords to screen position |
|
|
710
|
+
| `computeHotspotPositionFromClick` | Derive coords from click event |
|
|
711
|
+
| `computeViewerImageLayout` | Image letterbox layout math |
|
|
712
|
+
| `computeDragFrameIndex` | Drag delta → frame index |
|
|
713
|
+
| `preloadViewerFrames` | Preload all frame images |
|
|
714
|
+
| `viewer360ClassNames` | Default Tailwind class map |
|
|
715
|
+
| `viewer360MarkerPinClassNames` | Default pin class map |
|
|
716
|
+
| `defaultViewer360Labels` | Default label strings |
|
|
717
|
+
| `defaultViewer360Config` | Default config values |
|
|
718
|
+
|
|
719
|
+
### Sub-components
|
|
720
|
+
|
|
721
|
+
These can be used standalone in custom render props:
|
|
722
|
+
|
|
723
|
+
- `Viewer360FrameIndicator` — frame badge
|
|
724
|
+
- `Viewer360MarkerPin` — hotspot dot + hover tooltip
|
|
725
|
+
- `Viewer360Toolbar` — bottom control bar
|
|
726
|
+
- `Viewer360LoadingOverlay` — loading state
|
|
727
|
+
- `Viewer360AddModeBanner` — add-mode banner
|
|
728
|
+
|
|
729
|
+
---
|
|
179
730
|
|
|
180
731
|
## Package structure
|
|
181
732
|
|
|
@@ -185,25 +736,35 @@ src/
|
|
|
185
736
|
ui/ Badge, Button, Card, Item, Label, Popover, Separator, Spinner
|
|
186
737
|
utils/ cn helper
|
|
187
738
|
feature/ Viewer360 and sub-components
|
|
188
|
-
constants/
|
|
189
|
-
helpers/
|
|
190
|
-
hooks/
|
|
191
|
-
types/
|
|
739
|
+
constants/ Default config, labels, class names
|
|
740
|
+
helpers/ Zoom, layout, hotspot, canvas utilities
|
|
741
|
+
hooks/ useViewer360
|
|
742
|
+
types/ TypeScript definitions
|
|
192
743
|
```
|
|
193
744
|
|
|
194
|
-
The published package includes
|
|
745
|
+
The published package includes:
|
|
746
|
+
|
|
747
|
+
- `dist/index.js` — ESM bundle
|
|
748
|
+
- `dist/index.d.ts` — TypeScript types
|
|
749
|
+
- `dist/styles.css` — pre-built Tailwind CSS (import this in your app)
|
|
750
|
+
- `src/` — source files (optional, for Tailwind `@source` scanning)
|
|
751
|
+
|
|
752
|
+
---
|
|
195
753
|
|
|
196
754
|
## Development
|
|
197
755
|
|
|
198
756
|
```bash
|
|
199
757
|
npm install
|
|
200
|
-
npm run test-run
|
|
201
|
-
npm run build
|
|
758
|
+
npm run test-run # run tests
|
|
759
|
+
npm run build # build dist/
|
|
760
|
+
npm run dev # watch mode
|
|
761
|
+
npm run tsc # type check
|
|
202
762
|
```
|
|
203
763
|
|
|
204
764
|
## Publishing
|
|
205
765
|
|
|
206
766
|
```bash
|
|
767
|
+
npm version patch # bump version (required — npm rejects duplicate versions)
|
|
207
768
|
npm publish
|
|
208
769
|
```
|
|
209
770
|
|