mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
382 lines
11 KiB
TypeScript
382 lines
11 KiB
TypeScript
import { useCallback } from "react";
|
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
|
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
|
import {
|
|
applyPatchByTarget,
|
|
buildPatchTarget,
|
|
findTimelineElementInIframe,
|
|
readFileContent,
|
|
type RecordEditInput,
|
|
} from "./timelineEditingHelpers";
|
|
|
|
interface MutableRef<T> {
|
|
current: T;
|
|
}
|
|
|
|
interface ReadonlyRef<T> {
|
|
readonly current: T;
|
|
}
|
|
|
|
interface ToggleTimelineTrackHiddenInput {
|
|
projectId: string;
|
|
activeCompPath: string | null;
|
|
timelineElements: readonly TimelineElement[];
|
|
track: number;
|
|
hidden: boolean;
|
|
previewIframe: HTMLIFrameElement | null;
|
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
domEditSaveTimestampRef: MutableRef<number>;
|
|
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
|
}
|
|
|
|
interface ToggleTimelineElementHiddenInput extends Omit<ToggleTimelineTrackHiddenInput, "track"> {
|
|
/** One timeline key, or several to hide/show in a single atomic file write. */
|
|
elementKey: string | readonly string[];
|
|
}
|
|
|
|
interface SetElementsHiddenInput {
|
|
projectId: string;
|
|
activeCompPath: string | null;
|
|
elements: readonly TimelineElement[];
|
|
hidden: boolean;
|
|
label: string;
|
|
previewIframe: HTMLIFrameElement | null;
|
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
domEditSaveTimestampRef: MutableRef<number>;
|
|
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
|
}
|
|
|
|
interface UseTimelineTrackVisibilityEditingInput extends Omit<
|
|
ToggleTimelineTrackHiddenInput,
|
|
"projectId" | "track" | "hidden" | "previewIframe"
|
|
> {
|
|
projectIdRef: ReadonlyRef<string | null>;
|
|
previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
|
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
isRecordingRef?: ReadonlyRef<boolean>;
|
|
forceReloadSdkSession?: () => void;
|
|
}
|
|
|
|
interface UseTimelineElementVisibilityEditingInput extends Omit<
|
|
ToggleTimelineElementHiddenInput,
|
|
"projectId" | "elementKey" | "hidden" | "previewIframe"
|
|
> {
|
|
projectIdRef: ReadonlyRef<string | null>;
|
|
previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
|
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
isRecordingRef?: ReadonlyRef<boolean>;
|
|
forceReloadSdkSession?: () => void;
|
|
}
|
|
|
|
function getTimelineElementTargetPath(
|
|
element: TimelineElement,
|
|
activeCompPath: string | null,
|
|
): string {
|
|
return element.sourceFile || activeCompPath || "index.html";
|
|
}
|
|
|
|
function patchLiveHiddenState(
|
|
iframe: HTMLIFrameElement | null,
|
|
elements: readonly TimelineElement[],
|
|
hidden: boolean,
|
|
activeCompPath: string | null,
|
|
): void {
|
|
for (const element of elements) {
|
|
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
|
|
if (!target) continue;
|
|
if (hidden) {
|
|
target.setAttribute("data-hidden", "");
|
|
} else {
|
|
target.removeAttribute("data-hidden");
|
|
}
|
|
}
|
|
}
|
|
|
|
function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
|
|
try {
|
|
const win: (Window & { __player?: { seek?: (time: number) => void } }) | null =
|
|
iframe?.contentWindow ?? null;
|
|
win?.__player?.seek?.(usePlayerStore.getState().currentTime);
|
|
} catch {}
|
|
}
|
|
|
|
function groupElementsByTargetPath(
|
|
elements: readonly TimelineElement[],
|
|
activeCompPath: string | null,
|
|
): Map<string, TimelineElement[]> {
|
|
const byPath = new Map<string, TimelineElement[]>();
|
|
for (const element of elements) {
|
|
const targetPath = getTimelineElementTargetPath(element, activeCompPath);
|
|
const existing = byPath.get(targetPath);
|
|
if (existing) {
|
|
existing.push(element);
|
|
} else {
|
|
byPath.set(targetPath, [element]);
|
|
}
|
|
}
|
|
return byPath;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
async function setElementsHidden({
|
|
projectId,
|
|
activeCompPath,
|
|
elements,
|
|
hidden,
|
|
label,
|
|
previewIframe,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
}: SetElementsHiddenInput): Promise<string[]> {
|
|
if (elements.length === 0) return [];
|
|
|
|
patchLiveHiddenState(previewIframe, elements, hidden, activeCompPath);
|
|
reseekPreviewRuntime(previewIframe);
|
|
|
|
const hiddenOperation: PatchOperation = {
|
|
type: "attribute",
|
|
property: "hidden",
|
|
value: hidden ? "" : null,
|
|
};
|
|
const originalByPath = new Map<string, string>();
|
|
const files: Record<string, string> = {};
|
|
|
|
try {
|
|
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
|
|
let patchedContent = await readFileContent(projectId, targetPath);
|
|
originalByPath.set(targetPath, patchedContent);
|
|
|
|
for (const element of fileElements) {
|
|
const patchTarget = buildPatchTarget(element);
|
|
if (!patchTarget) {
|
|
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
}
|
|
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
|
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
}
|
|
patchedContent = applyPatchByTarget(patchedContent, patchTarget, hiddenOperation);
|
|
}
|
|
|
|
files[targetPath] = patchedContent;
|
|
pendingTimelineEditPathRef.current.add(targetPath);
|
|
}
|
|
|
|
domEditSaveTimestampRef.current = Date.now();
|
|
const changedPaths = await saveProjectFilesWithHistory({
|
|
projectId,
|
|
label,
|
|
kind: "timeline",
|
|
files,
|
|
readFile: async (path) => {
|
|
const original = originalByPath.get(path);
|
|
if (original !== undefined) return original;
|
|
return readFileContent(projectId, path);
|
|
},
|
|
writeFile: writeProjectFile,
|
|
recordEdit,
|
|
});
|
|
domEditSaveTimestampRef.current = Date.now();
|
|
for (const element of elements) {
|
|
usePlayerStore.getState().updateElement(element.key ?? element.id, { hidden });
|
|
}
|
|
return changedPaths;
|
|
} catch (error) {
|
|
// The optimistic live patch already ran; a patch-target/save failure here would
|
|
// otherwise leave the preview showing the wrong visibility until a reload. Revert
|
|
// the live DOM to the prior state so what's on screen matches what persisted.
|
|
patchLiveHiddenState(previewIframe, elements, !hidden, activeCompPath);
|
|
reseekPreviewRuntime(previewIframe);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function toggleTimelineTrackHidden({
|
|
projectId,
|
|
activeCompPath,
|
|
timelineElements,
|
|
track,
|
|
hidden,
|
|
previewIframe,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
}: ToggleTimelineTrackHiddenInput): Promise<string[]> {
|
|
return setElementsHidden({
|
|
projectId,
|
|
activeCompPath,
|
|
elements: timelineElements.filter((element) => element.track === track),
|
|
hidden,
|
|
label: hidden ? `Hide track ${track}` : `Show track ${track}`,
|
|
previewIframe,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
});
|
|
}
|
|
|
|
export async function toggleTimelineElementHidden({
|
|
projectId,
|
|
activeCompPath,
|
|
timelineElements,
|
|
elementKey,
|
|
hidden,
|
|
previewIframe,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
}: ToggleTimelineElementHiddenInput): Promise<string[]> {
|
|
const keys = new Set(typeof elementKey === "string" ? [elementKey] : elementKey);
|
|
const elements = timelineElements.filter((item) => keys.has(item.key ?? item.id));
|
|
return setElementsHidden({
|
|
projectId,
|
|
activeCompPath,
|
|
elements,
|
|
hidden,
|
|
label:
|
|
elements.length > 1
|
|
? hidden
|
|
? `Hide ${elements.length} elements`
|
|
: `Show ${elements.length} elements`
|
|
: hidden
|
|
? "Hide element"
|
|
: "Show element",
|
|
previewIframe,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
});
|
|
}
|
|
|
|
export function useTimelineTrackVisibilityEditing({
|
|
projectIdRef,
|
|
activeCompPath,
|
|
showToast,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
previewIframeRef,
|
|
pendingTimelineEditPathRef,
|
|
isRecordingRef,
|
|
forceReloadSdkSession,
|
|
}: UseTimelineTrackVisibilityEditingInput): (track: number, hidden: boolean) => Promise<void> {
|
|
// Resolve the eye toggle against the EXPANDED rows the canvas actually renders:
|
|
// virtual sub-comp children carry their own (display.track + idx) track numbers,
|
|
// so filtering the raw store list by a virtual track number would hide the wrong
|
|
// outer-scene sibling sharing that index.
|
|
const expandedElements = useExpandedTimelineElements();
|
|
return useCallback(
|
|
async (track: number, hidden: boolean) => {
|
|
if (isRecordingRef?.current) {
|
|
showToast("Cannot edit timeline while recording", "error");
|
|
return;
|
|
}
|
|
const pid = projectIdRef.current;
|
|
if (!pid) return;
|
|
try {
|
|
await toggleTimelineTrackHidden({
|
|
projectId: pid,
|
|
activeCompPath,
|
|
timelineElements: expandedElements,
|
|
track,
|
|
hidden,
|
|
previewIframe: previewIframeRef.current,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
});
|
|
forceReloadSdkSession?.();
|
|
} catch (error) {
|
|
console.error("[Timeline] Failed to toggle track visibility", error);
|
|
const message =
|
|
error instanceof Error ? error.message : "Failed to toggle track visibility";
|
|
showToast(message);
|
|
}
|
|
},
|
|
[
|
|
activeCompPath,
|
|
expandedElements,
|
|
previewIframeRef,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
isRecordingRef,
|
|
showToast,
|
|
forceReloadSdkSession,
|
|
projectIdRef,
|
|
],
|
|
);
|
|
}
|
|
|
|
export function useTimelineElementVisibilityEditing({
|
|
projectIdRef,
|
|
activeCompPath,
|
|
timelineElements,
|
|
showToast,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
previewIframeRef,
|
|
pendingTimelineEditPathRef,
|
|
isRecordingRef,
|
|
forceReloadSdkSession,
|
|
}: UseTimelineElementVisibilityEditingInput): (
|
|
elementKey: string | readonly string[],
|
|
hidden: boolean,
|
|
) => Promise<void> {
|
|
return useCallback(
|
|
async (elementKey: string | readonly string[], hidden: boolean) => {
|
|
if (isRecordingRef?.current) {
|
|
showToast("Cannot edit timeline while recording", "error");
|
|
return;
|
|
}
|
|
const pid = projectIdRef.current;
|
|
if (!pid) return;
|
|
try {
|
|
await toggleTimelineElementHidden({
|
|
projectId: pid,
|
|
activeCompPath,
|
|
timelineElements,
|
|
elementKey,
|
|
hidden,
|
|
previewIframe: previewIframeRef.current,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
});
|
|
forceReloadSdkSession?.();
|
|
} catch (error) {
|
|
console.error("[Timeline] Failed to toggle element visibility", error);
|
|
const message =
|
|
error instanceof Error ? error.message : "Failed to toggle element visibility";
|
|
showToast(message);
|
|
}
|
|
},
|
|
[
|
|
activeCompPath,
|
|
timelineElements,
|
|
previewIframeRef,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
isRecordingRef,
|
|
showToast,
|
|
forceReloadSdkSession,
|
|
projectIdRef,
|
|
],
|
|
);
|
|
}
|