Files
hyperframes/packages/studio/src/hooks/useTimelineGroupEditing.ts
T
Miguel Angel Simon Sierra 076c656d6e fix(studio): fold GSAP timing rewrites into the recorded history entry
A timeline move/resize recorded the timing patch, then a server GSAP rewrite mutated the
same file afterward, leaving the recorded after stale so an undo hit a hash conflict. The
GSAP mutation now snapshots the touched files and records a follow-up edit under the same
coalesceKey, with a per-entry coalesceMs override large enough to survive the GSAP round
trip, so undo restores the original in one step. Applies to single-clip and group edits.
2026-07-09 17:28:37 -04:00

371 lines
13 KiB
TypeScript

import { useCallback, type MutableRefObject, type RefObject } from "react";
import type { Composition } from "@hyperframes/sdk";
import type { TimelineElement } from "../player";
import { sdkTimingBatchPersist } from "../utils/sdkCutover";
import {
buildTimelineMoveTimingPatch,
buildTimelineResizeTimingPatch,
extendRootDurationIfNeeded,
finishTimelineTimingFallback,
foldGsapMutationIntoHistory,
formatTimelineAttributeNumber,
patchIframeDomTiming,
persistTimelineBatchEdit,
readFileContent,
scaleGsapPositions,
shiftGsapPositions,
type PersistTimelineBatchChange,
type RecordEditInput,
} from "./timelineEditingHelpers";
export interface TimelineGroupMoveChange {
element: TimelineElement;
start: number;
}
export interface TimelineGroupResizeChange {
element: TimelineElement;
start: number;
duration: number;
playbackStart?: number;
}
export interface TimelineGroupCommitOptions {
beforeTiming?: Promise<void>;
coalesceKey?: string;
}
interface UseTimelineGroupEditingOptions {
activeCompPath: string | null;
domEditSaveTimestampRef: MutableRefObject<number>;
editQueueRef: MutableRefObject<Promise<unknown>>;
forceReloadSdkSession?: () => void;
isRecordingRef?: RefObject<boolean>;
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
previewIframeRef: RefObject<HTMLIFrameElement | null>;
projectIdRef: MutableRefObject<string | null>;
recordEdit: (input: RecordEditInput) => Promise<void>;
reloadPreview: () => void;
sdkSession?: Composition | null;
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
}
function targetPathFor(element: TimelineElement, activeCompPath: string | null): string {
return element.sourceFile || activeCompPath || "index.html";
}
function allChangesSharePath(
changes: readonly { element: TimelineElement }[],
activeCompPath: string | null,
): string | null {
const firstPath = changes[0] ? targetPathFor(changes[0].element, activeCompPath) : null;
if (!firstPath) return null;
return changes.every((change) => targetPathFor(change.element, activeCompPath) === firstPath)
? firstPath
: null;
}
function moveCoalesceKey(changes: readonly TimelineGroupMoveChange[]): string {
return `timeline-group-move:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
}
function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): string {
return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
}
function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
return (
change.playbackStart != null ||
(change.start !== change.element.start && change.element.playbackStart != null)
);
}
export function useTimelineGroupEditing({
activeCompPath,
domEditSaveTimestampRef,
editQueueRef,
forceReloadSdkSession,
isRecordingRef,
pendingTimelineEditPathRef,
previewIframeRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
showToast,
writeProjectFile,
}: UseTimelineGroupEditingOptions) {
const enqueueGroupOperation = useCallback(
(label: string, operation: (projectId: string) => Promise<void>): Promise<void> => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return Promise.reject(new Error(`${label}: blocked while recording`));
}
const projectId = projectIdRef.current;
if (!projectId) return Promise.reject(new Error(`${label}: no active project`));
const run = editQueueRef.current.then(() => operation(projectId));
// Keep the shared edit queue from wedging on a rejection, but return the raw
// (rejecting) promise so the gesture owner can roll back on a real failure.
editQueueRef.current = run.then(
() => undefined,
(error) => {
console.error(`[Timeline] Failed to persist: ${label}`, error);
},
);
return run;
},
[editQueueRef, isRecordingRef, projectIdRef, showToast],
);
const persistServerBatch = useCallback(
async (
projectId: string,
label: string,
batchChanges: PersistTimelineBatchChange[],
coalesceKey: string,
) => {
await persistTimelineBatchEdit({
projectId,
activeCompPath,
label,
changes: batchChanges,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
coalesceKey,
});
forceReloadSdkSession?.();
},
[
activeCompPath,
domEditSaveTimestampRef,
forceReloadSdkSession,
pendingTimelineEditPathRef,
recordEdit,
writeProjectFile,
],
);
const handleTimelineGroupMove = useCallback(
(changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
if (changes.length === 0) return Promise.resolve();
for (const change of changes) {
patchIframeDomTiming(previewIframeRef.current, change.element, [
["data-start", formatTimelineAttributeNumber(change.start)],
]);
}
const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
const needsExtension = extendRootDurationIfNeeded(maxEnd);
const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
return enqueueGroupOperation("Move timeline clips", async (projectId) => {
await options?.beforeTiming;
const sharedPath = allChangesSharePath(changes, activeCompPath);
const sdkChanges = changes.map((change) =>
change.element.hfId
? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
: null,
);
const canUseSdk =
!needsExtension && sharedPath !== null && sdkChanges.every((change) => change !== null);
if (canUseSdk) {
const handled = await sdkTimingBatchPersist(
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
sharedPath,
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Move timeline clips", coalesceKey },
);
if (handled) return;
}
await persistServerBatch(
projectId,
"Move timeline clips",
changes.map((change) => ({
element: change.element,
buildPatches: (original, target) =>
buildTimelineMoveTimingPatch(original, target, change.start, change.element.duration),
})),
coalesceKey,
);
await finishTimelineTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: maxEnd,
reloadPreview,
gsapMutation: () =>
foldGsapMutationIntoHistory({
projectId,
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
label: "Move timeline clips",
coalesceKey,
recordEdit,
gsapMutation: async () => {
let mutated = false;
for (const change of changes) {
const delta = change.start - change.element.start;
const domId = change.element.domId;
if (delta === 0 || !domId) continue;
const status = await shiftGsapPositions(
projectId,
targetPathFor(change.element, activeCompPath),
domId,
delta,
);
mutated = mutated || status.mutated;
}
return { mutated };
},
}),
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
});
});
},
[
activeCompPath,
domEditSaveTimestampRef,
enqueueGroupOperation,
persistServerBatch,
previewIframeRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
writeProjectFile,
],
);
const handleTimelineGroupResize = useCallback(
(changes: TimelineGroupResizeChange[], options?: TimelineGroupCommitOptions) => {
if (changes.length === 0) return Promise.resolve();
for (const change of changes) {
const liveAttrs: Array<[string, string]> = [
["data-start", formatTimelineAttributeNumber(change.start)],
["data-duration", formatTimelineAttributeNumber(change.duration)],
];
if (change.playbackStart != null) {
const liveAttr =
change.element.playbackStartAttr === "playback-start"
? "data-playback-start"
: "data-media-start";
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs);
}
const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
const needsExtension = extendRootDurationIfNeeded(maxEnd);
const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
await options?.beforeTiming;
const sharedPath = allChangesSharePath(changes, activeCompPath);
const sdkChanges = changes.map((change) =>
change.element.hfId
? {
hfId: change.element.hfId,
timingUpdate: { start: change.start, duration: change.duration },
}
: null,
);
const canUseSdk =
!needsExtension &&
sharedPath !== null &&
changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) &&
sdkChanges.every((change) => change !== null);
if (canUseSdk) {
const handled = await sdkTimingBatchPersist(
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
sharedPath,
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Resize timeline clips", coalesceKey },
);
if (handled) return;
}
await persistServerBatch(
projectId,
"Resize timeline clips",
changes.map((change) => ({
element: change.element,
buildPatches: (original, target) =>
buildTimelineResizeTimingPatch(original, target, change.element, {
start: change.start,
duration: change.duration,
playbackStart: change.playbackStart,
}),
})),
coalesceKey,
);
await finishTimelineTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: maxEnd,
reloadPreview,
gsapMutation: () =>
foldGsapMutationIntoHistory({
projectId,
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
label: "Resize timeline clips",
coalesceKey,
recordEdit,
gsapMutation: async () => {
let mutated = false;
for (const change of changes) {
const domId = change.element.domId;
const timingChanged =
change.start !== change.element.start ||
change.duration !== change.element.duration;
if (!timingChanged || !domId) continue;
const status = await scaleGsapPositions(
projectId,
targetPathFor(change.element, activeCompPath),
domId,
change.element.start,
change.element.duration,
change.start,
change.duration,
);
mutated = mutated || status.mutated;
}
return { mutated };
},
}),
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
});
});
},
[
activeCompPath,
domEditSaveTimestampRef,
enqueueGroupOperation,
persistServerBatch,
previewIframeRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
writeProjectFile,
],
);
return { handleTimelineGroupMove, handleTimelineGroupResize };
}