@hyperframes/studio 0.7.60 → 0.7.62
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-CWpmBjzv.js +459 -0
- package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
- package/dist/assets/index-Ceyz8Qt2.css +1 -0
- package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
- package/dist/assets/index-vDRVQtTK.js +426 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.html +2 -2
- package/dist/index.js +4392 -3314
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +15 -15
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/EditorShell.tsx +12 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioLeftSidebar.tsx +3 -0
- package/src/components/StudioRightPanel.tsx +34 -26
- package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +50 -42
- package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
- package/src/components/editor/domEditing.test.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/editor/propertyPanelColor.test.tsx +31 -0
- package/src/components/editor/propertyPanelColor.tsx +150 -28
- package/src/components/editor/propertyPanelCommitField.tsx +178 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
- package/src/components/editor/propertyPanelSections.tsx +2 -2
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +6 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
- package/src/components/sidebar/CompositionsTab.tsx +45 -2
- package/src/components/sidebar/LeftSidebar.tsx +3 -0
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
- package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
- package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
- package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
- package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
- package/src/components/storyboard/storyboardReviewStage.ts +45 -0
- package/src/components/storyboard/useFrameComments.ts +22 -6
- package/src/contexts/StudioContext.tsx +4 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +10 -4
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- package/src/hooks/usePreviewPersistence.ts +7 -1
- package/src/hooks/useRazorSplit.history.test.tsx +21 -28
- package/src/hooks/useRazorSplit.test.tsx +20 -9
- package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
- package/src/hooks/useRazorSplit.ts +78 -350
- package/src/hooks/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +52 -2
- package/src/hooks/useTimelineEditing.ts +19 -17
- package/src/hooks/useTimelineGroupEditing.ts +6 -7
- package/src/player/components/Timeline.tsx +12 -1
- package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
- package/src/player/components/timelineAuthoredTrack.ts +29 -0
- package/src/player/components/timelineCallbacks.ts +4 -0
- package/src/player/components/timelineClipDragCommit.test.ts +176 -0
- package/src/player/components/timelineClipDragCommit.ts +77 -105
- package/src/player/components/timelineClipDragPreview.test.ts +33 -1
- package/src/player/components/timelineClipDragPreview.ts +2 -1
- package/src/player/components/timelineCollision.test.ts +18 -0
- package/src/player/components/timelineCollision.ts +12 -9
- package/src/player/components/timelineDragDrop.ts +28 -7
- package/src/player/components/timelineEditCapabilities.ts +4 -1
- package/src/player/components/timelineGroupEditing.test.ts +10 -0
- package/src/player/components/timelineGroupEditing.ts +4 -3
- package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
- package/src/player/components/timelineLayout.ts +3 -1
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.ts +5 -0
- package/src/player/components/useTimelineEditPinning.ts +12 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/lib/playbackTypes.ts +9 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.test.ts +58 -0
- package/src/player/lib/timelineDOM.ts +26 -12
- package/src/player/lib/timelineElementHelpers.ts +21 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- package/src/player/store/playerStore.ts +2 -1
- package/src/utils/razorSplitTransaction.test.ts +175 -0
- package/src/utils/razorSplitTransaction.ts +200 -0
- package/src/utils/studioPreviewHelpers.test.ts +27 -0
- package/src/utils/timelineCompositionDrop.test.ts +12 -0
- package/src/utils/timelineCompositionDrop.ts +16 -0
- package/src/utils/timelineCompositionInsert.test.ts +142 -0
- package/src/utils/timelineCompositionInsert.ts +90 -0
- package/src/utils/timelineElementSplit.test.ts +14 -2
- package/src/utils/timelineElementSplit.ts +5 -1
- package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
- package/dist/assets/index-DXbu6IPT.css +0 -1
- package/dist/assets/index-cH6NfVV_.js +0 -426
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
|
@@ -19,19 +19,22 @@ import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/stud
|
|
|
19
19
|
import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
|
|
20
20
|
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
|
|
21
21
|
import { readFileContent } from "./timelineTimingSync";
|
|
22
|
+
import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
|
|
23
|
+
import { usePlayerStore } from "../player";
|
|
22
24
|
|
|
23
25
|
interface UseTimelineAssetDropOpsOptions {
|
|
24
26
|
projectIdRef: MutableRefObject<string | null>;
|
|
25
27
|
activeCompPath: string | null;
|
|
26
28
|
timelineElements: TimelineElement[];
|
|
27
29
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
28
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
30
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
29
31
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
30
32
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
31
33
|
reloadPreview: () => void;
|
|
32
34
|
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
33
35
|
isRecordingRef?: RefObject<boolean>;
|
|
34
36
|
forceReloadSdkSession?: () => void;
|
|
37
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export function useTimelineAssetDropOps({
|
|
@@ -46,6 +49,7 @@ export function useTimelineAssetDropOps({
|
|
|
46
49
|
uploadProjectFiles,
|
|
47
50
|
isRecordingRef,
|
|
48
51
|
forceReloadSdkSession,
|
|
52
|
+
observeProjectFileVersion,
|
|
49
53
|
}: UseTimelineAssetDropOpsOptions) {
|
|
50
54
|
// fallow-ignore-next-line complexity
|
|
51
55
|
const handleTimelineAssetDrop = useCallback(
|
|
@@ -171,5 +175,51 @@ export function useTimelineAssetDropOps({
|
|
|
171
175
|
[handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
|
|
172
176
|
);
|
|
173
177
|
|
|
174
|
-
|
|
178
|
+
const handleTimelineCompositionDrop = useCallback(
|
|
179
|
+
async (sourcePath: string, placement: Pick<TimelineElement, "start" | "track">) => {
|
|
180
|
+
if (isRecordingRef?.current) {
|
|
181
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const pid = projectIdRef.current;
|
|
185
|
+
if (!pid) throw new Error("No active project");
|
|
186
|
+
const targetPath = activeCompPath || "index.html";
|
|
187
|
+
try {
|
|
188
|
+
await commitTimelineCompositionInsertion({
|
|
189
|
+
projectId: pid,
|
|
190
|
+
targetPath,
|
|
191
|
+
sourcePath,
|
|
192
|
+
start: placement.start,
|
|
193
|
+
track: placement.track,
|
|
194
|
+
writeFile: writeProjectFile,
|
|
195
|
+
recordEdit,
|
|
196
|
+
observeVersion: observeProjectFileVersion,
|
|
197
|
+
selectHost: (key) => usePlayerStore.getState().setSelectedElementId(key),
|
|
198
|
+
resync: forceReloadSdkSession,
|
|
199
|
+
refresh: reloadPreview,
|
|
200
|
+
});
|
|
201
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
202
|
+
showToast("Composition added to the timeline.", "info");
|
|
203
|
+
} catch (error) {
|
|
204
|
+
showToast(
|
|
205
|
+
error instanceof Error ? error.message : "Failed to add composition to timeline",
|
|
206
|
+
"error",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
[
|
|
211
|
+
activeCompPath,
|
|
212
|
+
domEditSaveTimestampRef,
|
|
213
|
+
forceReloadSdkSession,
|
|
214
|
+
isRecordingRef,
|
|
215
|
+
observeProjectFileVersion,
|
|
216
|
+
projectIdRef,
|
|
217
|
+
recordEdit,
|
|
218
|
+
reloadPreview,
|
|
219
|
+
showToast,
|
|
220
|
+
writeProjectFile,
|
|
221
|
+
],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop };
|
|
175
225
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
applyTimelineStackingReorder,
|
|
13
13
|
buildPatchTarget,
|
|
14
14
|
patchIframeDomTiming,
|
|
15
|
+
playbackStartAttributeForElement,
|
|
15
16
|
persistTimelineEdit,
|
|
16
17
|
formatTimelineAttributeNumber,
|
|
17
18
|
extendRootDurationIfNeeded,
|
|
@@ -263,10 +264,7 @@ export function useTimelineEditing({
|
|
|
263
264
|
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
|
264
265
|
];
|
|
265
266
|
if (updates.playbackStart != null) {
|
|
266
|
-
const liveAttr =
|
|
267
|
-
element.playbackStartAttr === "playback-start"
|
|
268
|
-
? "data-playback-start"
|
|
269
|
-
: "data-media-start";
|
|
267
|
+
const liveAttr = playbackStartAttributeForElement(element);
|
|
270
268
|
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
|
|
271
269
|
}
|
|
272
270
|
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
|
|
@@ -475,19 +473,21 @@ export function useTimelineEditing({
|
|
|
475
473
|
],
|
|
476
474
|
);
|
|
477
475
|
|
|
478
|
-
const { handleTimelineAssetDrop, handleTimelineFileDrop } =
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
476
|
+
const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
|
|
477
|
+
useTimelineAssetDropOps({
|
|
478
|
+
projectIdRef,
|
|
479
|
+
activeCompPath,
|
|
480
|
+
timelineElements,
|
|
481
|
+
showToast,
|
|
482
|
+
writeProjectFile,
|
|
483
|
+
recordEdit,
|
|
484
|
+
domEditSaveTimestampRef,
|
|
485
|
+
reloadPreview,
|
|
486
|
+
uploadProjectFiles,
|
|
487
|
+
isRecordingRef,
|
|
488
|
+
forceReloadSdkSession,
|
|
489
|
+
observeProjectFileVersion,
|
|
490
|
+
});
|
|
491
491
|
|
|
492
492
|
const handleBlockedTimelineEdit = useCallback(
|
|
493
493
|
(_element: TimelineElement) => {
|
|
@@ -509,6 +509,7 @@ export function useTimelineEditing({
|
|
|
509
509
|
domEditSaveTimestampRef,
|
|
510
510
|
reloadPreview,
|
|
511
511
|
isRecordingRef,
|
|
512
|
+
forceReloadSdkSession,
|
|
512
513
|
});
|
|
513
514
|
|
|
514
515
|
return {
|
|
@@ -522,6 +523,7 @@ export function useTimelineEditing({
|
|
|
522
523
|
handleRazorSplitAll,
|
|
523
524
|
handleTimelineAssetDrop,
|
|
524
525
|
handleTimelineFileDrop,
|
|
526
|
+
handleTimelineCompositionDrop,
|
|
525
527
|
handleBlockedTimelineEdit,
|
|
526
528
|
...groupEditing,
|
|
527
529
|
};
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
extendRootDurationIfNeeded,
|
|
13
13
|
formatTimelineAttributeNumber,
|
|
14
14
|
patchIframeDomTiming,
|
|
15
|
+
playbackStartAttributeForElement,
|
|
15
16
|
persistTimelineBatchEdit,
|
|
16
17
|
type PersistTimelineBatchChange,
|
|
17
18
|
type RecordEditInput,
|
|
@@ -242,9 +243,10 @@ export function useTimelineGroupEditing({
|
|
|
242
243
|
// duration change), so nothing timing-related changed — the batch only
|
|
243
244
|
// rewrites data-track-index, which the renderer never reads (documented
|
|
244
245
|
// in core runtime/timeline.ts; track is a studio lane concept). The live
|
|
245
|
-
// DOM patch above + the gesture owner's optimistic store update
|
|
246
|
-
//
|
|
247
|
-
//
|
|
246
|
+
// DOM patch above + the gesture owner's optimistic store update cover the
|
|
247
|
+
// in-flight UI; after the complete lane + z transaction, that owner
|
|
248
|
+
// refreshes the preview so its runtime manifest converges to disk. There
|
|
249
|
+
// is still nothing to GSAP-shift here, so skip this fallback entirely.
|
|
248
250
|
// Running it anyway is what made the mirrored z-order lane move blink —
|
|
249
251
|
// a zero-delta batch yields no scriptText, and finishGroupTimingGsapFallback
|
|
250
252
|
// used to full-reload the iframe when there was no script to soft-swap
|
|
@@ -348,10 +350,7 @@ export function useTimelineGroupEditing({
|
|
|
348
350
|
["data-duration", formatTimelineAttributeNumber(change.duration)],
|
|
349
351
|
];
|
|
350
352
|
if (change.playbackStart != null) {
|
|
351
|
-
const liveAttr =
|
|
352
|
-
change.element.playbackStartAttr === "playback-start"
|
|
353
|
-
? "data-playback-start"
|
|
354
|
-
: "data-media-start";
|
|
353
|
+
const liveAttr = playbackStartAttributeForElement(change.element);
|
|
355
354
|
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
|
|
356
355
|
}
|
|
357
356
|
patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath);
|
|
@@ -28,6 +28,7 @@ import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallb
|
|
|
28
28
|
import type { TimelineProps } from "./TimelineTypes";
|
|
29
29
|
import { useTrackGapMenu } from "./useTrackGapMenu";
|
|
30
30
|
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
|
31
|
+
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
|
|
31
32
|
|
|
32
33
|
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
|
33
34
|
export {
|
|
@@ -52,6 +53,7 @@ export const Timeline = memo(function Timeline({
|
|
|
52
53
|
onFileDrop,
|
|
53
54
|
onAssetDrop,
|
|
54
55
|
onBlockDrop,
|
|
56
|
+
onCompositionDrop,
|
|
55
57
|
onDeleteElement: _onDeleteElement,
|
|
56
58
|
onMoveElement: onMoveElementOverride,
|
|
57
59
|
onMoveElements: onMoveElementsOverride,
|
|
@@ -84,6 +86,11 @@ export const Timeline = memo(function Timeline({
|
|
|
84
86
|
onSplitElement: onSplitElementOverride,
|
|
85
87
|
});
|
|
86
88
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
89
|
+
const playbackContext = useStudioPlaybackContextOptional();
|
|
90
|
+
const setRefreshKey = playbackContext?.setRefreshKey;
|
|
91
|
+
const refreshAfterLaneMove = useCallback(() => {
|
|
92
|
+
setRefreshKey?.((key) => key + 1);
|
|
93
|
+
}, [setRefreshKey]);
|
|
87
94
|
useMusicBeatAnalysis();
|
|
88
95
|
const rawElements = usePlayerStore((s) => s.elements);
|
|
89
96
|
const expandedElements = useExpandedTimelineElements();
|
|
@@ -169,6 +176,7 @@ export const Timeline = memo(function Timeline({
|
|
|
169
176
|
pinnedOnFileDrop,
|
|
170
177
|
pinnedOnAssetDrop,
|
|
171
178
|
pinnedOnBlockDrop,
|
|
179
|
+
pinnedOnCompositionDrop,
|
|
172
180
|
} = useTimelineEditPinning({
|
|
173
181
|
ppsRef,
|
|
174
182
|
fitPpsRef,
|
|
@@ -179,6 +187,7 @@ export const Timeline = memo(function Timeline({
|
|
|
179
187
|
onFileDrop,
|
|
180
188
|
onAssetDrop,
|
|
181
189
|
onBlockDrop,
|
|
190
|
+
onCompositionDrop,
|
|
182
191
|
});
|
|
183
192
|
|
|
184
193
|
const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
|
|
@@ -223,6 +232,7 @@ export const Timeline = memo(function Timeline({
|
|
|
223
232
|
setRangeSelectionRef,
|
|
224
233
|
readZIndex: zSyncEnabled ? readClipZIndex : undefined,
|
|
225
234
|
onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined,
|
|
235
|
+
refreshAfterLaneMove,
|
|
226
236
|
});
|
|
227
237
|
|
|
228
238
|
const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
|
|
@@ -234,6 +244,7 @@ export const Timeline = memo(function Timeline({
|
|
|
234
244
|
onFileDrop: pinnedOnFileDrop,
|
|
235
245
|
onAssetDrop: pinnedOnAssetDrop,
|
|
236
246
|
onBlockDrop: pinnedOnBlockDrop,
|
|
247
|
+
onCompositionDrop: pinnedOnCompositionDrop,
|
|
237
248
|
});
|
|
238
249
|
|
|
239
250
|
const displayTrackOrder = useMemo(() => {
|
|
@@ -399,7 +410,7 @@ export const Timeline = memo(function Timeline({
|
|
|
399
410
|
<div
|
|
400
411
|
ref={setContainerRef}
|
|
401
412
|
aria-label="Timeline"
|
|
402
|
-
className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
413
|
+
className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
403
414
|
onMouseMove={(e) => {
|
|
404
415
|
if (activeTool === "razor" && scrollRef.current) {
|
|
405
416
|
const rect = scrollRef.current.getBoundingClientRect();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
3
|
+
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
4
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
5
|
+
|
|
6
|
+
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
|
|
7
|
+
export function canMoveTimelineElement(element: TimelineElement): boolean {
|
|
8
|
+
return getTimelineEditCapabilities({
|
|
9
|
+
tag: element.tag,
|
|
10
|
+
kind: element.kind,
|
|
11
|
+
duration: element.duration,
|
|
12
|
+
domId: element.domId,
|
|
13
|
+
selector: element.selector,
|
|
14
|
+
compositionSrc: element.compositionSrc,
|
|
15
|
+
playbackStart: element.playbackStart,
|
|
16
|
+
playbackStartAttr: element.playbackStartAttr,
|
|
17
|
+
sourceDuration: element.sourceDuration,
|
|
18
|
+
timingSource: element.timingSource,
|
|
19
|
+
timelineLocked: element.timelineLocked,
|
|
20
|
+
}).canMove;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ExpandedHostAliasDeps {
|
|
24
|
+
elements: TimelineElement[];
|
|
25
|
+
selectedKeys?: ReadonlySet<string> | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Expanded children keep their own source-file identity for direct edits, but a
|
|
30
|
+
* selection can briefly contain both a composition host and one of its visible
|
|
31
|
+
* expanded children. That pair is one authored move target, not two. Resolve it
|
|
32
|
+
* to the host before time/lane/collision commit so ordinary clip placement stays
|
|
33
|
+
* the single owner of the gesture semantics.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveExpandedHostAlias(
|
|
36
|
+
drag: DraggedClipState,
|
|
37
|
+
deps: ExpandedHostAliasDeps,
|
|
38
|
+
): { drag: DraggedClipState; selectedKeys: ReadonlySet<string> } | null {
|
|
39
|
+
const selectedKeys = deps.selectedKeys;
|
|
40
|
+
if (!selectedKeys) return null;
|
|
41
|
+
|
|
42
|
+
const collapsedKeys = new Set(selectedKeys);
|
|
43
|
+
const candidates = deps.elements.includes(drag.element)
|
|
44
|
+
? deps.elements
|
|
45
|
+
: [...deps.elements, drag.element];
|
|
46
|
+
for (const element of candidates) {
|
|
47
|
+
const hostKey = element.expandedHostKey;
|
|
48
|
+
const childKey = getTimelineElementIdentity(element);
|
|
49
|
+
if (hostKey && collapsedKeys.has(hostKey) && collapsedKeys.has(childKey)) {
|
|
50
|
+
collapsedKeys.delete(childKey);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hostKey = drag.element.expandedHostKey;
|
|
55
|
+
const childKey = getTimelineElementIdentity(drag.element);
|
|
56
|
+
if (!hostKey || collapsedKeys.has(childKey)) {
|
|
57
|
+
if (collapsedKeys.size === selectedKeys.size) return null;
|
|
58
|
+
return { drag, selectedKeys: collapsedKeys };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const host = deps.elements.find((element) => getTimelineElementIdentity(element) === hostKey);
|
|
62
|
+
if (!host || !canMoveTimelineElement(host)) return null;
|
|
63
|
+
|
|
64
|
+
const delta = drag.previewStart - drag.element.start;
|
|
65
|
+
const mapTrack = (track: number | undefined): number | undefined =>
|
|
66
|
+
track === drag.element.track ? host.track : track;
|
|
67
|
+
return {
|
|
68
|
+
drag: {
|
|
69
|
+
...drag,
|
|
70
|
+
element: host,
|
|
71
|
+
previewStart: Math.max(0, Math.round((host.start + delta) * 1000) / 1000),
|
|
72
|
+
previewTrack: mapTrack(drag.previewTrack) ?? host.track,
|
|
73
|
+
desiredTrack: mapTrack(drag.desiredTrack),
|
|
74
|
+
},
|
|
75
|
+
selectedKeys: collapsedKeys,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
|
|
3
|
+
const keyOf = (element: TimelineElement) => element.key ?? element.id;
|
|
4
|
+
|
|
5
|
+
/** Authored track numbers only compare within one source file. */
|
|
6
|
+
export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
|
7
|
+
(a.sourceFile ?? null) === (b.sourceFile ?? null);
|
|
8
|
+
|
|
9
|
+
/** Translate a display lane into the source-file track to persist. */
|
|
10
|
+
export function authoredTrackForLane(
|
|
11
|
+
lane: number,
|
|
12
|
+
elements: TimelineElement[],
|
|
13
|
+
dragged: TimelineElement,
|
|
14
|
+
): number {
|
|
15
|
+
const dragKey = keyOf(dragged);
|
|
16
|
+
const peers = elements.filter((element) => {
|
|
17
|
+
return keyOf(element) !== dragKey && sameSourceFile(element, dragged);
|
|
18
|
+
});
|
|
19
|
+
const occupant = peers.find((element) => element.track === lane);
|
|
20
|
+
if (occupant) return occupant.authoredTrack ?? occupant.track;
|
|
21
|
+
|
|
22
|
+
let nearest: TimelineElement | null = null;
|
|
23
|
+
for (const peer of peers) {
|
|
24
|
+
if (!nearest || Math.abs(peer.track - lane) < Math.abs(nearest.track - lane)) nearest = peer;
|
|
25
|
+
}
|
|
26
|
+
if (!nearest) return lane;
|
|
27
|
+
// Synthetic expanded-child display rows can be fractional; authored tracks cannot.
|
|
28
|
+
return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
|
|
29
|
+
}
|
|
@@ -22,6 +22,10 @@ export interface TimelineDropCallbacks {
|
|
|
22
22
|
blockName: string,
|
|
23
23
|
placement: { start: number; track: number },
|
|
24
24
|
) => Promise<void> | void;
|
|
25
|
+
onCompositionDrop?: (
|
|
26
|
+
sourcePath: string,
|
|
27
|
+
placement: { start: number; track: number },
|
|
28
|
+
) => Promise<void> | void;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export interface TimelineEditCallbacks {
|
|
@@ -4,6 +4,7 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
|
4
4
|
import {
|
|
5
5
|
commitDraggedClipMove,
|
|
6
6
|
commitZMirrorLaneMove,
|
|
7
|
+
persistMoveEdits,
|
|
7
8
|
type DragCommitDeps,
|
|
8
9
|
type TimelineMoveEdit,
|
|
9
10
|
} from "./timelineClipDragCommit";
|
|
@@ -314,6 +315,118 @@ describe("commitDraggedClipMove", () => {
|
|
|
314
315
|
expect(map.c).toBeUndefined(); // unselected clips untouched
|
|
315
316
|
});
|
|
316
317
|
|
|
318
|
+
it("collapses a selected expanded child onto its authored composition host", () => {
|
|
319
|
+
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
|
320
|
+
const child = {
|
|
321
|
+
...el("scene.html#title", 0.25, 12, 2),
|
|
322
|
+
sourceFile: "scene.html",
|
|
323
|
+
expandedParentStart: 10,
|
|
324
|
+
expandedHostKey: "host",
|
|
325
|
+
};
|
|
326
|
+
const { updateElement, onMoveElement, onMoveElements } = runClipMove(
|
|
327
|
+
drag(child, { previewStart: 15, previewTrack: child.track }),
|
|
328
|
+
{
|
|
329
|
+
elements: [host],
|
|
330
|
+
trackOrder: [0, child.track],
|
|
331
|
+
selectedKeys: new Set(["host", "scene.html#title"]),
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
336
|
+
expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
|
|
337
|
+
expect(updateElement).toHaveBeenCalledWith("host", { start: 13, track: 0 });
|
|
338
|
+
expect(updateElement).not.toHaveBeenCalledWith("scene.html#title", expect.anything());
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("drops a selected expanded child alias when the authored host initiates the drag", () => {
|
|
342
|
+
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
|
343
|
+
const child = {
|
|
344
|
+
...el("scene.html#title", 0.25, 12, 2),
|
|
345
|
+
sourceFile: "scene.html",
|
|
346
|
+
expandedParentStart: 10,
|
|
347
|
+
expandedHostKey: "host",
|
|
348
|
+
};
|
|
349
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
350
|
+
drag(host, { previewStart: 13, previewTrack: host.track }),
|
|
351
|
+
{
|
|
352
|
+
elements: [host, child],
|
|
353
|
+
trackOrder: [0, child.track],
|
|
354
|
+
selectedKeys: new Set(["host", "scene.html#title"]),
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
359
|
+
expect(onMoveElement).toHaveBeenCalledOnce();
|
|
360
|
+
expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("keeps an expanded child as the edit target when its host is not selected", () => {
|
|
364
|
+
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
|
365
|
+
const child = {
|
|
366
|
+
...el("scene.html#title", 0.25, 12, 2),
|
|
367
|
+
sourceFile: "scene.html",
|
|
368
|
+
expandedParentStart: 10,
|
|
369
|
+
expandedHostKey: "host",
|
|
370
|
+
};
|
|
371
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
372
|
+
drag(child, { previewStart: 15, previewTrack: child.track }),
|
|
373
|
+
{
|
|
374
|
+
elements: [host],
|
|
375
|
+
trackOrder: [0, child.track],
|
|
376
|
+
selectedKeys: new Set(["scene.html#title"]),
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
381
|
+
expect(onMoveElement).toHaveBeenCalledWith(child, { start: 15, track: child.track });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("moves a host alias and an ordinary selected clip once each in one batch", () => {
|
|
385
|
+
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
|
386
|
+
const ordinary = el("ordinary", 1, 20, 3);
|
|
387
|
+
const child = {
|
|
388
|
+
...el("scene.html#title", 0.25, 12, 2),
|
|
389
|
+
sourceFile: "scene.html",
|
|
390
|
+
expandedParentStart: 10,
|
|
391
|
+
expandedHostKey: "host",
|
|
392
|
+
};
|
|
393
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
394
|
+
drag(child, { previewStart: 14, previewTrack: child.track }),
|
|
395
|
+
{
|
|
396
|
+
elements: [host, ordinary],
|
|
397
|
+
trackOrder: [0, child.track, 1],
|
|
398
|
+
selectedKeys: new Set(["host", "scene.html#title", "ordinary"]),
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
|
|
403
|
+
expect(map).toEqual({
|
|
404
|
+
host: { start: 12, track: 0 },
|
|
405
|
+
ordinary: { start: 22, track: 1 },
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("applies an expanded-child vertical drag to the selected host lane", () => {
|
|
410
|
+
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
|
411
|
+
const child = {
|
|
412
|
+
...el("scene.html#title", 0.25, 12, 2),
|
|
413
|
+
sourceFile: "scene.html",
|
|
414
|
+
expandedParentStart: 10,
|
|
415
|
+
expandedHostKey: "host",
|
|
416
|
+
};
|
|
417
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
418
|
+
drag(child, { previewStart: 12, previewTrack: 1, desiredTrack: 1 }),
|
|
419
|
+
{
|
|
420
|
+
elements: [host],
|
|
421
|
+
trackOrder: [0, child.track, 1],
|
|
422
|
+
selectedKeys: new Set(["host", "scene.html#title"]),
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
|
|
427
|
+
expect(map).toEqual({ host: { start: 10, track: 1 } });
|
|
428
|
+
});
|
|
429
|
+
|
|
317
430
|
it("multi-selection move clamps shifted clips at 0 and applies the store update optimistically", () => {
|
|
318
431
|
const elements = [el("a", 0, 6, 3), el("b", 1, 2, 3)];
|
|
319
432
|
// Drag 'a' −5s: b would land at −3 → clamps to 0.
|
|
@@ -959,20 +1072,40 @@ describe("commitDraggedClipMove", () => {
|
|
|
959
1072
|
expectZLiftedToSix(onStackingPatches);
|
|
960
1073
|
});
|
|
961
1074
|
|
|
1075
|
+
it("refreshes the preview only after the complete lane and z transaction", async () => {
|
|
1076
|
+
const order: string[] = [];
|
|
1077
|
+
commitInsertAbove(overlapping(), {
|
|
1078
|
+
onMoveElements: vi.fn(async () => {
|
|
1079
|
+
order.push("lane");
|
|
1080
|
+
}),
|
|
1081
|
+
onStackingPatches: vi.fn(async () => {
|
|
1082
|
+
order.push("z");
|
|
1083
|
+
}),
|
|
1084
|
+
refreshAfterLaneMove: () => order.push("refresh"),
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
await flushMicrotasks();
|
|
1088
|
+
|
|
1089
|
+
expect(order).toEqual(["lane", "z", "refresh"]);
|
|
1090
|
+
});
|
|
1091
|
+
|
|
962
1092
|
it("rolls back the move and skips the z-sync when the persist fails", async () => {
|
|
963
1093
|
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
964
1094
|
const elements = overlapping();
|
|
965
1095
|
const onMoveElements = vi.fn(() => Promise.reject(new Error("write failed")));
|
|
966
1096
|
const onStackingPatches = vi.fn();
|
|
1097
|
+
const refreshAfterLaneMove = vi.fn();
|
|
967
1098
|
const updateElement = vi.fn();
|
|
968
1099
|
commitInsertAbove(elements, {
|
|
969
1100
|
updateElement,
|
|
970
1101
|
onMoveElements,
|
|
971
1102
|
onStackingPatches,
|
|
1103
|
+
refreshAfterLaneMove,
|
|
972
1104
|
});
|
|
973
1105
|
await flushMicrotasks();
|
|
974
1106
|
// Failed move → z patch never issued (no orphaned z change left behind)...
|
|
975
1107
|
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
1108
|
+
expect(refreshAfterLaneMove).not.toHaveBeenCalled();
|
|
976
1109
|
// ...and the optimistic start/track edit for the dragged clip is rolled back.
|
|
977
1110
|
expect(updateElement).toHaveBeenCalledWith("a", { start: 0, track: 1 });
|
|
978
1111
|
errSpy.mockRestore();
|
|
@@ -1300,3 +1433,46 @@ describe("commitZMirrorLaneMove", () => {
|
|
|
1300
1433
|
expect(onMoveElements).not.toHaveBeenCalled();
|
|
1301
1434
|
});
|
|
1302
1435
|
});
|
|
1436
|
+
|
|
1437
|
+
describe("persistMoveEdits convergence", () => {
|
|
1438
|
+
it("reasserts a saved lane after a stale runtime sync", async () => {
|
|
1439
|
+
const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
|
|
1440
|
+
let releaseSave: (() => void) | undefined;
|
|
1441
|
+
const pendingSave = new Promise<void>((resolve) => {
|
|
1442
|
+
releaseSave = resolve;
|
|
1443
|
+
});
|
|
1444
|
+
let liveTrack = clip.track;
|
|
1445
|
+
let liveAuthoredTrack = clip.authoredTrack;
|
|
1446
|
+
const updateElement = vi.fn((_key: string, updates: Partial<TimelineElement>) => {
|
|
1447
|
+
if (updates.track != null) liveTrack = updates.track;
|
|
1448
|
+
if (updates.authoredTrack != null) liveAuthoredTrack = updates.authoredTrack;
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
const persisted = persistMoveEdits(
|
|
1452
|
+
[
|
|
1453
|
+
{
|
|
1454
|
+
element: clip,
|
|
1455
|
+
updates: { start: clip.start, track: 0 },
|
|
1456
|
+
persistTrack: 0,
|
|
1457
|
+
},
|
|
1458
|
+
],
|
|
1459
|
+
{
|
|
1460
|
+
elements: [clip],
|
|
1461
|
+
trackOrder: [0, 1, 2],
|
|
1462
|
+
updateElement,
|
|
1463
|
+
onMoveElements: () => pendingSave,
|
|
1464
|
+
},
|
|
1465
|
+
);
|
|
1466
|
+
expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
|
|
1467
|
+
|
|
1468
|
+
// Reproduce the real failure: the preview emits its cached pre-drag lane
|
|
1469
|
+
// while the file write is still pending.
|
|
1470
|
+
liveTrack = 2;
|
|
1471
|
+
liveAuthoredTrack = 2;
|
|
1472
|
+
releaseSave?.();
|
|
1473
|
+
|
|
1474
|
+
await expect(persisted).resolves.toBe(true);
|
|
1475
|
+
expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
|
|
1476
|
+
expect(updateElement).toHaveBeenCalledTimes(2);
|
|
1477
|
+
});
|
|
1478
|
+
});
|