@hyperframes/studio 0.8.106 → 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 (110) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/{index-CMiYVGv5.js → index-CM1xA_jD.js} +195 -200
  3. package/dist/assets/{index-ooaZZhKf.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/{index-DKT5NF1Y.js → index-zoqPmBF6.js} +1 -1
  5. package/dist/{chunk-ICTZJPQQ.js → chunk-4OZC426H.js} +12 -3
  6. package/dist/chunk-4OZC426H.js.map +1 -0
  7. package/dist/index.d.ts +367 -11
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1064 -714
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +5 -3
  14. package/src/agentToolsExport.test.ts +8 -0
  15. package/src/captions/keyboard.test.ts +6 -0
  16. package/src/components/StudioRightPanels.tsx +2 -0
  17. package/src/components/TimelineToolbar.test.tsx +27 -0
  18. package/src/components/TimelineToolbar.tsx +2 -5
  19. package/src/components/editor/DomEditOverlay.tsx +12 -18
  20. package/src/components/editor/PropertyPanel.tsx +1 -14
  21. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  22. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  23. package/src/components/editor/SnapToolbar.tsx +3 -4
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  25. package/src/components/editor/fontAssets.test.ts +12 -22
  26. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  27. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  28. package/src/components/editor/manualEdits.test.ts +4 -3
  29. package/src/components/editor/manualEditsDom.ts +28 -5
  30. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  31. package/src/components/editor/manualOffsetDrag.ts +4 -1
  32. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  33. package/src/components/editor/mediaAudioEdits.ts +253 -0
  34. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  35. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  36. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  37. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  40. package/src/components/editor/useDomEditNudge.ts +41 -23
  41. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  42. package/src/components/panels/SlideshowPanel.tsx +12 -7
  43. package/src/contexts/DomEditContext.test.tsx +62 -0
  44. package/src/contexts/DomEditContext.tsx +94 -77
  45. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  46. package/src/hooks/domEditCommitTypes.ts +10 -0
  47. package/src/hooks/domEditPersistFailure.ts +12 -1
  48. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  49. package/src/hooks/domStyleCommit.ts +2 -8
  50. package/src/hooks/gsapDragCommit.ts +3 -8
  51. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  52. package/src/hooks/gsapEditOutcome.ts +2 -1
  53. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  54. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  55. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  56. package/src/hooks/gsapResizeIntercept.ts +6 -2
  57. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  58. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  59. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  60. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  61. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  62. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  63. package/src/hooks/useAppHotkeys.test.ts +17 -0
  64. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  65. package/src/hooks/useAppHotkeys.ts +3 -3
  66. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  67. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  68. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  69. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  70. package/src/hooks/useDomEditCommits.ts +2 -0
  71. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  72. package/src/hooks/useDomEditPersist.ts +28 -13
  73. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  74. package/src/hooks/useDomEditSession.ts +2 -3
  75. package/src/hooks/useDomEditTextCommits.ts +4 -8
  76. package/src/hooks/useDomGeometryCommits.ts +11 -2
  77. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  78. package/src/hooks/useGsapAwareEditing.ts +6 -6
  79. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  80. package/src/hooks/useGsapScriptCommits.ts +5 -4
  81. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  82. package/src/hooks/useInlineTextEdit.ts +9 -7
  83. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  84. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  85. package/src/hooks/usePreviewPersistence.ts +6 -0
  86. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  87. package/src/index.ts +7 -0
  88. package/src/player/components/Timeline.test.ts +0 -21
  89. package/src/player/components/Timeline.tsx +0 -1
  90. package/src/player/components/timelineLayout.ts +0 -28
  91. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  92. package/src/player/lib/playbackShortcuts.ts +3 -7
  93. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  94. package/src/player/lib/timelineElementHelpers.ts +16 -2
  95. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  96. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  97. package/src/utils/gsapSoftReload.ts +3 -2
  98. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  99. package/src/utils/gsapUndoRestore.test.ts +145 -1
  100. package/src/utils/gsapUndoRestore.ts +70 -21
  101. package/src/utils/sourcePatcher.ts +1 -1
  102. package/src/utils/studioFontHelpers.ts +2 -23
  103. package/src/utils/studioHelpers.test.ts +30 -0
  104. package/src/utils/studioHelpers.ts +18 -4
  105. package/src/utils/studioPendingEdits.test.ts +63 -0
  106. package/src/utils/studioPendingEdits.ts +16 -7
  107. package/src/utils/typingTarget.test.ts +13 -0
  108. package/src/utils/typingTarget.ts +5 -2
  109. package/src/webmcp/useStudioAgentTools.ts +3 -1
  110. package/dist/chunk-ICTZJPQQ.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment happy-dom
2
- import { act, createElement } from "react";
2
+ import { act, createElement, type ReactElement } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
@@ -10,6 +10,11 @@ import {
10
10
  openProjectHistory,
11
11
  type StudioApiAdapter,
12
12
  } from "@hyperframes/studio-server";
13
+ import { useDomEditNudge } from "../components/editor/useDomEditNudge";
14
+ import { DomEditProvider, useDomEditActionsContext } from "../contexts/DomEditContext";
15
+ import { __resetForTests as resetNudgeKeys } from "../utils/canvasNudgeGate";
16
+ import { trackedStudioEdit } from "../utils/studioPendingEdits";
17
+ import { makeSelection } from "./domSelectionTestHarness";
13
18
  import { useEditHistoryActions } from "./useEditHistoryActions";
14
19
  import { usePersistentEditHistory } from "./usePersistentEditHistory";
15
20
  import { usePreviewPersistence } from "./usePreviewPersistence";
@@ -19,6 +24,7 @@ const page = (left: string, top = "0px") =>
19
24
  const BEFORE = page("10px");
20
25
  const AFTER = page("50px");
21
26
  const OUTSIDE = page("50px", "99px");
27
+ const NUDGED = page("51px");
22
28
 
23
29
  const cleanup: Array<() => unknown> = [];
24
30
  let scratch = "";
@@ -78,14 +84,15 @@ async function studio() {
78
84
  showToast: () => {},
79
85
  syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply,
80
86
  showHistoryRestoreNow: persistence.showHistoryRestoreNow,
81
- waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves,
87
+ waitForPendingDomEditSaves: persistence.settlePendingEdits,
82
88
  });
