@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,14 +1,16 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
|
-
import { createRoot } from "react-dom/client";
|
|
5
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { cleanupMounted, trackedRoot } from "../ui/mountHost.testHelpers";
|
|
6
6
|
import { InlineTextToolbar, swatchBackground } from "./InlineTextToolbar";
|
|
7
7
|
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
|
|
8
8
|
|
|
9
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
10
|
|
|
11
11
|
afterEach(() => {
|
|
12
|
+
// Unmount before clearing the body: the bar is portaled there, so clearing first orphans React's node.
|
|
13
|
+
cleanupMounted();
|
|
12
14
|
document.body.innerHTML = "";
|
|
13
15
|
});
|
|
14
16
|
|
|
@@ -33,7 +35,7 @@ function scene(html: string) {
|
|
|
33
35
|
function render(session: InlineTextEditSession | null, iframe: HTMLIFrameElement | null) {
|
|
34
36
|
const host = document.createElement("div");
|
|
35
37
|
document.body.append(host);
|
|
36
|
-
const root =
|
|
38
|
+
const root = trackedRoot(host);
|
|
37
39
|
act(() => root.render(<InlineTextToolbar session={session} iframe={iframe} />));
|
|
38
40
|
return { host, root, rerender: () => act(() => root.render(<div />)) };
|
|
39
41
|
}
|
|
@@ -49,8 +51,21 @@ function selectAll(element: HTMLElement) {
|
|
|
49
51
|
});
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function selectAt(element: HTMLElement, top: number) {
|
|
55
|
+
const range = document.createRange();
|
|
56
|
+
range.selectNodeContents(element);
|
|
57
|
+
range.getBoundingClientRect = () =>
|
|
58
|
+
({ left: 20, top, width: 100, height: 10 }) as unknown as DOMRect;
|
|
59
|
+
const selection = document.getSelection()!;
|
|
60
|
+
act(() => {
|
|
61
|
+
selection.removeAllRanges();
|
|
62
|
+
selection.addRange(range);
|
|
63
|
+
document.dispatchEvent(new Event("selectionchange"));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
function toolbarIn(host: HTMLElement): HTMLElement | null {
|
|
53
|
-
return host.querySelector<HTMLElement>('[data-inline-text-toolbar="true"]');
|
|
68
|
+
return host.ownerDocument.querySelector<HTMLElement>('[data-inline-text-toolbar="true"]');
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
function renderSelected(html = "hello world") {
|
|
@@ -120,7 +135,7 @@ describe("InlineTextToolbar", () => {
|
|
|
120
135
|
// these are the handlers that would deselect the element.
|
|
121
136
|
const host = document.createElement("div");
|
|
122
137
|
document.body.append(host);
|
|
123
|
-
const root =
|
|
138
|
+
const root = trackedRoot(host);
|
|
124
139
|
act(() =>
|
|
125
140
|
root.render(
|
|
126
141
|
<div
|
|
@@ -152,7 +167,7 @@ describe("InlineTextToolbar", () => {
|
|
|
152
167
|
const { host } = render(session, iframe);
|
|
153
168
|
selectAll(element);
|
|
154
169
|
|
|
155
|
-
const input = host.querySelector<HTMLInputElement>('input[type="color"]')!;
|
|
170
|
+
const input = host.ownerDocument.querySelector<HTMLInputElement>('input[type="color"]')!;
|
|
156
171
|
expect(input.className).toContain("w-full");
|
|
157
172
|
expect(input.className).toContain("h-full");
|
|
158
173
|
expect(input.className).toContain("min-w-0");
|
|
@@ -161,14 +176,14 @@ describe("InlineTextToolbar", () => {
|
|
|
161
176
|
it("styles the selected characters when a control is used", () => {
|
|
162
177
|
const { element, host } = renderSelected();
|
|
163
178
|
|
|
164
|
-
act(() => host.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!.click());
|
|
179
|
+
act(() => host.ownerDocument.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!.click());
|
|
165
180
|
|
|
166
181
|
expect(element.innerHTML).toBe('<span style="font-weight: 700">hello world</span>');
|
|
167
182
|
});
|
|
168
183
|
|
|
169
184
|
it("ignores a click when the selection disappeared before React hid the toolbar", () => {
|
|
170
185
|
const { element, host } = renderSelected();
|
|
171
|
-
const bold = host.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!;
|
|
186
|
+
const bold = host.ownerDocument.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!;
|
|
172
187
|
|
|
173
188
|
expect(() => {
|
|
174
189
|
act(() => {
|
|
@@ -185,8 +200,12 @@ describe("InlineTextToolbar", () => {
|
|
|
185
200
|
|
|
186
201
|
selectAll(element);
|
|
187
202
|
|
|
188
|
-
expect(
|
|
189
|
-
|
|
203
|
+
expect(
|
|
204
|
+
host.ownerDocument.querySelector('[aria-label="Italic"]')?.getAttribute("aria-pressed"),
|
|
205
|
+
).toBe("true");
|
|
206
|
+
expect(
|
|
207
|
+
host.ownerDocument.querySelector('[aria-label="Bold"]')?.getAttribute("aria-pressed"),
|
|
208
|
+
).toBe("false");
|
|
190
209
|
});
|
|
191
210
|
|
|
192
211
|
it("turns a style back off when the control is used again", () => {
|
|
@@ -194,7 +213,7 @@ describe("InlineTextToolbar", () => {
|
|
|
194
213
|
const { host } = render(session, iframe);
|
|
195
214
|
selectAll(element);
|
|
196
215
|
|
|
197
|
-
act(() => host.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!.click());
|
|
216
|
+
act(() => host.ownerDocument.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!.click());
|
|
198
217
|
|
|
199
218
|
expect(element.innerHTML).toBe("words");
|
|
200
219
|
});
|
|
@@ -202,16 +221,7 @@ describe("InlineTextToolbar", () => {
|
|
|
202
221
|
it("places itself over the selection, mapped out of the scaled composition", () => {
|
|
203
222
|
const { element, session, iframe } = scene("hello world");
|
|
204
223
|
const { host } = render(session, iframe);
|
|
205
|
-
|
|
206
|
-
range.selectNodeContents(element);
|
|
207
|
-
range.getBoundingClientRect = () =>
|
|
208
|
-
({ left: 20, top: 40, width: 100, height: 10 }) as unknown as DOMRect;
|
|
209
|
-
const selection = document.getSelection()!;
|
|
210
|
-
act(() => {
|
|
211
|
-
selection.removeAllRanges();
|
|
212
|
-
selection.addRange(range);
|
|
213
|
-
document.dispatchEvent(new Event("selectionchange"));
|
|
214
|
-
});
|
|
224
|
+
selectAt(element, 40);
|
|
215
225
|
|
|
216
226
|
const toolbar = toolbarIn(host)!;
|
|
217
227
|
// Frame at 100,50; scale 400/innerWidth; centre of the range, above it.
|
|
@@ -223,16 +233,7 @@ describe("InlineTextToolbar", () => {
|
|
|
223
233
|
const { element, session, iframe } = scene("hello world");
|
|
224
234
|
iframe.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400 }) as DOMRect;
|
|
225
235
|
const { host } = render(session, iframe);
|
|
226
|
-
|
|
227
|
-
range.selectNodeContents(element);
|
|
228
|
-
range.getBoundingClientRect = () =>
|
|
229
|
-
({ left: 20, top: 0, width: 100, height: 10 }) as unknown as DOMRect;
|
|
230
|
-
const selection = document.getSelection()!;
|
|
231
|
-
act(() => {
|
|
232
|
-
selection.removeAllRanges();
|
|
233
|
-
selection.addRange(range);
|
|
234
|
-
document.dispatchEvent(new Event("selectionchange"));
|
|
235
|
-
});
|
|
236
|
+
selectAt(element, 0);
|
|
236
237
|
|
|
237
238
|
const toolbar = toolbarIn(host)!;
|
|
238
239
|
const scale = 400 / window.innerWidth;
|
|
@@ -271,7 +272,9 @@ describe("InlineTextToolbar", () => {
|
|
|
271
272
|
|
|
272
273
|
selectAll(element);
|
|
273
274
|
|
|
274
|
-
expect(host.querySelector<HTMLInputElement>('input[type="color"]')?.value).toBe(
|
|
275
|
+
expect(host.ownerDocument.querySelector<HTMLInputElement>('input[type="color"]')?.value).toBe(
|
|
276
|
+
"#ff0000",
|
|
277
|
+
);
|
|
275
278
|
});
|
|
276
279
|
});
|
|
277
280
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
2
3
|
import { applyInlineStyle } from "./inlineTextStyleRange";
|
|
3
4
|
import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
|
|
4
5
|
import { parseCssColor, toHexColor } from "./colorValue";
|
|
@@ -69,10 +70,10 @@ export function InlineTextToolbar({
|
|
|
69
70
|
[session, refresh],
|
|
70
71
|
);
|
|
71
72
|
|
|
72
|
-
if (!placement) return null;
|
|
73
|
+
if (!placement || !iframe) return null;
|
|
73
74
|
const styles = placement.styles;
|
|
74
75
|
|
|
75
|
-
return (
|
|
76
|
+
return createPortal(
|
|
76
77
|
<div
|
|
77
78
|
data-inline-text-toolbar="true"
|
|
78
79
|
role="toolbar"
|
|
@@ -89,8 +90,8 @@ export function InlineTextToolbar({
|
|
|
89
90
|
// The default, because a press anywhere in Studio moves the focus, and
|
|
90
91
|
// moving it out of the text collapses the selection being styled.
|
|
91
92
|
//
|
|
92
|
-
// The propagation, because
|
|
93
|
-
// press that reaches the canvas is read as a click on the composition,
|
|
93
|
+
// The propagation, because React bubbles it through the canvas overlay
|
|
94
|
+
// that renders this bar: a press that reaches the canvas is read as a click on the composition,
|
|
94
95
|
// which deselects the element and commits the edit out from under the
|
|
95
96
|
// button that was just pressed.
|
|
96
97
|
onPointerDown={swallow}
|
|
@@ -147,7 +148,8 @@ export function InlineTextToolbar({
|
|
|
147
148
|
on={styles["text-decoration-line"] === "underline"}
|
|
148
149
|
onToggle={(on) => apply({ "text-decoration-line": on ? "underline" : null })}
|
|
149
150
|
/>
|
|
150
|
-
</div
|
|
151
|
+
</div>,
|
|
152
|
+
iframe.ownerDocument.body,
|
|
151
153
|
);
|
|
152
154
|
}
|
|
153
155
|
|
|
@@ -5,6 +5,7 @@ import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext
|
|
|
5
5
|
import { slugifyDesignInput } from "../../utils/designInputTracking";
|
|
6
6
|
import { isTextEditableSelection } from "./domEditing";
|
|
7
7
|
import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps";
|
|
8
|
+
import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits";
|
|
8
9
|
import { formatPxMetricValue } from "./propertyPanelHelpers";
|
|
9
10
|
import { audioFxSummary } from "./audioFxSummary";
|
|
10
11
|
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
|
@@ -289,10 +290,12 @@ export function PropertyPanelFlat({
|
|
|
289
290
|
const showMotionEffects = gsapEffectHandlers !== null && !audioSelection;
|
|
290
291
|
const showMotionGroup = showMotionTiming || showMotionEffects;
|
|
291
292
|
|
|
293
|
+
const linkedSpeed = useLinkedSpeedCommit(element, forwardedProps.onSetAttributeBatch);
|
|
292
294
|
const volumeAutomation = useVolumeAutomation(
|
|
293
295
|
element,
|
|
294
296
|
currentTime,
|
|
295
297
|
onSetAttributeQuiet ?? onSetAttributeLive,
|
|
298
|
+
linkedSpeed,
|
|
296
299
|
);
|
|
297
300
|
|
|
298
301
|
// The group this clip belongs to, if any — the Audio FX summary reads
|
|
@@ -505,7 +508,7 @@ export function PropertyPanelFlat({
|
|
|
505
508
|
element={element}
|
|
506
509
|
styles={styles}
|
|
507
510
|
onSetStyle={onSetStyle}
|
|
508
|
-
onSetAttribute={onSetAttribute}
|
|
511
|
+
onSetAttribute={withLinkedPlaybackRate(onSetAttribute, linkedSpeed)}
|
|
509
512
|
{...volumeAutomation}
|
|
510
513
|
/>
|
|
511
514
|
),
|
|
@@ -54,18 +54,20 @@ export function canEditElementTextInline(element: HTMLElement | null): boolean {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function hasOnlyFormattingChildren(element: HTMLElement): boolean {
|
|
57
|
-
const HTMLElementClass = element.ownerDocument.defaultView?.HTMLElement;
|
|
58
|
-
if (!HTMLElementClass) return false;
|
|
59
57
|
for (const child of Array.from(element.children)) {
|
|
60
58
|
if (!isRichTextFormattingTag(child.tagName)) return false;
|
|
61
|
-
if (!(child
|
|
59
|
+
if (!isHtmlFromAnyWindow(child)) return false;
|
|
62
60
|
// Formatting nests, and a structural child hidden inside a span is still
|
|
63
61
|
// structural.
|
|
64
|
-
if (!hasOnlyFormattingChildren(child)) return false;
|
|
62
|
+
if (!hasOnlyFormattingChildren(child as HTMLElement)) return false;
|
|
65
63
|
}
|
|
66
64
|
return true;
|
|
67
65
|
}
|
|
68
66
|
|
|
67
|
+
function isHtmlFromAnyWindow(node: Element): boolean {
|
|
68
|
+
return node.namespaceURI === "http://www.w3.org/1999/xhtml";
|
|
69
|
+
}
|
|
70
|
+
|
|
69
71
|
/** Where and when a press landed, for recognising the next one as a pair. */
|
|
70
72
|
export interface PressMark {
|
|
71
73
|
x: number;
|
|
@@ -30,6 +30,13 @@ describe("canEditElementTextInline", () => {
|
|
|
30
30
|
expect(canEditElementTextInline(element)).toBe(true);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
it("opens styled copy whose formatting comes from another window", () => {
|
|
34
|
+
const element = mount("<h1>a <strong>bold</strong></h1>");
|
|
35
|
+
// happy-dom shares classes across windows; drop this window's HTMLElement as a foreign realm would.
|
|
36
|
+
Object.setPrototypeOf(element.firstElementChild, Element.prototype);
|
|
37
|
+
expect(canEditElementTextInline(element)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
33
40
|
it("keeps out an element with a structural child, which the panel edits field by field", () => {
|
|
34
41
|
expect(canEditElementTextInline(mount("<div><h1>a</h1><p>b</p></div>"))).toBe(false);
|
|
35
42
|
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createHappyDomRootHarness } from "../../player/components/testRootHarness";
|
|
5
|
+
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
|
|
6
|
+
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
7
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
8
|
+
import {
|
|
9
|
+
fanOutAttributeInSource,
|
|
10
|
+
linkedPartnerTargets,
|
|
11
|
+
useLinkedSpeedCommit,
|
|
12
|
+
withLinkedPlaybackRate,
|
|
13
|
+
type LinkedSpeedCommit,
|
|
14
|
+
} from "./linkedSpeedEdits";
|
|
15
|
+
import { useVolumeAutomation } from "./useVolumeAutomation";
|
|
16
|
+
import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
|
17
|
+
|
|
18
|
+
const clip = (id: string, tag: string, link?: string): TimelineElement => ({
|
|
19
|
+
id,
|
|
20
|
+
domId: id,
|
|
21
|
+
tag,
|
|
22
|
+
start: 0,
|
|
23
|
+
duration: 4,
|
|
24
|
+
track: 0,
|
|
25
|
+
...(link ? { link } : {}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const elements = [
|
|
29
|
+
clip("talk", "video", "lk-1"),
|
|
30
|
+
clip("talk-audio", "audio", "lk-1"),
|
|
31
|
+
clip("music", "audio"),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const source = `<div data-composition-id="main">
|
|
35
|
+
<video id="talk" data-start="0" data-duration="4" data-link="lk-1" muted></video>
|
|
36
|
+
<audio id="talk-audio" data-start="0" data-duration="4" data-link="lk-1" data-automation="{"version":1,"lanes":[]}"></audio>
|
|
37
|
+
<audio id="music" data-start="0" data-duration="4"></audio>
|
|
38
|
+
</div>`;
|
|
39
|
+
|
|
40
|
+
function selection(id: string, dataAttributes: Record<string, string> = {}): DomEditSelection {
|
|
41
|
+
const element = document.createElement("video");
|
|
42
|
+
element.id = id;
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
element,
|
|
46
|
+
label: id,
|
|
47
|
+
tagName: "video",
|
|
48
|
+
sourceFile: "index.html",
|
|
49
|
+
compositionPath: "index.html",
|
|
50
|
+
isCompositionHost: false,
|
|
51
|
+
isInsideLockedComposition: false,
|
|
52
|
+
boundingBox: { x: 0, y: 0, width: 0, height: 0 },
|
|
53
|
+
textContent: null,
|
|
54
|
+
dataAttributes: { start: "0", duration: "4", ...dataAttributes },
|
|
55
|
+
inlineStyles: {},
|
|
56
|
+
computedStyles: {},
|
|
57
|
+
textFields: [],
|
|
58
|
+
capabilities: {
|
|
59
|
+
canSelect: true,
|
|
60
|
+
canEditStyles: true,
|
|
61
|
+
canCrop: true,
|
|
62
|
+
canMove: true,
|
|
63
|
+
canResize: true,
|
|
64
|
+
canApplyManualOffset: true,
|
|
65
|
+
canApplyManualSize: true,
|
|
66
|
+
canApplyManualRotation: true,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("linkedPartnerTargets", () => {
|
|
72
|
+
it("returns the other data-link members and nothing for an unlinked clip", () => {
|
|
73
|
+
expect(linkedPartnerTargets({ id: "talk" }, elements)).toEqual([{ id: "talk-audio" }]);
|
|
74
|
+
expect(linkedPartnerTargets({ id: "music" }, elements)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reaches no partner with Linked Selection off", () => {
|
|
78
|
+
useLinkedClipPreferences.getState().setLinkedSelection(false);
|
|
79
|
+
try {
|
|
80
|
+
expect(linkedPartnerTargets({ id: "talk" }, elements)).toEqual([]);
|
|
81
|
+
} finally {
|
|
82
|
+
useLinkedClipPreferences.getState().setLinkedSelection(true);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("fanOutAttributeInSource", () => {
|
|
88
|
+
it("writes each partner from its own current value", () => {
|
|
89
|
+
const seen: Array<string | undefined> = [];
|
|
90
|
+
const next = fanOutAttributeInSource(
|
|
91
|
+
source,
|
|
92
|
+
[{ id: "talk-audio" }],
|
|
93
|
+
"data-automation",
|
|
94
|
+
(raw) => {
|
|
95
|
+
seen.push(raw);
|
|
96
|
+
return "rate-lane";
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
expect(seen).toEqual(['{"version":1,"lanes":[]}']);
|
|
100
|
+
expect(next).toContain(
|
|
101
|
+
'id="talk-audio" data-start="0" data-duration="4" data-link="lk-1" data-automation="rate-lane"',
|
|
102
|
+
);
|
|
103
|
+
expect(next).not.toContain('id="music" data-start="0" data-duration="4" data-automation');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const harness = createHappyDomRootHarness();
|
|
108
|
+
|
|
109
|
+
function renderLinked(sel: DomEditSelection, commit: CommitDomAttributeBatch) {
|
|
110
|
+
let linked: LinkedSpeedCommit | null = null;
|
|
111
|
+
let rate: ReturnType<typeof useVolumeAutomation>["rate"] | null = null;
|
|
112
|
+
function Probe() {
|
|
113
|
+
linked = useLinkedSpeedCommit(sel, commit);
|
|
114
|
+
rate = useVolumeAutomation(sel, 0, vi.fn(), linked).rate;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const host = document.createElement("div");
|
|
118
|
+
document.body.appendChild(host);
|
|
119
|
+
act(() => harness.mount(host).render(<Probe />));
|
|
120
|
+
return { linked: () => linked, rate: () => rate };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
describe("linked speed edits", () => {
|
|
124
|
+
it("fans a playback-rate edit out to the partner in one batch commit", async () => {
|
|
125
|
+
usePlayerStore.getState().setElements(elements);
|
|
126
|
+
const commit = vi.fn<CommitDomAttributeBatch>(async () => true);
|
|
127
|
+
const onSetAttribute = vi.fn();
|
|
128
|
+
const probe = renderLinked(selection("talk"), commit);
|
|
129
|
+
await withLinkedPlaybackRate(onSetAttribute, probe.linked())("playback-rate", "2");
|
|
130
|
+
expect(onSetAttribute).not.toHaveBeenCalled();
|
|
131
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
132
|
+
const [, ops, options] = commit.mock.calls[0] ?? [];
|
|
133
|
+
expect(ops).toEqual([{ type: "attribute", property: "playback-rate", value: "2" }]);
|
|
134
|
+
expect(options?.prepareContent?.(source)).toContain(
|
|
135
|
+
'id="talk-audio" data-start="0" data-duration="4" data-link="lk-1" data-automation="{"version":1,"lanes":[]}" data-playback-rate="2"',
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("fans a ramp preset out as one commit carrying each member's rate lane", () => {
|
|
140
|
+
usePlayerStore.getState().setElements(elements);
|
|
141
|
+
const commit = vi.fn<CommitDomAttributeBatch>(async () => true);
|
|
142
|
+
const probe = renderLinked(selection("talk"), commit);
|
|
143
|
+
act(() => probe.rate()?.onApplyPreset("ramp-in"));
|
|
144
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
145
|
+
const [, ops, options] = commit.mock.calls[0] ?? [];
|
|
146
|
+
expect(ops?.[0]?.property).toBe("automation");
|
|
147
|
+
expect(String(ops?.[0]?.value)).toContain('"target":"rate"');
|
|
148
|
+
const partner = /id="talk-audio"[^>]*data-automation="([^"]*)"/.exec(
|
|
149
|
+
options?.prepareContent?.(source) ?? "",
|
|
150
|
+
)?.[1];
|
|
151
|
+
expect(partner).toContain("rate");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("leaves an unlinked clip on its usual write path", async () => {
|
|
155
|
+
usePlayerStore.getState().setElements(elements);
|
|
156
|
+
const commit = vi.fn<CommitDomAttributeBatch>(async () => true);
|
|
157
|
+
const onSetAttribute = vi.fn();
|
|
158
|
+
const probe = renderLinked(selection("music"), commit);
|
|
159
|
+
expect(probe.linked()).toBeNull();
|
|
160
|
+
await withLinkedPlaybackRate(onSetAttribute, probe.linked())("playback-rate", "2");
|
|
161
|
+
expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "2");
|
|
162
|
+
expect(commit).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
|
|
2
|
+
import { expandToLinkedMembers } from "../../player/components/audioClipLink";
|
|
3
|
+
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
|
|
4
|
+
import {
|
|
5
|
+
applyPatchByTarget,
|
|
6
|
+
readAttributeByTarget,
|
|
7
|
+
type PatchTarget,
|
|
8
|
+
} from "../../utils/sourcePatcher";
|
|
9
|
+
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
10
|
+
import { buildPatchTarget } from "../../hooks/timelineEditingHelpers";
|
|
11
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
12
|
+
|
|
13
|
+
type AttributeValueFor = (current: string | undefined) => string | null;
|
|
14
|
+
|
|
15
|
+
/** Writes a speed attribute on the selection and every `data-link` partner as one undo step. */
|
|
16
|
+
export interface LinkedSpeedCommit {
|
|
17
|
+
commitAttribute: (attr: string, valueFor: AttributeValueFor, label: string) => Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
21
|
+
const dataProperty = (attr: string) => (attr.startsWith("data-") ? attr.slice(5) : attr);
|
|
22
|
+
|
|
23
|
+
function isSelected(el: TimelineElement, selection: PatchTarget): boolean {
|
|
24
|
+
if (selection.hfId && el.hfId) return el.hfId === selection.hfId;
|
|
25
|
+
return selection.id != null && (el.domId ?? el.id) === selection.id;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function linkedPartnerTargets(
|
|
29
|
+
selection: PatchTarget,
|
|
30
|
+
elements: readonly TimelineElement[],
|
|
31
|
+
): PatchTarget[] {
|
|
32
|
+
const self = elements.find((el) => isSelected(el, selection));
|
|
33
|
+
if (!self?.link) return [];
|
|
34
|
+
const members = expandToLinkedMembers([keyOf(self)], elements, isLinkedSelectionOn());
|
|
35
|
+
members.delete(keyOf(self));
|
|
36
|
+
return elements
|
|
37
|
+
.filter((el) => members.has(keyOf(el)))
|
|
38
|
+
.map(buildPatchTarget)
|
|
39
|
+
.filter((target): target is NonNullable<typeof target> => target !== null);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function fanOutAttributeInSource(
|
|
43
|
+
source: string,
|
|
44
|
+
targets: readonly PatchTarget[],
|
|
45
|
+
attr: string,
|
|
46
|
+
valueFor: AttributeValueFor,
|
|
47
|
+
): string {
|
|
48
|
+
const property = dataProperty(attr);
|
|
49
|
+
return targets.reduce(
|
|
50
|
+
(html, target) =>
|
|
51
|
+
applyPatchByTarget(html, target, {
|
|
52
|
+
type: "attribute",
|
|
53
|
+
property,
|
|
54
|
+
value: valueFor(readAttributeByTarget(html, target, attr)),
|
|
55
|
+
}),
|
|
56
|
+
source,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Null when the selection has no link partner, so unlinked clips keep their usual write path. */
|
|
61
|
+
export function useLinkedSpeedCommit(
|
|
62
|
+
selection: DomEditSelection,
|
|
63
|
+
commit: CommitDomAttributeBatch | undefined,
|
|
64
|
+
): LinkedSpeedCommit | null {
|
|
65
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
66
|
+
if (!commit) return null;
|
|
67
|
+
const partners = linkedPartnerTargets(selection, elements);
|
|
68
|
+
if (partners.length === 0) return null;
|
|
69
|
+
return {
|
|
70
|
+
commitAttribute: (attr, valueFor, label) => {
|
|
71
|
+
const property = dataProperty(attr);
|
|
72
|
+
return commit(
|
|
73
|
+
selection,
|
|
74
|
+
[{ type: "attribute", property, value: valueFor(selection.dataAttributes[property]) }],
|
|
75
|
+
{
|
|
76
|
+
label,
|
|
77
|
+
prepareContent: (html) => fanOutAttributeInSource(html, partners, attr, valueFor),
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Routes `playback-rate` through the linked commit; every other attribute keeps `onSetAttribute`. */
|
|
85
|
+
export function withLinkedPlaybackRate(
|
|
86
|
+
onSetAttribute: (attr: string, value: string) => void | Promise<void>,
|
|
87
|
+
linked: LinkedSpeedCommit | null,
|
|
88
|
+
): (attr: string, value: string) => void | Promise<void> {
|
|
89
|
+
if (!linked) return onSetAttribute;
|
|
90
|
+
return async (attr, value) => {
|
|
91
|
+
if (attr !== "playback-rate") return onSetAttribute(attr, value);
|
|
92
|
+
await linked.commitAttribute("data-playback-rate", () => value, "Edit speed");
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -88,9 +88,9 @@ describe("cutoutOps", () => {
|
|
|
88
88
|
describe("mintLinkId", () => {
|
|
89
89
|
it("dedupes against element ids and existing links", () => {
|
|
90
90
|
const doc = document.implementation.createHTMLDocument("t");
|
|
91
|
-
expect(mintLinkId(doc)).toBe("
|
|
92
|
-
doc.body.innerHTML = '<div id="
|
|
93
|
-
expect(mintLinkId(doc)).toBe("
|
|
91
|
+
expect(mintLinkId(doc)).toBe("lk-1");
|
|
92
|
+
doc.body.innerHTML = '<div id="lk-1"></div><video data-link="lk-2"></video>';
|
|
93
|
+
expect(mintLinkId(doc)).toBe("lk-3");
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
96
|
|
|
@@ -176,10 +176,26 @@ describe("buildKeepSoundCutoutEdit", () => {
|
|
|
176
176
|
{ target: "rate", points: [{ t: 0, v: 2 }] },
|
|
177
177
|
]);
|
|
178
178
|
expect(audio.getAttribute("data-link")).toBe(video.getAttribute("data-link"));
|
|
179
|
-
expect(video.getAttribute("data-link")).toBe("
|
|
179
|
+
expect(video.getAttribute("data-link")).toBe("lk-1");
|
|
180
|
+
expect(video.getAttribute("data-sync-origin")).toBe("lk-1");
|
|
181
|
+
expect(audio.getAttribute("data-sync-origin")).toBe("lk-1");
|
|
180
182
|
expect(audio.parentElement).toBe(video.parentElement);
|
|
181
183
|
});
|
|
182
184
|
|
|
185
|
+
it("keeps a hidden video's sound silent on the linked audio", () => {
|
|
186
|
+
const video = liveVideo();
|
|
187
|
+
video.setAttribute("data-hidden", "");
|
|
188
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
189
|
+
video,
|
|
190
|
+
videoId: "clip",
|
|
191
|
+
target: { id: "clip" },
|
|
192
|
+
cutoutSrc: "assets/talk-cutout.webm",
|
|
193
|
+
});
|
|
194
|
+
const hidden = source.replace('<video id="clip"', '<video id="clip" data-hidden');
|
|
195
|
+
const doc = parse(applyEdit(hidden, edit));
|
|
196
|
+
expect(doc.getElementById("clip-audio")?.hasAttribute("data-hidden")).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
183
199
|
it("drops the video's automation attribute when it held no rate lane", () => {
|
|
184
200
|
const video = makeVideo({
|
|
185
201
|
id: "clip",
|