@mmmmzxe/react-360-viewer 0.1.13 → 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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +3 -3
  3. package/src/components/ui/Badge/index.tsx +0 -45
  4. package/src/components/ui/Button/index.tsx +0 -67
  5. package/src/components/ui/Card/index.tsx +0 -74
  6. package/src/components/ui/Item/index.tsx +0 -136
  7. package/src/components/ui/Label/index.tsx +0 -20
  8. package/src/components/ui/Popover/index.tsx +0 -56
  9. package/src/components/ui/Separator/index.tsx +0 -30
  10. package/src/components/ui/Spinner/index.tsx +0 -11
  11. package/src/components/utils/index.ts +0 -6
  12. package/src/constants/viewer360ClassNames.ts +0 -42
  13. package/src/constants/viewer360Config.ts +0 -11
  14. package/src/constants/viewer360Labels.ts +0 -14
  15. package/src/constants/viewer360MarkerLabels.ts +0 -3
  16. package/src/feature/Viewer360.test.tsx +0 -47
  17. package/src/feature/Viewer360.tsx +0 -223
  18. package/src/feature/Viewer360AddModeBanner.tsx +0 -20
  19. package/src/feature/Viewer360FrameIndicator.tsx +0 -20
  20. package/src/feature/Viewer360HotspotOverlay.tsx +0 -57
  21. package/src/feature/Viewer360LoadingOverlay.tsx +0 -28
  22. package/src/feature/Viewer360MarkerPin.tsx +0 -105
  23. package/src/feature/Viewer360Toolbar.tsx +0 -75
  24. package/src/helpers/adjustViewerZoom.test.ts +0 -29
  25. package/src/helpers/adjustViewerZoom.ts +0 -64
  26. package/src/helpers/computeDragFrameIndex.test.ts +0 -20
  27. package/src/helpers/computeDragFrameIndex.ts +0 -23
  28. package/src/helpers/computeViewerImageLayout.test.ts +0 -48
  29. package/src/helpers/computeViewerImageLayout.ts +0 -114
  30. package/src/helpers/computeViewerPanOffset.ts +0 -18
  31. package/src/helpers/markerHelpers.test.ts +0 -38
  32. package/src/helpers/markerHelpers.ts +0 -33
  33. package/src/helpers/viewer360PropsHelpers.ts +0 -46
  34. package/src/helpers/viewerHelpers.ts +0 -74
  35. package/src/hooks/useViewer360.ts +0 -306
  36. package/src/index.ts +0 -68
  37. package/src/styles.css +0 -80
  38. package/src/types/Viewer360Hotspot.ts +0 -67
  39. package/src/types/Viewer360Marker.ts +0 -52
  40. package/src/types/Viewer360Props.ts +0 -108
  41. package/src/types/index.ts +0 -30
  42. package/src/utils/index.ts +0 -6
