@hyperframes/studio 0.7.46 → 0.7.48

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 (110) hide show
  1. package/dist/assets/{index-DO2MAFSG.js → index-CWHON2kh.js} +1 -1
  2. package/dist/assets/index-DX02Q1Yl.js +423 -0
  3. package/dist/assets/index-Dq7FEg0K.css +1 -0
  4. package/dist/assets/{index-BGVoOXdg.js → index-RTqYPUN7.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-BsLQ2qGn.js +0 -420
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -102,4 +102,15 @@ describe("TimelineClip", () => {
102
102
 
103
103
  act(() => root.unmount());
104
104
  });
105
+
106
+ it("applies selected styling when rendered as selected", () => {
107
+ const { host, root } = renderClip({
108
+ element: { id: "selected", label: "Selected", tag: "div", start: 0, duration: 1, track: 0 },
109
+ isSelected: true,
110
+ });
111
+
112
+ expect(host.querySelector(".timeline-clip")?.classList.contains("is-selected")).toBe(true);
113
+
114
+ act(() => root.unmount());
115
+ });
105
116
  });
@@ -1,10 +1,12 @@
1
1
  import { memo, useRef, useState } from "react";
2
2
  import { BEAT_BAND_H } from "./BeatStrip";
3
3
  import {
4
+ clampToNeighbors,
4
5
  KEYFRAME_DRAG_THRESHOLD_PX,
5
6
  previewClipPct,
6
7
  resolveKeyframeDrag,
7
8
  } from "../../components/editor/keyframeDrag";
9
+ import { snapKeyframePctToBeat } from "./timelineEditing";
8
10
 