83
89
  return null;
84
90
  }
85
91
  const root = createRoot(document.createElement("div"));
86
92
  await act(async () => root.render(createElement(Harness)));
87
93
  cleanup.push(() => act(() => root.unmount()));
88
- const box = () => iframe.contentDocument!.getElementById("box")!.style.left;
94
+ const element = (id: string) => iframe.contentDocument!.getElementById(id)!;
95
+ const box = () => element("box").style.left;
89
96
  const other = () => iframe.contentDocument!.getElementById("other")!.style.top;
90
97
  const show = (html: string) => {
91
98
  iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString(
@@ -105,8 +112,16 @@ async function studio() {
105
112
  );
106
113
  await vi.waitFor(() => expect(history.undoLabel).toBe("Move layer"));
107
114
  };
115
+ /** Mounts `ui` beside Studio, as the canvas is, for the rest of the test. */
116
+ const mount = (ui: ReactElement) => {
117
+ const canvas = createRoot(document.createElement("div"));
118
+ act(() => canvas.render(ui));
119
+ cleanup.push(() => act(() => canvas.unmount()));
120
+ };
108
121
  return {
109
122
  history: () => history,
123
+ element,
124
+ mount,
110
125
  persistence: () => persistence,
111
126
  actions: () => actions,
112
127
  box,
@@ -164,3 +179,107 @@ it("an undo after an outside write ends with the preview showing the file the se
164
179
  expect(s.other()).toBe("0px");
165
180
  expect(s.box()).toBe("50px");
166
181
  });
182
+
183
+ /** The canvas's arrow-key nudge on the box; `save` stands in for the burst's save once the keys stop. */
184
+ function Nudge({ target, save }: { target: HTMLElement; save: () => Promise<void> }) {
185
+ const selection = makeSelection("Box", target);
186
+ const ref = <T,>(current: T) => ({ current });
187
+ useDomEditNudge({
188
+ selection,
189
+ groupSelections: [],
190
+ allowCanvasMovement: true,
191
+ selectionRef: ref(selection),
192
+ overlayRectRef: ref({ left: 0, top: 0, width: 100, height: 40, editScaleX: 1, editScaleY: 1 }),
193
+ groupOverlayItemsRef: ref([]),
194
+ gestureRef: ref(null),
195
+ groupGestureRef: ref(null),
196
+ blockedMoveRef: ref(null),
197
+ onManualDragStartRef: ref(() => {}),
198
+ onBlockedMoveRef: ref(() => {}),
199
+ onPathOffsetCommitRef: ref(save),
200
+ onGroupPathOffsetCommitRef: ref(async () => {}),
201
+ });
202
+ return null;
203
+ }
204
+
205
+ it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
206
+ const s = await studio();
207
+ await s.edit();
208
+ resetNudgeKeys();
209
+ const save = vi.fn(async () => {
210
+ writeFileSync(s.path, NUDGED);
211
+ await s.history().recordEdit({
212
+ label: "Move layer",
213
+ files: { "index.html": { before: AFTER, after: NUDGED } },
214
+ });
215
+ });
216
+ s.mount(createElement(Nudge, { target: s.element("box"), save }));
217
+ act(() => {
218
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
219
+ });
220
+
221
+ const undone = s.actions().undo();
222
+ expect(s.box()).toBe("50px");
223
+ await act(() => undone);
224
+ expect(save).toHaveBeenCalledTimes(1);
225
+ expect(s.file()).toBe(AFTER);
226
+ expect(s.box()).toBe("50px");
227
+ });
228
+
229
+ it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
230
+ const s = await studio();
231
+ await s.edit();
232
+ const box = s.element("box");
233
+ let fail!: () => void;
234
+ const handleDomStyleCommit = vi.fn(async () => {
235
+ box.style.left = "70px";
236
+ try {
237
+ await s.persistence().queueDomEditSave(async () => {
238
+ await new Promise<void>((resolve) => (fail = resolve));
239
+ throw new Error("The save failed.");
240
+ });
241
+ } catch (error) {
242
+ box.style.left = "50px";
243
+ throw error;
244
+ }
245
+ });
246
+ let actions!: ReturnType<typeof useDomEditActionsContext>;
247
+ function Canvas() {
248
+ actions = useDomEditActionsContext();
249
+ return null;
250
+ }
251
+ const value = { handleDomStyleCommit } as unknown as Parameters<
252
+ typeof DomEditProvider
253
+ >[0]["value"];
254
+ s.mount(
255
+ <DomEditProvider value={value}>
256
+ <Canvas />
257
+ </DomEditProvider>,
258
+ );
259
+ const failed = actions.handleDomStyleCommit("left", "70px");
260
+
261
+ const undone = s.actions().undo();
262
+ await vi.waitFor(() => expect(fail).toBeTypeOf("function"));
263
+ fail();
264
+ await expect(failed).rejects.toThrow("The save failed.");
265
+ await act(() => undone);
266
+ expect(s.file()).toBe(BEFORE);
267
+ expect(s.box()).toBe("10px");
268
+ });
269
+
270
+ it("an undo pressed while a tracked timeline edit fails still undoes the edit before it", async () => {
271
+ const s = await studio();
272
+ await s.edit();
273
+ let fail!: () => void;
274
+ const timelineEdit = trackedStudioEdit(
275
+ () => new Promise<void>((_, reject) => (fail = () => reject(new Error("The save failed.")))),
276
+ );
277
+ const failed = timelineEdit();
278
+
279
+ const undone = s.actions().undo();
280
+ fail();
281
+ await expect(failed).rejects.toThrow("The save failed.");
282
+ await act(() => undone);
283
+ expect(s.file()).toBe(BEFORE);
284
+ expect(s.box()).toBe("10px");
285
+ });
@@ -93,6 +93,7 @@ export interface UseGsapAwareEditingParams {
93
93
  next: { width: number; height: number },
94
94
  offset?: { x: number; y: number },
95
95
  restore?: () => void,
96
+ undoKey?: string,
96
97
  ) => Promise<void>;
