@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.
- package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-h4mktSdm.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-CM1xA_jD.js} +195 -200
- package/dist/assets/{index-ooaZZhKf.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/{index-DKT5NF1Y.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-ICTZJPQQ.js → chunk-4OZC426H.js} +12 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +367 -11
- package/dist/index.html +1 -1
- package/dist/index.js +1064 -714
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/DomEditOverlay.tsx +12 -18
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +28 -5
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +4 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useDomEditNudge.test.tsx +134 -2
- package/src/components/editor/useDomEditNudge.ts +41 -23
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +17 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +3 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -3
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
- package/src/hooks/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +9 -7
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +7 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +16 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/utils/gsapSoftReload.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +63 -0
- package/src/utils/studioPendingEdits.ts +16 -7
- package/src/utils/typingTarget.test.ts +13 -0
- package/src/utils/typingTarget.ts +5 -2
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- 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.
|
|
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
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 (
|
|
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 {
|
|
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 = (
|
|
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 {
|
|
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 (
|
|
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
|
|
228
|
-
|
|
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,
|