@mmmmzxe/react-360-viewer 0.1.12 → 0.1.14

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