97
98
  commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
98
99
  // GSAP script commit ops (from useGsapScriptCommits)
@@ -394,12 +395,11 @@ export function useGsapAwareEditing({
394
395
  makeFetchFallback(selection),
395
396
  );
396
397
  assertGsapEditPersisted(outcome);
397
- cropUndoKey = coalesceKey;
398
- // What the resize actually did, not what its animations suggest
399
- // it would do. An element whose scale is an instant hold has a
400
- // scale-group tween and still commits width/height, so guessing
401
- // from the tweens withheld an offset nobody had written and the
402
- // element snapped back to its authored position on every drag.
398
+ // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
399
+ if (outcome.status === "element-size")
400
+ await handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey);
401
+ else cropUndoKey = coalesceKey;
402
+ // What the resize did, not what its tweens suggest: a scale hold still commits a size.
403
403
  const ownsDragOffset =
404
404
  outcome.status === "persisted" && outcome.ownsDragOffset === true;
405
405
  logResize("intercept-handled", {
@@ -30,6 +30,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
30
30
  import type { MutationResult } from "./gsapScriptCommitTypes";
31
31
  import { persistSdkSerialize } from "../utils/sdkCutover";
32
32
  import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
33
+ import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
33
34
 
34
35
  // ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
35
36
 
@@ -426,6 +427,24 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
426
427
  );
427
428
  }
