@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
|
@@ -3,6 +3,7 @@ import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-pa
|
|
|
3
3
|
import type { DomEditSelection } from "./domEditing";
|
|
4
4
|
import type { ImportedFontAsset } from "./fontAssets";
|
|
5
5
|
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
|
6
|
+
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
6
7
|
|
|
7
8
|
export interface BackgroundRemovalProgress {
|
|
8
9
|
status: "processing" | "complete" | "failed";
|
|
@@ -68,6 +69,7 @@ export interface PropertyPanelProps {
|
|
|
68
69
|
value: string | null,
|
|
69
70
|
) => Promise<{ changedFiles: number; changedElements: number }>;
|
|
70
71
|
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
72
|
+
onSetAttributeBatch: CommitDomAttributeBatch;
|
|
71
73
|
onRemoveBackground?: (
|
|
72
74
|
inputPath: string,
|
|
73
75
|
options: {
|
|
@@ -6,6 +6,7 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect
|
|
|
6
6
|
import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
|
|
7
7
|
import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
|
|
8
8
|
import { __resetForTests } from "../../utils/canvasNudgeGate";
|
|
9
|
+
import { flushStudioPendingEdits, hasStudioPendingEdits } from "../../utils/studioPendingEdits";
|
|
9
10
|
import type { DomEditSelection } from "./domEditing";
|
|
10
11
|
import type { OverlayRect } from "./domEditOverlayGeometry";
|
|
11
12
|
|
|
@@ -27,6 +28,7 @@ const REST_RECT: OverlayRect = {
|
|
|
27
28
|
// Stable across renders on purpose: the test targets the `selection` identity
|
|
28
29
|
// key specifically, so `groupSelections` must not itself be a source of churn.
|
|
29
30
|
const EMPTY_GROUP_SELECTIONS: DomEditSelection[] = [];
|
|
31
|
+
let flushNudge = () => {};
|
|
30
32
|
|
|
31
33
|
function Harness({
|
|
32
34
|
selection,
|
|
@@ -35,7 +37,7 @@ function Harness({
|
|
|
35
37
|
selection: DomEditSelection | null;
|
|
36
38
|
onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
|
|
37
39
|
}) {
|
|
38
|
-
useDomEditNudge({
|
|
40
|
+
flushNudge = useDomEditNudge({
|
|
39
41
|
selection,
|
|
40
42
|
groupSelections: EMPTY_GROUP_SELECTIONS,
|
|
41
43
|
allowCanvasMovement: true,
|
|
@@ -49,7 +51,7 @@ function Harness({
|
|
|
49
51
|
onBlockedMoveRef: makeRef(() => {}),
|
|
50
52
|
onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
|
|
51
53
|
onGroupPathOffsetCommitRef: makeRef(async () => {}),
|
|
52
|
-
});
|
|
54
|
+
}).flushNudge;
|
|
53
55
|
return null;
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -259,6 +261,51 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
|
|
|
259
261
|
});
|
|
260
262
|
});
|
|
261
263
|
|
|
264
|
+
describe("useDomEditNudge — a focused native player owns the arrow keys", () => {
|
|
265
|
+
beforeEach(() => {
|
|
266
|
+
vi.useFakeTimers();
|
|
267
|
+
__resetForTests();
|
|
268
|
+
});
|
|
269
|
+
let root: ReturnType<typeof createRoot> | null = null;
|
|
270
|
+
afterEach(() => {
|
|
271
|
+
act(() => root?.unmount());
|
|
272
|
+
root = null;
|
|
273
|
+
document.body.innerHTML = "";
|
|
274
|
+
vi.useRealTimers();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it.each(["video", "audio"])(
|
|
278
|
+
"does not nudge the selection while a <%s controls> has focus",
|
|
279
|
+
(tag) => {
|
|
280
|
+
root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
281
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
282
|
+
element.id = "dot";
|
|
283
|
+
const player = document.body.appendChild(document.createElement(tag));
|
|
284
|
+
player.setAttribute("controls", "");
|
|
285
|
+
const commit = vi.fn();
|
|
286
|
+
act(() => {
|
|
287
|
+
root?.render(
|
|
288
|
+
React.createElement(Harness, {
|
|
289
|
+
selection: makeSelection("Dot", element),
|
|
290
|
+
onPathOffsetCommit: commit,
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
const event = new KeyboardEvent("keydown", {
|
|
295
|
+
key: "ArrowRight",
|
|
296
|
+
bubbles: true,
|
|
297
|
+
cancelable: true,
|
|
298
|
+
});
|
|
299
|
+
act(() => {
|
|
300
|
+
player.dispatchEvent(event);
|
|
301
|
+
vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
|
|
302
|
+
});
|
|
303
|
+
expect(event.defaultPrevented).toBe(false);
|
|
304
|
+
expect(commit).not.toHaveBeenCalled();
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
262
309
|
describe("useDomEditNudge carries the route its press chose", () => {
|
|
263
310
|
beforeEach(() => {
|
|
264
311
|
vi.useFakeTimers();
|
|
@@ -291,3 +338,88 @@ describe("useDomEditNudge carries the route its press chose", () => {
|
|
|
291
338
|
element.remove();
|
|
292
339
|
});
|
|
293
340
|
});
|
|
341
|
+
|
|
342
|
+
describe("useDomEditNudge — undo right after a burst", () => {
|
|
343
|
+
it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
|
|
344
|
+
__resetForTests();
|
|
345
|
+
vi.useFakeTimers();
|
|
346
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
347
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
348
|
+
element.id = "dot-undo";
|
|
349
|
+
let saved!: () => void;
|
|
350
|
+
const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
|
|
351
|
+
act(() => {
|
|
352
|
+
root.render(
|
|
353
|
+
React.createElement(Harness, {
|
|
354
|
+
selection: makeSelection("Dot", element),
|
|
355
|
+
onPathOffsetCommit: commit,
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
act(() => dispatchArrowRight());
|
|
360
|
+
|
|
361
|
+
let drained = false;
|
|
362
|
+
const drain = flushStudioPendingEdits().then(() => (drained = true));
|
|
363
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
364
|
+
vi.useRealTimers();
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
366
|
+
expect(drained).toBe(false);
|
|
367
|
+
saved();
|
|
368
|
+
await drain;
|
|
369
|
+
expect(drained).toBe(true);
|
|
370
|
+
act(() => root.unmount());
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("counts the burst as a pending edit from its first key until its save lands", async () => {
|
|
374
|
+
__resetForTests();
|
|
375
|
+
vi.useFakeTimers();
|
|
376
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
377
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
378
|
+
element.id = "dot-pending";
|
|
379
|
+
let saved!: () => void;
|
|
380
|
+
const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
|
|
381
|
+
act(() => {
|
|
382
|
+
root.render(
|
|
383
|
+
React.createElement(Harness, {
|
|
384
|
+
selection: makeSelection("Dot", element),
|
|
385
|
+
onPathOffsetCommit: commit,
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
expect(hasStudioPendingEdits()).toBe(false);
|
|
390
|
+
act(() => dispatchArrowRight());
|
|
391
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
392
|
+
act(() => vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
|
|
393
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
394
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
395
|
+
vi.useRealTimers();
|
|
396
|
+
saved();
|
|
397
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
398
|
+
act(() => root.unmount());
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("useDomEditNudge — a commit that throws", () => {
|
|
403
|
+
it("still ends the burst's pending edit, so undo and export never wait on it", async () => {
|
|
404
|
+
__resetForTests();
|
|
405
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
406
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
407
|
+
element.id = "dot-throws";
|
|
408
|
+
const commit = vi.fn(() => {
|
|
409
|
+
throw new Error("The commit threw.");
|
|
410
|
+
});
|
|
411
|
+
act(() => {
|
|
412
|
+
root.render(
|
|
413
|
+
React.createElement(Harness, {
|
|
414
|
+
selection: makeSelection("Dot", element),
|
|
415
|
+
onPathOffsetCommit: commit,
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
act(() => dispatchArrowRight());
|
|
420
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
421
|
+
expect(() => flushNudge()).toThrow("The commit threw.");
|
|
422
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
423
|
+
act(() => root.unmount());
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -9,6 +9,10 @@ import { useCallback, useEffect, useRef, type RefObject } from "react";
|
|
|
9
9
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
10
10
|
import { ownsPlainKeys } from "../../utils/typingTarget";
|
|
11
11
|
import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
|
|
12
|
+
import {
|
|
13
|
+
addStudioPendingEditFlushListener,
|
|
14
|
+
trackStudioPendingEdit,
|
|
15
|
+
} from "../../utils/studioPendingEdits";
|
|
12
16
|
import type { DomEditSelection } from "./domEditing";
|
|
13
17
|
import {
|
|
14
18
|
type GroupOverlayItem,
|
|
@@ -43,6 +47,7 @@ interface NudgeSession {
|
|
|
43
47
|
/** Accumulated delta of the burst, in composition px. */
|
|
44
48
|
accum: { x: number; y: number };
|
|
45
49
|
timer: ReturnType<typeof setTimeout> | null;
|
|
50
|
+
endPendingEdit: (saved?: Promise<unknown>) => void;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
export interface UseDomEditNudgeParams {
|
|
@@ -127,7 +132,7 @@ function resolveSingleNudgeTarget(
|
|
|
127
132
|
|
|
128
133
|
/**
|
|
129
134
|
* True when a keydown must not start/extend a nudge: canvas movement disabled,
|
|
130
|
-
* a pointer gesture already owns the element, or
|
|
135
|
+
* a pointer gesture already owns the element, or focus is in a field or a native player.
|
|
131
136
|
*/
|
|
132
137
|
function shouldIgnoreNudgeKey(p: UseDomEditNudgeParams, event: KeyboardEvent): boolean {
|
|
133
138
|
if (!p.allowCanvasMovement || event.defaultPrevented) return true;
|
|
@@ -143,31 +148,37 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
143
148
|
// Commit the pending burst: one source write per burst = one undo entry.
|
|
144
149
|
// Mirrors the drag's onPointerUp — same commit callbacks, same failure
|
|
145
150
|
// restore, same member teardown.
|
|
146
|
-
const commitSession = () => {
|
|
151
|
+
const commitSession = (): Promise<unknown> | undefined => {
|
|
147
152
|
const session = sessionRef.current;
|
|
148
|
-
if (!session) return;
|
|
153
|
+
if (!session) return undefined;
|
|
149
154
|
sessionRef.current = null;
|
|
150
155
|
if (session.timer) clearTimeout(session.timer);
|
|
151
|
-
const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
|
|
152
|
-
selection: member.selection,
|
|
153
|
-
next: applyManualOffsetNudgeCommit(member, session.accum),
|
|
154
|
-
plainTranslate: member.plainTranslate,
|
|
155
|
-
}));
|
|
156
156
|
const p = paramsRef.current;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
157
|
+
let saved: Promise<unknown> | undefined;
|
|
158
|
+
try {
|
|
159
|
+
const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
|
|
160
|
+
selection: member.selection,
|
|
161
|
+
next: applyManualOffsetNudgeCommit(member, session.accum),
|
|
162
|
+
plainTranslate: member.plainTranslate,
|
|
163
|
+
}));
|
|
164
|
+
const commit = session.isGroup
|
|
165
|
+
? p.onGroupPathOffsetCommitRef.current(updates)
|
|
166
|
+
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
|
|
167
|
+
plainTranslate: updates[0].plainTranslate,
|
|
168
|
+
});
|
|
169
|
+
saved = Promise.resolve(commit)
|
|
170
|
+
.catch(() => {
|
|
171
|
+
for (const member of session.members) {
|
|
172
|
+
if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
|
|
173
|
+
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
174
|
+
}
|
|
167
175
|
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
})
|
|
177
|
+
.finally(() => endManualOffsetDragMembers(session.members));
|
|
178
|
+
return saved;
|
|
179
|
+
} finally {
|
|
180
|
+
session.endPendingEdit(saved);
|
|
181
|
+
}
|
|
171
182
|
};
|
|
172
183
|
const commitSessionRef = useRef(commitSession);
|
|
173
184
|
commitSessionRef.current = commitSession;
|
|
@@ -179,6 +190,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
179
190
|
if (session.timer) clearTimeout(session.timer);
|
|
180
191
|
restoreManualOffsetDragMembers(session.members);
|
|
181
192
|
endManualOffsetDragMembers(session.members);
|
|
193
|
+
session.endPendingEdit();
|
|
182
194
|
}, [params.allowCanvasMovement]);
|
|
183
195
|
|
|
184
196
|
// Build drag members for the current target set — the same member snapshot a
|
|
@@ -205,7 +217,9 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
205
217
|
if (members.length === 0) return null;
|
|
206
218
|
// Same side effect a drag start has (pauses preview playback).
|
|
207
219
|
p.onManualDragStartRef.current?.();
|
|
208
|
-
|
|
220
|
+
let endPendingEdit: NudgeSession["endPendingEdit"] = () => {};
|
|
221
|
+
trackStudioPendingEdit(new Promise<unknown>((resolve) => (endPendingEdit = resolve)));
|
|
222
|
+
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, endPendingEdit };
|
|
209
223
|
};
|
|
210
224
|
|
|
211
225
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
@@ -231,8 +245,12 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
231
245
|
// Capture, like the other app-level key handlers, so a focused panel
|
|
232
246
|
// can't swallow the nudge before it reaches us.
|
|
233
247
|
window.addEventListener("keydown", listener, true);
|
|
248
|
+
const stopCommitOnUndoDrain = addStudioPendingEditFlushListener(() =>
|
|
249
|
+
commitSessionRef.current(),
|
|
250
|
+
);
|
|
234
251
|
return () => {
|
|
235
252
|
window.removeEventListener("keydown", listener, true);
|
|
253
|
+
stopCommitOnUndoDrain();
|
|
236
254
|
commitSessionRef.current();
|
|
237
255
|
};
|
|
238
256
|
});
|
|
@@ -245,7 +263,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
245
263
|
const selectionKey = selectionIdentityKey(params.selection);
|
|
246
264
|
const groupSelectionsKey = groupSelectionsIdentityKey(params.groupSelections);
|
|
247
265
|
// eslint-disable-next-line no-restricted-syntax
|
|
248
|
-
useEffect(() => () => commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
|
|
266
|
+
useEffect(() => () => void commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
|
|
249
267
|
|
|
250
268
|
// Claim the arrow keys from the playback frame-step while the selection is
|
|
251
269
|
// nudgeable (see canvasNudgeGate — listener order is mount-dependent, so
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
1
3
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
4
|
import {
|
|
3
5
|
toggleMainLineSlide,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
removeHotspot,
|
|
15
17
|
safeParseManifest,
|
|
16
18
|
makeSlideshowNotesController,
|
|
19
|
+
isPanelUndoKey,
|
|
17
20
|
} from "./SlideshowPanel";
|
|
18
21
|
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
|
|
19
22
|
|
|
@@ -518,3 +521,27 @@ describe("makeSlideshowNotesController", () => {
|
|
|
518
521
|
expect(persist).not.toHaveBeenCalled();
|
|
519
522
|
});
|
|
520
523
|
});
|
|
524
|
+
|
|
525
|
+
describe("isPanelUndoKey", () => {
|
|
526
|
+
function undoFrom(html: string) {
|
|
527
|
+
document.body.innerHTML = `<div>${html}</div>`;
|
|
528
|
+
const target = document.body.firstElementChild!.firstElementChild!;
|
|
529
|
+
return { metaKey: true, ctrlKey: false, shiftKey: false, key: "z", target };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
it("undoes the panel from a button or a slider", () => {
|
|
533
|
+
expect(isPanelUndoKey(undoFrom("<button>Add</button>"))).toBe(true);
|
|
534
|
+
expect(isPanelUndoKey(undoFrom('<div role="slider" tabindex="0"></div>'))).toBe(true);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("leaves Cmd+Z to a text field, contenteditable and select included", () => {
|
|
538
|
+
expect(isPanelUndoKey(undoFrom("<input />"))).toBe(false);
|
|
539
|
+
expect(isPanelUndoKey(undoFrom('<p contenteditable="plaintext-only">Note</p>'))).toBe(false);
|
|
540
|
+
expect(isPanelUndoKey(undoFrom("<select><option>a</option></select>"))).toBe(false);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("ignores Cmd+Shift+Z and a bare z", () => {
|
|
544
|
+
expect(isPanelUndoKey({ ...undoFrom("<button />"), shiftKey: true })).toBe(false);
|
|
545
|
+
expect(isPanelUndoKey({ ...undoFrom("<button />"), metaKey: false })).toBe(false);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
@@ -21,6 +21,7 @@ import { usePlayerStore } from "../../player";
|
|
|
21
21
|
import { useDomEditSelectionContext } from "../../contexts/DomEditContext";
|
|
22
22
|
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
|
23
23
|
import { generateId } from "../../utils/generateId";
|
|
24
|
+
import { isTypingTarget } from "../../utils/typingTarget";
|
|
24
25
|
import {
|
|
25
26
|
SectionHeader,
|
|
26
27
|
SlideList,
|
|
@@ -153,6 +154,13 @@ export function makeSlideshowNotesController(): NotesController {
|
|
|
153
154
|
};
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
export function isPanelUndoKey(
|
|
158
|
+
e: Pick<KeyboardEvent, "metaKey" | "ctrlKey" | "shiftKey" | "key" | "target">,
|
|
159
|
+
): boolean {
|
|
160
|
+
if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.key.toLowerCase() !== "z") return false;
|
|
161
|
+
return !isTypingTarget(e.target);
|
|
162
|
+
}
|
|
163
|
+
|
|
156
164
|
// ── Component ─────────────────────────────────────────────────────────────
|
|
157
165
|
|
|
158
166
|
export interface SlideshowPanelProps {
|
|
@@ -416,13 +424,10 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
|
|
|
416
424
|
className="flex flex-col h-full overflow-y-auto text-text-0"
|
|
417
425
|
onKeyDown={(e) => {
|
|
418
426
|
// In-panel undo — scoped so it never fights the app-level file undo.
|
|
419
|
-
if ((e
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
e.stopPropagation();
|
|
424
|
-
handleUndo();
|
|
425
|
-
}
|
|
427
|
+
if (!isPanelUndoKey(e)) return;
|
|
428
|
+
e.preventDefault();
|
|
429
|
+
e.stopPropagation();
|
|
430
|
+
handleUndo();
|
|
426
431
|
}}
|
|
427
432
|
>
|
|
428
433
|
{persistError && (
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
|
|
6
|
+
import { DomEditProvider, useDomEditActionsContext } from "./DomEditContext";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
const cleanup: Array<() => void> = [];
|
|
11
|
+
afterEach(() => cleanup.splice(0).forEach((step) => step()));
|
|
12
|
+
|
|
13
|
+
/** A resize whose save is still running, committed through the actions the canvas and panels use. */
|
|
14
|
+
function commitResize(save: Promise<void>) {
|
|
15
|
+
const handleDomBoxSizeCommit = vi.fn(() => save);
|
|
16
|
+
const value = { handleDomBoxSizeCommit } as unknown as Parameters<
|
|
17
|
+
typeof DomEditProvider
|
|
18
|
+
>[0]["value"];
|
|
19
|
+
let actions!: ReturnType<typeof useDomEditActionsContext>;
|
|
20
|
+
function Canvas() {
|
|
21
|
+
actions = useDomEditActionsContext();
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const root = createRoot(document.createElement("div"));
|
|
25
|
+
act(() =>
|
|
26
|
+
root.render(
|
|
27
|
+
<DomEditProvider value={value}>
|
|
28
|
+
<Canvas />
|
|
29
|
+
</DomEditProvider>,
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
cleanup.push(() => act(() => root.unmount()));
|
|
33
|
+
const committed = actions.handleDomBoxSizeCommit(
|
|
34
|
+
{} as never,
|
|
35
|
+
{ width: 300, height: 200 },
|
|
36
|
+
undefined,
|
|
37
|
+
);
|
|
38
|
+
return { committed, handleDomBoxSizeCommit };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
it("undo waits for an edit committed before its save has written anything", async () => {
|
|
42
|
+
let finish!: () => void;
|
|
43
|
+
const { committed } = commitResize(new Promise<void>((resolve) => (finish = resolve)));
|
|
44
|
+
let drained = false;
|
|
45
|
+
const drain = flushStudioPendingEdits().then(() => (drained = true));
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
47
|
+
expect(drained).toBe(false);
|
|
48
|
+
finish();
|
|
49
|
+
await drain;
|
|
50
|
+
await committed;
|
|
51
|
+
expect(drained).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("an edit that fails is waited out and reported to the drain, like every tracked edit", async () => {
|
|
55
|
+
let fail!: (error: Error) => void;
|
|
56
|
+
const { committed } = commitResize(new Promise<void>((_, reject) => (fail = reject)));
|
|
57
|
+
const drain = flushStudioPendingEdits();
|
|
58
|
+
const failure = new Error("blocked");
|
|
59
|
+
fail(failure);
|
|
60
|
+
await expect(committed).rejects.toThrow("blocked");
|
|
61
|
+
await expect(drain).resolves.toEqual({ status: "failed", error: failure });
|
|
62
|
+
});
|