@vuer-ai/vuer-uikit 0.0.11 → 0.0.13
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/dist/{chunk-CP7QYV4N.mjs → chunk-44KW6PTD.mjs} +1 -0
- package/dist/{chunk-KQAHAGUG.js → chunk-AOUGWGQR.js} +2 -2
- package/dist/{chunk-J7PTSP4Q.mjs → chunk-EAZQH4IX.mjs} +8 -0
- package/dist/chunk-EOFYA6CS.js +53 -0
- package/dist/{chunk-O4TK3AKI.mjs → chunk-EYIFKSU2.mjs} +1 -1
- package/dist/{chunk-WMXX2UMD.mjs → chunk-FEOONYZ2.mjs} +2 -2
- package/dist/{chunk-66DYOZI3.js → chunk-FORVP7XH.js} +6 -5
- package/dist/{chunk-7X6N54LX.mjs → chunk-G5KMD72J.mjs} +6 -5
- package/dist/{chunk-5MZCQW4U.js → chunk-IXHMIR5P.js} +4 -4
- package/dist/{chunk-CCGUGCCV.js → chunk-JAJNLRY5.js} +9 -9
- package/dist/{chunk-WOZWC6KO.mjs → chunk-JMPVDU2C.mjs} +35 -4
- package/dist/{chunk-E5KWKBH3.mjs → chunk-LEPXSM36.mjs} +10 -24
- package/dist/{chunk-3XVIJY3H.mjs → chunk-MRVMXKZO.mjs} +1 -1
- package/dist/{chunk-UMIQYHO6.mjs → chunk-OOI3SJDU.mjs} +10 -4
- package/dist/{chunk-B5EMQBVL.js → chunk-OTPUWWEB.js} +8 -3
- package/dist/{chunk-FSMKJAZT.js → chunk-PVRQNCSJ.js} +9 -23
- package/dist/chunk-Q5KEB4UK.mjs +51 -0
- package/dist/{chunk-XUQ5A7ZA.js → chunk-QEY7BWIR.js} +10 -4
- package/dist/{chunk-QZASHJNJ.js → chunk-QJR6MCTR.js} +8 -0
- package/dist/{chunk-IOI3YFTY.js → chunk-U2ZLDPOE.js} +8 -8
- package/dist/{chunk-ZJXBC33M.js → chunk-UFHMEEGL.js} +1 -0
- package/dist/{chunk-5P3KAS53.js → chunk-WJ7T7FAH.js} +34 -3
- package/dist/{chunk-6L4KPPOD.mjs → chunk-XMG3IVGZ.mjs} +8 -3
- package/dist/{chunk-XCCC7HVN.mjs → chunk-ZVG2UKKC.mjs} +1 -1
- package/dist/highlight-cursor/cursor-provider.js +2 -2
- package/dist/highlight-cursor/cursor-provider.mjs +1 -1
- package/dist/highlight-cursor/enhanced-components.js +11 -10
- package/dist/highlight-cursor/enhanced-components.mjs +6 -5
- package/dist/highlight-cursor/index.js +15 -14
- package/dist/highlight-cursor/index.mjs +7 -6
- package/dist/highlight-cursor/tabs-cursor-context.js +3 -3
- package/dist/highlight-cursor/tabs-cursor-context.mjs +1 -1
- package/dist/highlight-cursor/with-cursor.js +2 -2
- package/dist/highlight-cursor/with-cursor.mjs +1 -1
- package/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +64 -59
- package/dist/index.mjs +15 -14
- package/dist/ui/avatar.js +2 -1
- package/dist/ui/avatar.mjs +2 -1
- package/dist/ui/badge.js +2 -1
- package/dist/ui/badge.mjs +2 -1
- package/dist/ui/button.js +2 -1
- package/dist/ui/button.mjs +2 -1
- package/dist/ui/card.js +2 -1
- package/dist/ui/card.mjs +2 -1
- package/dist/ui/checkbox.js +2 -1
- package/dist/ui/checkbox.mjs +2 -1
- package/dist/ui/collapsible.js +2 -1
- package/dist/ui/collapsible.mjs +2 -1
- package/dist/ui/dropdown.js +2 -1
- package/dist/ui/dropdown.mjs +2 -1
- package/dist/ui/index.js +49 -48
- package/dist/ui/index.mjs +12 -11
- package/dist/ui/input-numbers.js +4 -3
- package/dist/ui/input-numbers.mjs +3 -2
- package/dist/ui/input.js +2 -1
- package/dist/ui/input.mjs +2 -1
- package/dist/ui/layout.js +2 -1
- package/dist/ui/layout.mjs +2 -1
- package/dist/ui/modal.js +2 -1
- package/dist/ui/modal.mjs +2 -1
- package/dist/ui/popover.js +2 -1
- package/dist/ui/popover.mjs +2 -1
- package/dist/ui/radio-group.js +2 -1
- package/dist/ui/radio-group.mjs +2 -1
- package/dist/ui/select.js +2 -1
- package/dist/ui/select.mjs +2 -1
- package/dist/ui/slider.js +2 -1
- package/dist/ui/slider.mjs +2 -1
- package/dist/ui/switch.js +2 -1
- package/dist/ui/switch.mjs +2 -1
- package/dist/ui/tabs.js +8 -7
- package/dist/ui/tabs.mjs +4 -3
- package/dist/ui/textarea.js +2 -1
- package/dist/ui/textarea.mjs +2 -1
- package/dist/ui/theme-context.js +6 -3
- package/dist/ui/theme-context.mjs +4 -1
- package/dist/ui/timeline.js +6 -5
- package/dist/ui/timeline.mjs +5 -4
- package/dist/ui/toast.d.mts +1 -1
- package/dist/ui/toast.d.ts +1 -1
- package/dist/ui/toast.js +3 -3
- package/dist/ui/toast.mjs +1 -1
- package/dist/ui/toggle-group.js +2 -1
- package/dist/ui/toggle-group.mjs +2 -1
- package/dist/ui/toggle.js +2 -1
- package/dist/ui/toggle.mjs +2 -1
- package/dist/ui/tooltip.d.mts +3 -3
- package/dist/ui/tooltip.d.ts +3 -3
- package/dist/ui/tooltip.js +7 -6
- package/dist/ui/tooltip.mjs +3 -2
- package/dist/ui/tree-view-v2.js +5 -4
- package/dist/ui/tree-view-v2.mjs +4 -3
- package/dist/ui/version-badge.js +2 -1
- package/dist/ui/version-badge.mjs +2 -1
- package/dist/utils/index.d.mts +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +6 -1
- package/dist/utils/index.mjs +2 -1
- package/dist/utils/use-local-storage.d.mts +3 -0
- package/dist/utils/use-local-storage.d.ts +3 -0
- package/dist/utils/use-local-storage.js +10 -0
- package/dist/utils/use-local-storage.mjs +1 -0
- package/package.json +6 -4
- package/src/highlight-cursor/cursor-context.tsx +23 -0
- package/src/highlight-cursor/cursor-provider.tsx +264 -0
- package/src/highlight-cursor/enhanced-components.tsx +16 -0
- package/src/highlight-cursor/index.ts +21 -0
- package/src/highlight-cursor/tabs-cursor-context.tsx +121 -0
- package/src/highlight-cursor/types.ts +40 -0
- package/src/highlight-cursor/with-cursor.tsx +144 -0
- package/src/index.css +5 -0
- package/src/index.ts +5 -0
- package/src/styles/theme.css +80 -0
- package/src/styles/toast.css +64 -0
- package/src/styles/variables.css +194 -0
- package/src/ui/avatar.tsx +92 -0
- package/src/ui/badge.tsx +68 -0
- package/src/ui/button.tsx +100 -0
- package/src/ui/card.tsx +88 -0
- package/src/ui/checkbox.tsx +76 -0
- package/src/ui/collapsible.tsx +36 -0
- package/src/ui/dropdown.tsx +375 -0
- package/src/ui/index.ts +31 -0
- package/src/ui/input-numbers.tsx +201 -0
- package/src/ui/input.tsx +141 -0
- package/src/ui/layout.tsx +41 -0
- package/src/ui/modal.tsx +198 -0
- package/src/ui/popover.tsx +59 -0
- package/src/ui/radio-group.tsx +56 -0
- package/src/ui/select.tsx +292 -0
- package/src/ui/sheet.tsx +131 -0
- package/src/ui/slider.tsx +189 -0
- package/src/ui/switch.tsx +43 -0
- package/src/ui/tabs.tsx +128 -0
- package/src/ui/textarea.tsx +52 -0
- package/src/ui/theme-context.tsx +80 -0
- package/src/ui/timeline.tsx +717 -0
- package/src/ui/toast.tsx +27 -0
- package/src/ui/toggle-group.tsx +82 -0
- package/src/ui/toggle.tsx +88 -0
- package/src/ui/tooltip.tsx +132 -0
- package/src/ui/tree-view-v2.tsx +300 -0
- package/src/ui/tree-view.tsx +550 -0
- package/src/ui/version-badge.tsx +65 -0
- package/src/utils/cn.ts +21 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/use-local-storage.ts +59 -0
- package/dist/chunk-OBWGRG45.js +0 -21
- package/dist/chunk-Z6XRPTIQ.mjs +0 -15
- /package/dist/{chunk-QS7WS6DD.mjs → chunk-M4IVLZIH.mjs} +0 -0
- /package/dist/{chunk-ST4P6AN6.js → chunk-XWS4SIB5.js} +0 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import { ChevronLeft, ChevronRight, Magnet } from "lucide-react";
|
|
2
|
+
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
3
|
+
|
|
4
|
+
import { TreeViewV2, type TreeDataItemV2 } from "./tree-view-v2";
|
|
5
|
+
import { cn } from "../utils/cn";
|
|
6
|
+
|
|
7
|
+
type LogItemData = TreeDataItemV2 & {
|
|
8
|
+
type: "task" | "attempt" | "info" | "step";
|
|
9
|
+
icon?: "history" | "file-code" | "bot" | "check-circle" | "pause-circle";
|
|
10
|
+
createTime?: number;
|
|
11
|
+
startTime?: number;
|
|
12
|
+
duration?: number;
|
|
13
|
+
time?: number;
|
|
14
|
+
color?: "blue" | "green" | "orange" | "gray-light" | "gray-medium" | "purple";
|
|
15
|
+
hasStripes?: boolean;
|
|
16
|
+
isHalted?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type TimelineProps = {
|
|
20
|
+
data: LogItemData[];
|
|
21
|
+
totalDuration: number;
|
|
22
|
+
getIcon: (item: LogItemData) => React.ReactNode;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const formatDuration = (seconds: number): string => {
|
|
26
|
+
const sign = seconds < 0 ? "-" : "";
|
|
27
|
+
const absSeconds = Math.abs(seconds);
|
|
28
|
+
|
|
29
|
+
if (absSeconds < 1) {
|
|
30
|
+
if (absSeconds < 0.0005) return "0s";
|
|
31
|
+
return `${sign}${Math.round(absSeconds * 1000)}ms`;
|
|
32
|
+
}
|
|
33
|
+
if (absSeconds < 60) {
|
|
34
|
+
return `${sign}${absSeconds.toFixed(3)}s`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SECONDS_IN_MINUTE = 60;
|
|
38
|
+
const SECONDS_IN_HOUR = 3600;
|
|
39
|
+
const SECONDS_IN_DAY = 86400;
|
|
40
|
+
const SECONDS_IN_MONTH = 2592000; // 30 days
|
|
41
|
+
const SECONDS_IN_YEAR = 31536000; // 365 days
|
|
42
|
+
|
|
43
|
+
if (absSeconds < SECONDS_IN_HOUR) {
|
|
44
|
+
const m = Math.floor(absSeconds / SECONDS_IN_MINUTE);
|
|
45
|
+
const s = absSeconds % SECONDS_IN_MINUTE;
|
|
46
|
+
return `${sign}${m}m ${s.toFixed(2).padStart(5, "0")}s`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (absSeconds < SECONDS_IN_DAY) {
|
|
50
|
+
const h = Math.floor(absSeconds / SECONDS_IN_HOUR);
|
|
51
|
+
const m = Math.floor((absSeconds % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE);
|
|
52
|
+
const s = Math.floor(absSeconds % SECONDS_IN_MINUTE);
|
|
53
|
+
return `${sign}${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (absSeconds < SECONDS_IN_MONTH) {
|
|
57
|
+
const d = Math.floor(absSeconds / SECONDS_IN_DAY);
|
|
58
|
+
const h = Math.floor((absSeconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR);
|
|
59
|
+
const m = Math.floor((absSeconds % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE);
|
|
60
|
+
return `${sign}${d}d ${String(h).padStart(2, "0")}h ${String(m).padStart(2, "0")}m`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (absSeconds < SECONDS_IN_YEAR) {
|
|
64
|
+
const mo = Math.floor(absSeconds / SECONDS_IN_MONTH);
|
|
65
|
+
const d = Math.floor((absSeconds % SECONDS_IN_MONTH) / SECONDS_IN_DAY);
|
|
66
|
+
const h = Math.floor((absSeconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR);
|
|
67
|
+
return `${sign}${mo}mo ${String(d).padStart(3, "0")}d ${String(h).padStart(2, "0")}h`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const y = Math.floor(absSeconds / SECONDS_IN_YEAR);
|
|
71
|
+
const mo = Math.floor((absSeconds % SECONDS_IN_YEAR) / SECONDS_IN_MONTH);
|
|
72
|
+
const d = Math.floor((absSeconds % SECONDS_IN_MONTH) / SECONDS_IN_DAY);
|
|
73
|
+
return `${sign}${y}y ${String(mo).padStart(2, "0")}mo ${String(d).padStart(3, "0")}d`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const borderColorClasses = {
|
|
77
|
+
blue: "border-blue-500/85",
|
|
78
|
+
green: "border-green-500/85",
|
|
79
|
+
orange: "border-orange-500/85",
|
|
80
|
+
purple: "border-purple-500/85",
|
|
81
|
+
"gray-light": "border-line-secondary/20",
|
|
82
|
+
"gray-medium": "border-slate-500/85",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const colorClasses = {
|
|
86
|
+
blue: "bg-blue-500/85",
|
|
87
|
+
green: "bg-green-500/85",
|
|
88
|
+
orange: "bg-orange-500/85",
|
|
89
|
+
purple: "bg-purple-500/85",
|
|
90
|
+
"gray-light": "bg-line-secondary/20",
|
|
91
|
+
"gray-medium": "bg-slate-500/85",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const leftWedgeClasses = {
|
|
95
|
+
blue: "border-l-blue-500/85",
|
|
96
|
+
green: "border-l-green-500/85",
|
|
97
|
+
orange: "border-l-orange-500/85",
|
|
98
|
+
purple: "border-l-purple-500/85",
|
|
99
|
+
"gray-light": "border-l-slate-300",
|
|
100
|
+
"gray-medium": "border-l-slate-500/85",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const rightWedgeClasses = {
|
|
104
|
+
blue: "border-r-blue-500/85",
|
|
105
|
+
green: "border-r-green-500/85",
|
|
106
|
+
orange: "border-r-orange-500/85",
|
|
107
|
+
purple: "border-r-purple-500/85",
|
|
108
|
+
"gray-light": "border-r-slate-300",
|
|
109
|
+
"gray-medium": "border-r-slate-500/85",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function Timeline({ data, totalDuration, getIcon }: TimelineProps) {
|
|
113
|
+
const [visibleData, setVisibleData] = useState<LogItemData[]>([]);
|
|
114
|
+
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
115
|
+
|
|
116
|
+
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
|
117
|
+
const [viewStart, setViewStart] = useState(-totalDuration * 0.25);
|
|
118
|
+
const [viewDuration, setViewDuration] = useState(totalDuration * 1.5);
|
|
119
|
+
|
|
120
|
+
const timeToPercent = useCallback(
|
|
121
|
+
(time: number) => ((time - viewStart) / viewDuration) * 100,
|
|
122
|
+
[viewStart, viewDuration],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const handlePan = (direction: "left" | "right") => {
|
|
126
|
+
const panAmount = viewDuration * 0.1;
|
|
127
|
+
if (direction === "left") {
|
|
128
|
+
setViewStart((s) => s - panAmount);
|
|
129
|
+
} else {
|
|
130
|
+
setViewStart((s) => s + panAmount);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const timeMarkers = useMemo(() => {
|
|
135
|
+
const markers: { time: number; label: string }[] = [];
|
|
136
|
+
const niceIntervals = [
|
|
137
|
+
0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100,
|
|
138
|
+
];
|
|
139
|
+
const targetMarkerCount = 10;
|
|
140
|
+
const rawInterval = viewDuration / targetMarkerCount;
|
|
141
|
+
const interval =
|
|
142
|
+
niceIntervals.find((i) => i > rawInterval) || niceIntervals[niceIntervals.length - 1];
|
|
143
|
+
|
|
144
|
+
const viewEnd = viewStart + viewDuration;
|
|
145
|
+
const shownSeconds = new Set<number>();
|
|
146
|
+
|
|
147
|
+
const formatTickLabel = (seconds: number) => {
|
|
148
|
+
const sign = seconds < 0 ? "-" : "";
|
|
149
|
+
const absSeconds = Math.abs(seconds);
|
|
150
|
+
|
|
151
|
+
let s = Math.floor(absSeconds);
|
|
152
|
+
let ms = Math.round((absSeconds - s) * 1000);
|
|
153
|
+
|
|
154
|
+
if (ms >= 1000) {
|
|
155
|
+
s += 1;
|
|
156
|
+
ms -= 1000;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (absSeconds < 1 && absSeconds > -1) {
|
|
160
|
+
return `${sign}${ms}ms`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const baseSecond = s * (sign === "-" ? -1 : 1);
|
|
164
|
+
if (shownSeconds.has(baseSecond)) {
|
|
165
|
+
return ms > 0 ? `+${ms}ms` : "";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
shownSeconds.add(baseSecond);
|
|
169
|
+
if (ms === 0) {
|
|
170
|
+
return `${sign}${s}s`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return `${sign}${s}s`;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const firstMarkerTime = Math.floor(viewStart / interval) * interval;
|
|
177
|
+
const lastMarkerTime = Math.ceil(viewEnd / interval) * interval;
|
|
178
|
+
|
|
179
|
+
for (let time = firstMarkerTime; time <= lastMarkerTime; time += interval) {
|
|
180
|
+
const roundedTime = Number.parseFloat(time.toPrecision(15));
|
|
181
|
+
const label = formatTickLabel(roundedTime);
|
|
182
|
+
if (label) {
|
|
183
|
+
markers.push({ time: roundedTime, label });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return markers;
|
|
187
|
+
}, [viewStart, viewDuration]);
|
|
188
|
+
|
|
189
|
+
const keyEventTimes = useMemo(() => {
|
|
190
|
+
const events: { time: number; type: string }[] = [];
|
|
191
|
+
const timeSet = new Set<string>();
|
|
192
|
+
|
|
193
|
+
const addEvent = (time: number, type: string) => {
|
|
194
|
+
const key = `${time.toFixed(6)}-${type}`;
|
|
195
|
+
if (!timeSet.has(key)) {
|
|
196
|
+
events.push({ time, type });
|
|
197
|
+
timeSet.add(key);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
visibleData.forEach((item) => {
|
|
202
|
+
if (item.createTime !== undefined) addEvent(item.createTime, "create");
|
|
203
|
+
if (item.startTime !== undefined) addEvent(item.startTime, "start");
|
|
204
|
+
if (item.startTime !== undefined && item.duration !== undefined) {
|
|
205
|
+
addEvent(item.startTime + item.duration, "end");
|
|
206
|
+
}
|
|
207
|
+
if (item.time !== undefined) addEvent(item.time, "event");
|
|
208
|
+
});
|
|
209
|
+
return events.sort((a, b) => a.time - b.time);
|
|
210
|
+
}, [visibleData]);
|
|
211
|
+
|
|
212
|
+
const cursorContainerRef = useRef<HTMLDivElement>(null);
|
|
213
|
+
const lastClientX = useRef(0);
|
|
214
|
+
const isMouseOver = useRef(false);
|
|
215
|
+
const animationFrameRef = useRef<number | null>(null);
|
|
216
|
+
|
|
217
|
+
const updateCursor = useCallback(() => {
|
|
218
|
+
if (!isMouseOver.current) return;
|
|
219
|
+
|
|
220
|
+
const timelineEl = timelineContainerRef.current;
|
|
221
|
+
const cursorEl = cursorContainerRef.current;
|
|
222
|
+
if (!timelineEl || !cursorEl) return;
|
|
223
|
+
|
|
224
|
+
const rect = timelineEl.getBoundingClientRect();
|
|
225
|
+
const cursorX = lastClientX.current - rect.left;
|
|
226
|
+
const rawHoverTime = viewStart + (cursorX / timelineEl.offsetWidth) * viewDuration;
|
|
227
|
+
|
|
228
|
+
// Snapping logic
|
|
229
|
+
const snapThresholdInPixels = 8;
|
|
230
|
+
const snapThresholdInTime = (snapThresholdInPixels / timelineEl.offsetWidth) * viewDuration;
|
|
231
|
+
|
|
232
|
+
let closestSnap: { time: number; type: string } | null = null;
|
|
233
|
+
let minDistance = Number.POSITIVE_INFINITY;
|
|
234
|
+
|
|
235
|
+
for (const event of keyEventTimes) {
|
|
236
|
+
const distance = Math.abs(event.time - rawHoverTime);
|
|
237
|
+
if (distance < minDistance && distance < snapThresholdInTime) {
|
|
238
|
+
minDistance = distance;
|
|
239
|
+
closestSnap = event;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const displayTime = closestSnap?.time ?? rawHoverTime;
|
|
244
|
+
const percent = timeToPercent(displayTime);
|
|
245
|
+
|
|
246
|
+
cursorEl.style.setProperty("--cursor-left", `${percent}%`);
|
|
247
|
+
cursorEl.style.setProperty("--readout-text", `"${formatDuration(displayTime)}"`);
|
|
248
|
+
cursorEl.style.setProperty("--magnet-opacity", closestSnap ? "1" : "0");
|
|
249
|
+
cursorEl.classList.remove("opacity-0");
|
|
250
|
+
}, [viewStart, viewDuration, keyEventTimes, timeToPercent]);
|
|
251
|
+
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
const timelineEl = timelineContainerRef.current;
|
|
254
|
+
if (!timelineEl) return;
|
|
255
|
+
|
|
256
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
257
|
+
lastClientX.current = e.clientX;
|
|
258
|
+
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
259
|
+
animationFrameRef.current = requestAnimationFrame(updateCursor);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const handleMouseEnter = () => {
|
|
263
|
+
isMouseOver.current = true;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const handleMouseLeave = () => {
|
|
267
|
+
isMouseOver.current = false;
|
|
268
|
+
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
269
|
+
cursorContainerRef.current?.classList.add("opacity-0");
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
timelineEl.addEventListener("mousemove", handleMouseMove);
|
|
273
|
+
timelineEl.addEventListener("mouseenter", handleMouseEnter);
|
|
274
|
+
timelineEl.addEventListener("mouseleave", handleMouseLeave);
|
|
275
|
+
|
|
276
|
+
return () => {
|
|
277
|
+
timelineEl.removeEventListener("mousemove", handleMouseMove);
|
|
278
|
+
timelineEl.removeEventListener("mouseenter", handleMouseEnter);
|
|
279
|
+
timelineEl.removeEventListener("mouseleave", handleMouseLeave);
|
|
280
|
+
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
281
|
+
};
|
|
282
|
+
}, [updateCursor]);
|
|
283
|
+
|
|
284
|
+
// Handle wheel events with passive: false to allow preventDefault
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
const timelineEl = timelineContainerRef.current;
|
|
287
|
+
if (!timelineEl) return;
|
|
288
|
+
|
|
289
|
+
const wheelHandler = (e: WheelEvent) => {
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
const container = timelineContainerRef.current;
|
|
292
|
+
if (!container) return;
|
|
293
|
+
|
|
294
|
+
if (e.ctrlKey || e.altKey) {
|
|
295
|
+
// Zooming
|
|
296
|
+
const rect = container.getBoundingClientRect();
|
|
297
|
+
const cursorX = e.clientX - rect.left;
|
|
298
|
+
const timeAtCursor = viewStart + (cursorX / container.offsetWidth) * viewDuration;
|
|
299
|
+
const zoomFactor = 1.1;
|
|
300
|
+
const newDuration = e.deltaY < 0 ? viewDuration / zoomFactor : viewDuration * zoomFactor;
|
|
301
|
+
const minDuration = 0.01; // 10ms
|
|
302
|
+
const maxDuration = totalDuration * 10;
|
|
303
|
+
if (newDuration < minDuration || newDuration > maxDuration) return;
|
|
304
|
+
const newViewStart = timeAtCursor - (cursorX / container.offsetWidth) * newDuration;
|
|
305
|
+
setViewDuration(newDuration);
|
|
306
|
+
setViewStart(newViewStart);
|
|
307
|
+
} else {
|
|
308
|
+
// Panning
|
|
309
|
+
const panX = (e.deltaX / container.offsetWidth) * viewDuration;
|
|
310
|
+
const panY = (e.deltaY / container.offsetWidth) * viewDuration;
|
|
311
|
+
setViewStart((s) => s + panX + panY);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
timelineEl.addEventListener("wheel", wheelHandler, { passive: false });
|
|
316
|
+
|
|
317
|
+
return () => {
|
|
318
|
+
timelineEl.removeEventListener("wheel", wheelHandler);
|
|
319
|
+
};
|
|
320
|
+
}, [viewStart, viewDuration, totalDuration]);
|
|
321
|
+
|
|
322
|
+
// This effect handles cursor updates on pan/zoom
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
if (isMouseOver.current) {
|
|
325
|
+
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
326
|
+
animationFrameRef.current = requestAnimationFrame(updateCursor);
|
|
327
|
+
}
|
|
328
|
+
}, [viewStart, viewDuration, updateCursor]);
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<div className="bg-bg-primary text-text-primary border-line-secondary/20 rounded-uk-md mx-auto w-full max-w-7xl overflow-hidden border font-sans shadow-2xl">
|
|
332
|
+
<div className="grid grid-cols-[minmax(300px,30%)_1fr]">
|
|
333
|
+
<TreeViewV2
|
|
334
|
+
data={data}
|
|
335
|
+
getIcon={getIcon}
|
|
336
|
+
onVisibleDataChange={setVisibleData}
|
|
337
|
+
onItemHover={setHoveredId}
|
|
338
|
+
hoveredId={hoveredId}
|
|
339
|
+
/>
|
|
340
|
+
|
|
341
|
+
{/* Timeline */}
|
|
342
|
+
<div className="relative">
|
|
343
|
+
<div
|
|
344
|
+
className="border-line-secondary/20 scrollbar-hide relative cursor-crosshair overflow-x-auto border-l active:cursor-grabbing"
|
|
345
|
+
ref={timelineContainerRef}
|
|
346
|
+
>
|
|
347
|
+
{/* Time Ruler timeline */}
|
|
348
|
+
<div className="bg-bg-primary sticky top-0 z-10">
|
|
349
|
+
<div className="border-line-secondary/20 relative h-8 border-b">
|
|
350
|
+
{/* These dots on the ruler indicate the positions of key events that the cursor can snap to. */}
|
|
351
|
+
{keyEventTimes.map(({ time }, tickIndex) => {
|
|
352
|
+
const percent = timeToPercent(time);
|
|
353
|
+
if (percent < 0 || percent > 100) return null;
|
|
354
|
+
return (
|
|
355
|
+
<div
|
|
356
|
+
key={`snap-${time}-${tickIndex}`}
|
|
357
|
+
className="bg-line-secondary/20 absolute top-1/2 z-0 h-1 w-1 -translate-y-1/2 rounded-full"
|
|
358
|
+
style={{ left: `${percent}%` }}
|
|
359
|
+
/>
|
|
360
|
+
);
|
|
361
|
+
})}
|
|
362
|
+
|
|
363
|
+
{timeMarkers.map((marker, ind) => {
|
|
364
|
+
const naturalCenterPercent = timeToPercent(marker.time);
|
|
365
|
+
|
|
366
|
+
// Filter out markers that are too far off-screen to improve performance
|
|
367
|
+
if (naturalCenterPercent < -20 || naturalCenterPercent > 120) {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// This is the offset from the edge, in percent.
|
|
372
|
+
// It ensures the label doesn't sit flush against the edge.
|
|
373
|
+
const labelHalfWidthPercent = 3;
|
|
374
|
+
|
|
375
|
+
// Clamp the label's center position to keep it within the viewport
|
|
376
|
+
const clampedCenterPercent = Math.min(
|
|
377
|
+
100 - labelHalfWidthPercent,
|
|
378
|
+
Math.max(labelHalfWidthPercent, naturalCenterPercent),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const zInd = ind < timeMarkers.length - 1 ? "10" : "0";
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<React.Fragment key={marker.time}>
|
|
385
|
+
{/* The tick line, always at its natural position */}
|
|
386
|
+
<div
|
|
387
|
+
className="bg-border bg-line-secondary/20 absolute top-0 h-full w-px"
|
|
388
|
+
style={{ left: `${naturalCenterPercent}%` }}
|
|
389
|
+
/>
|
|
390
|
+
{/* The label, with its position clamped to the viewport edges */}
|
|
391
|
+
<div
|
|
392
|
+
className={cn(
|
|
393
|
+
"bg-bg-primary text-text-secondary rounded-uk-xs text-uk-sm pointer-events-none absolute top-1/2 -translate-x-1/2 -translate-y-1/2 px-1 backdrop-blur-sm",
|
|
394
|
+
"z-" + zInd,
|
|
395
|
+
)}
|
|
396
|
+
style={{ left: `${clampedCenterPercent}%` }}
|
|
397
|
+
>
|
|
398
|
+
{marker.label}
|
|
399
|
+
</div>
|
|
400
|
+
</React.Fragment>
|
|
401
|
+
);
|
|
402
|
+
})}
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
{/* Timeline Bars */}
|
|
407
|
+
<div className="relative pb-12">
|
|
408
|
+
{visibleData.map((item) => {
|
|
409
|
+
const isHaltedStep = item.isHalted;
|
|
410
|
+
const barStart = item.startTime;
|
|
411
|
+
const barEnd =
|
|
412
|
+
item.startTime !== undefined && item.duration !== undefined
|
|
413
|
+
? item.startTime + item.duration
|
|
414
|
+
: undefined;
|
|
415
|
+
|
|
416
|
+
const viewEnd = viewStart + viewDuration;
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<div
|
|
420
|
+
key={item.id}
|
|
421
|
+
className={cn(
|
|
422
|
+
"relative h-[32px]",
|
|
423
|
+
hoveredId === item.id && "bg-bg-secondary/80",
|
|
424
|
+
)}
|
|
425
|
+
onMouseEnter={() => setHoveredId(item.id)}
|
|
426
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
427
|
+
>
|
|
428
|
+
{/* Launch Wait Line */}
|
|
429
|
+
{item.createTime !== undefined &&
|
|
430
|
+
item.startTime !== undefined &&
|
|
431
|
+
item.createTime < item.startTime &&
|
|
432
|
+
item.color && (
|
|
433
|
+
<div
|
|
434
|
+
className="absolute top-1/2 h-2 -translate-y-1/2"
|
|
435
|
+
style={{
|
|
436
|
+
left: `${timeToPercent(item.createTime)}%`,
|
|
437
|
+
width: `${((item.startTime - item.createTime) / viewDuration) * 100}%`,
|
|
438
|
+
}}
|
|
439
|
+
>
|
|
440
|
+
<div
|
|
441
|
+
className={cn(
|
|
442
|
+
"absolute top-1/2 left-0 h-2 w-px -translate-y-1/2",
|
|
443
|
+
colorClasses[item.color],
|
|
444
|
+
)}
|
|
445
|
+
/>
|
|
446
|
+
<div
|
|
447
|
+
className={cn(
|
|
448
|
+
"absolute top-1/2 w-full -translate-y-1/2 border-t",
|
|
449
|
+
borderColorClasses[item.color],
|
|
450
|
+
)}
|
|
451
|
+
/>
|
|
452
|
+
<div
|
|
453
|
+
className={cn(
|
|
454
|
+
"absolute top-1/2 right-0 h-2 w-px -translate-y-1/2",
|
|
455
|
+
colorClasses[item.color],
|
|
456
|
+
)}
|
|
457
|
+
/>
|
|
458
|
+
</div>
|
|
459
|
+
)}
|
|
460
|
+
|
|
461
|
+
{/* Execution Bar */}
|
|
462
|
+
{item.startTime !== undefined &&
|
|
463
|
+
item.duration !== undefined &&
|
|
464
|
+
!isHaltedStep && (
|
|
465
|
+
<div
|
|
466
|
+
className={cn(
|
|
467
|
+
"rounded-uk-xs absolute top-1/2 flex h-5 -translate-y-1/2 items-center justify-center overflow-hidden",
|
|
468
|
+
item.color && colorClasses[item.color],
|
|
469
|
+
item.hasStripes &&
|
|
470
|
+
"bg-[repeating-linear-gradient(-45deg,transparent,transparent_4px,rgba(0,0,0,0.1)_4px,rgba(0,0,0,0.1)_8px)]",
|
|
471
|
+
)}
|
|
472
|
+
style={{
|
|
473
|
+
left: `${timeToPercent(item.startTime)}%`,
|
|
474
|
+
width: `${(item.duration / viewDuration) * 100}%`,
|
|
475
|
+
}}
|
|
476
|
+
/>
|
|
477
|
+
)}
|
|
478
|
+
|
|
479
|
+
{/* Visible Label for Execution Bar */}
|
|
480
|
+
{item.startTime !== undefined &&
|
|
481
|
+
item.duration !== undefined &&
|
|
482
|
+
!isHaltedStep &&
|
|
483
|
+
(() => {
|
|
484
|
+
const visibleStart = Math.max(barStart || 0, viewStart);
|
|
485
|
+
const visibleEnd = Math.min(barEnd || 0, viewEnd);
|
|
486
|
+
|
|
487
|
+
if (visibleEnd <= visibleStart) return null;
|
|
488
|
+
|
|
489
|
+
const visibleDuration = visibleEnd - visibleStart;
|
|
490
|
+
const visibleWidthPercent = (visibleDuration / viewDuration) * 100;
|
|
491
|
+
|
|
492
|
+
if (visibleWidthPercent < 4) return null;
|
|
493
|
+
|
|
494
|
+
return (
|
|
495
|
+
<div
|
|
496
|
+
className="pointer-events-none absolute top-1/2 flex h-5 -translate-y-1/2 items-center justify-center"
|
|
497
|
+
style={{
|
|
498
|
+
left: `${timeToPercent(visibleStart)}%`,
|
|
499
|
+
width: `${visibleWidthPercent}%`,
|
|
500
|
+
}}
|
|
501
|
+
>
|
|
502
|
+
<span
|
|
503
|
+
className={cn(
|
|
504
|
+
"text-uk-sm font-medium whitespace-nowrap",
|
|
505
|
+
item.color === "gray-light" || item.color === "gray-medium"
|
|
506
|
+
? "text-slate-600 dark:text-slate-300"
|
|
507
|
+
: "text-text-highlight",
|
|
508
|
+
)}
|
|
509
|
+
>
|
|
510
|
+
{formatDuration(item.duration)}
|
|
511
|
+
</span>
|
|
512
|
+
</div>
|
|
513
|
+
);
|
|
514
|
+
})()}
|
|
515
|
+
|
|
516
|
+
{/* Start Circle */}
|
|
517
|
+
{item.startTime !== undefined &&
|
|
518
|
+
item.duration !== undefined &&
|
|
519
|
+
!isHaltedStep &&
|
|
520
|
+
item.color && (
|
|
521
|
+
<div
|
|
522
|
+
className={cn(
|
|
523
|
+
"bg-bg-primary absolute top-1/2 z-10 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2",
|
|
524
|
+
borderColorClasses[item.color],
|
|
525
|
+
)}
|
|
526
|
+
style={{
|
|
527
|
+
left: `${timeToPercent(item.startTime)}%`,
|
|
528
|
+
}}
|
|
529
|
+
/>
|
|
530
|
+
)}
|
|
531
|
+
|
|
532
|
+
{/* Special Halted Step Visualization */}
|
|
533
|
+
{isHaltedStep &&
|
|
534
|
+
item.startTime !== undefined &&
|
|
535
|
+
item.duration !== undefined && (
|
|
536
|
+
<div
|
|
537
|
+
className="absolute top-1/2 flex h-full -translate-y-1/2 items-center"
|
|
538
|
+
style={{
|
|
539
|
+
left: `${timeToPercent(item.startTime)}%`,
|
|
540
|
+
width: `${(item.duration / viewDuration) * 100}%`,
|
|
541
|
+
}}
|
|
542
|
+
>
|
|
543
|
+
<div className="relative flex h-full w-full items-center justify-center">
|
|
544
|
+
<div className="bg-line-secondary absolute top-1/2 left-0 h-2 w-px -translate-y-1/2" />
|
|
545
|
+
<div className="border-line-secondary w-full border-t border-dashed" />
|
|
546
|
+
<div className="bg-line-secondary absolute top-1/2 right-0 h-2 w-px -translate-y-1/2" />
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
)}
|
|
550
|
+
|
|
551
|
+
{/* Halted Step Label */}
|
|
552
|
+
{isHaltedStep &&
|
|
553
|
+
item.startTime !== undefined &&
|
|
554
|
+
item.duration !== undefined &&
|
|
555
|
+
(() => {
|
|
556
|
+
const visibleStart = Math.max(barStart || 0, viewStart);
|
|
557
|
+
const visibleEnd = Math.min(barEnd || 0, viewEnd);
|
|
558
|
+
|
|
559
|
+
if (visibleEnd <= visibleStart) return null;
|
|
560
|
+
|
|
561
|
+
const visibleDuration = visibleEnd - visibleStart;
|
|
562
|
+
const visibleWidthPercent = (visibleDuration / viewDuration) * 100;
|
|
563
|
+
|
|
564
|
+
if (visibleWidthPercent < 4) return null;
|
|
565
|
+
|
|
566
|
+
return (
|
|
567
|
+
<div
|
|
568
|
+
className="pointer-events-none absolute top-1/2 flex h-5 -translate-y-1/2 items-center justify-center"
|
|
569
|
+
style={{
|
|
570
|
+
left: `${timeToPercent(visibleStart)}%`,
|
|
571
|
+
width: `${visibleWidthPercent}%`,
|
|
572
|
+
}}
|
|
573
|
+
>
|
|
574
|
+
<div className="text-text-highlight text-uk-sm flex h-5 items-center rounded-full bg-orange-500/85 px-2 font-medium whitespace-nowrap">
|
|
575
|
+
{formatDuration(item.duration)}
|
|
576
|
+
</div>
|
|
577
|
+
</div>
|
|
578
|
+
);
|
|
579
|
+
})()}
|
|
580
|
+
|
|
581
|
+
{/* Point in Time Event */}
|
|
582
|
+
{item.time !== undefined && (
|
|
583
|
+
<div
|
|
584
|
+
className="absolute top-1/2 size-4 -translate-y-1/2 rounded-full"
|
|
585
|
+
style={{
|
|
586
|
+
left: `calc(${timeToPercent(item.time)}% - 4px)`,
|
|
587
|
+
}}
|
|
588
|
+
>
|
|
589
|
+
{getIcon(item)}
|
|
590
|
+
</div>
|
|
591
|
+
)}
|
|
592
|
+
</div>
|
|
593
|
+
);
|
|
594
|
+
})}
|
|
595
|
+
</div>
|
|
596
|
+
|
|
597
|
+
{/* Left Wedges */}
|
|
598
|
+
<div className="pointer-events-none absolute top-0 left-0 z-10 h-full w-2">
|
|
599
|
+
{visibleData.map((item, index) => {
|
|
600
|
+
const barEnd =
|
|
601
|
+
item.startTime !== undefined && item.duration !== undefined
|
|
602
|
+
? item.startTime + item.duration
|
|
603
|
+
: undefined;
|
|
604
|
+
const isOffscreenLeft =
|
|
605
|
+
(barEnd !== undefined && barEnd < viewStart) ||
|
|
606
|
+
(item.time !== undefined && item.time < viewStart);
|
|
607
|
+
|
|
608
|
+
if (isOffscreenLeft && item.color) {
|
|
609
|
+
return (
|
|
610
|
+
<div
|
|
611
|
+
key={`left-wedge-${item.id}`}
|
|
612
|
+
className={cn(
|
|
613
|
+
"absolute h-0 w-0 border-y-[6px] border-r-[5px] border-y-transparent",
|
|
614
|
+
rightWedgeClasses[item.color],
|
|
615
|
+
)}
|
|
616
|
+
style={{
|
|
617
|
+
top: `${32 + index * 32 + 16}px`,
|
|
618
|
+
transform: "translateY(-50%)",
|
|
619
|
+
}}
|
|
620
|
+
/>
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
})}
|
|
625
|
+
</div>
|
|
626
|
+
|
|
627
|
+
{/* Right Wedges */}
|
|
628
|
+
<div className="pointer-events-none absolute top-0 right-0 z-10 h-full w-2">
|
|
629
|
+
{visibleData.map((item, index) => {
|
|
630
|
+
const barStart = item.startTime;
|
|
631
|
+
const viewEnd = viewStart + viewDuration;
|
|
632
|
+
const isOffscreenRight =
|
|
633
|
+
(barStart !== undefined && barStart > viewEnd) ||
|
|
634
|
+
(item.time !== undefined && item.time > viewEnd);
|
|
635
|
+
|
|
636
|
+
if (isOffscreenRight && item.color) {
|
|
637
|
+
return (
|
|
638
|
+
<div
|
|
639
|
+
key={`right-wedge-${item.id}`}
|
|
640
|
+
className={cn(
|
|
641
|
+
"absolute h-0 w-0 border-y-[6px] border-l-[5px] border-y-transparent",
|
|
642
|
+
leftWedgeClasses[item.color],
|
|
643
|
+
)}
|
|
644
|
+
style={{
|
|
645
|
+
top: `${32 + index * 32 + 16}px`,
|
|
646
|
+
transform: "translateY(-50%)",
|
|
647
|
+
}}
|
|
648
|
+
/>
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
return null;
|
|
652
|
+
})}
|
|
653
|
+
</div>
|
|
654
|
+
|
|
655
|
+
{/* Imperative Cursor and Readout */}
|
|
656
|
+
<div
|
|
657
|
+
ref={cursorContainerRef}
|
|
658
|
+
className="pointer-events-none absolute top-0 left-0 z-10 h-full w-full opacity-0 transition-opacity duration-150"
|
|
659
|
+
style={
|
|
660
|
+
{
|
|
661
|
+
"--cursor-left": "0%",
|
|
662
|
+
"--readout-text": '""',
|
|
663
|
+
"--magnet-opacity": "0",
|
|
664
|
+
} as React.CSSProperties
|
|
665
|
+
}
|
|
666
|
+
>
|
|
667
|
+
{/* Cursor Line */}
|
|
668
|
+
<div
|
|
669
|
+
className="absolute top-0 h-full w-px bg-red-500"
|
|
670
|
+
style={{ left: "var(--cursor-left)" }}
|
|
671
|
+
/>
|
|
672
|
+
|
|
673
|
+
{/* Time Readout */}
|
|
674
|
+
<div
|
|
675
|
+
className="bg-bg-primary border-line-secondary/20 rounded-uk-sm text-uk-sm absolute top-1 flex items-center justify-center border px-2 py-0.5 whitespace-nowrap shadow-lg"
|
|
676
|
+
style={{
|
|
677
|
+
left: "var(--cursor-left)",
|
|
678
|
+
transform: "translateX(-50%)",
|
|
679
|
+
minWidth: "11ch",
|
|
680
|
+
}}
|
|
681
|
+
>
|
|
682
|
+
<Magnet
|
|
683
|
+
className="text-text-secondary mr-1.5 size-3 transition-opacity"
|
|
684
|
+
style={{ opacity: "var(--magnet-opacity)" }}
|
|
685
|
+
/>
|
|
686
|
+
<span className="font-mono tabular-nums after:content-[var(--readout-text)]" />
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
</div>
|
|
690
|
+
|
|
691
|
+
{/* Timeline Controls */}
|
|
692
|
+
<div className="absolute bottom-4 left-1/2 z-20 w-max -translate-x-1/2">
|
|
693
|
+
<div className="bg-bg-primary/75 flex items-center gap-2 rounded-full p-1 shadow-lg backdrop-blur-[2px]">
|
|
694
|
+
<button
|
|
695
|
+
onClick={() => handlePan("left")}
|
|
696
|
+
className="hover:bg-bg-secondary rounded-full p-1"
|
|
697
|
+
>
|
|
698
|
+
<ChevronLeft className="size-4" />
|
|
699
|
+
</button>
|
|
700
|
+
<span className="text-uk-sm w-24 text-center font-mono">
|
|
701
|
+
{formatDuration(viewDuration)}
|
|
702
|
+
</span>
|
|
703
|
+
<button
|
|
704
|
+
onClick={() => handlePan("right")}
|
|
705
|
+
className="hover:bg-bg-secondary rounded-full p-1"
|
|
706
|
+
>
|
|
707
|
+
<ChevronRight className="size-4" />
|
|
708
|
+
</button>
|
|
709
|
+
</div>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
</div>
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export { Timeline, type LogItemData };
|