428
429
 
430
+ describe("a GSAP script commit", () => {
431
+ it("counts as a pending edit from its call until it lands, so a quick Cmd+Z waits for it", async () => {
432
+ mockFetchResult();
433
+ const deps = renderCommitHook();
434
+ let committed!: Promise<unknown>;
435
+ act(() => {
436
+ committed = deps.api.commitMutation(
437
+ selection,
438
+ { type: "remove-all-keyframes", animationId: "a" },
439
+ { label: "Remove all keyframes" },
440
+ );
441
+ });
442
+ expect(hasStudioPendingEdits()).toBe(true);
443
+ await act(async () => void (await committed));
444
+ expect(hasStudioPendingEdits()).toBe(false);
445
+ });
446
+ });
447
+
429
448
  describe("runCommit — instantPatch wiring", () => {
430
449
  it("explains a deliberate mutation that the server safely rejected as unchanged", async () => {
431
450
  mockFetchResult({ changed: false });
@@ -7,6 +7,7 @@ import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"
7
7
  import type { SoftReloadResult } from "../utils/gsapSoftReload";
8
8
  import { trackStudioEvent } from "../utils/studioTelemetry";
9
9
  import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
10
+ import { trackedStudioEdit } from "../utils/studioPendingEdits";
10
11
  import {
11
12
  getStudioSaveErrorMessage,
12
13
  isStudioSaveErrorAlreadyToasted,
@@ -416,20 +417,20 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
416
417
  }
417
418
  return run();
418
419
  };
419
- const commit: CommitMutation = (selection, mutation, options) => {
420
+ const commit: CommitMutation = trackedStudioEdit((selection, mutation, options) => {
420
421
  if (!activeProjectId) return Promise.resolve();
421
422
  const file = selection.sourceFile || activeCompPath || "index.html";
422
423
  return serializeCommit(file, options.serializeKey, () =>
423
424
  runCommit(activeProjectId, activeCompPath, file, selection, mutation, options),
424
425
  );
425
- };
426
- commit.batch = (calls, options) => {
426
+ }) as CommitMutation;
427
+ commit.batch = trackedStudioEdit((calls: CommitMutationCall[], options: CommitMutationOptions) => {
427
428
  if (!activeProjectId) return Promise.resolve();
428
429
  const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
429
430
  return serializeCommit(file, options.serializeKey, () =>
430
431
  runBatchCommit(activeProjectId, activeCompPath, file, calls, options),
431
432
  );
432
- };
433
+ });
433
434
  return commit;
434
435
  }, [runCommit, runBatchCommit, activeCompPath, activeProjectId, writeProjectFile]);
435
436
  const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { PreviewReadOnlyProvider } from "../components/editor/previewReadOnlyContext";
7
7
  import { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
8
+ import { applyUndoRestoreToPreview } from "../utils/gsapUndoRestore";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
@@ -643,3 +644,29 @@ describe("useInlineTextEdit with styled runs", () => {
643
644
  act(() => root.unmount());
644
645
  });
645
646
  });
