@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.
Files changed (118) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
  5. package/dist/assets/{index-C5dyUHwk.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +44 -4
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +7021 -5669
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  24. package/src/components/editor/InlineTextCaret.tsx +211 -0
  25. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  26. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  27. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  28. package/src/components/editor/domEditInlineText.ts +6 -4
  29. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  30. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  31. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  32. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  33. package/src/components/editor/mediaAudioEdits.ts +27 -14
  34. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  35. package/src/components/editor/mediaLinkEdits.ts +282 -0
  36. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  37. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  38. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  39. package/src/components/editor/useVolumeAutomation.ts +29 -7
  40. package/src/components/nle/useTimelineEditCallbacks.ts +8 -0
  41. package/src/components/ui/Menu.test.tsx +3 -22
  42. package/src/components/ui/Menu.tsx +2 -2
  43. package/src/components/ui/Popover.tsx +2 -2
  44. package/src/components/ui/Select.tsx +2 -2
  45. package/src/components/ui/Tooltip.tsx +2 -2
  46. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  47. package/src/components/ui/valueControls.test.tsx +2 -21
  48. package/src/contexts/TimelineEditContext.tsx +3 -0
  49. package/src/hooks/appHotkeysDispatch.ts +2 -1
  50. package/src/hooks/linkShortcuts.test.ts +70 -0
  51. package/src/hooks/linkShortcuts.ts +41 -0
  52. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  53. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  54. package/src/hooks/timelineMoveAdapter.ts +13 -0
  55. package/src/hooks/timelineTimingSync.test.ts +59 -0
  56. package/src/hooks/timelineTimingSync.ts +22 -3
  57. package/src/hooks/useAppHotkeys.ts +9 -0
  58. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  59. package/src/hooks/useGsapScriptCommits.ts +23 -3
  60. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  61. package/src/hooks/usePreviewPersistence.ts +15 -0
  62. package/src/hooks/useRazorSplit.test.tsx +48 -0
  63. package/src/hooks/useRazorSplit.ts +12 -1
  64. package/src/hooks/useRenderClipContent.test.ts +6 -4
  65. package/src/hooks/useRenderClipContent.ts +6 -3
  66. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  67. package/src/hooks/useTimelineEditGuard.ts +54 -0
  68. package/src/hooks/useTimelineEditing.ts +36 -55
  69. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  70. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  71. package/src/index.ts +1 -0
  72. package/src/player/components/AudioWaveform.test.tsx +30 -3
  73. package/src/player/components/AudioWaveform.tsx +6 -7
  74. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  75. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  76. package/src/player/components/ClipContextMenu.tsx +115 -90
  77. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  78. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  79. package/src/player/components/TimelineClip.test.tsx +17 -0
  80. package/src/player/components/TimelineClip.tsx +6 -0
  81. package/src/player/components/audioClipLink.test.ts +228 -31
  82. package/src/player/components/audioClipLink.ts +72 -25
  83. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  84. package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
  85. package/src/player/components/clipMenuAudioItems.tsx +31 -23
  86. package/src/player/components/clipMenuDuck.ts +5 -0
  87. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  88. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  89. package/src/player/components/clipMenuToolItems.tsx +1 -3
  90. package/src/player/components/clipSync.test.ts +67 -0
  91. package/src/player/components/clipSync.ts +80 -0
  92. package/src/player/components/linkLabelColor.test.ts +20 -0
  93. package/src/player/components/linkLabelColor.ts +24 -0
  94. package/src/player/components/timelineCallbacks.ts +11 -0
  95. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  96. package/src/player/components/timelineClipDragTypes.ts +2 -0
  97. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  98. package/src/player/components/timelineLinkSelection.ts +23 -0
  99. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  100. package/src/player/components/useTimelineClipDrag.ts +21 -0
  101. package/src/player/components/useTimelineProviderState.tsx +3 -0
  102. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  103. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  104. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  105. package/src/player/lib/timelineDOM.test.ts +31 -0
  106. package/src/player/lib/timelineDOM.ts +13 -0
  107. package/src/player/store/timelineElement.ts +12 -0
  108. package/src/styles/components.css +27 -11
  109. package/src/styles/theme.css +0 -1
  110. package/src/utils/gsapSoftReload.test.ts +238 -2
  111. package/src/utils/gsapSoftReload.ts +89 -74
  112. package/src/utils/gsapUndoRestore.test.ts +4 -3
  113. package/src/utils/gsapUndoRestore.ts +15 -0
  114. package/src/utils/linkedClipPreferences.ts +34 -0
  115. package/src/utils/softReloadTargets.ts +128 -0
  116. package/src/utils/studioUiPreferences.ts +8 -0
  117. package/dist/chunk-AOWVEVEU.js.map +0 -1
  118. 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 refuseEdit = useTimelineEditRefusal(canEdit, showToast);
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, handleTimelineElementDelete } = useTimelineDeleteOps({
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(guard((element) => [element], handleRazorSplit));
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(guard((element) => [element], handleTimelineElementDelete)),
546
+ handleTimelineElementDelete: track(
547
+ guard((element) => withLinkPartners([element]), linkEditing.handleLinkedElementDelete),
548
+ ),
572
549
  handleTimelineElementsDelete: track(
573
- guard((elements) => elements, handleTimelineElementsDelete),
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
@@ -31,6 +31,7 @@ export {
31
31
  liveTime,
32
32
  formatTime,
33
33
  } from "./player";
34
+ export { usePreviewIframeStore } from "./player/store/previewIframeStore";
34
35
  export type {
35
36
  PlayerHandle,
36
37
  PlayerHandleElement,
@@ -96,7 +96,7 @@ describe("AudioWaveform", () => {
96
96
  act(() => root.unmount());
97
97
  });
98
98
 
99
- it("greys the clip in place when muted and draws the parent tick when linked", () => {
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
- /** Same media file as a video clip. Draws the 1px parent tick. */
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
- linked = false,
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: 16, height: "calc(100% - 16px)" }}
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 top-4 animate-pulse"
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: 16, bottom: 0 }}
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("counts host rows and their divider when keeping the menu inside the window", () => {
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
- // Four 30 px rows, two 9 px dividers and 8 px of padding end 96 px below the window.
90
- expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("446px");
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.querySelectorAll(".border-t")).toHaveLength(1);
115
+ expect(document.body.querySelector("[role=group][aria-label=Host]")).toBeNull();
110
116
  unmount();
111
117
  });
112
118
  });