@hyperframes/studio 0.8.105 → 0.8.107

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/index-CM1xA_jD.js +394 -0
  3. package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
  7. package/dist/chunk-4OZC426H.js.map +1 -0
  8. package/dist/index.d.ts +381 -14
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +9974 -9045
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/App.tsx +5 -3
  16. package/src/agentToolsExport.test.ts +8 -0
  17. package/src/captions/keyboard.test.ts +12 -0
  18. package/src/captions/keyboard.ts +2 -1
  19. package/src/components/StudioHeader.dom.test.tsx +31 -9
  20. package/src/components/StudioHeader.tsx +4 -1
  21. package/src/components/StudioRightPanels.tsx +2 -0
  22. package/src/components/ThemeToggle.tsx +99 -0
  23. package/src/components/TimelineToolbar.test.tsx +27 -0
  24. package/src/components/TimelineToolbar.tsx +2 -5
  25. package/src/components/editor/DomEditOverlay.tsx +31 -5
  26. package/src/components/editor/PropertyPanel.tsx +1 -14
  27. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  28. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  29. package/src/components/editor/SnapToolbar.tsx +3 -4
  30. package/src/components/editor/SourceEditor.tsx +2 -6
  31. package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
  32. package/src/components/editor/fontAssets.test.ts +12 -22
  33. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
  34. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  35. package/src/components/editor/manualEdits.test.ts +4 -3
  36. package/src/components/editor/manualEditsDom.ts +58 -2
  37. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  38. package/src/components/editor/manualOffsetDrag.ts +4 -1
  39. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  40. package/src/components/editor/marqueeCommit.ts +6 -1
  41. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  42. package/src/components/editor/mediaAudioEdits.ts +253 -0
  43. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  44. package/src/components/editor/overlayFrameLoop.ts +21 -0
  45. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  46. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  47. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  48. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  49. package/src/components/editor/propertyPanelFont.tsx +69 -46
  50. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  51. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  52. package/src/components/editor/propertyPanelTypes.ts +2 -0
  53. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  54. package/src/components/editor/studioCodeTheme.ts +99 -0
  55. package/src/components/editor/useDomEditNudge.test.tsx +165 -2
  56. package/src/components/editor/useDomEditNudge.ts +43 -25
  57. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  58. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  59. package/src/components/editor/useMotionPathData.ts +5 -1
  60. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  61. package/src/components/panels/SlideshowPanel.tsx +12 -7
  62. package/src/contexts/DomEditContext.test.tsx +62 -0
  63. package/src/contexts/DomEditContext.tsx +94 -77
  64. package/src/hooks/appHotkeysDispatch.ts +2 -1
  65. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  66. package/src/hooks/domEditCommitTypes.ts +10 -0
  67. package/src/hooks/domEditPersistFailure.ts +12 -1
  68. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  69. package/src/hooks/domStyleCommit.ts +2 -8
  70. package/src/hooks/gsapDragCommit.ts +3 -8
  71. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  72. package/src/hooks/gsapEditOutcome.ts +2 -1
  73. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  74. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  75. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  76. package/src/hooks/gsapResizeIntercept.ts +6 -2
  77. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  78. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  79. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  80. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  81. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  82. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  83. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  84. package/src/hooks/useAppHotkeys.test.ts +49 -0
  85. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  86. package/src/hooks/useAppHotkeys.ts +4 -5
  87. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  88. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  89. package/src/hooks/useClipboard.source.test.ts +99 -19
  90. package/src/hooks/useClipboard.ts +145 -124
  91. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  92. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  93. package/src/hooks/useDomEditCommits.ts +2 -0
  94. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  95. package/src/hooks/useDomEditPersist.ts +28 -13
  96. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  97. package/src/hooks/useDomEditSession.test.tsx +76 -72
  98. package/src/hooks/useDomEditSession.ts +9 -46
  99. package/src/hooks/useDomEditTextCommits.ts +4 -8
  100. package/src/hooks/useDomGeometryCommits.ts +11 -2
  101. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  102. package/src/hooks/useGsapAwareEditing.ts +6 -6
  103. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  104. package/src/hooks/useGsapScriptCommits.ts +5 -4
  105. package/src/hooks/useGsapTweenCache.ts +1 -2
  106. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  107. package/src/hooks/useInlineTextEdit.ts +21 -5
  108. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  109. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  110. package/src/hooks/useLivePreviewIframe.ts +18 -0
  111. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  112. package/src/hooks/usePreviewPersistence.ts +8 -5
  113. package/src/hooks/useRenderClipContent.ts +3 -3
  114. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  115. package/src/index.ts +9 -0
  116. package/src/livePreviewIframeExport.test.tsx +33 -0
  117. package/src/main.tsx +4 -1
  118. package/src/player/components/AudioWaveform.test.tsx +36 -15
  119. package/src/player/components/AudioWaveform.tsx +24 -7
  120. package/src/player/components/Timeline.test.ts +0 -21
  121. package/src/player/components/Timeline.tsx +0 -1
  122. package/src/player/components/TimelineClip.test.tsx +38 -0
  123. package/src/player/components/TimelineClip.tsx +14 -6
  124. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  125. package/src/player/components/TimelineClipFades.tsx +368 -137
  126. package/src/player/components/timelineLayout.ts +0 -28
  127. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  128. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  129. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  130. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  131. package/src/player/lib/automationStoreSync.test.ts +18 -1
  132. package/src/player/lib/automationStoreSync.ts +29 -17
  133. package/src/player/lib/playbackShortcuts.ts +3 -7
  134. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  135. package/src/player/lib/timelineElementHelpers.ts +16 -2
  136. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  137. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  138. package/src/player/sceneSwap.ts +18 -0
  139. package/src/styles/components.css +26 -0
  140. package/src/styles/studio.css +6 -0
  141. package/src/styles/theme.css +10 -0
  142. package/src/utils/clipboardPayload.ts +10 -1
  143. package/src/utils/gsapSoftReload.ts +3 -2
  144. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  145. package/src/utils/gsapUndoRestore.test.ts +145 -1
  146. package/src/utils/gsapUndoRestore.ts +70 -21
  147. package/src/utils/sourcePatcher.ts +1 -1
  148. package/src/utils/studioFontHelpers.ts +2 -23
  149. package/src/utils/studioHelpers.test.ts +30 -0
  150. package/src/utils/studioHelpers.ts +18 -4
  151. package/src/utils/studioPendingEdits.test.ts +63 -0
  152. package/src/utils/studioPendingEdits.ts +37 -7
  153. package/src/utils/studioTheme.test.tsx +141 -0
  154. package/src/utils/studioTheme.ts +34 -0
  155. package/src/utils/studioUiPreferences.ts +3 -0
  156. package/src/utils/typingTarget.test.ts +21 -1
  157. package/src/utils/typingTarget.ts +8 -0
  158. package/src/webmcp/useStudioAgentTools.ts +3 -1
  159. package/dist/assets/index-Cre9X4RS.css +0 -1
  160. package/dist/assets/index-DEDhPmPD.js +0 -399
  161. package/dist/chunk-QADHJFDF.js.map +0 -1
