@hyperframes/studio 0.8.49 → 0.8.50
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/{hyperframes-player-CqON2Rzo.js → hyperframes-player-CxxDxHTW.js} +1 -1
- package/dist/assets/{index-mIgSsKqs.js → index-DUAIWxkI.js} +1 -1
- package/dist/assets/{index-BGovDksE.js → index-Dt3qBI-9.js} +1 -1
- package/dist/assets/{index-Bgmx2h7W.js → index-n2ml4tUn.js} +218 -217
- package/dist/assets/index-pO6p0H27.css +1 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5899 -5754
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +10 -8
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +24 -0
- package/src/components/StudioOverlays.tsx +1 -5
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +25 -1
- package/src/components/editor/domEditingAgentPrompt.ts +5 -0
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +9 -4
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +2 -0
- package/src/components/nle/TimelinePane.tsx +14 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +3 -0
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/player/components/ClipContextMenu.tsx +87 -7
- package/src/player/components/Timeline.tsx +14 -14
- package/src/player/components/TimelineCanvas.tsx +30 -0
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +22 -1
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineTypes.ts +4 -0
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.ts +2 -2
- package/src/player/components/timelineDragDrop.test.tsx +31 -0
- package/src/player/components/timelineDragDrop.ts +30 -23
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +103 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +2 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +27 -6
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
|
-
import type
|
|
3
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
4
4
|
import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
5
5
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
6
6
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
@@ -13,8 +13,14 @@ interface ClipContextMenuProps {
|
|
|
13
13
|
onClose: () => void;
|
|
14
14
|
onSplit: (element: TimelineElement, splitTime: number) => void;
|
|
15
15
|
onDelete: (element: TimelineElement) => void;
|
|
16
|
+
onCopy?: () => boolean;
|
|
17
|
+
onPaste?: () => Promise<void>;
|
|
18
|
+
onDuplicate?: () => Promise<boolean>;
|
|
19
|
+
canPaste?: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
// A menu with many independently gated items (Split/Delete/Copy/Paste/Duplicate).
|
|
23
|
+
// fallow-ignore-next-line complexity
|
|
18
24
|
export const ClipContextMenu = memo(function ClipContextMenu({
|
|
19
25
|
x,
|
|
20
26
|
y,
|
|
@@ -23,15 +29,22 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
23
29
|
onClose,
|
|
24
30
|
onSplit,
|
|
25
31
|
onDelete,
|
|
32
|
+
onCopy,
|
|
33
|
+
onPaste,
|
|
34
|
+
onDuplicate,
|
|
35
|
+
canPaste,
|
|
26
36
|
}: ClipContextMenuProps) {
|
|
27
37
|
const menuRef = useContextMenuDismiss(onClose);
|
|
28
38
|
useMenuKeyboardNav(menuRef);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
// The right-clicked clip's own id: a member of the live multi-selection
|
|
40
|
+
// means Copy/Duplicate act on the whole group, matching onContextMenuClip's
|
|
41
|
+
// selection-preserving behaviour for a right-click inside it.
|
|
42
|
+
const selectionSize = usePlayerStore((s) => {
|
|
43
|
+
const id = element.key ?? element.id;
|
|
44
|
+
return s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)
|
|
45
|
+
? s.selectedElementIds.size
|
|
46
|
+
: 1;
|
|
47
|
+
});
|
|
35
48
|
|
|
36
49
|
const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
|
|
37
50
|
const canSplit =
|
|
@@ -43,6 +56,23 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
43
56
|
? `Split at ${currentTime.toFixed(2)}s`
|
|
44
57
|
: "Split (move playhead inside clip)";
|
|
45
58
|
|
|
59
|
+
const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
|
|
60
|
+
const rowCount = (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
|
|
61
|
+
const dividerCount = (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
|
|
62
|
+
const menuWidth = 200;
|
|
63
|
+
const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
|
|
64
|
+
const overflowY = y + menuHeight - window.innerHeight;
|
|
65
|
+
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
66
|
+
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
|
|
67
|
+
|
|
68
|
+
// Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
|
|
69
|
+
const itemClass = (enabled: boolean) =>
|
|
70
|
+
`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
|
|
71
|
+
enabled
|
|
72
|
+
? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
73
|
+
: " text-neutral-600 cursor-not-allowed"
|
|
74
|
+
}`;
|
|
75
|
+
|
|
46
76
|
return createPortal(
|
|
47
77
|
<div
|
|
48
78
|
ref={menuRef}
|
|
@@ -76,6 +106,56 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
76
106
|
</>
|
|
77
107
|
)}
|
|
78
108
|
|
|
109
|
+
{(onCopy || onPaste || onDuplicate) && (
|
|
110
|
+
<>
|
|
111
|
+
{onCopy && (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
role="menuitem"
|
|
115
|
+
className={itemClass(true)}
|
|
116
|
+
onClick={() => {
|
|
117
|
+
onCopy();
|
|
118
|
+
onClose();
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
|
|
122
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
|
|
123
|
+
</button>
|
|
124
|
+
)}
|
|
125
|
+
{onPaste && (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
role="menuitem"
|
|
129
|
+
className={itemClass(!!canPaste)}
|
|
130
|
+
disabled={!canPaste}
|
|
131
|
+
onClick={() => {
|
|
132
|
+
if (!canPaste) return;
|
|
133
|
+
void onPaste();
|
|
134
|
+
onClose();
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<span>Paste</span>
|
|
138
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
|
|
139
|
+
</button>
|
|
140
|
+
)}
|
|
141
|
+
{onDuplicate && (
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
role="menuitem"
|
|
145
|
+
className={itemClass(true)}
|
|
146
|
+
onClick={() => {
|
|
147
|
+
void onDuplicate();
|
|
148
|
+
onClose();
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
|
|
152
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
|
|
153
|
+
</button>
|
|
154
|
+
)}
|
|
155
|
+
<div className="my-1 border-t border-neutral-700/60" />
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
|
|
79
159
|
<button
|
|
80
160
|
type="button"
|
|
81
161
|
role="menuitem"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRef, useMemo, useCallback, useState, memo } from "react";
|
|
2
2
|
import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
|
3
3
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
4
|
-
import {
|
|
4
|
+
import { useTimelineRowElements } from "../hooks/useTimelineRowElements";
|
|
5
5
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
6
6
|
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
|
7
7
|
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
|
@@ -43,6 +43,7 @@ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
|
|
|
43
43
|
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
|
44
44
|
import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
|
|
45
45
|
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
|
|
46
|
+
import { useClipContextMenu } from "./useTimelineClipContextMenu";
|
|
46
47
|
|
|
47
48
|
export {
|
|
48
49
|
shouldAutoScrollTimeline,
|
|
@@ -79,6 +80,10 @@ export const Timeline = memo(function Timeline({
|
|
|
79
80
|
onBlockedEditAttempt: onBlockedEditAttemptOverride,
|
|
80
81
|
onSplitElement: onSplitElementOverride,
|
|
81
82
|
onSelectElement,
|
|
83
|
+
onCopyClip,
|
|
84
|
+
onPasteClip,
|
|
85
|
+
onDuplicateClip,
|
|
86
|
+
canPasteClip,
|
|
82
87
|
theme: themeOverrides,
|
|
83
88
|
sessionEpoch = 0,
|
|
84
89
|
}: TimelineProps = {}) {
|
|
@@ -106,7 +111,7 @@ export const Timeline = memo(function Timeline({
|
|
|
106
111
|
const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
|
|
107
112
|
useMusicBeatAnalysis();
|
|
108
113
|
const rawElements = usePlayerStore((s) => s.elements);
|
|
109
|
-
const expandedElements =
|
|
114
|
+
const expandedElements = useTimelineRowElements();
|
|
110
115
|
const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
|
|
111
116
|
const duration = usePlayerStore((s) => s.duration);
|
|
112
117
|
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
@@ -208,6 +213,7 @@ export const Timeline = memo(function Timeline({
|
|
|
208
213
|
onMoveElement: pinnedOnMoveElement,
|
|
209
214
|
onMoveElements: pinnedOnMoveElements,
|
|
210
215
|
});
|
|
216
|
+
const onContextMenuClip = useClipContextMenu(onSelectElement, dismissGapMenu, setClipContextMenu);
|
|
211
217
|
|
|
212
218
|
const {
|
|
213
219
|
draggedClip,
|
|
@@ -492,6 +498,7 @@ export const Timeline = memo(function Timeline({
|
|
|
492
498
|
rangeSelection={rangeSelection}
|
|
493
499
|
marqueeRect={marqueeRect}
|
|
494
500
|
laneGapStrips={laneGapStrips}
|
|
501
|
+
dropPreview={assetDrop.dropPreview}
|
|
495
502
|
theme={theme}
|
|
496
503
|
displayTrackOrder={displayLayout.displayTrackOrder}
|
|
497
504
|
rowHeights={displayLayout.displayRowHeights}
|
|
@@ -548,18 +555,7 @@ export const Timeline = memo(function Timeline({
|
|
|
548
555
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
549
556
|
onMoveKeyframe={onMoveKeyframe}
|
|
550
557
|
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
551
|
-
onContextMenuClip={
|
|
552
|
-
e.preventDefault();
|
|
553
|
-
setSelectedElementId(el.key ?? el.id);
|
|
554
|
-
onSelectElement?.(el);
|
|
555
|
-
dismissGapMenu();
|
|
556
|
-
setClipContextMenu({
|
|
557
|
-
x: e.clientX,
|
|
558
|
-
y: e.clientY,
|
|
559
|
-
element: el,
|
|
560
|
-
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
|
|
561
|
-
});
|
|
562
|
-
}}
|
|
558
|
+
onContextMenuClip={onContextMenuClip}
|
|
563
559
|
onContextMenuLane={(e, track, time) => {
|
|
564
560
|
if (draggedClip?.started || resizingClip) return;
|
|
565
561
|
setClipContextMenu(null);
|
|
@@ -588,6 +584,10 @@ export const Timeline = memo(function Timeline({
|
|
|
588
584
|
onSplitElement={onSplitElement}
|
|
589
585
|
pinZoomBeforeEdit={pinZoomBeforeEdit}
|
|
590
586
|
onDeleteElement={_onDeleteElement}
|
|
587
|
+
onCopyClip={onCopyClip}
|
|
588
|
+
onPasteClip={onPasteClip}
|
|
589
|
+
onDuplicateClip={onDuplicateClip}
|
|
590
|
+
canPasteClip={canPasteClip}
|
|
591
591
|
gapContextMenu={gapMenuModel}
|
|
592
592
|
onDismissGapContextMenu={dismissGapMenu}
|
|
593
593
|
onCloseTrackGap={closeTrackGap}
|
|
@@ -39,12 +39,22 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps {
|
|
|
39
39
|
playheadRef: React.RefObject<HTMLDivElement | null>;
|
|
40
40
|
/** Gap strips: loud on gap-menu-row hover, quiet on the selected clip's lane. */
|
|
41
41
|
laneGapStrips: TimelineLaneGapStrips[];
|
|
42
|
+
/** Landing spot of an outside drag in progress, or null. */
|
|
43
|
+
dropPreview: { start: number; track: number } | null;
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
// A dropped clip's length is unknown until it lands; the preview shows a default.
|
|
47
|
+
const DROP_PREVIEW_SECONDS = 3;
|
|
48
|
+
|
|
44
49
|
export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
|
|
45
50
|
const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
|
|
46
51
|
const draggedRowIndex =
|
|
47
52
|
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
|
53
|
+
const dropTrackIndex = props.dropPreview
|
|
54
|
+
? displayTrackOrder.indexOf(props.dropPreview.track)
|
|
55
|
+
: -1;
|
|
56
|
+
// A track not in the order is a new track, drawn one row past the last lane.
|
|
57
|
+
const dropRowIndex = dropTrackIndex < 0 ? displayTrackOrder.length : dropTrackIndex;
|
|
48
58
|
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
|
|
49
59
|
// A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
|
|
50
60
|
// occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
|
|
@@ -171,6 +181,26 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|
|
171
181
|
/>
|
|
172
182
|
)}
|
|
173
183
|
|
|
184
|
+
{/* Drop preview: where an asset or file dragged in from outside will land
|
|
185
|
+
(a row past the last lane means a new track). */}
|
|
186
|
+
{props.dropPreview && (
|
|
187
|
+
<div
|
|
188
|
+
aria-hidden="true"
|
|
189
|
+
data-testid="timeline-drop-preview"
|
|
190
|
+
className="absolute pointer-events-none"
|
|
191
|
+
style={{
|
|
192
|
+
top: getTimelineRowTop(dropRowIndex, props.rowHeights) + CLIP_Y,
|
|
193
|
+
left: props.contentOrigin + props.dropPreview.start * props.pps,
|
|
194
|
+
width: DROP_PREVIEW_SECONDS * props.pps,
|
|
195
|
+
height: TRACK_H - CLIP_Y * 2,
|
|
196
|
+
border: "1px solid rgba(60,230,172,0.55)",
|
|
197
|
+
background: "rgba(60,230,172,0.12)",
|
|
198
|
+
borderRadius: 4,
|
|
199
|
+
zIndex: 30,
|
|
200
|
+
}}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
|
|
174
204
|
{/* Insertion line — a new track will be inserted at this boundary on drop.
|
|
175
205
|
Shown while the pointer is near a lane boundary (insert mode). */}
|
|
176
206
|
{draggedClip?.started && draggedClip.insertRow != null && (
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
7
7
|
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
|
8
8
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
@@ -65,6 +65,40 @@ describe("TimelineGestureOverlay", () => {
|
|
|
65
65
|
act(() => root.unmount());
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it("asks for the same thumbnail frames as the clip at rest", () => {
|
|
69
|
+
const host = document.createElement("div");
|
|
70
|
+
document.body.append(host);
|
|
71
|
+
const root = createRoot(host);
|
|
72
|
+
const renderClipContent = vi.fn(() => null);
|
|
73
|
+
act(() => {
|
|
74
|
+
root.render(
|
|
75
|
+
<TimelineGestureOverlay
|
|
76
|
+
drag={drag}
|
|
77
|
+
scrollRef={{
|
|
78
|
+
current: {
|
|
79
|
+
scrollLeft: 0,
|
|
80
|
+
scrollTop: 0,
|
|
81
|
+
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
|
82
|
+
} as HTMLDivElement,
|
|
83
|
+
}}
|
|
84
|
+
pixelsPerSecond={100}
|
|
85
|
+
rowHeight={42}
|
|
86
|
+
selectedElementId="hero"
|
|
87
|
+
currentTime={4}
|
|
88
|
+
theme={defaultTimelineTheme}
|
|
89
|
+
getTrackStyle={getTrackStyle}
|
|
90
|
+
renderClipContent={renderClipContent}
|
|
91
|
+
/>,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
expect(renderClipContent).toHaveBeenCalledWith(
|
|
95
|
+
expect.anything(),
|
|
96
|
+
expect.anything(),
|
|
97
|
+
expect.objectContaining({ rich: false }),
|
|
98
|
+
);
|
|
99
|
+
act(() => root.unmount());
|
|
100
|
+
});
|
|
101
|
+
|
|
68
102
|
it("keeps the stable overlay host after terminal cleanup", () => {
|
|
69
103
|
const host = document.createElement("div");
|
|
70
104
|
document.body.append(host);
|
|
@@ -89,7 +89,7 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
|
|
|
89
89
|
getTrackStyle(element.tag),
|
|
90
90
|
renderClipContent,
|
|
91
91
|
renderClipOverlay,
|
|
92
|
-
{ priority: "interaction", rich:
|
|
92
|
+
{ priority: "interaction", rich: false },
|
|
93
93
|
)}
|
|
94
94
|
</TimelineClip>
|
|
95
95
|
</div>
|
|
@@ -70,6 +70,8 @@ interface RenderLanesOptions {
|
|
|
70
70
|
draggedClip?: DraggedClipState | null;
|
|
71
71
|
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
72
72
|
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
|
73
|
+
hoveredClip?: string | null;
|
|
74
|
+
renderClipContent?: React.ComponentProps<typeof TimelineLanes>["renderClipContent"];
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
function renderLanes(options: RenderLanesOptions = {}): {
|
|
@@ -135,7 +137,8 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
135
137
|
laneCounts={laneCounts}
|
|
136
138
|
selectedElementId={null}
|
|
137
139
|
selectedElementIds={next.selectedElementIds ?? new Set()}
|
|
138
|
-
hoveredClip={null}
|
|
140
|
+
hoveredClip={next.hoveredClip ?? null}
|
|
141
|
+
renderClipContent={next.renderClipContent}
|
|
139
142
|
draggedClip={next.draggedClip ?? null}
|
|
140
143
|
draggedElement={null}
|
|
141
144
|
multiDragPreview={next.multiDragPreview ?? null}
|
|
@@ -395,3 +398,21 @@ describe("TimelineLanes selection", () => {
|
|
|
395
398
|
act(() => view.root.unmount());
|
|
396
399
|
});
|
|
397
400
|
});
|
|
401
|
+
|
|
402
|
+
describe("TimelineLanes clip thumbnails", () => {
|
|
403
|
+
it("asks for the same frames at rest, hovered and selected", () => {
|
|
404
|
+
const rich: unknown[] = [];
|
|
405
|
+
const renderClipContent = vi.fn(
|
|
406
|
+
(_el: TimelineElement, _style: unknown, context: { rich: boolean }) => {
|
|
407
|
+
rich.push(context.rich);
|
|
408
|
+
return null;
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
const elements = [element("clip-a", TRACK_A)];
|
|
412
|
+
const view = renderLanes({ elements, renderClipContent });
|
|
413
|
+
view.rerender({ elements, renderClipContent, hoveredClip: "clip-a" });
|
|
414
|
+
view.rerender({ elements, renderClipContent, selectedElementIds: new Set(["clip-a"]) });
|
|
415
|
+
expect(new Set(rich)).toEqual(new Set([false]));
|
|
416
|
+
act(() => view.root.unmount());
|
|
417
|
+
});
|
|
418
|
+
});
|
|
@@ -52,6 +52,10 @@ interface TimelineOverlaysProps {
|
|
|
52
52
|
onSplitElement: TimelineEditCallbacks["onSplitElement"];
|
|
53
53
|
pinZoomBeforeEdit: () => void;
|
|
54
54
|
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
55
|
+
onCopyClip?: () => boolean;
|
|
56
|
+
onPasteClip?: () => Promise<void>;
|
|
57
|
+
onDuplicateClip?: () => Promise<boolean>;
|
|
58
|
+
canPasteClip?: () => boolean;
|
|
55
59
|
gapContextMenu: TrackGapContextMenuState | null;
|
|
56
60
|
onDismissGapContextMenu: () => void;
|
|
57
61
|
onCloseTrackGap: () => void;
|
|
@@ -119,6 +123,10 @@ export function TimelineOverlays({
|
|
|
119
123
|
onSplitElement,
|
|
120
124
|
pinZoomBeforeEdit,
|
|
121
125
|
onDeleteElement,
|
|
126
|
+
onCopyClip,
|
|
127
|
+
onPasteClip,
|
|
128
|
+
onDuplicateClip,
|
|
129
|
+
canPasteClip,
|
|
122
130
|
gapContextMenu,
|
|
123
131
|
onDismissGapContextMenu,
|
|
124
132
|
onCloseTrackGap,
|
|
@@ -249,6 +257,10 @@ export function TimelineOverlays({
|
|
|
249
257
|
pinZoomBeforeEdit();
|
|
250
258
|
onDeleteElement?.(element);
|
|
251
259
|
}}
|
|
260
|
+
onCopy={onCopyClip}
|
|
261
|
+
onPaste={onPasteClip}
|
|
262
|
+
onDuplicate={onDuplicateClip}
|
|
263
|
+
canPaste={canPasteClip?.() ?? false}
|
|
252
264
|
/>
|
|
253
265
|
)}
|
|
254
266
|
|
|
@@ -22,5 +22,9 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverri
|
|
|
22
22
|
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
23
23
|
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
24
24
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
25
|
+
onCopyClip?: () => boolean;
|
|
26
|
+
onPasteClip?: () => Promise<void>;
|
|
27
|
+
onDuplicateClip?: () => Promise<boolean>;
|
|
28
|
+
canPasteClip?: () => boolean;
|
|
25
29
|
theme?: Partial<TimelineTheme>;
|
|
26
30
|
}
|
|
@@ -3,7 +3,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
|
3
3
|
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
4
4
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
5
5
|
|
|
6
|
-
/** Whether Studio may write timing to this clip (false for locked
|
|
6
|
+
/** Whether Studio may write timing to this clip (false for locked rows). */
|
|
7
7
|
export function canMoveTimelineElement(element: TimelineElement): boolean {
|
|
8
8
|
return getTimelineEditCapabilities({
|
|
9
9
|
tag: element.tag,
|
|
@@ -16,7 +16,6 @@ export function canMoveTimelineElement(element: TimelineElement): boolean {
|
|
|
16
16
|
playbackStart: element.playbackStart,
|
|
17
17
|
playbackStartAttr: element.playbackStartAttr,
|
|
18
18
|
sourceDuration: element.sourceDuration,
|
|
19
|
-
timingSource: element.timingSource,
|
|
20
19
|
timelineLocked: element.timelineLocked,
|
|
21
20
|
}).canMove;
|
|
22
21
|
}
|
|
@@ -11,10 +11,10 @@ const clip: TimelineElement = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
describe("resolveClipRenderContext", () => {
|
|
14
|
-
it("prioritizes interactive clips
|
|
14
|
+
it("prioritizes interactive clips without changing their thumbnail frames", () => {
|
|
15
15
|
expect(resolveClipRenderContext(clip, { start: 0, end: 1 }, true)).toEqual({
|
|
16
16
|
priority: "interaction",
|
|
17
|
-
rich:
|
|
17
|
+
rich: false,
|
|
18
18
|
});
|
|
19
19
|
});
|
|
20
20
|
|
|
@@ -9,7 +9,8 @@ export function resolveClipRenderContext(
|
|
|
9
9
|
visibleTimeRange: TimelineTimeRange,
|
|
10
10
|
interactive: boolean,
|
|
11
11
|
): TimelineClipRenderContext {
|
|
12
|
-
|
|
12
|
+
// Interaction only reorders loading; `rich` would swap the frames under the pointer.
|
|
13
|
+
if (interactive) return { priority: "interaction", rich: false };
|
|
13
14
|
const visible =
|
|
14
15
|
element.start < visibleTimeRange.end &&
|
|
15
16
|
element.start + element.duration > visibleTimeRange.start;
|
|
@@ -176,8 +176,8 @@ export function persistMoveEdits(
|
|
|
176
176
|
/**
|
|
177
177
|
* Build the time-shift resolver for a multi-selection drag: every member of the
|
|
178
178
|
* selection moves by the dragged clip's delta (clamped ≥ 0); non-members are
|
|
179
|
-
* untouched. Returns null when this is not a multi-selection drag. A locked
|
|
180
|
-
*
|
|
179
|
+
* untouched. Returns null when this is not a multi-selection drag. A locked
|
|
180
|
+
* member is dropped from the moving set (a marquee can sweep one in).
|
|
181
181
|
*/
|
|
182
182
|
function resolveMultiSelection(
|
|
183
183
|
drag: DraggedClipState,
|
|
@@ -220,4 +220,35 @@ describe("useTimelineAssetDrop", () => {
|
|
|
220
220
|
expect(view.api.isDragOver).toBe(false);
|
|
221
221
|
act(() => view.root.unmount());
|
|
222
222
|
});
|
|
223
|
+
|
|
224
|
+
it("previews the exact placement the drop commits, and clears it after", () => {
|
|
225
|
+
const onAssetDrop = vi.fn();
|
|
226
|
+
const view = renderHarness(onAssetDrop);
|
|
227
|
+
const payload = JSON.stringify({ path: "assets/a.png" });
|
|
228
|
+
|
|
229
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, 100)));
|
|
230
|
+
const preview = view.api.dropPreview;
|
|
231
|
+
expect(preview).toEqual({ start: 10, track: 0 });
|
|
232
|
+
|
|
233
|
+
act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, 100)));
|
|
234
|
+
expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
|
|
235
|
+
expect(view.api.dropPreview).toBeNull();
|
|
236
|
+
act(() => view.root.unmount());
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("moves the preview with the pointer and drops it when the drag leaves", () => {
|
|
240
|
+
const view = renderHarness(vi.fn());
|
|
241
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
|
|
242
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, 100)));
|
|
243
|
+
expect(view.api.dropPreview?.start).toBe(20);
|
|
244
|
+
|
|
245
|
+
act(() =>
|
|
246
|
+
view.api.handleAssetDragLeave({
|
|
247
|
+
relatedTarget: null,
|
|
248
|
+
currentTarget: document.body,
|
|
249
|
+
} as unknown as React.DragEvent),
|
|
250
|
+
);
|
|
251
|
+
expect(view.api.dropPreview).toBeNull();
|
|
252
|
+
act(() => view.root.unmount());
|
|
253
|
+
});
|
|
223
254
|
});
|
|
@@ -98,6 +98,7 @@ export function useTimelineAssetDrop({
|
|
|
98
98
|
sessionEpoch,
|
|
99
99
|
}: UseTimelineAssetDropOptions) {
|
|
100
100
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
101
|
+
const [dropPreview, setDropPreview] = useState<TimelinePlacement | null>(null);
|
|
101
102
|
const dragPointerRef = useRef<{ clientX: number; clientY: number; sessionEpoch: number } | null>(
|
|
102
103
|
null,
|
|
103
104
|
);
|
|
@@ -142,6 +143,28 @@ export function useTimelineAssetDrop({
|
|
|
142
143
|
[scrollRef, sessionEpoch, stepAutoScroll],
|
|
143
144
|
);
|
|
144
145
|
|
|
146
|
+
const resolveDropPlacement = useCallback(
|
|
147
|
+
(clientX: number, clientY: number): TimelinePlacement => {
|
|
148
|
+
const scroll = scrollRef.current;
|
|
149
|
+
const rect = scroll?.getBoundingClientRect();
|
|
150
|
+
return resolveTimelineAssetDrop(
|
|
151
|
+
{
|
|
152
|
+
rectLeft: rect?.left ?? 0,
|
|
153
|
+
rectTop: rect?.top ?? 0,
|
|
154
|
+
scrollLeft: scroll?.scrollLeft ?? 0,
|
|
155
|
+
scrollTop: scroll?.scrollTop ?? 0,
|
|
156
|
+
contentOrigin,
|
|
157
|
+
pixelsPerSecond: ppsRef.current,
|
|
158
|
+
rowHeights: rowGeometryRef.current.rowHeights,
|
|
159
|
+
trackOrder: trackOrderRef.current,
|
|
160
|
+
},
|
|
161
|
+
clientX,
|
|
162
|
+
clientY,
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
[scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
|
|
166
|
+
);
|
|
167
|
+
|
|
145
168
|
const handleAssetDragOver = useCallback(
|
|
146
169
|
(e: React.DragEvent) => {
|
|
147
170
|
const types = Array.from(e.dataTransfer.types);
|
|
@@ -154,15 +177,20 @@ export function useTimelineAssetDrop({
|
|
|
154
177
|
e.dataTransfer.dropEffect = "copy";
|
|
155
178
|
activeDropEpochRef.current = sessionEpoch;
|
|
156
179
|
setIsDragOver(true);
|
|
180
|
+
const next = resolveDropPlacement(e.clientX, e.clientY);
|
|
181
|
+
setDropPreview((prev) =>
|
|
182
|
+
prev?.start === next.start && prev.track === next.track ? prev : next,
|
|
183
|
+
);
|
|
157
184
|
syncAutoScroll(e.clientX, e.clientY);
|
|
158
185
|
},
|
|
159
|
-
[sessionEpoch, syncAutoScroll],
|
|
186
|
+
[resolveDropPlacement, sessionEpoch, syncAutoScroll],
|
|
160
187
|
);
|
|
161
188
|
|
|
162
189
|
const clearDropPreview = useCallback(() => {
|
|
163
190
|
activeDropEpochRef.current = null;
|
|
164
191
|
stopAutoScroll();
|
|
165
192
|
setIsDragOver(false);
|
|
193
|
+
setDropPreview(null);
|
|
166
194
|
}, [stopAutoScroll]);
|
|
167
195
|
|
|
168
196
|
const handleAssetDragLeave = useCallback(
|
|
@@ -174,28 +202,6 @@ export function useTimelineAssetDrop({
|
|
|
174
202
|
[clearDropPreview],
|
|
175
203
|
);
|
|
176
204
|
|
|
177
|
-
const resolveDropPlacement = useCallback(
|
|
178
|
-
(clientX: number, clientY: number): TimelinePlacement => {
|
|
179
|
-
const scroll = scrollRef.current;
|
|
180
|
-
const rect = scroll?.getBoundingClientRect();
|
|
181
|
-
return resolveTimelineAssetDrop(
|
|
182
|
-
{
|
|
183
|
-
rectLeft: rect?.left ?? 0,
|
|
184
|
-
rectTop: rect?.top ?? 0,
|
|
185
|
-
scrollLeft: scroll?.scrollLeft ?? 0,
|
|
186
|
-
scrollTop: scroll?.scrollTop ?? 0,
|
|
187
|
-
contentOrigin,
|
|
188
|
-
pixelsPerSecond: ppsRef.current,
|
|
189
|
-
rowHeights: rowGeometryRef.current.rowHeights,
|
|
190
|
-
trackOrder: trackOrderRef.current,
|
|
191
|
-
},
|
|
192
|
-
clientX,
|
|
193
|
-
clientY,
|
|
194
|
-
);
|
|
195
|
-
},
|
|
196
|
-
[scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
|
|
197
|
-
);
|
|
198
|
-
|
|
199
205
|
const handleAssetDrop = useCallback(
|
|
200
206
|
(e: React.DragEvent) => {
|
|
201
207
|
e.preventDefault();
|
|
@@ -240,6 +246,7 @@ export function useTimelineAssetDrop({
|
|
|
240
246
|
|
|
241
247
|
return {
|
|
242
248
|
isDragOver,
|
|
249
|
+
dropPreview,
|
|
243
250
|
handleAssetDragOver,
|
|
244
251
|
handleAssetDragLeave,
|
|
245
252
|
handleAssetDrop,
|
|
@@ -42,10 +42,9 @@ export function getTimelineEditCapabilities(input: {
|
|
|
42
42
|
playbackStart?: number;
|
|
43
43
|
playbackStartAttr?: "media-start" | "playback-start";
|
|
44
44
|
sourceDuration?: number;
|
|
45
|
-
timingSource?: "authored" | "implicit";
|
|
46
45
|
timelineLocked?: boolean;
|
|
47
46
|
}): TimelineEditCapabilities {
|
|
48
|
-
if (input.
|
|
47
|
+
if (input.timelineLocked) {
|
|
49
48
|
return { canMove: false, canTrimStart: false, canTrimEnd: false };
|
|
50
49
|
}
|
|
51
50
|
|
|
@@ -412,21 +412,6 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
412
412
|
});
|
|
413
413
|
});
|
|
414
414
|
|
|
415
|
-
it("keeps implicit layout layers selectable but not timeline-editable", () => {
|
|
416
|
-
expect(
|
|
417
|
-
getTimelineEditCapabilities({
|
|
418
|
-
duration: 8,
|
|
419
|
-
selector: ".scene-shell",
|
|
420
|
-
tag: "div",
|
|
421
|
-
timingSource: "implicit",
|
|
422
|
-
}),
|
|
423
|
-
).toEqual({
|
|
424
|
-
canMove: false,
|
|
425
|
-
canTrimStart: false,
|
|
426
|
-
canTrimEnd: false,
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
|
|
430
415
|
it("allows move and both trims for patchable media clips with offset support", () => {
|
|
431
416
|
expect(
|
|
432
417
|
getTimelineEditCapabilities({
|
|
@@ -496,7 +481,6 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
496
481
|
tag: "div",
|
|
497
482
|
duration: 4,
|
|
498
483
|
selector: "#hero-card",
|
|
499
|
-
timingSource: "authored",
|
|
500
484
|
}),
|
|
501
485
|
).toEqual({
|
|
502
486
|
canMove: true,
|
|
@@ -34,11 +34,9 @@ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
|
|
|
34
34
|
expect(buildTimelineGroupResizeMembers([a, locked], keys("a", "b"), "a", "end")).toBeNull();
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
it("degrades when a member
|
|
37
|
+
it("degrades when a member has no patch target", () => {
|
|
38
38
|
const a = el("a");
|
|
39
|
-
const implicit = el("b", { timingSource: "implicit" });
|
|
40
39
|
const noTarget = el("c", { domId: undefined, selector: undefined });
|
|
41
|
-
expect(buildTimelineGroupResizeMembers([a, implicit], keys("a", "b"), "a", "end")).toBeNull();
|
|
42
40
|
expect(buildTimelineGroupResizeMembers([a, noTarget], keys("a", "c"), "a", "end")).toBeNull();
|
|
43
41
|
});
|
|
44
42
|
|
|
@@ -198,7 +198,6 @@ function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): b
|
|
|
198
198
|
playbackStart: element.playbackStart,
|
|
199
199
|
playbackStartAttr: element.playbackStartAttr,
|
|
200
200
|
sourceDuration: element.sourceDuration,
|
|
201
|
-
timingSource: element.timingSource,
|
|
202
201
|
timelineLocked: element.timelineLocked,
|
|
203
202
|
});
|
|
204
203
|
return edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `TimelineElement.track` is a z-order SORT key, not a row number: an expanded
|
|
5
5
|
* sub-composition child gets a synthesized fractional key (`host.track + n /
|
|
6
|
-
* (siblings + 2)`, see `
|
|
6
|
+
* (siblings + 2)`, see `useTimelineRowElements`), so putting it in a string
|
|
7
7
|
* announces "Hide track 0.16666666666666666". Every user-visible track number,
|
|
8
8
|
* whether it is rendered by a component or baked into an undo-history label,
|
|
9
9
|
* routes through here; the raw key stays in callbacks and lookups only.
|