@@ -1,48 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import {
4
- computeHotspotPositionFromClick,
5
- computeHotspotScreenPosition,
6
- computeViewerImageLayout,
7
- } from './computeViewerImageLayout';
8
-
9
- describe('computeViewerImageLayout', () => {
10
- const layout = computeViewerImageLayout({
11
- containerWidth: 800,
12
- containerHeight: 500,
13
- imageWidth: 1600,
14
- imageHeight: 900,
15
- });
16
-
17
- it('letterboxes wide images', () => {
18
- expect(layout.drawWidth).toBe(800);
19
- expect(layout.drawHeight).toBeCloseTo(450);
20
- expect(layout.offsetY).toBeCloseTo(25);
21
- });
22
-
23
- it('maps stored hotspot coordinates to screen space', () => {
24
- const screen = computeHotspotScreenPosition(50, 50, layout, 1);
25
- expect(screen.leftPercent).toBeCloseTo(50, 1);
26
- expect(screen.topPercent).toBeCloseTo(50, 1);
27
- });
28
-
29
- it('derives hotspot coordinates from click position', () => {
30
- const rect = {
31
- left: 0,
32
- top: 0,
33
- width: 800,
34
- height: 500,
35
- right: 800,
36
- bottom: 500,
37
- x: 0,
38
- y: 0,
39
- toJSON: () => ({}),
40
- } as DOMRect;
41
-
42
- const position = computeHotspotPositionFromClick(400, 250, rect, layout, 1);
43
- expect(position.positionX).toBeGreaterThan(0);
44
- expect(position.positionY).toBeGreaterThan(0);
45
- expect(position.positionX).toBeLessThanOrEqual(100);
46
- expect(position.positionY).toBeLessThanOrEqual(100);
47
- });
48
- });
@@ -1,114 +0,0 @@
1
- export type ViewerImageLayout = {
2
- width: number;
3
- height: number;
4
- centerX: number;
5
- centerY: number;
6
- drawWidth: number;
7
- drawHeight: number;
8
- offsetX: number;
9
- offsetY: number;
10
- };
11
-
12
- export type PanOffset = {
13
- panX: number;
14
- panY: number;
15
- };
16
-
17
- type ComputeViewerImageLayoutParams = {
18
- containerWidth: number;
19
- containerHeight: number;
20
- imageWidth: number;
21
- imageHeight: number;
22
- pan?: PanOffset;
23
- };
24
-
25
- export function computeViewerImageLayout({
26
- containerWidth,
27
- containerHeight,
28
- imageWidth,
29
- imageHeight,
30
- pan = { panX: 0, panY: 0 },
31
- }: ComputeViewerImageLayoutParams): ViewerImageLayout {
32
- const imgAspect = imageWidth / imageHeight;
33
- const containerAspect = containerWidth / containerHeight;
34
-
35
- let drawWidth: number;
36
- let drawHeight: number;
37
-
38
- if (imgAspect > containerAspect) {
39
- drawWidth = containerWidth;
40
- drawHeight = containerWidth / imgAspect;
41
- } else {
42
- drawHeight = containerHeight;
43
- drawWidth = containerHeight * imgAspect;
44
- }
45
-
46
- const offsetX = (containerWidth - drawWidth) / 2 + pan.panX;
47
- const offsetY = (containerHeight - drawHeight) / 2 + pan.panY;
48
-
49
- return {
50
- width: containerWidth,
51
- height: containerHeight,
52
- centerX: containerWidth / 2,
53
- centerY: containerHeight / 2,
54
- drawWidth,
55
- drawHeight,
56
- offsetX,
57
- offsetY,
58
- };
59
- }
60
-
61
- export function computeHotspotScreenPosition(
62
- hotspotX: number,
63
- hotspotY: number,
64
- layout: ViewerImageLayout,
65
- zoom: number
66
- ): { leftPercent: number; topPercent: number } {
67
- const baseOffsetX = (layout.width - layout.drawWidth) / 2;
68
- const baseOffsetY = (layout.height - layout.drawHeight) / 2;
69
-
70
- const containerX = (hotspotX / 100) * layout.width;
71
- const containerY = (hotspotY / 100) * layout.height;
72
-
73
- const imageLocalX = (containerX - baseOffsetX) / layout.drawWidth;
74
- const imageLocalY = (containerY - baseOffsetY) / layout.drawHeight;
75
-
76
- const imagePointX = layout.offsetX + imageLocalX * layout.drawWidth;
77
- const imagePointY = layout.offsetY + imageLocalY * layout.drawHeight;
78
-
79
- const screenX = layout.centerX + zoom * (imagePointX - layout.centerX);
80
- const screenY = layout.centerY + zoom * (imagePointY - layout.centerY);
81
-
82
- return {
83
- leftPercent: (screenX / layout.width) * 100,
84
- topPercent: (screenY / layout.height) * 100,
85
- };
86
- }
87
-
88
- export function computeHotspotPositionFromClick(
89
- clientX: number,
90
- clientY: number,
91
- containerRect: DOMRect,
92
- layout: ViewerImageLayout,
93
- zoom: number
94
- ): { positionX: number; positionY: number } {
95
- const clickX = clientX - containerRect.left;
96
- const clickY = clientY - containerRect.top;
97
-
98
- const unzoomedX = layout.centerX + (clickX - layout.centerX) / zoom;
99
- const unzoomedY = layout.centerY + (clickY - layout.centerY) / zoom;
100
-
101
- const baseOffsetX = (layout.width - layout.drawWidth) / 2;
102
- const baseOffsetY = (layout.height - layout.drawHeight) / 2;
103
-
104
- const imageLocalX = Math.min(1, Math.max(0, (unzoomedX - layout.offsetX) / layout.drawWidth));
105
- const imageLocalY = Math.min(1, Math.max(0, (unzoomedY - layout.offsetY) / layout.drawHeight));
106
-
107
- const storedX = baseOffsetX + imageLocalX * layout.drawWidth;
108
- const storedY = baseOffsetY + imageLocalY * layout.drawHeight;
109
-
110
- return {
111
- positionX: Math.min(100, Math.max(0, (storedX / layout.width) * 100)),
112
- positionY: Math.min(100, Math.max(0, (storedY / layout.height) * 100)),
113
- };
114
- }
@@ -1,18 +0,0 @@
1
- import type { PanOffset } from './computeViewerImageLayout';
2
-
3
- type PanStart = {
4
- pointerX: number;
5
- pointerY: number;
6
- panX: number;
7
- panY: number;
8
- };
9
-
10
- export function computeViewerPanOffset(panStart: PanStart, clientX: number, clientY: number): PanOffset {
11
- const deltaX = clientX - panStart.pointerX;
12
- const deltaY = clientY - panStart.pointerY;
13
-
14
- return {
15
- panX: panStart.panX + deltaX,
16
- panY: panStart.panY + deltaY,
17
- };
18
- }
@@ -1,38 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { hotspotToViewer360Marker, toViewer360Hotspots } from './markerHelpers';
4
-
5
- describe('markerHelpers', () => {
6
- it('maps hotspot data to marker content', () => {
7
- const marker = hotspotToViewer360Marker({
8
- id: '1',
9
- frameIndex: 0,
10
- positionX: 40,
11
- positionY: 50,
12
- data: { id: '1', title: 'Scratch', description: 'Front bumper' },
13
- });
14
-
15
- expect(marker).toEqual({ id: '1', title: 'Scratch', description: 'Front bumper' });
16
- });
17
-
18
- it('falls back to hotspot id when data is missing', () => {
19
- const marker = hotspotToViewer360Marker({
20
- id: 'marker-2',
21
- frameIndex: 1,
22
- positionX: 10,
23
- positionY: 20,
24
- });
25
-
26
- expect(marker).toEqual({ id: 'marker-2', title: 'marker-2' });
27
- });
28
-
29
- it('builds hotspot array from marker records', () => {
30
- const hotspots = toViewer360Hotspots([
31
- { id: 'a', frameIndex: 0, positionX: 12, positionY: 34, title: 'Dent' },
32
- ]);
33
-
34
- expect(hotspots).toHaveLength(1);
35
- expect(hotspots[0]?.frameIndex).toBe(0);
36
- expect(hotspots[0]?.positionX).toBe(12);
37
- });
38
- });
@@ -1,33 +0,0 @@
1
- import type { Viewer360Hotspot, Viewer360Marker } from '../types';
2
-
3
- export function hotspotToViewer360Marker<TData>(hotspot: Viewer360Hotspot<TData>): Viewer360Marker {
4
- const data = hotspot.data;
5
-
6
- if (data && typeof data === 'object' && 'title' in data && typeof (data as { title?: unknown }).title === 'string') {
7
- const marker = data as unknown as Viewer360Marker;
8
-
9
- return {
10
- id: marker.id ?? hotspot.id,
11
- title: marker.title,
12
- description: marker.description,
13
- };
14
- }
15
-
16
- return {
17
- id: hotspot.id,
18
- title: hotspot.id,
19
- };
20
- }
21
-
22
- export function toViewer360Hotspots<TData extends Viewer360Marker>(
23
- markers: Array<Viewer360Marker & { frameIndex: number; positionX: number; positionY: number }>,
24
- mapData?: (marker: Viewer360Marker & { frameIndex: number; positionX: number; positionY: number }) => TData
25
- ): Viewer360Hotspot<TData>[] {
26
- return markers.map((marker) => ({
27
- id: marker.id,
28
- frameIndex: marker.frameIndex,
29
- positionX: marker.positionX,
30
- positionY: marker.positionY,
31
- data: mapData ? mapData(marker) : (marker as unknown as TData),
32
- }));
33
- }
@@ -1,46 +0,0 @@
1
- import type { CSSProperties } from 'react';
2
-
3
- import { defaultViewer360Labels } from '../constants/viewer360Labels';
4
- import { viewer360ClassNames } from '../constants/viewer360ClassNames';
5
- import type { Viewer360ClassNames, Viewer360Labels, Viewer360Theme } from '../types';
6
- import { cn } from '@/components/utils';
7
-
8
- export function mergeViewer360Labels(labels?: Viewer360Labels): Required<Viewer360Labels> {
9
- return {
10
- loading: labels?.loading ?? defaultViewer360Labels.loading,
11
- dragHint: labels?.dragHint ?? defaultViewer360Labels.dragHint,
12
- frameIndicator: labels?.frameIndicator ?? defaultViewer360Labels.frameIndicator,
13
- zoom: labels?.zoom ?? defaultViewer360Labels.zoom,
14
- hotspotModeActive: labels?.hotspotModeActive ?? defaultViewer360Labels.hotspotModeActive,
15
- addHotspot: labels?.addHotspot ?? defaultViewer360Labels.addHotspot,
16
- zoomIn: labels?.zoomIn ?? defaultViewer360Labels.zoomIn,
17
- zoomOut: labels?.zoomOut ?? defaultViewer360Labels.zoomOut,
18
- resetView: labels?.resetView ?? defaultViewer360Labels.resetView,
19
- deleteMarker: labels?.deleteMarker ?? defaultViewer360Labels.deleteMarker,
20
- };
21
- }
22
-
23
- export function mergeViewer360ClassNames(classNames?: Viewer360ClassNames): Required<Viewer360ClassNames> {
24
- return {
25
- root: cn(viewer360ClassNames.root, classNames?.root),
26
- viewport: cn(viewer360ClassNames.viewport, classNames?.viewport),
27
- canvas: cn(viewer360ClassNames.canvas, classNames?.canvas),
28
- overlay: cn(viewer360ClassNames.overlay, classNames?.overlay),
29
- loading: cn(viewer360ClassNames.loading, classNames?.loading),
30
- loadingText: cn(viewer360ClassNames.loadingText, classNames?.loadingText),
31
- frameIndicator: cn(viewer360ClassNames.frameIndicator, classNames?.frameIndicator),
32
- hotspotModeBanner: cn(viewer360ClassNames.hotspotModeBanner, classNames?.hotspotModeBanner),
33
- toolbar: cn(viewer360ClassNames.toolbar, classNames?.toolbar),
34
- dragHint: cn(viewer360ClassNames.dragHint, classNames?.dragHint),
35
- controls: cn(viewer360ClassNames.controls, classNames?.controls),
36
- controlButton: cn(viewer360ClassNames.controlButton, classNames?.controlButton),
37
- controlButtonActive: cn(viewer360ClassNames.controlButtonActive, classNames?.controlButtonActive),
38
- controlButtonDisabled: cn(viewer360ClassNames.controlButtonDisabled, classNames?.controlButtonDisabled),
39
- zoomDisplay: cn(viewer360ClassNames.zoomDisplay, classNames?.zoomDisplay),
40
- divider: cn(viewer360ClassNames.divider, classNames?.divider),
41
- };
42
- }
43
-
44
- export function buildViewer360ThemeStyle(theme?: Viewer360Theme): CSSProperties {
45
- return theme ? (theme as CSSProperties) : {};
46
- }
@@ -1,74 +0,0 @@
1
- import type { Viewer360Frame } from '../types';
2
-
3
- import { computeViewerImageLayout, type PanOffset } from './computeViewerImageLayout';
4
-
5
- type DrawFrameOnCanvasParams = {
6
- canvas: HTMLCanvasElement;
7
- container: HTMLDivElement;
8
- image: HTMLImageElement;
9
- zoom: number;
10
- pan: PanOffset;
11
- };
12
-
13
- export function drawFrameOnCanvas({ canvas, container, image, zoom, pan }: DrawFrameOnCanvasParams): void {
14
- if (!image.complete || !image.naturalWidth) return;
15
-
16
- const rect = container.getBoundingClientRect();
17
- const dpr = window.devicePixelRatio || 1;
18
-
19
- canvas.width = rect.width * dpr;
20
- canvas.height = rect.height * dpr;
21
- canvas.style.width = `${rect.width}px`;
22
- canvas.style.height = `${rect.height}px`;
23
-
24
- const ctx = canvas.getContext('2d');
25
- if (!ctx) return;
26
-
27
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
28
- ctx.clearRect(0, 0, rect.width, rect.height);
29
-
30
- const layout = computeViewerImageLayout({
31
- containerWidth: rect.width,
32
- containerHeight: rect.height,
33
- imageWidth: image.naturalWidth,
34
- imageHeight: image.naturalHeight,
35
- pan,
36
- });
37
-
38
- ctx.save();
39
- ctx.translate(layout.centerX, layout.centerY);
40
- ctx.scale(zoom, zoom);
41
- ctx.translate(-layout.centerX, -layout.centerY);
42
- ctx.drawImage(image, layout.offsetX, layout.offsetY, layout.drawWidth, layout.drawHeight);
43
- ctx.restore();
44
- }
45
-
46
- export function getFramesSignature(frames: Viewer360Frame[]): string {
47
- return frames.map((frame) => frame.id).join('-');
48
- }
49
-
50
- export function preloadFrameImage(frame: Viewer360Frame): Promise<HTMLImageElement> {
51
- return new Promise((resolve, reject) => {
52
- const img = new Image();
53
- img.onload = (): void => resolve(img);
54
- img.onerror = (): void => reject(new Error(`Failed to load frame: ${frame.src}`));
55
- img.src = frame.src;
56
- });
57
- }
58
-
59
- export async function preloadViewerFrames(frames: Viewer360Frame[]): Promise<HTMLImageElement[]> {
60
- const results = await Promise.allSettled(frames.map(preloadFrameImage));
61
-
62
- return results.map((result) => (result.status === 'fulfilled' ? result.value : new Image()));
63
- }
64
-
65
- export function hasLoadedViewerFrame(images: HTMLImageElement[]): boolean {
66
- return images.some((image) => image.complete && image.naturalWidth > 0);
67
- }
68
-
69
- export function filterHotspotsByFrame<TData>(
70
- hotspots: Array<{ frameIndex: number; data?: TData }>,
71
- frameIndex: number
72
- ): Array<{ frameIndex: number; data?: TData }> {
73
- return hotspots.filter((hotspot) => hotspot.frameIndex === frameIndex);
74
- }
@@ -1,306 +0,0 @@
1
- import type { PointerEvent as ReactPointerEvent, RefObject, WheelEvent as ReactWheelEvent } from 'react';
2
- import { useEffect, useMemo, useRef, useState } from 'react';
3
-
4
- import {
5
- applyWheelZoom,
6
- getViewerCursorClass,
7
- isResetDisabled,
8
- resolveViewer360Config,
9
- stepZoomIn,
10
- stepZoomOut,
11
- } from '../helpers/adjustViewerZoom';
12
- import { computeDragFrameIndex } from '../helpers/computeDragFrameIndex';
13
- import {
14
- computeHotspotPositionFromClick,
15
- computeHotspotScreenPosition,
16
- computeViewerImageLayout,
17
- type ViewerImageLayout,
18
- } from '../helpers/computeViewerImageLayout';
19
- import { computeViewerPanOffset } from '../helpers/computeViewerPanOffset';
20
- import {
21
- drawFrameOnCanvas,
22
- filterHotspotsByFrame,
23
- getFramesSignature,
24
- hasLoadedViewerFrame,
25
- preloadViewerFrames,
26
- } from '../helpers/viewerHelpers';
27
- import type {
28
- Viewer360Config,
29
- Viewer360Frame,
30
- Viewer360Hotspot,
31
- Viewer360HotspotPosition,
32
- Viewer360PanOffset,
33
- } from '../types';
34
-
35
- type UseViewer360Params<TData> = {
36
- frames: Viewer360Frame[];
37
- currentFrameIndex: number;
38
- onFrameChange: (index: number) => void;
39
- hotspots?: Viewer360Hotspot<TData>[];
40
- config?: Viewer360Config;
41
- hotspotMode?: boolean;
42
- onHotspotAdd?: (position: Viewer360HotspotPosition) => void;
43
- };
44
-
45
- type UseViewer360Return<TData> = {
46
- canvasRef: RefObject<HTMLCanvasElement | null>;
47
- containerRef: RefObject<HTMLDivElement | null>;
48
- currentFrame: Viewer360Frame | undefined;
49
- currentFrameHotspots: Viewer360Hotspot<TData>[];
50
- imagesLoaded: boolean;
51
- isHotspotMode: boolean;
52
- isResetDisabled: boolean;
53
- maxZoom: number;
54
- minZoom: number;
55
- viewerCursorClass: string;
56
- zoom: number;
57
- getCurrentImageLayout: () => ViewerImageLayout | null;
58
- getHotspotScreenPosition: (hotspot: Viewer360Hotspot<TData>) => { leftPercent: number; topPercent: number };
59
- handleCanvasClick: (event: React.MouseEvent<HTMLDivElement>) => void;
60
- handlePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
61
- handlePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
62
- handlePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
63
- handleResetView: () => void;
64
- handleWheel: (event: ReactWheelEvent<HTMLDivElement>) => void;
65
- handleZoomIn: () => void;
66
- handleZoomOut: () => void;
67
- };
68
-
69
- export function useViewer360<TData = unknown>({
70
- frames,
71
- hotspots = [],
72
- currentFrameIndex,
73
- onFrameChange,
74
- config,
75
- hotspotMode: hotspotModeProp = false,
76
- onHotspotAdd,
77
- }: UseViewer360Params<TData>): UseViewer360Return<TData> {
78
- const resolvedConfig = useMemo(() => resolveViewer360Config(config), [config]);
79
- const { minZoom, maxZoom } = resolvedConfig;
80
-
81
- const canvasRef = useRef<HTMLCanvasElement>(null);
82
- const containerRef = useRef<HTMLDivElement>(null);
83
- const imagesRef = useRef<HTMLImageElement[]>([]);
84
- const dragStartRef = useRef<{ pointerX: number; frameIndex: number } | null>(null);
85
- const panStartRef = useRef<{ pointerX: number; pointerY: number; panX: number; panY: number } | null>(null);
86
-
87
- const framesSignature = getFramesSignature(frames);
88
- const [loadedSignature, setLoadedSignature] = useState<string | null>(null);
89
- const [zoom, setZoom] = useState<number>(minZoom);
90
- const [pan, setPan] = useState<Viewer360PanOffset>({ panX: 0, panY: 0 });
91
- const [isDragging, setIsDragging] = useState(false);
92
-
93
- const isHotspotMode = hotspotModeProp;
94
- const imagesLoaded = loadedSignature === framesSignature;
95
- const currentFrameHotspots = filterHotspotsByFrame(hotspots, currentFrameIndex) as Viewer360Hotspot<TData>[];
96
- const currentFrame = frames[currentFrameIndex];
97
- const viewerCursorClass = getViewerCursorClass(isHotspotMode, zoom, isDragging, minZoom);
98
- const resetDisabled = isResetDisabled(zoom, pan, resolvedConfig);
99
-
100
- useEffect(() => {
101
- let cancelled = false;
102
- imagesRef.current = [];
103
-
104
- async function loadFrames(): Promise<void> {
105
- const loadedImages = await preloadViewerFrames(frames);
106
-
107
- if (!cancelled && hasLoadedViewerFrame(loadedImages)) {
108
- imagesRef.current = loadedImages;
109
- setLoadedSignature(framesSignature);
110
- }
111
- }
112
-
113
- void loadFrames();
114
-
115
- return (): void => {
116
- cancelled = true;
117
- };
118
- }, [frames, framesSignature]);
119
-
120
- useEffect(() => {
121
- if (!imagesLoaded) return;
122
-
123
- function renderFrame(): void {
124
- const canvas = canvasRef.current;
125
- const container = containerRef.current;
126
- const img = imagesRef.current[currentFrameIndex];
127
-
128
- if (!canvas || !container) return;
129
-
130
- drawFrameOnCanvas({ canvas, container, image: img, zoom, pan });
131
- }
132
-
133
- renderFrame();
134
- window.addEventListener('resize', renderFrame);
135
-
136
- return (): void => window.removeEventListener('resize', renderFrame);
137
- }, [imagesLoaded, currentFrameIndex, zoom, pan]);
138
-
139
- useEffect(() => {
140
- if (!resolvedConfig.autoRotate || frames.length <= 1 || isDragging || isHotspotMode || zoom > minZoom) {
141
- return;
142
- }
143
-
144
- const direction = resolvedConfig.autoRotateDirection === 'backward' ? -1 : 1;
145
- const interval = window.setInterval(() => {
146
- onFrameChange((currentFrameIndex + direction + frames.length) % frames.length);
147
- }, resolvedConfig.autoRotateIntervalMs);
148
-
149
- return (): void => window.clearInterval(interval);
150
- }, [
151
- resolvedConfig.autoRotate,
152
- resolvedConfig.autoRotateDirection,
153
- resolvedConfig.autoRotateIntervalMs,
154
- frames.length,
155
- currentFrameIndex,
156
- isDragging,
157
- isHotspotMode,
158
- zoom,
159
- minZoom,
160
- onFrameChange,
161
- ]);
162
-
163
- function getCurrentImageLayout(): ViewerImageLayout | null {
164
- const container = containerRef.current;
165
- const image = imagesRef.current[currentFrameIndex];
166
-
167
- if (!container || !image || !image.complete || !image.naturalWidth) return null;
168
-
169
- const rect = container.getBoundingClientRect();
170
-
171
- return computeViewerImageLayout({
172
- containerWidth: rect.width,
173
- containerHeight: rect.height,
174
- imageWidth: image.naturalWidth,
175
- imageHeight: image.naturalHeight,
176
- pan,
177
- });
178
- }
179
-
180
- function getHotspotScreenPosition(hotspot: Viewer360Hotspot<TData>): { leftPercent: number; topPercent: number } {
181
- const layout = getCurrentImageLayout();
182
-
183
- if (!layout) {
184
- return { leftPercent: hotspot.positionX, topPercent: hotspot.positionY };
185
- }
186
-
187
- return computeHotspotScreenPosition(hotspot.positionX, hotspot.positionY, layout, zoom);
188
- }
189
-
190
- function handlePointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
191
- if (isHotspotMode) return;
192
-
193
- event.currentTarget.setPointerCapture(event.pointerId);
194
-
195
- if (zoom > minZoom) {
196
- panStartRef.current = { pointerX: event.clientX, pointerY: event.clientY, panX: pan.panX, panY: pan.panY };
197
- } else {
198
- dragStartRef.current = { pointerX: event.clientX, frameIndex: currentFrameIndex };
199
- }
200
-
201
- setIsDragging(true);
202
- }
203
-
204
- function handlePointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
205
- if (!isDragging) return;
206
-
207
- if (panStartRef.current && zoom > minZoom) {
208
- setPan(computeViewerPanOffset(panStartRef.current, event.clientX, event.clientY));
209
- return;
210
- }
211
-
212
- if (dragStartRef.current && zoom <= minZoom) {
213
- const nextFrameIndex = computeDragFrameIndex(
214
- dragStartRef.current,
215
- event.clientX,
216
- frames.length,
217
- resolvedConfig.dragSensitivity
218
- );
219
-
220
- if (nextFrameIndex !== null) {
221
- onFrameChange(nextFrameIndex);
222
- }
223
- }
224
- }
225
-
226
- function handlePointerUp(event: ReactPointerEvent<HTMLDivElement>): void {
227
- if (event.currentTarget.hasPointerCapture(event.pointerId)) {
228
- event.currentTarget.releasePointerCapture(event.pointerId);
229
- }
230
-
231
- dragStartRef.current = null;
232
- panStartRef.current = null;
233
- setIsDragging(false);
234
- }
235
-
236
- function handleWheel(event: ReactWheelEvent<HTMLDivElement>): void {
237
- event.preventDefault();
238
- const { zoom: nextZoom, pan: nextPan } = applyWheelZoom(zoom, event.deltaY, pan, resolvedConfig);
239
- setZoom(nextZoom);
240
- setPan(nextPan);
241
- }
242
-
243
- function handleCanvasClick(event: React.MouseEvent<HTMLDivElement>): void {
244
- if (!isHotspotMode || isDragging || !onHotspotAdd) return;
245
-
246
- const frame = frames[currentFrameIndex];
247
- if (!frame) return;
248
-
249
- const layout = getCurrentImageLayout();
250
- if (!layout) return;
251
-
252
- const { positionX, positionY } = computeHotspotPositionFromClick(
253
- event.clientX,
254
- event.clientY,
255
- event.currentTarget.getBoundingClientRect(),
256
- layout,
257
- zoom
258
- );
259
-
260
- onHotspotAdd({
261
- frameIndex: currentFrameIndex,
262
- frameId: frame.id,
263
- positionX,
264
- positionY,
265
- });
266
- }
267
-
268
- function handleResetView(): void {
269
- setZoom(minZoom);
270
- setPan({ panX: 0, panY: 0 });
271
- }
272
-
273
- function handleZoomIn(): void {
274
- setZoom(stepZoomIn(zoom, resolvedConfig));
275
- }
276
-
277
- function handleZoomOut(): void {
278
- const { zoom: nextZoom, pan: nextPan } = stepZoomOut(zoom, pan, resolvedConfig);
279
- setZoom(nextZoom);
280
- setPan(nextPan);
281
- }
282
-
283
- return {
284
- canvasRef,
285
- containerRef,
286
- currentFrame,
287
- currentFrameHotspots,
288
- imagesLoaded,
289
- isHotspotMode,
290
- isResetDisabled: resetDisabled,
291
- maxZoom,
292
- minZoom,
293
- viewerCursorClass,
294
- zoom,
295
- getCurrentImageLayout,
296
- getHotspotScreenPosition,
297
- handleCanvasClick,
298
- handlePointerDown,
299
- handlePointerMove,
300
- handlePointerUp,
301
- handleResetView,
302
- handleWheel,
303
- handleZoomIn,
304
- handleZoomOut,
305
- };
306
- }