@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.
- package/LICENSE +21 -0
- package/package.json +3 -3
- package/src/components/ui/Badge/index.tsx +0 -45
- package/src/components/ui/Button/index.tsx +0 -67
- package/src/components/ui/Card/index.tsx +0 -74
- package/src/components/ui/Item/index.tsx +0 -136
- package/src/components/ui/Label/index.tsx +0 -20
- package/src/components/ui/Popover/index.tsx +0 -56
- package/src/components/ui/Separator/index.tsx +0 -30
- package/src/components/ui/Spinner/index.tsx +0 -11
- package/src/components/utils/index.ts +0 -6
- package/src/constants/viewer360ClassNames.ts +0 -42
- package/src/constants/viewer360Config.ts +0 -11
- package/src/constants/viewer360Labels.ts +0 -14
- package/src/constants/viewer360MarkerLabels.ts +0 -3
- package/src/feature/Viewer360.test.tsx +0 -47
- package/src/feature/Viewer360.tsx +0 -223
- package/src/feature/Viewer360AddModeBanner.tsx +0 -20
- package/src/feature/Viewer360FrameIndicator.tsx +0 -20
- package/src/feature/Viewer360HotspotOverlay.tsx +0 -57
- package/src/feature/Viewer360LoadingOverlay.tsx +0 -28
- package/src/feature/Viewer360MarkerPin.tsx +0 -105
- package/src/feature/Viewer360Toolbar.tsx +0 -75
- package/src/helpers/adjustViewerZoom.test.ts +0 -29
- package/src/helpers/adjustViewerZoom.ts +0 -64
- package/src/helpers/computeDragFrameIndex.test.ts +0 -20
- package/src/helpers/computeDragFrameIndex.ts +0 -23
- package/src/helpers/computeViewerImageLayout.test.ts +0 -48
- package/src/helpers/computeViewerImageLayout.ts +0 -114
- package/src/helpers/computeViewerPanOffset.ts +0 -18
- package/src/helpers/markerHelpers.test.ts +0 -38
- package/src/helpers/markerHelpers.ts +0 -33
- package/src/helpers/viewer360PropsHelpers.ts +0 -46
- package/src/helpers/viewerHelpers.ts +0 -74
- package/src/hooks/useViewer360.ts +0 -306
- package/src/index.ts +0 -68
- package/src/styles.css +0 -80
- package/src/types/Viewer360Hotspot.ts +0 -67
- package/src/types/Viewer360Marker.ts +0 -52
- package/src/types/Viewer360Props.ts +0 -108
- package/src/types/index.ts +0 -30
- 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
|
-
}
|