@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.
Files changed (75) hide show
  1. package/dist/assets/index-14zH9lqh.css +1 -0
  2. package/dist/assets/index-B-16fRnH.js +108 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +2965 -186
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  8. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  9. package/src/components/editor/MotionPanel.tsx +651 -0
  10. package/src/components/editor/PropertyPanel.test.ts +116 -0
  11. package/src/components/editor/PropertyPanel.tsx +2829 -205
  12. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  13. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  14. package/src/components/editor/colorValue.test.ts +82 -0
  15. package/src/components/editor/colorValue.ts +175 -0
  16. package/src/components/editor/domEditing.test.ts +1120 -0
  17. package/src/components/editor/domEditing.ts +1117 -0
  18. package/src/components/editor/floatingPanel.test.ts +34 -0
  19. package/src/components/editor/floatingPanel.ts +54 -0
  20. package/src/components/editor/fontAssets.ts +32 -0
  21. package/src/components/editor/fontCatalog.ts +126 -0
  22. package/src/components/editor/gradientValue.test.ts +89 -0
  23. package/src/components/editor/gradientValue.ts +445 -0
  24. package/src/components/editor/manualEditingAvailability.test.ts +131 -0
  25. package/src/components/editor/manualEditingAvailability.ts +62 -0
  26. package/src/components/editor/manualEdits.test.ts +945 -0
  27. package/src/components/editor/manualEdits.ts +1409 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  29. package/src/components/editor/manualOffsetDrag.ts +307 -0
  30. package/src/components/editor/studioMotion.test.ts +355 -0
  31. package/src/components/editor/studioMotion.ts +632 -0
  32. package/src/components/nle/NLELayout.test.ts +12 -0
  33. package/src/components/nle/NLELayout.tsx +84 -22
  34. package/src/components/nle/NLEPreview.tsx +56 -5
  35. package/src/components/renders/RenderQueue.tsx +24 -11
  36. package/src/components/sidebar/AssetsTab.tsx +3 -4
  37. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  38. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  39. package/src/components/sidebar/LeftSidebar.tsx +194 -179
  40. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  41. package/src/hooks/usePersistentEditHistory.ts +337 -0
  42. package/src/icons/SystemIcons.tsx +2 -0
  43. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  44. package/src/player/components/CompositionThumbnail.tsx +50 -13
  45. package/src/player/components/EditModal.tsx +5 -20
  46. package/src/player/components/Player.test.ts +58 -0
  47. package/src/player/components/Player.tsx +88 -5
  48. package/src/player/components/PlayerControls.tsx +20 -7
  49. package/src/player/components/Timeline.test.ts +20 -0
  50. package/src/player/components/Timeline.tsx +147 -40
  51. package/src/player/components/TimelineClip.test.ts +92 -0
  52. package/src/player/components/TimelineClip.tsx +241 -7
  53. package/src/player/components/timelineEditing.test.ts +16 -3
  54. package/src/player/components/timelineEditing.ts +10 -3
  55. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  56. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  57. package/src/player/store/playerStore.ts +2 -0
  58. package/src/utils/clipboard.test.ts +89 -0
  59. package/src/utils/clipboard.ts +57 -0
  60. package/src/utils/editHistory.test.ts +244 -0
  61. package/src/utils/editHistory.ts +218 -0
  62. package/src/utils/editHistoryStorage.test.ts +37 -0
  63. package/src/utils/editHistoryStorage.ts +99 -0
  64. package/src/utils/mediaTypes.ts +1 -1
  65. package/src/utils/sourcePatcher.test.ts +128 -1
  66. package/src/utils/sourcePatcher.ts +130 -18
  67. package/src/utils/studioFileHistory.test.ts +156 -0
  68. package/src/utils/studioFileHistory.ts +61 -0
  69. package/src/utils/timelineAssetDrop.test.ts +31 -11
  70. package/src/utils/timelineAssetDrop.ts +22 -2
  71. package/src/utils/timelineDiscovery.ts +1 -1
  72. package/src/utils/timelineInspector.test.ts +79 -0
  73. package/src/utils/timelineInspector.ts +116 -0
  74. package/dist/assets/index-04Mp2wOn.css +0 -1
  75. 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 = 24;
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((el: HTMLDivElement | null) => {
438
- if (roRef.current) {
439
- roRef.current.disconnect();
440
- roRef.current = null;
441
- }
442
- containerRef.current = el;
443
- if (!el) return;
444
- setViewportWidth(el.clientWidth);
445
- roRef.current = new ResizeObserver(([entry]) => {
446
- setViewportWidth(entry.contentRect.width);
447
- });
448
- roRef.current.observe(el);
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
- useMountEffect(() => {
915
- const handleKeyDown = (event: KeyboardEvent) => {
916
- if (!shouldHandleTimelineDeleteKey(event)) return;
917
- const selected = selectedElementRef.current;
918
- const onDelete = onDeleteElementRef.current;
919
- if (!selected || !onDelete || deleteInFlightRef.current) return;
920
- event.preventDefault();
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
- Promise.resolve(onDelete(selected)).finally(() => {
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
- className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
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
- setSelectedElementId(isSelected ? null : elementKey);
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 — always visible */}
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
+ });