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