@@ -0,0 +1,107 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import type { DomEditSelection } from "../components/editor/domEditing";
6
+ import {
7
+ makePreview,
8
+ mountPlayerWithPreview,
9
+ paintShadow,
10
+ resetPlayerStore,
11
+ } from "../player/hooks/timelinePlayerTestHarness";
12
+ import { useDomEditCommits } from "./useDomEditCommits";
13
+
14
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
15
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
16
+ vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
17
+ ...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
18
+ ensureMotionPathPluginLoaded: vi.fn(),
19
+ }));
20
+
21
+ /** The project file; the patch route applies a colour to it, and each preview load serves it. */
22
+ let color = "red";
23
+ const card = () => `<div data-hf-id="hf-card" style="color: ${color}">Card</div>`;
24
+
25
+ afterEach(() => {
26
+ vi.unstubAllGlobals();
27
+ document.body.innerHTML = "";
28
+ resetPlayerStore();
29
+ });
30
+
31
+ it("an inspector save made while a reload loads is not undone when that reload goes on screen", async () => {
32
+ color = "red";
33
+ let land = () => {};
34
+ const landed = new Promise<void>((resolve) => (land = resolve));
35
+ vi.stubGlobal(
36
+ "fetch",
37
+ vi.fn(async (input: string, init?: RequestInit) => {
38
+ if (!input.includes("/file-mutations/patch-element/"))
39
+ return Response.json({ content: card() });
40
+ await landed;
41
+ const { operations } = JSON.parse(String(init?.body)) as { operations: { value: string }[] };
42
+ color = operations[0]!.value;
43
+ return Response.json({ ok: true, changed: true, matched: true, content: card() });
44
+ }),
45
+ );
46
+ const live = makePreview(card());
47
+ const player = mountPlayerWithPreview(live);
48
+ const element = live.contentDocument!.querySelector<HTMLElement>("[data-hf-id]")!;
49
+ const selection = {
50
+ element,
51
+ label: "Card",
52
+ tagName: "div",
53
+ sourceFile: "index.html",
54
+ compositionPath: "index.html",
55
+ textFields: [],
56
+ inlineStyles: { color: "red" },
57
+ computedStyles: {},
58
+ dataAttributes: {},
59
+ capabilities: { canSelect: true, canEditStyles: true },
60
+ hfId: "hf-card",
61
+ selector: '[data-hf-id="hf-card"]',
62
+ selectorIndex: 0,
63
+ } as unknown as DomEditSelection;
64
+ let commits: ReturnType<typeof useDomEditCommits> | null = null;
65
+ function Inspector() {
66
+ commits = useDomEditCommits({
67
+ activeCompPath: "index.html",
68
+ previewIframeRef: player.getApi().iframeRef,
69
+ showToast: () => {},
70
+ queueDomEditSave: (save) => save(),
71
+ writeProjectFile: async () => {},
72
+ editHistory: { recordEdit: async () => {} },
73
+ fileTree: [],
74
+ importedFontAssetsRef: { current: [] },
75
+ projectId: "p1",
76
+ projectIdRef: { current: "p1" },
77
+ reloadPreview: () => {},
78
+ domEditSelection: selection,
79
+ applyDomSelection: () => {},
80
+ clearDomSelection: () => {},
81
+ refreshDomEditSelectionFromPreview: () => {},
82
+ buildDomSelectionFromTarget: async () => null,
83
+ readOnlyPreview: false,
84
+ });
85
+ return null;
86
+ }
87
+ const root = createRoot(document.createElement("div"));
88
+ act(() => root.render(createElement(Inspector)));
89
+
90
+ act(() => player.getApi().refreshPlayer());
91
+ const beforeSave = makePreview(card(), "?_t=1");
92
+ let saved: Promise<unknown> = Promise.resolve();
93
+ act(() => void (saved = commits!.handleDomStyleCommit("color", "green")));
94
+ await act(async () => {
95
+ land();
96
+ await saved;
97
+ });
98
+ expect(color).toBe("green");
99
+
100
+ const requested = await paintShadow(player.getApi, beforeSave);
101
+ expect(player.getApi().iframeRef.current, "the file before the save").toBe(live);
102
+ const fresh = makePreview(card(), "?_t=2");
103
+ expect(await paintShadow(player.getApi, fresh)).toBeGreaterThan(requested);
104
+ expect(player.getApi().iframeRef.current).toBe(fresh);
105
+ act(() => root.unmount());
106
+ act(() => player.root.unmount());
107
+ });
@@ -0,0 +1,180 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { DomEditSelection } from "../components/editor/domEditing";
6
+ import { resolveElementForOverlay } from "../components/editor/domEditOverlayGeometry";
7
+ import {
8
+ makePreview,
9
+ mountPlayerWithPreview,
10
+ paintShadow,
11
+ resetPlayerStore,
12
+ type TimelinePlayerApi,
13
+ } from "../player/hooks/timelinePlayerTestHarness";
14
+ import { announcePreviewPromoted } from "../player/sceneSwap";
15
+ import { stageElementOffset } from "./elementOffsetStager";
16
+ import { savePlainRotation } from "./plainRotation";
17
+ import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
18
+ import { useLivePreviewIframe } from "./useLivePreviewIframe";
19
+
20
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
21
+
22
+ vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
23
+ ...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
24
+ ensureMotionPathPluginLoaded: vi.fn(),
25
+ }));
26
+
27
+ afterEach(() => {
28
+ document.body.innerHTML = "";
29
+ resetPlayerStore();
30
+ });
31
+
32
+ const previewWithTitle = (query = "") => makePreview('<h1 id="title">Title</h1>', query);
33
+
34
+ const titleOf = (iframe: HTMLIFrameElement | null) =>
35
+ iframe!.contentDocument!.getElementById("title") as HTMLElement;
36
+
37
+ const selectionOf = (element: HTMLElement) =>
38
+ ({ element, id: "title", selector: "#title", label: "title" }) as unknown as DomEditSelection;
39
+
40
+ /** A host shaped like Desktop's: it reads its iframe once and never re-renders for a reload. */
41
+ function mountHost(followPromotions: boolean) {
42
+ const selectionRef = { current: null as DomEditSelection | null };
43
+ function Session({ host }: { host: HTMLIFrameElement }) {
44
+ const live = useLivePreviewIframe(host);
45
+ useDomEditPreviewSync({
46
+ previewIframe: followPromotions ? live : host,
47
+ activeCompPath: null,
48
+ captionEditMode: false,
49
+ domEditSelectionRef: selectionRef,
50
+ domEditGroupSelectionsRef: { current: [] },
51
+ domEditSelection: selectionRef.current,
52
+ refreshDomEditGroupSelectionsFromPreview: async () => {},
53
+ applyDomSelection: (selection) => void (selectionRef.current = selection),
54
+ buildDomSelectionFromTarget: async (element) => selectionOf(element),
55
+ refreshPreviewDocumentVersion: () => {},
56
+ syncPreviewHotkeys: () => {},
57
+ applyStudioManualEditsToPreviewRef: { current: async () => {} },
58
+ });
59
+ return null;
60
+ }
61
+ const live = previewWithTitle();
62
+ const player = mountPlayerWithPreview(live);
63
+ selectionRef.current = selectionOf(titleOf(live));
64
+ const sessionRoot = createRoot(document.body.appendChild(document.createElement("div")));
65
+ act(() => sessionRoot.render(React.createElement(Session, { host: live })));
66
+ const unmount = () => {
67
+ act(() => sessionRoot.unmount());
68
+ act(() => player.root.unmount());
69
+ };
70
+ return { player: player.getApi, selectionRef, live, unmount };
71
+ }
72
+
73
+ /** The real shadow reload: load a shadow, report it painted, and let the player promote it. */
74
+ async function promoteShadow(player: () => TimelinePlayerApi): Promise<HTMLIFrameElement> {
75
+ act(() => player().refreshPlayer());
76
+ const shadow = previewWithTitle("?_t=1");
77
+ await paintShadow(player, shadow);
78
+ expect(player().iframeRef.current).toBe(shadow);
79
+ return shadow;
80
+ }
81
+
82
+ describe("a shadow reload promoted without a host re-render", () => {
83
+ // A host stage holds both players, each with the same #title, in either order.
84
+ it.each([
85
+ ["second", true],
86
+ ["first", false],
87
+ ])(
88
+ "with the live preview %s on the stage, the selection, overlay, rotate and move land on it",
89
+ async (_, liveSecond) => {
90
+ const host = mountHost(true);
91
+ const shadow = await promoteShadow(host.player);
92
+ // The stage only orders the two players; nothing here needs their pages to load.
93
+ for (const frame of [host.live, shadow]) frame.removeAttribute("src");
94
+ const stage = document.body.appendChild(document.createElement("div"));
95
+ stage.append(...(liveSecond ? [host.live, shadow] : [shadow, host.live]));
96
+
97
+ const selection = host.selectionRef.current!;
98
+ expect(selection.element).toBe(titleOf(shadow));
99
+ const overlayNode = resolveElementForOverlay(shadow.contentDocument!, selection, null, {
100
+ current: null,
101
+ });
102
+ expect(overlayNode).toBe(selection.element);
103
+
104
+ const commitPositionPatchToHtml = vi.fn(async () => {});
105
+ await savePlainRotation({ commitPositionPatchToHtml }, selection, { angle: 30 });
106
+ await stageElementOffset(
107
+ { commitPositionPatchToHtml, showToast: vi.fn() },
108
+ selection,
109
+ { x: 10, y: 5 },
110
+ true,
111
+ ).save();
112
+ expect(titleOf(shadow).style.getPropertyValue("rotate")).toBe("30deg");
113
+ expect(titleOf(shadow).style.getPropertyValue("translate")).toBe("10px 5px");
114
+ expect(titleOf(host.live).getAttribute("style")).toBeNull();
115
+ host.unmount();
116
+ },
117
+ );
118
+
119
+ it("left the selection on the retired node when the session kept the host's first iframe", async () => {
120
+ const host = mountHost(false);
121
+ await promoteShadow(host.player);
122
+ expect(host.selectionRef.current!.element).toBe(titleOf(host.live));
123
+ host.unmount();
124
+ });
125
+ });
126
+
127
+ describe("useLivePreviewIframe", () => {
128
+ function track(host: HTMLIFrameElement) {
129
+ const seen: Array<HTMLIFrameElement | null> = [];
130
+ function Probe({ iframe }: { iframe: HTMLIFrameElement }) {
131
+ seen.push(useLivePreviewIframe(iframe));
132
+ return null;
133
+ }
134
+ const root = createRoot(document.createElement("div"));
135
+ act(() => root.render(React.createElement(Probe, { iframe: host })));
136
+ const rerender = (iframe: HTMLIFrameElement) =>
137
+ act(() => root.render(React.createElement(Probe, { iframe })));
138
+ return { live: () => seen.at(-1), rerender, root };
139
+ }
140
+
141
+ it("follows each promotion of its own preview and ignores another preview's", () => {
142
+ const [a, b, c, other] = [0, 1, 2, 3].map(() => document.createElement("iframe"));
143
+ const probe = track(a!);
144
+ act(() => announcePreviewPromoted({ retired: other!, live: c! }));
145
+ expect(probe.live()).toBe(a);
146
+ act(() => announcePreviewPromoted({ retired: a!, live: b! }));
147
+ expect(probe.live()).toBe(b);
148
+ act(() => announcePreviewPromoted({ retired: b!, live: c! }));
149
+ expect(probe.live()).toBe(c);
150
+ act(() => probe.root.unmount());
151
+ });
152
+
153
+ it("lands on the same iframe wrapped twice, as when a host wraps what it hands the session", () => {
154
+ const [a, b, c] = [0, 1, 2].map(() => document.createElement("iframe"));
155
+ let seen: { outer: HTMLIFrameElement | null; inner: HTMLIFrameElement | null } | null = null;
156
+ function Probe({ host }: { host: HTMLIFrameElement }) {
157
+ const outer = useLivePreviewIframe(host);
158
+ seen = { outer, inner: useLivePreviewIframe(outer) };
159
+ return null;
160
+ }
161
+ const root = createRoot(document.createElement("div"));
162
+ act(() => root.render(React.createElement(Probe, { host: a! })));
163
+ act(() => announcePreviewPromoted({ retired: a!, live: b! }));
164
+ expect(seen).toEqual({ outer: b, inner: b });
165
+ act(() => announcePreviewPromoted({ retired: b!, live: c! }));
166
+ expect(seen).toEqual({ outer: c, inner: c });
167
+ act(() => root.unmount());
168
+ });
169
+
170
+ it("takes a new host iframe as given, and stops listening on unmount", () => {
171
+ const [a, b, d] = [0, 1, 2].map(() => document.createElement("iframe"));
172
+ const probe = track(a!);
173
+ act(() => announcePreviewPromoted({ retired: a!, live: b! }));
174
+ probe.rerender(d!);
175
+ expect(probe.live()).toBe(d);
176
+ const remove = vi.spyOn(document, "removeEventListener");
177
+ act(() => probe.root.unmount());
178
+ expect(remove).toHaveBeenCalledWith("hf-preview-promoted", expect.any(Function));
179
+ });
180
+ });
@@ -16,6 +16,7 @@ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
16
16
  const domDelete = vi.fn(async () => undefined);