647
+
648
+ describe("an undo that lands while a text edit is open", () => {
649
+ it("marks the edit as drawing nothing, so the undo reverts the move and keeps the edit", () => {
650
+ const wrap = (body: string) => `<html><body>${body}</body></html>`;
651
+ const moved = `<h1 id="t" style="translate: 90px 60px">Title</h1>`;
652
+ document.body.innerHTML = moved;
653
+ const element = document.getElementById("t")!;
654
+ const { controls, root } = mount();
655
+ act(() => {
656
+ controls().start(element);
657
+ });
658
+ const contentWindow = { __player: { seek: vi.fn() }, __hfStudioManualEditsApply: vi.fn() };
659
+ const iframe = { contentWindow, contentDocument: document } as unknown as HTMLIFrameElement;
660
+ const files = {
661
+ "index.html": { previous: wrap(moved), restored: wrap(`<h1 id="t">Title</h1>`) },
662
+ };
663
+
664
+ expect(element.getAttribute("data-hf-studio-manual-edit-gesture")).toMatch(/:edit$/);
665
+ expect(applyUndoRestoreToPreview(iframe, "index.html", files, 0, vi.fn())).toBe("soft");
666
+
667
+ expect(element.style.getPropertyValue("translate")).toBe("");
668
+ expect(element.getAttribute("contenteditable")).toBe("true");
669
+ expect(element.style.getPropertyValue("outline")).not.toBe("");
670
+ act(() => root.unmount());
671
+ });
672
+ });
@@ -5,7 +5,7 @@ import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
5
5
  import {
6
6
  beginStudioManualEditGesture,
7
7
  endStudioManualEditGesture,
8
- noteStudioManualEditSave,
8
+ countStudioManualEditSave,
9
9
  } from "../components/editor/manualEditsDom";
10
10
 
