@hyperframes/studio 0.8.106 → 0.8.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
- package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +377 -12
- package/dist/index.html +2 -2
- package/dist/index.js +13445 -11874
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/EditorShell.tsx +2 -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/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- 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/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- 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 +15 -1
- package/src/components/editor/mediaAudioEdits.test.ts +271 -0
- package/src/components/editor/mediaAudioEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +290 -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 +46 -23
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- 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/contexts/TimelineEditContext.tsx +1 -0
- 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 +163 -3
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- 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/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +8 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.tsx +2 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useClipToolState.ts +92 -0
- 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 +25 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -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/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +2 -2
- 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 +129 -0
- package/src/utils/studioPendingEdits.ts +61 -18
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- 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-46XP7J3B.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-ICTZJPQQ.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -12,6 +12,8 @@ import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
|
|
|
12
12
|
import {
|
|
13
13
|
AudioMeterStrip,
|
|
14
14
|
type AudioMeterStripProps,
|
|
15
|
+
CLIP_LATCH_PEAK,
|
|
16
|
+
stepClipLatch,
|
|
15
17
|
evictGoneMeterState,
|
|
16
18
|
followMeterHook,
|
|
17
19
|
stepAndPaintStrips,
|
|
@@ -416,3 +418,54 @@ describe("stepAndPaintStrips", () => {
|
|
|
416
418
|
expect(state.has("loud")).toBe(true);
|
|
417
419
|
});
|
|
418
420
|
});
|
|
421
|
+
|
|
422
|
+
describe("CLIP light", () => {
|
|
423
|
+
const hookAt = (peak: number) => ({
|
|
424
|
+
start: vi.fn(),
|
|
425
|
+
stop: vi.fn(),
|
|
426
|
+
read: vi.fn(() => ({ master: { l: peak, r: 0 }, groups: {} })),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("latches at the -1 dBFS export ceiling, stays lit when the level drops, and resets on click", () => {
|
|
430
|
+
usePlayerStore.setState({ elements: [clip({})] });
|
|
431
|
+
const hook = hookAt(1);
|
|
432
|
+
setHook(hook);
|
|
433
|
+
const { host } = mount();
|
|
434
|
+
const light = host.querySelector<HTMLElement>("[data-testid=meter-clip-light]");
|
|
435
|
+
expect(light?.dataset.lit).toBe("false");
|
|
436
|
+
tick();
|
|
437
|
+
expect(light?.dataset.lit).toBe("true");
|
|
438
|
+
hook.read.mockReturnValue({ master: { l: 0.1, r: 0.1 }, groups: {} });
|
|
439
|
+
tick();
|
|
440
|
+
expect(light?.dataset.lit).toBe("true");
|
|
441
|
+
act(() => light?.click());
|
|
442
|
+
expect(light?.dataset.lit).toBe("false");
|
|
443
|
+
tick();
|
|
444
|
+
expect(light?.dataset.lit).toBe("false");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("stays dark in the red band just under the ceiling", () => {
|
|
448
|
+
usePlayerStore.setState({ elements: [clip({})] });
|
|
449
|
+
setHook(hookAt(10 ** (-1.2 / 20)));
|
|
450
|
+
const { host } = mount();
|
|
451
|
+
tick();
|
|
452
|
+
expect(host.querySelector<HTMLElement>("[data-testid=meter-clip-light]")?.dataset.lit).toBe(
|
|
453
|
+
"false",
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
describe("stepClipLatch", () => {
|
|
459
|
+
it("lights exactly at -1 dBFS on either channel, not below", () => {
|
|
460
|
+
expect(CLIP_LATCH_PEAK).toBeCloseTo(0.891251, 6);
|
|
461
|
+
expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK, r: 0 })).toBe(true);
|
|
462
|
+
expect(stepClipLatch(false, { l: 0, r: CLIP_LATCH_PEAK })).toBe(true);
|
|
463
|
+
expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK - 1e-6, r: 0 })).toBe(false);
|
|
464
|
+
expect(stepClipLatch(false, undefined)).toBe(false);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("holds once lit, whatever comes next", () => {
|
|
468
|
+
expect(stepClipLatch(true, { l: 0, r: 0 })).toBe(true);
|
|
469
|
+
expect(stepClipLatch(true, undefined)).toBe(true);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
useMemo,
|
|
6
6
|
useRef,
|
|
7
7
|
type PointerEvent as ReactPointerEvent,
|
|
8
|
+
type ReactNode,
|
|
8
9
|
type Ref,
|
|
9
10
|
type RefObject,
|
|
10
11
|
} from "react";
|
|
@@ -20,6 +21,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
|
|
|
20
21
|
import { usePlayerStore } from "../../player";
|
|
21
22
|
import { clampNumber } from "../../utils/studioHelpers";
|
|
22
23
|
import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
|
|
24
|
+
import { EXPORT_PEAK_CEILING } from "../../utils/exportPeakCeiling";
|
|
23
25
|
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
24
26
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
25
27
|
import {
|
|
@@ -49,6 +51,24 @@ type StripBars = [Bars, Bars];
|
|
|
49
51
|
|
|
50
52
|
const MONITOR_LABEL = "Monitor";
|
|
51
53
|
|
|
54
|
+
/** The CLIP light latches at the export ceiling, stricter than RED_AT. */
|
|
55
|
+
export const CLIP_LATCH_PEAK = EXPORT_PEAK_CEILING;
|
|
56
|
+
|
|
57
|
+
export function stepClipLatch(latched: boolean, master: StereoLevel | undefined): boolean {
|
|
58
|
+
if (latched || !master) return latched;
|
|
59
|
+
return Math.max(master.l, master.r) >= CLIP_LATCH_PEAK;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function paintClipLight(el: HTMLElement | null, lit: boolean): void {
|
|
63
|
+
if (!el) return;
|
|
64
|
+
el.dataset.lit = String(lit);
|
|
65
|
+
el.style.setProperty(
|
|
66
|
+
"background-color",
|
|
67
|
+
lit ? "var(--color-red-500)" : "var(--color-neutral-800)",
|
|
68
|
+
);
|
|
69
|
+
el.style.setProperty("color", lit ? "var(--color-white)" : "var(--color-neutral-500)");
|
|
70
|
+
}
|
|
71
|
+
|
|
52
72
|
/** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */
|
|
53
73
|
const AMBER_AT = markFraction(-6);
|
|
54
74
|
const RED_AT = markFraction(-3);
|
|
@@ -178,10 +198,16 @@ export function stepAndPaintStrips(
|
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
/** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
|
|
201
|
+
interface ClipLatch {
|
|
202
|
+
lit: boolean;
|
|
203
|
+
light: HTMLElement | null;
|
|
204
|
+
}
|
|
205
|
+
|
|
181
206
|
function useMeterLoop(
|
|
182
207
|
strips: Strip[],
|
|
183
208
|
bars: RefObject<Map<string | null, StripBars>>,
|
|
184
209
|
iframeRef: RefObject<HTMLIFrameElement | null> | undefined,
|
|
210
|
+
clip: RefObject<ClipLatch>,
|
|
185
211
|
) {
|
|
186
212
|
const shell = useStudioShellContextOptional();
|
|
187
213
|
const previewIframeRef = iframeRef ?? shell?.previewIframeRef;
|
|
@@ -200,6 +226,11 @@ function useMeterLoop(
|
|
|
200
226
|
last = now;
|
|
201
227
|
evictGoneMeterState(state, new Set(stripsRef.current.map((s) => s.id)));
|
|
202
228
|
stepAndPaintStrips(stripsRef.current, state, bars.current, levels, now, dt);
|
|
229
|
+
const lit = stepClipLatch(clip.current.lit, levels?.master);
|
|
230
|
+
if (lit !== clip.current.lit) {
|
|
231
|
+
clip.current.lit = lit;
|
|
232
|
+
paintClipLight(clip.current.light, lit);
|
|
233
|
+
}
|
|
203
234
|
};
|
|
204
235
|
raf = requestAnimationFrame(tick);
|
|
205
236
|
return () => {
|
|
@@ -210,7 +241,29 @@ function useMeterLoop(
|
|
|
210
241
|
// Preview iframe already gone.
|
|
211
242
|
}
|
|
212
243
|
};
|
|
213
|
-
}, [previewIframeRef, bars]);
|
|
244
|
+
}, [previewIframeRef, bars, clip]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ClipLight({ clip }: { clip: RefObject<ClipLatch> }) {
|
|
248
|
+
return (
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
data-testid="meter-clip-light"
|
|
252
|
+
data-lit="false"
|
|
253
|
+
title="Lights when the preview reaches −1 dBFS, the export ceiling. Click to reset."
|
|
254
|
+
ref={(el) => {
|
|
255
|
+
clip.current.light = el;
|
|
256
|
+
paintClipLight(el, clip.current.lit);
|
|
257
|
+
}}
|
|
258
|
+
onClick={() => {
|
|
259
|
+
clip.current.lit = false;
|
|
260
|
+
paintClipLight(clip.current.light, false);
|
|
261
|
+
}}
|
|
262
|
+
className="rounded-[2px] px-1 font-mono text-[9px] leading-[14px] font-bold tracking-wide"
|
|
263
|
+
>
|
|
264
|
+
CLIP
|
|
265
|
+
</button>
|
|
266
|
+
);
|
|
214
267
|
}
|
|
215
268
|
|
|
216
269
|
function Bar({ maskRef, peakRef }: { maskRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
|
|
@@ -341,11 +394,13 @@ function MeterStrip({
|
|
|
341
394
|
register,
|
|
342
395
|
onLive,
|
|
343
396
|
onCommit,
|
|
397
|
+
clipLight,
|
|
344
398
|
}: {
|
|
345
399
|
strip: Strip;
|
|
346
400
|
register: (id: string | null, bars: StripBars | null) => void;
|
|
347
401
|
onLive: (id: string | null, volume: number) => void;
|
|
348
402
|
onCommit: (id: string | null, volume: number) => void;
|
|
403
|
+
clipLight?: ReactNode;
|
|
349
404
|
}) {
|
|
350
405
|
const refs = [
|
|
351
406
|
useRef<HTMLDivElement>(null),
|
|
@@ -364,6 +419,7 @@ function MeterStrip({
|
|
|
364
419
|
}, [strip.id, register]);
|
|
365
420
|
return (
|
|
366
421
|
<div className="flex w-[104px] shrink-0 flex-col items-center gap-1 px-1.5 pt-2 pb-1">
|
|
422
|
+
{clipLight}
|
|
367
423
|
<div
|
|
368
424
|
className="flex min-h-0 flex-1 items-stretch gap-1.5"
|
|
369
425
|
aria-label={`${strip.label} level`}
|
|
@@ -421,7 +477,8 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
|
|
|
421
477
|
else bars.current.delete(id);
|
|
422
478
|
}).current;
|
|
423
479
|
const { onLive, onCommit } = useVolumeHandlers();
|
|
424
|
-
|
|
480
|
+
const clip = useRef<ClipLatch>({ lit: false, light: null });
|
|
481
|
+
useMeterLoop(strips, bars, previewIframeRef, clip);
|
|
425
482
|
return (
|
|
426
483
|
<div
|
|
427
484
|
data-testid="audio-meter-strip"
|
|
@@ -434,6 +491,7 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
|
|
|
434
491
|
register={register}
|
|
435
492
|
onLive={onLive}
|
|
436
493
|
onCommit={onCommit}
|
|
494
|
+
clipLight={strip.id === null ? <ClipLight clip={clip} /> : undefined}
|
|
437
495
|
/>
|
|
438
496
|
))}
|
|
439
497
|
</div>
|
|
@@ -53,6 +53,7 @@ export interface TimelineEditCallbackDeps {
|
|
|
53
53
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
54
54
|
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
55
55
|
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
|
|
56
|
+
handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
|
|
56
57
|
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
|
|
57
58
|
handleGroupClips?: (
|
|
58
59
|
clipIds: readonly string[],
|
|
@@ -132,6 +133,7 @@ export function useTimelineEditCallbacks({
|
|
|
132
133
|
handleTimelineElementSplit,
|
|
133
134
|
handleRazorSplit,
|
|
134
135
|
handleRazorSplitAll,
|
|
136
|
+
handleFreezeFrame,
|
|
135
137
|
handleGroupClips,
|
|
136
138
|
setElementFxAttribute,
|
|
137
139
|
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
|
|
@@ -227,6 +229,7 @@ export function useTimelineEditCallbacks({
|
|
|
227
229
|
onSplitElement: handleTimelineElementSplit,
|
|
228
230
|
onRazorSplit: handleRazorSplit,
|
|
229
231
|
onRazorSplitAll: handleRazorSplitAll,
|
|
232
|
+
onFreezeFrame: handleFreezeFrame,
|
|
230
233
|
onDeleteAllKeyframes: (element, animationId) => {
|
|
231
234
|
// Hold the element where it is (collapse keyframes to a static set) rather
|
|
232
235
|
// than deleting the whole animation — deleting strands a stale GSAP base
|
|
@@ -423,6 +426,7 @@ export function useTimelineEditCallbacks({
|
|
|
423
426
|
handleTimelineElementSplit,
|
|
424
427
|
handleRazorSplit,
|
|
425
428
|
handleRazorSplitAll,
|
|
429
|
+
handleFreezeFrame,
|
|
426
430
|
handleGsapRemoveAllKeyframes,
|
|
427
431
|
resolveElementAnimations,
|
|
428
432
|
resolveKeyframeTarget,
|
|
@@ -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,
|
|
@@ -42,6 +42,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
|
|
|
42
42
|
onSplitElement: true,
|
|
43
43
|
onRazorSplit: true,
|
|
44
44
|
onRazorSplitAll: true,
|
|
45
|
+
onFreezeFrame: true,
|
|
45
46
|
onDeleteKeyframe: true,
|
|
46
47
|
onDeleteAllKeyframes: true,
|
|
47
48
|
onMoveKeyframeToPlayhead: true,
|