@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
@@ -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 = createRoot(host);
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 = createRoot(host);
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(host.querySelector('[aria-label="Italic"]')?.getAttribute("aria-pressed")).toBe("true");
189
- expect(host.querySelector('[aria-label="Bold"]')?.getAttribute("aria-pressed")).toBe("false");
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
- const range = document.createRange();
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
- const range = document.createRange();
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("#ff0000");
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 this renders inside the canvas overlay: a
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 instanceof HTMLElementClass)) return false;
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="{&quot;version&quot;:1,&quot;lanes&quot;:[]}"></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="{&quot;version&quot;:1,&quot;lanes&quot;:[]}" 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("link");
92
- doc.body.innerHTML = '<div id="link"></div><video data-link="link-2"></video>';
93
- expect(mintLinkId(doc)).toBe("link-3");
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("link");
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",