@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 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
- - Hotspot support with built-in marker pins
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
- - Peer dependencies: React, lucide-react, and a Tailwind setup in the host app
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: `react`, `react-dom` (v18+), and `lucide-react`.
26
+ ### Peer dependencies
25
27
 
26
- ### Tailwind setup
28
+ | Package | Version |
29
+ |---------|---------|
30
+ | `react` | `>=18` |
31
+ | `react-dom` | `>=18` |
32
+ | `lucide-react` | `>=0.400.0` |
27
33
 
28
- The package uses Tailwind utility classes with shadcn design tokens (`bg-card`, `border-border`, `text-muted-foreground`, etc.). Add the package source to your Tailwind CSS entry so those classes are generated:
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
- Your host app should also define the standard shadcn CSS variables (`--background`, `--foreground`, `--primary`, `--border`, `--muted-foreground`, and related tokens) in your global stylesheet.
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, autoRotate: false }}
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
- Hotspots are percentage-based positions (`positionX`, `positionY` from 0–100) tied to a specific frame.
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
- Use `hotspotPin` for tooltip pins with optional delete no custom UI required:
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
- { id: '1', frameIndex: 0, positionX: 42, positionY: 58, title: 'Scratch', description: 'Front bumper' },
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
- Use `renderTag` inside `hotspotPin` for badges (e.g. damage type). Use `renderHotspot` only when you need fully custom marker UI.
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
- ### Custom hotspot UI
314
+ ### Hotspot add mode
92
315
 
93
- ```tsx
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
- const hotspots: Viewer360Hotspot<DamageHotspot>[] = [
99
- { id: 'a', frameIndex: 0, positionX: 42, positionY: 58, data: { title: 'Scratch', severity: 'low' } },
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
- console.log('Place hotspot at', position);
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
- style={{ position: 'absolute', left: `${leftPercent}%`, top: `${topPercent}%` }}
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 hotspot add mode is active, clicking the viewport calls `onHotspotAdd` with normalized coordinates that stay aligned while zooming or panning.
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
- | `config.minZoom` | `number` | `1` | Minimum zoom level |
127
- | `config.maxZoom` | `number` | `3` | Maximum zoom level |
128
- | `config.zoomStep` | `number` | `0.15` | Zoom increment |
129
- | `config.dragSensitivity` | `number` | `8` | Pixels per frame while dragging |
130
- | `config.autoRotate` | `boolean` | `false` | Enable automatic rotation |
131
- | `config.autoRotateIntervalMs` | `number` | `100` | Delay between auto-rotate steps |
132
- | `config.autoRotateDirection` | `'forward' \| 'backward'` | `'forward'` | Auto-rotate direction |
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` | Helper text in toolbar |
137
- | `aspectRatio` | `string` | `'16 / 10'` | CSS aspect-ratio value |
138
- | `labels` | `Viewer360Labels` | English defaults | Localized strings |
139
- | `classNames` | `Viewer360ClassNames` | BEM classes | Override CSS hooks |
140
- | `theme` | `Viewer360Theme` | light palette | CSS variable overrides |
141
- | `hotspotPin` | `Viewer360HotspotPinOptions` | — | Built-in marker pin with tooltip/delete |
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` | — | App design-system toolbar |
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
- ## Headless usage
478
+ Default `frameIndicator` format: `Interior · 1 / 3`.
479
+
480
+ ### Class names
149
481
 
150
- Use `useViewer360` when you need full control over markup:
482
+ Import defaults and extend:
151
483
 
152
484
  ```tsx
153
- import { useViewer360 } from '@mmmmzxe/react-360-viewer';
485
+ import { viewer360ClassNames, viewer360MarkerPinClassNames } from '@mmmmzxe/react-360-viewer';
154
486
 
155
- const viewer = useViewer360({
156
- frames,
157
- currentFrameIndex,
158
- onFrameChange: setFrameIndex,
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
- The hook exposes canvas refs, interaction handlers, zoom state, and hotspot positioning helpers.
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
- ## Theming
540
+ ### Theming
165
541
 
166
- Override CSS variables on the root element or pass `theme`:
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
- See the `theme` prop on `Viewer360` for supported CSS variable overrides.
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 `dist/` (ESM + type declarations) and `src/` (for Tailwind `@source` scanning).
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