@hyperframes/studio 0.8.108 → 0.8.109
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-CJYtikAy.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
- package/dist/assets/{index-C5dyUHwk.js → index-HTFVHUJ4.js} +1 -1
- 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 +44 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7021 -5669
- 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/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/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +8 -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 +3 -0
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -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.ts +9 -0
- 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 +6 -3
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +36 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- 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/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +6 -0
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
- package/src/player/components/clipMenuAudioItems.tsx +31 -23
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuToolItems.tsx +1 -3
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +11 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- 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/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -27,6 +27,12 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
|
27
27
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
28
28
|
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
29
29
|
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
30
|
+
import { useTimelineEditGuard } from "./useTimelineEditGuard";
|
|
31
|
+
import {
|
|
32
|
+
linkEditTargets,
|
|
33
|
+
useTimelineLinkEditing,
|
|
34
|
+
withLinkPartners,
|
|
35
|
+
} from "./useTimelineLinkEditing";
|
|
30
36
|
import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
|
|
31
37
|
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
|
|
32
38
|
import {
|
|
@@ -35,26 +41,12 @@ import {
|
|
|
35
41
|
} from "./timelineTrackVisibility";
|
|
36
42
|
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
|
37
43
|
import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
|
|
38
|
-
import {
|
|
39
|
-
useTimelineEditGate,
|
|
40
|
-
useTimelineEditRefusal,
|
|
41
|
-
type TimelineEditOutcome,
|
|
42
|
-
} from "./timelineEditPermission";
|
|
44
|
+
import { useTimelineEditGate, type TimelineEditOutcome } from "./timelineEditPermission";
|
|
43
45
|
import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
|
44
46
|
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
45
47
|
import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
46
48
|
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
47
49
|
|
|
48
|
-
type GuardedTimelineHandler = (...args: never[]) => Promise<unknown>;
|
|
49
|
-
type GuardedTimelineResolver = (...args: never[]) => readonly TimelineElement[];
|
|
50
|
-
type GuardedTimelineRefusal = (reason: string, ...args: never[]) => unknown;
|
|
51
|
-
|
|
52
|
-
interface GuardedTimelineEntry {
|
|
53
|
-
resolveTargets: GuardedTimelineResolver;
|
|
54
|
-
onRefused?: GuardedTimelineRefusal;
|
|
55
|
-
wrapped: GuardedTimelineHandler;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
50
|
export function useTimelineEditing({
|
|
59
51
|
projectId,
|
|
60
52
|
activeCompPath,
|
|
@@ -80,40 +72,7 @@ export function useTimelineEditing({
|
|
|
80
72
|
const editQueueRef = useRef(Promise.resolve());
|
|
81
73
|
const track = useTrackPendingTimelineEdit();
|
|
82
74
|
const checkEditable = useTimelineEditGate(canEdit, showToast);
|
|
83
|
-
const
|
|
84
|
-
const refuseEditRef = useRef(refuseEdit);
|
|
85
|
-
refuseEditRef.current = refuseEdit;
|
|
86
|
-
const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
|
|
87
|
-
// Refuses (no call, no write, no history entry) when any target is
|
|
88
|
-
// blocked; otherwise runs fn as before. Cached by fn identity — like
|
|
89
|
-
// track() — so a fresh closure here doesn't defeat track's own cache.
|
|
90
|
-
const guard = useCallback(
|
|
91
|
-
<H extends (...args: never[]) => Promise<unknown>>(
|
|
92
|
-
resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
|
|
93
|
-
fn: H,
|
|
94
|
-
onRefused?: (reason: string, ...args: Parameters<H>) => Awaited<ReturnType<H>>,
|
|
95
|
-
): H => {
|
|
96
|
-
const key = fn as unknown as GuardedTimelineHandler;
|
|
97
|
-
const cached = guardedRef.current.get(key);
|
|
98
|
-
if (cached) {
|
|
99
|
-
cached.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
|
|
100
|
-
cached.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
|
|
101
|
-
return cached.wrapped as H;
|
|
102
|
-
}
|
|
103
|
-
const entry = {} as GuardedTimelineEntry;
|
|
104
|
-
entry.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
|
|
105
|
-
entry.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
|
|
106
|
-
entry.wrapped = ((...args: Parameters<H>) => {
|
|
107
|
-
const reason = refuseEditRef.current(entry.resolveTargets(...(args as never[])));
|
|
108
|
-
if (reason !== null)
|
|
109
|
-
return Promise.resolve(entry.onRefused?.(reason, ...(args as never[])));
|
|
110
|
-
return fn(...args);
|
|
111
|
-
}) as H as unknown as GuardedTimelineHandler;
|
|
112
|
-
guardedRef.current.set(key, entry);
|
|
113
|
-
return entry.wrapped as H;
|
|
114
|
-
},
|
|
115
|
-
[],
|
|
116
|
-
);
|
|
75
|
+
const guard = useTimelineEditGuard(canEdit, showToast);
|
|
117
76
|
|
|
118
77
|
const enqueueEdit = useCallback(
|
|
119
78
|
(
|
|
@@ -252,7 +211,7 @@ export function useTimelineEditing({
|
|
|
252
211
|
return reorderDone
|
|
253
212
|
.then(() => {
|
|
254
213
|
// SDK setTiming writes start only; a lane change needs the fallback's track patch.
|
|
255
|
-
if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
|
|
214
|
+
if (sdkSession && element.hfId && !element.link && !needsExtension && !trackChanged) {
|
|
256
215
|
return sdkTimingPersist(
|
|
257
216
|
element.hfId,
|
|
258
217
|
targetPath,
|
|
@@ -359,7 +318,7 @@ export function useTimelineEditing({
|
|
|
359
318
|
finishResizeGsapSync(),
|
|
360
319
|
);
|
|
361
320
|
const persistDone =
|
|
362
|
-
sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
|
|
321
|
+
sdkSession && element.hfId && !element.link && !hasPbsAdjustment && !needsExtension
|
|
363
322
|
? sdkTimingPersist(
|
|
364
323
|
element.hfId,
|
|
365
324
|
targetPath,
|
|
@@ -461,7 +420,7 @@ export function useTimelineEditing({
|
|
|
461
420
|
isRecordingRef,
|
|
462
421
|
});
|
|
463
422
|
|
|
464
|
-
const { handleTimelineElementsDelete
|
|
423
|
+
const { handleTimelineElementsDelete } = useTimelineDeleteOps({
|
|
465
424
|
projectIdRef,
|
|
466
425
|
activeCompPath,
|
|
467
426
|
timelineElements,
|
|
@@ -491,6 +450,20 @@ export function useTimelineEditing({
|
|
|
491
450
|
checkEditable,
|
|
492
451
|
});
|
|
493
452
|
|
|
453
|
+
const linkEditing = useTimelineLinkEditing({
|
|
454
|
+
projectIdRef,
|
|
455
|
+
activeCompPath,
|
|
456
|
+
editQueueRef,
|
|
457
|
+
pendingTimelineEditPathRef,
|
|
458
|
+
isRecordingRef,
|
|
459
|
+
showToast,
|
|
460
|
+
writeProjectFile,
|
|
461
|
+
recordEdit,
|
|
462
|
+
reloadPreview,
|
|
463
|
+
forceReloadSdkSession,
|
|
464
|
+
handleTimelineElementsDelete,
|
|
465
|
+
});
|
|
466
|
+
|
|
494
467
|
const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
|
|
495
468
|
const { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame } = useRazorSplit({
|
|
496
469
|
projectId,
|
|
@@ -526,7 +499,9 @@ export function useTimelineEditing({
|
|
|
526
499
|
// Every write-handler is tracked here, the one place all hand edits
|
|
527
500
|
// converge, so undo never races a write; canEdit gates the same point.
|
|
528
501
|
// Coverage boundary: see the PR body, not every kind resolves an element.
|
|
529
|
-
const trackedRazorSplit = track(
|
|
502
|
+
const trackedRazorSplit = track(
|
|
503
|
+
guard((element) => withLinkPartners([element]), handleRazorSplit),
|
|
504
|
+
);
|
|
530
505
|
return {
|
|
531
506
|
handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
|
|
532
507
|
handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
|
|
@@ -568,10 +543,16 @@ export function useTimelineEditing({
|
|
|
568
543
|
),
|
|
569
544
|
),
|
|
570
545
|
},
|
|
571
|
-
handleTimelineElementDelete: track(
|
|
546
|
+
handleTimelineElementDelete: track(
|
|
547
|
+
guard((element) => withLinkPartners([element]), linkEditing.handleLinkedElementDelete),
|
|
548
|
+
),
|
|
572
549
|
handleTimelineElementsDelete: track(
|
|
573
|
-
guard(
|
|
550
|
+
guard(withLinkPartners, linkEditing.handleLinkedElementsDelete),
|
|
551
|
+
),
|
|
552
|
+
handleTimelineElementDeleteOnly: track(
|
|
553
|
+
guard((element) => [element], linkEditing.handleDeleteElementOnly),
|
|
574
554
|
),
|
|
555
|
+
handleLinkEdit: track(guard(linkEditTargets, linkEditing.handleLinkEdit)),
|
|
575
556
|
handleTimelineElementSplit: trackedRazorSplit,
|
|
576
557
|
handleRazorSplit: trackedRazorSplit,
|
|
577
558
|
handleFreezeFrame: track(guard((element) => [element], handleFreezeFrame)),
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
6
|
+
import { useTimelineLinkEditing, withLinkPartners } from "./useTimelineLinkEditing";
|
|
7
|
+
import { useLinkedClipPreferences } from "../utils/linkedClipPreferences";
|
|
8
|
+
|
|
9
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const clip = (id: string, tag: string, link?: string): TimelineElement => ({
|
|
12
|
+
id,
|
|
13
|
+
domId: id,
|
|
14
|
+
tag,
|
|
15
|
+
start: 0,
|
|
16
|
+
duration: 4,
|
|
17
|
+
track: 0,
|
|
18
|
+
...(link ? { link } : {}),
|
|
19
|
+
});
|
|
20
|
+
const video = clip("talk", "video", "lk-1");
|
|
21
|
+
const audio = clip("talk-audio", "audio", "lk-1");
|
|
22
|
+
const title = clip("title", "div");
|
|
23
|
+
|
|
24
|
+
const SOURCE =
|
|
25
|
+
'<div><video id="talk" muted data-link="lk-1"></video><audio id="talk-audio" data-link="lk-1"></audio></div>';
|
|
26
|
+
|
|
27
|
+
async function renderLinkEditing(handleTimelineElementsDelete = vi.fn()) {
|
|
28
|
+
usePlayerStore.getState().setElements([video, audio, title]);
|
|
29
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
30
|
+
const recordEdit = vi.fn().mockResolvedValue(undefined);
|
|
31
|
+
let api: ReturnType<typeof useTimelineLinkEditing> | undefined;
|
|
32
|
+
function Harness() {
|
|
33
|
+
api = useTimelineLinkEditing({
|
|
34
|
+
projectIdRef: { current: "p1" },
|
|
35
|
+
activeCompPath: "index.html",
|
|
36
|
+
editQueueRef: { current: Promise.resolve() },
|
|
37
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
38
|
+
showToast: vi.fn(),
|
|
39
|
+
writeProjectFile,
|
|
40
|
+
recordEdit,
|
|
41
|
+
reloadPreview: vi.fn(),
|
|
42
|
+
handleTimelineElementsDelete,
|
|
43
|
+
});
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
47
|
+
await act(async () => root.render(React.createElement(Harness)));
|
|
48
|
+
if (!api) throw new Error("hook did not render");
|
|
49
|
+
return { api, writeProjectFile, recordEdit, unmount: () => act(() => root.unmount()) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("withLinkPartners", () => {
|
|
53
|
+
it("adds partners after the selection, once", () => {
|
|
54
|
+
usePlayerStore.getState().setElements([video, audio, title]);
|
|
55
|
+
expect(withLinkPartners([video, title]).map((el) => el.id)).toEqual([
|
|
56
|
+
"talk",
|
|
57
|
+
"title",
|
|
58
|
+
"talk-audio",
|
|
59
|
+
]);
|
|
60
|
+
expect(withLinkPartners([video, audio])).toHaveLength(2);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("useTimelineLinkEditing", () => {
|
|
65
|
+
afterEach(() => vi.restoreAllMocks());
|
|
66
|
+
|
|
67
|
+
it("deletes a linked clip with its partner", async () => {
|
|
68
|
+
const del = vi.fn().mockResolvedValue(undefined);
|
|
69
|
+
const { api, unmount } = await renderLinkEditing(del);
|
|
70
|
+
await act(async () => api.handleLinkedElementDelete(video));
|
|
71
|
+
expect(del.mock.calls[0]?.[0].map((el: TimelineElement) => el.id)).toEqual([
|
|
72
|
+
"talk",
|
|
73
|
+
"talk-audio",
|
|
74
|
+
]);
|
|
75
|
+
unmount();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("with Linked Selection off, delete removes the clip alone and unlinks the survivor", async () => {
|
|
79
|
+
const del = vi.fn().mockResolvedValue(undefined);
|
|
80
|
+
const { api, unmount } = await renderLinkEditing(del);
|
|
81
|
+
useLinkedClipPreferences.getState().setLinkedSelection(false);
|
|
82
|
+
try {
|
|
83
|
+
expect(withLinkPartners([video])).toEqual([video]);
|
|
84
|
+
await act(async () => api.handleLinkedElementDelete(video));
|
|
85
|
+
expect(del).toHaveBeenCalledWith([video], [audio]);
|
|
86
|
+
} finally {
|
|
87
|
+
useLinkedClipPreferences.getState().setLinkedSelection(true);
|
|
88
|
+
}
|
|
89
|
+
unmount();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("delete-only removes one member and unlinks the survivor", async () => {
|
|
93
|
+
const del = vi.fn().mockResolvedValue(undefined);
|
|
94
|
+
const { api, unmount } = await renderLinkEditing(del);
|
|
95
|
+
await act(async () => api.handleDeleteElementOnly(video));
|
|
96
|
+
expect(del).toHaveBeenCalledWith([video], [audio]);
|
|
97
|
+
unmount();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("unlink writes one undo step removing data-link from both", async () => {
|
|
101
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
102
|
+
new Response(JSON.stringify({ content: SOURCE }), { status: 200 }),
|
|
103
|
+
);
|
|
104
|
+
const { api, writeProjectFile, recordEdit, unmount } = await renderLinkEditing();
|
|
105
|
+
await act(async () => api.handleLinkEdit({ kind: "unlink", elements: [video, audio] }));
|
|
106
|
+
expect(writeProjectFile).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(writeProjectFile.mock.calls[0]?.[1]).not.toContain("data-link");
|
|
108
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
109
|
+
expect(recordEdit.mock.calls[0]?.[0].label).toBe("Unlink clips");
|
|
110
|
+
unmount();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useCallback, type MutableRefObject } from "react";
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
3
|
+
import type { TimelineLinkEdit } from "../player/components/timelineCallbacks";
|
|
4
|
+
import { expandToLinkedMembers } from "../player/components/audioClipLink";
|
|
5
|
+
import { isLinkedSelectionOn } from "../utils/linkedClipPreferences";
|
|
6
|
+
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
|
7
|
+
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
8
|
+
import { readFileContent } from "./timelineEditingHelpers";
|
|
9
|
+
import { clipsToUnlink, planLinkEdit } from "./timelineLinkEditPlan";
|
|
10
|
+
|
|
11
|
+
interface UseTimelineLinkEditingOptions {
|
|
12
|
+
projectIdRef: MutableRefObject<string | null>;
|
|
13
|
+
activeCompPath: string | null;
|
|
14
|
+
editQueueRef: MutableRefObject<Promise<void>>;
|
|
15
|
+
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
|
16
|
+
isRecordingRef?: MutableRefObject<boolean>;
|
|
17
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
18
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
19
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
20
|
+
reloadPreview: () => void;
|
|
21
|
+
forceReloadSdkSession?: () => void;
|
|
22
|
+
handleTimelineElementsDelete: (
|
|
23
|
+
selection: TimelineElement[],
|
|
24
|
+
alsoUnlink?: readonly TimelineElement[],
|
|
25
|
+
) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
29
|
+
|
|
30
|
+
/** Every clip a link edit writes, for the edit-permission gate. */
|
|
31
|
+
export function linkEditTargets(edit: TimelineLinkEdit): TimelineElement[] {
|
|
32
|
+
switch (edit.kind) {
|
|
33
|
+
case "unlink":
|
|
34
|
+
case "link":
|
|
35
|
+
return [...edit.elements];
|
|
36
|
+
case "detach":
|
|
37
|
+
case "move-into-sync":
|
|
38
|
+
case "slip-into-sync":
|
|
39
|
+
return [edit.element];
|
|
40
|
+
case "merge":
|
|
41
|
+
return [edit.video, edit.audio];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function withLinkPartners(selection: readonly TimelineElement[]): TimelineElement[] {
|
|
46
|
+
const elements = usePlayerStore.getState().elements;
|
|
47
|
+
const keys = expandToLinkedMembers(selection.map(keyOf), elements, isLinkedSelectionOn());
|
|
48
|
+
const known = new Set(selection.map(keyOf));
|
|
49
|
+
return [...selection, ...elements.filter((el) => keys.has(keyOf(el)) && !known.has(keyOf(el)))];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useTimelineLinkEditing({
|
|
53
|
+
projectIdRef,
|
|
54
|
+
activeCompPath,
|
|
55
|
+
editQueueRef,
|
|
56
|
+
pendingTimelineEditPathRef,
|
|
57
|
+
isRecordingRef,
|
|
58
|
+
showToast,
|
|
59
|
+
writeProjectFile,
|
|
60
|
+
recordEdit,
|
|
61
|
+
reloadPreview,
|
|
62
|
+
forceReloadSdkSession,
|
|
63
|
+
handleTimelineElementsDelete,
|
|
64
|
+
}: UseTimelineLinkEditingOptions) {
|
|
65
|
+
const handleLinkEdit = useCallback(
|
|
66
|
+
async (edit: TimelineLinkEdit) => {
|
|
67
|
+
if (isRecordingRef?.current) {
|
|
68
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const pid = projectIdRef.current;
|
|
72
|
+
const plan = planLinkEdit(edit, usePlayerStore.getState().elements);
|
|
73
|
+
if (!pid || !plan) return;
|
|
74
|
+
const path = plan.anchor.sourceFile || activeCompPath || "index.html";
|
|
75
|
+
pendingTimelineEditPathRef.current.add(path);
|
|
76
|
+
const queued = editQueueRef.current.then(() =>
|
|
77
|
+
saveProjectFilesWithHistory({
|
|
78
|
+
projectId: pid,
|
|
79
|
+
label: plan.label,
|
|
80
|
+
files: {
|
|
81
|
+
[path]: (current) => {
|
|
82
|
+
const next = plan.transform(current);
|
|
83
|
+
if (next === null || next === current) {
|
|
84
|
+
throw new Error(`Couldn't ${plan.label.toLowerCase()}`);
|
|
85
|
+
}
|
|
86
|
+
return next;
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
readFile: (filePath) => readFileContent(pid, filePath),
|
|
90
|
+
writeFile: writeProjectFile,
|
|
91
|
+
recordEdit,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
editQueueRef.current = queued.then(
|
|
95
|
+
() => undefined,
|
|
96
|
+
() => undefined,
|
|
97
|
+
);
|
|
98
|
+
try {
|
|
99
|
+
await queued;
|
|
100
|
+
forceReloadSdkSession?.();
|
|
101
|
+
reloadPreview();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
showToast(getStudioSaveErrorMessage(error), "error");
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
[
|
|
107
|
+
activeCompPath,
|
|
108
|
+
editQueueRef,
|
|
109
|
+
forceReloadSdkSession,
|
|
110
|
+
isRecordingRef,
|
|
111
|
+
pendingTimelineEditPathRef,
|
|
112
|
+
projectIdRef,
|
|
113
|
+
recordEdit,
|
|
114
|
+
reloadPreview,
|
|
115
|
+
showToast,
|
|
116
|
+
writeProjectFile,
|
|
117
|
+
],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const handleLinkedElementsDelete = useCallback(
|
|
121
|
+
(selection: TimelineElement[]) => {
|
|
122
|
+
if (isLinkedSelectionOn()) return handleTimelineElementsDelete(withLinkPartners(selection));
|
|
123
|
+
const removed = new Set(selection.map(keyOf));
|
|
124
|
+
const orphans = clipsToUnlink(selection, usePlayerStore.getState().elements).filter(
|
|
125
|
+
(el) => !removed.has(keyOf(el)),
|
|
126
|
+
);
|
|
127
|
+
return handleTimelineElementsDelete(selection, orphans);
|
|
128
|
+
},
|
|
129
|
+
[handleTimelineElementsDelete],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const handleLinkedElementDelete = useCallback(
|
|
133
|
+
(element: TimelineElement) => handleLinkedElementsDelete([element]),
|
|
134
|
+
[handleLinkedElementsDelete],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const handleDeleteElementOnly = useCallback(
|
|
138
|
+
(element: TimelineElement) => {
|
|
139
|
+
const orphans = clipsToUnlink([element], usePlayerStore.getState().elements).filter(
|
|
140
|
+
(el) => keyOf(el) !== keyOf(element),
|
|
141
|
+
);
|
|
142
|
+
return handleTimelineElementsDelete([element], orphans);
|
|
143
|
+
},
|
|
144
|
+
[handleTimelineElementsDelete],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
handleLinkEdit,
|
|
149
|
+
handleLinkedElementsDelete,
|
|
150
|
+
handleLinkedElementDelete,
|
|
151
|
+
handleDeleteElementOnly,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -96,7 +96,7 @@ describe("AudioWaveform", () => {
|
|
|
96
96
|
act(() => root.unmount());
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
it("greys the clip in place when muted
|
|
99
|
+
it("greys the clip in place when muted", () => {
|
|
100
100
|
const host = document.createElement("div");
|
|
101
101
|
host.className = "timeline-clip is-audio";
|
|
102
102
|
document.body.append(host);
|
|
@@ -112,15 +112,42 @@ describe("AudioWaveform", () => {
|
|
|
112
112
|
sessionEpoch={1}
|
|
113
113
|
priority="visible"
|
|
114
114
|
muted
|
|
115
|
-
linked
|
|
116
115
|
/>,
|
|
117
116
|
);
|
|
118
117
|
});
|
|
119
118
|
|
|
120
119
|
expect(host.getAttribute("data-audio-muted")).toBe("true");
|
|
121
|
-
expect(host.querySelector(".timeline-audio-link")).not.toBeNull();
|
|
122
120
|
|
|
123
121
|
act(() => root.unmount());
|
|
124
122
|
expect(host.hasAttribute("data-audio-muted")).toBe(false);
|
|
125
123
|
});
|
|
124
|
+
|
|
125
|
+
it("fills a short sound strip when the label band is dropped", () => {
|
|
126
|
+
const heights = [16, 0].map((labelInset) => {
|
|
127
|
+
const host = document.createElement("div");
|
|
128
|
+
document.body.append(host);
|
|
129
|
+
const root = createRoot(host);
|
|
130
|
+
act(() => {
|
|
131
|
+
root.render(
|
|
132
|
+
<AudioWaveform
|
|
133
|
+
audioUrl="/media/talk.mp4"
|
|
134
|
+
label=""
|
|
135
|
+
labelColor="#fff"
|
|
136
|
+
projectId="project-a"
|
|
137
|
+
sessionEpoch={1}
|
|
138
|
+
priority="visible"
|
|
139
|
+
{...(labelInset === 16 ? {} : { labelInset })}
|
|
140
|
+
/>,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
const canvas = host.querySelector("canvas");
|
|
144
|
+
const box = { top: canvas?.style.top, height: canvas?.style.height };
|
|
145
|
+
act(() => root.unmount());
|
|
146
|
+
return box;
|
|
147
|
+
});
|
|
148
|
+
expect(heights).toEqual([
|
|
149
|
+
{ top: "16px", height: "calc(100% - 16px)" },
|
|
150
|
+
{ top: "0px", height: "calc(100% - 0px)" },
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
126
153
|
});
|
|
@@ -18,8 +18,7 @@ export interface AudioWaveformProps {
|
|
|
18
18
|
priority: ThumbnailPriority;
|
|
19
19
|
/** `data-hidden` or a muted audio group. Greys the pill; the clip stays. */
|
|
20
20
|
muted?: boolean;
|
|
21
|
-
|
|
22
|
-
linked?: boolean;
|
|
21
|
+
labelInset?: number;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
const BAR_STEP = 3;
|
|
@@ -167,7 +166,7 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
167
166
|
sessionEpoch,
|
|
168
167
|
priority,
|
|
169
168
|
muted = false,
|
|
170
|
-
|
|
169
|
+
labelInset = 16,
|
|
171
170
|
}: AudioWaveformProps) {
|
|
172
171
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
173
172
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
@@ -236,17 +235,17 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
236
235
|
|
|
237
236
|
return (
|
|
238
237
|
<div ref={rootRef} className="absolute inset-0">
|
|
239
|
-
{linked ? <span className="timeline-audio-link" aria-hidden="true" /> : null}
|
|
240
238
|
<div className="absolute inset-0 overflow-hidden" style={{ zIndex: 10 }}>
|
|
241
239
|
<canvas
|
|
242
240
|
ref={setCanvasRef}
|
|
243
241
|
className="absolute inset-x-0 bottom-0 w-full"
|
|
244
|
-
style={{ top:
|
|
242
|
+
style={{ top: labelInset, height: `calc(100% - ${labelInset}px)` }}
|
|
245
243
|
/>
|
|
246
244
|
{snapshot.status === "loading" && (
|
|
247
245
|
<div
|
|
248
|
-
className="absolute inset-x-0 bottom-0
|
|
246
|
+
className="absolute inset-x-0 bottom-0 animate-pulse"
|
|
249
247
|
style={{
|
|
248
|
+
top: labelInset,
|
|
250
249
|
background: "var(--timeline-thumbnail-shimmer)",
|
|
251
250
|
}}
|
|
252
251
|
/>
|
|
@@ -256,7 +255,7 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
256
255
|
{snapshot.status === "error" && (
|
|
257
256
|
<div
|
|
258
257
|
className="absolute inset-x-0 flex items-center justify-center gap-1.5"
|
|
259
|
-
style={{ top:
|
|
258
|
+
style={{ top: labelInset, bottom: 0 }}
|
|
260
259
|
>
|
|
261
260
|
<div
|
|
262
261
|
className="absolute inset-x-0"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
5
|
+
import { ClipContextMenu } from "./ClipContextMenu";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
8
|
+
|
|
9
|
+
const iframe = document.createElement("iframe");
|
|
10
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
11
|
+
useStudioShellContextOptional: () => ({
|
|
12
|
+
projectId: "p1",
|
|
13
|
+
showToast: vi.fn(),
|
|
14
|
+
previewIframeRef: { current: iframe },
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const harness = createHappyDomRootHarness();
|
|
19
|
+
|
|
20
|
+
const talk: TimelineElement = {
|
|
21
|
+
id: "talk",
|
|
22
|
+
domId: "talk",
|
|
23
|
+
tag: "video",
|
|
24
|
+
src: "talk.mp4",
|
|
25
|
+
start: 0,
|
|
26
|
+
duration: 6,
|
|
27
|
+
track: 0,
|
|
28
|
+
hasAudio: true,
|
|
29
|
+
link: "lk-1",
|
|
30
|
+
};
|
|
31
|
+
const talkAudio: TimelineElement = {
|
|
32
|
+
...talk,
|
|
33
|
+
id: "talk-a",
|
|
34
|
+
domId: "talk-a",
|
|
35
|
+
tag: "audio",
|
|
36
|
+
track: 1,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function renderMenu(element: TimelineElement) {
|
|
40
|
+
usePlayerStore.getState().setElements([talk, talkAudio]);
|
|
41
|
+
document.body.appendChild(iframe);
|
|
42
|
+
const node = iframe.contentDocument?.createElement("video");
|
|
43
|
+
if (node) {
|
|
44
|
+
node.id = "talk";
|
|
45
|
+
node.setAttribute("data-has-audio", "true");
|
|
46
|
+
iframe.contentDocument?.body.appendChild(node);
|
|
47
|
+
}
|
|
48
|
+
const host = document.createElement("div");
|
|
49
|
+
document.body.appendChild(host);
|
|
50
|
+
const noop = vi.fn();
|
|
51
|
+
act(() =>
|
|
52
|
+
harness.mount(host).render(
|
|
53
|
+
<TimelineEditProvider
|
|
54
|
+
value={{
|
|
55
|
+
onSetElementAttributeQuiet: vi.fn(async () => undefined),
|
|
56
|
+
onFreezeFrame: noop,
|
|
57
|
+
onLinkEdit: noop,
|
|
58
|
+
onDeleteElementOnly: noop,
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<ClipContextMenu
|
|
62
|
+
x={10}
|
|
63
|
+
y={10}
|
|
64
|
+
element={element}
|
|
65
|
+
currentTime={2}
|
|
66
|
+
onClose={noop}
|
|
67
|
+
onSplit={noop}
|
|
68
|
+
onDelete={noop}
|
|
69
|
+
/>
|
|
70
|
+
</TimelineEditProvider>,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function menuLabels(): string[] {
|
|
76
|
+
return Array.from(document.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]')).map(
|
|
77
|
+
(item) => item.querySelector("span")?.textContent ?? "",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("ClipContextMenu order", () => {
|
|
82
|
+
it("groups time, sound, picture, then delete for a linked video with sound", () => {
|
|
83
|
+
renderMenu(talk);
|
|
84
|
+
const expected = [
|
|
85
|
+
"Split at 2.00s",
|
|
86
|
+
"Freeze frame",
|
|
87
|
+
"Normalize loudness",
|
|
88
|
+
"Voice",
|
|
89
|
+
"Detach audio",
|
|
90
|
+
"Unlink from audio",
|
|
91
|
+
"Look",
|
|
92
|
+
"Crop",
|
|
93
|
+
"Delete",
|
|
94
|
+
"Delete this clip only",
|
|
95
|
+
];
|
|
96
|
+
expect(menuLabels().filter((label) => expected.includes(label))).toEqual(expected);
|
|
97
|
+
const groups = Array.from(document.querySelectorAll('[role="group"]')).map((group) =>
|
|
98
|
+
group.getAttribute("aria-label"),
|
|
99
|
+
);
|
|
100
|
+
expect(groups).toEqual(["Time", "Sound", "Picture", "Clipboard", "Delete"]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -60,9 +60,14 @@ describe("ClipContextMenu host items", () => {
|
|
|
60
60
|
unmount();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it("
|
|
63
|
+
it("keeps the menu, host rows included, inside the window by its measured height", () => {
|
|
64
64
|
const innerHeight = Object.getOwnPropertyDescriptor(window, "innerHeight");
|
|
65
65
|
Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
|
|
66
|
+
const offsetHeight = vi
|
|
67
|
+
.spyOn(HTMLElement.prototype, "offsetHeight", "get")
|
|
68
|
+
.mockImplementation(function (this: HTMLElement) {
|
|
69
|
+
return this.getAttribute("role") === "menu" ? 150 : 0;
|
|
70
|
+
});
|
|
66
71
|
try {
|
|
67
72
|
const host = document.createElement("div");
|
|
68
73
|
document.body.appendChild(host);
|
|
@@ -86,10 +91,11 @@ describe("ClipContextMenu host items", () => {
|
|
|
86
91
|
/>,
|
|
87
92
|
),
|
|
88
93
|
);
|
|
89
|
-
//
|
|
90
|
-
expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("
|
|
94
|
+
// 150 px measured from y 550 ends 100 px below the window, kept 8 px off its edge.
|
|
95
|
+
expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("442px");
|
|
91
96
|
act(() => root.unmount());
|
|
92
97
|
} finally {
|
|
98
|
+
offsetHeight.mockRestore();
|
|
93
99
|
if (innerHeight) Object.defineProperty(window, "innerHeight", innerHeight);
|
|
94
100
|
}
|
|
95
101
|
});
|
|
@@ -106,7 +112,7 @@ describe("ClipContextMenu host items", () => {
|
|
|
106
112
|
it("shows only Studio's items when the host adds none", () => {
|
|
107
113
|
const { items, unmount } = renderMenu([]);
|
|
108
114
|
expect(items().map((item) => item.textContent)).toEqual(["Copy⌘C", "Delete⌫"]);
|
|
109
|
-
expect(document.body.
|
|
115
|
+
expect(document.body.querySelector("[role=group][aria-label=Host]")).toBeNull();
|
|
110
116
|
unmount();
|
|
111
117
|
});
|
|
112
118
|
});
|