@xcelsior/ui-chat 2.0.3 → 2.0.5
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/.storybook/preview.tsx +2 -1
- package/dist/index.d.mts +58 -5
- package/dist/index.d.ts +58 -5
- package/dist/index.js +1073 -473
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1060 -463
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -2
- package/src/components/BookingCancelledCard.tsx +103 -0
- package/src/components/BookingCards.stories.tsx +102 -0
- package/src/components/BookingConfirmationCard.tsx +170 -0
- package/src/components/BookingSlotPicker.stories.tsx +87 -0
- package/src/components/BookingSlotPicker.tsx +253 -0
- package/src/components/BrandIcons.stories.tsx +32 -1
- package/src/components/BrandIcons.tsx +21 -17
- package/src/components/Chat.tsx +78 -83
- package/src/components/ChatWidget.tsx +30 -3
- package/src/components/MessageItem.tsx +83 -72
- package/src/components/MessageList.tsx +4 -0
- package/src/hooks/useDraggablePosition.ts +147 -42
- package/src/hooks/useMessages.ts +119 -45
- package/src/hooks/useWebSocket.ts +17 -4
- package/src/index.tsx +11 -0
- package/src/types.ts +39 -2
- package/src/utils/api.ts +1 -0
- package/storybook-static/assets/BookingCancelledCard-XHuB-Ebp.js +31 -0
- package/storybook-static/assets/BookingCards.stories-DfJ482RS.js +66 -0
- package/storybook-static/assets/BookingSlotPicker-BkfssueW.js +1 -0
- package/storybook-static/assets/BookingSlotPicker.stories-fYlg1zLg.js +50 -0
- package/storybook-static/assets/BrandIcons-BsRAdWzL.js +4 -0
- package/storybook-static/assets/BrandIcons.stories-C6gBovfU.js +106 -0
- package/storybook-static/assets/Chat.stories-BrR7LHsz.js +830 -0
- package/storybook-static/assets/{Color-YHDXOIA2-BMnd3YrF.js → Color-YHDXOIA2-azE51u2m.js} +1 -1
- package/storybook-static/assets/{DocsRenderer-CFRXHY34-i_W8iCu9.js → DocsRenderer-CFRXHY34-jTmzKIDk.js} +3 -3
- package/storybook-static/assets/MessageItem-pEOwuLyh.js +34 -0
- package/storybook-static/assets/MessageItem.stories-Cs5Vtkle.js +422 -0
- package/storybook-static/assets/{entry-preview-oDnntGcx.js → entry-preview-vcpiajAT.js} +1 -1
- package/storybook-static/assets/globe-BtMvkLMD.js +31 -0
- package/storybook-static/assets/{iframe-CGBtu2Se.js → iframe-Cx1n-SeE.js} +2 -2
- package/storybook-static/assets/preview-B8y-wc-n.css +1 -0
- package/storybook-static/assets/preview-CC4t7T7W.js +1 -0
- package/storybook-static/assets/{preview-BRpahs9B.js → preview-Do3b3dZv.js} +2 -2
- package/storybook-static/iframe.html +1 -1
- package/storybook-static/index.json +1 -1
- package/storybook-static/project.json +1 -1
- package/tsconfig.json +4 -0
- package/storybook-static/assets/BrandIcons-Cjy5INAp.js +0 -4
- package/storybook-static/assets/BrandIcons.stories-BeVC6svr.js +0 -64
- package/storybook-static/assets/Chat.stories-J_Yp51wU.js +0 -803
- package/storybook-static/assets/MessageItem-DAaKZ9s9.js +0 -14
- package/storybook-static/assets/MessageItem.stories-Ckr1_scc.js +0 -255
- package/storybook-static/assets/ToastContext-Bty1K7ya.js +0 -1
- package/storybook-static/assets/preview-DUOvJmsz.js +0 -1
- package/storybook-static/assets/preview-DcGwT3kv.css +0 -1
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { formatDistanceToNow } from 'date-fns';
|
|
2
|
-
import
|
|
2
|
+
import { Check, CheckCheck, Headphones, Paperclip } from 'lucide-react';
|
|
3
|
+
import type { IMessage, IUser, IChatTheme, IBookingData, IBookingConfirmationData, IBookingCancelledData } from '../types';
|
|
3
4
|
import { XcelsiorAvatar } from './BrandIcons';
|
|
4
5
|
import { MarkdownMessage } from './MarkdownMessage';
|
|
6
|
+
import { BookingSlotPicker } from './BookingSlotPicker';
|
|
7
|
+
import { BookingConfirmationCard } from './BookingConfirmationCard';
|
|
8
|
+
import { BookingCancelledCard } from './BookingCancelledCard';
|
|
5
9
|
|
|
6
10
|
interface MessageItemProps {
|
|
7
11
|
message: IMessage;
|
|
@@ -9,6 +13,8 @@ interface MessageItemProps {
|
|
|
9
13
|
showAvatar?: boolean;
|
|
10
14
|
showTimestamp?: boolean;
|
|
11
15
|
theme?: IChatTheme;
|
|
16
|
+
/** Called when visitor selects a booking slot — sends their choice as a chat message */
|
|
17
|
+
onBookingSlotSelected?: (date: string, time: string, messageId: string) => void;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
export function MessageItem({
|
|
@@ -17,6 +23,7 @@ export function MessageItem({
|
|
|
17
23
|
showAvatar = true,
|
|
18
24
|
showTimestamp = true,
|
|
19
25
|
theme,
|
|
26
|
+
onBookingSlotSelected,
|
|
20
27
|
}: MessageItemProps) {
|
|
21
28
|
const isOwnMessage = message.senderType === currentUser.type;
|
|
22
29
|
const isSystemMessage = message.senderType === 'system';
|
|
@@ -38,6 +45,73 @@ export function MessageItem({
|
|
|
38
45
|
const textColor = theme?.text || (isLightTheme ? '#1a1a2e' : '#f7f7f8');
|
|
39
46
|
const textMuted = theme?.textMuted || (isLightTheme ? 'rgba(0,0,0,0.35)' : 'rgba(247,247,248,0.35)');
|
|
40
47
|
|
|
48
|
+
// Booking messages — render card components directly (no bubble wrapper)
|
|
49
|
+
if (
|
|
50
|
+
message.messageType === 'booking_slots' ||
|
|
51
|
+
message.messageType === 'booking_confirmation' ||
|
|
52
|
+
message.messageType === 'booking_cancelled'
|
|
53
|
+
) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex gap-2.5 mb-3 flex-row">
|
|
56
|
+
{/* Avatar placeholder */}
|
|
57
|
+
{showAvatar && (
|
|
58
|
+
<div className="flex-shrink-0 mt-auto mb-5">
|
|
59
|
+
<XcelsiorAvatar size={28} />
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
<div className="flex flex-col max-w-[85%] items-start">
|
|
63
|
+
{/* Sender label */}
|
|
64
|
+
<span
|
|
65
|
+
className="mb-1 px-1 font-medium"
|
|
66
|
+
style={{
|
|
67
|
+
color: isLightTheme ? 'rgba(0,0,0,0.45)' : 'rgba(247,247,248,0.4)',
|
|
68
|
+
fontSize: '11px',
|
|
69
|
+
letterSpacing: '0.019em',
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
AI Assistant
|
|
73
|
+
</span>
|
|
74
|
+
{message.messageType === 'booking_slots' && (
|
|
75
|
+
<BookingSlotPicker
|
|
76
|
+
data={message.metadata?.bookingData as IBookingData}
|
|
77
|
+
theme={theme}
|
|
78
|
+
onSlotSelected={(date, time) => {
|
|
79
|
+
onBookingSlotSelected?.(date, time, message.id);
|
|
80
|
+
}}
|
|
81
|
+
disabled={!!message.metadata?.slotSelected}
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
{message.messageType === 'booking_confirmation' && (
|
|
85
|
+
<BookingConfirmationCard
|
|
86
|
+
data={message.metadata?.bookingConfirmation as IBookingConfirmationData}
|
|
87
|
+
theme={theme}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
{message.messageType === 'booking_cancelled' && (
|
|
91
|
+
<BookingCancelledCard
|
|
92
|
+
data={message.metadata?.bookingCancelled as IBookingCancelledData}
|
|
93
|
+
theme={theme}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
96
|
+
{/* Timestamp */}
|
|
97
|
+
{showTimestamp && (
|
|
98
|
+
<div className="flex items-center gap-1.5 mt-1 px-1">
|
|
99
|
+
<span
|
|
100
|
+
style={{
|
|
101
|
+
fontSize: '11px',
|
|
102
|
+
letterSpacing: '0.019em',
|
|
103
|
+
color: textMuted,
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{formatDistanceToNow(new Date(message.createdAt), { addSuffix: true })}
|
|
107
|
+
</span>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
41
115
|
// System messages — centered pill with ultra-subtle surface
|
|
42
116
|
if (isSystemMessage) {
|
|
43
117
|
return (
|
|
@@ -121,21 +195,12 @@ export function MessageItem({
|
|
|
121
195
|
: 'inset 0 0 0 0.5px rgba(255,255,255,0.1)',
|
|
122
196
|
}}
|
|
123
197
|
>
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
height="14"
|
|
127
|
-
viewBox="0 0 24 24"
|
|
128
|
-
fill="none"
|
|
198
|
+
<Headphones
|
|
199
|
+
size={14}
|
|
129
200
|
stroke={isLightTheme ? primaryColor : 'white'}
|
|
130
|
-
strokeWidth=
|
|
131
|
-
strokeLinecap="round"
|
|
132
|
-
strokeLinejoin="round"
|
|
201
|
+
strokeWidth={2}
|
|
133
202
|
aria-hidden="true"
|
|
134
|
-
|
|
135
|
-
<title>Agent</title>
|
|
136
|
-
<path d="M3 18v-6a9 9 0 0 1 18 0v6" />
|
|
137
|
-
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z" />
|
|
138
|
-
</svg>
|
|
203
|
+
/>
|
|
139
204
|
</div>
|
|
140
205
|
)}
|
|
141
206
|
</div>
|
|
@@ -194,20 +259,7 @@ export function MessageItem({
|
|
|
194
259
|
)}
|
|
195
260
|
{message.messageType === 'file' && (
|
|
196
261
|
<div className="flex items-center gap-2">
|
|
197
|
-
<
|
|
198
|
-
width="18"
|
|
199
|
-
height="18"
|
|
200
|
-
viewBox="0 0 24 24"
|
|
201
|
-
fill="none"
|
|
202
|
-
stroke="currentColor"
|
|
203
|
-
strokeWidth="1.75"
|
|
204
|
-
strokeLinecap="round"
|
|
205
|
-
strokeLinejoin="round"
|
|
206
|
-
aria-hidden="true"
|
|
207
|
-
>
|
|
208
|
-
<title>File</title>
|
|
209
|
-
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
|
210
|
-
</svg>
|
|
262
|
+
<Paperclip size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
211
263
|
<a
|
|
212
264
|
href={message.content}
|
|
213
265
|
target="_blank"
|
|
@@ -245,54 +297,13 @@ export function MessageItem({
|
|
|
245
297
|
{isOwnMessage && message.status && (
|
|
246
298
|
<span style={{ color: textMuted }}>
|
|
247
299
|
{message.status === 'sent' && (
|
|
248
|
-
<
|
|
249
|
-
width="14"
|
|
250
|
-
height="14"
|
|
251
|
-
viewBox="0 0 24 24"
|
|
252
|
-
fill="none"
|
|
253
|
-
stroke="currentColor"
|
|
254
|
-
strokeWidth="2.5"
|
|
255
|
-
strokeLinecap="round"
|
|
256
|
-
strokeLinejoin="round"
|
|
257
|
-
aria-hidden="true"
|
|
258
|
-
>
|
|
259
|
-
<title>Sent</title>
|
|
260
|
-
<polyline points="20 6 9 17 4 12" />
|
|
261
|
-
</svg>
|
|
300
|
+
<Check size={14} strokeWidth={2.5} aria-hidden="true" />
|
|
262
301
|
)}
|
|
263
302
|
{message.status === 'delivered' && (
|
|
264
|
-
<
|
|
265
|
-
width="14"
|
|
266
|
-
height="14"
|
|
267
|
-
viewBox="0 0 24 24"
|
|
268
|
-
fill="none"
|
|
269
|
-
stroke="currentColor"
|
|
270
|
-
strokeWidth="2.5"
|
|
271
|
-
strokeLinecap="round"
|
|
272
|
-
strokeLinejoin="round"
|
|
273
|
-
aria-hidden="true"
|
|
274
|
-
>
|
|
275
|
-
<title>Delivered</title>
|
|
276
|
-
<polyline points="18 6 7 17 2 12" />
|
|
277
|
-
<polyline points="22 6 11 17" />
|
|
278
|
-
</svg>
|
|
303
|
+
<CheckCheck size={14} strokeWidth={2.5} aria-hidden="true" />
|
|
279
304
|
)}
|
|
280
305
|
{message.status === 'read' && (
|
|
281
|
-
<
|
|
282
|
-
width="14"
|
|
283
|
-
height="14"
|
|
284
|
-
viewBox="0 0 24 24"
|
|
285
|
-
fill="none"
|
|
286
|
-
stroke={primaryColor}
|
|
287
|
-
strokeWidth="2.5"
|
|
288
|
-
strokeLinecap="round"
|
|
289
|
-
strokeLinejoin="round"
|
|
290
|
-
aria-hidden="true"
|
|
291
|
-
>
|
|
292
|
-
<title>Read</title>
|
|
293
|
-
<polyline points="18 6 7 17 2 12" />
|
|
294
|
-
<polyline points="22 6 11 17" />
|
|
295
|
-
</svg>
|
|
306
|
+
<CheckCheck size={14} strokeWidth={2.5} stroke={primaryColor} aria-hidden="true" />
|
|
296
307
|
)}
|
|
297
308
|
</span>
|
|
298
309
|
)}
|
|
@@ -23,6 +23,8 @@ interface MessageListProps {
|
|
|
23
23
|
onQuickAction?: (text: string) => void;
|
|
24
24
|
/** True when user sent a message and bot response hasn't arrived yet */
|
|
25
25
|
isBotThinking?: boolean;
|
|
26
|
+
/** Called when visitor selects a booking slot */
|
|
27
|
+
onBookingSlotSelected?: (date: string, time: string, messageId: string) => void;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export function MessageList({
|
|
@@ -38,6 +40,7 @@ export function MessageList({
|
|
|
38
40
|
theme,
|
|
39
41
|
onQuickAction,
|
|
40
42
|
isBotThinking = false,
|
|
43
|
+
onBookingSlotSelected,
|
|
41
44
|
}: MessageListProps) {
|
|
42
45
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
43
46
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -250,6 +253,7 @@ export function MessageList({
|
|
|
250
253
|
showAvatar={true}
|
|
251
254
|
showTimestamp={true}
|
|
252
255
|
theme={theme}
|
|
256
|
+
onBookingSlotSelected={onBookingSlotSelected}
|
|
253
257
|
/>
|
|
254
258
|
))}
|
|
255
259
|
|
|
@@ -2,35 +2,50 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
|
|
2
2
|
import type { WidgetPosition } from '../types';
|
|
3
3
|
|
|
4
4
|
const STORAGE_KEY = 'xcelsior-chat-position';
|
|
5
|
+
const DRAG_THRESHOLD = 5;
|
|
6
|
+
const FAB_SIZE = 64;
|
|
7
|
+
const MARGIN = 16;
|
|
8
|
+
const BOTTOM = 20;
|
|
9
|
+
const VELOCITY_THRESHOLD = 0.4;
|
|
10
|
+
const SETTLE_DURATION = 400;
|
|
11
|
+
const SETTLE_EASING = 'cubic-bezier(0.2, 0.9, 0.3, 1.1)';
|
|
5
12
|
|
|
6
13
|
function getStoredPosition(): 'left' | 'right' {
|
|
7
14
|
try {
|
|
8
15
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
9
16
|
if (stored === 'left' || stored === 'right') return stored;
|
|
10
|
-
} catch {
|
|
11
|
-
// localStorage not available
|
|
12
|
-
}
|
|
17
|
+
} catch { /* */ }
|
|
13
18
|
return 'right';
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
function storePosition(
|
|
17
|
-
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
function storePosition(pos: 'left' | 'right') {
|
|
22
|
+
try { localStorage.setItem(STORAGE_KEY, pos); } catch { /* */ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function restingX(side: 'left' | 'right'): number {
|
|
26
|
+
return side === 'left' ? MARGIN : window.innerWidth - MARGIN - FAB_SIZE;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function restingY(): number {
|
|
30
|
+
return window.innerHeight - BOTTOM - FAB_SIZE;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
export function useDraggablePosition(configPosition: WidgetPosition = 'auto') {
|
|
25
34
|
const resolvedDefault = configPosition === 'auto' ? getStoredPosition() : configPosition;
|
|
26
35
|
const [position, setPosition] = useState<'left' | 'right'>(resolvedDefault);
|
|
36
|
+
const positionRef = useRef(position);
|
|
37
|
+
positionRef.current = position;
|
|
38
|
+
|
|
27
39
|
const [isDragging, setIsDragging] = useState(false);
|
|
28
|
-
const
|
|
29
|
-
const
|
|
40
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
const draggingRef = useRef(false);
|
|
42
|
+
const didDragRef = useRef(false);
|
|
43
|
+
const startPointer = useRef({ x: 0, y: 0 });
|
|
44
|
+
const startRect = useRef({ x: 0, y: 0 });
|
|
45
|
+
const samples = useRef<Array<{ x: number; t: number }>>([]);
|
|
30
46
|
const hasShownHint = useRef(false);
|
|
31
47
|
const [showHint, setShowHint] = useState(false);
|
|
32
48
|
|
|
33
|
-
// Show bounce hint on first visit
|
|
34
49
|
useEffect(() => {
|
|
35
50
|
try {
|
|
36
51
|
const hasVisited = localStorage.getItem('xcelsior-chat-hint-shown');
|
|
@@ -43,49 +58,139 @@ export function useDraggablePosition(configPosition: WidgetPosition = 'auto') {
|
|
|
43
58
|
}, 2000);
|
|
44
59
|
return () => clearTimeout(timer);
|
|
45
60
|
}
|
|
46
|
-
} catch {
|
|
47
|
-
// localStorage not available
|
|
48
|
-
}
|
|
61
|
+
} catch { /* */ }
|
|
49
62
|
}, []);
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const handleMove = (e: PointerEvent) => {
|
|
66
|
+
if (!draggingRef.current) return;
|
|
67
|
+
const el = containerRef.current;
|
|
68
|
+
if (!el) return;
|
|
69
|
+
|
|
70
|
+
const dx = e.clientX - startPointer.current.x;
|
|
71
|
+
const dy = e.clientY - startPointer.current.y;
|
|
72
|
+
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
|
|
73
|
+
didDragRef.current = true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
el.style.transition = 'none';
|
|
77
|
+
el.style.transform = `translate(${dx}px, ${dy}px)`;
|
|
78
|
+
|
|
79
|
+
const now = performance.now();
|
|
80
|
+
samples.current.push({ x: e.clientX, t: now });
|
|
81
|
+
if (samples.current.length > 5) samples.current.shift();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleUp = (e: PointerEvent) => {
|
|
85
|
+
if (!draggingRef.current) return;
|
|
86
|
+
draggingRef.current = false;
|
|
87
|
+
|
|
88
|
+
const el = containerRef.current;
|
|
89
|
+
if (!el) return;
|
|
90
|
+
|
|
91
|
+
// ── Velocity ────────────────────────────────────
|
|
92
|
+
let velocityX = 0;
|
|
93
|
+
const s = samples.current;
|
|
94
|
+
if (s.length >= 2) {
|
|
95
|
+
const first = s[0];
|
|
96
|
+
const last = s[s.length - 1];
|
|
97
|
+
const dt = last.t - first.t;
|
|
98
|
+
if (dt > 0) velocityX = (last.x - first.x) / dt;
|
|
99
|
+
}
|
|
100
|
+
samples.current = [];
|
|
101
|
+
|
|
102
|
+
// ── Target side ─────────────────────────────────
|
|
103
|
+
const currentVisualX = startRect.current.x + (e.clientX - startPointer.current.x);
|
|
104
|
+
const screenMid = window.innerWidth / 2;
|
|
105
|
+
let targetSide: 'left' | 'right';
|
|
106
|
+
|
|
107
|
+
if (Math.abs(velocityX) > VELOCITY_THRESHOLD) {
|
|
108
|
+
targetSide = velocityX < 0 ? 'left' : 'right';
|
|
109
|
+
} else {
|
|
110
|
+
targetSide = (currentVisualX + FAB_SIZE / 2) < screenMid ? 'left' : 'right';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Animate from current position to target resting position ──
|
|
114
|
+
// Both expressed as offsets from the CURRENT CSS base (old position).
|
|
115
|
+
// We do NOT update React state until animation finishes.
|
|
116
|
+
const oldBaseX = restingX(positionRef.current);
|
|
117
|
+
const oldBaseY = restingY();
|
|
118
|
+
|
|
119
|
+
const currentX = startRect.current.x + (e.clientX - startPointer.current.x);
|
|
120
|
+
const currentY = startRect.current.y + (e.clientY - startPointer.current.y);
|
|
121
|
+
|
|
122
|
+
const targetX = restingX(targetSide);
|
|
123
|
+
const targetY = restingY();
|
|
124
|
+
|
|
125
|
+
// Current visual position as translate from old CSS base
|
|
126
|
+
const fromTx = currentX - oldBaseX;
|
|
127
|
+
const fromTy = currentY - oldBaseY;
|
|
128
|
+
|
|
129
|
+
// Target resting position as translate from old CSS base
|
|
130
|
+
const toTx = targetX - oldBaseX;
|
|
131
|
+
const toTy = targetY - oldBaseY;
|
|
132
|
+
|
|
133
|
+
// Ensure we're at the "from" position with no transition
|
|
134
|
+
el.style.transition = 'none';
|
|
135
|
+
el.style.transform = `translate(${fromTx}px, ${fromTy}px)`;
|
|
136
|
+
|
|
137
|
+
// Force reflow
|
|
138
|
+
void el.offsetHeight;
|
|
139
|
+
|
|
140
|
+
// Animate to target
|
|
141
|
+
el.style.transition = `transform ${SETTLE_DURATION}ms ${SETTLE_EASING}`;
|
|
142
|
+
el.style.transform = `translate(${toTx}px, ${toTy}px)`;
|
|
143
|
+
|
|
144
|
+
// After animation: clean up DOM styles, update React state
|
|
145
|
+
const finish = () => {
|
|
146
|
+
el.style.transition = '';
|
|
147
|
+
el.style.transform = '';
|
|
148
|
+
el.removeEventListener('transitionend', finish);
|
|
149
|
+
// Now update React state — this re-renders with correct CSS class
|
|
150
|
+
setIsDragging(false);
|
|
151
|
+
if (targetSide !== positionRef.current) {
|
|
152
|
+
setPosition(targetSide);
|
|
153
|
+
storePosition(targetSide);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
el.addEventListener('transitionend', finish, { once: true });
|
|
158
|
+
// Safety fallback
|
|
159
|
+
setTimeout(finish, SETTLE_DURATION + 50);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
document.addEventListener('pointermove', handleMove);
|
|
163
|
+
document.addEventListener('pointerup', handleUp);
|
|
164
|
+
return () => {
|
|
165
|
+
document.removeEventListener('pointermove', handleMove);
|
|
166
|
+
document.removeEventListener('pointerup', handleUp);
|
|
167
|
+
};
|
|
55
168
|
}, []);
|
|
56
169
|
|
|
57
|
-
const
|
|
58
|
-
|
|
170
|
+
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
const el = containerRef.current;
|
|
173
|
+
if (!el) return;
|
|
174
|
+
|
|
175
|
+
const rect = el.getBoundingClientRect();
|
|
176
|
+
startPointer.current = { x: e.clientX, y: e.clientY };
|
|
177
|
+
startRect.current = { x: rect.left, y: rect.top };
|
|
178
|
+
draggingRef.current = true;
|
|
179
|
+
didDragRef.current = false;
|
|
180
|
+
samples.current = [];
|
|
181
|
+
setIsDragging(true);
|
|
59
182
|
}, []);
|
|
60
183
|
|
|
61
|
-
const
|
|
62
|
-
(e: React.PointerEvent) => {
|
|
63
|
-
setIsDragging(false);
|
|
64
|
-
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
|
65
|
-
|
|
66
|
-
const deltaX = e.clientX - dragStartX.current;
|
|
67
|
-
// Only snap if dragged more than 50px horizontally
|
|
68
|
-
if (Math.abs(deltaX) > 50) {
|
|
69
|
-
const screenMid = window.innerWidth / 2;
|
|
70
|
-
const newPosition = e.clientX < screenMid ? 'left' : 'right';
|
|
71
|
-
if (newPosition !== position) {
|
|
72
|
-
setPosition(newPosition);
|
|
73
|
-
storePosition(newPosition);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
[position]
|
|
78
|
-
);
|
|
184
|
+
const shouldSuppressClick = useCallback(() => didDragRef.current, []);
|
|
79
185
|
|
|
80
186
|
return {
|
|
81
187
|
position,
|
|
82
188
|
isDragging,
|
|
83
189
|
showHint,
|
|
84
|
-
|
|
190
|
+
containerRef,
|
|
191
|
+
shouldSuppressClick,
|
|
85
192
|
handlers: {
|
|
86
193
|
onPointerDown: handlePointerDown,
|
|
87
|
-
onPointerMove: handlePointerMove,
|
|
88
|
-
onPointerUp: handlePointerUp,
|
|
89
194
|
},
|
|
90
195
|
};
|
|
91
196
|
}
|