@hyperframes/studio 0.8.105 → 0.8.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
- package/dist/assets/index-CM1xA_jD.js +394 -0
- package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/index-lrT-gfHa.css +1 -0
- package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +381 -14
- package/dist/index.html +11 -2
- package/dist/index.js +9974 -9045
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -9
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +2 -1
- package/src/components/StudioHeader.dom.test.tsx +31 -9
- package/src/components/StudioHeader.tsx +4 -1
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/ThemeToggle.tsx +99 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/DomEditOverlay.tsx +31 -5
- 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/SourceEditor.tsx +2 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +58 -2
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +4 -1
- package/src/components/editor/marqueeCommit.test.tsx +69 -1
- package/src/components/editor/marqueeCommit.ts +6 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -0
- package/src/components/editor/overlayFrameLoop.test.ts +27 -0
- package/src/components/editor/overlayFrameLoop.ts +21 -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/propertyPanelFont.test.tsx +80 -3
- package/src/components/editor/propertyPanelFont.tsx +69 -46
- package/src/components/editor/propertyPanelHelpers.ts +1 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/studioCodeTheme.test.ts +20 -0
- package/src/components/editor/studioCodeTheme.ts +99 -0
- package/src/components/editor/useDomEditNudge.test.tsx +165 -2
- package/src/components/editor/useDomEditNudge.ts +43 -25
- package/src/components/editor/useInlineTextEditing.tsx +1 -1
- package/src/components/editor/useMotionPathData.test.tsx +64 -0
- package/src/components/editor/useMotionPathData.ts +5 -1
- 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/appHotkeysDispatch.ts +2 -1
- 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/previewPromotionSelection.test.tsx +180 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +49 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +4 -5
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useClipboard.source.test.ts +99 -19
- package/src/hooks/useClipboard.ts +145 -124
- 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.test.tsx +76 -72
- package/src/hooks/useDomEditSession.ts +9 -46
- 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/useGsapTweenCache.ts +1 -2
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +21 -5
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/useLivePreviewIframe.ts +18 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
- package/src/hooks/usePreviewPersistence.ts +8 -5
- package/src/hooks/useRenderClipContent.ts +3 -3
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +9 -0
- package/src/livePreviewIframeExport.test.tsx +33 -0
- package/src/main.tsx +4 -1
- package/src/player/components/AudioWaveform.test.tsx +36 -15
- package/src/player/components/AudioWaveform.tsx +24 -7
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.test.tsx +38 -0
- package/src/player/components/TimelineClip.tsx +14 -6
- package/src/player/components/TimelineClipFades.test.tsx +376 -74
- package/src/player/components/TimelineClipFades.tsx +368 -137
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useTimelineKeyboardActor.ts +1 -1
- package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/hooks/useShadowPreviewReload.ts +72 -5
- package/src/player/lib/automationStoreSync.test.ts +18 -1
- package/src/player/lib/automationStoreSync.ts +29 -17
- 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/player/sceneSwap.ts +18 -0
- package/src/styles/components.css +26 -0
- package/src/styles/studio.css +6 -0
- package/src/styles/theme.css +10 -0
- package/src/utils/clipboardPayload.ts +10 -1
- 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 +37 -7
- package/src/utils/studioTheme.test.tsx +141 -0
- package/src/utils/studioTheme.ts +34 -0
- package/src/utils/studioUiPreferences.ts +3 -0
- package/src/utils/typingTarget.test.ts +21 -1
- package/src/utils/typingTarget.ts +8 -0
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/assets/index-Cre9X4RS.css +0 -1
- package/dist/assets/index-DEDhPmPD.js +0 -399
- package/dist/chunk-QADHJFDF.js.map +0 -1
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { useCallback, useEffect, useRef, type RefObject } from "react";
|
|
9
9
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
10
|
-
import {
|
|
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,12 +132,12 @@ 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;
|
|
134
139
|
if (p.gestureRef.current || p.groupGestureRef.current || p.blockedMoveRef.current) return true;
|
|
135
|
-
return
|
|
140
|
+
return ownsPlainKeys(event.target);
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () => void } {
|
|
@@ -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
|
|
@@ -44,7 +44,7 @@ export function useInlineTextEditing(
|
|
|
44
44
|
} {
|
|
45
45
|
const actions = useDomEditActionsContextOptional();
|
|
46
46
|
const inlineText = useInlineTextEdit({
|
|
47
|
-
onCommit: (commit) =>
|
|
47
|
+
onCommit: (commit) => actions?.handleDomRichTextCommit(commit),
|
|
48
48
|
onPause: () => usePlayerStore.getState().setIsPlaying(false),
|
|
49
49
|
});
|
|
50
50
|
const lastPressRef = useRef<PressMark | null>(null);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, useRef } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
5
|
+
import { useMotionPathData } from "./useMotionPathData";
|
|
6
|
+
import { resetOverlayFrameLoopForTests } from "./overlayFrameLoop";
|
|
7
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
|
+
|
|
9
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
10
|
+
|
|
11
|
+
const originalRaf = window.requestAnimationFrame;
|
|
12
|
+
let frames: Array<() => void> = [];
|
|
13
|
+
const runFrames = (count: number) => {
|
|
14
|
+
for (let i = 0; i < count; i++) {
|
|
15
|
+
const batch = frames;
|
|
16
|
+
frames = [];
|
|
17
|
+
act(() => {
|
|
18
|
+
for (const frame of batch) frame();
|
|
19
|
+
vi.advanceTimersByTime(16);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.useFakeTimers({
|
|
26
|
+
toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "performance"],
|
|
27
|
+
});
|
|
28
|
+
window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
|
|
29
|
+
frames.push(() => callback(performance.now()))) as typeof window.requestAnimationFrame;
|
|
30
|
+
usePlayerStore.setState({ previewBooted: true, motionPathArmed: false });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
resetOverlayFrameLoopForTests();
|
|
35
|
+
window.requestAnimationFrame = originalRaf;
|
|
36
|
+
vi.useRealTimers();
|
|
37
|
+
usePlayerStore.setState({ previewBooted: false, motionPathArmed: false });
|
|
38
|
+
document.body.innerHTML = "";
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("reads no layout per frame until there is a path or a create ring to draw", () => {
|
|
42
|
+
const iframe = document.createElement("iframe");
|
|
43
|
+
document.body.append(iframe);
|
|
44
|
+
const reads = vi.spyOn(iframe, "getBoundingClientRect");
|
|
45
|
+
function Probe() {
|
|
46
|
+
const ref = useRef(iframe);
|
|
47
|
+
useMotionPathData(ref, "#box");
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const host = document.createElement("div");
|
|
51
|
+
document.body.append(host);
|
|
52
|
+
const root = createRoot(host);
|
|
53
|
+
try {
|
|
54
|
+
act(() => root.render(<Probe />));
|
|
55
|
+
runFrames(10);
|
|
56
|
+
expect(reads).not.toHaveBeenCalled();
|
|
57
|
+
|
|
58
|
+
act(() => usePlayerStore.setState({ motionPathArmed: true }));
|
|
59
|
+
runFrames(10);
|
|
60
|
+
expect(reads).toHaveBeenCalled();
|
|
61
|
+
} finally {
|
|
62
|
+
act(() => root.unmount());
|
|
63
|
+
}
|
|
64
|
+
});
|
|
@@ -3,6 +3,7 @@ import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
|
|
|
3
3
|
import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
|
|
4
4
|
import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGeometry";
|
|
5
5
|
import { subscribeOverlayFrame } from "./overlayFrameLoop";
|
|
6
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
6
7
|
|
|
7
8
|
type Rect = { left: number; top: number; width: number; height: number };
|
|
8
9
|
|
|
@@ -114,6 +115,8 @@ export function useMotionPathData(
|
|
|
114
115
|
// Perspective magnification (1/m44) of the selected element — applied to the
|
|
115
116
|
// path's offset points so depth (translateZ) elements' paths track on screen.
|
|
116
117
|
const [pScale, setPScale] = useState(1);
|
|
118
|
+
const armed = usePlayerStore((s) => s.motionPathArmed);
|
|
119
|
+
const drawn = geometry !== null || armed;
|
|
117
120
|
|
|
118
121
|
useEffect(() => {
|
|
119
122
|
if (!selector) {
|
|
@@ -122,6 +125,7 @@ export function useMotionPathData(
|
|
|
122
125
|
return;
|
|
123
126
|
}
|
|
124
127
|
setHome(null);
|
|
128
|
+
if (!drawn) return;
|
|
125
129
|
const tick = () => {
|
|
126
130
|
const el = iframeRef.current;
|
|
127
131
|
if (el) {
|
|
@@ -155,7 +159,7 @@ export function useMotionPathData(
|
|
|
155
159
|
}
|
|
156
160
|
};
|
|
157
161
|
return subscribeOverlayFrame(tick);
|
|
158
|
-
}, [selector, iframeRef]);
|
|
162
|
+
}, [selector, iframeRef, drawn]);
|
|
159
163
|
|
|
160
164
|
useEffect(() => {
|
|
161
165
|
if (!selector) {
|
|
@@ -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
|
+
});
|
|
@@ -2,85 +2,88 @@
|
|
|
2
2
|
import type { useDomEditSession } from "../hooks/useDomEditSession";
|
|
3
3
|
import { useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
|
|
4
4
|
import { createStableContext } from "../utils/hmrStableContext";
|
|
5
|
+
import { trackedStudioEdit } from "../utils/studioPendingEdits";
|
|
5
6
|
|
|
6
7
|
type DomEditValue = ReturnType<typeof useDomEditSession>;
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
9
|
+
const SAVES_AN_EDIT = {
|
|
10
|
+
handleTimelineElementSelect: false,
|
|
11
|
+
handlePreviewCanvasMouseDown: false,
|
|
12
|
+
handlePreviewCanvasPointerMove: false,
|
|
13
|
+
handlePreviewCanvasPointerLeave: false,
|
|
14
|
+
applyDomSelection: false,
|
|
15
|
+
clearDomSelection: false,
|
|
16
|
+
refreshDomEditSelectionFromPreview: false,
|
|
17
|
+
handleDomStyleCommit: true,
|
|
18
|
+
handleDomStyleCommitForSelection: true,
|
|
19
|
+
handleDomAttributeCommit: true,
|
|
20
|
+
handleDomAttributeLiveCommit: false,
|
|
21
|
+
handleDomAttributeQuietCommit: true,
|
|
22
|
+
handleDomHtmlAttributeCommit: true,
|
|
23
|
+
handleDomAttributesCommit: true,
|
|
24
|
+
handleDomAttributeBatchCommit: true,
|
|
25
|
+
handleDomPathOffsetCommit: true,
|
|
26
|
+
handleDomGroupPathOffsetCommit: true,
|
|
27
|
+
handleDomZIndexReorderCommit: true,
|
|
28
|
+
handleDomBoxSizeCommit: true,
|
|
29
|
+
handleDomRotationCommit: true,
|
|
30
|
+
handleDomManualEditsReset: true,
|
|
31
|
+
handleDomTextCommit: true,
|
|
32
|
+
handleDomTextCommitForSelection: true,
|
|
33
|
+
handleDomRichTextCommit: true,
|
|
34
|
+
handleDomTextFieldStyleCommit: true,
|
|
35
|
+
handleDomAddTextField: true,
|
|
36
|
+
handleDomRemoveTextField: true,
|
|
37
|
+
getGsapAnimationsForSelection: false,
|
|
38
|
+
handleAskAgent: false,
|
|
39
|
+
handleAgentModalSubmit: false,
|
|
40
|
+
handleBlockedDomMove: false,
|
|
41
|
+
handleDomManualDragStart: false,
|
|
42
|
+
handleDomEditElementDelete: true,
|
|
43
|
+
handleGroupSelection: true,
|
|
44
|
+
handleUngroupSelection: true,
|
|
45
|
+
setActiveGroupElement: false,
|
|
46
|
+
buildDomSelectionFromTarget: false,
|
|
47
|
+
buildDomSelectionForTimelineElement: false,
|
|
48
|
+
updateDomEditHoverSelection: false,
|
|
49
|
+
resolveImportedFontAsset: false,
|
|
50
|
+
setAgentModalOpen: false,
|
|
51
|
+
setAgentPromptSelectionContext: false,
|
|
52
|
+
setAgentModalAnchorPoint: false,
|
|
53
|
+
handleGsapUpdateProperty: true,
|
|
54
|
+
handleGsapUpdateMeta: true,
|
|
55
|
+
handleGsapDeleteAnimation: true,
|
|
56
|
+
handleGsapDeleteAllForElement: true,
|
|
57
|
+
handleGsapAddAnimation: true,
|
|
58
|
+
handleGsapAddProperty: true,
|
|
59
|
+
handleGsapRemoveProperty: true,
|
|
60
|
+
handleGsapUpdateFromProperty: true,
|
|
61
|
+
handleGsapAddFromProperty: true,
|
|
62
|
+
handleGsapRemoveFromProperty: true,
|
|
63
|
+
handleGsapAddKeyframe: true,
|
|
64
|
+
handleGsapAddKeyframeBatch: true,
|
|
65
|
+
handleGsapRemoveKeyframe: true,
|
|
66
|
+
handleGsapMoveKeyframeToPlayhead: true,
|
|
67
|
+
handleGsapMoveKeyframe: true,
|
|
68
|
+
handleGsapResizeKeyframedTween: true,
|
|
69
|
+
handleGsapConvertToKeyframes: true,
|
|
70
|
+
handleGsapRemoveAllKeyframes: true,
|
|
71
|
+
handleResetSelectedElementKeyframes: true,
|
|
72
|
+
commitAnimatedProperty: true,
|
|
73
|
+
commitAnimatedProperties: true,
|
|
74
|
+
handleSetArcPath: true,
|
|
75
|
+
handleUpdateArcSegment: true,
|
|
76
|
+
handleUnroll: true,
|
|
77
|
+
invalidateGsapCache: false,
|
|
78
|
+
previewIframeRef: false,
|
|
79
|
+
commitMutation: true,
|
|
80
|
+
applyMarqueeSelection: false,
|
|
81
|
+
handleUpdateKeyframeEase: true,
|
|
82
|
+
handleUpdateSegmentEase: true,
|
|
83
|
+
handleSetAllKeyframeEases: true,
|
|
84
|
+
} as const satisfies Partial<Record<keyof DomEditValue, boolean>>;
|
|
85
|
+
|
|
86
|
+
export interface DomEditActionsValue extends Pick<DomEditValue, keyof typeof SAVES_AN_EDIT> {}
|
|
84
87
|
|
|
85
88
|
export interface DomEditSelectionValue extends Pick<
|
|
86
89
|
DomEditValue,
|
|
@@ -98,6 +101,15 @@ export interface DomEditSelectionValue extends Pick<
|
|
|
98
101
|
| "agentPromptSelectionContext"
|
|
99
102
|
> {}
|
|
100
103
|
|
|
104
|
+
function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue {
|
|
105
|
+
const tracked: Record<string, unknown> = { ...actions };
|
|
106
|
+
for (const key of Object.keys(SAVES_AN_EDIT) as Array<keyof typeof SAVES_AN_EDIT>) {
|
|
107
|
+
if (!SAVES_AN_EDIT[key]) continue;
|
|
108
|
+
tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown);
|
|
109
|
+
}
|
|
110
|
+
return tracked as unknown as DomEditActionsValue;
|
|
111
|
+
}
|
|
112
|
+
|
|
101
113
|
const DomEditActionsContext = createStableContext<DomEditActionsValue | null>(
|
|
102
114
|
"DomEditActionsContext",
|
|
103
115
|
null,
|
|
@@ -164,6 +176,7 @@ export function DomEditProvider({
|
|
|
164
176
|
handleDomAttributeQuietCommit,
|
|
165
177
|
handleDomHtmlAttributeCommit,
|
|
166
178
|
handleDomAttributesCommit,
|
|
179
|
+
handleDomAttributeBatchCommit,
|
|
167
180
|
handleDomPathOffsetCommit,
|
|
168
181
|
handleDomGroupPathOffsetCommit,
|
|
169
182
|
handleDomZIndexReorderCommit,
|
|
@@ -242,7 +255,7 @@ export function DomEditProvider({
|
|
|
242
255
|
[],
|
|
243
256
|
);
|
|
244
257
|
|
|
245
|
-
const
|
|
258
|
+
const untrackedActions = useMemo<DomEditActionsValue>(
|
|
246
259
|
() => ({
|
|
247
260
|
handleTimelineElementSelect,
|
|
248
261
|
handlePreviewCanvasMouseDown,
|
|
@@ -258,6 +271,7 @@ export function DomEditProvider({
|
|
|
258
271
|
handleDomAttributeQuietCommit,
|
|
259
272
|
handleDomHtmlAttributeCommit,
|
|
260
273
|
handleDomAttributesCommit,
|
|
274
|
+
handleDomAttributeBatchCommit,
|
|
261
275
|
handleDomPathOffsetCommit,
|
|
262
276
|
handleDomGroupPathOffsetCommit,
|
|
263
277
|
handleDomZIndexReorderCommit,
|
|
@@ -333,6 +347,7 @@ export function DomEditProvider({
|
|
|
333
347
|
handleDomAttributeQuietCommit,
|
|
334
348
|
handleDomHtmlAttributeCommit,
|
|
335
349
|
handleDomAttributesCommit,
|
|
350
|
+
handleDomAttributeBatchCommit,
|
|
336
351
|
handleDomPathOffsetCommit,
|
|
337
352
|
handleDomGroupPathOffsetCommit,
|
|
338
353
|
handleDomZIndexReorderCommit,
|
|
@@ -395,6 +410,8 @@ export function DomEditProvider({
|
|
|
395
410
|
],
|
|
396
411
|
);
|
|
397
412
|
|
|
413
|
+
const actions = useMemo(() => trackEditCommits(untrackedActions), [untrackedActions]);
|
|
414
|
+
|
|
398
415
|
const selection = useMemo<DomEditSelectionValue>(
|
|
399
416
|
() => ({
|
|
400
417
|
domEditSelection,
|
|
@@ -3,7 +3,7 @@ import { usePlayerStore } from "../player";
|
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
5
|
import { useDockLayoutStore } from "../components/dock/dockLayoutStore";
|
|
6
|
-
import { isTypingTarget } from "../utils/typingTarget";
|
|
6
|
+
import { isTypingTarget, ownsPlainKeys } from "../utils/typingTarget";
|
|
7
7
|
import { isEditableTarget } from "../utils/timelineDiscovery";
|
|
8
8
|
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
|
9
9
|
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
@@ -153,6 +153,7 @@ export function dispatchModifierKey(
|
|
|
153
153
|
* Delete arbitration between keyframes, an automation range and the clip can
|
|
154
154
|
* be asserted without standing up the whole hook. */
|
|
155
155
|
export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
|
|
156
|
+
if (ownsPlainKeys(event.target)) return;
|
|
156
157
|
if (key === STUDIO_PLAIN_KEYS.fullscreen && !event.shiftKey && !event.altKey) {
|
|
157
158
|
event.preventDefault();
|
|
158
159
|
if (document.fullscreenElement) void document.exitFullscreen();
|