@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.
- package/dist/assets/{index-DO2MAFSG.js → index-CWHON2kh.js} +1 -1
- package/dist/assets/index-DX02Q1Yl.js +423 -0
- package/dist/assets/index-Dq7FEg0K.css +1 -0
- package/dist/assets/{index-BGVoOXdg.js → index-RTqYPUN7.js} +1 -1
- package/dist/index.d.ts +96 -29
- package/dist/index.html +2 -2
- package/dist/index.js +8443 -4305
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -32
- package/src/components/DesignPanelPromoteProvider.tsx +45 -0
- package/src/components/PanelTabButton.tsx +31 -0
- package/src/components/StudioPreviewArea.tsx +75 -3
- package/src/components/StudioRightPanel.tsx +111 -113
- package/src/components/editor/LayersPanel.tsx +2 -22
- package/src/components/editor/PromotableControl.tsx +105 -0
- package/src/components/editor/propertyPanelSections.tsx +42 -21
- package/src/components/panels/VariablesBindElement.tsx +266 -0
- package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
- package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
- package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
- package/src/components/panels/VariablesPanel.tsx +554 -0
- package/src/components/panels/VariablesRowAction.tsx +25 -0
- package/src/components/panels/VariablesValueControls.tsx +215 -0
- package/src/components/renders/useRenderQueue.ts +12 -0
- package/src/contexts/TimelineEditContext.tsx +7 -2
- package/src/contexts/VariablePromoteContext.tsx +138 -0
- package/src/contexts/variablePromoteHelpers.test.ts +124 -0
- package/src/contexts/variablePromoteHelpers.ts +53 -0
- package/src/contexts/variablePromoteIntegration.test.ts +80 -0
- package/src/hooks/previewVariablesStore.ts +34 -0
- package/src/hooks/timelineEditingHelpers.test.ts +104 -0
- package/src/hooks/timelineEditingHelpers.ts +386 -10
- package/src/hooks/useDomEditWiring.ts +9 -7
- package/src/hooks/useDomSelection.test.ts +36 -2
- package/src/hooks/useDomSelection.ts +56 -75
- package/src/hooks/useElementLifecycleOps.ts +57 -17
- package/src/hooks/usePreviewDocumentVersion.ts +27 -0
- package/src/hooks/useProjectCompositionVariables.ts +131 -0
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useStudioSdkSessions.ts +37 -0
- package/src/hooks/useTimelineEditing.test.tsx +963 -0
- package/src/hooks/useTimelineEditing.ts +122 -100
- package/src/hooks/useTimelineEditingTypes.ts +15 -0
- package/src/hooks/useTimelineGroupEditing.ts +370 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
- package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
- package/src/hooks/useVariablesPersist.ts +61 -0
- package/src/player/components/BeatStrip.tsx +19 -3
- package/src/player/components/Player.tsx +7 -1
- package/src/player/components/Timeline.test.ts +38 -0
- package/src/player/components/Timeline.tsx +102 -80
- package/src/player/components/TimelineCanvas.tsx +340 -296
- package/src/player/components/TimelineClip.test.tsx +11 -0
- package/src/player/components/TimelineClipDiamonds.tsx +40 -9
- package/src/player/components/TimelineDragGhost.tsx +59 -0
- package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
- package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
- package/src/player/components/TimelineLayerGutter.tsx +61 -0
- package/src/player/components/TimelineRuler.tsx +4 -2
- package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
- package/src/player/components/timelineCallbacks.ts +20 -2
- package/src/player/components/timelineClipDragPreview.ts +52 -0
- package/src/player/components/timelineDragDrop.ts +19 -3
- package/src/player/components/timelineDropIndicator.test.ts +48 -0
- package/src/player/components/timelineDropIndicator.ts +33 -0
- package/src/player/components/timelineEditing.test.ts +251 -1
- package/src/player/components/timelineEditing.ts +178 -19
- package/src/player/components/timelineGroupEditing.ts +159 -0
- package/src/player/components/timelineLayerDrag.test.ts +200 -0
- package/src/player/components/timelineLayerDrag.ts +383 -0
- package/src/player/components/timelineLayout.test.ts +34 -0
- package/src/player/components/timelineLayout.ts +47 -0
- package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
- package/src/player/components/timelineSnapTargets.test.ts +144 -0
- package/src/player/components/timelineSnapTargets.ts +164 -0
- package/src/player/components/timelineStacking.ts +62 -0
- package/src/player/components/timelineTrackOrder.test.ts +144 -0
- package/src/player/components/timelineTrackOrder.ts +173 -0
- package/src/player/components/useTimelineActiveClips.test.ts +20 -0
- package/src/player/components/useTimelineActiveClips.ts +15 -4
- package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
- package/src/player/components/useTimelineClipDrag.ts +172 -126
- package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
- package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
- package/src/player/hooks/useTimelinePlayer.ts +7 -0
- package/src/player/lib/layerOrdering.test.ts +77 -0
- package/src/player/lib/layerOrdering.ts +98 -0
- package/src/player/lib/playbackTypes.ts +3 -0
- package/src/player/lib/timelineDOM.test.ts +103 -0
- package/src/player/lib/timelineDOM.ts +80 -0
- package/src/player/lib/timelineElementHelpers.ts +1 -1
- package/src/player/store/playerStore.test.ts +92 -0
- package/src/player/store/playerStore.ts +69 -9
- package/src/utils/blockInstaller.ts +2 -14
- package/src/utils/editHistory.test.ts +61 -0
- package/src/utils/editHistory.ts +7 -1
- package/src/utils/rootDuration.test.ts +34 -0
- package/src/utils/rootDuration.ts +17 -0
- package/src/utils/sdkCutover.ts +45 -0
- package/src/utils/studioHelpers.test.ts +31 -0
- package/src/utils/studioHelpers.ts +29 -1
- package/src/utils/studioUrlState.test.ts +29 -0
- package/src/utils/studioUrlState.ts +16 -1
- package/dist/assets/index-BsLQ2qGn.js +0 -420
- 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:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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:
|
|
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<
|
|
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
|
|
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
|
-
[
|
|
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
|
+
}
|