@hyperframes/studio 0.8.81 → 0.8.83
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-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
- package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +834 -330
- package/dist/index.html +1 -1
- package/dist/index.js +26378 -28802
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +20 -31
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +37 -38
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +53 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/playerEntry.ts +1 -0
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
serializeStudioFileMutation,
|
|
4
|
+
type StudioProjectFileWriter,
|
|
5
|
+
} from "../utils/studioFileMutationCoordinator";
|
|
2
6
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
7
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
8
|
import { roundTo3 } from "../utils/rounding";
|
|
@@ -27,6 +31,7 @@ interface GsapAnimationOpsParams extends SdkAnimationDeps {
|
|
|
27
31
|
commitMutation: CommitMutation;
|
|
28
32
|
commitMutationSafely: SafeGsapCommitMutation;
|
|
29
33
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
|
+
writeProjectFile?: StudioProjectFileWriter;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export function useGsapAnimationOps({
|
|
@@ -37,6 +42,7 @@ export function useGsapAnimationOps({
|
|
|
37
42
|
showToast,
|
|
38
43
|
sdkSession,
|
|
39
44
|
sdkDeps,
|
|
45
|
+
writeProjectFile,
|
|
40
46
|
}: GsapAnimationOpsParams) {
|
|
41
47
|
const updateGsapMeta = useCallback(
|
|
42
48
|
async (
|
|
@@ -122,13 +128,17 @@ export function useGsapAnimationOps({
|
|
|
122
128
|
const pid = projectIdRef.current;
|
|
123
129
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
124
130
|
if (!pid) return;
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
const assign = () =>
|
|
132
|
+
assignGsapTargetAutoIdIfNeeded({
|
|
133
|
+
projectId: pid,
|
|
134
|
+
targetPath,
|
|
135
|
+
selection,
|
|
136
|
+
autoId,
|
|
137
|
+
showToast,
|
|
138
|
+
});
|
|
139
|
+
const assigned = await (writeProjectFile
|
|
140
|
+
? serializeStudioFileMutation(writeProjectFile, targetPath, assign)
|
|
141
|
+
: assign());
|
|
132
142
|
if (!assigned) return;
|
|
133
143
|
}
|
|
134
144
|
|
|
@@ -181,7 +191,15 @@ export function useGsapAnimationOps({
|
|
|
181
191
|
{ label: `Add GSAP ${method} animation`, softReload: true },
|
|
182
192
|
);
|
|
183
193
|
},
|
|
184
|
-
[
|
|
194
|
+
[
|
|
195
|
+
activeCompPath,
|
|
196
|
+
commitMutation,
|
|
197
|
+
projectIdRef,
|
|
198
|
+
showToast,
|
|
199
|
+
sdkSession,
|
|
200
|
+
sdkDeps,
|
|
201
|
+
writeProjectFile,
|
|
202
|
+
],
|
|
185
203
|
);
|
|
186
204
|
|
|
187
205
|
type KeyframeEntry = {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { useKeyframeKeyboard } from "./useKeyframeKeyboard";
|
|
5
|
+
import { dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
|
|
6
|
+
import { DEFAULT_SHORTCUT_SECTIONS } from "../player/components/studioShortcuts";
|
|
7
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
|
+
import { createHappyDomRootHarness } from "../player/components/testRootHarness";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
const { mount } = createHappyDomRootHarness();
|
|
13
|
+
|
|
14
|
+
function KeyframeKeyboard(props: { enabled: boolean; onAddKeyframe: () => void }) {
|
|
15
|
+
useKeyframeKeyboard(props);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Wires the keyframe keys the way TimelineToolbar does; returns the add-keyframe spy. */
|
|
20
|
+
function mountKeyframeKeyboard(enabled: boolean) {
|
|
21
|
+
const onAddKeyframe = vi.fn();
|
|
22
|
+
const host = document.createElement("div");
|
|
23
|
+
document.body.append(host);
|
|
24
|
+
act(() =>
|
|
25
|
+
mount(host).render(<KeyframeKeyboard enabled={enabled} onAddKeyframe={onAddKeyframe} />),
|
|
26
|
+
);
|
|
27
|
+
return onAddKeyframe;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
|
31
|
+
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
|
|
32
|
+
window.dispatchEvent(event);
|
|
33
|
+
return event;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function selectKeyframe() {
|
|
37
|
+
usePlayerStore.setState({ selectedElementId: "box", selectedKeyframes: new Set(["box:50"]) });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(selectKeyframe);
|
|
41
|
+
afterEach(() => usePlayerStore.getState().reset());
|
|
42
|
+
|
|
43
|
+
describe("keyframe shortcuts", () => {
|
|
44
|
+
it("every key the Keyframes section lists does something with a keyframe selected", () => {
|
|
45
|
+
mountKeyframeKeyboard(true);
|
|
46
|
+
const cb = {
|
|
47
|
+
onDeleteSelectedKeyframes: vi.fn(),
|
|
48
|
+
onToggleRecording: vi.fn(),
|
|
49
|
+
domEditSelectionRef: { current: null },
|
|
50
|
+
readOnlyPreview: false,
|
|
51
|
+
} as unknown as HotkeyCallbacks;
|
|
52
|
+
const appHotkeys = (event: KeyboardEvent) =>
|
|
53
|
+
dispatchPlainKey(event, event.key.toLowerCase(), cb);
|
|
54
|
+
window.addEventListener("keydown", appHotkeys, true);
|
|
55
|
+
|
|
56
|
+
const section = DEFAULT_SHORTCUT_SECTIONS.find((s) => s.title.startsWith("Keyframes"));
|
|
57
|
+
const dead = (section?.hints ?? []).filter((hint) => {
|
|
58
|
+
selectKeyframe();
|
|
59
|
+
return !press(hint.key === "Del" ? "Delete" : hint.key.toLowerCase()).defaultPrevented;
|
|
60
|
+
});
|
|
61
|
+
window.removeEventListener("keydown", appHotkeys, true);
|
|
62
|
+
|
|
63
|
+
expect(section?.hints.length).toBeGreaterThan(0);
|
|
64
|
+
expect(dead.map((hint) => hint.key)).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("K adds a keyframe only while enabled and leaves J and the arrows to playback", () => {
|
|
68
|
+
const playback = vi.fn();
|
|
69
|
+
window.addEventListener("keydown", playback);
|
|
70
|
+
const onAddKeyframe = mountKeyframeKeyboard(true);
|
|
71
|
+
|
|
72
|
+
for (const [key, shiftKey] of [
|
|
73
|
+
["j", false],
|
|
74
|
+
["J", true],
|
|
75
|
+
["ArrowLeft", false],
|
|
76
|
+
["ArrowRight", true],
|
|
77
|
+
] as const) {
|
|
78
|
+
expect(press(key, { shiftKey }).defaultPrevented).toBe(false);
|
|
79
|
+
}
|
|
80
|
+
expect(playback).toHaveBeenCalledTimes(4);
|
|
81
|
+
expect(onAddKeyframe).not.toHaveBeenCalled();
|
|
82
|
+
|
|
83
|
+
expect(press("k").defaultPrevented).toBe(true);
|
|
84
|
+
expect(onAddKeyframe).toHaveBeenCalledTimes(1);
|
|
85
|
+
expect(playback).toHaveBeenCalledTimes(4);
|
|
86
|
+
window.removeEventListener("keydown", playback);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("K stays playback's stop key when no keyframeable element is selected", () => {
|
|
90
|
+
const playback = vi.fn();
|
|
91
|
+
window.addEventListener("keydown", playback);
|
|
92
|
+
const onAddKeyframe = mountKeyframeKeyboard(false);
|
|
93
|
+
|
|
94
|
+
expect(press("k").defaultPrevented).toBe(false);
|
|
95
|
+
expect(onAddKeyframe).not.toHaveBeenCalled();
|
|
96
|
+
expect(playback).toHaveBeenCalledTimes(1);
|
|
97
|
+
window.removeEventListener("keydown", playback);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
import { useEffect, useCallback } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
|
3
3
|
|
|
4
4
|
interface KeyframeKeyboardOptions {
|
|
5
5
|
enabled: boolean;
|
|
6
6
|
onAddKeyframe?: () => void;
|
|
7
|
-
onDeleteKeyframe?: () => void;
|
|
8
|
-
onPrevKeyframe?: () => void;
|
|
9
|
-
onNextKeyframe?: () => void;
|
|
10
|
-
onToggleHold?: () => void;
|
|
11
|
-
onToggleExpand?: () => void;
|
|
12
|
-
onNudgeKeyframe?: (direction: -1 | 1, large: boolean) => void;
|
|
13
7
|
}
|
|
14
8
|
|
|
15
9
|
function isTextInput(el: Element | null): boolean {
|
|
@@ -19,75 +13,19 @@ function isTextInput(el: Element | null): boolean {
|
|
|
19
13
|
return (el as HTMLElement).isContentEditable === true;
|
|
20
14
|
}
|
|
21
15
|
|
|
22
|
-
export function useKeyframeKeyboard({
|
|
23
|
-
enabled,
|
|
24
|
-
onAddKeyframe,
|
|
25
|
-
onDeleteKeyframe,
|
|
26
|
-
onPrevKeyframe,
|
|
27
|
-
onNextKeyframe,
|
|
28
|
-
onToggleHold,
|
|
29
|
-
onToggleExpand,
|
|
30
|
-
onNudgeKeyframe,
|
|
31
|
-
}: KeyframeKeyboardOptions): void {
|
|
16
|
+
export function useKeyframeKeyboard({ enabled, onAddKeyframe }: KeyframeKeyboardOptions): void {
|
|
32
17
|
const handler = useCallback(
|
|
33
18
|
(e: KeyboardEvent) => {
|
|
34
|
-
if (!enabled) return;
|
|
19
|
+
if (!enabled || !onAddKeyframe) return;
|
|
35
20
|
if (isTextInput(document.activeElement)) return;
|
|
36
21
|
if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// usePlaybackKeyboard, so when a handler is absent we must let the event
|
|
43
|
-
// continue. When we DO act, stopImmediatePropagation prevents the playback
|
|
44
|
-
// handler from also firing (e.g. K pausing instead of adding a keyframe).
|
|
45
|
-
// The listener is registered in the capture phase so it runs first.
|
|
46
|
-
const consume = (run: () => void) => {
|
|
47
|
-
e.preventDefault();
|
|
48
|
-
e.stopImmediatePropagation();
|
|
49
|
-
run();
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
switch (e.key.toLowerCase()) {
|
|
53
|
-
case "k":
|
|
54
|
-
if (onAddKeyframe) consume(onAddKeyframe);
|
|
55
|
-
break;
|
|
56
|
-
case "delete":
|
|
57
|
-
case "backspace":
|
|
58
|
-
if (onDeleteKeyframe && hasSelectedKeyframes) consume(onDeleteKeyframe);
|
|
59
|
-
break;
|
|
60
|
-
case "j": {
|
|
61
|
-
const nav = e.shiftKey ? onNextKeyframe : onPrevKeyframe;
|
|
62
|
-
if (nav) consume(nav);
|
|
63
|
-
break;
|
|
64
|
-
}
|
|
65
|
-
case "h":
|
|
66
|
-
if (onToggleHold && hasSelectedKeyframes) consume(onToggleHold);
|
|
67
|
-
break;
|
|
68
|
-
case "u":
|
|
69
|
-
if (onToggleExpand) consume(onToggleExpand);
|
|
70
|
-
break;
|
|
71
|
-
case "arrowleft":
|
|
72
|
-
if (onNudgeKeyframe && hasSelectedKeyframes && !e.altKey)
|
|
73
|
-
consume(() => onNudgeKeyframe(-1, e.shiftKey));
|
|
74
|
-
break;
|
|
75
|
-
case "arrowright":
|
|
76
|
-
if (onNudgeKeyframe && hasSelectedKeyframes && !e.altKey)
|
|
77
|
-
consume(() => onNudgeKeyframe(1, e.shiftKey));
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
22
|
+
if (e.key.toLowerCase() !== STUDIO_PLAIN_KEYS.addKeyframe) return;
|
|
23
|
+
// K is also playback's stop key: claiming it here keeps playback from also pausing.
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
e.stopImmediatePropagation();
|
|
26
|
+
onAddKeyframe();
|
|
80
27
|
},
|
|
81
|
-
[
|
|
82
|
-
enabled,
|
|
83
|
-
onAddKeyframe,
|
|
84
|
-
onDeleteKeyframe,
|
|
85
|
-
onPrevKeyframe,
|
|
86
|
-
onNextKeyframe,
|
|
87
|
-
onToggleHold,
|
|
88
|
-
onToggleExpand,
|
|
89
|
-
onNudgeKeyframe,
|
|
90
|
-
],
|
|
28
|
+
[enabled, onAddKeyframe],
|
|
91
29
|
);
|
|
92
30
|
|
|
93
31
|
useEffect(() => {
|
|
@@ -29,7 +29,7 @@ interface RecordEditInput {
|
|
|
29
29
|
files: Record<string, { before: string; after: string }>;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
interface UsePreviewPersistenceParams {
|
|
32
|
+
export interface UsePreviewPersistenceParams {
|
|
33
33
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
34
|
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
35
35
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import { MockResizeObserver, reportResize } from "./resizeObserverTestUtils";
|
|
6
|
+
import { useThumbnailStripSize } from "./useThumbnailStripSize";
|
|
7
|
+
|
|
8
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
9
|
+
|
|
10
|
+
it("does not re-render the strip when the observer reports the size it already holds", () => {
|
|
11
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
12
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
13
|
+
const host = document.createElement("div");
|
|
14
|
+
document.body.append(host);
|
|
15
|
+
Object.defineProperty(host, "clientWidth", { configurable: true, value: 300 });
|
|
16
|
+
Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
|
|
17
|
+
const root = createRoot(host);
|
|
18
|
+
let stripRenders = 0;
|
|
19
|
+
function Strip({ label }: { label: string }) {
|
|
20
|
+
stripRenders += 1;
|
|
21
|
+
return label;
|
|
22
|
+
}
|
|
23
|
+
function Harness() {
|
|
24
|
+
const [size, ref] = useThumbnailStripSize();
|
|
25
|
+
return (
|
|
26
|
+
<div ref={ref}>
|
|
27
|
+
<Strip label={`${size.width}x${size.height}`} />
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
act(() => root.render(<Harness />));
|
|
33
|
+
expect(host.textContent).toBe("300x40");
|
|
34
|
+
const settled = stripRenders;
|
|
35
|
+
|
|
36
|
+
act(() => reportResize(300, 40));
|
|
37
|
+
expect(stripRenders).toBe(settled);
|
|
38
|
+
|
|
39
|
+
act(() => reportResize(320, 40));
|
|
40
|
+
expect(stripRenders).toBe(settled + 1);
|
|
41
|
+
expect(host.textContent).toBe("320x40");
|
|
42
|
+
} finally {
|
|
43
|
+
act(() => root.unmount());
|
|
44
|
+
host.remove();
|
|
45
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import { useMountEffect } from "./useMountEffect";
|
|
3
|
+
|
|
4
|
+
interface StripSize {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const resize = (width: number, height: number) => (prev: StripSize) =>
|
|
10
|
+
prev.width === width && prev.height === height ? prev : { width, height };
|
|
11
|
+
|
|
12
|
+
/** Size of the thumbnail's parent, kept current by a ResizeObserver; 0x0 until measured. */
|
|
13
|
+
export function useThumbnailStripSize() {
|
|
14
|
+
const [size, setSize] = useState<StripSize>({ width: 0, height: 0 });
|
|
15
|
+
const observerRef = useRef<ResizeObserver | null>(null);
|
|
16
|
+
|
|
17
|
+
const ref = useCallback((element: HTMLDivElement | null) => {
|
|
18
|
+
observerRef.current?.disconnect();
|
|
19
|
+
if (!element) return;
|
|
20
|
+
const target = element.parentElement ?? element;
|
|
21
|
+
setSize(resize(target.clientWidth, target.clientHeight));
|
|
22
|
+
observerRef.current = new ResizeObserver(([entry]) =>
|
|
23
|
+
setSize(resize(entry.contentRect.width, entry.contentRect.height)),
|
|
24
|
+
);
|
|
25
|
+
observerRef.current.observe(target);
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
29
|
+
|
|
30
|
+
return [size, ref] as const;
|
|
31
|
+
}
|
|
@@ -109,8 +109,6 @@ export function useTimelineAssetDropOps({
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
try {
|
|
112
|
-
const originalContent = await readFileContent(pid, targetPath);
|
|
113
|
-
|
|
114
112
|
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
|
115
113
|
const duration =
|
|
116
114
|
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
|
|
@@ -120,7 +118,6 @@ export function useTimelineAssetDropOps({
|
|
|
120
118
|
// A video with an audio stream lands audible; the mixer only hears a
|
|
121
119
|
// <video> marked data-has-audio, and a muted drop was losing the sound.
|
|
122
120
|
const hasAudio = await resolveDroppedAssetHasAudio(pid, assetPath, kind);
|
|
123
|
-
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
124
121
|
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
125
122
|
|
|
126
123
|
const resolvedTargetPath = targetPath || "index.html";
|
|
@@ -129,45 +126,51 @@ export function useTimelineAssetDropOps({
|
|
|
129
126
|
);
|
|
130
127
|
const newElementZIndex = Math.max(1, relevantElements.length + 1);
|
|
131
128
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
const patchedContent = extendRootDurationInSource(
|
|
145
|
-
insertTimelineAssetIntoSource(
|
|
146
|
-
sourceWithRoom,
|
|
147
|
-
buildTimelineAssetInsertHtml({
|
|
129
|
+
let newId = "";
|
|
130
|
+
let track = 0;
|
|
131
|
+
const insertAsset = (originalContent: string) => {
|
|
132
|
+
newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
133
|
+
const resolved = resolveDropTrack({
|
|
134
|
+
source: originalContent,
|
|
135
|
+
// insertRow counts the rows the timeline shows, so plan against those.
|
|
136
|
+
elements: relevantElements,
|
|
137
|
+
placement,
|
|
138
|
+
dropped: {
|
|
148
139
|
id: newId,
|
|
149
|
-
|
|
150
|
-
assetPath: resolvedAssetSrc,
|
|
151
|
-
kind,
|
|
140
|
+
tag: kind === "image" ? "img" : kind,
|
|
152
141
|
start: normalizedStart,
|
|
153
142
|
duration: normalizedDuration,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
track = resolved.track;
|
|
146
|
+
return extendRootDurationInSource(
|
|
147
|
+
insertTimelineAssetIntoSource(
|
|
148
|
+
resolved.source,
|
|
149
|
+
buildTimelineAssetInsertHtml({
|
|
150
|
+
id: newId,
|
|
151
|
+
hfId: `hf-${generateId()}`,
|
|
152
|
+
assetPath: resolvedAssetSrc,
|
|
153
|
+
kind,
|
|
154
|
+
start: normalizedStart,
|
|
155
|
+
duration: normalizedDuration,
|
|
156
|
+
track,
|
|
157
|
+
zIndex: newElementZIndex,
|
|
158
|
+
hasAudio,
|
|
159
|
+
geometry: fitTimelineAssetGeometry(
|
|
160
|
+
null,
|
|
161
|
+
resolveTimelineAssetCompositionSize(originalContent),
|
|
162
|
+
),
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
165
|
+
normalizedStart + normalizedDuration,
|
|
166
|
+
);
|
|
167
|
+
};
|
|
165
168
|
|
|
166
169
|
await saveProjectFilesWithHistory({
|
|
167
170
|
projectId: pid,
|
|
168
171
|
label: "Add timeline asset",
|
|
169
|
-
files: { [targetPath]:
|
|
170
|
-
readFile:
|
|
172
|
+
files: { [targetPath]: insertAsset },
|
|
173
|
+
readFile: (path) => readFileContent(pid, path),
|
|
171
174
|
writeFile: writeProjectFile,
|
|
172
175
|
recordEdit,
|
|
173
176
|
});
|
|
@@ -5,11 +5,11 @@ import { buildProjectApiPath } from "../utils/projectRouting";
|
|
|
5
5
|
import { useCallback, useRef, type MutableRefObject, type RefObject } from "react";
|
|
6
6
|
import type { TimelineElement } from "../player";
|
|
7
7
|
import { usePlayerStore } from "../player";
|
|
8
|
-
import {
|
|
8
|
+
import { saveServerRewriteWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
|
9
9
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
10
10
|
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
11
11
|
import { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
|
|
12
|
-
import { captureDurationRollback
|
|
12
|
+
import { captureDurationRollback } from "./timelineTimingSync";
|
|
13
13
|
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
14
14
|
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
|
15
15
|
import {
|
|
@@ -100,71 +100,68 @@ export function useTimelineDeleteOps({
|
|
|
100
100
|
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
|
101
101
|
);
|
|
102
102
|
try {
|
|
103
|
-
const originalContent = await readFileContent(pid, targetPath);
|
|
104
|
-
|
|
105
|
-
// Remove every selected element before saving once. The server rewrites
|
|
106
|
-
// the file per call, so `removedContent` after the last one holds them
|
|
107
|
-
// all — which is what makes this a single history entry, and a single
|
|
108
|
-
// undo, rather than one per clip.
|
|
109
|
-
let removedContent = originalContent;
|
|
110
|
-
for (const target of sameFile) {
|
|
111
|
-
const patchTarget = buildPatchTarget(target);
|
|
112
|
-
if (!patchTarget) {
|
|
113
|
-
throw new Error(`Timeline element ${target.id} is missing a patchable target`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const removeResponse = await fetch(
|
|
117
|
-
buildProjectApiPath(
|
|
118
|
-
pid,
|
|
119
|
-
`/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
120
|
-
),
|
|
121
|
-
{
|
|
122
|
-
method: "POST",
|
|
123
|
-
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
124
|
-
body: JSON.stringify({ target: patchTarget }),
|
|
125
|
-
},
|
|
126
|
-
);
|
|
127
|
-
if (!removeResponse.ok) {
|
|
128
|
-
throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const removeData = (await removeResponse.json()) as {
|
|
132
|
-
changed?: boolean;
|
|
133
|
-
content?: string;
|
|
134
|
-
};
|
|
135
|
-
if (typeof removeData.content === "string") removedContent = removeData.content;
|
|
136
|
-
}
|
|
137
|
-
// Content-driven duration: shrink the composition to the furthest
|
|
138
|
-
// remaining clip end, read from the post-removal SOURCE (raw
|
|
139
|
-
// data-duration), so deleting the last/longest clip removes trailing
|
|
140
|
-
// empty space. Measured from the source, not the store, whose
|
|
141
|
-
// durations are runtime-truncated.
|
|
142
|
-
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
|
143
|
-
const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
|
|
144
|
-
// Optimistically reflect the shrunk length in the readout/seek bar,
|
|
145
|
-
// rolling it back if the persist below fails (see captureDurationRollback).
|
|
146
|
-
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
147
|
-
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
|
148
|
-
usePlayerStore.getState().setDuration(deleteContentEnd);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
103
|
// Shared with the ripple move below so a folded ripple is one undo
|
|
152
104
|
// step with the delete, not two (editHistory.ts coalesces by key +
|
|
153
105
|
// window across separate recordEdit calls, not by label).
|
|
154
106
|
const coalesceKey = `main-track-ripple-delete:${deleteGestureSeq++}`;
|
|
155
107
|
const deleteHistoryLabel = "Delete timeline clip";
|
|
108
|
+
let rollbackDuration = () => {};
|
|
156
109
|
try {
|
|
157
|
-
await
|
|
110
|
+
await saveServerRewriteWithHistory({
|
|
158
111
|
projectId: pid,
|
|
112
|
+
path: targetPath,
|
|
159
113
|
label: deleteHistoryLabel,
|
|
160
114
|
coalesceKey,
|
|
161
|
-
files: { [targetPath]: patchedContent },
|
|
162
|
-
readFile: async () => originalContent,
|
|
163
|
-
// remove-element already wrote the removal, so disk holds THAT — not the
|
|
164
|
-
// content read at the top. Undo still goes back to the original.
|
|
165
|
-
diskContent: { [targetPath]: removedContent },
|
|
166
115
|
writeFile: writeProjectFile,
|
|
167
116
|
recordEdit,
|
|
117
|
+
rewrite: async (originalContent) => {
|
|
118
|
+
// Remove every selected element before saving once. The server rewrites
|
|
119
|
+
// the file per call, so `removedContent` after the last one holds them
|
|
120
|
+
// all — which is what makes this a single history entry, and a single
|
|
121
|
+
// undo, rather than one per clip.
|
|
122
|
+
let removedContent = originalContent;
|
|
123
|
+
for (const target of sameFile) {
|
|
124
|
+
const patchTarget = buildPatchTarget(target);
|
|
125
|
+
if (!patchTarget) {
|
|
126
|
+
throw new Error(`Timeline element ${target.id} is missing a patchable target`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const removeResponse = await fetch(
|
|
130
|
+
buildProjectApiPath(
|
|
131
|
+
pid,
|
|
132
|
+
`/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
133
|
+
),
|
|
134
|
+
{
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
137
|
+
body: JSON.stringify({ target: patchTarget }),
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
if (!removeResponse.ok) {
|
|
141
|
+
throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const removeData = (await removeResponse.json()) as {
|
|
145
|
+
changed?: boolean;
|
|
146
|
+
content?: string;
|
|
147
|
+
};
|
|
148
|
+
if (typeof removeData.content === "string") removedContent = removeData.content;
|
|
149
|
+
}
|
|
150
|
+
// Shrink to the furthest remaining clip end, read from the post-removal source:
|
|
151
|
+
// store durations are runtime-truncated.
|
|
152
|
+
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
|
153
|
+
const patchedContent = setCompositionDurationToContent(
|
|
154
|
+
removedContent,
|
|
155
|
+
deleteContentEnd,
|
|
156
|
+
);
|
|
157
|
+
// Optimistically reflect the shrunk length in the readout/seek bar,
|
|
158
|
+
// rolling it back if the persist below fails (see captureDurationRollback).
|
|
159
|
+
rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
160
|
+
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
|
161
|
+
usePlayerStore.getState().setDuration(deleteContentEnd);
|
|
162
|
+
}
|
|
163
|
+
return { disk: removedContent, after: patchedContent };
|
|
164
|
+
},
|
|
168
165
|
});
|
|
169
166
|
} catch (error) {
|
|
170
167
|
rollbackDuration();
|
|
@@ -8,7 +8,6 @@ import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
|
8
8
|
import {
|
|
9
9
|
applyTimelineStackingReorder,
|
|
10
10
|
patchIframeDomTiming,
|
|
11
|
-
playbackStartAttributeForElement,
|
|
12
11
|
persistTimelineEdit,
|
|
13
12
|
formatTimelineAttributeNumber,
|
|
14
13
|
formatTimelineMediaOffset,
|
|
@@ -16,6 +15,7 @@ import {
|
|
|
16
15
|
buildTimelineMoveTimingPatch,
|
|
17
16
|
buildTimelineResizeTimingPatch,
|
|
18
17
|
} from "./timelineEditingHelpers";
|
|
18
|
+
import { playbackStartAttributeForElement } from "../player/lib/timelineElementHelpers";
|
|
19
19
|
import {
|
|
20
20
|
captureDurationRollback,
|
|
21
21
|
finishClipTimingFallback,
|
|
@@ -243,6 +243,7 @@ export function useTimelineEditing({
|
|
|
243
243
|
label: "Move timeline clip",
|
|
244
244
|
coalesceKey,
|
|
245
245
|
recordEdit,
|
|
246
|
+
writeProjectFile,
|
|
246
247
|
edit: { kind: "shift", delta: updates.start - element.start },
|
|
247
248
|
}).finally(() => invalidateGsapCache?.());
|
|
248
249
|
const moveFallback = () =>
|
|
@@ -346,6 +347,7 @@ export function useTimelineEditing({
|
|
|
346
347
|
label: "Resize timeline clip",
|
|
347
348
|
coalesceKey,
|
|
348
349
|
recordEdit,
|
|
350
|
+
writeProjectFile,
|
|
349
351
|
edit: {
|
|
350
352
|
kind: "scale",
|
|
351
353
|
from: { start: element.start, duration: element.duration },
|
|
@@ -15,11 +15,11 @@ import {
|
|
|
15
15
|
formatTimelineAttributeNumber,
|
|
16
16
|
formatTimelineMediaOffset,
|
|
17
17
|
patchIframeDomTiming,
|
|
18
|
-
playbackStartAttributeForElement,
|
|
19
18
|
persistTimelineBatchEdit,
|
|
20
19
|
type PersistTimelineBatchChange,
|
|
21
20
|
type RecordEditInput,
|
|
22
21
|
} from "./timelineEditingHelpers";
|
|
22
|
+
import { playbackStartAttributeForElement } from "../player/lib/timelineElementHelpers";
|
|
23
23
|
import {
|
|
24
24
|
captureDurationRollback,
|
|
25
25
|
finishGroupTimingGsapFallback,
|
|
@@ -330,6 +330,7 @@ export function useTimelineGroupEditing({
|
|
|
330
330
|
errorLabel: "Failed to shift GSAP positions",
|
|
331
331
|
coalesceKey,
|
|
332
332
|
recordEdit,
|
|
333
|
+
writeProjectFile,
|
|
333
334
|
activeCompPath,
|
|
334
335
|
changes,
|
|
335
336
|
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
|
@@ -365,6 +366,7 @@ export function useTimelineGroupEditing({
|
|
|
365
366
|
trySdkBatchPersist,
|
|
366
367
|
showToast,
|
|
367
368
|
invalidateGsapCache,
|
|
369
|
+
writeProjectFile,
|
|
368
370
|
],
|
|
369
371
|
);
|
|
370
372
|
|
|
@@ -437,6 +439,7 @@ export function useTimelineGroupEditing({
|
|
|
437
439
|
errorLabel: "Failed to scale GSAP positions",
|
|
438
440
|
coalesceKey,
|
|
439
441
|
recordEdit,
|
|
442
|
+
writeProjectFile,
|
|
440
443
|
activeCompPath,
|
|
441
444
|
changes,
|
|
442
445
|
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
|
@@ -478,6 +481,7 @@ export function useTimelineGroupEditing({
|
|
|
478
481
|
trySdkBatchPersist,
|
|
479
482
|
showToast,
|
|
480
483
|
invalidateGsapCache,
|
|
484
|
+
writeProjectFile,
|
|
481
485
|
],
|
|
482
486
|
);
|
|
483
487
|
|