@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
1
|
+
import { memo, useLayoutEffect, useState, type RefObject } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
4
4
|
import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
@@ -7,6 +7,25 @@ import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
|
7
7
|
import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
8
8
|
import { ClipMenuToolItems } from "./clipMenuToolItems";
|
|
9
9
|
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
|
|
10
|
+
import { ClipMenuLinkItems } from "./clipMenuLinkItems";
|
|
11
|
+
|
|
12
|
+
const MENU_MARGIN = 8;
|
|
13
|
+
// Empty groups collapse; every non-empty group before the always-present Delete group ends in a divider.
|
|
14
|
+
const GROUP_CLASS = "empty:hidden mb-1 pb-1 border-b border-neutral-700/60";
|
|
15
|
+
|
|
16
|
+
function useMeasuredHeight(ref: RefObject<HTMLDivElement | null>, anchorKey: string): number {
|
|
17
|
+
const [height, setHeight] = useState(0);
|
|
18
|
+
useLayoutEffect(() => {
|
|
19
|
+
const node = ref.current;
|
|
20
|
+
if (!node) return;
|
|
21
|
+
setHeight(node.offsetHeight);
|
|
22
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
23
|
+
const observer = new ResizeObserver(() => setHeight(node.offsetHeight));
|
|
24
|
+
observer.observe(node);
|
|
25
|
+
return () => observer.disconnect();
|
|
26
|
+
}, [ref, anchorKey]);
|
|
27
|
+
return height;
|
|
28
|
+
}
|
|
10
29
|
|
|
11
30
|
interface ClipContextMenuProps {
|
|
12
31
|
x: number;
|
|
@@ -62,7 +81,6 @@ function HostItems({
|
|
|
62
81
|
)}
|
|
63
82
|
</button>
|
|
64
83
|
))}
|
|
65
|
-
<div className="my-1 border-t border-neutral-700/60" />
|
|
66
84
|
</>
|
|
67
85
|
);
|
|
68
86
|
}
|
|
@@ -105,16 +123,11 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
105
123
|
? `Split at ${currentTime.toFixed(2)}s`
|
|
106
124
|
: "Split (move playhead inside clip)";
|
|
107
125
|
|
|
108
|
-
const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
|
|
109
|
-
const hostRows = hostItems.length;
|
|
110
|
-
const rowCount = hostRows + (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
|
|
111
|
-
const dividerCount =
|
|
112
|
-
(hostRows > 0 ? 1 : 0) + (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
|
|
113
126
|
const menuWidth = 200;
|
|
114
|
-
const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
|
|
115
|
-
const overflowY = y + menuHeight - window.innerHeight;
|
|
116
127
|
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
117
|
-
const
|
|
128
|
+
const menuHeight = useMeasuredHeight(menuRef, `${x},${y},${element.key ?? element.id}`);
|
|
129
|
+
const overflowY = y + menuHeight - window.innerHeight;
|
|
130
|
+
const adjustedY = overflowY > 0 ? Math.max(MENU_MARGIN, y - overflowY - MENU_MARGIN) : y;
|
|
118
131
|
|
|
119
132
|
return createPortal(
|
|
120
133
|
<div
|
|
@@ -124,9 +137,13 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
124
137
|
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
125
138
|
style={{ left: adjustedX, top: adjustedY }}
|
|
126
139
|
>
|
|
127
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
140
|
+
{hostItems.length > 0 && (
|
|
141
|
+
<div role="group" aria-label="Host" className={GROUP_CLASS}>
|
|
142
|
+
<HostItems items={hostItems} onClose={onClose} />
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
<div role="group" aria-label="Time" className={GROUP_CLASS}>
|
|
146
|
+
{splitLabel && (
|
|
130
147
|
<button
|
|
131
148
|
type="button"
|
|
132
149
|
role="menuitem"
|
|
@@ -146,92 +163,100 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
146
163
|
<span>{splitLabel}</span>
|
|
147
164
|
<span className="text-neutral-500 text-[10px] ml-3">S</span>
|
|
148
165
|
</button>
|
|
166
|
+
)}
|
|
167
|
+
{splitLabel && (
|
|
149
168
|
<ClipMenuToolItems
|
|
150
169
|
group="time"
|
|
151
170
|
element={element}
|
|
152
171
|
currentTime={currentTime}
|
|
153
172
|
onClose={onClose}
|
|
154
173
|
/>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
)}
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
158
176
|
|
|
159
|
-
<
|
|
177
|
+
<div role="group" aria-label="Sound" className={GROUP_CLASS}>
|
|
178
|
+
<ClipMenuAudioItems part="normalize" element={element} onClose={onClose} />
|
|
179
|
+
<ClipMenuToolItems
|
|
180
|
+
group="sound"
|
|
181
|
+
element={element}
|
|
182
|
+
currentTime={currentTime}
|
|
183
|
+
onClose={onClose}
|
|
184
|
+
/>
|
|
185
|
+
<ClipMenuLinkItems part="link" element={element} onClose={onClose} />
|
|
186
|
+
<ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
|
|
187
|
+
</div>
|
|
160
188
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
189
|
+
<div role="group" aria-label="Picture" className={GROUP_CLASS}>
|
|
190
|
+
<ClipMenuToolItems
|
|
191
|
+
group="picture"
|
|
192
|
+
element={element}
|
|
193
|
+
currentTime={currentTime}
|
|
194
|
+
onClose={onClose}
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div role="group" aria-label="Clipboard" className={GROUP_CLASS}>
|
|
199
|
+
{onCopy && (
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
role="menuitem"
|
|
203
|
+
className={itemClass(true)}
|
|
204
|
+
onClick={() => {
|
|
205
|
+
onCopy();
|
|
206
|
+
onClose();
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
<span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
|
|
210
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
|
|
211
|
+
</button>
|
|
212
|
+
)}
|
|
213
|
+
{onPaste && (
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
role="menuitem"
|
|
217
|
+
className={itemClass(!!canPaste)}
|
|
218
|
+
disabled={!canPaste}
|
|
219
|
+
onClick={() => {
|
|
220
|
+
if (!canPaste) return;
|
|
221
|
+
void onPaste();
|
|
222
|
+
onClose();
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
<span>Paste</span>
|
|
226
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
|
|
227
|
+
</button>
|
|
228
|
+
)}
|
|
229
|
+
{onDuplicate && (
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
role="menuitem"
|
|
233
|
+
className={itemClass(true)}
|
|
234
|
+
onClick={() => {
|
|
235
|
+
void onDuplicate();
|
|
236
|
+
onClose();
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
|
|
240
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
210
244
|
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
|
|
227
|
-
onClick={() => {
|
|
228
|
-
onDelete(element);
|
|
229
|
-
onClose();
|
|
230
|
-
}}
|
|
231
|
-
>
|
|
232
|
-
<span>Delete</span>
|
|
233
|
-
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
|
|
234
|
-
</button>
|
|
245
|
+
<div role="group" aria-label="Delete">
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
role="menuitem"
|
|
249
|
+
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
|
|
250
|
+
onClick={() => {
|
|
251
|
+
onDelete(element);
|
|
252
|
+
onClose();
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
<span>Delete</span>
|
|
256
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
|
|
257
|
+
</button>
|
|
258
|
+
<ClipMenuLinkItems part="delete" element={element} onClose={onClose} />
|
|
259
|
+
</div>
|
|
235
260
|
</div>,
|
|
236
261
|
document.body,
|
|
237
262
|
);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
5
|
+
import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { OutOfSyncBadge } from "./OutOfSyncBadge";
|
|
8
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
9
|
+
|
|
10
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
const harness = createHappyDomRootHarness();
|
|
13
|
+
const clip = (id: string, tag: string, start: number): TimelineElement => ({
|
|
14
|
+
id,
|
|
15
|
+
domId: id,
|
|
16
|
+
tag,
|
|
17
|
+
start,
|
|
18
|
+
duration: 4,
|
|
19
|
+
track: 0,
|
|
20
|
+
playbackStart: 0,
|
|
21
|
+
syncOrigin: "lk-1",
|
|
22
|
+
});
|
|
23
|
+
const video = clip("talk", "video", 1);
|
|
24
|
+
const lateAudio = clip("talk-audio", "audio", 1 + 51 / 30);
|
|
25
|
+
|
|
26
|
+
afterEach(() =>
|
|
27
|
+
useLinkedClipPreferences.setState({ syncIndicatorsVisible: true, compositionFps: 30 }),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
function render(el: TimelineElement, onLinkEdit = vi.fn()) {
|
|
31
|
+
usePlayerStore.getState().setElements([video, lateAudio]);
|
|
32
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
33
|
+
act(() =>
|
|
34
|
+
harness.mount(host).render(
|
|
35
|
+
<TimelineEditProvider value={{ onLinkEdit }}>
|
|
36
|
+
<OutOfSyncBadge el={el} />
|
|
37
|
+
</TimelineEditProvider>,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
const badge = () => document.querySelector('[data-testid="out-of-sync-badge"]');
|
|
41
|
+
return { badge, onLinkEdit };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("OutOfSyncBadge", () => {
|
|
45
|
+
it("shows the signed offset on both halves", () => {
|
|
46
|
+
useLinkedClipPreferences.setState({ compositionFps: 30 });
|
|
47
|
+
expect(render(lateAudio).badge()?.textContent).toBe("+1:21");
|
|
48
|
+
document.body.innerHTML = "";
|
|
49
|
+
expect(render(video).badge()?.textContent).toBe("-1:21");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("hides when the preference is off", () => {
|
|
53
|
+
useLinkedClipPreferences.setState({ syncIndicatorsVisible: false });
|
|
54
|
+
expect(render(lateAudio).badge()).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("opens Move / Slip into Sync from a click anywhere on the badge", () => {
|
|
58
|
+
const { badge, onLinkEdit } = render(lateAudio);
|
|
59
|
+
act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
60
|
+
const items = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'));
|
|
61
|
+
expect(items.map((item) => item.textContent)).toEqual(["Move into Sync", "Slip into Sync"]);
|
|
62
|
+
act(() => items[0]?.click());
|
|
63
|
+
expect(onLinkEdit).toHaveBeenCalledWith(
|
|
64
|
+
expect.objectContaining({ kind: "move-into-sync", element: lateAudio, start: 1 }),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("slips from a right-click, and the click never reaches the clip", () => {
|
|
69
|
+
const clipClick = vi.fn();
|
|
70
|
+
document.body.addEventListener("contextmenu", clipClick);
|
|
71
|
+
const { badge, onLinkEdit } = render(lateAudio);
|
|
72
|
+
act(() => badge()?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })));
|
|
73
|
+
expect(clipClick).not.toHaveBeenCalled();
|
|
74
|
+
const slip = document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')[1];
|
|
75
|
+
act(() => slip?.click());
|
|
76
|
+
const edit = onLinkEdit.mock.calls[0]?.[0];
|
|
77
|
+
expect(edit.kind).toBe("slip-into-sync");
|
|
78
|
+
expect(edit.mediaStart).toBeCloseTo(51 / 30);
|
|
79
|
+
document.body.removeEventListener("contextmenu", clipClick);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("flips the menu above the pointer near the bottom edge", () => {
|
|
83
|
+
const { badge } = render(lateAudio);
|
|
84
|
+
const y = window.innerHeight - 10;
|
|
85
|
+
act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true, clientY: y })));
|
|
86
|
+
const menu = document.querySelector<HTMLElement>('[role="menu"]');
|
|
87
|
+
const top = Number.parseFloat(menu?.style.top ?? "");
|
|
88
|
+
expect(top).toBeLessThan(y);
|
|
89
|
+
expect(top).toBeGreaterThanOrEqual(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { memo, useCallback, useState, type SyntheticEvent } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { formatSyncOffset } from "@hyperframes/core/media-link";
|
|
4
|
+
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
5
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
6
|
+
import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
|
7
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
8
|
+
import { clipSyncState, type ClipSyncState } from "./clipSync";
|
|
9
|
+
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
10
|
+
|
|
11
|
+
const stop = (e: SyntheticEvent) => e.stopPropagation();
|
|
12
|
+
|
|
13
|
+
const MENU_ROW =
|
|
14
|
+
"block w-full px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800 disabled:opacity-40";
|
|
15
|
+
|
|
16
|
+
function SyncMenu({
|
|
17
|
+
x,
|
|
18
|
+
y,
|
|
19
|
+
el,
|
|
20
|
+
sync,
|
|
21
|
+
onClose,
|
|
22
|
+
}: {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
el: TimelineElement;
|
|
26
|
+
sync: ClipSyncState<TimelineElement>;
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
}) {
|
|
29
|
+
const menuRef = useContextMenuDismiss(onClose);
|
|
30
|
+
useMenuKeyboardNav(menuRef);
|
|
31
|
+
const { onLinkEdit } = useTimelineEditContextOptional();
|
|
32
|
+
const { moveStart, slipMediaStart } = sync;
|
|
33
|
+
const menuWidth = 170;
|
|
34
|
+
const menuHeight = 66;
|
|
35
|
+
const left = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
36
|
+
const top = y + menuHeight > window.innerHeight ? Math.max(0, y - menuHeight) : y;
|
|
37
|
+
return createPortal(
|
|
38
|
+
<div
|
|
39
|
+
ref={menuRef}
|
|
40
|
+
role="menu"
|
|
41
|
+
aria-label="Out of sync"
|
|
42
|
+
className="fixed z-200 min-w-[170px] rounded-sm border border-neutral-700 bg-neutral-900 py-1 shadow-lg"
|
|
43
|
+
style={{ left, top }}
|
|
44
|
+
>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
role="menuitem"
|
|
48
|
+
className={MENU_ROW}
|
|
49
|
+
disabled={!onLinkEdit || moveStart === null}
|
|
50
|
+
title={moveStart === null ? "Would move the clip before 0:00" : undefined}
|
|
51
|
+
onClick={() => {
|
|
52
|
+
if (moveStart !== null)
|
|
53
|
+
void onLinkEdit?.({ kind: "move-into-sync", element: el, start: moveStart });
|
|
54
|
+
onClose();
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
Move into Sync
|
|
58
|
+
</button>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
role="menuitem"
|
|
62
|
+
className={MENU_ROW}
|
|
63
|
+
disabled={!onLinkEdit || slipMediaStart === null}
|
|
64
|
+
title={slipMediaStart === null ? "Would slip before the start of the file" : undefined}
|
|
65
|
+
onClick={() => {
|
|
66
|
+
if (slipMediaStart !== null) {
|
|
67
|
+
void onLinkEdit?.({ kind: "slip-into-sync", element: el, mediaStart: slipMediaStart });
|
|
68
|
+
}
|
|
69
|
+
onClose();
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
Slip into Sync
|
|
73
|
+
</button>
|
|
74
|
+
</div>,
|
|
75
|
+
document.body,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Premiere's red out-of-sync number, at the start of each half of a source pair
|
|
81
|
+
* that drifted. The whole badge opens Move / Slip into Sync (Premiere's own hit
|
|
82
|
+
* target is a sliver at the badge edge, a known complaint).
|
|
83
|
+
*/
|
|
84
|
+
export const OutOfSyncBadge = memo(function OutOfSyncBadge({ el }: { el: TimelineElement }) {
|
|
85
|
+
const visible = useLinkedClipPreferences((s) => s.syncIndicatorsVisible);
|
|
86
|
+
const fps = useLinkedClipPreferences((s) => s.compositionFps);
|
|
87
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
88
|
+
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
|
89
|
+
const closeMenu = useCallback(() => setMenuAt(null), []);
|
|
90
|
+
const sync = visible ? clipSyncState(el, elements, fps) : null;
|
|
91
|
+
if (!sync) return null;
|
|
92
|
+
const label = formatSyncOffset(sync.frames, fps);
|
|
93
|
+
const partnerName = sync.partner.label || sync.partner.id;
|
|
94
|
+
const open = (e: React.MouseEvent) => {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
e.stopPropagation();
|
|
97
|
+
setMenuAt({ x: e.clientX, y: e.clientY });
|
|
98
|
+
};
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
<span
|
|
102
|
+
role="button"
|
|
103
|
+
tabIndex={-1}
|
|
104
|
+
aria-haspopup="menu"
|
|
105
|
+
aria-label={`Out of sync with ${partnerName} by ${label} frames`}
|
|
106
|
+
title={`Out of sync with ${partnerName} by ${label} — click for Move or Slip into Sync`}
|
|
107
|
+
data-testid="out-of-sync-badge"
|
|
108
|
+
className="absolute bottom-0.5 left-1 z-[7] cursor-pointer rounded-[3px] bg-red-600 px-1 font-mono text-[9px] leading-[14px] text-white tabular-nums"
|
|
109
|
+
onPointerDown={stop}
|
|
110
|
+
onDoubleClick={stop}
|
|
111
|
+
onClick={open}
|
|
112
|
+
onContextMenu={open}
|
|
113
|
+
>
|
|
114
|
+
{label}
|
|
115
|
+
</span>
|
|
116
|
+
{menuAt && <SyncMenu x={menuAt.x} y={menuAt.y} el={el} sync={sync} onClose={closeMenu} />}
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
});
|
|
@@ -105,6 +105,23 @@ describe("TimelineClip", () => {
|
|
|
105
105
|
expect(seen).toEqual([{ fadeIn: 1, fadeOut: 0.5, duration: 4 }, null]);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
it("gives both halves of a link group the same label colour, and unlinked clips none", () => {
|
|
109
|
+
const colorOf = (element: TimelineElement) => {
|
|
110
|
+
const { host, root } = renderClip({ element });
|
|
111
|
+
const clip = host.querySelector<HTMLElement>(".timeline-clip");
|
|
112
|
+
const color = clip?.getAttribute("data-link-color") ?? null;
|
|
113
|
+
expect(clip?.style.getPropertyValue("--clip-link-color") || null).toBe(color);
|
|
114
|
+
act(() => root.unmount());
|
|
115
|
+
return color;
|
|
116
|
+
};
|
|
117
|
+
const base = { start: 0, duration: 2, track: 0 };
|
|
118
|
+
const video = colorOf({ id: "v", tag: "video", link: "lk-3", ...base });
|
|
119
|
+
expect(video).not.toBeNull();
|
|
120
|
+
expect(colorOf({ id: "a", tag: "audio", link: "lk-3", ...base })).toBe(video);
|
|
121
|
+
expect(colorOf({ id: "b", tag: "audio", link: "lk-4", ...base })).not.toBe(video);
|
|
122
|
+
expect(colorOf({ id: "c", tag: "audio", ...base })).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
108
125
|
it("renders the clip label above custom content without showing default timecode", () => {
|
|
109
126
|
const { host, root } = renderClip({
|
|
110
127
|
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 1, track: 0 },
|
|
@@ -12,6 +12,8 @@ import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
|
12
12
|
import { ClipFadesContext, TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
|
|
13
13
|
import { rendersWaveform } from "./AudioWaveform";
|
|
14
14
|
import { ClipBadges } from "./ClipBadges";
|
|
15
|
+
import { linkLabelColor } from "./linkLabelColor";
|
|
16
|
+
import { OutOfSyncBadge } from "./OutOfSyncBadge";
|
|
15
17
|
|
|
16
18
|
interface TimelineClipProps {
|
|
17
19
|
el: TimelineElement;
|
|
@@ -83,6 +85,8 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
83
85
|
"--clip-border-active": theme.clipBorderActive,
|
|
84
86
|
"--clip-handle": theme.handleColor,
|
|
85
87
|
} as CSSProperties;
|
|
88
|
+
const linkColor = linkLabelColor(el.link);
|
|
89
|
+
if (linkColor) Object.assign(themeVariables, { "--clip-link-color": linkColor });
|
|
86
90
|
const isAudioClip = isAudioTimelineElement(el);
|
|
87
91
|
const hasFades = (isAudioClip || Boolean(el.hasAudio)) && !isGestureActor;
|
|
88
92
|
const fade = useClipFadeDraft(el);
|
|
@@ -124,6 +128,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
124
128
|
data-clip-start={el.start}
|
|
125
129
|
data-clip-end={el.start + el.duration}
|
|
126
130
|
data-clip-hidden={el.hidden ? "true" : undefined}
|
|
131
|
+
data-link-color={linkColor ?? undefined}
|
|
127
132
|
data-ladder={ladder}
|
|
128
133
|
data-active={isActive ? "" : undefined}
|
|
129
134
|
aria-hidden={isGestureActor ? "true" : undefined}
|
|
@@ -206,6 +211,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
206
211
|
)}
|
|
207
212
|
{showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
|
|
208
213
|
{showLabel && !isGestureActor && <ClipBadges el={el} />}
|
|
214
|
+
{!isGestureActor && el.syncOrigin && <OutOfSyncBadge el={el} />}
|
|
209
215
|
{showDefaultText && (
|
|
210
216
|
<span className="timeline-clip__timecode">
|
|
211
217
|
{startLabel}-{endLabel}s
|