17
17
  const historyUndo = vi.fn(async () => ({ ok: false }));
18
18
  const historyRedo = vi.fn(async () => ({ ok: false }));
19
+ const settle = vi.fn(async () => undefined);
19
20
  let root: Root | null = null;
20
21
  let sync: ((iframe: HTMLIFrameElement | null) => void) | null = null;
21
22
 
@@ -49,7 +50,7 @@ function Harness() {
49
50
  writeProjectFile: vi.fn(async () => undefined),
50
51
  showToast: vi.fn(),
51
52
  syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
52
- waitForPendingDomEditSaves: vi.fn(async () => undefined),
53
+ settlePendingEdits: settle,
53
54
  handleCopy: vi.fn(() => false),
54
55
  handlePaste: vi.fn(() => false),
55
56
  handleCut: vi.fn(() => false),
@@ -69,6 +70,8 @@ afterEach(() => {
69
70
  domDelete.mockClear();
70
71
  historyUndo.mockClear();
71
72
  historyRedo.mockClear();
73
+ settle.mockReset();
74
+ settle.mockImplementation(async () => undefined);
72
75
  vi.mocked(trackStudioEvent).mockClear();
73
76
  });
74
77
 
@@ -122,6 +125,18 @@ describe("preview iframe hotkey forwarding", () => {
122
125
  expect(trackStudioEvent).toHaveBeenCalledWith("keyboard_shortcut", { action: "undo" });
123
126
  });
124
127
 
128
+ it("settles pending edits, failed ones included, before Cmd+Z steps the history", async () => {
129
+ let settled!: () => void;
130
+ settle.mockImplementation(
131
+ () => new Promise<undefined>((resolve) => (settled = () => resolve(undefined))),
132
+ );
133
+ await press(mountWithPreview(), { key: "z", ctrlKey: true });
134
+ expect(settle).toHaveBeenCalledTimes(1);
135
+ expect(historyUndo).not.toHaveBeenCalled();
136
+ await act(async () => settled());
137
+ expect(historyUndo).toHaveBeenCalledTimes(1);
138
+ });
139
+
125
140
  it.each([
126
141
  ["Shift+Cmd+Z", { key: "Z", metaKey: true, shiftKey: true }],
127
142
  ["Ctrl+Y", { key: "y", ctrlKey: true }],
@@ -60,6 +60,55 @@ afterEach(() => {
60
60
  });
61
61
  });
62
62
 
63
+ describe("keys on a focused slider", () => {
64
+ afterEach(() => {
65
+ document.body.innerHTML = "";
66
+ });
67
+ const slider = () => {
68
+ const el = document.createElement("div");
69
+ el.setAttribute("role", "slider");
70
+ document.body.append(el);
71
+ return el;
72
+ };
73
+ const from = (target: HTMLElement, init: KeyboardEventInit) => {
74
+ const event = new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true });
75
+ Object.defineProperty(event, "target", { value: target });
76
+ return event;
77
+ };
78
+
79
+ it("leaves Delete to the slider instead of deleting the selected clip", () => {
80
+ usePlayerStore.setState({
81
+ elements: [bgmElement],
82
+ selectedElementId: "bgm",
83
+ selectedElementIds: new Set(["bgm"]),
84
+ });
85
+ const cb = callbacks();
86
+ dispatchPlainKey(from(slider(), { key: "Delete" }), "delete", cb);
87
+ expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
88
+ expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it.each(["video", "audio"])("leaves Delete to a focused <%s controls>", (tag) => {
92
+ usePlayerStore.setState({
93
+ elements: [bgmElement],
94
+ selectedElementId: "bgm",
95
+ selectedElementIds: new Set(["bgm"]),
96
+ });
97
+ const player = document.body.appendChild(document.createElement(tag));
98
+ player.setAttribute("controls", "");
99
+ const cb = callbacks();
100
+ dispatchPlainKey(from(player, { key: "Delete" }), "delete", cb);
101
+ expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
102
+ expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("still undoes with Cmd+Z while a slider has focus", () => {
106
+ const cb = callbacks();
107
+ dispatchModifierKey(from(slider(), { key: "z", metaKey: true }), "z", cb);
108
+ expect(cb.handleUndo).toHaveBeenCalledTimes(1);
109
+ });
110
+ });
111
+
63
112
  describe("dispatchPlainKey — select leftward / rightward", () => {
64
113
  const clips = [
65
114
  { ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
@@ -86,7 +86,7 @@ function Harness() {
86
86
  writeProjectFile: vi.fn(async () => undefined),
87
87
  showToast: vi.fn(),
88
88
  syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
89
- waitForPendingDomEditSaves: vi.fn(async () => undefined),
89
+ settlePendingEdits: vi.fn(async () => undefined),
90
90
  handleCopy: vi.fn(() => false),
91
91
  handlePaste: vi.fn(async () => undefined),
92
92
  handleCut: vi.fn(async () => false),
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react";
2
2
  import { usePlayerStore } from "../player";
3
3
  import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
- import { isTypingTarget } from "../utils/typingTarget";
6
5
  import { useCaptionStore } from "../captions/store";
7
6
  import {
8
7
  applyCaptionModelToIframe,
@@ -86,7 +85,7 @@ interface UseAppHotkeysParams {
86
85
  showToast: (message: string, tone?: "error" | "info") => void;
87
86
  syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"];
88
87
  showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"];
89
- waitForPendingDomEditSaves: () => Promise<void>;
88
+ settlePendingEdits: () => Promise<void>;
90
89
  handleCopy: () => boolean;
91
90
  handlePaste: () => Promise<void>;
92
91
  handleCut: () => Promise<boolean>;
@@ -125,7 +124,7 @@ export function useAppHotkeys({
125
124
  showToast,
126
125
  syncHistoryPreviewAfterApply,
127
126
  showHistoryRestoreNow,
128
- waitForPendingDomEditSaves,
127
+ settlePendingEdits,
129
128
  handleCopy,
130
129
  handlePaste,
131
130
  handleCut,
@@ -152,7 +151,7 @@ export function useAppHotkeys({
152
151
  showToast,
153
152
  syncHistoryPreviewAfterApply,
154
153
  showHistoryRestoreNow,
155
- waitForPendingDomEditSaves,
154
+ waitForPendingDomEditSaves: settlePendingEdits,
156
155
  onAfterUndoRedo,
157
156
  activeCompPath,
158
157
  forceReloadSdkSession,
@@ -220,7 +219,7 @@ export function useAppHotkeys({
220
219
  dispatchModifierKey(event, key, cb);
221
220
  return;
222
221
  }
223
- if (!isTypingTarget(event.target)) dispatchPlainKey(event, key, cb);
222
+ dispatchPlainKey(event, key, cb);
224
223
  }, []);
225
224
 
226
225
  // eslint-disable-next-line no-restricted-syntax
@@ -216,6 +216,23 @@ describe("useAutomationSelectionKeyboard", () => {
216
216
  input.remove();
217
217
  });
218
218
 
219
+ describe("with a focused picker", () => {
220
+ const picker = document.createElement("button");
221
+ picker.setAttribute("role", "combobox");
222
+ afterEach(() => picker.remove());
223
+
224
+ it("is inert while a picker that owns typing has focus", () => {
225
+ usePlayerStore
226
+ .getState()
227
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
228
+ const { onCommit } = setup({});
229
+ document.body.append(picker);
230
+ picker.focus();
231
+ key("Delete");
232
+ expect(onCommit).not.toHaveBeenCalled();
233
+ });
234
+ });
235
+
219
236
  it("Cmd+C copies the active selection", () => {
220
237
  clearAutomationClipboard();
221
238
  usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
@@ -31,6 +31,7 @@ import {
31
31
  type HfAutomationLane,
32
32
  } from "@hyperframes/core/audio-automation";
33
33
  import { clampNumber } from "../utils/studioHelpers";
34
+ import { isTypingTarget } from "../utils/typingTarget";
34
35
  import type { AutomationSelection } from "../player/store/automationSelectionSlice";
35
36
  import type {
36
37
  AutomationLaneBinding,
@@ -39,13 +40,6 @@ import type {
39
40
 
40
41
  type PlayerState = ReturnType<typeof usePlayerStore.getState>;
41
42
 
42
- function isTextInput(el: Element | null): boolean {
43
- if (!el) return false;
44
- const tag = el.tagName;
45
- if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
46
- return el instanceof HTMLElement && el.isContentEditable;
47
- }
48
-
49
43
  /**
50
44
  * A Cmd/Ctrl+<letter> chord. `e.key` is normalised because CapsLock makes it
51
45
  * "V"/"C", and useAppHotkeys already lowercases — a raw `e.key === "v"` test
@@ -302,7 +296,7 @@ function handleCopy(
302
296
  * dead key beats today's double write.
303
297
  */
304
298
  export function automationOwnsKey(e: KeyboardEvent): boolean {
305
- if (isTextInput(document.activeElement)) return false;
299
+ if (isTypingTarget(document.activeElement)) return false;
306
300
  const state = usePlayerStore.getState();
307
301
  if (isChord(e, "c")) return state.automationSelection !== null;
308
302
  if (!isChord(e, "v")) return false;
@@ -340,7 +334,7 @@ export function useAutomationSelectionKeyboard({
340
334
  }): void {
341
335
  useEffect(() => {
342
336
  const handler = (e: KeyboardEvent): void => {
343
- if (isTextInput(document.activeElement)) return;
337
+ if (isTypingTarget(document.activeElement)) return;
344
338
  // Somebody upstream already claimed this key. useAppHotkeys is on
345
339
  // window/capture so it always runs first, and it deliberately lets a
346
340
  // keyframe selection outrank an automation range on Delete — without
@@ -98,6 +98,7 @@ function mountClipboard(
98
98
  sub = SUB,
99
99
  bundle: (host: Element) => void = () => {},
100
100
  saved = SAVED,
101
+ view = { path: "index.html" },
101
102
  ) {
102
103
  const files: Record<string, string> = { "index.html": saved, "compositions/sub.html": sub };
103
104
  const fail = { on: false };
@@ -115,7 +116,7 @@ function mountClipboard(
115
116
  function Harness() {
116
117
  api = useClipboard({
117
118
  projectId: "p",
118
- activeCompPath: "index.html",
119
+ activeCompPath: view.path,
119
120
  domEditSelectionRef: domSelectionRef,
120
121
  showToast: () => {},
121
122
  writeProjectFile,
@@ -134,7 +135,9 @@ function mountClipboard(
134
135
  root = createRoot(document.createElement("div"));
135
136
  act(() => root?.render(React.createElement(Harness)));
136
137
  const clipboard = () => api as ReturnType<typeof useClipboard>;
138
+ const rerender = () => act(() => root?.render(React.createElement(Harness)));
137
139
  return {
140
+ rerender,
138
141
  clipboard,
139
142
  writes,
140
143
  deleted,
@@ -253,6 +256,17 @@ const SUB_SELECTION = {
253
256
  sourceFile: "compositions/sub.html",
254
257
  } as DomEditSelection;
255
258
 
259
+ const copySubNext = ({ clipboard, domSelectionRef }: ReturnType<typeof mountClipboard>) => {
260
+ clearSelection();
261
+ domSelectionRef.current = SUB_SELECTION;
262
+ clipboard().handleCopy();
263
+ };
264
+
265
+ const expectSubPastedAlone = (writes: string[]) => {
266
+ expect(writes[0]).toContain(">Sub</h2>");
267
+ expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
268
+ };
269
+
256
270
  describe("paste of an element styled by its id", () => {
257
271
  it("gives the copy the look its original has", async () => {
258
272
  clearSelection();
@@ -339,30 +353,24 @@ describe("copy order", () => {
339
353
 
340
354
  it("pastes the second of two copies", async () => {
341
355
  selectTitle();
342
- const { clipboard, writes, domSelectionRef } = mountClipboard();
343
- clipboard().handleCopy();
356
+ const view = mountClipboard();
357
+ view.clipboard().handleCopy();
344
358
  await new Promise((resolve) => setTimeout(resolve, 10));
345
- clearSelection();
346
- domSelectionRef.current = SUB_SELECTION;
347
- clipboard().handleCopy();
348
- await clipboard().handlePaste();
349
- expect(writes[0]).toContain(">Sub</h2>");
350
- expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
359
+ copySubNext(view);
360
+ await view.clipboard().handlePaste();
361
+ expectSubPastedAlone(view.writes);
351
362
  });
352
363
 
353
364
  it("pastes the later copy even when the earlier copy's read lands last", async () => {
354
365
  selectTitle();
355
- const { clipboard, writes, delayMs, domSelectionRef } = mountClipboard();
356
- delayMs["index.html"] = 20;
357
- clipboard().handleCopy();
358
- clearSelection();
359
- domSelectionRef.current = SUB_SELECTION;
360
- clipboard().handleCopy();
366
+ const view = mountClipboard();
367
+ view.delayMs["index.html"] = 20;
368
+ view.clipboard().handleCopy();
369
+ copySubNext(view);
361
370
  await new Promise((resolve) => setTimeout(resolve, 40));
362
- delayMs["index.html"] = 0;
363
- await clipboard().handlePaste();
364
- expect(writes[0]).toContain(">Sub</h2>");
365
- expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
371
+ view.delayMs["index.html"] = 0;
372
+ await view.clipboard().handlePaste();
373
+ expectSubPastedAlone(view.writes);
366
374
  });
367
375
 
368
376
  it("duplicates a hidden clip with its saved markup", async () => {
@@ -374,6 +382,78 @@ describe("copy order", () => {
374
382
  });
375
383
  });
376
384
 
385
+ describe("a copy in place, as the canvas makes one", () => {
386
+ // The copy's start and track: the saved original has no data-hf-id, its copy is minted one.
387
+ const copyAt = (html: string | undefined) => {
388
+ const copy = html?.match(/<h1[^>]*data-hf-id[^>]*>Title<\/h1>/)?.[0] ?? "";
389
+ return [copy.match(/data-start="([^"]*)"/)?.[1], copy.match(/data-track-index="([^"]*)"/)?.[1]];
390
+ };
391
+
392
+ it("duplicates at the clip's own time, on the next free track", async () => {
393
+ selectTitle();
394
+ const { clipboard, writes } = mountClipboard();
395
+ await clipboard().handleDuplicate({ inPlace: true });
396
+ expect(copyAt(writes[0])).toEqual(["2", "1"]);
397
+ });
398
+
399
+ it("pastes at the copied clip's time, not the playhead's", async () => {
400
+ selectTitle();
401
+ const { clipboard, writes } = mountClipboard();
402
+ clipboard().handleCopy();
403
+ await clipboard().handlePaste({ inPlace: true });
404
+ expect(copyAt(writes[0])).toEqual(["2", "1"]);
405
+ });
406
+
407
+ // Host at 2 s: the clip's local 1-3 s shows as a 3-5 s master row, the clock the film's own timeline has.
408
+ const SUB_CLIP: TimelineElement = {
409
+ id: SUB_HF_ID,
410
+ hfId: SUB_HF_ID,
411
+ tag: "h2",
412
+ start: 3,
413
+ duration: 2,
414
+ track: 0,
415
+ authoredTrack: 0,
416
+ sourceFile: "compositions/sub.html",
417
+ parentCompositionStart: 2,
418
+ };
419
+ const pastedSubStarts = (html: string | undefined) =>
420
+ [...(html ?? "").matchAll(/<h2[^>]*data-hf-id[^>]*data-start="([^"]+)"/g)].map((m) => m[1]);
421
+ const mountSubClip = (currentTime = 0, view = { path: "index.html" }) => {
422
+ usePlayerStore.setState({
423
+ elements: [TITLE, SUB_CLIP],
424
+ selectedElementId: SUB_CLIP.id,
425
+ selectedElementIds: new Set([SUB_CLIP.id]),
426
+ currentTime,
427
+ });
428
+ return mountClipboard(null, SUB, (host) => host.setAttribute("data-start", "2"), SAVED, view);
429
+ };
430
+
431
+ it("pastes a sub-composition's clip into the film at the moment the film shows it", async () => {
432
+ const { clipboard, writes } = mountSubClip(7);
433
+ clipboard().handleCopy();
434
+ await clipboard().handlePaste({ inPlace: true });
435
+ expect(pastedSubStarts(writes[0])).toEqual(["3"]);
436
+ });
437
+
438
+ it("duplicates a sub-composition's clip at its own local time, on another track", async () => {
439
+ const { clipboard, writes } = mountSubClip();
440
+ await clipboard().handleDuplicate({ inPlace: true });
441
+ const copy = /<h2[^>]*data-hf-id[^>]*>/.exec(writes[0] ?? "")?.[0] ?? "";
442
+ expect(copy).toContain('data-start="1"');
443
+ expect(copy).toContain('data-track-index="1"');
444
+ });
445
+
446
+ it("pastes at the playhead once the edited composition is another, whose clock differs", async () => {
447
+ const view = { path: "index.html" };
448
+ const { clipboard, writes, rerender } = mountSubClip(0.5, view);
449
+ clipboard().handleCopy();
450
+ view.path = "compositions/sub.html";
451
+ rerender();
452
+ await clipboard().handlePaste({ inPlace: true });
453
+ expect(pastedSubStarts(writes[0])).toEqual(["0.5"]);
454
+ });
455
+ });
456
+
377
457
  describe("duplicate of a clip inside a sub-composition", () => {
378
458
  it("writes the copy at local time right after the original", async () => {
379
459
  // Host at 2 s, so the clip's local 1-3 s shows as a 3-5 s master row.