@hyperframes/studio 0.8.106 → 0.8.108

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 (184) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
  2. package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
  3. package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
  4. package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
  5. package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
  6. package/dist/chunk-6LUNLTHR.js +611 -0
  7. package/dist/chunk-6LUNLTHR.js.map +1 -0
  8. package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
  9. package/dist/chunk-AOWVEVEU.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  13. package/dist/index.d.ts +377 -12
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +13445 -11874
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/package.json +8 -8
  20. package/src/App.tsx +6 -3
  21. package/src/agentToolsExport.test.ts +8 -0
  22. package/src/captions/keyboard.test.ts +6 -0
  23. package/src/components/EditorShell.tsx +2 -0
  24. package/src/components/StudioRightPanels.tsx +2 -0
  25. package/src/components/TimelineToolbar.test.tsx +27 -0
  26. package/src/components/TimelineToolbar.tsx +2 -5
  27. package/src/components/editor/CropPresetBar.tsx +69 -0
  28. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  29. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  30. package/src/components/editor/DomEditOverlay.tsx +12 -18
  31. package/src/components/editor/PropertyPanel.tsx +1 -14
  32. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  33. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  34. package/src/components/editor/SnapToolbar.tsx +3 -4
  35. package/src/components/editor/carveOutput.ts +54 -0
  36. package/src/components/editor/cropPresetStore.ts +33 -0
  37. package/src/components/editor/cropPresets.test.ts +41 -0
  38. package/src/components/editor/cropPresets.ts +24 -0
  39. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  40. package/src/components/editor/domEditingLayers.test.ts +21 -0
  41. package/src/components/editor/domEditingLayers.ts +2 -0
  42. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  43. package/src/components/editor/fontAssets.test.ts +12 -22
  44. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  45. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  46. package/src/components/editor/manualEdits.test.ts +4 -3
  47. package/src/components/editor/manualEditsDom.ts +28 -5
  48. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  49. package/src/components/editor/manualOffsetDrag.ts +15 -1
  50. package/src/components/editor/mediaAudioEdits.test.ts +271 -0
  51. package/src/components/editor/mediaAudioEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +290 -87
  55. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  58. package/src/components/editor/propertyPanelTypes.ts +2 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  60. package/src/components/editor/useDomEditNudge.ts +46 -23
  61. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  62. package/src/components/editor/useFxCarve.ts +3 -61
  63. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  64. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  65. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  66. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  67. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  68. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  69. package/src/components/panels/SlideshowPanel.tsx +12 -7
  70. package/src/contexts/DomEditContext.test.tsx +62 -0
  71. package/src/contexts/DomEditContext.tsx +94 -77
  72. package/src/contexts/TimelineEditContext.tsx +1 -0
  73. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  74. package/src/hooks/domEditCommitTypes.ts +10 -0
  75. package/src/hooks/domEditPersistFailure.ts +12 -1
  76. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  77. package/src/hooks/domStyleCommit.ts +2 -8
  78. package/src/hooks/gsapDragCommit.ts +3 -8
  79. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  80. package/src/hooks/gsapEditOutcome.ts +2 -1
  81. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  82. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  83. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  84. package/src/hooks/gsapResizeIntercept.ts +6 -2
  85. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  86. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  87. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  88. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  89. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  90. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  91. package/src/hooks/useAppHotkeys.test.ts +17 -0
  92. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  93. package/src/hooks/useAppHotkeys.ts +3 -3
  94. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  95. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  96. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  97. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  98. package/src/hooks/useDomEditCommits.ts +2 -0
  99. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  100. package/src/hooks/useDomEditPersist.ts +28 -13
  101. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  102. package/src/hooks/useDomEditSession.ts +2 -3
  103. package/src/hooks/useDomEditTextCommits.ts +4 -8
  104. package/src/hooks/useDomGeometryCommits.ts +11 -2
  105. package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
  106. package/src/hooks/useEditHistoryActions.ts +13 -4
  107. package/src/hooks/useElementPicker.test.ts +62 -13
  108. package/src/hooks/useElementPicker.ts +10 -2
  109. package/src/hooks/useFreezeFrame.test.ts +56 -0
  110. package/src/hooks/useFreezeFrame.ts +121 -0
  111. package/src/hooks/useGsapAwareEditing.ts +6 -6
  112. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  113. package/src/hooks/useGsapScriptCommits.ts +5 -4
  114. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  115. package/src/hooks/useInlineTextEdit.ts +9 -7
  116. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  117. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  118. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  119. package/src/hooks/usePersistentEditHistory.ts +1 -1
  120. package/src/hooks/usePreviewPersistence.ts +6 -0
  121. package/src/hooks/useRazorSplit.ts +14 -1
  122. package/src/hooks/useRenderClipContent.test.ts +102 -0
  123. package/src/hooks/useRenderClipContent.ts +30 -23
  124. package/src/hooks/useTimelineEditing.ts +2 -2
  125. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  126. package/src/index.ts +8 -0
  127. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  128. package/src/player/components/ClipBadges.test.tsx +82 -0
  129. package/src/player/components/ClipBadges.tsx +67 -0
  130. package/src/player/components/ClipContextMenu.tsx +22 -0
  131. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  132. package/src/player/components/ClipPeakMarks.tsx +71 -0
  133. package/src/player/components/Timeline.test.ts +0 -21
  134. package/src/player/components/Timeline.tsx +0 -1
  135. package/src/player/components/TimelineClip.tsx +2 -0
  136. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  137. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  138. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  139. package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
  140. package/src/player/components/clipMenuAudioItems.tsx +108 -0
  141. package/src/player/components/clipMenuDuck.test.ts +117 -0
  142. package/src/player/components/clipMenuDuck.ts +142 -0
  143. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  144. package/src/player/components/clipMenuNormalize.ts +96 -0
  145. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  146. package/src/player/components/clipMenuToolItems.tsx +235 -0
  147. package/src/player/components/clipPeakRuns.test.ts +38 -0
  148. package/src/player/components/clipPeakRuns.ts +59 -0
  149. package/src/player/components/clipToolAttrs.test.ts +161 -0
  150. package/src/player/components/clipToolAttrs.ts +187 -0
  151. package/src/player/components/timelineCallbacks.ts +1 -0
  152. package/src/player/components/timelineLayout.ts +0 -28
  153. package/src/player/components/useClipToolState.ts +92 -0
  154. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  155. package/src/player/lib/playbackShortcuts.ts +3 -7
  156. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  157. package/src/player/lib/timelineElementHelpers.ts +25 -2
  158. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  159. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  160. package/src/timelineChromeExports.test.tsx +7 -0
  161. package/src/utils/audioMeterMath.test.ts +37 -0
  162. package/src/utils/audioMeterMath.ts +9 -3
  163. package/src/utils/exportPeakCeiling.ts +1 -0
  164. package/src/utils/gsapSoftReload.ts +3 -2
  165. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  166. package/src/utils/gsapUndoRestore.test.ts +145 -1
  167. package/src/utils/gsapUndoRestore.ts +70 -21
  168. package/src/utils/previewRelativePath.ts +19 -0
  169. package/src/utils/sourcePatcher.ts +2 -2
  170. package/src/utils/studioFontHelpers.ts +2 -23
  171. package/src/utils/studioHelpers.test.ts +30 -0
  172. package/src/utils/studioHelpers.ts +18 -4
  173. package/src/utils/studioPendingEdits.test.ts +129 -0
  174. package/src/utils/studioPendingEdits.ts +61 -18
  175. package/src/utils/timelineInspector.test.ts +33 -2
  176. package/src/utils/timelineInspector.ts +16 -4
  177. package/src/utils/typingTarget.test.ts +13 -0
  178. package/src/utils/typingTarget.ts +5 -2
  179. package/src/webmcp/useStudioAgentTools.ts +3 -1
  180. package/dist/chunk-46XP7J3B.js.map +0 -1
  181. package/dist/chunk-DI4LCJHL.js +0 -102
  182. package/dist/chunk-DI4LCJHL.js.map +0 -1
  183. package/dist/chunk-ICTZJPQQ.js.map +0 -1
  184. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -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
