@hyperframes/studio 0.5.7 → 0.6.0-alpha.10
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-14zH9lqh.css +1 -0
- package/dist/assets/index-B-16fRnH.js +108 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2965 -186
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +1300 -0
- package/src/components/editor/MotionPanel.tsx +651 -0
- package/src/components/editor/PropertyPanel.test.ts +116 -0
- package/src/components/editor/PropertyPanel.tsx +2829 -205
- package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
- package/src/components/editor/TimelineLayerPanel.tsx +113 -0
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +1120 -0
- package/src/components/editor/domEditing.ts +1117 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/editor/manualEditingAvailability.test.ts +131 -0
- package/src/components/editor/manualEditingAvailability.ts +62 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1409 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/editor/studioMotion.test.ts +355 -0
- package/src/components/editor/studioMotion.ts +632 -0
- package/src/components/nle/NLELayout.test.ts +12 -0
- package/src/components/nle/NLELayout.tsx +84 -22
- package/src/components/nle/NLEPreview.tsx +56 -5
- package/src/components/renders/RenderQueue.tsx +24 -11
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +194 -179
- package/src/hooks/usePersistentEditHistory.test.ts +256 -0
- package/src/hooks/usePersistentEditHistory.ts +337 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +50 -13
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.test.ts +58 -0
- package/src/player/components/Player.tsx +88 -5
- package/src/player/components/PlayerControls.tsx +20 -7
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +147 -40
- package/src/player/components/TimelineClip.test.ts +92 -0
- package/src/player/components/TimelineClip.tsx +241 -7
- package/src/player/components/timelineEditing.test.ts +16 -3
- package/src/player/components/timelineEditing.ts +10 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
- package/src/player/hooks/useTimelinePlayer.ts +287 -16
- package/src/player/store/playerStore.ts +2 -0
- package/src/utils/clipboard.test.ts +89 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/src/utils/timelineDiscovery.ts +1 -1
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-Dcw3BoVw.js +0 -93
|
@@ -28,6 +28,11 @@ import {
|
|
|
28
28
|
} from "./timelineTheme";
|
|
29
29
|
import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
30
30
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
31
|
+
import {
|
|
32
|
+
canInspectTimelineElement,
|
|
33
|
+
getTimelineElementKey,
|
|
34
|
+
isAudioTimelineElement,
|
|
35
|
+
} from "../../utils/timelineInspector";
|
|
31
36
|
|
|
32
37
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
|
33
38
|
const GUTTER = 32;
|
|
@@ -35,7 +40,7 @@ const TRACK_H = 72;
|
|
|
35
40
|
const RULER_H = 24;
|
|
36
41
|
const CLIP_Y = 3; // vertical inset inside track
|
|
37
42
|
const CLIP_HANDLE_W = 18;
|
|
38
|
-
const TIMELINE_SCROLL_BUFFER =
|
|
43
|
+
const TIMELINE_SCROLL_BUFFER = 20;
|
|
39
44
|
|
|
40
45
|
interface TrackVisualStyle extends TimelineTrackStyle {
|
|
41
46
|
icon: ReactNode;
|
|
@@ -216,6 +221,14 @@ export function getTimelineCanvasHeight(trackCount: number): number {
|
|
|
216
221
|
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
|
217
222
|
}
|
|
218
223
|
|
|
224
|
+
export function shouldShowTimelineShortcutHint(
|
|
225
|
+
scrollHeight: number,
|
|
226
|
+
clientHeight: number,
|
|
227
|
+
): boolean {
|
|
228
|
+
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
|
|
229
|
+
return scrollHeight - clientHeight <= 1;
|
|
230
|
+
}
|
|
231
|
+
|
|
219
232
|
export function shouldHandleTimelineDeleteKey(input: {
|
|
220
233
|
key: string;
|
|
221
234
|
metaKey?: boolean;
|
|
@@ -279,7 +292,6 @@ export function resolveTimelineAssetDrop(
|
|
|
279
292
|
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
|
280
293
|
};
|
|
281
294
|
}
|
|
282
|
-
|
|
283
295
|
/* ── Component ──────────────────────────────────────────────────── */
|
|
284
296
|
interface TimelineProps {
|
|
285
297
|
/** Called when user seeks via ruler/track click or playhead drag */
|
|
@@ -322,6 +334,13 @@ interface TimelineProps {
|
|
|
322
334
|
element: import("../store/playerStore").TimelineElement,
|
|
323
335
|
intent: BlockedTimelineEditIntent,
|
|
324
336
|
) => void;
|
|
337
|
+
onSelectElement?: (element: import("../store/playerStore").TimelineElement | null) => void;
|
|
338
|
+
onInspectElement?: (element: import("../store/playerStore").TimelineElement) => void;
|
|
339
|
+
inspectedElementId?: string | null;
|
|
340
|
+
layerChildCounts?: ReadonlyMap<string, number>;
|
|
341
|
+
thumbnailedElementIds?: ReadonlySet<string>;
|
|
342
|
+
onToggleElementThumbnail?: (element: import("../store/playerStore").TimelineElement) => void;
|
|
343
|
+
disabled?: boolean;
|
|
325
344
|
theme?: Partial<TimelineTheme>;
|
|
326
345
|
}
|
|
327
346
|
|
|
@@ -369,6 +388,13 @@ export const Timeline = memo(function Timeline({
|
|
|
369
388
|
onMoveElement,
|
|
370
389
|
onResizeElement,
|
|
371
390
|
onBlockedEditAttempt,
|
|
391
|
+
onSelectElement,
|
|
392
|
+
onInspectElement,
|
|
393
|
+
inspectedElementId,
|
|
394
|
+
layerChildCounts,
|
|
395
|
+
thumbnailedElementIds,
|
|
396
|
+
onToggleElementThumbnail,
|
|
397
|
+
disabled = false,
|
|
372
398
|
theme: themeOverrides,
|
|
373
399
|
}: TimelineProps = {}) {
|
|
374
400
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
@@ -388,6 +414,8 @@ export const Timeline = memo(function Timeline({
|
|
|
388
414
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
389
415
|
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
|
390
416
|
const isDragging = useRef(false);
|
|
417
|
+
const disabledRef = useRef(disabled);
|
|
418
|
+
disabledRef.current = disabled;
|
|
391
419
|
const shiftClickClipRef = useRef<{
|
|
392
420
|
element: TimelineElement;
|
|
393
421
|
anchorX: number;
|
|
@@ -418,7 +446,6 @@ export const Timeline = memo(function Timeline({
|
|
|
418
446
|
const resizingClipRef = useRef<ResizingClipState | null>(null);
|
|
419
447
|
resizingClipRef.current = resizingClip;
|
|
420
448
|
const blockedClipRef = useRef<BlockedClipState | null>(null);
|
|
421
|
-
const deleteInFlightRef = useRef(false);
|
|
422
449
|
const onMoveElementRef = useRef(onMoveElement);
|
|
423
450
|
onMoveElementRef.current = onMoveElement;
|
|
424
451
|
const onResizeElementRef = useRef(onResizeElement);
|
|
@@ -427,32 +454,66 @@ export const Timeline = memo(function Timeline({
|
|
|
427
454
|
onDeleteElementRef.current = onDeleteElement;
|
|
428
455
|
const suppressClickRef = useRef(false);
|
|
429
456
|
const [showPopover, setShowPopover] = useState(false);
|
|
457
|
+
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
|
430
458
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
431
459
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
460
|
+
const shortcutHintRafRef = useRef(0);
|
|
461
|
+
const syncShortcutHintVisibility = useCallback(() => {
|
|
462
|
+
const scroll = scrollRef.current;
|
|
463
|
+
setShowShortcutHint(
|
|
464
|
+
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
|
|
465
|
+
);
|
|
466
|
+
}, []);
|
|
467
|
+
const scheduleShortcutHintVisibilitySync = useCallback(() => {
|
|
468
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
469
|
+
shortcutHintRafRef.current = requestAnimationFrame(() => {
|
|
470
|
+
shortcutHintRafRef.current = 0;
|
|
471
|
+
syncShortcutHintVisibility();
|
|
472
|
+
});
|
|
473
|
+
}, [syncShortcutHintVisibility]);
|
|
432
474
|
|
|
433
475
|
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
|
|
434
476
|
// useMountEffect can't work here because the component returns null on first
|
|
435
477
|
// render (timelineReady=false), so containerRef.current is null when the
|
|
436
478
|
// effect fires and the ResizeObserver is never created.
|
|
437
|
-
const setContainerRef = useCallback(
|
|
438
|
-
|
|
439
|
-
roRef.current
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
479
|
+
const setContainerRef = useCallback(
|
|
480
|
+
(el: HTMLDivElement | null) => {
|
|
481
|
+
if (roRef.current) {
|
|
482
|
+
roRef.current.disconnect();
|
|
483
|
+
roRef.current = null;
|
|
484
|
+
}
|
|
485
|
+
containerRef.current = el;
|
|
486
|
+
if (!el) return;
|
|
487
|
+
setViewportWidth(el.clientWidth);
|
|
488
|
+
scheduleShortcutHintVisibilitySync();
|
|
489
|
+
roRef.current = new ResizeObserver(([entry]) => {
|
|
490
|
+
setViewportWidth(entry.contentRect.width);
|
|
491
|
+
scheduleShortcutHintVisibilitySync();
|
|
492
|
+
});
|
|
493
|
+
roRef.current.observe(el);
|
|
494
|
+
},
|
|
495
|
+
[scheduleShortcutHintVisibilitySync],
|
|
496
|
+
);
|
|
450
497
|
|
|
451
498
|
// Clean up ResizeObserver on unmount
|
|
452
499
|
useMountEffect(() => () => {
|
|
453
500
|
roRef.current?.disconnect();
|
|
501
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
454
502
|
});
|
|
455
503
|
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
if (!disabled) return;
|
|
506
|
+
stopClipDragAutoScrollRef.current();
|
|
507
|
+
isDragging.current = false;
|
|
508
|
+
isRangeSelecting.current = false;
|
|
509
|
+
blockedClipRef.current = null;
|
|
510
|
+
setDraggedClip(null);
|
|
511
|
+
setResizingClip(null);
|
|
512
|
+
setRangeSelection(null);
|
|
513
|
+
setShowPopover(false);
|
|
514
|
+
setIsDragOver(false);
|
|
515
|
+
}, [disabled]);
|
|
516
|
+
|
|
456
517
|
// Effective duration: max of store duration and the furthest element end.
|
|
457
518
|
// processTimelineMessage updates elements but not duration, so elements can
|
|
458
519
|
// extend beyond the store's duration — this ensures fit mode shows everything.
|
|
@@ -495,6 +556,7 @@ export const Timeline = memo(function Timeline({
|
|
|
495
556
|
}
|
|
496
557
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
497
558
|
}, [draggedClip, trackOrder]);
|
|
559
|
+
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
498
560
|
const selectedElement = useMemo(
|
|
499
561
|
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
500
562
|
[elements, selectedElementId],
|
|
@@ -544,7 +606,6 @@ export const Timeline = memo(function Timeline({
|
|
|
544
606
|
);
|
|
545
607
|
previousZoomModeRef.current = zoomMode;
|
|
546
608
|
}, [zoomMode]);
|
|
547
|
-
|
|
548
609
|
useMountEffect(() => {
|
|
549
610
|
const unsub = liveTime.subscribe((t) => {
|
|
550
611
|
const dur = durationRef.current;
|
|
@@ -679,6 +740,7 @@ export const Timeline = memo(function Timeline({
|
|
|
679
740
|
|
|
680
741
|
const seekFromX = useCallback(
|
|
681
742
|
(clientX: number) => {
|
|
743
|
+
if (disabledRef.current) return;
|
|
682
744
|
const el = scrollRef.current;
|
|
683
745
|
if (!el || effectiveDuration <= 0) return;
|
|
684
746
|
const rect = el.getBoundingClientRect();
|
|
@@ -736,6 +798,7 @@ export const Timeline = memo(function Timeline({
|
|
|
736
798
|
};
|
|
737
799
|
|
|
738
800
|
const handleWindowPointerMove = (e: PointerEvent) => {
|
|
801
|
+
if (disabledRef.current) return;
|
|
739
802
|
const drag = draggedClipRef.current;
|
|
740
803
|
const resize = resizingClipRef.current;
|
|
741
804
|
const blocked = blockedClipRef.current;
|
|
@@ -820,6 +883,7 @@ export const Timeline = memo(function Timeline({
|
|
|
820
883
|
|
|
821
884
|
const handleWindowPointerUp = () => {
|
|
822
885
|
stopClipDragAutoScrollRef.current();
|
|
886
|
+
if (disabledRef.current) return;
|
|
823
887
|
const resize = resizingClipRef.current;
|
|
824
888
|
if (resize) {
|
|
825
889
|
resizingClipRef.current = null;
|
|
@@ -911,30 +975,24 @@ export const Timeline = memo(function Timeline({
|
|
|
911
975
|
};
|
|
912
976
|
});
|
|
913
977
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
deleteInFlightRef.current = true;
|
|
922
|
-
suppressClickRef.current = true;
|
|
978
|
+
const prevSelectedRef = useRef(selectedElementRef.current);
|
|
979
|
+
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
|
|
980
|
+
useEffect(() => {
|
|
981
|
+
const prev = prevSelectedRef.current;
|
|
982
|
+
const curr = selectedElementRef.current;
|
|
983
|
+
prevSelectedRef.current = curr;
|
|
984
|
+
if (prev && !curr) {
|
|
923
985
|
setShowPopover(false);
|
|
924
986
|
setRangeSelection(null);
|
|
925
|
-
|
|
926
|
-
deleteInFlightRef.current = false;
|
|
927
|
-
requestAnimationFrame(() => {
|
|
928
|
-
suppressClickRef.current = false;
|
|
929
|
-
});
|
|
930
|
-
});
|
|
931
|
-
};
|
|
932
|
-
window.addEventListener("keydown", handleKeyDown);
|
|
933
|
-
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
987
|
+
}
|
|
934
988
|
});
|
|
935
989
|
|
|
936
990
|
const handlePointerDown = useCallback(
|
|
937
991
|
(e: React.PointerEvent) => {
|
|
992
|
+
if (disabledRef.current) {
|
|
993
|
+
e.preventDefault();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
938
996
|
if (e.button !== 0) return;
|
|
939
997
|
|
|
940
998
|
// Shift+click starts range selection — even on clips
|
|
@@ -966,6 +1024,7 @@ export const Timeline = memo(function Timeline({
|
|
|
966
1024
|
);
|
|
967
1025
|
const handlePointerMove = useCallback(
|
|
968
1026
|
(e: React.PointerEvent) => {
|
|
1027
|
+
if (disabledRef.current) return;
|
|
969
1028
|
if (isRangeSelecting.current) {
|
|
970
1029
|
const rect = scrollRef.current?.getBoundingClientRect();
|
|
971
1030
|
if (rect) {
|
|
@@ -1012,6 +1071,10 @@ export const Timeline = memo(function Timeline({
|
|
|
1012
1071
|
);
|
|
1013
1072
|
const majorTickInterval =
|
|
1014
1073
|
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
|
|
1074
|
+
useEffect(() => {
|
|
1075
|
+
syncShortcutHintVisibility();
|
|
1076
|
+
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
|
|
1077
|
+
|
|
1015
1078
|
const getPreviewElement = useCallback(
|
|
1016
1079
|
(element: TimelineElement): TimelineElement => {
|
|
1017
1080
|
if (
|
|
@@ -1032,6 +1095,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1032
1095
|
|
|
1033
1096
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
1034
1097
|
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
|
|
1098
|
+
if (disabledRef.current) return;
|
|
1035
1099
|
const hasFiles = e.dataTransfer.files.length > 0;
|
|
1036
1100
|
const hasAsset = Array.from(e.dataTransfer.types).includes(TIMELINE_ASSET_MIME);
|
|
1037
1101
|
if (!hasFiles && !hasAsset) return;
|
|
@@ -1046,6 +1110,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1046
1110
|
(e: React.DragEvent) => {
|
|
1047
1111
|
e.preventDefault();
|
|
1048
1112
|
setIsDragOver(false);
|
|
1113
|
+
if (disabledRef.current) return;
|
|
1049
1114
|
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
1050
1115
|
const scroll = scrollRef.current;
|
|
1051
1116
|
const rect = scroll?.getBoundingClientRect();
|
|
@@ -1102,6 +1167,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1102
1167
|
|
|
1103
1168
|
const handlePinchWheel = useCallback(
|
|
1104
1169
|
(e: WheelEvent) => {
|
|
1170
|
+
if (disabledRef.current) return;
|
|
1105
1171
|
if (!e.ctrlKey) return;
|
|
1106
1172
|
const scroll = scrollRef.current;
|
|
1107
1173
|
if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) {
|
|
@@ -1157,6 +1223,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1157
1223
|
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
|
|
1158
1224
|
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
|
|
1159
1225
|
}`}
|
|
1226
|
+
aria-disabled={disabled || undefined}
|
|
1160
1227
|
onDragOver={handleAssetDragOver}
|
|
1161
1228
|
onDragLeave={() => setIsDragOver(false)}
|
|
1162
1229
|
onDrop={handleAssetDrop}
|
|
@@ -1239,7 +1306,6 @@ export const Timeline = memo(function Timeline({
|
|
|
1239
1306
|
);
|
|
1240
1307
|
}
|
|
1241
1308
|
|
|
1242
|
-
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
1243
1309
|
const draggedElement = draggedClip?.element ?? null;
|
|
1244
1310
|
const activeDraggedElement =
|
|
1245
1311
|
draggedClip?.started === true && draggedElement
|
|
@@ -1313,7 +1379,14 @@ export const Timeline = memo(function Timeline({
|
|
|
1313
1379
|
<div
|
|
1314
1380
|
ref={setContainerRef}
|
|
1315
1381
|
aria-label="Timeline"
|
|
1316
|
-
|
|
1382
|
+
aria-disabled={disabled || undefined}
|
|
1383
|
+
className={`relative border-t select-none h-full overflow-hidden transition-opacity ${
|
|
1384
|
+
disabled
|
|
1385
|
+
? "cursor-not-allowed opacity-45"
|
|
1386
|
+
: shiftHeld
|
|
1387
|
+
? "cursor-crosshair"
|
|
1388
|
+
: "cursor-default"
|
|
1389
|
+
}`}
|
|
1317
1390
|
style={{
|
|
1318
1391
|
touchAction: "pan-x pan-y",
|
|
1319
1392
|
background: theme.shellBackground,
|
|
@@ -1451,6 +1524,14 @@ export const Timeline = memo(function Timeline({
|
|
|
1451
1524
|
const elementKey = el.key ?? el.id;
|
|
1452
1525
|
const capabilities = getTimelineEditCapabilities(el);
|
|
1453
1526
|
const isSelected = selectedElementId === elementKey;
|
|
1527
|
+
const canInspectClip = canInspectTimelineElement(el);
|
|
1528
|
+
const isInspectorActive =
|
|
1529
|
+
canInspectClip && inspectedElementId === getTimelineElementKey(el);
|
|
1530
|
+
const childCount = canInspectClip
|
|
1531
|
+
? (layerChildCounts?.get(elementKey) ?? 0)
|
|
1532
|
+
: 0;
|
|
1533
|
+
const isThumbnailActive = thumbnailedElementIds?.has(elementKey) ?? false;
|
|
1534
|
+
const thumbnailLabel = isAudioTimelineElement(el) ? "waveform" : "thumbnail";
|
|
1454
1535
|
const isComposition = !!el.compositionSrc;
|
|
1455
1536
|
const clipKey = `${elementKey}-${i}`;
|
|
1456
1537
|
const isHovered = hoveredClip === clipKey;
|
|
@@ -1474,8 +1555,32 @@ export const Timeline = memo(function Timeline({
|
|
|
1474
1555
|
theme={theme}
|
|
1475
1556
|
trackStyle={clipStyle}
|
|
1476
1557
|
isComposition={isComposition}
|
|
1558
|
+
isInspectorActive={isInspectorActive}
|
|
1559
|
+
isThumbnailActive={isThumbnailActive}
|
|
1560
|
+
thumbnailLabel={thumbnailLabel}
|
|
1561
|
+
childCount={childCount}
|
|
1477
1562
|
onHoverStart={() => setHoveredClip(clipKey)}
|
|
1478
1563
|
onHoverEnd={() => setHoveredClip(null)}
|
|
1564
|
+
onInspectorClick={
|
|
1565
|
+
canInspectClip && onInspectElement
|
|
1566
|
+
? (e) => {
|
|
1567
|
+
e.stopPropagation();
|
|
1568
|
+
if (suppressClickRef.current) return;
|
|
1569
|
+
setSelectedElementId(elementKey);
|
|
1570
|
+
onSelectElement?.(el);
|
|
1571
|
+
onInspectElement(el);
|
|
1572
|
+
}
|
|
1573
|
+
: undefined
|
|
1574
|
+
}
|
|
1575
|
+
onThumbnailClick={
|
|
1576
|
+
onToggleElementThumbnail && canInspectClip
|
|
1577
|
+
? (e) => {
|
|
1578
|
+
e.stopPropagation();
|
|
1579
|
+
if (suppressClickRef.current) return;
|
|
1580
|
+
onToggleElementThumbnail(el);
|
|
1581
|
+
}
|
|
1582
|
+
: undefined
|
|
1583
|
+
}
|
|
1479
1584
|
onResizeStart={(edge, e) => {
|
|
1480
1585
|
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
|
1481
1586
|
if (edge === "start" && !capabilities.canTrimStart) return;
|
|
@@ -1548,7 +1653,9 @@ export const Timeline = memo(function Timeline({
|
|
|
1548
1653
|
onClick={(e) => {
|
|
1549
1654
|
e.stopPropagation();
|
|
1550
1655
|
if (suppressClickRef.current) return;
|
|
1551
|
-
|
|
1656
|
+
const nextElement = isSelected ? null : el;
|
|
1657
|
+
setSelectedElementId(nextElement ? elementKey : null);
|
|
1658
|
+
onSelectElement?.(nextElement);
|
|
1552
1659
|
}}
|
|
1553
1660
|
onDoubleClick={(e) => {
|
|
1554
1661
|
e.stopPropagation();
|
|
@@ -1652,8 +1759,8 @@ export const Timeline = memo(function Timeline({
|
|
|
1652
1759
|
</div>
|
|
1653
1760
|
</div>
|
|
1654
1761
|
|
|
1655
|
-
{/* Keyboard shortcut hint
|
|
1656
|
-
{!showPopover && !rangeSelection && (
|
|
1762
|
+
{/* Keyboard shortcut hint */}
|
|
1763
|
+
{showShortcutHint && !showPopover && !rangeSelection && (
|
|
1657
1764
|
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
|
1658
1765
|
<div
|
|
1659
1766
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getTimelineClipControlPresentation } from "./TimelineClip";
|
|
3
|
+
|
|
4
|
+
describe("getTimelineClipControlPresentation", () => {
|
|
5
|
+
it("collapses persistent controls for compact clips until the clip is interactive", () => {
|
|
6
|
+
expect(
|
|
7
|
+
getTimelineClipControlPresentation({
|
|
8
|
+
widthPx: 42,
|
|
9
|
+
isHovered: false,
|
|
10
|
+
isSelected: false,
|
|
11
|
+
isInspectorActive: false,
|
|
12
|
+
isThumbnailActive: false,
|
|
13
|
+
isDragging: false,
|
|
14
|
+
}),
|
|
15
|
+
).toMatchObject({
|
|
16
|
+
compact: true,
|
|
17
|
+
showControls: false,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("shows compact controls when the clip is hovered, selected, or active", () => {
|
|
22
|
+
expect(
|
|
23
|
+
getTimelineClipControlPresentation({
|
|
24
|
+
widthPx: 42,
|
|
25
|
+
isHovered: true,
|
|
26
|
+
isSelected: false,
|
|
27
|
+
isInspectorActive: false,
|
|
28
|
+
isThumbnailActive: false,
|
|
29
|
+
isDragging: false,
|
|
30
|
+
}),
|
|
31
|
+
).toMatchObject({
|
|
32
|
+
compact: true,
|
|
33
|
+
showControls: true,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
getTimelineClipControlPresentation({
|
|
38
|
+
widthPx: 42,
|
|
39
|
+
isHovered: false,
|
|
40
|
+
isSelected: false,
|
|
41
|
+
isInspectorActive: true,
|
|
42
|
+
isThumbnailActive: false,
|
|
43
|
+
isDragging: false,
|
|
44
|
+
}).showControls,
|
|
45
|
+
).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps controls visible on wide clips", () => {
|
|
49
|
+
expect(
|
|
50
|
+
getTimelineClipControlPresentation({
|
|
51
|
+
widthPx: 120,
|
|
52
|
+
isHovered: false,
|
|
53
|
+
isSelected: false,
|
|
54
|
+
isInspectorActive: false,
|
|
55
|
+
isThumbnailActive: false,
|
|
56
|
+
isDragging: false,
|
|
57
|
+
}),
|
|
58
|
+
).toMatchObject({
|
|
59
|
+
compact: false,
|
|
60
|
+
showControls: true,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("treats medium-width clips as compact so dense tracks do not turn into icon grids", () => {
|
|
65
|
+
expect(
|
|
66
|
+
getTimelineClipControlPresentation({
|
|
67
|
+
widthPx: 96,
|
|
68
|
+
isHovered: false,
|
|
69
|
+
isSelected: false,
|
|
70
|
+
isInspectorActive: false,
|
|
71
|
+
isThumbnailActive: false,
|
|
72
|
+
isDragging: false,
|
|
73
|
+
}),
|
|
74
|
+
).toMatchObject({
|
|
75
|
+
compact: true,
|
|
76
|
+
showControls: false,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("hides controls while dragging", () => {
|
|
81
|
+
expect(
|
|
82
|
+
getTimelineClipControlPresentation({
|
|
83
|
+
widthPx: 120,
|
|
84
|
+
isHovered: true,
|
|
85
|
+
isSelected: true,
|
|
86
|
+
isInspectorActive: true,
|
|
87
|
+
isThumbnailActive: true,
|
|
88
|
+
isDragging: true,
|
|
89
|
+
}).showControls,
|
|
90
|
+
).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|