@mmmmzxe/react-360-viewer 0.1.3 → 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 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,95 @@ 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.** 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
- Your host app should also define the standard shadcn CSS variables (`--background`, `--foreground`, `--primary`, `--border`, `--muted-foreground`, and related tokens) in your global stylesheet.
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, autoRotate: false }}
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
- Hotspots are percentage-based positions (`positionX`, `positionY` from 0–100) tied to a specific frame.
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
- ### Built-in marker pins
227
+ Every hotspot automatically renders as a red marker pin with a **hover tooltip**. No extra setup required:
67
228
 
68
- Use `hotspotPin` for tooltip pins with optional delete — no custom UI required:
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
- { id: '1', frameIndex: 0, positionX: 42, positionY: 58, title: 'Scratch', description: 'Front bumper' },
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
- Use `renderTag` inside `hotspotPin` for badges (e.g. damage type). Use `renderHotspot` only when you need fully custom marker UI.
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
- ### Custom hotspot UI
297
+ ### Hotspot add mode
92
298
 
93
- ```tsx
94
- import { Viewer360, type Viewer360Hotspot } from '@mmmmzxe/react-360-viewer';
299
+ Enable placing new hotspots by clicking the image:
95
300
 
96
- type DamageHotspot = { title: string; severity: 'low' | 'high' };
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
- console.log('Place hotspot at', position);
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
- style={{ position: 'absolute', left: `${leftPercent}%`, top: `${topPercent}%` }}
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 hotspot add mode is active, clicking the viewport calls `onHotspotAdd` with normalized coordinates that stay aligned while zooming or panning.
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
- | `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 |
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` | 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 |
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` | — | App design-system toolbar |
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
- ## Headless usage
461
+ Default `frameIndicator` format: `Interior · 1 / 3`.
462
+
463
+ ### Class names
149
464
 
150
- Use `useViewer360` when you need full control over markup:
465
+ Import defaults and extend:
151
466
 
152
467
  ```tsx
153
- import { useViewer360 } from '@mmmmzxe/react-360-viewer';
468
+ import { viewer360ClassNames, viewer360MarkerPinClassNames } from '@mmmmzxe/react-360-viewer';
154
469
 
155
- const viewer = useViewer360({
156
- frames,
157
- currentFrameIndex,
158
- onFrameChange: setFrameIndex,
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
- The hook exposes canvas refs, interaction handlers, zoom state, and hotspot positioning helpers.
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
- ## Theming
496
+ ## Customization & render props
165
497
 
166
- Override CSS variables on the root element or pass `theme`:
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
- See the `theme` prop on `Viewer360` for supported CSS variable overrides.
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 `dist/` (ESM + type declarations) and `src/` (for Tailwind `@source` scanning).
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