+ });
@@ -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 }],
@@ -61,6 +61,9 @@ afterEach(() => {
61
61
  });
62
62
 
63
63
  describe("keys on a focused slider", () => {
64
+ afterEach(() => {
65
+ document.body.innerHTML = "";
66
+ });
64
67
  const slider = () => {
65
68
  const el = document.createElement("div");
66
69
  el.setAttribute("role", "slider");
@@ -85,6 +88,20 @@ describe("keys on a focused slider", () => {
85
88
  expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
86
89
  });
87
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
+
88
105
  it("still undoes with Cmd+Z while a slider has focus", () => {
89
106
  const cb = callbacks();
90
107
  dispatchModifierKey(from(slider(), { key: "z", metaKey: true }), "z", cb);
@@ -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),
@@ -85,7 +85,7 @@ interface UseAppHotkeysParams {
85
85
  showToast: (message: string, tone?: "error" | "info") => void;
86
86
  syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"];
87
87
  showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"];
88
- waitForPendingDomEditSaves: () => Promise<void>;
88
+ settlePendingEdits: () => Promise<void>;
89
89
  handleCopy: () => boolean;
90
90
  handlePaste: () => Promise<void>;
91
91
  handleCut: () => Promise<boolean>;
@@ -124,7 +124,7 @@ export function useAppHotkeys({
124
124
  showToast,
125
125
  syncHistoryPreviewAfterApply,
126
126
  showHistoryRestoreNow,
127
- waitForPendingDomEditSaves,
127
+ settlePendingEdits,
128
128
  handleCopy,
129
129
  handlePaste,
130
130
  handleCut,
@@ -151,7 +151,7 @@ export function useAppHotkeys({
151
151
  showToast,
152
152
  syncHistoryPreviewAfterApply,
153
153
  showHistoryRestoreNow,
154
- waitForPendingDomEditSaves,
154
+ waitForPendingDomEditSaves: settlePendingEdits,
155
155
  onAfterUndoRedo,
156
156
  activeCompPath,
157
157
  forceReloadSdkSession,
@@ -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
@@ -5,8 +5,11 @@ import {
5
5
  getDomEditTargetKey,
6
6
  type DomEditSelection,
7
7
  } from "../components/editor/domEditing";
8
- import type { PersistDomEditOperations } from "./domEditCommitTypes";
9
- import { reportDomEditPersistFailure } from "./domEditPersistFailure";
8
+ import type { CommitDomAttributeBatch, PersistDomEditOperations } from "./domEditCommitTypes";
9
+ import {
10
+ DomEditPersistPreparedWriteError,
11
+ reportDomEditPersistFailure,
12
+ } from "./domEditPersistFailure";
10
13
  import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
11
14
  import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
12
15
  import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
@@ -54,6 +57,15 @@ export function resolveOptimisticAttributeValue(attr: string, value: string | nu
54
57
  return value === "false" && HTML_BOOLEAN_ATTRIBUTES.has(attr) ? null : value;
55
58
  }
56
59
 
60
+ function previewAttributeName(op: PatchOperation): string {
61
+ return op.type === "attribute" ? resolveFullAttrName(op.property, true) : op.property;
62
+ }
63
+
64
+ function previewAttributeValue(op: PatchOperation): string | null {
65
+ if (op.type === "html-attribute") return resolveOptimisticAttributeValue(op.property, op.value);
66
+ return op.value === "" ? null : op.value;
67
+ }
68
+
57
69
  function setOrRemovePreviewAttribute(
58
70
  el: HTMLElement,
59
71
  fullAttr: string,
@@ -209,7 +221,7 @@ export function useDomEditAttributeCommits({
209
221
  ],
210
222
  );
211
223
 
212
- // Commits several data-* attributes on the SAME element in ONE persist call
224
+ // Commits several data-* and HTML attributes on the SAME element in ONE persist call
213
225
  // — needed when two attributes together describe a single logical value
214
226
  // (e.g. a pinned timing range's start+duration): committing them through two
215
227
  // separate sequential `commitDataAttribute` calls leaves a window where the
@@ -221,32 +233,30 @@ export function useDomEditAttributeCommits({
221
233
  // explicit, caller-supplied `selection` (not the "current" one) closes both
222
234
  // gaps — matching `onCommitAnimatedProperties`'s same-shaped fix for GSAP
223
235
  // property batches.
224
- const commitDataAttributes = useCallback(
236
+ const commitAttributeOps = useCallback(
225
237
  async (
226
238
  selection: DomEditSelection,
227
- attrs: Record<string, string | null>,
228
- options: DataAttributeCommitOptions,
229
- ) => {
239
+ ops: PatchOperation[],
240
+ options: DataAttributeCommitOptions & {
241
+ prepareContent?: (html: string) => string;
242
+ syncAutomation?: boolean;
243
+ },
244
+ ): Promise<boolean> => {
230
245
  const iframe = previewIframeRef.current;
231
- const entries = Object.entries(attrs).map(([attr, value]) => ({
232
- attr,
233
- fullAttr: resolveFullAttrName(attr, true),
234
- value,
246
+ const entries = ops.map((op) => ({
247
+ fullAttr: previewAttributeName(op),
248
+ value: previewAttributeValue(op),
235
249
  }));
236
- const commitKey = `${options.coalescePrefix}:${entries
237
- .map((entry) => entry.attr)
250
+ const commitKey = `${options.coalescePrefix}:${ops
251
+ .map((op) => op.property)
238
252
  .sort()
239
253
  .join(",")}:${getDomEditTargetKey(selection)}`;
240
254
  const isLatestCommit = bumpDomEditCommitMapVersion(
241
255
  domAttributeCommitVersionRef.current,
242
256
  commitKey,
243
257
  );
244
- const ops: PatchOperation[] = entries.map((entry) => ({
245
- type: "attribute",
246
- property: entry.attr,
247
- value: entry.value,
248
- }));
249
258
  let captured: CapturedMultiAttributeElement | null = null;
259
+ let landed = false;
250
260
 
251
261
  await runDomEditCommit({
252
262
  capture: () => {
@@ -260,8 +270,7 @@ export function useDomEditAttributeCommits({
260
270
  apply: () => {
261
271
  if (!captured) return;
262
272
  for (const entry of entries) {
263
- const nextValue = entry.value === null || entry.value === "" ? null : entry.value;
264
- setOrRemovePreviewAttribute(captured.element, entry.fullAttr, nextValue);
273
+ setOrRemovePreviewAttribute(captured.element, entry.fullAttr, entry.value);
265
274
  }
266
275
  },
267
276
  persist: () =>
@@ -269,8 +278,10 @@ export function useDomEditAttributeCommits({
269
278
  label: options.label,
270
279
  coalesceKey: commitKey,
271
280
  skipRefresh: options.skipRefresh,
281
+ prepareContent: options.prepareContent,
272
282
  }),
273
- shouldRevert: () => isLatestCommit(),
283
+ shouldRevert: (error) =>
284
+ isLatestCommit() && !(error instanceof DomEditPersistPreparedWriteError),
274
285
  revert: () => {
275
286
  if (!captured) return;
276
287
  for (const entry of entries) {
@@ -283,10 +294,19 @@ export function useDomEditAttributeCommits({
283
294
  },
284
295
  onError: (error) => reportDomEditPersistFailure(selection, ops, error, showToast),
285
296
  shouldResync: () => isLatestCommit() && !!options.refreshAfter,
286
- resync: () => refreshDomEditSelectionFromPreview(selection),
287
- onSettled: options.onSettled,
297
+ resync: () => {
298
+ refreshDomEditSelectionFromPreview(selection);
299
+ if (options.syncAutomation) {
300
+ syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
301
+ }
302
+ },
303
+ onSettled: (ok) => {
304
+ landed = ok;
305
+ options.onSettled?.(ok);
306
+ },
288
307
  onFinally: isLatestCommit.release,
289
308
  });
309
+ return landed;
290
310
  },
291
311
  [
292
312
  activeCompPath,
@@ -299,14 +319,35 @@ export function useDomEditAttributeCommits({
299
319
 
300
320
  const handleDomAttributesCommit = useCallback(
301
321
  async (selection: DomEditSelection, attrs: Record<string, string>) => {
302
- await commitDataAttributes(selection, attrs, {
303
- label: "Edit timing",
304
- coalescePrefix: "attrs",
322
+ await commitAttributeOps(
323
+ selection,
324
+ Object.entries(attrs).map(([property, value]) => ({
325
+ type: "attribute",
326
+ property,
327
+ value,
328
+ })),
329
+ {
330
+ label: "Edit timing",
331
+ coalescePrefix: "attrs",
332
+ skipRefresh: false,
333
+ refreshAfter: true,
334
+ },
335
+ );
336
+ },
337
+ [commitAttributeOps],
338
+ );
339
+
340
+ const handleDomAttributeBatchCommit: CommitDomAttributeBatch = useCallback(
341
+ (selection, ops, options) =>
342
+ commitAttributeOps(selection, ops, {
343
+ label: options.label,
344
+ coalescePrefix: "attr-batch",
305
345
  skipRefresh: false,
306
346
  refreshAfter: true,
307
- });
308
- },
309
- [commitDataAttributes],
347
+ syncAutomation: true,
348
+ prepareContent: options.prepareContent,
349
+ }),
350
+ [commitAttributeOps],
310
351
  );
311
352
 
312
353
  const handleDomAttributeCommit = useCallback(
@@ -438,5 +479,6 @@ export function useDomEditAttributeCommits({
438
479
  handleDomAttributeQuietCommit,
439
480
  handleDomHtmlAttributeCommit,
440
481
  handleDomAttributesCommit,
482
+ handleDomAttributeBatchCommit,
441
483
  };
442
484
  }