Files
hyperframes/packages/studio/src/hooks/useTimelineEditing.ts
T
Miguel Ángel 366fcc2a64 fix(studio): address PR review — restore rollback, fix probe race, harden edit suppression
Restore rollback path: enqueueEdit now returns the queued promise so
Promise.resolve(handler(...)).catch(rollback) in useTimelineClipDrag
fires correctly on save failure. Handlers return the promise chain.

Fix lost-update race in probe enrichment: use zustand's functional
setState so concurrent probe completions each read the latest state
atomically instead of all reading the same stale snapshot.

Harden file-change suppression: pendingTimelineEditPathRef is now a
Set<string> with exact-match lookup instead of single-slot + endsWith.
Multiple concurrent edits on different files are all suppressed correctly.

Remove dead canOffsetTrimClipStart function and its tests — no longer
called after the capability gate simplification.

Document runtime sync mechanism: added comment explaining that the
runtime re-reads data attributes on each sync tick (init.ts:1324-1368).

Fix comment wording in patchIframeDomTiming catch block.
2026-05-19 20:59:45 -04:00

478 lines
16 KiB
TypeScript

import { useCallback, useRef } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
import {
buildTimelineAssetId,
buildTimelineAssetInsertHtml,
buildTimelineFileDropPlacements,
getTimelineAssetKind,
insertTimelineAssetIntoSource,
resolveTimelineAssetInitialGeometry,
resolveTimelineAssetSrc,
} from "../utils/timelineAssetDrop";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import {
getTimelineElementLabel,
collectHtmlIds,
resolveDroppedAssetDuration,
} from "../utils/studioHelpers";
import type { EditHistoryKind } from "../utils/editHistory";
// ── Types ──
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
interface UseTimelineEditingOptions {
projectId: string | null;
activeCompPath: string | null;
timelineElements: TimelineElement[];
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
reloadPreview: () => void;
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
}
// ── Helpers ──
function buildPatchTarget(element: { domId?: string; selector?: string; selectorIndex?: number }) {
if (element.domId) {
return { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex };
}
if (element.selector) {
return { selector: element.selector, selectorIndex: element.selectorIndex };
}
return null;
}
// The runtime re-reads data-start/data-duration from the DOM on each sync tick
// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
// picked up automatically on the next frame without a rebind call.
function patchIframeDomTiming(
iframe: HTMLIFrameElement | null,
element: TimelineElement,
attrs: Array<[string, string]>,
): void {
try {
const doc = iframe?.contentDocument;
if (!doc) return;
const el = element.domId
? doc.getElementById(element.domId)
: element.selector
? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
: null;
if (!el) return;
for (const [name, value] of attrs) el.setAttribute(name, value);
} catch {
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
}
}
function resolveResizePlaybackStart(
original: string,
target: PatchTarget,
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "playbackStart">,
): { attrName: string; value: number } | null {
if (updates.playbackStart != null) {
const attrName =
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
return { attrName, value: updates.playbackStart };
}
const trimDelta = updates.start - element.start;
if (trimDelta === 0) return null;
const raw =
readAttributeByTarget(original, target, "playback-start") ??
readAttributeByTarget(original, target, "media-start");
const current = raw != null ? parseFloat(raw) : undefined;
if (current == null || !Number.isFinite(current)) return null;
const attrName =
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
return {
attrName,
value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
};
}
type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
interface PersistTimelineEditInput {
projectId: string;
element: TimelineElement;
activeCompPath: string | null;
label: string;
buildPatches: (original: string, target: PatchTarget) => string;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
}
async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
const originalContent = await readFileContent(input.projectId, targetPath);
const patchTarget = buildPatchTarget(input.element);
if (!patchTarget) {
throw new Error(`Timeline element ${input.element.id} is missing a patchable target`);
}
const patchedContent = input.buildPatches(originalContent, patchTarget);
if (patchedContent === originalContent) {
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
}
input.pendingTimelineEditPathRef.current.add(targetPath);
input.domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: input.projectId,
label: input.label,
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: input.writeProjectFile,
recordEdit: input.recordEdit,
});
input.domEditSaveTimestampRef.current = Date.now();
}
async function readFileContent(projectId: string, targetPath: string): Promise<string> {
const response = await fetch(
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) {
throw new Error(`Failed to read ${targetPath}`);
}
const data = (await response.json()) as { content?: string };
if (typeof data.content !== "string") {
throw new Error(`Missing file contents for ${targetPath}`);
}
return data.content;
}
// ── Hook ──
export function useTimelineEditing({
projectId,
activeCompPath,
timelineElements,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
previewIframeRef,
pendingTimelineEditPathRef,
uploadProjectFiles,
}: UseTimelineEditingOptions) {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
const editQueueRef = useRef(Promise.resolve());
const lastBlockedTimelineToastAtRef = useRef(0);
const enqueueEdit = useCallback(
(
element: TimelineElement,
label: string,
buildPatches: PersistTimelineEditInput["buildPatches"],
): Promise<void> => {
const pid = projectIdRef.current;
if (!pid) return Promise.resolve();
const queued = editQueueRef.current.then(() =>
persistTimelineEdit({
projectId: pid,
element,
activeCompPath,
label,
buildPatches,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}),
);
editQueueRef.current = queued.catch((error) => {
console.error(`[Timeline] Failed to persist: ${label}`, error);
});
return queued;
},
[
activeCompPath,
recordEdit,
writeProjectFile,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
],
);
const handleTimelineElementMove = useCallback(
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
patchIframeDomTiming(previewIframeRef.current, element, [
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-track-index", String(updates.track)],
]);
return enqueueEdit(element, "Move timeline clip", (original, target) => {
let patched = applyPatchByTarget(original, target, {
type: "attribute",
property: "start",
value: formatTimelineAttributeNumber(updates.start),
});
return applyPatchByTarget(patched, target, {
type: "attribute",
property: "track-index",
value: String(updates.track),
});
});
},
[previewIframeRef, enqueueEdit],
);
const handleTimelineElementResize = useCallback(
(
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => {
patchIframeDomTiming(previewIframeRef.current, element, [
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-duration", formatTimelineAttributeNumber(updates.duration)],
]);
return enqueueEdit(element, "Resize timeline clip", (original, target) => {
const pbs = resolveResizePlaybackStart(original, target, element, updates);
let patched = applyPatchByTarget(original, target, {
type: "attribute",
property: "start",
value: formatTimelineAttributeNumber(updates.start),
});
patched = applyPatchByTarget(patched, target, {
type: "attribute",
property: "duration",
value: formatTimelineAttributeNumber(updates.duration),
});
if (pbs) {
patched = applyPatchByTarget(patched, target, {
type: "attribute",
property: pbs.attrName,
value: formatTimelineAttributeNumber(pbs.value),
});
}
return patched;
});
},
[previewIframeRef, enqueueEdit],
);
const handleTimelineElementDelete = useCallback(
async (element: TimelineElement) => {
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const label = getTimelineElementLabel(element);
const targetPath = element.sourceFile || activeCompPath || "index.html";
try {
const originalContent = await readFileContent(pid, targetPath);
const patchTarget = buildPatchTarget(element);
if (!patchTarget) {
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
}
const removeResponse = await fetch(
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: patchTarget }),
},
);
if (!removeResponse.ok) {
throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
}
const removeData = (await removeResponse.json()) as {
changed?: boolean;
content?: string;
};
const patchedContent =
typeof removeData.content === "string" ? removeData.content : originalContent;
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Delete timeline clip",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
usePlayerStore
.getState()
.setElements(
timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)),
);
usePlayerStore.getState().setSelectedElementId(null);
reloadPreview();
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
showToast(message);
}
},
[
activeCompPath,
recordEdit,
showToast,
timelineElements,
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
],
);
const handleTimelineAssetDrop = useCallback(
async (
assetPath: string,
placement: Pick<TimelineElement, "start" | "track">,
durationOverride?: number,
) => {
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const kind = getTimelineAssetKind(assetPath);
if (!kind) {
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
return;
}
const targetPath = activeCompPath || "index.html";
try {
const originalContent = await readFileContent(pid, targetPath);
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
const duration =
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
? durationOverride
: await resolveDroppedAssetDuration(pid, assetPath, kind);
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
const resolvedTargetPath = targetPath || "index.html";
const relevantElements = timelineElements.filter(
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
);
const newElementZIndex = Math.max(1, relevantElements.length + 1);
const patchedContent = insertTimelineAssetIntoSource(
originalContent,
buildTimelineAssetInsertHtml({
id: newId,
assetPath: resolvedAssetSrc,
kind,
start: normalizedStart,
duration: normalizedDuration,
track: placement.track,
zIndex: newElementZIndex,
geometry: resolveTimelineAssetInitialGeometry(originalContent),
}),
);
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Add timeline asset",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
reloadPreview();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
showToast(message);
}
},
[
activeCompPath,
recordEdit,
showToast,
timelineElements,
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
],
);
const handleTimelineFileDrop = useCallback(
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
const pid = projectIdRef.current;
if (!pid) return;
const uploaded = await uploadProjectFiles(files);
if (uploaded.length === 0) return;
const durations: number[] = [];
for (const assetPath of uploaded) {
const kind = getTimelineAssetKind(assetPath);
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
durations.push(Number(formatTimelineAttributeNumber(duration)));
}
const placements = buildTimelineFileDropPlacements(
placement ?? { start: 0, track: 0 },
durations,
timelineElements
.filter(
(te) =>
(te.sourceFile || activeCompPath || "index.html") ===
(activeCompPath || "index.html"),
)
.map((te) => ({
start: te.start,
duration: te.duration,
track: te.track,
})),
);
for (const [index, assetPath] of uploaded.entries()) {
await handleTimelineAssetDrop(
assetPath,
placements[index] ?? placements[0],
durations[index],
);
}
},
[activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
);
const handleBlockedTimelineEdit = useCallback(
(_element: TimelineElement) => {
const now = Date.now();
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
lastBlockedTimelineToastAtRef.current = now;
showToast("This clip can't be moved or resized from the timeline yet.", "info");
},
[showToast],
);
return {
handleTimelineElementMove,
handleTimelineElementResize,
handleTimelineElementDelete,
handleTimelineAssetDrop,
handleTimelineFileDrop,
handleBlockedTimelineEdit,
};
}