11
11
  /**
@@ -141,7 +141,7 @@ export function useInlineTextEdit({
141
141
  original: element.innerHTML,
142
142
  outline: element.style.outline,
143
143
  outlineOffset: element.style.outlineOffset,
144
- gesture: beginStudioManualEditGesture(element),
144
+ gesture: beginStudioManualEditGesture(element, "edit"),
145
145
  };
146
146
  // Drawn on the element itself, not in Studio's overlay above it. This is
147
147
  // the only mark that says the caret is in the TEXT rather than the
@@ -181,11 +181,13 @@ export function useInlineTextEdit({
181
181
  // Counted and pending before the edit closes, so a reload waits for the save, not the mark.
182
182
  let save: (saving: unknown) => void = () => {};
183
183
  trackStudioPendingEdit(new Promise((resolve) => (save = resolve)));
184
- noteStudioManualEditSave(open.element);
185
- teardown();
186
- // After teardown, so the commit path's own resync does not fight an
187
- // element that is still editable.
188
- save(onCommit({ element: open.element, html, previousHtml: open.original }));
184
+ const commitText = () => {
185
+ teardown();
186
+ // After teardown, so the commit path's own resync does not fight an
187
+ // element that is still editable.
188
+ return onCommit({ element: open.element, html, previousHtml: open.original });
189
+ };
190
+ save(countStudioManualEditSave(open.element, commitText));
189
191
  }, [onCommit, teardown]);
190
192
 
191
193
  const cancel = useCallback(() => {
@@ -86,6 +86,21 @@ describe("keyframe shortcuts", () => {
86
86
  window.removeEventListener("keydown", playback);
87
87
  });
88
88
 
89
+ describe("with a focused picker", () => {
90
+ const picker = document.createElement("button");
91
+ picker.setAttribute("role", "combobox");
92
+ afterEach(() => picker.remove());
93
+
94
+ it("leaves K to a focused picker that owns typing, like a font combobox", () => {
95
+ const onAddKeyframe = mountKeyframeKeyboard(true);
96
+ document.body.append(picker);
97
+ picker.focus();
98
+
99
+ expect(press("k").defaultPrevented).toBe(false);
100
+ expect(onAddKeyframe).not.toHaveBeenCalled();
101
+ });
102
+ });
103
+
89
104
  it("K stays playback's stop key when no keyframeable element is selected", () => {
90
105
  const playback = vi.fn();
91
106
  window.addEventListener("keydown", playback);
@@ -1,23 +1,17 @@
1
1
  import { useEffect, useCallback } from "react";
2
2
  import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
3
+ import { isTypingTarget } from "../utils/typingTarget";
3
4
 
4
5
  interface KeyframeKeyboardOptions {
5
6
  enabled: boolean;
6
7
  onAddKeyframe?: () => void;
7
8
  }
8
9
 
9
- function isTextInput(el: Element | null): boolean {
10
- if (!el) return false;
11
- const tag = el.tagName;
12
- if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
13
- return (el as HTMLElement).isContentEditable === true;
14
- }
15
-
16
10
  export function useKeyframeKeyboard({ enabled, onAddKeyframe }: KeyframeKeyboardOptions): void {
17
11
  const handler = useCallback(
18
12
  (e: KeyboardEvent) => {
19
13
  if (!enabled || !onAddKeyframe) return;
20
- if (isTextInput(document.activeElement)) return;
14
+ if (isTypingTarget(document.activeElement)) return;
21
15
  if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
22
16
  if (e.key.toLowerCase() !== STUDIO_PLAIN_KEYS.addKeyframe) return;
23
17
  // K is also playback's stop key: claiming it here keeps playback from also pausing.
@@ -159,6 +159,11 @@ export function usePreviewPersistence({
159
159
  if (result.status !== "clean") throw result.error;
160
160
  }, [drainPendingDomEditSaves]);
161
161
 
162
+ const settlePendingEdits = useCallback(async (): Promise<void> => {
163
+ await flushStudioPendingEdits();
164
+ await domEditSaveQueueRef.current?.waitForIdle();
165
+ }, []);
166
+
162
167
  const resetDomEditSaveQueueBreaker = useCallback(() => {
163
168
  domEditSaveQueueRef.current?.reset();
164
169
  setDomEditSaveQueuePaused(null);
@@ -253,6 +258,7 @@ export function usePreviewPersistence({
253
258
  queueDomEditSave,
254
259
  drainPendingDomEditSaves,
255
260
  waitForPendingDomEditSaves,
261
+ settlePendingEdits,
256
262
  domEditSaveQueuePaused,
257
263
  resetDomEditSaveQueueBreaker,
258
264
  applyCurrentStudioManualEditsToPreview,
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
- import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
2
+ import { trackedStudioEdit } from "../utils/studioPendingEdits";
3
3
 
4
4
  type AsyncFn = (...args: never[]) => Promise<unknown>;
5
5
 
@@ -11,11 +11,7 @@ export function useTrackPendingTimelineEdit() {
11
11
  const key = fn as unknown as AsyncFn;
12
12
  const cached = wrappedRef.current.get(key);
13
13
  if (cached) return cached as unknown as (...args: Args) => Promise<R>;
14
- const wrapped = (...args: Args): Promise<R> => {
15
- const result = fn(...args);
16
- trackStudioPendingEdit(result);
17
- return result;
18
- };
14
+ const wrapped = trackedStudioEdit(fn);
19
15
  wrappedRef.current.set(key, wrapped as unknown as AsyncFn);
20
16
  return wrapped;
21
17
  }, []);
package/src/index.ts CHANGED
@@ -240,3 +240,10 @@ export type { MarqueeGestures, MarqueeGesturesDeps } from "./components/editor/m
240
240
  export { MarqueeOverlay } from "./components/editor/MarqueeOverlay";
241
241
  export type { MarqueeOverlayProps } from "./components/editor/MarqueeOverlay";
242
242
  export type { Rect as MarqueeRect } from "./utils/marqueeGeometry";
243
+
244
+ export { buildStudioTools } from "./webmcp/useStudioAgentTools";
245
+ export type { StudioAgentToolsDeps } from "./webmcp/useStudioAgentTools";
246
+ export { collectStudioLookScene } from "./webmcp/tools/lookTools";
247
+ export type { StudioLookSnapshot } from "./webmcp/tools/lookTools";
248
+ export type { ModelContextTool } from "./webmcp/types";
249
+ export type { ToolResult } from "./webmcp/toolResult";
@@ -16,7 +16,6 @@ import {
16
16
  getTimelineScrollLeftForZoomAnchor,
17
17
  getTimelineScrollLeftForZoomTransition,
18
18
  shouldShowTimelineShortcutHint,
19
- shouldHandleTimelineDeleteKey,
20
19
  shouldAutoScrollTimeline,
21
20
  getTimelineVisibleTimeRange,
22
21
  getTimelineScrollTopForGeometryChange,
@@ -1248,26 +1247,6 @@ describe("shouldShowTimelineShortcutHint", () => {
1248
1247
  });
1249
1248
  });
1250
1249
 
1251
- describe("shouldHandleTimelineDeleteKey", () => {
1252
- it("handles Delete and Backspace when focus is not in an editor", () => {
1253
- expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
1254
- expect(shouldHandleTimelineDeleteKey({ key: "Backspace" })).toBe(true);
1255
- });
1256
-
1257
- it("ignores modifier shortcuts", () => {
1258
- expect(shouldHandleTimelineDeleteKey({ key: "Delete", metaKey: true })).toBe(false);
1259
- expect(shouldHandleTimelineDeleteKey({ key: "Backspace", ctrlKey: true })).toBe(false);
1260
- });
1261
-
1262
- it("ignores input and editable targets", () => {
1263
- const input = { tagName: "INPUT", isContentEditable: false };
1264
- const editable = { tagName: "DIV", isContentEditable: true };
1265
-
1266
- expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: input })).toBe(false);
1267
- expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: editable })).toBe(false);
1268
- });
1269
- });
1270
-
1271
1250
  describe("getDefaultDroppedTrack", () => {
1272
1251
  it("defaults to track 0 when there are no rows yet", () => {
1273
1252
  expect(getDefaultDroppedTrack([])).toBe(0);
@@ -26,7 +26,6 @@ export {
26
26
  getTimelineCanvasHeight,
27
27
  shouldShowTimelineShortcutHint,
28
28
  resolveTimelineAssetDrop,
29
- shouldHandleTimelineDeleteKey,
30
29
  getDefaultDroppedTrack,
31
30
  } from "./timelineLayout";
32
31
  export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";
@@ -506,34 +506,6 @@ export function shouldShowTimelineShortcutHint(
506
506
  return scrollHeight - clientHeight <= 1;
507
507
  }
508
508
 
509
- export function shouldHandleTimelineDeleteKey(input: {
510
- key: string;
511
- metaKey?: boolean;
512
- ctrlKey?: boolean;
513
- altKey?: boolean;
514
- target?: EventTarget | null;
515
- }): boolean {
516
- if (input.key !== "Delete" && input.key !== "Backspace") return false;
517
- if (input.metaKey || input.ctrlKey || input.altKey) return false;
518
- const target =
519
- input.target && typeof input.target === "object"
520
- ? (input.target as {
521
- tagName?: string;
522
- isContentEditable?: boolean;
523
- closest?: (selector: string) => Element | null;
524
- })
525
- : null;
526
- if (target) {
527
- const tag = target.tagName?.toLowerCase() ?? "";
528
- if (target.isContentEditable) return false;
529
- if (["input", "textarea", "select"].includes(tag)) return false;
530
- if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
531
- return false;
532
- }
533
- }
534
- return true;
535
- }
536
-
537
509
  /* ── Asset drop ───────────────────────────────────────────────────── */
