@hienlh/ppm 0.2.16 → 0.2.18
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/bun.lock +30 -3
- package/dist/ppm +0 -0
- package/dist/web/assets/{button-CQ5h5gxS.js → button-CvHWF07y.js} +1 -1
- package/dist/web/assets/{chat-tab-Cpj7_-mS.js → chat-tab-Cbc-uzKV.js} +4 -4
- package/dist/web/assets/{code-editor-BauDrXcB.js → code-editor-B9e5P-DN.js} +1 -1
- package/dist/web/assets/{dialog-CCBmXo6-.js → dialog-Cn5zGuid.js} +1 -1
- package/dist/web/assets/diff-viewer-B0iMQ1Qf.js +4 -0
- package/dist/web/assets/git-graph-D3ls9-HA.js +1 -0
- package/dist/web/assets/{git-status-panel-B_iCL1Ge.js → git-status-panel-UB0AyOdX.js} +1 -1
- package/dist/web/assets/index-BdUoflYx.css +2 -0
- package/dist/web/assets/index-DLIV9ojh.js +17 -0
- package/dist/web/assets/{project-list-7ReggIMy.js → project-list-D6oBUMd8.js} +1 -1
- package/dist/web/assets/settings-tab-DmTDAK9n.js +1 -0
- package/dist/web/assets/{terminal-tab-Cg4Pm_3X.js → terminal-tab-DlRo-KzS.js} +1 -1
- package/dist/web/index.html +7 -8
- package/dist/web/sw.js +1 -1
- package/package.json +3 -2
- package/src/providers/claude-agent-sdk.ts +1 -2
- package/src/server/index.ts +2 -0
- package/src/server/routes/push.ts +54 -0
- package/src/server/ws/chat.ts +211 -87
- package/src/services/push-notification.service.ts +118 -0
- package/src/types/config.ts +7 -0
- package/src/web/app.tsx +4 -11
- package/src/web/components/layout/draggable-tab.tsx +58 -0
- package/src/web/components/layout/editor-panel.tsx +64 -0
- package/src/web/components/layout/mobile-nav.tsx +99 -55
- package/src/web/components/layout/panel-layout.tsx +71 -0
- package/src/web/components/layout/sidebar.tsx +29 -6
- package/src/web/components/layout/split-drop-overlay.tsx +111 -0
- package/src/web/components/layout/tab-bar.tsx +60 -68
- package/src/web/components/settings/settings-tab.tsx +45 -1
- package/src/web/hooks/use-chat.ts +31 -2
- package/src/web/hooks/use-global-keybindings.ts +8 -0
- package/src/web/hooks/use-push-notification.ts +96 -0
- package/src/web/hooks/use-tab-drag.ts +109 -0
- package/src/web/stores/panel-store.ts +383 -0
- package/src/web/stores/panel-utils.ts +116 -0
- package/src/web/stores/settings-store.ts +25 -17
- package/src/web/stores/tab-store.ts +32 -152
- package/src/web/sw.ts +52 -0
- package/vite.config.ts +4 -11
- package/dist/web/assets/diff-viewer-CGIQRv_l.js +0 -4
- package/dist/web/assets/dist-CYANqO1g.js +0 -1
- package/dist/web/assets/git-graph-D4IUX9-7.js +0 -1
- package/dist/web/assets/index-DOHQ7GlD.js +0 -12
- package/dist/web/assets/index-Jhl6F2vS.css +0 -2
- package/dist/web/assets/settings-tab-Ceuow24i.js +0 -1
- package/dist/web/workbox-3e722498.js +0 -1
- /package/dist/web/assets/{api-client-tgjN9Mx8.js → api-client-B_eCZViO.js} +0 -0
- /package/dist/web/assets/{dist-0XHv8Vwc.js → dist-B6sG2GPc.js} +0 -0
- /package/dist/web/assets/{dist-BeHIxUn0.js → dist-CBiGQxfr.js} +0 -0
- /package/dist/web/assets/{utils-D6me7KDg.js → utils-61GRB9Cb.js} +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { Moon, Sun, Monitor } from "lucide-react";
|
|
1
|
+
import { Moon, Sun, Monitor, Bell, BellOff } from "lucide-react";
|
|
2
2
|
import { Button } from "@/components/ui/button";
|
|
3
3
|
import { Separator } from "@/components/ui/separator";
|
|
4
4
|
import { useSettingsStore, type Theme } from "@/stores/settings-store";
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
import { AISettingsSection } from "./ai-settings-section";
|
|
7
|
+
import { usePushNotification } from "@/hooks/use-push-notification";
|
|
7
8
|
|
|
8
9
|
const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
|
|
9
10
|
{ value: "light", label: "Light", icon: Sun },
|
|
@@ -11,8 +12,13 @@ const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[]
|
|
|
11
12
|
{ value: "system", label: "System", icon: Monitor },
|
|
12
13
|
];
|
|
13
14
|
|
|
15
|
+
const pushSupported = "PushManager" in window && "serviceWorker" in navigator;
|
|
16
|
+
const isIosNonPwa = /iPhone|iPad/.test(navigator.userAgent) &&
|
|
17
|
+
!window.matchMedia("(display-mode: standalone)").matches;
|
|
18
|
+
|
|
14
19
|
export function SettingsTab() {
|
|
15
20
|
const { theme, setTheme } = useSettingsStore();
|
|
21
|
+
const { permission, isSubscribed, loading, subscribe, unsubscribe } = usePushNotification();
|
|
16
22
|
|
|
17
23
|
return (
|
|
18
24
|
<div className="h-full p-4 space-y-6 overflow-auto max-w-lg">
|
|
@@ -48,6 +54,44 @@ export function SettingsTab() {
|
|
|
48
54
|
|
|
49
55
|
<Separator />
|
|
50
56
|
|
|
57
|
+
<div className="space-y-3">
|
|
58
|
+
<h3 className="text-sm font-medium text-text-secondary">Notifications</h3>
|
|
59
|
+
{!pushSupported ? (
|
|
60
|
+
<p className="text-sm text-text-subtle">
|
|
61
|
+
Push notifications are not supported in this browser.
|
|
62
|
+
</p>
|
|
63
|
+
) : (
|
|
64
|
+
<>
|
|
65
|
+
<div className="flex items-center justify-between">
|
|
66
|
+
<div className="flex items-center gap-2">
|
|
67
|
+
{isSubscribed ? <Bell className="size-4" /> : <BellOff className="size-4" />}
|
|
68
|
+
<span className="text-sm">Push notifications</span>
|
|
69
|
+
</div>
|
|
70
|
+
<Button
|
|
71
|
+
variant={isSubscribed ? "default" : "outline"}
|
|
72
|
+
size="sm"
|
|
73
|
+
disabled={loading || permission === "denied"}
|
|
74
|
+
onClick={() => (isSubscribed ? unsubscribe() : subscribe())}
|
|
75
|
+
>
|
|
76
|
+
{loading ? "..." : isSubscribed ? "On" : "Off"}
|
|
77
|
+
</Button>
|
|
78
|
+
</div>
|
|
79
|
+
{permission === "denied" && (
|
|
80
|
+
<p className="text-xs text-destructive">
|
|
81
|
+
Notifications blocked. Enable in browser settings.
|
|
82
|
+
</p>
|
|
83
|
+
)}
|
|
84
|
+
{isIosNonPwa && (
|
|
85
|
+
<p className="text-xs text-text-subtle">
|
|
86
|
+
On iOS, install PPM to Home Screen for push notifications.
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<Separator />
|
|
94
|
+
|
|
51
95
|
<div className="space-y-3">
|
|
52
96
|
<h3 className="text-sm font-medium text-text-secondary">About</h3>
|
|
53
97
|
<p className="text-sm text-text-secondary">
|
|
@@ -43,6 +43,7 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
|
|
|
43
43
|
const isStreamingRef = useRef(false);
|
|
44
44
|
const pendingMessageRef = useRef<string | null>(null);
|
|
45
45
|
const sendRef = useRef<(data: string) => void>(() => {});
|
|
46
|
+
const refetchRef = useRef<(() => void) | null>(null);
|
|
46
47
|
|
|
47
48
|
const handleMessage = useCallback((event: MessageEvent) => {
|
|
48
49
|
let data: ChatWsServerMessage;
|
|
@@ -55,12 +56,32 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
|
|
|
55
56
|
// Ignore keepalive pings
|
|
56
57
|
if ((data as any).type === "ping") return;
|
|
57
58
|
|
|
58
|
-
// Handle connected event (
|
|
59
|
+
// Handle connected event (new session)
|
|
59
60
|
if ((data as any).type === "connected") {
|
|
60
61
|
setIsConnected(true);
|
|
61
62
|
return;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
// Handle status event (FE reconnected to existing session)
|
|
66
|
+
if ((data as any).type === "status") {
|
|
67
|
+
setIsConnected(true);
|
|
68
|
+
const status = data as any;
|
|
69
|
+
if (status.isStreaming) {
|
|
70
|
+
isStreamingRef.current = true;
|
|
71
|
+
setIsStreaming(true);
|
|
72
|
+
}
|
|
73
|
+
if (status.pendingApproval) {
|
|
74
|
+
setPendingApproval({
|
|
75
|
+
requestId: status.pendingApproval.requestId,
|
|
76
|
+
tool: status.pendingApproval.tool,
|
|
77
|
+
input: status.pendingApproval.input,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
// Refetch history to catch up on events missed during disconnect
|
|
81
|
+
refetchRef.current?.();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
64
85
|
switch (data.type) {
|
|
65
86
|
case "text": {
|
|
66
87
|
streamingContentRef.current += data.content;
|
|
@@ -379,10 +400,12 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
|
|
|
379
400
|
const reconnect = useCallback(() => {
|
|
380
401
|
setIsConnected(false);
|
|
381
402
|
wsReconnect();
|
|
403
|
+
// Refetch history on manual reconnect to catch up on missed events
|
|
404
|
+
refetchRef.current?.();
|
|
382
405
|
}, [wsReconnect]);
|
|
383
406
|
|
|
384
407
|
const refetchMessages = useCallback(() => {
|
|
385
|
-
if (!sessionId || !projectName
|
|
408
|
+
if (!sessionId || !projectName) return;
|
|
386
409
|
setMessagesLoading(true);
|
|
387
410
|
fetch(`${projectUrl(projectName)}/chat/sessions/${sessionId}/messages?providerId=${providerId}`, {
|
|
388
411
|
headers: { Authorization: `Bearer ${getAuthToken()}` },
|
|
@@ -391,12 +414,18 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
|
|
|
391
414
|
.then((json: any) => {
|
|
392
415
|
if (json.ok && Array.isArray(json.data) && json.data.length > 0) {
|
|
393
416
|
setMessages(json.data);
|
|
417
|
+
// Reset streaming content refs so live tokens append cleanly after history
|
|
418
|
+
streamingContentRef.current = "";
|
|
419
|
+
streamingEventsRef.current = [];
|
|
394
420
|
}
|
|
395
421
|
})
|
|
396
422
|
.catch(() => {})
|
|
397
423
|
.finally(() => setMessagesLoading(false));
|
|
398
424
|
}, [sessionId, providerId, projectName]);
|
|
399
425
|
|
|
426
|
+
// Keep refetchRef in sync so handleMessage (status event) can trigger refetch
|
|
427
|
+
refetchRef.current = refetchMessages;
|
|
428
|
+
|
|
400
429
|
return {
|
|
401
430
|
messages,
|
|
402
431
|
messagesLoading,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState, useCallback } from "react";
|
|
2
2
|
import { useTabStore } from "@/stores/tab-store";
|
|
3
|
+
import { useSettingsStore } from "@/stores/settings-store";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Global keyboard shortcuts.
|
|
@@ -29,6 +30,13 @@ export function useGlobalKeybindings() {
|
|
|
29
30
|
// Keydown shortcuts
|
|
30
31
|
if (e.type !== "keydown") return;
|
|
31
32
|
|
|
33
|
+
// Cmd/Ctrl+B → Toggle sidebar
|
|
34
|
+
if (e.key === "b" && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
useSettingsStore.getState().toggleSidebar();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
// Alt+] / Alt+[ → Cycle tabs
|
|
33
41
|
if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "]" || e.key === "[")) {
|
|
34
42
|
e.preventDefault();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { getAuthToken } from "@/lib/api-client";
|
|
3
|
+
|
|
4
|
+
/** Convert VAPID public key from base64url to Uint8Array for PushManager */
|
|
5
|
+
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
6
|
+
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
|
7
|
+
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
8
|
+
const rawData = atob(base64);
|
|
9
|
+
return Uint8Array.from(rawData, (char) => char.charCodeAt(0));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function usePushNotification() {
|
|
13
|
+
const [permission, setPermission] = useState<NotificationPermission>("default");
|
|
14
|
+
const [isSubscribed, setIsSubscribed] = useState(false);
|
|
15
|
+
const [loading, setLoading] = useState(false);
|
|
16
|
+
|
|
17
|
+
// Check current permission and subscription state on mount
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if ("Notification" in window) {
|
|
20
|
+
setPermission(Notification.permission);
|
|
21
|
+
}
|
|
22
|
+
setIsSubscribed(localStorage.getItem("ppm-push-subscribed") === "true");
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
const subscribe = useCallback(async () => {
|
|
26
|
+
setLoading(true);
|
|
27
|
+
try {
|
|
28
|
+
// 1. Request notification permission
|
|
29
|
+
const perm = await Notification.requestPermission();
|
|
30
|
+
setPermission(perm);
|
|
31
|
+
if (perm !== "granted") return;
|
|
32
|
+
|
|
33
|
+
// 2. Get VAPID public key from server
|
|
34
|
+
const headers: Record<string, string> = {};
|
|
35
|
+
const token = getAuthToken();
|
|
36
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
37
|
+
|
|
38
|
+
const res = await fetch("/api/push/vapid-key", { headers });
|
|
39
|
+
const json = await res.json();
|
|
40
|
+
if (!json.ok) throw new Error(json.error || "Failed to get VAPID key");
|
|
41
|
+
|
|
42
|
+
// 3. Subscribe via PushManager
|
|
43
|
+
const reg = await navigator.serviceWorker.ready;
|
|
44
|
+
const sub = await reg.pushManager.subscribe({
|
|
45
|
+
userVisibleOnly: true,
|
|
46
|
+
applicationServerKey: urlBase64ToUint8Array(json.data.publicKey).buffer as ArrayBuffer,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// 4. Send subscription to server
|
|
50
|
+
await fetch("/api/push/subscribe", {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
53
|
+
body: JSON.stringify(sub.toJSON()),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
setIsSubscribed(true);
|
|
57
|
+
localStorage.setItem("ppm-push-subscribed", "true");
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error("[push] Subscribe failed:", err);
|
|
60
|
+
} finally {
|
|
61
|
+
setLoading(false);
|
|
62
|
+
}
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const unsubscribe = useCallback(async () => {
|
|
66
|
+
setLoading(true);
|
|
67
|
+
try {
|
|
68
|
+
const reg = await navigator.serviceWorker.ready;
|
|
69
|
+
const sub = await reg.pushManager.getSubscription();
|
|
70
|
+
if (sub) {
|
|
71
|
+
// Remove from server
|
|
72
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
73
|
+
const token = getAuthToken();
|
|
74
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
75
|
+
|
|
76
|
+
await fetch("/api/push/subscribe", {
|
|
77
|
+
method: "DELETE",
|
|
78
|
+
headers,
|
|
79
|
+
body: JSON.stringify({ endpoint: sub.endpoint }),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Unsubscribe locally
|
|
83
|
+
await sub.unsubscribe();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setIsSubscribed(false);
|
|
87
|
+
localStorage.removeItem("ppm-push-subscribed");
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error("[push] Unsubscribe failed:", err);
|
|
90
|
+
} finally {
|
|
91
|
+
setLoading(false);
|
|
92
|
+
}
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
return { permission, isSubscribed, loading, subscribe, unsubscribe };
|
|
96
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useRef, useState, useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
import { usePanelStore } from "@/stores/panel-store";
|
|
3
|
+
|
|
4
|
+
export const TAB_DRAG_TYPE = "application/ppm-tab";
|
|
5
|
+
|
|
6
|
+
export interface DragPayload {
|
|
7
|
+
tabId: string;
|
|
8
|
+
panelId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Global drag state — lets overlays know a tab drag is in progress
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
let _dragging = false;
|
|
15
|
+
const _listeners = new Set<() => void>();
|
|
16
|
+
|
|
17
|
+
function setDragging(v: boolean) {
|
|
18
|
+
if (_dragging === v) return;
|
|
19
|
+
_dragging = v;
|
|
20
|
+
_listeners.forEach((fn) => fn());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useIsDraggingTab(): boolean {
|
|
24
|
+
return useSyncExternalStore(
|
|
25
|
+
(cb) => { _listeners.add(cb); return () => _listeners.delete(cb); },
|
|
26
|
+
() => _dragging,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Call from any drop handler to clear global drag state */
|
|
31
|
+
export function clearDragging() {
|
|
32
|
+
setDragging(false);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Hook for tab bar DnD
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
export function useTabDrag(panelId: string) {
|
|
39
|
+
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
|
40
|
+
const dragOverRef = useRef<string | null>(null);
|
|
41
|
+
|
|
42
|
+
const handleDragStart = useCallback(
|
|
43
|
+
(e: React.DragEvent, tabId: string) => {
|
|
44
|
+
const payload: DragPayload = { tabId, panelId };
|
|
45
|
+
e.dataTransfer.setData(TAB_DRAG_TYPE, JSON.stringify(payload));
|
|
46
|
+
e.dataTransfer.effectAllowed = "move";
|
|
47
|
+
// Delay so browser captures the drag image first
|
|
48
|
+
requestAnimationFrame(() => setDragging(true));
|
|
49
|
+
},
|
|
50
|
+
[panelId],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const handleDragOver = useCallback(
|
|
54
|
+
(e: React.DragEvent, tabId: string, tabIndex: number) => {
|
|
55
|
+
if (!e.dataTransfer.types.includes(TAB_DRAG_TYPE)) return;
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
e.dataTransfer.dropEffect = "move";
|
|
58
|
+
|
|
59
|
+
if (dragOverRef.current === tabId) return;
|
|
60
|
+
dragOverRef.current = tabId;
|
|
61
|
+
|
|
62
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
63
|
+
const midX = rect.left + rect.width / 2;
|
|
64
|
+
setDropIndex(e.clientX < midX ? tabIndex : tabIndex + 1);
|
|
65
|
+
},
|
|
66
|
+
[],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const handleDragOverBar = useCallback(
|
|
70
|
+
(e: React.DragEvent) => {
|
|
71
|
+
if (!e.dataTransfer.types.includes(TAB_DRAG_TYPE)) return;
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
e.dataTransfer.dropEffect = "move";
|
|
74
|
+
},
|
|
75
|
+
[],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const handleDrop = useCallback(
|
|
79
|
+
(e: React.DragEvent) => {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
setDragging(false);
|
|
82
|
+
const raw = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
|
83
|
+
if (!raw) return;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const payload = JSON.parse(raw) as DragPayload;
|
|
87
|
+
const store = usePanelStore.getState();
|
|
88
|
+
|
|
89
|
+
if (payload.panelId === panelId) {
|
|
90
|
+
if (dropIndex !== null) store.reorderTab(payload.tabId, panelId, dropIndex);
|
|
91
|
+
} else {
|
|
92
|
+
store.moveTab(payload.tabId, payload.panelId, panelId, dropIndex ?? undefined);
|
|
93
|
+
}
|
|
94
|
+
} catch { /* ignore */ }
|
|
95
|
+
|
|
96
|
+
setDropIndex(null);
|
|
97
|
+
dragOverRef.current = null;
|
|
98
|
+
},
|
|
99
|
+
[panelId, dropIndex],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const handleDragEnd = useCallback(() => {
|
|
103
|
+
setDragging(false);
|
|
104
|
+
setDropIndex(null);
|
|
105
|
+
dragOverRef.current = null;
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
return { dropIndex, handleDragStart, handleDragOver, handleDragOverBar, handleDrop, handleDragEnd };
|
|
109
|
+
}
|