mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
When a child element inside a sub-composition is selected, the timeline replaces the parent scene clip with the deepest-level siblings. Deselect or selecting outside collapses back. Expanded clips are fully editable — move, resize, delete, and split — addressed by their real DOM id with timeline time rebased onto the sub-comp they live in. Runtime: - New window.__clipTree API: a read-only hierarchical ClipNode tree (id/parentId/children + backing element) so Studio can derive parent/child relationships for inline expansion. Studio: - useExpandedTimelineElements derives the expanded view from selectedElementId + clipParentMap (pure useMemo, no useEffect). Each child rebases onto its immediate sub-comp host (start + sourceFile), so multi-level nesting targets the right file. - NLELayout routes expanded-clip edits through the same handlers top-level clips use, in local coordinates — edits save to the sub-comp source and reflect via reloadPreview (no separate DOM-patch path). This is the canonical update; there is no reactive observer. - findMatchingTimelineElementId resolves sub-comp children with no top-level element to `sourceFile#id`. - Razor tool enabled by default; studio_razor_split analytics event fired on single and split-all. - O(n²) isElementGsapTargeted extracted to gsapTargetCache.ts with a cached Set+WeakSet O(1) lookup.
122 lines
3.6 KiB
TypeScript
122 lines
3.6 KiB
TypeScript
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
|
export { PROPERTY_DEFAULTS } from "./gsapShared";
|
|
|
|
export function ensureElementAddressable(selection: DomEditSelection): {
|
|
selector: string;
|
|
autoId?: string;
|
|
} {
|
|
if (selection.id) return { selector: `#${selection.id}` };
|
|
if (selection.selector) return { selector: selection.selector };
|
|
|
|
const el = selection.element;
|
|
const doc = el.ownerDocument;
|
|
const tag = el.tagName.toLowerCase();
|
|
let id = tag;
|
|
let n = 1;
|
|
while (doc.getElementById(id)) {
|
|
n += 1;
|
|
id = `${tag}-${n}`;
|
|
}
|
|
el.setAttribute("id", id);
|
|
return { selector: `#${id}`, autoId: id };
|
|
}
|
|
|
|
export class GsapMutationHttpError extends Error {
|
|
constructor(
|
|
readonly statusCode: number,
|
|
readonly responseBody: unknown,
|
|
) {
|
|
super(formatGsapMutationHttpErrorMessage(statusCode, responseBody));
|
|
this.name = "GsapMutationHttpError";
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
export function formatFieldsSuffix(rawFields: unknown): string {
|
|
const fields = Array.isArray(rawFields)
|
|
? rawFields.filter((f): f is string => typeof f === "string")
|
|
: [];
|
|
return fields.length > 0 ? ` (${fields.join(", ")})` : "";
|
|
}
|
|
|
|
export async function readJsonResponseBody(res: Response): Promise<unknown> {
|
|
const contentType = res.headers.get("content-type") ?? "";
|
|
if (!contentType.includes("application/json")) {
|
|
return await res.text().catch(() => null);
|
|
}
|
|
return await res.json().catch(() => null);
|
|
}
|
|
|
|
function formatGsapMutationHttpErrorMessage(statusCode: number, body: unknown): string {
|
|
if (isRecord(body) && typeof body.error === "string") {
|
|
return body.error;
|
|
}
|
|
return `GSAP mutation failed with status ${statusCode}`;
|
|
}
|
|
|
|
export function formatGsapMutationRejectionToast(error: GsapMutationHttpError): string {
|
|
const body = error.responseBody;
|
|
if (isRecord(body)) {
|
|
return `Couldn't save animation: ${formatGsapMutationHttpErrorMessage(
|
|
error.statusCode,
|
|
body,
|
|
)}${formatFieldsSuffix(body.fields)}`;
|
|
}
|
|
return `Couldn't save animation: ${error.message}`;
|
|
}
|
|
|
|
interface AssignAutoIdParams {
|
|
projectId: string;
|
|
targetPath: string;
|
|
selection: DomEditSelection;
|
|
autoId: string;
|
|
showToast?: (message: string, tone?: "error" | "info") => void;
|
|
}
|
|
|
|
export async function assignGsapTargetAutoIdIfNeeded({
|
|
projectId,
|
|
targetPath,
|
|
selection,
|
|
autoId,
|
|
showToast,
|
|
}: AssignAutoIdParams): Promise<boolean> {
|
|
const patchBody = {
|
|
target: {
|
|
id: selection.id,
|
|
hfId: selection.hfId,
|
|
selector: selection.selector,
|
|
selectorIndex: selection.selectorIndex,
|
|
},
|
|
operations: [{ type: "html-attribute", property: "id", value: autoId }],
|
|
};
|
|
const unsafePatchFields = findUnsafeDomPatchValues(patchBody);
|
|
if (unsafePatchFields.length > 0) {
|
|
showToast?.("Couldn't assign element id because the patch contains invalid values", "error");
|
|
return false;
|
|
}
|
|
const res = await fetch(
|
|
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(patchBody),
|
|
},
|
|
);
|
|
if (!res.ok) {
|
|
showToast?.(
|
|
formatGsapMutationRejectionToast(
|
|
new GsapMutationHttpError(res.status, await readJsonResponseBody(res)),
|
|
),
|
|
"error",
|
|
);
|
|
return false;
|
|
}
|
|
const data = (await res.json()) as { changed?: boolean };
|
|
return data.changed === true;
|
|
}
|