538
510
  export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
539
511
  if (trackOrder.length === 0) return 0;
@@ -261,3 +261,46 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
261
261
  expect(usePlayerStore.getState().loopEnabled).toBe(false);
262
262
  });
263
263
  });
264
+
265
+ describe("usePlaybackKeyboard — a focused native player owns its keys", () => {
266
+ const KEYS = [
267
+ { code: "ArrowLeft", key: "ArrowLeft" },
268
+ { code: "ArrowRight", key: "ArrowRight" },
269
+ { code: "Space", key: " " },
270
+ { code: "KeyJ", key: "j" },
271
+ { code: "KeyK", key: "k" },
272
+ { code: "KeyL", key: "l" },
273
+ ];
274
+
275
+ it.each(["video", "audio"])(
276
+ "leaves the timeline alone while a <%s controls> has focus",
277
+ (tag) => {
278
+ const { dispatch, spies } = setupHook();
279
+ const player = document.createElement(tag);
280
+ player.setAttribute("controls", "");
281
+ document.body.append(player);
282
+ const before = usePlayerStore.getState().currentTime;
283
+ for (const init of KEYS) {
284
+ const event = keydown(init);
285
+ Object.defineProperty(event, "target", { value: player });
286
+ dispatch(event);
287
+ expect(event.defaultPrevented, init.code).toBe(false);
288
+ }
289
+ expect(spies.seek).not.toHaveBeenCalled();
290
+ expect(spies.play).not.toHaveBeenCalled();
291
+ expect(spies.playBackward).not.toHaveBeenCalled();
292
+ expect(spies.pause).not.toHaveBeenCalled();
293
+ expect(usePlayerStore.getState().currentTime).toBe(before);
294
+ },
295
+ );
296
+
297
+ it("still steps the timeline from a player without controls", () => {
298
+ const { dispatch } = setupHook();
299
+ const video = document.createElement("video");
300
+ document.body.append(video);
301
+ const event = keydown({ code: "ArrowRight", key: "ArrowRight" });
302
+ Object.defineProperty(event, "target", { value: video });
303
+ dispatch(event);
304
+ expect(event.defaultPrevented).toBe(true);
305
+ });
306
+ });
@@ -6,7 +6,7 @@
6
6
  * is active and the user is navigating caption segments).
