mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(studio): timeline inline expansion + __clipTree runtime primitive
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.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveTimelineSelectionSeekTime } from "./studioHelpers";
|
||||
import { findMatchingTimelineElementId, resolveTimelineSelectionSeekTime } from "./studioHelpers";
|
||||
|
||||
describe("resolveTimelineSelectionSeekTime", () => {
|
||||
it("keeps the current time when it is already inside the clip range", () => {
|
||||
@@ -18,3 +18,27 @@ describe("resolveTimelineSelectionSeekTime", () => {
|
||||
expect(resolveTimelineSelectionSeekTime(Number.NaN, { start: 2, duration: 5 })).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMatchingTimelineElementId", () => {
|
||||
const el = (over: Record<string, unknown>) =>
|
||||
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
|
||||
|
||||
it("matches a top-level element by domId + sourceFile", () => {
|
||||
const els = [el({ id: "s1", domId: "s1", sourceFile: "index.html" })];
|
||||
expect(findMatchingTimelineElementId({ id: "s1", sourceFile: "index.html" }, els)).toBe("s1");
|
||||
});
|
||||
|
||||
it("returns a qualified id for a sub-comp child with no matching timeline element", () => {
|
||||
const els = [el({ id: "s3", domId: "s3", sourceFile: "index.html" })];
|
||||
expect(
|
||||
findMatchingTimelineElementId(
|
||||
{ id: "stat-3", sourceFile: "compositions/stats-panel.html" },
|
||||
els,
|
||||
),
|
||||
).toBe("compositions/stats-panel.html#stat-3");
|
||||
});
|
||||
|
||||
it("returns null for an unmatched element in index.html", () => {
|
||||
expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,38 +116,64 @@ export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
||||
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
||||
}
|
||||
|
||||
type ElementMatchSelection = Pick<
|
||||
DomEditSelection,
|
||||
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
||||
>;
|
||||
|
||||
function matchesByDomId(
|
||||
selection: ElementMatchSelection,
|
||||
element: TimelineElement,
|
||||
selectionSourceFile: string,
|
||||
): boolean {
|
||||
if (!selection.id) return false;
|
||||
return (
|
||||
element.domId === selection.id && (element.sourceFile || "index.html") === selectionSourceFile
|
||||
);
|
||||
}
|
||||
|
||||
function matchesByCompositionHost(
|
||||
selection: ElementMatchSelection,
|
||||
element: TimelineElement,
|
||||
): boolean {
|
||||
if (!selection.isCompositionHost || !selection.compositionSrc) return false;
|
||||
return element.compositionSrc === selection.compositionSrc;
|
||||
}
|
||||
|
||||
function matchesBySelector(selection: ElementMatchSelection, element: TimelineElement): boolean {
|
||||
if (!selection.selector) return false;
|
||||
return (
|
||||
element.selector === selection.selector &&
|
||||
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
||||
(element.sourceFile ?? "index.html") === selection.sourceFile
|
||||
);
|
||||
}
|
||||
|
||||
function elementMatchesSelection(
|
||||
selection: ElementMatchSelection,
|
||||
element: TimelineElement,
|
||||
selectionSourceFile: string,
|
||||
): boolean {
|
||||
return (
|
||||
matchesByDomId(selection, element, selectionSourceFile) ||
|
||||
matchesByCompositionHost(selection, element) ||
|
||||
matchesBySelector(selection, element)
|
||||
);
|
||||
}
|
||||
|
||||
export function findMatchingTimelineElementId(
|
||||
selection: Pick<
|
||||
DomEditSelection,
|
||||
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
||||
>,
|
||||
selection: ElementMatchSelection,
|
||||
elements: TimelineElement[],
|
||||
): string | null {
|
||||
const selectionSourceFile = selection.sourceFile || "index.html";
|
||||
for (const element of elements) {
|
||||
const elementSourceFile = element.sourceFile || "index.html";
|
||||
if (
|
||||
selection.id &&
|
||||
element.domId === selection.id &&
|
||||
elementSourceFile === selectionSourceFile
|
||||
) {
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
if (
|
||||
selection.isCompositionHost &&
|
||||
selection.compositionSrc &&
|
||||
element.compositionSrc === selection.compositionSrc
|
||||
) {
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
if (
|
||||
selection.selector &&
|
||||
element.selector === selection.selector &&
|
||||
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
||||
(element.sourceFile ?? "index.html") === selection.sourceFile
|
||||
) {
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
const match = elements.find((el) => elementMatchesSelection(selection, el, selectionSourceFile));
|
||||
if (match) return match.key ?? match.id;
|
||||
|
||||
// Child inside a sub-composition: return a qualified ID so the expansion
|
||||
// hook can resolve the child via clipParentMap even though no timeline
|
||||
// element exists for it yet (the expansion creates it on the fly).
|
||||
if (selection.id && selectionSourceFile !== "index.html") {
|
||||
return `${selectionSourceFile}#${selection.id}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user