@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,223 +0,0 @@
1
- import type { JSX } from 'react';
2
- import { useEffect, useMemo, useState } from 'react';
3
-
4
- import { buildViewer360ThemeStyle, mergeViewer360ClassNames, mergeViewer360Labels } from '../helpers/viewer360PropsHelpers';
5
- import { useViewer360 } from '../hooks/useViewer360';
6
- import type { Viewer360OverlayRenderProps, Viewer360Props, Viewer360ToolbarRenderProps } from '../types';
7
- import { Card } from '@/components/ui/Card';
8
- import { cn } from '@/components/utils';
9
-
10
- import { Viewer360AddModeBanner } from './Viewer360AddModeBanner';
11
- import { Viewer360FrameIndicator } from './Viewer360FrameIndicator';
12
- import { Viewer360HotspotOverlay } from './Viewer360HotspotOverlay';
13
- import { Viewer360LoadingOverlay } from './Viewer360LoadingOverlay';
14
- import { Viewer360Toolbar } from './Viewer360Toolbar';
15
-
16
- export function Viewer360<TData = unknown>({
17
- frames,
18
- currentFrameIndex: controlledFrameIndex,
19
- defaultFrameIndex = 0,
20
- onFrameChange,
21
- config,
22
- className,
23
- classNames,
24
- style,
25
- theme,
26
- labels,
27
- aspectRatio = '16 / 10',
28
- showZoomControls = true,
29
- showResetControl = true,
30
- showFrameIndicator = true,
31
- showDragHint = true,
32
- showHotspotModeControl = false,
33
- hotspotPin,
34
- hotspots = [],
35
- renderHotspot,
36
- renderLoading,
37
- renderFrameIndicator,
38
- renderHotspotModeBanner,
39
- renderToolbar,
40
- onHotspotClick,
41
- hotspotMode: controlledHotspotMode,
42
- defaultHotspotMode = false,
43
- onHotspotModeChange,
44
- onHotspotAdd,
45
- children,
46
- }: Viewer360Props<TData>): JSX.Element {
47
- // ----------------------------------------------------------------------------------------------------
48
- // MARK: States & Constants
49
- // ----------------------------------------------------------------------------------------------------
50
- const mergedLabels = useMemo(() => mergeViewer360Labels(labels), [labels]);
51
- const mergedClassNames = useMemo(() => mergeViewer360ClassNames(classNames), [classNames]);
52
- const themeStyle = useMemo(() => buildViewer360ThemeStyle(theme), [theme]);
53
-
54
- const [internalFrameIndex, setInternalFrameIndex] = useState(defaultFrameIndex);
55
- const [internalHotspotMode, setInternalHotspotMode] = useState(defaultHotspotMode);
56
-
57
- const currentFrameIndex = controlledFrameIndex ?? internalFrameIndex;
58
- const hotspotMode = controlledHotspotMode ?? internalHotspotMode;
59
-
60
- // ----------------------------------------------------------------------------------------------------
61
- // MARK: Functions
62
- // ----------------------------------------------------------------------------------------------------
63
- function handleFrameChange(index: number): void {
64
- if (controlledFrameIndex === undefined) {
65
- setInternalFrameIndex(index);
66
- }
67
-
68
- onFrameChange?.(index);
69
- }
70
-
71
- function handleHotspotModeChange(active: boolean): void {
72
- if (controlledHotspotMode === undefined) {
73
- setInternalHotspotMode(active);
74
- }
75
-
76
- onHotspotModeChange?.(active);
77
- }
78
-
79
- const {
80
- canvasRef,
81
- containerRef,
82
- currentFrame,
83
- currentFrameHotspots,
84
- imagesLoaded,
85
- isHotspotMode,
86
- isResetDisabled,
87
- maxZoom,
88
- minZoom,
89
- viewerCursorClass,
90
- zoom,
91
- getHotspotScreenPosition,
92
- handleCanvasClick,
93
- handlePointerDown,
94
- handlePointerMove,
95
- handlePointerUp,
96
- handleWheel,
97
- handleResetView,
98
- handleZoomIn,
99
- handleZoomOut,
100
- } = useViewer360<TData>({
101
- frames,
102
- hotspots,
103
- currentFrameIndex,
104
- onFrameChange: handleFrameChange,
105
- config,
106
- hotspotMode,
107
- onHotspotAdd,
108
- });
109
-
110
- useEffect(() => {
111
- if (controlledHotspotMode === undefined) return;
112
- if (controlledHotspotMode !== internalHotspotMode) {
113
- setInternalHotspotMode(controlledHotspotMode);
114
- }
115
- }, [controlledHotspotMode, internalHotspotMode]);
116
-
117
- const frameLabel = currentFrame?.label ?? frames[currentFrameIndex]?.label;
118
- const overlayProps: Viewer360OverlayRenderProps = {
119
- currentFrameIndex,
120
- frameCount: frames.length,
121
- frameLabel,
122
- isHotspotMode,
123
- labels: mergedLabels,
124
- frameIndicatorClassName: mergedClassNames.frameIndicator,
125
- };
126
- const toolbarProps: Viewer360ToolbarRenderProps = {
127
- zoom,
128
- minZoom,
129
- maxZoom,
130
- isResetDisabled,
131
- isHotspotMode,
132
- showHotspotModeControl,
133
- showZoomControls,
134
- showResetControl,
135
- showDragHint,
136
- labels: mergedLabels,
137
- onZoomIn: handleZoomIn,
138
- onZoomOut: handleZoomOut,
139
- onResetView: handleResetView,
140
- onHotspotModeChange: handleHotspotModeChange,
141
- };
142
- const showDefaultToolbar = showZoomControls || showResetControl || showHotspotModeControl || showDragHint;
143
-
144
- // ----------------------------------------------------------------------------------------------------
145
- // MARK: Main Component UI
146
- // ----------------------------------------------------------------------------------------------------
147
- return (
148
- <Card
149
- data-viewer-360=""
150
- className={cn(mergedClassNames.root, 'gap-0 py-0 shadow-none ring-0', className)}
151
- style={{ ...themeStyle, ...style }}
152
- >
153
- <div
154
- ref={containerRef}
155
- className={cn(mergedClassNames.viewport, viewerCursorClass)}
156
- style={{ aspectRatio }}
157
- onPointerDown={handlePointerDown}
158
- onPointerMove={handlePointerMove}
159
- onPointerUp={handlePointerUp}
160
- onPointerLeave={handlePointerUp}
161
- onWheel={handleWheel}
162
- onClick={handleCanvasClick}
163
- >
164
- <canvas ref={canvasRef} className={mergedClassNames.canvas} />
165
-
166
- <div className={mergedClassNames.overlay}>
167
- {currentFrameHotspots.map((hotspot) => {
168
- const position = getHotspotScreenPosition(hotspot);
169
-
170
- return (
171
- <Viewer360HotspotOverlay
172
- key={hotspot.id}
173
- hotspot={hotspot}
174
- leftPercent={position.leftPercent}
175
- topPercent={position.topPercent}
176
- hotspotPin={hotspotPin}
177
- renderHotspot={renderHotspot}
178
- onHotspotClick={onHotspotClick}
179
- />
180
- );
181
- })}
182
- {children}
183
- </div>
184
-
185
- {!imagesLoaded &&
186
- (renderLoading ? (
187
- renderLoading()
188
- ) : (
189
- <Viewer360LoadingOverlay
190
- className={mergedClassNames.loading}
191
- textClassName={mergedClassNames.loadingText}
192
- label={mergedLabels.loading}
193
- />
194
- ))}
195
-
196
- {showFrameIndicator &&
197
- frames.length > 0 &&
198
- (renderFrameIndicator ? (
199
- renderFrameIndicator(overlayProps)
200
- ) : (
201
- <Viewer360FrameIndicator
202
- className={mergedClassNames.frameIndicator}
203
- label={mergedLabels.frameIndicator({
204
- current: currentFrameIndex + 1,
205
- total: frames.length,
206
- label: frameLabel,
207
- })}
208
- />
209
- ))}
210
-
211
- {isHotspotMode &&
212
- onHotspotAdd &&
213
- (renderHotspotModeBanner ? (
214
- renderHotspotModeBanner({ labels: mergedLabels })
215
- ) : (
216
- <Viewer360AddModeBanner className={mergedClassNames.hotspotModeBanner} label={mergedLabels.hotspotModeActive} />
217
- ))}
218
- </div>
219
-
220
- {renderToolbar ? renderToolbar(toolbarProps) : showDefaultToolbar ? <Viewer360Toolbar {...toolbarProps} /> : null}
221
- </Card>
222
- );
223
- }
@@ -1,20 +0,0 @@
1
- import type { JSX } from 'react';
2
-
3
- import { Badge } from '@/components/ui/Badge';
4
- import { cn } from '@/components/utils';
5
-
6
- type Viewer360AddModeBannerProps = {
7
- className?: string;
8
- label: string;
9
- };
10
-
11
- export function Viewer360AddModeBanner({ className, label }: Viewer360AddModeBannerProps): JSX.Element {
12
- // ----------------------------------------------------------------------------------------------------
13
- // MARK: Main Component UI
14
- // ----------------------------------------------------------------------------------------------------
15
- return (
16
- <Badge variant="outline" className={cn('pointer-events-none', className)}>
17
- {label}
18
- </Badge>
19
- );
20
- }
@@ -1,20 +0,0 @@
1
- import type { JSX } from 'react';
2
-
3
- import { Badge } from '@/components/ui/Badge';
4
- import { cn } from '@/components/utils';
5
-
6
- type Viewer360FrameIndicatorProps = {
7
- className?: string;
8
- label: string;
9
- };
10
-
11
- export function Viewer360FrameIndicator({ className, label }: Viewer360FrameIndicatorProps): JSX.Element {
12
- // ----------------------------------------------------------------------------------------------------
13
- // MARK: Main Component UI
14
- // ----------------------------------------------------------------------------------------------------
15
- return (
16
- <Badge variant="outline" className={cn('pointer-events-none shadow-sm', className)}>
17
- {label}
18
- </Badge>
19
- );
20
- }
@@ -1,57 +0,0 @@
1
- import type { JSX, MouseEvent, ReactNode } from 'react';
2
-
3
- import { hotspotToViewer360Marker } from '../helpers/markerHelpers';
4
- import type { Viewer360Hotspot, Viewer360HotspotPinOptions, Viewer360HotspotRenderProps } from '../types';
5
- import { Item } from '@/components/ui/Item';
6
-
7
- import { Viewer360MarkerPin } from './Viewer360MarkerPin';
8
-
9
- type Viewer360HotspotOverlayProps<TData = unknown> = {
10
- hotspot: Viewer360Hotspot<TData>;
11
- leftPercent: number;
12
- topPercent: number;
13
- hotspotPin?: Viewer360HotspotPinOptions<TData>;
14
- renderHotspot?: (props: Viewer360HotspotRenderProps<TData>) => ReactNode;
15
- onHotspotClick?: (hotspot: Viewer360Hotspot<TData>, event: MouseEvent<HTMLDivElement>) => void;
16
- };
17
-
18
- export function Viewer360HotspotOverlay<TData = unknown>({
19
- hotspot,
20
- leftPercent,
21
- topPercent,
22
- hotspotPin,
23
- renderHotspot,
24
- onHotspotClick,
25
- }: Viewer360HotspotOverlayProps<TData>): JSX.Element {
26
- // ----------------------------------------------------------------------------------------------------
27
- // MARK: Main Component UI
28
- // ----------------------------------------------------------------------------------------------------
29
- if (renderHotspot) {
30
- return (
31
- <Item size="xs" variant="default" className="pointer-events-auto w-auto border-transparent p-0">
32
- {renderHotspot({ hotspot, leftPercent, topPercent })}
33
- </Item>
34
- );
35
- }
36
-
37
- const marker = hotspotPin?.getMarker?.(hotspot) ?? hotspotToViewer360Marker(hotspot);
38
-
39
- return (
40
- <Viewer360MarkerPin
41
- marker={marker}
42
- hotspot={hotspot}
43
- leftPercent={leftPercent}
44
- topPercent={topPercent}
45
- onDelete={hotspotPin?.onDelete}
46
- isDeletePending={hotspotPin?.deletingMarkerId === hotspot.id}
47
- renderTag={hotspotPin?.renderTag}
48
- classNames={hotspotPin?.classNames}
49
- labels={hotspotPin?.labels}
50
- onClick={
51
- onHotspotClick
52
- ? (event) => onHotspotClick(hotspot, event as unknown as MouseEvent<HTMLDivElement>)
53
- : undefined
54
- }
55
- />
56
- );
57
- }
@@ -1,28 +0,0 @@
1
- import type { JSX } from 'react';
2
-
3
- import { Item } from '@/components/ui/Item';
4
- import { Label } from '@/components/ui/Label';
5
- import { Spinner } from '@/components/ui/Spinner';
6
- import { cn } from '@/components/utils';
7
-
8
- type Viewer360LoadingOverlayProps = {
9
- className?: string;
10
- textClassName?: string;
11
- label: string;
12
- };
13
-
14
- export function Viewer360LoadingOverlay({ className, textClassName, label }: Viewer360LoadingOverlayProps): JSX.Element {
15
- // ----------------------------------------------------------------------------------------------------
16
- // MARK: Main Component UI
17
- // ----------------------------------------------------------------------------------------------------
18
- return (
19
- <Item
20
- size="sm"
21
- variant="muted"
22
- className={cn('pointer-events-none w-auto justify-center border-transparent bg-muted/80', className)}
23
- >
24
- <Spinner className="size-5 text-muted-foreground" />
25
- <Label className={cn('font-normal text-muted-foreground', textClassName)}>{label}</Label>
26
- </Item>
27
- );
28
- }
@@ -1,105 +0,0 @@
1
- import type { JSX, MouseEvent } from 'react';
2
-
3
- import { Trash2 } from 'lucide-react';
4
-
5
- import { defaultViewer360MarkerPinLabels } from '../constants/viewer360MarkerLabels';
6
- import { viewer360MarkerPinClassNames } from '../constants/viewer360ClassNames';
7
- import type { Viewer360MarkerPinProps } from '../types';
8
- import { Button } from '@/components/ui/Button';
9
- import {
10
- Item,
11
- ItemActions,
12
- ItemContent,
13
- ItemDescription,
14
- ItemTitle,
15
- } from '@/components/ui/Item';
16
- import { cn } from '@/components/utils';
17
-
18
- export function Viewer360MarkerPin<TData = unknown>({
19
- marker,
20
- hotspot,
21
- leftPercent,
22
- topPercent,
23
- onDelete,
24
- isDeletePending = false,
25
- onClick,
26
- renderTag,
27
- classNames,
28
- labels,
29
- }: Viewer360MarkerPinProps<TData>): JSX.Element {
30
- // ----------------------------------------------------------------------------------------------------
31
- // MARK: States & Constants
32
- // ----------------------------------------------------------------------------------------------------
33
- const deleteLabel = labels?.delete ?? defaultViewer360MarkerPinLabels.delete;
34
- const showTooltip = Boolean(marker.title || marker.description || onDelete || renderTag);
35
-
36
- // ----------------------------------------------------------------------------------------------------
37
- // MARK: Main Component UI
38
- // ----------------------------------------------------------------------------------------------------
39
- return (
40
- <div
41
- className={cn(viewer360MarkerPinClassNames.root, classNames?.root, 'group/marker')}
42
- style={{ left: `${leftPercent}%`, top: `${topPercent}%` }}
43
- >
44
- <div className="relative size-4 shrink-0">
45
- <span className={cn(viewer360MarkerPinClassNames.ping, classNames?.ping)} aria-hidden="true" />
46
-
47
- <Button
48
- type="button"
49
- variant="destructive"
50
- size="icon-xs"
51
- className={cn(viewer360MarkerPinClassNames.dot, classNames?.dot, 'hover:bg-destructive')}
52
- aria-label={marker.title}
53
- onClick={onClick}
54
- />
55
- </div>
56
-
57
- {showTooltip && (
58
- <div
59
- className={cn(
60
- viewer360MarkerPinClassNames.tooltip,
61
- classNames?.tooltip,
62
- 'pointer-events-none opacity-0 transition-opacity duration-150 group-hover/marker:pointer-events-auto group-hover/marker:opacity-100 group-focus-within/marker:pointer-events-auto group-focus-within/marker:opacity-100'
63
- )}
64
- >
65
- <Item
66
- size="sm"
67
- variant="default"
68
- className={cn(viewer360MarkerPinClassNames.tooltipHeader, classNames?.tooltipHeader, 'w-full border-transparent')}
69
- >
70
- <ItemContent className={cn(viewer360MarkerPinClassNames.tooltipBody, classNames?.tooltipBody)}>
71
- <ItemTitle className={cn(viewer360MarkerPinClassNames.tooltipTitle, classNames?.tooltipTitle)}>
72
- {marker.title}
73
- </ItemTitle>
74
- {renderTag && (
75
- <div className="mt-1 flex w-fit items-center">{renderTag({ marker, hotspot })}</div>
76
- )}
77
- {marker.description && (
78
- <ItemDescription className={cn(viewer360MarkerPinClassNames.tooltipDescription, classNames?.tooltipDescription)}>
79
- {marker.description}
80
- </ItemDescription>
81
- )}
82
- </ItemContent>
83
- {onDelete && (
84
- <ItemActions>
85
- <Button
86
- variant="ghost"
87
- size="icon-sm"
88
- className={classNames?.deleteButton}
89
- disabled={isDeletePending}
90
- aria-label={deleteLabel}
91
- onClick={(event: MouseEvent<HTMLButtonElement>) => {
92
- event.stopPropagation();
93
- onDelete(marker.id);
94
- }}
95
- >
96
- <Trash2 className="size-4" />
97
- </Button>
98
- </ItemActions>
99
- )}
100
- </Item>
101
- </div>
102
- )}
103
- </div>
104
- );
105
- }
@@ -1,75 +0,0 @@
1
- import type { JSX } from 'react';
2
-
3
- import { Crosshair, Minus, Plus, RotateCcw, ZoomIn } from 'lucide-react';
4
-
5
- import { viewer360ClassNames } from '../constants/viewer360ClassNames';
6
- import type { Viewer360ToolbarRenderProps } from '../types';
7
- import { Badge } from '@/components/ui/Badge';
8
- import { Button } from '@/components/ui/Button';
9
- import { CardFooter } from '@/components/ui/Card';
10
- import { Label } from '@/components/ui/Label';
11
- import { Separator } from '@/components/ui/Separator';
12
- import { cn } from '@/components/utils';
13
-
14
- type Viewer360ToolbarProps = Viewer360ToolbarRenderProps;
15
-
16
- export function Viewer360Toolbar({
17
- showDragHint,
18
- showHotspotModeControl,
19
- showZoomControls,
20
- showResetControl,
21
- labels,
22
- isHotspotMode,
23
- zoom,
24
- minZoom,
25
- maxZoom,
26
- isResetDisabled,
27
- onHotspotModeChange,
28
- onZoomIn,
29
- onZoomOut,
30
- onResetView,
31
- }: Viewer360ToolbarProps): JSX.Element {
32
- // ----------------------------------------------------------------------------------------------------
33
- // MARK: Main Component UI
34
- // ----------------------------------------------------------------------------------------------------
35
- return (
36
- <CardFooter className={cn(viewer360ClassNames.toolbar, 'gap-2 border-t px-4 py-3 pt-3')}>
37
- {showDragHint && (
38
- <Label className={cn(viewer360ClassNames.dragHint, 'font-normal text-muted-foreground')}>{labels.dragHint}</Label>
39
- )}
40
-
41
- <div className={viewer360ClassNames.controls}>
42
- {showHotspotModeControl && (
43
- <>
44
- <Button variant={isHotspotMode ? 'default' : 'outline'} size="sm" onClick={() => onHotspotModeChange(!isHotspotMode)}>
45
- <Crosshair className="me-1.5 size-4" />
46
- {labels.addHotspot}
47
- </Button>
48
- <Separator orientation="vertical" className={cn(viewer360ClassNames.divider, 'h-6')} />
49
- </>
50
- )}
51
-
52
- {showZoomControls && (
53
- <>
54
- <Button variant="outline" size="icon-sm" disabled={zoom <= minZoom} aria-label={labels.zoomOut} onClick={onZoomOut}>
55
- <Minus className="size-4" />
56
- </Button>
57
- <Badge variant="outline" className={cn(viewer360ClassNames.zoomDisplay, 'h-8 gap-1 px-2 py-1')}>
58
- <ZoomIn className="size-3 text-muted-foreground" />
59
- {labels.zoom(Math.round(zoom * 100))}
60
- </Badge>
61
- <Button variant="outline" size="icon-sm" disabled={zoom >= maxZoom} aria-label={labels.zoomIn} onClick={onZoomIn}>
62
- <Plus className="size-4" />
63
- </Button>
64
- </>
65
- )}
66
-
67
- {showResetControl && (
68
- <Button variant="outline" size="icon-sm" disabled={isResetDisabled} aria-label={labels.resetView} onClick={onResetView}>
69
- <RotateCcw className="size-4" />
70
- </Button>
71
- )}
72
- </div>
73
- </CardFooter>
74
- );
75
- }
@@ -1,29 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { applyWheelZoom, isResetDisabled, resolveViewer360Config, stepZoomIn, stepZoomOut } from './adjustViewerZoom';
4
-
5
- describe('adjustViewerZoom', () => {
6
- const config = resolveViewer360Config();
7
-
8
- it('clamps wheel zoom within bounds', () => {
9
- const result = applyWheelZoom(1, -100, { panX: 0, panY: 0 }, config);
10
- expect(result.zoom).toBeGreaterThan(1);
11
- expect(result.zoom).toBeLessThanOrEqual(config.maxZoom);
12
- });
13
-
14
- it('resets pan when zoom returns to minimum', () => {
15
- const result = applyWheelZoom(1.15, 100, { panX: 12, panY: 8 }, config);
16
- expect(result.zoom).toBe(config.minZoom);
17
- expect(result.pan).toEqual({ panX: 0, panY: 0 });
18
- });
19
-
20
- it('steps zoom in and out', () => {
21
- expect(stepZoomIn(1, config)).toBeCloseTo(1.15);
22
- expect(stepZoomOut(1.15, { panX: 4, panY: 2 }, config).zoom).toBeCloseTo(1);
23
- });
24
-
25
- it('detects reset disabled state', () => {
26
- expect(isResetDisabled(1, { panX: 0, panY: 0 }, config)).toBe(true);
27
- expect(isResetDisabled(1.3, { panX: 0, panY: 0 }, config)).toBe(false);
28
- });
29
- });
@@ -1,64 +0,0 @@
1
- import type { Viewer360Config } from '../types';
2
-
3
- import type { PanOffset } from './computeViewerImageLayout';
4
-
5
- type ResolvedViewer360Config = Required<Viewer360Config>;
6
-
7
- export function resolveViewer360Config(config?: Viewer360Config): ResolvedViewer360Config {
8
- return {
9
- minZoom: config?.minZoom ?? 1,
10
- maxZoom: config?.maxZoom ?? 3,
11
- zoomStep: config?.zoomStep ?? 0.15,
12
- dragSensitivity: config?.dragSensitivity ?? 8,
13
- autoRotate: config?.autoRotate ?? false,
14
- autoRotateIntervalMs: config?.autoRotateIntervalMs ?? 100,
15
- autoRotateDirection: config?.autoRotateDirection ?? 'forward',
16
- };
17
- }
18
-
19
- export function clampZoom(zoom: number, config: ResolvedViewer360Config): number {
20
- return Math.min(config.maxZoom, Math.max(config.minZoom, zoom));
21
- }
22
-
23
- export function applyWheelZoom(
24
- currentZoom: number,
25
- deltaY: number,
26
- currentPan: PanOffset,
27
- config: ResolvedViewer360Config
28
- ): { zoom: number; pan: PanOffset } {
29
- const delta = deltaY > 0 ? -config.zoomStep : config.zoomStep;
30
- const zoom = clampZoom(currentZoom + delta, config);
31
-
32
- return {
33
- zoom,
34
- pan: zoom === config.minZoom ? { panX: 0, panY: 0 } : currentPan,
35
- };
36
- }
37
-
38
- export function stepZoomIn(currentZoom: number, config: ResolvedViewer360Config): number {
39
- return clampZoom(currentZoom + config.zoomStep, config);
40
- }
41
-
42
- export function stepZoomOut(
43
- currentZoom: number,
44
- currentPan: PanOffset,
45
- config: ResolvedViewer360Config
46
- ): { zoom: number; pan: PanOffset } {
47
- const zoom = clampZoom(currentZoom - config.zoomStep, config);
48
-
49
- return {
50
- zoom,
51
- pan: zoom === config.minZoom ? { panX: 0, panY: 0 } : currentPan,
52
- };
53
- }
54
-
55
- export function isResetDisabled(zoom: number, pan: PanOffset, config: ResolvedViewer360Config): boolean {
56
- return zoom === config.minZoom && pan.panX === 0 && pan.panY === 0;
57
- }
58
-
59
- export function getViewerCursorClass(isHotspotMode: boolean, zoom: number, isDragging: boolean, minZoom: number): string {
60
- if (isHotspotMode) return 'cursor-crosshair';
61
- if (isDragging) return 'cursor-grabbing';
62
- if (zoom > minZoom) return 'cursor-grab';
63
- return 'cursor-ew-resize';
64
- }
@@ -1,20 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { clampFrameIndex, computeDragFrameIndex } from './computeDragFrameIndex';
4
-
5
- describe('computeDragFrameIndex', () => {
6
- it('wraps frame indices', () => {
7
- expect(clampFrameIndex(-1, 24)).toBe(23);
8
- expect(clampFrameIndex(24, 24)).toBe(0);
9
- });
10
-
11
- it('returns null when drag delta is below sensitivity', () => {
12
- const result = computeDragFrameIndex({ pointerX: 100, frameIndex: 3 }, 104, 24, 8);
13
- expect(result).toBeNull();
14
- });
15
-
16
- it('advances frames based on horizontal drag', () => {
17
- const result = computeDragFrameIndex({ pointerX: 200, frameIndex: 5 }, 120, 24, 8);
18
- expect(result).toBe(15);
19
- });
20
- });
@@ -1,23 +0,0 @@
1
- export function clampFrameIndex(index: number, frameCount: number): number {
2
- if (frameCount === 0) return 0;
3
- return ((index % frameCount) + frameCount) % frameCount;
4
- }
5
-
6
- type DragStart = {
7
- pointerX: number;
8
- frameIndex: number;
9
- };
10
-
11
- export function computeDragFrameIndex(
12
- dragStart: DragStart,
13
- clientX: number,
14
- frameCount: number,
15
- dragSensitivity: number
16
- ): number | null {
17
- const deltaX = clientX - dragStart.pointerX;
18
- const frameDelta = Math.round(-deltaX / dragSensitivity);
19
-
20
- if (frameDelta === 0) return null;
21
-
22
- return clampFrameIndex(dragStart.frameIndex + frameDelta, frameCount);
23
- }