7
7
  */
8
8
 
9
- import { isTypingTarget } from "../../utils/typingTarget";
9
+ import { ownsPlainKeys } from "../../utils/typingTarget";
10
10
 
11
11
  const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
12
12
 
@@ -15,21 +15,17 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
15
15
  "a[href]",
16
16
  "[role='button']",
17
17
  "[role='checkbox']",
18
- "[role='combobox']",
19
18
  "[role='menuitem']",
20
19
  // Base UI's menu radio item is a `<div>`, so `button` above no longer catches it.
21
20
  "[role='menuitemradio']",
22
21
  "[role='radio']",
23
- "[role='slider']",
24
22
  "[role='spinbutton']",
25
- "[role='switch']",
26
- "[role='textbox']",
27
23
  ].join(",");
28
24
 
29
25
  export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
30
26
  // Anything the user is typing into owns its keys outright, editable elements
31
- // included: a letter claimed here never reaches the text.
32
- if (isTypingTarget(target)) return true;
27
+ // included: a letter claimed here never reaches the text. So does a native player.
28
+ if (ownsPlainKeys(target)) return true;
33
29
  if (!target || typeof target !== "object") return false;
34
30
  const candidate = target as { closest?: unknown };
35
31
  if (typeof candidate.closest !== "function") return false;
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isVideoAudible } from "./timelineElementHelpers";
3
+
4
+ describe("isVideoAudible — the compiler's data-has-audio rule", () => {
5
+ it("explicit data-has-audio wins", () => {
6
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: "true", muted: false })).toBe(true);
7
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: "false", muted: false })).toBe(false);
8
+ });
9
+ it("no attribute: an unmuted video is audible, a muted one is not", () => {
10
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: null, muted: false })).toBe(true);
11
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: undefined, muted: true })).toBe(false);
12
+ });
13
+ it("never true for non-video without the attribute", () => {
14
+ expect(isVideoAudible({ tag: "img", hasAudioAttr: null, muted: false })).toBe(false);
15
+ });
16
+ });
@@ -220,12 +220,26 @@ function readVolume(el: Element, media: Element): number | undefined {
220
220
  return Number.isFinite(volume) ? volume : undefined;
221
221
  }
222
222
 
223
+ /** The compiler's rule (timingCompiler): explicit data-has-audio wins; otherwise an unmuted <video> is audible. */
224
+ export function isVideoAudible(opts: {
225
+ tag: string;
226
+ hasAudioAttr: string | null | undefined;
227
+ muted: boolean;
228
+ }): boolean {
229
+ if (opts.hasAudioAttr === "true") return true;
230
+ if (opts.hasAudioAttr === "false" || opts.hasAudioAttr === "") return false;
231
+ return opts.tag.toLowerCase() === "video" && !opts.muted;
232
+ }
233
+
223
234
  /** What the mixer gets: the compiler's `data-has-audio` rule, muted and volume. */
224
235
  function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
225
236
  const media = resolveMediaElement(el) ?? el;
226
237
  const muted = el.hasAttribute("muted") || media.hasAttribute("muted");
227
- const hasAudio = el.getAttribute("data-has-audio");
228
- const sound = hasAudio === null ? el.tagName === "VIDEO" && !muted : hasAudio === "true";
238
+ const sound = isVideoAudible({
239
+ tag: el.tagName,
240
+ hasAudioAttr: el.getAttribute("data-has-audio"),
241
+ muted,
242
+ });
229
243
  setOptional(entry, "hasAudio", sound ? true : undefined);
230
244
  setOptional(entry, "muted", muted ? true : undefined);
231
245
  setOptional(entry, "volume", readVolume(el, media));
@@ -19,6 +19,8 @@ describe("timeline viewport budgets", () => {
19
19
  interactionP95Ms: 50,
20
20
  constrainedInteractionP95Ms: 75,
21
21
  constrainedFrameIntervalP95Ms: 75,
22
+ ciVirtualizedInteractionP95Ms: 58.3,
23
+ ciVirtualizedFrameIntervalP95Ms: 25,
22
24
  longTaskLimitMs: 50,
23
25
  constrainedLongTaskLimitMs: 300,
24
26
  posterCoverageRatio: 0.9,