9
11
  interface KeyframeEntry {
10
12
  percentage: number;
@@ -28,6 +30,14 @@ interface TimelineClipDiamondsProps {
28
30
  /** Beat-dot strip is shown on this track → shrink diamonds + drop them into
29
31
  * the bottom half so they clear the strip at the top. */
30
32
  beatsActive?: boolean;
33
+ /** Composition-time beat positions (same source the beat strip renders from).
34
+ * When present and `beatsActive`, a dragged keyframe snaps to the nearest beat. */
35
+ beatTimes?: number[];
36
+ /** Clip start / duration (seconds) + pixels-per-second, needed to map a
37
+ * dragged keyframe's clip-% to composition time for beat snapping. */
38
+ clipStart?: number;
39
+ clipDurationSeconds?: number;
40
+ pixelsPerSecond?: number;
31
41
  accentColor: string;
32
42
  isSelected: boolean;
33
43
  currentPercentage: number;
@@ -71,6 +81,10 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
71
81
  clipWidthPx,
72
82
  clipHeightPx,
73
83
  beatsActive,
84
+ beatTimes,
85
+ clipStart = 0,
86
+ clipDurationSeconds = 0,
87
+ pixelsPerSecond = 1,
74
88
  accentColor,
75
89
  isSelected,
76
90
  currentPercentage,
@@ -121,6 +135,20 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
121
135
  const baseOpacity = isSelected ? 0.4 : 0.25;
122
136
  const canDrag = isSelected && !!onMoveKeyframe;
123
137
 
138
+ // Snap a dragged keyframe's clip-% to the nearest beat (within ~8px), then
139
+ // re-clamp to neighbours so the snap can't cross a sibling keyframe. No-op
140
+ // when the beat strip isn't active for this track or no beats are loaded.
141
+ const snapClipPctToBeat = (clipPct: number, draggedIndex: number): number => {
142
+ if (!beatsActive || !beatTimes || beatTimes.length === 0) return clipPct;
143
+ const snapped = snapKeyframePctToBeat(
144
+ { start: clipStart, duration: clipDurationSeconds },
145
+ clipPct,
146
+ beatTimes,
147
+ pixelsPerSecond,
148
+ );
149
+ return clampToNeighbors(snapped, sortedClipPcts, draggedIndex);
150
+ };
151
+
124
152
  return (
125
153
  <div
126
154
  className="absolute inset-0"
@@ -195,14 +223,17 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
195
223
  if (d.moved) {
196
224
  setPreview({
197
225
  kfKey,
198
- clipPct: previewClipPct({
199
- pointerDownX: d.startX,
200
- pointerMoveX: e.clientX,
201
- clipWidthPx,
202
- draggedClipPct: d.fromClipPct,
203
- draggedIndex: i,
204
- sortedClipPcts,
205
- }),
226
+ clipPct: snapClipPctToBeat(
227
+ previewClipPct({
228
+ pointerDownX: d.startX,
229
+ pointerMoveX: e.clientX,
230
+ clipWidthPx,
231
+ draggedClipPct: d.fromClipPct,
232
+ draggedIndex: i,
233
+ sortedClipPcts,
234
+ }),
235
+ i,
236
+ ),
206
237
  });
207
238
  }
208
239
  };
@@ -238,7 +269,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
238
269
  if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
239
270
  else onClickKeyframe?.(kf.percentage);
240
271
  } else if (res.kind === "move" && res.toClipPct != null) {
241
- onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
272
+ onMoveKeyframe?.(elementId, d.fromClipPct, snapClipPctToBeat(res.toClipPct, i));
242
273
  // A retime still targeted this exact diamond — park/select it at its
243
274
  // new position, same as a plain click, or a drag that actually moved
244
275
  // something looks identical to one that silently did nothing.
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from "react";
2
+ import { TimelineClip } from "./TimelineClip";
3
+ import { getTimelineEditCapabilities } from "./timelineEditing";
4
+ import { CLIP_Y, TRACK_H } from "./timelineLayout";
5
+ import type { TimelineTheme } from "./timelineTheme";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+
8
+ interface TimelineDragGhostProps {
9
+ element: TimelineElement;
10
+ position: { left: number; top: number };
11
+ pps: number;
12
+ selectedElementId: string | null;
13
+ hasCustomContent: boolean;
14
+ theme: TimelineTheme;
15
+ children: ReactNode;
16
+ }
17
+
18
+ export function TimelineDragGhost({
19
+ element,
20
+ position,
21
+ pps,
22
+ selectedElementId,
23
+ hasCustomContent,
24
+ theme,
25
+ children,
26
+ }: TimelineDragGhostProps) {
27
+ return (
28
+ <div
29
+ className="absolute pointer-events-none"
30
+ style={{
31
+ top: position.top,
32
+ left: position.left,
33
+ width: Math.max(element.duration * pps, 4),
34
+ height: TRACK_H - CLIP_Y * 2,
35
+ zIndex: 40,
36
+ }}
37
+ >
38
+ <TimelineClip
39
+ el={{ ...element, start: 0 }}
40
+ pps={pps}
41
+ clipY={0}
42
+ isSelected={selectedElementId === (element.key ?? element.id)}
43
+ isHovered={false}
44
+ isDragging={true}
45
+ hasCustomContent={hasCustomContent}
46
+ capabilities={getTimelineEditCapabilities(element)}
47
+ theme={theme}
48
+ isComposition={!!element.compositionSrc}
49
+ onHoverStart={() => {}}
50
+ onHoverEnd={() => {}}
51
+ onResizeStart={() => {}}
52
+ onClick={() => {}}
53
+ onDoubleClick={() => {}}
54
+ >
55
+ {children}
56
+ </TimelineClip>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,32 @@
1
+ interface TimelineDropInsertionLineProps {
2
+ edge: "top" | "bottom";
3
+ accentColor: string;
4
+ }
5
+
6
+ export function TimelineDropInsertionLine({ edge, accentColor }: TimelineDropInsertionLineProps) {
7
+ return (
8
+ <div
9
+ className="absolute left-0 right-0 pointer-events-none"
10
+ style={{
11
+ top: edge === "top" ? -1 : undefined,
12
+ bottom: edge === "bottom" ? -1 : undefined,
13
+ height: 2,
14
+ background: accentColor,
15
+ boxShadow: `0 0 8px ${accentColor}`,
16
+ zIndex: 30,
17
+ }}
18
+ >
19
+ <span
20
+ className="absolute rounded-full"
21
+ style={{
22
+ left: 0,
23
+ top: -3,
24
+ width: 8,
25
+ height: 8,
26
+ background: accentColor,
27
+ boxShadow: `0 0 8px ${accentColor}`,
28
+ }}
29
+ />
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,97 @@
1
+ import type { TimelineTheme } from "./timelineTheme";
2
+ import { GUTTER } from "./timelineLayout";
3
+ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
4
+
5
+ export const TIMELINE_LAYER_GROUP_HEADER_H = 18;
6
+
7
+ export function shouldShowTimelineLayerGroupHeader(
8
+ contextKey: string,
9
+ previousContextKey: string,
10
+ ): boolean {
11
+ return contextKey !== "" && contextKey !== previousContextKey;
12
+ }
13
+
14
+ export function getTimelineLayerGroupHeaderTotalHeight(
15
+ layerOrder: readonly TimelineLayerId[],
16
+ layers: readonly StackingTimelineLayer[],
17
+ ): number {
18
+ const layerById = new Map<TimelineLayerId, StackingTimelineLayer>();
19
+ for (const layer of layers) layerById.set(layer.id, layer);
20
+
21
+ let previousContextKey = "";
22
+ let count = 0;
23
+ for (const layerId of layerOrder) {
24
+ const contextKey = layerById.get(layerId)?.contextKey ?? "";
25
+ if (shouldShowTimelineLayerGroupHeader(contextKey, previousContextKey)) count += 1;
26
+ previousContextKey = contextKey;
27
+ }
28
+ return count * TIMELINE_LAYER_GROUP_HEADER_H;
29
+ }
30
+
31
+ interface TimelineLayerGroupHeaderProps {
32
+ contextKey: string;
33
+ trackContentWidth: number;
34
+ theme: TimelineTheme;
35
+ accentColor: string;
36
+ }
37
+
38
+ export function TimelineLayerGroupHeader({
39
+ contextKey,
40
+ trackContentWidth,
41
+ theme,
42
+ accentColor,
43
+ }: TimelineLayerGroupHeaderProps) {
44
+ return (
45
+ <div
46
+ className="relative"
47
+ style={{
48
+ height: TIMELINE_LAYER_GROUP_HEADER_H,
49
+ // Fill the full canvas width (min 100% of the panel) so the context
50
+ // header spans the timeline at any zoom; minWidth preserves the intrinsic
51
+ // composition width when zoomed in and scrolling.
52
+ width: "100%",
53
+ minWidth: GUTTER + trackContentWidth,
54
+ background: theme.gutterBackground,
55
+ borderBottom: `1px solid ${theme.rowBorder}`,
56
+ }}
57
+ >
58
+ <div
59
+ className="sticky left-0 z-[13] flex h-full items-center"
60
+ style={{
61
+ width: Math.min(GUTTER + 220, GUTTER + trackContentWidth),
62
+ background: theme.gutterBackground,
63
+ }}
64
+ >
65
+ <div
66
+ className="relative h-full flex-shrink-0"
67
+ style={{
68
+ width: GUTTER,
69
+ borderRight: `1px solid ${theme.gutterBorder}`,
70
+ }}
71
+ >
72
+ <span
73
+ className="absolute bottom-0 top-0"
74
+ style={{
75
+ left: 8,
76
+ width: 2,
77
+ background: accentColor,
78
+ opacity: 0.72,
79
+ }}
80
+ />
81
+ </div>
82
+ <div className="min-w-0 px-2">
83
+ <span
84
+ className="block truncate font-mono uppercase leading-none"
85
+ style={{
86
+ color: theme.textSecondary,
87
+ fontSize: 10,
88
+ letterSpacing: 0,
89
+ }}
90
+ >
91
+ Inside: {contextKey}
92
+ </span>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,61 @@
1
+ import { Eye, EyeSlash } from "@phosphor-icons/react";
2
+ import { Music } from "../../icons/SystemIcons";
3
+ import type { TimelineTheme } from "./timelineTheme";
4
+ import { GUTTER } from "./timelineLayout";
5
+
6
+ interface TimelineLayerGutterProps {
7
+ isAudio: boolean;
8
+ isTrackHidden: boolean;
9
+ rowTrack: number;
10
+ theme: TimelineTheme;
11
+ onToggleHidden: () => void;
12
+ }
13
+
14
+ export function TimelineLayerGutter({
15
+ isAudio,
16
+ isTrackHidden,
17
+ rowTrack,
18
+ theme,
19
+ onToggleHidden,
20
+ }: TimelineLayerGutterProps) {
21
+ return (
22
+ <div
23
+ className="sticky left-0 z-[12] flex-shrink-0 flex flex-col items-center justify-center gap-0.5"
24
+ style={{
25
+ width: GUTTER,
26
+ background: theme.gutterBackground,
27
+ borderRight: `1px solid ${theme.gutterBorder}`,
28
+ }}
29
+ >
30
+ {isAudio && (
31
+ <Music
32
+ size={12}
33
+ weight="fill"
34
+ aria-hidden="true"
35
+ style={{ color: theme.textSecondary, opacity: 0.7 }}
36
+ />
37
+ )}
38
+ <button
39
+ type="button"
40
+ aria-label={isTrackHidden ? `Show track ${rowTrack}` : `Hide track ${rowTrack}`}
41
+ title={isTrackHidden ? `Show track ${rowTrack}` : `Hide track ${rowTrack}`}
42
+ className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
43
+ isTrackHidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
44
+ }`}
45
+ onPointerDown={(e) => {
46
+ e.stopPropagation();
47
+ }}
48
+ onClick={(e) => {
49
+ e.stopPropagation();
50
+ onToggleHidden();
51
+ }}
52
+ >
53
+ {isTrackHidden ? (
54
+ <EyeSlash size={14} weight="bold" aria-hidden="true" />
55
+ ) : (
56
+ <Eye size={14} weight="bold" aria-hidden="true" />
57
+ )}
58
+ </button>
59
+ </div>
60
+ );
61
+ }
@@ -79,13 +79,15 @@ export const TimelineRuler = memo(function TimelineRuler({
79
79
  })}
80
80
  </svg>
81
81
 
82
- {/* Ruler */}
82
+ {/* Ruler. The bar fills the full panel width (canvas is min 100% wide);
83
+ calc(100% - GUTTER) equals trackContentWidth when zoomed in and extends
84
+ past the content when zoomed out. Ticks stay at composition coordinates. */}
83
85
  <div
84
86
  className="relative overflow-hidden"
85
87
  style={{
86
88
  height: RULER_H,
87
89
  marginLeft: GUTTER,
88
- width: trackContentWidth,
90
+ width: `calc(100% - ${GUTTER}px)`,
89
91
  background: theme.gutterBackground,
90
92
  borderBottom: `1px solid ${theme.rulerBorder}`,
91
93
  }}
@@ -0,0 +1,55 @@
1
+ import type { TimelineRangeSelection } from "./timelineEditing";
2
+ import { GUTTER, RULER_H } from "./timelineLayout";
3
+ import type { TimelineMarqueeOverlayRect } from "./useTimelineMarqueeSelection";
4
+
5
+ interface TimelineSelectionOverlaysProps {
6
+ rangeSelection: TimelineRangeSelection | null;
7
+ marqueeRect: TimelineMarqueeOverlayRect | null;
8
+ pps: number;
9
+ /** Primary/accent color (hex) shared with the rest of the timeline chrome. */
10
+ accentColor: string;
11
+ }
12
+
13
+ export function TimelineSelectionOverlays({
14
+ rangeSelection,
15
+ marqueeRect,
16
+ pps,
17
+ accentColor,
18
+ }: TimelineSelectionOverlaysProps) {
19
+ return (
20
+ <>
21
+ {rangeSelection && (
22
+ <div
23
+ className="absolute pointer-events-none"
24
+ style={{
25
+ left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
26
+ width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
27
+ top: RULER_H,
28
+ bottom: 0,
29
+ backgroundColor: `${accentColor}1f`,
30
+ borderLeft: `1px solid ${accentColor}`,
31
+ borderRight: `1px solid ${accentColor}`,
32
+ zIndex: 50,
33
+ }}
34
+ />
35
+ )}
36
+ {marqueeRect && (
37
+ <div
38
+ aria-hidden="true"
39
+ className="absolute pointer-events-none"
40
+ data-timeline-marquee="true"
41
+ style={{
42
+ left: marqueeRect.left,
43
+ top: marqueeRect.top,
44
+ width: marqueeRect.width,
45
+ height: marqueeRect.height,
46
+ backgroundColor: `${accentColor}29`,
47
+ border: `1px solid ${accentColor}`,
48
+ boxShadow: "0 0 0 1px rgba(15, 23, 42, 0.35)",
49
+ zIndex: 60,
50
+ }}
51
+ />
52
+ )}
53
+ </>
54
+ );
55
+ }
@@ -1,7 +1,12 @@
1
1
  // fallow-ignore-file code-duplication
2
2
  // fallow-ignore-file dead-code
3
3
  import type { TimelineElement } from "../store/playerStore";
4
- import type { BlockedTimelineEditIntent } from "./timelineEditing";
4
+ import type { BlockedTimelineEditIntent, TimelineStackingReorderIntent } from "./timelineEditing";
5
+ import type {
6
+ TimelineGroupCommitOptions,
7
+ TimelineGroupMoveChange,
8
+ TimelineGroupResizeChange,
9
+ } from "../../hooks/useTimelineGroupEditing";
5
10
 
6
11
  /**
7
12
  * Shared callback signatures for timeline editing operations.
@@ -26,13 +31,26 @@ export interface TimelineDropCallbacks {
26
31
  export interface TimelineEditCallbacks {
27
32
  onMoveElement?: (
28
33
  element: TimelineElement,
29
- updates: Pick<TimelineElement, "start" | "track">,
34
+ updates: Pick<TimelineElement, "start" | "track"> & {
35
+ stackingReorder?: TimelineStackingReorderIntent | null;
36
+ },
30
37
  ) => Promise<void> | void;
31
38
  onResizeElement?: (
32
39
  element: TimelineElement,
33
40
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
34
41
  ) => Promise<void> | void;
42
+ onMoveElements?: (
43
+ changes: TimelineGroupMoveChange[],
44
+ options?: TimelineGroupCommitOptions,
45
+ ) => Promise<void> | void;
46
+ onResizeElements?: (
47
+ changes: TimelineGroupResizeChange[],
48
+ options?: TimelineGroupCommitOptions,
49
+ ) => Promise<void> | void;
50
+ onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
51
+ onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void;
35
52
  onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
53
+ onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
36
54
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
37
55
  onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
38
56
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -0,0 +1,52 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import type { TimelineStackingReorderIntent } from "./timelineEditing";
3
+ import type { TimelineLayerId } from "./timelineTrackOrder";
4
+
5
+ interface DragPreviewState {
6
+ element: Pick<TimelineElement, "track">;
7
+ previewLayerId: TimelineLayerId;
8
+ previewLayerIndex: number;
9
+ }
10
+
11
+ export interface TimelineMovePreview {
12
+ start: number;
13
+ track: number;
14
+ previewLayerId?: TimelineLayerId;
15
+ previewLayerIndex?: number;
16
+ stackingReorder?: TimelineStackingReorderIntent | null;
17
+ }
18
+
19
+ export interface TimelineGroupMovePreview {
20
+ active: boolean;
21
+ previewStart: number;
22
+ }
23
+
24
+ export function resolveDragPreviewPlacement(
25
+ drag: DragPreviewState,
26
+ nextMove: TimelineMovePreview,
27
+ groupMove: TimelineGroupMovePreview,
28
+ ): {
29
+ previewStart: number;
30
+ previewTrack: number;
31
+ previewLayerId: TimelineLayerId;
32
+ previewLayerIndex: number;
33
+ previewStackingReorder: TimelineStackingReorderIntent | null;
34
+ } {
35
+ if (groupMove.active) {
36
+ return {
37
+ previewStart: groupMove.previewStart,
38
+ previewTrack: drag.element.track,
39
+ previewLayerId: drag.previewLayerId,
40
+ previewLayerIndex: drag.previewLayerIndex,
41
+ previewStackingReorder: null,
42
+ };
43
+ }
44
+
45
+ return {
46
+ previewStart: groupMove.previewStart,
47
+ previewTrack: nextMove.track,
48
+ previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId,
49
+ previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex,
50
+ previewStackingReorder: nextMove.stackingReorder ?? null,
51
+ };
52
+ }
@@ -2,12 +2,14 @@ import { useCallback, useState, type RefObject } from "react";
2
2
  import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
3
3
  import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
4
4
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
5
+ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
5
6
 
6
7
  interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
7
8
  scrollRef: RefObject<HTMLDivElement | null>;
8
9
  ppsRef: RefObject<number>;
9
10
  durationRef: RefObject<number>;
10
- trackOrderRef: RefObject<number[]>;
11
+ trackOrderRef: RefObject<TimelineLayerId[]>;
12
+ timelineLayersRef: RefObject<StackingTimelineLayer[]>;
11
13
  }
12
14
 
13
15
  export function useTimelineAssetDrop({
@@ -15,6 +17,7 @@ export function useTimelineAssetDrop({
15
17
  ppsRef,
16
18
  durationRef,
17
19
  trackOrderRef,
20
+ timelineLayersRef,
18
21
  onFileDrop,
19
22
  onAssetDrop,
20
23
  onBlockDrop,
@@ -39,6 +42,10 @@ export function useTimelineAssetDrop({
39
42
  setIsDragOver(false);
40
43
  const scroll = scrollRef.current;
41
44
  const rect = scroll?.getBoundingClientRect();
45
+ const layerById = new Map(timelineLayersRef.current.map((layer) => [layer.id, layer]));
46
+ const trackOrder = trackOrderRef.current
47
+ .map((id) => layerById.get(id)?.placementTrack)
48
+ .filter((track): track is number => track != null);
42
49
  const dropInput = {
43
50
  rectLeft: rect?.left ?? 0,
44
51
  rectTop: rect?.top ?? 0,
@@ -47,7 +54,7 @@ export function useTimelineAssetDrop({
47
54
  pixelsPerSecond: ppsRef.current,
48
55
  duration: durationRef.current,
49
56
  trackHeight: TRACK_H,
50
- trackOrder: trackOrderRef.current,
57
+ trackOrder,
51
58
  };
52
59
  if (onFileDrop && e.dataTransfer.files.length > 0) {
53
60
  void onFileDrop(
@@ -84,7 +91,16 @@ export function useTimelineAssetDrop({
84
91
  }
85
92
  }
86
93
  },
87
- [onAssetDrop, onBlockDrop, onFileDrop, scrollRef, ppsRef, durationRef, trackOrderRef],
94
+ [
95
+ onAssetDrop,
96
+ onBlockDrop,
97
+ onFileDrop,
98
+ scrollRef,
99
+ ppsRef,
100
+ durationRef,
101
+ trackOrderRef,
102
+ timelineLayersRef,
103
+ ],
88
104
  );
89
105
 
90
106
  return { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop };
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineLayerDropPlacement } from "./timelineStacking";
3
+ import { resolveTimelineDropIndicator } from "./timelineDropIndicator";
4
+
5
+ const layerOrder = ["front", "middle", "back"];
6
+
7
+ describe("resolveTimelineDropIndicator", () => {
8
+ it("highlights only the row targeted by an onto placement", () => {
9
+ const placement: TimelineLayerDropPlacement = { type: "onto", layerId: "middle" };
10
+
11
+ expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toBeNull();
12
+ expect(resolveTimelineDropIndicator({ placement, layerId: "middle", layerOrder })).toEqual({
13
+ kind: "onto",
14
+ });
15
+ });
16
+
17
+ it("renders a single insertion line at the bottom edge of the before row", () => {
18
+ const placement: TimelineLayerDropPlacement = {
19
+ type: "between",
20
+ beforeLayerId: "front",
21
+ afterLayerId: "middle",
22
+ };
23
+
24
+ expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toEqual({
25
+ kind: "line",
26
+ edge: "bottom",
27
+ });
28
+ expect(resolveTimelineDropIndicator({ placement, layerId: "middle", layerOrder })).toBeNull();
29
+ });
30
+
31
+ it("renders above placements on the target row top edge", () => {
32
+ const placement: TimelineLayerDropPlacement = { type: "above", layerId: "front" };
33
+
34
+ expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toEqual({
35
+ kind: "line",
36
+ edge: "top",
37
+ });
38
+ });
39
+
40
+ it("renders below placements on the target row bottom edge", () => {
41
+ const placement: TimelineLayerDropPlacement = { type: "below", layerId: "back" };
42
+
43
+ expect(resolveTimelineDropIndicator({ placement, layerId: "back", layerOrder })).toEqual({
44
+ kind: "line",
45
+ edge: "bottom",
46
+ });
47
+ });
48
+ });
@@ -0,0 +1,33 @@
1
+ import type { TimelineLayerDropPlacement } from "./timelineStacking";
2
+ import type { TimelineLayerId } from "./timelineTrackOrder";
3
+
4
+ export type TimelineDropIndicator = { kind: "onto" } | { kind: "line"; edge: "top" | "bottom" };
5
+
6
+ interface ResolveTimelineDropIndicatorInput {
7
+ placement: TimelineLayerDropPlacement | null;
8
+ layerId: TimelineLayerId;
9
+ layerOrder: readonly TimelineLayerId[];
10
+ }
11
+
12
+ export function resolveTimelineDropIndicator({
13
+ placement,
14
+ layerId,
15
+ layerOrder,
16
+ }: ResolveTimelineDropIndicatorInput): TimelineDropIndicator | null {
17
+ if (!placement || !layerOrder.includes(layerId)) return null;
18
+
19
+ switch (placement.type) {
20
+ case "onto":
21
+ return placement.layerId === layerId ? { kind: "onto" } : null;
22
+ case "between":
23
+ if (placement.beforeLayerId === layerId) return { kind: "line", edge: "bottom" };
24
+ if (!layerOrder.includes(placement.beforeLayerId) && placement.afterLayerId === layerId) {
25
+ return { kind: "line", edge: "top" };
26
+ }
27
+ return null;
28
+ case "above":
29
+ return placement.layerId === layerId ? { kind: "line", edge: "top" } : null;
30
+ case "below":
31
+ return placement.layerId === layerId ? { kind: "line", edge: "bottom" } : null;
32
+ }
33
+ }