@hyperframes/studio 0.8.108 → 0.8.110
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-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +64 -9
- package/dist/index.html +2 -2
- package/dist/index.js +8216 -6268
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +334 -0
- package/src/components/editor/mediaLinkEdits.ts +303 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +11 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
- package/src/hooks/timelineLinkEditPlan.ts +128 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +9 -8
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +49 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
- package/src/hooks/useTimelineLinkEditing.ts +158 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +58 -0
- package/src/player/components/TimelineClip.tsx +9 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +288 -31
- package/src/player/components/audioClipLink.ts +151 -27
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
- package/src/player/components/clipMenuAudioItems.tsx +44 -57
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +174 -0
- package/src/player/components/clipMenuLinkItems.tsx +166 -0
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +80 -9
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +17 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +29 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +41 -0
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +28 -0
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +3 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { LINK_LABEL_COLORS, linkLabelColor } from "./linkLabelColor";
|
|
3
|
+
|
|
4
|
+
describe("linkLabelColor", () => {
|
|
5
|
+
it("has no colour for an unlinked clip", () => {
|
|
6
|
+
expect(linkLabelColor(undefined)).toBeNull();
|
|
7
|
+
expect(linkLabelColor("")).toBeNull();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("walks the palette in lk-N order, wrapping", () => {
|
|
11
|
+
expect(linkLabelColor("lk-1")).toBe(LINK_LABEL_COLORS[0]);
|
|
12
|
+
expect(linkLabelColor("lk-2")).toBe(LINK_LABEL_COLORS[1]);
|
|
13
|
+
expect(linkLabelColor(`lk-${LINK_LABEL_COLORS.length + 1}`)).toBe(LINK_LABEL_COLORS[0]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("is deterministic for hand-written ids", () => {
|
|
17
|
+
expect(linkLabelColor("talk-pair")).toBe(linkLabelColor("talk-pair"));
|
|
18
|
+
expect(LINK_LABEL_COLORS).toContain(linkLabelColor("talk-pair"));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Link-group label colours; teal, violet, white and red belong to clips, selection and the sync badge. */
|
|
2
|
+
export const LINK_LABEL_COLORS = [
|
|
3
|
+
"var(--color-amber-500)",
|
|
4
|
+
"var(--color-sky-400)",
|
|
5
|
+
"var(--color-green-400)",
|
|
6
|
+
"var(--color-pink-400)",
|
|
7
|
+
"var(--color-orange-400)",
|
|
8
|
+
"var(--color-amber-300)",
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
function stableIndex(link: string): number {
|
|
12
|
+
const numbered = /^lk-(\d+)$/.exec(link);
|
|
13
|
+
if (numbered) return Number(numbered[1]) - 1;
|
|
14
|
+
let hash = 0;
|
|
15
|
+
for (const char of link) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
|
16
|
+
return hash;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The same colour for every member of a link group, from its id alone. */
|
|
20
|
+
export function linkLabelColor(link: string | undefined): string | null {
|
|
21
|
+
if (!link) return null;
|
|
22
|
+
const count = LINK_LABEL_COLORS.length;
|
|
23
|
+
return LINK_LABEL_COLORS[((stableIndex(link) % count) + count) % count] ?? null;
|
|
24
|
+
}
|
|
@@ -4,6 +4,7 @@ export const STUDIO_PLAIN_KEYS = {
|
|
|
4
4
|
split: "s",
|
|
5
5
|
record: "r",
|
|
6
6
|
addKeyframe: "k",
|
|
7
|
+
audioGain: "g",
|
|
7
8
|
} as const;
|
|
8
9
|
|
|
9
10
|
export interface ShortcutHint {
|
|
@@ -51,6 +52,7 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
|
|
|
51
52
|
{ key: "⌘V", label: "Paste element" },
|
|
52
53
|
{ key: "⌘X", label: "Cut element" },
|
|
53
54
|
{ key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
|
|
55
|
+
{ key: hintKey(STUDIO_PLAIN_KEYS.audioGain), label: "Audio Gain for clips with sound" },
|
|
54
56
|
{ key: "⇧Click", label: "Razor tool: split all tracks" },
|
|
55
57
|
{ key: "[", label: "Select clips starting before the playhead" },
|
|
56
58
|
{ key: "]", label: "Select clips running at or after the playhead" },
|
|
@@ -39,6 +39,14 @@ export interface TimelineDropCallbacks {
|
|
|
39
39
|
) => Promise<void> | void;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export type TimelineLinkEdit =
|
|
43
|
+
| { kind: "unlink"; elements: readonly TimelineElement[] }
|
|
44
|
+
| { kind: "link"; elements: readonly TimelineElement[] }
|
|
45
|
+
| { kind: "detach"; element: TimelineElement }
|
|
46
|
+
| { kind: "merge"; video: TimelineElement; audio: TimelineElement }
|
|
47
|
+
| { kind: "move-into-sync"; element: TimelineElement; start: number }
|
|
48
|
+
| { kind: "slip-into-sync"; element: TimelineElement; mediaStart: number };
|
|
49
|
+
|
|
42
50
|
export interface TimelineEditCallbacks {
|
|
43
51
|
onMoveElement?: (
|
|
44
52
|
element: TimelineElement,
|
|
@@ -114,11 +122,20 @@ export interface TimelineEditCallbacks {
|
|
|
114
122
|
value: string | null,
|
|
115
123
|
label: string,
|
|
116
124
|
) => Promise<TimelineEditOutcome | void>;
|
|
125
|
+
/** One attribute on several clips, saved as one undo step. */
|
|
126
|
+
onSetElementsAttributeQuiet?: (
|
|
127
|
+
edits: ReadonlyArray<{ element: TimelineElement; value: string | null }>,
|
|
128
|
+
attr: string,
|
|
129
|
+
label: string,
|
|
130
|
+
) => Promise<TimelineEditOutcome | void>;
|
|
117
131
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
132
|
+
onLinkEdit?: (edit: TimelineLinkEdit) => Promise<void> | void;
|
|
133
|
+
onDeleteElementOnly?: (element: TimelineElement) => Promise<void> | void;
|
|
118
134
|
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
119
135
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
120
136
|
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
|
121
137
|
onFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
|
|
138
|
+
onNotice?: (message: string, tone?: "error" | "info") => void;
|
|
122
139
|
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
123
140
|
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
|
|
124
141
|
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
@@ -70,6 +70,7 @@ interface TimelineClipDragGestureLifecycleInput {
|
|
|
70
70
|
onBlockedEditAttemptRef: RefObject<
|
|
71
71
|
((element: TimelineElement, intent: BlockedClipState["intent"]) => void) | undefined
|
|
72
72
|
>;
|
|
73
|
+
onLinkEditRef: RefObject<TimelineEditCallbacks["onLinkEdit"]>;
|
|
73
74
|
readZIndexRef: RefObject<((element: TimelineElement) => number) | undefined>;
|
|
74
75
|
onStackingPatchesRef: RefObject<
|
|
75
76
|
((patches: StackingPatch[]) => Promise<unknown> | void) | undefined
|
|
@@ -107,6 +108,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
107
108
|
onResizeElementRef,
|
|
108
109
|
onResizeElementsRef,
|
|
109
110
|
onBlockedEditAttemptRef,
|
|
111
|
+
onLinkEditRef,
|
|
110
112
|
readZIndexRef,
|
|
111
113
|
onStackingPatchesRef,
|
|
112
114
|
refreshAfterLaneMoveRef,
|
|
@@ -228,6 +230,11 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
228
230
|
if (drag && pointerMatchesGesture(event)) handleDragPointerMove(event, drag);
|
|
229
231
|
};
|
|
230
232
|
|
|
233
|
+
const unlinkAltEdited = (gesture: { element: TimelineElement; altKey?: boolean }) => {
|
|
234
|
+
if (!gesture.altKey || !gesture.element.link) return;
|
|
235
|
+
void onLinkEditRef.current?.({ kind: "unlink", elements: [gesture.element] });
|
|
236
|
+
};
|
|
237
|
+
|
|
231
238
|
const commitResizePointerUp = (
|
|
232
239
|
resize: ResizingClipState,
|
|
233
240
|
groupSession: TimelineGroupResizeSession | null,
|
|
@@ -271,6 +278,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
271
278
|
]);
|
|
272
279
|
console.error("[Timeline] Failed to persist clip resize", error);
|
|
273
280
|
});
|
|
281
|
+
unlinkAltEdited(resize);
|
|
274
282
|
};
|
|
275
283
|
|
|
276
284
|
const finishBlockedPointerUp = (blocked: BlockedClipState) => {
|
|
@@ -293,6 +301,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
293
301
|
onStackingPatches: onStackingPatchesRef.current,
|
|
294
302
|
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
|
295
303
|
});
|
|
304
|
+
unlinkAltEdited(drag);
|
|
296
305
|
};
|
|
297
306
|
|
|
298
307
|
/** Group gestures commit atomically: one missing member cancels the whole resize. */
|
|
@@ -726,3 +726,99 @@ describe("a nested clip's drop stops at its host's start in the preview", () =>
|
|
|
726
726
|
expect(await saved(early, 1)).toBe(1);
|
|
727
727
|
});
|
|
728
728
|
});
|
|
729
|
+
|
|
730
|
+
describe("an audio clip stays inside its partner video", () => {
|
|
731
|
+
const video = (extra: Partial<TimelineElement> = {}) => ({
|
|
732
|
+
...clip("v", 0, 10, 20, 1),
|
|
733
|
+
syncOrigin: "lk-1",
|
|
734
|
+
...extra,
|
|
735
|
+
});
|
|
736
|
+
const audio = (extra: Partial<TimelineElement> = {}) => ({
|
|
737
|
+
...clip("a", 1, 12, 8, 0, "audio"),
|
|
738
|
+
syncOrigin: "lk-1",
|
|
739
|
+
...extra,
|
|
740
|
+
});
|
|
741
|
+
const audioCtx = (elements: TimelineElement[], selected: string[] = []) => ({
|
|
742
|
+
...ctx(undefined, elements),
|
|
743
|
+
trackOrder: [0, 1],
|
|
744
|
+
audioTracks: new Set([1]),
|
|
745
|
+
selectedKeys: new Set(selected),
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("a drag past the video's end stops with the audio's end on it", () => {
|
|
749
|
+
const a = audio();
|
|
750
|
+
const { drag, clientX, clientY } = horizontalDrag(a, 1.5, 30);
|
|
751
|
+
const next = computeDragPreview(drag, clientX, clientY, audioCtx([video(), a]));
|
|
752
|
+
expect(next.previewStart).toBe(22);
|
|
753
|
+
expect(next.pointerClientX).toBe(drag.originClientX + 10 * PPS);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it("a drag before the video's start stops at it, linked or same-source", () => {
|
|
757
|
+
const linked = audio({ syncOrigin: undefined, link: "lk-9" });
|
|
758
|
+
const { drag, clientX, clientY } = horizontalDrag(linked, 1.5, -10);
|
|
759
|
+
const elements = [video({ syncOrigin: undefined, link: "lk-9" }), linked];
|
|
760
|
+
expect(computeDragPreview(drag, clientX, clientY, audioCtx(elements)).previewStart).toBe(10);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("moves freely with no partner video, or when the video moves with it", () => {
|
|
764
|
+
const loose = audio({ syncOrigin: undefined });
|
|
765
|
+
const free = horizontalDrag(loose, 1.5, 30);
|
|
766
|
+
expect(
|
|
767
|
+
computeDragPreview(free.drag, free.clientX, free.clientY, audioCtx([video(), loose]))
|
|
768
|
+
.previewStart,
|
|
769
|
+
).toBe(42);
|
|
770
|
+
const a = audio();
|
|
771
|
+
const both = horizontalDrag(a, 1.5, 30);
|
|
772
|
+
expect(
|
|
773
|
+
computeDragPreview(both.drag, both.clientX, both.clientY, audioCtx([video(), a], ["v", "a"]))
|
|
774
|
+
.previewStart,
|
|
775
|
+
).toBe(42);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("rejects a drop that collision placement would push outside the video", () => {
|
|
779
|
+
const a = audio();
|
|
780
|
+
const obstacle = clip("o", 1, 20, 10, 0, "audio");
|
|
781
|
+
const { drag, clientX, clientY } = horizontalDrag(a, 1.5, 10);
|
|
782
|
+
const next = computeDragPreview(drag, clientX, clientY, audioCtx([video(), a, obstacle]));
|
|
783
|
+
expect(next.previewStart).toBeGreaterThanOrEqual(10);
|
|
784
|
+
expect(next.previewStart + a.duration).toBeLessThanOrEqual(30);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("keeps an audio carried in a multi-selection inside its video", () => {
|
|
788
|
+
const a = audio();
|
|
789
|
+
const title = clip("t", 0, 40, 4, 0, "text");
|
|
790
|
+
const { drag, clientX, clientY } = horizontalDrag(title, 0.5, 30);
|
|
791
|
+
const elements = [video(), a, title];
|
|
792
|
+
const next = computeDragPreview(drag, clientX, clientY, audioCtx(elements, ["t", "a"]));
|
|
793
|
+
expect(next.previewStart).toBe(50);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
const trim = (edge: "start" | "end", deltaSeconds: number, gestureKeys: string[] = []) =>
|
|
797
|
+
computeResizePreview(
|
|
798
|
+
{
|
|
799
|
+
element: audio({ sourceDuration: 100, playbackStart: 20 }),
|
|
800
|
+
edge,
|
|
801
|
+
originClientX: 0,
|
|
802
|
+
previewStart: 12,
|
|
803
|
+
previewDuration: 8,
|
|
804
|
+
started: true,
|
|
805
|
+
},
|
|
806
|
+
deltaSeconds * 100,
|
|
807
|
+
{
|
|
808
|
+
scroll: fakeScroll(),
|
|
809
|
+
pps: 100,
|
|
810
|
+
buildSnapTargets: () => [],
|
|
811
|
+
elements: [video(), audio()],
|
|
812
|
+
gestureKeys: new Set(["a", ...gestureKeys]),
|
|
813
|
+
},
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
it("a trim cannot extend past the video's start or end", () => {
|
|
817
|
+
expect(trim("end", 50)).toMatchObject({ previewStart: 12, previewDuration: 18 });
|
|
818
|
+
expect(trim("start", -10)).toMatchObject({ previewStart: 10, previewDuration: 10 });
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it("a trim carried by the video too is not held to the video's old span", () => {
|
|
822
|
+
expect(trim("end", 50, ["v"]).previewDuration).toBe(58);
|
|
823
|
+
});
|
|
824
|
+
});
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { groupMoveFloor, resolveGroupMovers } from "./timelineMultiDragPreview";
|
|
24
24
|
import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
|
|
25
25
|
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
26
|
+
import { heldAudioShiftRange, heldPartnerVideoBounds } from "./audioClipLink";
|
|
26
27
|
|
|
27
28
|
/** Snap-target builder closure supplied by the hook (closes over refs + store). */
|
|
28
29
|
type BuildSnapTargets = (
|
|
@@ -111,6 +112,7 @@ interface GroupDrag {
|
|
|
111
112
|
obstacles: TimelineElement[];
|
|
112
113
|
/** Lowest start the grabbed clip may take (see groupMoveFloor). */
|
|
113
114
|
floor: number;
|
|
115
|
+
moving: ReadonlySet<string>;
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
function resolveGroupDrag(drag: DraggedClipState, ctx: DragPreviewContext): GroupDrag {
|
|
@@ -119,14 +121,20 @@ function resolveGroupDrag(drag: DraggedClipState, ctx: DragPreviewContext): Grou
|
|
|
119
121
|
ctx.selectedKeys,
|
|
120
122
|
drag.element.key ?? drag.element.id,
|
|
121
123
|
);
|
|
122
|
-
if (!movers)
|
|
124
|
+
if (!movers) {
|
|
125
|
+
const moving = new Set([drag.element.key ?? drag.element.id]);
|
|
126
|
+
return { obstacles: ctx.elements, floor: groupMoveFloor(drag.element, []), moving };
|
|
127
|
+
}
|
|
123
128
|
const moving = new Set(movers.map((e) => e.key ?? e.id));
|
|
124
129
|
return {
|
|
125
130
|
obstacles: ctx.elements.filter((e) => !moving.has(e.key ?? e.id)),
|
|
126
131
|
floor: groupMoveFloor(drag.element, movers),
|
|
132
|
+
moving,
|
|
127
133
|
};
|
|
128
134
|
}
|
|
129
135
|
|
|
136
|
+
const SHIFT_EPSILON_S = 1e-6;
|
|
137
|
+
|
|
130
138
|
/** Recompute the dragged-clip preview (move + snap + group clamp + drop placement). */
|
|
131
139
|
export function computeDragPreview(
|
|
132
140
|
drag: DraggedClipState,
|
|
@@ -179,13 +187,25 @@ export function computeDragPreview(
|
|
|
179
187
|
);
|
|
180
188
|
// A group moves rigidly: the grabbed clip stops where any mover would cross its host's start.
|
|
181
189
|
const group = resolveGroupDrag(drag, ctx);
|
|
182
|
-
const
|
|
190
|
+
const dragKey = drag.element.key ?? drag.element.id;
|
|
191
|
+
const passengers = ctx.elements.filter(
|
|
192
|
+
(el) => (el.key ?? el.id) !== dragKey && group.moving.has(el.key ?? el.id),
|
|
193
|
+
);
|
|
194
|
+
const movers = [drag.element, ...passengers];
|
|
195
|
+
const shift = heldAudioShiftRange(movers, ctx.elements, group.moving);
|
|
196
|
+
const origin = drag.element.start;
|
|
197
|
+
const floored = Math.max(snap.start, group.floor);
|
|
198
|
+
const previewStart = origin + Math.max(shift.min, Math.min(floored - origin, shift.max));
|
|
183
199
|
const placement = resolveDropPlacement(drag, clientY, previewStart, nextMove.track, ctx, group);
|
|
200
|
+
const placedShift = placement.start - origin;
|
|
201
|
+
if (placedShift < shift.min - SHIFT_EPSILON_S || placedShift > shift.max + SHIFT_EPSILON_S) {
|
|
202
|
+
return { ...drag, started: true };
|
|
203
|
+
}
|
|
184
204
|
const { track: previewTrack, insertRow } = placement;
|
|
185
205
|
return {
|
|
186
206
|
...drag,
|
|
187
207
|
started: true,
|
|
188
|
-
pointerClientX: clientX,
|
|
208
|
+
pointerClientX: clientX - (floored - previewStart) * pps,
|
|
189
209
|
pointerClientY: clientY,
|
|
190
210
|
previewStart: placement.start,
|
|
191
211
|
previewTrack,
|
|
@@ -210,6 +230,8 @@ export interface ResizePreviewContext {
|
|
|
210
230
|
scroll: HTMLDivElement | null;
|
|
211
231
|
pps: number;
|
|
212
232
|
buildSnapTargets: BuildSnapTargets;
|
|
233
|
+
elements?: readonly TimelineElement[];
|
|
234
|
+
gestureKeys?: ReadonlySet<string>;
|
|
213
235
|
}
|
|
214
236
|
|
|
215
237
|
export interface ResizePreviewResult {
|
|
@@ -253,14 +275,18 @@ export function computeResizePreview(
|
|
|
253
275
|
// rightward and, after a far move, collapsed a clip to the sliver between its
|
|
254
276
|
// start and the comp end (the 8s→0.95s audio incident). Images/text/shapes
|
|
255
277
|
// have no source, so they extend freely.
|
|
256
|
-
const
|
|
278
|
+
const video = ctx.elements
|
|
279
|
+
? heldPartnerVideoBounds(resize.element, ctx.elements, ctx.gestureKeys ?? new Set())
|
|
280
|
+
: null;
|
|
281
|
+
const minStart = Math.max(clampToHostStart(resize.element, 0), video?.start ?? 0);
|
|
282
|
+
const maxEnd = Math.min(resize.element.start + sourceRemaining, video?.end ?? Infinity);
|
|
257
283
|
let nextResize = resolveTimelineResize(
|
|
258
284
|
{
|
|
259
285
|
start: resize.element.start,
|
|
260
286
|
duration: resize.element.duration,
|
|
261
287
|
originClientX: resize.originClientX,
|
|
262
288
|
pixelsPerSecond: pps,
|
|
263
|
-
minStart
|
|
289
|
+
minStart,
|
|
264
290
|
maxEnd,
|
|
265
291
|
playbackStart:
|
|
266
292
|
resize.edge === "start" && canSeedPlaybackStart
|
|
@@ -303,8 +329,7 @@ export function computeResizePreview(
|
|
|
303
329
|
const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
|
|
304
330
|
const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
|
|
305
331
|
const delta = snapped - nextResize.start;
|
|
306
|
-
const
|
|
307
|
-
const bounds = clipStartTrimDeltaBounds(clip, floor, resolveTimelineMinDuration());
|
|
332
|
+
const bounds = clipStartTrimDeltaBounds(clip, minStart, resolveTimelineMinDuration());
|
|
308
333
|
if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
|
|
309
334
|
if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
|
|
310
335
|
snap = target;
|
|
@@ -33,6 +33,7 @@ export interface DraggedClipState {
|
|
|
33
33
|
snapTime: number | null;
|
|
34
34
|
snapType: TimelineSnapType | null;
|
|
35
35
|
started: boolean;
|
|
36
|
+
altKey?: boolean;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
export interface ResizingClipState {
|
|
@@ -63,6 +64,7 @@ export interface ResizingClipState {
|
|
|
63
64
|
playbackStart?: number;
|
|
64
65
|
}[];
|
|
65
66
|
started: boolean;
|
|
67
|
+
altKey?: boolean;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export interface BlockedClipState {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
9
9
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
10
10
|
import { CLIP_HANDLE_W } from "./timelineLayout";
|
|
11
|
+
import { selectClipWithLinks, toggleClipWithLinks } from "./timelineLinkSelection";
|
|
11
12
|
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
12
13
|
|
|
13
14
|
export interface ClipGestureDeps {
|
|
@@ -17,11 +18,6 @@ export interface ClipGestureDeps {
|
|
|
17
18
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
18
19
|
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
|
19
20
|
blockedClipRef: React.RefObject<BlockedClipState | null>;
|
|
20
|
-
shiftClickClipRef: React.RefObject<{
|
|
21
|
-
element: TimelineElement;
|
|
22
|
-
anchorX: number;
|
|
23
|
-
anchorY: number;
|
|
24
|
-
} | null>;
|
|
25
21
|
suppressClickRef: React.RefObject<boolean>;
|
|
26
22
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
27
23
|
setShowPopover(v: boolean): void;
|
|
@@ -44,9 +40,11 @@ function canStartResize(
|
|
|
44
40
|
return capabilities.canTrimEnd;
|
|
45
41
|
}
|
|
46
42
|
|
|
43
|
+
const isAdditiveClick = (e: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }) =>
|
|
44
|
+
e.shiftKey || e.metaKey || e.ctrlKey;
|
|
45
|
+
|
|
47
46
|
type PointerDownAction =
|
|
48
47
|
| { kind: "ignore" }
|
|
49
|
-
| { kind: "arm-shift-click" }
|
|
50
48
|
| { kind: "block"; intent: BlockedTimelineEditIntent; rect: DOMRect }
|
|
51
49
|
| { kind: "move"; rect: DOMRect };
|
|
52
50
|
|
|
@@ -81,7 +79,7 @@ function resolvePointerDownAction(
|
|
|
81
79
|
): PointerDownAction {
|
|
82
80
|
if (e.button !== 0) return { kind: "ignore" };
|
|
83
81
|
if (usePlayerStore.getState().activeTool === "razor") return { kind: "ignore" };
|
|
84
|
-
if (e
|
|
82
|
+
if (isAdditiveClick(e)) return { kind: "ignore" };
|
|
85
83
|
|
|
86
84
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
87
85
|
const intent = resolveBlockedTimelineEditIntent({
|
|
@@ -118,7 +116,6 @@ export function createClipGestureHandlers(
|
|
|
118
116
|
onRazorSplit,
|
|
119
117
|
onRazorSplitAll,
|
|
120
118
|
blockedClipRef,
|
|
121
|
-
shiftClickClipRef,
|
|
122
119
|
suppressClickRef,
|
|
123
120
|
scrollRef,
|
|
124
121
|
setShowPopover,
|
|
@@ -145,6 +142,7 @@ export function createClipGestureHandlers(
|
|
|
145
142
|
previewDuration: el.duration,
|
|
146
143
|
previewPlaybackStart: el.playbackStart,
|
|
147
144
|
started: false,
|
|
145
|
+
altKey: e.altKey,
|
|
148
146
|
});
|
|
149
147
|
};
|
|
150
148
|
|
|
@@ -152,11 +150,6 @@ export function createClipGestureHandlers(
|
|
|
152
150
|
const action = resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement);
|
|
153
151
|
if (action.kind === "ignore") return;
|
|
154
152
|
|
|
155
|
-
if (action.kind === "arm-shift-click") {
|
|
156
|
-
shiftClickClipRef.current = { element: el, anchorX: e.clientX, anchorY: e.clientY };
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
153
|
if (action.kind === "block") {
|
|
161
154
|
blockedClipRef.current = {
|
|
162
155
|
pointerId: e.pointerId,
|
|
@@ -191,33 +184,38 @@ export function createClipGestureHandlers(
|
|
|
191
184
|
snapTime: null,
|
|
192
185
|
snapType: null,
|
|
193
186
|
started: false,
|
|
187
|
+
altKey: e.altKey,
|
|
194
188
|
});
|
|
195
189
|
};
|
|
196
190
|
|
|
191
|
+
const razorSplit = (e: ReactMouseEvent, split: NonNullable<typeof onRazorSplit>): void => {
|
|
192
|
+
const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
193
|
+
const splitTime = previewElement.start + (e.clientX - clipRect.left) / pps;
|
|
194
|
+
const clampedTime = Math.max(
|
|
195
|
+
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
|
|
196
|
+
Math.min(
|
|
197
|
+
previewElement.start + previewElement.duration - SPLIT_BOUNDARY_EPSILON_S,
|
|
198
|
+
splitTime,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
if (e.shiftKey && onRazorSplitAll) onRazorSplitAll(clampedTime);
|
|
202
|
+
else split(el, clampedTime);
|
|
203
|
+
};
|
|
204
|
+
|
|
197
205
|
const onClick = (e: ReactMouseEvent): void => {
|
|
198
206
|
e.stopPropagation();
|
|
199
207
|
if (suppressClickRef.current) return;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
Math.min(
|
|
208
|
-
previewElement.start + previewElement.duration - SPLIT_BOUNDARY_EPSILON_S,
|
|
209
|
-
splitTime,
|
|
210
|
-
),
|
|
211
|
-
);
|
|
212
|
-
if (e.shiftKey && onRazorSplitAll) {
|
|
213
|
-
onRazorSplitAll(clampedTime);
|
|
214
|
-
} else {
|
|
215
|
-
onRazorSplit(el, clampedTime);
|
|
216
|
-
}
|
|
208
|
+
if (usePlayerStore.getState().activeTool === "razor" && onRazorSplit) {
|
|
209
|
+
razorSplit(e, onRazorSplit);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (isAdditiveClick(e)) {
|
|
213
|
+
const primary = toggleClipWithLinks(elementKey, e.altKey);
|
|
214
|
+
onSelectElement?.(primary);
|
|
217
215
|
return;
|
|
218
216
|
}
|
|
219
217
|
// Clip selection is idempotent; empty timeline space owns deselection.
|
|
220
|
-
|
|
218
|
+
selectClipWithLinks(elementKey, e.altKey, setSelectedElementId);
|
|
221
219
|
onSelectElement?.(el);
|
|
222
220
|
};
|
|
223
221
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
buildClipRangeSelection,
|
|
4
3
|
buildPromptCopyText,
|
|
5
4
|
buildTimelineElementAgentPrompt,
|
|
6
5
|
buildTimelineAgentPrompt,
|
|
@@ -607,18 +606,6 @@ describe("resolveBlockedTimelineEditIntent", () => {
|
|
|
607
606
|
});
|
|
608
607
|
});
|
|
609
608
|
|
|
610
|
-
describe("buildClipRangeSelection", () => {
|
|
611
|
-
it("anchors the full clip range at the click position", () => {
|
|
612
|
-
expect(
|
|
613
|
-
buildClipRangeSelection({ start: 1.25, duration: 3.5 }, { anchorX: 320, anchorY: 180 }),
|
|
614
|
-
).toEqual({
|
|
615
|
-
start: 1.25,
|
|
616
|
-
end: 4.75,
|
|
617
|
-
anchorX: 320,
|
|
618
|
-
anchorY: 180,
|
|
619
|
-
});
|
|
620
|
-
});
|
|
621
|
-
});
|
|
622
609
|
describe("resolveTimelineAutoScroll", () => {
|
|
623
610
|
it("does not scroll when the pointer stays away from the edges", () => {
|
|
624
611
|
expect(
|
|
@@ -382,17 +382,6 @@ export function resolveBlockedTimelineEditIntent(input: {
|
|
|
382
382
|
return "move";
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
-
export function buildClipRangeSelection(
|
|
386
|
-
clip: { start: number; duration: number },
|
|
387
|
-
anchor: { anchorX: number; anchorY: number },
|
|
388
|
-
): TimelineRangeSelection {
|
|
389
|
-
return {
|
|
390
|
-
start: clip.start,
|
|
391
|
-
end: clip.start + clip.duration,
|
|
392
|
-
anchorX: anchor.anchorX,
|
|
393
|
-
anchorY: anchor.anchorY,
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
385
|
export function buildTimelineAgentPrompt({
|
|
397
386
|
rangeStart,
|
|
398
387
|
rangeEnd,
|
|
@@ -66,11 +66,6 @@ export interface TimelineLaneBaseProps {
|
|
|
66
66
|
setResizingClip: (v: ResizingClipState | null) => void;
|
|
67
67
|
setDraggedClip: (v: DraggedClipState | null) => void;
|
|
68
68
|
setSelectedElementId: (id: string | null) => void;
|
|
69
|
-
shiftClickClipRef: React.RefObject<{
|
|
70
|
-
element: TimelineElement;
|
|
71
|
-
anchorX: number;
|
|
72
|
-
anchorY: number;
|
|
73
|
-
} | null>;
|
|
74
69
|
getPreviewElement: (element: TimelineElement) => TimelineElement;
|
|
75
70
|
getTrackStyle: (tag: string) => TrackVisualStyle;
|
|
76
71
|
keyframeCache?: Map<string, KeyframeCacheEntry>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { expandToLinkedMembers, linkedMembersOf } from "./audioClipLink";
|
|
3
|
+
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Select `key` the way a clip click does, then widen to link partners; Alt
|
|
7
|
+
* (or Linked Selection off) selects `key` alone, even inside a larger selection.
|
|
8
|
+
*/
|
|
9
|
+
export function selectClipWithLinks(
|
|
10
|
+
key: string,
|
|
11
|
+
altKey: boolean,
|
|
12
|
+
setSelectedElementId: (id: string) => void,
|
|
13
|
+
): void {
|
|
14
|
+
const state = usePlayerStore.getState();
|
|
15
|
+
if (altKey || !isLinkedSelectionOn()) {
|
|
16
|
+
state.setSelection([key], key);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
setSelectedElementId(key);
|
|
20
|
+
const { selectedElementIds, elements } = usePlayerStore.getState();
|
|
21
|
+
const expanded = expandToLinkedMembers(selectedElementIds, elements);
|
|
22
|
+
if (expanded.size > selectedElementIds.size) state.setSelection(expanded, key);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function toggleClipWithLinks(key: string, altKey: boolean): TimelineElement | null {
|
|
26
|
+
const state = usePlayerStore.getState();
|
|
27
|
+
const element = state.elements.find((el) => (el.key ?? el.id) === key);
|
|
28
|
+
const members = element
|
|
29
|
+
? linkedMembersOf(element, state.elements, !altKey && isLinkedSelectionOn())
|
|
30
|
+
: [];
|
|
31
|
+
const memberKeys = members.length > 0 ? members.map((el) => el.key ?? el.id) : [key];
|
|
32
|
+
const next = new Set(state.selectedElementIds);
|
|
33
|
+
const removing = next.has(key);
|
|
34
|
+
for (const memberKey of memberKeys) {
|
|
35
|
+
if (removing) next.delete(memberKey);
|
|
36
|
+
else next.add(memberKey);
|
|
37
|
+
}
|
|
38
|
+
state.setSelection(next, removing ? state.selectedElementId : key);
|
|
39
|
+
const primary = usePlayerStore.getState().selectedElementId;
|
|
40
|
+
return state.elements.find((el) => (el.key ?? el.id) === primary) ?? null;
|
|
41
|
+
}
|
|
@@ -40,7 +40,10 @@ export function getTimelineScrollTopForGeometryChange(
|
|
|
40
40
|
previous: TimelineRowGeometry,
|
|
41
41
|
next: TimelineRowGeometry,
|
|
42
42
|
scrollTop: number,
|
|
43
|
+
dragAutoScrollOwnsScrollTop = false,
|
|
43
44
|
): number {
|
|
45
|
+
const atTopSoRowsAddedAboveStayShown = scrollTop <= 0;
|
|
46
|
+
if (dragAutoScrollOwnsScrollTop || atTopSoRowsAddedAboveStayShown) return scrollTop;
|
|
44
47
|
const anchor = previous.getRowPositionFromY(scrollTop + RULER_H);
|
|
45
48
|
if (anchor.row < 0 || anchor.row >= previous.rowKeys.length) return scrollTop;
|
|
46
49
|
const anchorKey = previous.rowKeys[anchor.row];
|
|
@@ -11,14 +11,11 @@ import { committedClipPath } from "../../components/editor/cropPresetStore";
|
|
|
11
11
|
import { isAudibleVideoNode } from "../lib/timelineElementHelpers";
|
|
12
12
|
import type { ClipToolState } from "./clipToolAttrs";
|
|
13
13
|
|
|
14
|
-
const LINK_ATTR = "data-link";
|
|
15
|
-
|
|
16
14
|
const WATCHED_ATTRS = [
|
|
17
15
|
HF_AUDIO_FX_ATTR,
|
|
18
16
|
HF_AUDIO_AUTOMATION_ATTR,
|
|
19
17
|
HF_AUDIO_CARVE_ATTR,
|
|
20
18
|
HF_COLOR_GRADING_ATTR,
|
|
21
|
-
LINK_ATTR,
|
|
22
19
|
"data-volume",
|
|
23
20
|
"data-has-audio",
|
|
24
21
|
"muted",
|
|
@@ -42,7 +39,6 @@ function toolStateFromNode(node: Element): ClipToolState {
|
|
|
42
39
|
colorGrading: node.getAttribute(HF_COLOR_GRADING_ATTR),
|
|
43
40
|
clipPath: committedClipPath(node),
|
|
44
41
|
carve: node.getAttribute(HF_AUDIO_CARVE_ATTR),
|
|
45
|
-
link: node.getAttribute(LINK_ATTR),
|
|
46
42
|
};
|
|
47
43
|
}
|
|
48
44
|
|
|
@@ -58,7 +54,6 @@ function toolStateFromElement(el: TimelineElement): ClipToolState {
|
|
|
58
54
|
colorGrading: null,
|
|
59
55
|
clipPath: null,
|
|
60
56
|
carve: null,
|
|
61
|
-
link: null,
|
|
62
57
|
};
|
|
63
58
|
}
|
|
64
59
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import type { ClipContextMenuState } from "./TimelineProvider";
|
|
4
|
+
import { selectClipWithLinks } from "./timelineLinkSelection";
|
|
4
5
|
|
|
5
6
|
/** A clicked member of an active multi-selection keeps the whole group
|
|
6
7
|
* selected, so the context menu it opens acts on all of it. */
|
|
@@ -16,7 +17,7 @@ export function useClipContextMenu(
|
|
|
16
17
|
e.preventDefault();
|
|
17
18
|
const id = el.key ?? el.id;
|
|
18
19
|
if (!(selectedElementIds.size > 1 && selectedElementIds.has(id))) {
|
|
19
|
-
|
|
20
|
+
selectClipWithLinks(id, e.altKey, setSelectedElementId);
|
|
20
21
|
onSelectElement?.(el);
|
|
21
22
|
}
|
|
22
23
|
dismissGapMenu();
|