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