@hyperframes/studio 0.1.10 → 0.1.12
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/assets/index-BEwJNmPo.js +92 -0
- package/dist/assets/index-BnvciBdD.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +10 -4
- package/src/App.tsx +744 -271
- package/src/components/editor/FileTree.tsx +186 -32
- package/src/components/editor/SourceEditor.tsx +3 -1
- package/src/components/nle/NLELayout.tsx +125 -23
- package/src/components/renders/RenderQueue.tsx +123 -0
- package/src/components/renders/RenderQueueItem.tsx +137 -0
- package/src/components/renders/useRenderQueue.ts +193 -0
- package/src/components/sidebar/AssetsTab.tsx +360 -0
- package/src/components/sidebar/CompositionsTab.tsx +227 -0
- package/src/components/sidebar/LeftSidebar.tsx +102 -0
- package/src/components/ui/ExpandOnHover.tsx +194 -0
- package/src/hooks/useCodeEditor.ts +1 -1
- package/src/hooks/useElementPicker.ts +5 -1
- package/src/index.ts +10 -2
- package/src/player/components/AudioWaveform.tsx +168 -0
- package/src/player/components/CompositionThumbnail.tsx +140 -0
- package/src/player/components/EditModal.tsx +165 -0
- package/src/player/components/Player.tsx +6 -5
- package/src/player/components/PlayerControls.tsx +78 -39
- package/src/player/components/Timeline.test.ts +110 -0
- package/src/player/components/Timeline.tsx +537 -260
- package/src/player/components/TimelineClip.tsx +80 -0
- package/src/player/components/VideoThumbnail.tsx +196 -0
- package/src/player/hooks/useTimelinePlayer.ts +404 -112
- package/src/player/index.ts +3 -3
- package/src/player/lib/time.test.ts +57 -0
- package/src/player/lib/time.ts +1 -0
- package/src/player/store/playerStore.test.ts +265 -0
- package/src/player/store/playerStore.ts +44 -16
- package/src/utils/htmlEditor.ts +164 -0
- package/dist/assets/index-Df6fO-S6.js +0 -78
- package/dist/assets/index-KoBceNoU.css +0 -1
- package/src/player/components/AgentActivityTrack.tsx +0 -93
- package/src/player/lib/useMountEffect.ts +0 -10
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
|
|
2
2
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
3
|
-
import { useMountEffect } from "
|
|
3
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
+
import { formatTime } from "../lib/time";
|
|
5
|
+
import { TimelineClip } from "./TimelineClip";
|
|
6
|
+
import { EditPopover } from "./EditModal";
|
|
4
7
|
|
|
5
8
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
|
6
9
|
const GUTTER = 32;
|
|
7
|
-
const TRACK_H =
|
|
10
|
+
const TRACK_H = 72;
|
|
8
11
|
const RULER_H = 24;
|
|
9
|
-
const CLIP_Y =
|
|
12
|
+
const CLIP_Y = 3; // vertical inset inside track
|
|
10
13
|
|
|
11
14
|
/* ── Vibrant Color System (Figma-inspired, dark-mode adapted) ──── */
|
|
12
15
|
interface TrackStyle {
|
|
@@ -22,7 +25,7 @@ interface TrackStyle {
|
|
|
22
25
|
icon: ReactNode;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
/* ── Icons from Figma
|
|
28
|
+
/* ── Icons from Figma Motion Cut design system ── */
|
|
26
29
|
const ICON_BASE = "/icons/timeline";
|
|
27
30
|
function TimelineIcon({ src }: { src: string }) {
|
|
28
31
|
return (
|
|
@@ -124,15 +127,21 @@ function getStyle(tag: string): TrackStyle {
|
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
/* ── Tick Generation ────────────────────────────────────────────── */
|
|
127
|
-
function generateTicks(duration: number): { major: number[]; minor: number[] } {
|
|
128
|
-
if (duration <= 0
|
|
130
|
+
export function generateTicks(duration: number): { major: number[]; minor: number[] } {
|
|
131
|
+
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
|
|
132
|
+
return { major: [], minor: [] };
|
|
129
133
|
const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60];
|
|
130
134
|
const target = duration / 6;
|
|
131
135
|
const majorInterval = intervals.find((i) => i >= target) ?? 60;
|
|
132
|
-
const minorInterval = majorInterval / 2;
|
|
136
|
+
const minorInterval = Math.max(0.25, majorInterval / 2);
|
|
133
137
|
const major: number[] = [];
|
|
134
138
|
const minor: number[] = [];
|
|
135
|
-
|
|
139
|
+
const maxTicks = 500; // Safety cap to prevent infinite loop
|
|
140
|
+
for (
|
|
141
|
+
let t = 0;
|
|
142
|
+
t <= duration + 0.001 && major.length + minor.length < maxTicks;
|
|
143
|
+
t += minorInterval
|
|
144
|
+
) {
|
|
136
145
|
const rounded = Math.round(t * 100) / 100;
|
|
137
146
|
const isMajor =
|
|
138
147
|
Math.abs(rounded % majorInterval) < 0.01 ||
|
|
@@ -143,11 +152,8 @@ function generateTicks(duration: number): { major: number[]; minor: number[] } {
|
|
|
143
152
|
return { major, minor };
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const sec = Math.floor(s % 60);
|
|
149
|
-
return `${m}:${sec.toString().padStart(2, "0")}`;
|
|
150
|
-
}
|
|
155
|
+
/** @deprecated Use formatTime from '../lib/time' instead */
|
|
156
|
+
export const formatTick = formatTime;
|
|
151
157
|
|
|
152
158
|
/* ── Component ──────────────────────────────────────────────────── */
|
|
153
159
|
interface TimelineProps {
|
|
@@ -155,67 +161,250 @@ interface TimelineProps {
|
|
|
155
161
|
onSeek?: (time: number) => void;
|
|
156
162
|
/** Called when user double-clicks a composition clip to drill into it */
|
|
157
163
|
onDrillDown?: (element: import("../store/playerStore").TimelineElement) => void;
|
|
164
|
+
/** Optional custom content renderer for clips (thumbnails, waveforms, etc.) */
|
|
165
|
+
renderClipContent?: (
|
|
166
|
+
element: import("../store/playerStore").TimelineElement,
|
|
167
|
+
style: { clip: string; label: string },
|
|
168
|
+
) => ReactNode;
|
|
169
|
+
/** Optional overlay renderer for clips (e.g. badges, cursors) */
|
|
170
|
+
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
|
|
171
|
+
/** Called when files are dropped onto the empty timeline */
|
|
172
|
+
onFileDrop?: (files: File[]) => void;
|
|
173
|
+
/** Called when a clip is moved, resized, or changes track via drag */
|
|
174
|
+
onClipChange?: (
|
|
175
|
+
elementId: string,
|
|
176
|
+
updates: { start?: number; duration?: number; track?: number },
|
|
177
|
+
) => void;
|
|
158
178
|
}
|
|
159
179
|
|
|
160
|
-
export const Timeline = memo(function Timeline({
|
|
180
|
+
export const Timeline = memo(function Timeline({
|
|
181
|
+
onSeek,
|
|
182
|
+
onDrillDown,
|
|
183
|
+
renderClipContent,
|
|
184
|
+
renderClipOverlay,
|
|
185
|
+
onFileDrop,
|
|
186
|
+
}: TimelineProps = {}) {
|
|
161
187
|
const elements = usePlayerStore((s) => s.elements);
|
|
162
188
|
const duration = usePlayerStore((s) => s.duration);
|
|
163
189
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
164
190
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
165
191
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
166
|
-
const
|
|
192
|
+
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
193
|
+
const manualPps = usePlayerStore((s) => s.pixelsPerSecond);
|
|
167
194
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
168
195
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
196
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
169
197
|
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
|
170
198
|
const isDragging = useRef(false);
|
|
199
|
+
// Range selection (Shift+drag)
|
|
200
|
+
const [shiftHeld, setShiftHeld] = useState(false);
|
|
201
|
+
useMountEffect(() => {
|
|
202
|
+
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
|
|
203
|
+
const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
|
|
204
|
+
const blur = () => setShiftHeld(false);
|
|
205
|
+
window.addEventListener("keydown", down);
|
|
206
|
+
window.addEventListener("keyup", up);
|
|
207
|
+
window.addEventListener("blur", blur);
|
|
208
|
+
return () => {
|
|
209
|
+
window.removeEventListener("keydown", down);
|
|
210
|
+
window.removeEventListener("keyup", up);
|
|
211
|
+
window.removeEventListener("blur", blur);
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
const isRangeSelecting = useRef(false);
|
|
215
|
+
const rangeAnchorTime = useRef(0);
|
|
216
|
+
const [rangeSelection, setRangeSelection] = useState<{
|
|
217
|
+
start: number;
|
|
218
|
+
end: number;
|
|
219
|
+
anchorX: number;
|
|
220
|
+
anchorY: number;
|
|
221
|
+
} | null>(null);
|
|
222
|
+
const [showPopover, setShowPopover] = useState(false);
|
|
223
|
+
const [viewportWidth, setViewportWidth] = useState(0);
|
|
224
|
+
const roRef = useRef<ResizeObserver | null>(null);
|
|
225
|
+
|
|
226
|
+
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
|
|
227
|
+
// useMountEffect can't work here because the component returns null on first
|
|
228
|
+
// render (timelineReady=false), so containerRef.current is null when the
|
|
229
|
+
// effect fires and the ResizeObserver is never created.
|
|
230
|
+
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
|
231
|
+
if (roRef.current) {
|
|
232
|
+
roRef.current.disconnect();
|
|
233
|
+
roRef.current = null;
|
|
234
|
+
}
|
|
235
|
+
containerRef.current = el;
|
|
236
|
+
if (!el) return;
|
|
237
|
+
setViewportWidth(el.clientWidth);
|
|
238
|
+
roRef.current = new ResizeObserver(([entry]) => {
|
|
239
|
+
setViewportWidth(entry.contentRect.width);
|
|
240
|
+
});
|
|
241
|
+
roRef.current.observe(el);
|
|
242
|
+
}, []);
|
|
243
|
+
|
|
244
|
+
// Clean up ResizeObserver on unmount
|
|
245
|
+
useMountEffect(() => () => {
|
|
246
|
+
roRef.current?.disconnect();
|
|
247
|
+
});
|
|
171
248
|
|
|
172
|
-
|
|
173
|
-
|
|
249
|
+
// Effective duration: max of store duration and the furthest element end.
|
|
250
|
+
// processTimelineMessage updates elements but not duration, so elements can
|
|
251
|
+
// extend beyond the store's duration — this ensures fit mode shows everything.
|
|
252
|
+
const effectiveDuration = useMemo(() => {
|
|
253
|
+
const safeDur = Number.isFinite(duration) ? duration : 0;
|
|
254
|
+
if (elements.length === 0) return safeDur;
|
|
255
|
+
const maxEnd = Math.max(...elements.map((el) => el.start + el.duration));
|
|
256
|
+
const result = Math.max(safeDur, maxEnd);
|
|
257
|
+
return Number.isFinite(result) ? result : safeDur;
|
|
258
|
+
}, [elements, duration]);
|
|
259
|
+
|
|
260
|
+
// Calculate effective pixels per second
|
|
261
|
+
// In fit mode, use clientWidth (excludes scrollbar) with a small padding
|
|
262
|
+
const fitPps =
|
|
263
|
+
viewportWidth > GUTTER && effectiveDuration > 0
|
|
264
|
+
? (viewportWidth - GUTTER - 2) / effectiveDuration
|
|
265
|
+
: 100;
|
|
266
|
+
const pps = zoomMode === "fit" ? fitPps : manualPps;
|
|
267
|
+
const trackContentWidth = Math.max(0, effectiveDuration * pps);
|
|
268
|
+
|
|
269
|
+
const durationRef = useRef(effectiveDuration);
|
|
270
|
+
durationRef.current = effectiveDuration;
|
|
271
|
+
const ppsRef = useRef(pps);
|
|
272
|
+
ppsRef.current = pps;
|
|
174
273
|
useMountEffect(() => {
|
|
175
274
|
const unsub = liveTime.subscribe((t) => {
|
|
176
275
|
const dur = durationRef.current;
|
|
177
276
|
if (!playheadRef.current || dur <= 0) return;
|
|
178
|
-
const
|
|
179
|
-
playheadRef.current.style.left =
|
|
277
|
+
const px = t * ppsRef.current;
|
|
278
|
+
playheadRef.current.style.left = `${GUTTER + px}px`;
|
|
279
|
+
|
|
280
|
+
// Auto-scroll to follow playhead during playback or seeking
|
|
281
|
+
const scroll = scrollRef.current;
|
|
282
|
+
if (scroll && !isDragging.current) {
|
|
283
|
+
const playheadX = GUTTER + px;
|
|
284
|
+
const visibleRight = scroll.scrollLeft + scroll.clientWidth;
|
|
285
|
+
const visibleLeft = scroll.scrollLeft;
|
|
286
|
+
const edgeMargin = scroll.clientWidth * 0.12;
|
|
287
|
+
|
|
288
|
+
if (playheadX > visibleRight - edgeMargin) {
|
|
289
|
+
// Playhead near right edge — page forward
|
|
290
|
+
scroll.scrollLeft = playheadX - scroll.clientWidth * 0.15;
|
|
291
|
+
} else if (playheadX < visibleLeft + GUTTER) {
|
|
292
|
+
// Playhead before visible area (e.g. loop) — jump back
|
|
293
|
+
scroll.scrollLeft = Math.max(0, playheadX - GUTTER);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
180
296
|
});
|
|
181
297
|
return unsub;
|
|
182
298
|
});
|
|
183
299
|
|
|
300
|
+
const dragScrollRaf = useRef(0);
|
|
301
|
+
|
|
184
302
|
const seekFromX = useCallback(
|
|
185
303
|
(clientX: number) => {
|
|
186
|
-
const el =
|
|
187
|
-
if (!el ||
|
|
304
|
+
const el = scrollRef.current;
|
|
305
|
+
if (!el || effectiveDuration <= 0) return;
|
|
188
306
|
const rect = el.getBoundingClientRect();
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
192
|
-
const
|
|
193
|
-
const time = pct * duration;
|
|
194
|
-
// Notify liveTime for instant visual update (direct DOM, no re-render)
|
|
307
|
+
const scrollLeft = el.scrollLeft;
|
|
308
|
+
const x = clientX - rect.left + scrollLeft - GUTTER;
|
|
309
|
+
if (x < 0) return;
|
|
310
|
+
const time = Math.max(0, Math.min(effectiveDuration, x / pps));
|
|
195
311
|
liveTime.notify(time);
|
|
196
|
-
// Call parent's onSeek to actually seek the iframe/player
|
|
197
312
|
onSeek?.(time);
|
|
198
313
|
},
|
|
199
|
-
[
|
|
314
|
+
[effectiveDuration, onSeek, pps],
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// Auto-scroll the timeline when dragging the playhead near edges
|
|
318
|
+
const autoScrollDuringDrag = useCallback(
|
|
319
|
+
(clientX: number) => {
|
|
320
|
+
cancelAnimationFrame(dragScrollRaf.current);
|
|
321
|
+
const el = scrollRef.current;
|
|
322
|
+
if (!el || !isDragging.current) return;
|
|
323
|
+
const rect = el.getBoundingClientRect();
|
|
324
|
+
const edgeZone = 40;
|
|
325
|
+
const maxSpeed = 12;
|
|
326
|
+
let scrollDelta = 0;
|
|
327
|
+
|
|
328
|
+
if (clientX < rect.left + edgeZone) {
|
|
329
|
+
// Near left edge — scroll left
|
|
330
|
+
const proximity = Math.max(0, 1 - (clientX - rect.left) / edgeZone);
|
|
331
|
+
scrollDelta = -maxSpeed * proximity;
|
|
332
|
+
} else if (clientX > rect.right - edgeZone) {
|
|
333
|
+
// Near right edge — scroll right
|
|
334
|
+
const proximity = Math.max(0, 1 - (rect.right - clientX) / edgeZone);
|
|
335
|
+
scrollDelta = maxSpeed * proximity;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (scrollDelta !== 0) {
|
|
339
|
+
el.scrollLeft += scrollDelta;
|
|
340
|
+
seekFromX(clientX);
|
|
341
|
+
dragScrollRaf.current = requestAnimationFrame(() => autoScrollDuringDrag(clientX));
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
[seekFromX],
|
|
200
345
|
);
|
|
201
346
|
|
|
202
347
|
const handlePointerDown = useCallback(
|
|
203
348
|
(e: React.PointerEvent) => {
|
|
204
349
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
205
|
-
|
|
350
|
+
if (e.button !== 0) return;
|
|
206
351
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
352
|
+
|
|
353
|
+
// Shift+click starts range selection
|
|
354
|
+
if (e.shiftKey) {
|
|
355
|
+
isRangeSelecting.current = true;
|
|
356
|
+
setShowPopover(false);
|
|
357
|
+
const rect = scrollRef.current?.getBoundingClientRect();
|
|
358
|
+
if (rect) {
|
|
359
|
+
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
|
|
360
|
+
const time = Math.max(0, x / pps);
|
|
361
|
+
rangeAnchorTime.current = time;
|
|
362
|
+
setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
isDragging.current = true;
|
|
368
|
+
setRangeSelection(null);
|
|
369
|
+
setShowPopover(false);
|
|
207
370
|
seekFromX(e.clientX);
|
|
208
371
|
},
|
|
209
|
-
[seekFromX],
|
|
372
|
+
[seekFromX, pps],
|
|
210
373
|
);
|
|
211
374
|
const handlePointerMove = useCallback(
|
|
212
375
|
(e: React.PointerEvent) => {
|
|
213
|
-
if (
|
|
376
|
+
if (isRangeSelecting.current) {
|
|
377
|
+
const rect = scrollRef.current?.getBoundingClientRect();
|
|
378
|
+
if (rect) {
|
|
379
|
+
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
|
|
380
|
+
const time = Math.max(0, x / pps);
|
|
381
|
+
setRangeSelection((prev) =>
|
|
382
|
+
prev ? { ...prev, end: time, anchorX: e.clientX, anchorY: e.clientY } : null,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (!isDragging.current) return;
|
|
388
|
+
seekFromX(e.clientX);
|
|
389
|
+
autoScrollDuringDrag(e.clientX);
|
|
214
390
|
},
|
|
215
|
-
[seekFromX],
|
|
391
|
+
[seekFromX, autoScrollDuringDrag, pps],
|
|
216
392
|
);
|
|
217
393
|
const handlePointerUp = useCallback(() => {
|
|
394
|
+
if (isRangeSelecting.current) {
|
|
395
|
+
isRangeSelecting.current = false;
|
|
396
|
+
// Show popover if range is meaningful (> 0.2s)
|
|
397
|
+
setRangeSelection((prev) => {
|
|
398
|
+
if (prev && Math.abs(prev.end - prev.start) > 0.2) {
|
|
399
|
+
setShowPopover(true);
|
|
400
|
+
return prev;
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
218
406
|
isDragging.current = false;
|
|
407
|
+
cancelAnimationFrame(dragScrollRaf.current);
|
|
219
408
|
}, []);
|
|
220
409
|
|
|
221
410
|
const tracks = useMemo(() => {
|
|
@@ -237,13 +426,101 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
|
|
|
237
426
|
return map;
|
|
238
427
|
}, [tracks]);
|
|
239
428
|
|
|
240
|
-
const { major, minor } = useMemo(() => generateTicks(
|
|
429
|
+
const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
|
|
430
|
+
|
|
431
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
241
432
|
|
|
242
|
-
if (!timelineReady)
|
|
243
|
-
if (elements.length === 0) {
|
|
433
|
+
if (!timelineReady || elements.length === 0) {
|
|
244
434
|
return (
|
|
245
|
-
<div
|
|
246
|
-
|
|
435
|
+
<div
|
|
436
|
+
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
|
|
437
|
+
isDragOver ? "border-blue-500/50 bg-blue-500/[0.03]" : "border-neutral-800/50"
|
|
438
|
+
}`}
|
|
439
|
+
onDragOver={(e) => {
|
|
440
|
+
e.preventDefault();
|
|
441
|
+
setIsDragOver(true);
|
|
442
|
+
}}
|
|
443
|
+
onDragLeave={() => setIsDragOver(false)}
|
|
444
|
+
onDrop={(e) => {
|
|
445
|
+
e.preventDefault();
|
|
446
|
+
setIsDragOver(false);
|
|
447
|
+
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
448
|
+
onFileDrop(Array.from(e.dataTransfer.files));
|
|
449
|
+
}
|
|
450
|
+
}}
|
|
451
|
+
>
|
|
452
|
+
{/* Ruler */}
|
|
453
|
+
<div
|
|
454
|
+
className="flex-shrink-0 border-b border-neutral-800/40 flex items-end relative"
|
|
455
|
+
style={{ height: RULER_H, paddingLeft: GUTTER }}
|
|
456
|
+
>
|
|
457
|
+
{[0, 10, 20, 30, 40, 50].map((s) => (
|
|
458
|
+
<div
|
|
459
|
+
key={s}
|
|
460
|
+
className="flex flex-col items-center"
|
|
461
|
+
style={{ position: "absolute", left: GUTTER + s * 14 }}
|
|
462
|
+
>
|
|
463
|
+
<span className="text-[9px] text-neutral-600 font-mono tabular-nums leading-none mb-0.5">
|
|
464
|
+
{`${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`}
|
|
465
|
+
</span>
|
|
466
|
+
<div className="w-px h-[5px] bg-neutral-700/40" />
|
|
467
|
+
</div>
|
|
468
|
+
))}
|
|
469
|
+
</div>
|
|
470
|
+
{/* Empty drop zone */}
|
|
471
|
+
<div className="flex-1 flex items-center justify-center">
|
|
472
|
+
<div
|
|
473
|
+
className={`flex items-center gap-3 px-6 py-3 border border-dashed rounded-lg transition-colors duration-150 ${
|
|
474
|
+
isDragOver ? "border-blue-400/60 bg-blue-500/[0.06]" : "border-neutral-700/50"
|
|
475
|
+
}`}
|
|
476
|
+
>
|
|
477
|
+
{isDragOver ? (
|
|
478
|
+
<>
|
|
479
|
+
<svg
|
|
480
|
+
width="18"
|
|
481
|
+
height="18"
|
|
482
|
+
viewBox="0 0 24 24"
|
|
483
|
+
fill="none"
|
|
484
|
+
stroke="currentColor"
|
|
485
|
+
strokeWidth="1.5"
|
|
486
|
+
strokeLinecap="round"
|
|
487
|
+
strokeLinejoin="round"
|
|
488
|
+
className="text-blue-400 flex-shrink-0"
|
|
489
|
+
>
|
|
490
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
491
|
+
<polyline points="7 10 12 15 17 10" />
|
|
492
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
493
|
+
</svg>
|
|
494
|
+
<span className="text-[13px] text-blue-400">Drop media files to import</span>
|
|
495
|
+
</>
|
|
496
|
+
) : (
|
|
497
|
+
<>
|
|
498
|
+
<svg
|
|
499
|
+
width="18"
|
|
500
|
+
height="18"
|
|
501
|
+
viewBox="0 0 24 24"
|
|
502
|
+
fill="none"
|
|
503
|
+
stroke="currentColor"
|
|
504
|
+
strokeWidth="1.5"
|
|
505
|
+
strokeLinecap="round"
|
|
506
|
+
strokeLinejoin="round"
|
|
507
|
+
className="text-neutral-600 flex-shrink-0"
|
|
508
|
+
>
|
|
509
|
+
<rect x="2" y="2" width="20" height="20" rx="2" />
|
|
510
|
+
<path d="M7 2v20" />
|
|
511
|
+
<path d="M17 2v20" />
|
|
512
|
+
<path d="M2 7h20" />
|
|
513
|
+
<path d="M2 17h20" />
|
|
514
|
+
</svg>
|
|
515
|
+
<span className="text-[13px] text-neutral-500">
|
|
516
|
+
{onFileDrop
|
|
517
|
+
? "Drop media here or describe your video to start"
|
|
518
|
+
: "Describe your video to start creating"}
|
|
519
|
+
</span>
|
|
520
|
+
</>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
247
524
|
</div>
|
|
248
525
|
);
|
|
249
526
|
}
|
|
@@ -252,249 +529,249 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
|
|
|
252
529
|
|
|
253
530
|
return (
|
|
254
531
|
<div
|
|
255
|
-
ref={
|
|
532
|
+
ref={setContainerRef}
|
|
256
533
|
aria-label="Timeline"
|
|
257
|
-
className=
|
|
258
|
-
style={{ touchAction: "
|
|
259
|
-
onPointerDown={handlePointerDown}
|
|
260
|
-
onPointerMove={handlePointerMove}
|
|
261
|
-
onPointerUp={handlePointerUp}
|
|
534
|
+
className={`border-t border-neutral-800/50 bg-[#0a0a0b] select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
535
|
+
style={{ touchAction: "pan-x pan-y" }}
|
|
262
536
|
>
|
|
263
|
-
<div
|
|
264
|
-
{
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
537
|
+
<div
|
|
538
|
+
ref={scrollRef}
|
|
539
|
+
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full`}
|
|
540
|
+
onPointerDown={handlePointerDown}
|
|
541
|
+
onPointerMove={handlePointerMove}
|
|
542
|
+
onPointerUp={handlePointerUp}
|
|
543
|
+
onLostPointerCapture={handlePointerUp}
|
|
544
|
+
>
|
|
545
|
+
<div className="relative" style={{ height: totalH, width: GUTTER + trackContentWidth }}>
|
|
546
|
+
{/* Grid lines */}
|
|
547
|
+
<svg
|
|
548
|
+
className="absolute pointer-events-none"
|
|
549
|
+
style={{ left: GUTTER, width: trackContentWidth }}
|
|
550
|
+
height={totalH}
|
|
551
|
+
>
|
|
552
|
+
{major.map((t) => {
|
|
553
|
+
const x = t * pps;
|
|
554
|
+
return (
|
|
555
|
+
<line
|
|
556
|
+
key={`g-${t}`}
|
|
557
|
+
x1={x}
|
|
558
|
+
y1={RULER_H}
|
|
559
|
+
x2={x}
|
|
560
|
+
y2={totalH}
|
|
561
|
+
stroke="rgba(255,255,255,0.035)"
|
|
562
|
+
strokeWidth="1"
|
|
563
|
+
/>
|
|
564
|
+
);
|
|
565
|
+
})}
|
|
566
|
+
</svg>
|
|
283
567
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
568
|
+
{/* Ruler */}
|
|
569
|
+
<div
|
|
570
|
+
className="relative border-b border-neutral-800/40 overflow-hidden"
|
|
571
|
+
style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
|
|
572
|
+
>
|
|
573
|
+
{/* Shift hint */}
|
|
574
|
+
{shiftHeld && !rangeSelection && (
|
|
575
|
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
|
576
|
+
<span className="text-[9px] text-blue-400/60 font-medium">
|
|
577
|
+
Drag to select range
|
|
578
|
+
</span>
|
|
579
|
+
</div>
|
|
580
|
+
)}
|
|
581
|
+
{minor.map((t) => (
|
|
582
|
+
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
|
|
583
|
+
<div className="w-px h-[3px] bg-neutral-700/40" />
|
|
584
|
+
</div>
|
|
585
|
+
))}
|
|
586
|
+
{major.map((t) => (
|
|
587
|
+
<div
|
|
588
|
+
key={`M-${t}`}
|
|
589
|
+
className="absolute bottom-0 flex flex-col items-center"
|
|
590
|
+
style={{ left: t * pps }}
|
|
591
|
+
>
|
|
592
|
+
<span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">
|
|
593
|
+
{formatTime(t)}
|
|
594
|
+
</span>
|
|
595
|
+
<div className="w-px h-[5px] bg-neutral-600/60" />
|
|
596
|
+
</div>
|
|
597
|
+
))}
|
|
598
|
+
</div>
|
|
311
599
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
<div
|
|
317
|
-
key={trackNum}
|
|
318
|
-
className="relative flex"
|
|
319
|
-
style={{ height: TRACK_H, backgroundColor: ts.row }}
|
|
320
|
-
>
|
|
321
|
-
{/* Gutter: colored icon badge (Figma HyperFrames style) */}
|
|
600
|
+
{/* Tracks */}
|
|
601
|
+
{tracks.map(([trackNum, els]) => {
|
|
602
|
+
const ts = trackStyles.get(trackNum) ?? DEFAULT;
|
|
603
|
+
return (
|
|
322
604
|
<div
|
|
323
|
-
|
|
324
|
-
|
|
605
|
+
key={trackNum}
|
|
606
|
+
className="relative flex"
|
|
607
|
+
style={{ height: TRACK_H, backgroundColor: ts.row }}
|
|
325
608
|
>
|
|
609
|
+
{/* Gutter: colored icon badge (Figma Motion Cut style) */}
|
|
326
610
|
<div
|
|
327
|
-
className="flex items-center justify-center"
|
|
328
|
-
style={{
|
|
329
|
-
width: 20,
|
|
330
|
-
height: 20,
|
|
331
|
-
borderRadius: 6,
|
|
332
|
-
backgroundColor: ts.gutter,
|
|
333
|
-
border: "1px solid rgba(255,255,255,0.35)",
|
|
334
|
-
color: "#fff",
|
|
335
|
-
}}
|
|
611
|
+
className="flex-shrink-0 flex items-center justify-center"
|
|
612
|
+
style={{ width: GUTTER }}
|
|
336
613
|
>
|
|
337
|
-
|
|
614
|
+
<div
|
|
615
|
+
className="flex items-center justify-center"
|
|
616
|
+
style={{
|
|
617
|
+
width: 20,
|
|
618
|
+
height: 20,
|
|
619
|
+
borderRadius: 6,
|
|
620
|
+
backgroundColor: ts.gutter,
|
|
621
|
+
border: "1px solid rgba(255,255,255,0.35)",
|
|
622
|
+
color: "#fff",
|
|
623
|
+
}}
|
|
624
|
+
>
|
|
625
|
+
{ts.icon}
|
|
626
|
+
</div>
|
|
338
627
|
</div>
|
|
339
|
-
</div>
|
|
340
628
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
:
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
: isBeingEdited
|
|
375
|
-
? `0 0 0 1px ${activeEdit.agentColor}80, 0 0 8px ${activeEdit.agentColor}40`
|
|
376
|
-
: isHovered
|
|
377
|
-
? "0 1px 4px rgba(0,0,0,0.3)"
|
|
378
|
-
: "none",
|
|
379
|
-
cursor: "pointer",
|
|
380
|
-
transition: "border-color 120ms, box-shadow 120ms, transform 80ms",
|
|
381
|
-
transform: isHovered && !isSelected ? "scaleY(1.04)" : "scaleY(1)",
|
|
382
|
-
zIndex: isSelected ? 10 : isHovered ? 5 : 1,
|
|
383
|
-
}}
|
|
384
|
-
title={
|
|
385
|
-
isComposition
|
|
386
|
-
? `${el.compositionSrc} \u2022 Double-click to open`
|
|
387
|
-
: `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
|
|
388
|
-
}
|
|
389
|
-
onPointerEnter={() => setHoveredClip(clipKey)}
|
|
390
|
-
onPointerLeave={() => setHoveredClip(null)}
|
|
391
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
392
|
-
onClick={(e) => {
|
|
393
|
-
e.stopPropagation();
|
|
394
|
-
setSelectedElementId(isSelected ? null : el.id);
|
|
395
|
-
}}
|
|
396
|
-
onDoubleClick={(e) => {
|
|
397
|
-
e.stopPropagation();
|
|
398
|
-
if (isComposition && onDrillDown) {
|
|
399
|
-
onDrillDown(el);
|
|
400
|
-
}
|
|
401
|
-
}}
|
|
402
|
-
>
|
|
403
|
-
{/* Agent ownership dot */}
|
|
404
|
-
{el.agentColor && (
|
|
405
|
-
<div
|
|
406
|
-
className="flex-shrink-0 w-1.5 h-1.5 rounded-full ml-1"
|
|
407
|
-
style={{ backgroundColor: el.agentColor }}
|
|
408
|
-
title={el.agentId ? `Agent: ${el.agentId}` : undefined}
|
|
409
|
-
/>
|
|
410
|
-
)}
|
|
411
|
-
{/* Editing glow pulse */}
|
|
412
|
-
{/* Agent editing indicator — cursor on the clip */}
|
|
413
|
-
{isBeingEdited && (
|
|
414
|
-
<>
|
|
415
|
-
<div
|
|
416
|
-
className="absolute inset-0 rounded-[5px] animate-pulse pointer-events-none"
|
|
417
|
-
style={{ boxShadow: `inset 0 0 0 1px ${activeEdit.agentColor}60` }}
|
|
418
|
-
/>
|
|
419
|
-
{/* Agent name badge above clip */}
|
|
420
|
-
<div
|
|
421
|
-
className="absolute pointer-events-none flex items-center gap-1"
|
|
422
|
-
style={{
|
|
423
|
-
top: -16,
|
|
424
|
-
left: 2,
|
|
425
|
-
zIndex: 30,
|
|
426
|
-
}}
|
|
427
|
-
>
|
|
428
|
-
{/* Mini cursor arrow */}
|
|
429
|
-
<svg
|
|
430
|
-
width="8"
|
|
431
|
-
height="10"
|
|
432
|
-
viewBox="0 0 12 16"
|
|
433
|
-
fill="none"
|
|
434
|
-
style={{ flexShrink: 0 }}
|
|
435
|
-
>
|
|
436
|
-
<path
|
|
437
|
-
d="M1 1L11 7L6 8L4 14L1 1Z"
|
|
438
|
-
fill={activeEdit.agentColor}
|
|
439
|
-
stroke="white"
|
|
440
|
-
strokeWidth="0.8"
|
|
441
|
-
/>
|
|
442
|
-
</svg>
|
|
443
|
-
<span
|
|
444
|
-
className="text-[8px] font-semibold px-1 py-px rounded whitespace-nowrap"
|
|
445
|
-
style={{
|
|
446
|
-
backgroundColor: activeEdit.agentColor,
|
|
447
|
-
color: "white",
|
|
448
|
-
boxShadow: `0 1px 4px ${activeEdit.agentColor}40`,
|
|
449
|
-
}}
|
|
450
|
-
>
|
|
451
|
-
{activeEdit.agentId}
|
|
452
|
-
</span>
|
|
453
|
-
</div>
|
|
454
|
-
</>
|
|
455
|
-
)}
|
|
456
|
-
<span
|
|
457
|
-
className="text-[10px] font-semibold truncate px-1.5 leading-none"
|
|
458
|
-
style={{ color: style.label }}
|
|
629
|
+
{/* Clips */}
|
|
630
|
+
<div style={{ width: trackContentWidth }} className="relative">
|
|
631
|
+
{els.map((el, i) => {
|
|
632
|
+
const clipStyle = getStyle(el.tag);
|
|
633
|
+
const isSelected = selectedElementId === el.id;
|
|
634
|
+
const isComposition = !!el.compositionSrc;
|
|
635
|
+
const clipKey = `${el.id}-${i}`;
|
|
636
|
+
const isHovered = hoveredClip === clipKey;
|
|
637
|
+
const hasCustomContent = !!renderClipContent;
|
|
638
|
+
const clipWidthPx = Math.max(el.duration * pps, 4);
|
|
639
|
+
|
|
640
|
+
return (
|
|
641
|
+
<TimelineClip
|
|
642
|
+
key={clipKey}
|
|
643
|
+
el={el}
|
|
644
|
+
pps={pps}
|
|
645
|
+
trackH={TRACK_H}
|
|
646
|
+
clipY={CLIP_Y}
|
|
647
|
+
isSelected={isSelected}
|
|
648
|
+
isHovered={isHovered}
|
|
649
|
+
hasCustomContent={hasCustomContent}
|
|
650
|
+
style={clipStyle}
|
|
651
|
+
isComposition={isComposition}
|
|
652
|
+
onHoverStart={() => setHoveredClip(clipKey)}
|
|
653
|
+
onHoverEnd={() => setHoveredClip(null)}
|
|
654
|
+
onClick={(e) => {
|
|
655
|
+
e.stopPropagation();
|
|
656
|
+
setSelectedElementId(isSelected ? null : el.id);
|
|
657
|
+
}}
|
|
658
|
+
onDoubleClick={(e) => {
|
|
659
|
+
e.stopPropagation();
|
|
660
|
+
if (isComposition && onDrillDown) onDrillDown(el);
|
|
661
|
+
}}
|
|
459
662
|
>
|
|
460
|
-
{el
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
663
|
+
{renderClipOverlay?.(el)}
|
|
664
|
+
<div
|
|
665
|
+
className={
|
|
666
|
+
renderClipContent
|
|
667
|
+
? "absolute inset-0 overflow-hidden rounded-[4px]"
|
|
668
|
+
: "flex items-center overflow-hidden flex-1 min-w-0"
|
|
669
|
+
}
|
|
466
670
|
>
|
|
467
|
-
{el
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
671
|
+
{renderClipContent?.(el, clipStyle) ?? (
|
|
672
|
+
<>
|
|
673
|
+
<span
|
|
674
|
+
className="text-[10px] font-semibold truncate px-1.5 leading-none"
|
|
675
|
+
style={{ color: clipStyle.label }}
|
|
676
|
+
>
|
|
677
|
+
{el.id || el.tag}
|
|
678
|
+
</span>
|
|
679
|
+
{clipWidthPx > 60 && (
|
|
680
|
+
<span
|
|
681
|
+
className="text-[9px] font-mono tabular-nums pr-1.5 ml-auto flex-shrink-0 leading-none opacity-70"
|
|
682
|
+
style={{ color: clipStyle.label }}
|
|
683
|
+
>
|
|
684
|
+
{el.duration.toFixed(1)}s
|
|
685
|
+
</span>
|
|
686
|
+
)}
|
|
687
|
+
</>
|
|
688
|
+
)}
|
|
689
|
+
</div>
|
|
690
|
+
</TimelineClip>
|
|
691
|
+
);
|
|
692
|
+
})}
|
|
693
|
+
</div>
|
|
473
694
|
</div>
|
|
474
|
-
|
|
475
|
-
)
|
|
476
|
-
})}
|
|
695
|
+
);
|
|
696
|
+
})}
|
|
477
697
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
698
|
+
{/* Range selection highlight */}
|
|
699
|
+
{rangeSelection && (
|
|
700
|
+
<div
|
|
701
|
+
className="absolute pointer-events-none"
|
|
702
|
+
style={{
|
|
703
|
+
left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
|
|
704
|
+
width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
|
|
705
|
+
top: RULER_H,
|
|
706
|
+
bottom: 0,
|
|
707
|
+
backgroundColor: "rgba(59, 130, 246, 0.12)",
|
|
708
|
+
borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
|
|
709
|
+
borderRight: "1px solid rgba(59, 130, 246, 0.4)",
|
|
710
|
+
zIndex: 50,
|
|
711
|
+
}}
|
|
712
|
+
/>
|
|
713
|
+
)}
|
|
714
|
+
|
|
715
|
+
{/* Playhead — z-[100] to stay above all clips (which use z-1 to z-10) */}
|
|
716
|
+
<div
|
|
717
|
+
ref={playheadRef}
|
|
718
|
+
className="absolute top-0 bottom-0 pointer-events-none"
|
|
719
|
+
style={{ left: `${GUTTER}px`, zIndex: 100 }}
|
|
720
|
+
>
|
|
486
721
|
<div
|
|
722
|
+
className="absolute top-0 bottom-0"
|
|
487
723
|
style={{
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
724
|
+
left: "50%",
|
|
725
|
+
width: 2,
|
|
726
|
+
marginLeft: -1,
|
|
727
|
+
background: "var(--hf-accent, #3CE6AC)",
|
|
728
|
+
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
|
|
493
729
|
}}
|
|
494
730
|
/>
|
|
731
|
+
<div
|
|
732
|
+
className="absolute"
|
|
733
|
+
style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
|
|
734
|
+
>
|
|
735
|
+
<div
|
|
736
|
+
style={{
|
|
737
|
+
width: 0,
|
|
738
|
+
height: 0,
|
|
739
|
+
borderLeft: "6px solid transparent",
|
|
740
|
+
borderRight: "6px solid transparent",
|
|
741
|
+
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
|
|
742
|
+
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
|
743
|
+
}}
|
|
744
|
+
/>
|
|
745
|
+
</div>
|
|
495
746
|
</div>
|
|
496
747
|
</div>
|
|
497
748
|
</div>
|
|
749
|
+
|
|
750
|
+
{/* Keyboard shortcut hint — always visible */}
|
|
751
|
+
{!showPopover && !rangeSelection && (
|
|
752
|
+
<div className="absolute bottom-2 right-3 pointer-events-none">
|
|
753
|
+
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-neutral-800/50 border border-neutral-700/20">
|
|
754
|
+
<kbd className="text-[9px] font-mono text-neutral-500 bg-neutral-700/40 px-1 py-0.5 rounded">
|
|
755
|
+
Shift
|
|
756
|
+
</kbd>
|
|
757
|
+
<span className="text-[9px] text-neutral-600">+ drag to edit range</span>
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
760
|
+
)}
|
|
761
|
+
|
|
762
|
+
{/* Edit range popover */}
|
|
763
|
+
{showPopover && rangeSelection && (
|
|
764
|
+
<EditPopover
|
|
765
|
+
rangeStart={rangeSelection.start}
|
|
766
|
+
rangeEnd={rangeSelection.end}
|
|
767
|
+
anchorX={rangeSelection.anchorX}
|
|
768
|
+
anchorY={rangeSelection.anchorY}
|
|
769
|
+
onClose={() => {
|
|
770
|
+
setShowPopover(false);
|
|
771
|
+
setRangeSelection(null);
|
|
772
|
+
}}
|
|
773
|
+
/>
|
|
774
|
+
)}
|
|
498
775
|
</div>
|
|
499
776
|
);
|
|
500
777
|
});
|