@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 CHANGED
@@ -1,111 +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.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 'react';
100
- 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";
101
37
 
102
38
  const frames: Viewer360Frame[] = [
103
- { id: '1', src: '/images/frame-01.jpg', label: 'Front' },
104
- { id: '2', src: '/images/frame-02.jpg', label: 'Front-right' },
105
- { 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" },
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
- renderFrameIndicator={({ currentFrameIndex, frameCount, frameLabel, labels, frameIndicatorClassName }) => (
165
- <Viewer360FrameIndicator
166
- className={frameIndicatorClassName}
167
- label={labels.frameIndicator({
168
- current: currentFrameIndex + 1,
169
- total: frameCount,
170
- label: frameLabel,
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
- | `hotspotPin` option | Type | Description |
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 // shows "Add hotspot" button in toolbar
288
- hotspotMode={isAddMode} // controlled add mode
289
- onHotspotModeChange={setIsAddMode}
83
+ showHotspotModeControl
290
84
  onHotspotAdd={(position) => {
291
- // position: { frameIndex, frameId, positionX, positionY }
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: 10,
98
+ dragSensitivity: 8,
383
99
  autoRotate: true,
384
100
  autoRotateIntervalMs: 150,
385
- autoRotateDirection: 'forward',
386
101
  }}
387
102
  />
388
103
  ```
389
104
 
390
- ### Viewer360 props
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
- Default `frameIndicator` format: `Interior · 1 / 3`.
442
-
443
- ### Class names
444
-
445
- Import defaults and extend:
446
-
447
- ```tsx
448
- import { viewer360ClassNames, viewer360MarkerPinClassNames } from '@mmmmzxe/react-360-viewer';
449
-
450
- <Viewer360
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 test-run # run tests
722
- npm run build # build dist/
723
- npm run dev # watch mode
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 # bump version (required — npm rejects duplicate versions)
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