Skip to content
13 changes: 13 additions & 0 deletions packages/core/src/runtime/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,19 @@ describe("collectRuntimeTimelinePayload", () => {
expect(result.clips[0].label).toBe("Hero Card");
});

it("names each instance of a repeated scene by its authored id, not the id the loader gave it", () => {
document.body.innerHTML = `<div data-composition-id="main" data-duration="10">
<div class="clip" data-composition-id="benefit-fresh__hf1" data-hf-original-composition-id="benefit-fresh"
data-start="0" data-duration="3"></div>
<div class="clip" data-composition-id="benefit-fresh__hf2" data-hf-original-composition-id="benefit-fresh"
data-start="3" data-duration="3"></div>
</div>`;

const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips.map((clip) => clip.label)).toEqual(["Benefit Fresh", "Benefit Fresh"]);
expect(result.scenes.map((scene) => scene.label)).toEqual(["benefit-fresh", "benefit-fresh"]);
});

it("falls back to a readable ordinal label instead of a node index id", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ function humanizeTimelineToken(value: string): string {
return normalized.replace(/\b\w/g, (char) => char.toUpperCase());
}

function authoredCompositionId(node: Element): string | null {
return (
node.getAttribute("data-hf-original-composition-id") ?? node.getAttribute("data-composition-id")
);
}

function buildTimelineClipLabel(node: Element, kind: RuntimeTimelineClip["kind"], ordinal: number) {
const explicit =
node.getAttribute("data-timeline-label") ??
Expand All @@ -217,7 +223,7 @@ function buildTimelineClipLabel(node: Element, kind: RuntimeTimelineClip["kind"]
null;
if (explicit?.trim()) return explicit.trim();

const compositionId = node.getAttribute("data-composition-id");
const compositionId = authoredCompositionId(node);
if (compositionId) return humanizeTimelineToken(compositionId);

const id = (node as HTMLElement).id;
Expand Down Expand Up @@ -679,7 +685,10 @@ export function collectRuntimeTimelinePayload(params: {
if (clampedDuration <= 0) continue;
scenes.push({
id: compositionId,
label: compositionNode.getAttribute("data-label") ?? compositionId,
label:
compositionNode.getAttribute("data-label") ??
authoredCompositionId(compositionNode) ??
compositionId,
start,
duration: clampedDuration,
thumbnailUrl: toAbsoluteAssetUrl(compositionNode.getAttribute("data-thumbnail-url")),
Expand Down
90 changes: 90 additions & 0 deletions packages/parsers/src/gsapWriter.acorn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
addAnimationToScript,
addKeyframeToScript,
convertToKeyframesFromScript,
copyAnimationsInScript,
removeAnimationFromScript,
removeKeyframeFromScript,
updateAnimationInScript,
Expand Down Expand Up @@ -445,3 +446,92 @@ window.__timelines["t"] = tl;`;
expect(reparsed.global).toBeFalsy();
});
});

describe("copyAnimationsInScript", () => {
const script = `\
gsap.set("#goodbye", { rotation: 4 });
var tl = gsap.timeline({ paused: true });
tl.from("#goodbye", { opacity: 0, y: 40, ease: EASE }, 1);
tl.to("#title", { opacity: 0.5, duration: 10 });
function pop(sel) { tl.to(sel, { scale: 1.2 }, 2); }
pop("#goodbye");
window.__timelines["t"] = tl;`;

it("adds each tween on the original for the copy, moved by the delta, with its own argument text", () => {
const result = copyAnimationsInScript(script, "#goodbye", "#goodbye-2", 3);
expect(result).toContain(`tl.from("#goodbye-2", { opacity: 0, y: 40, ease: EASE }, 4);`);
expect(result).toContain(`gsap.set("#goodbye-2", { rotation: 4 });\nvar tl`);
const copies = parseGsapScriptAcorn(result)?.animations.filter(
(a) => a.targetSelector === "#goodbye-2" && a.method !== "set",
);
expect(copies?.map((a) => a.position)).toEqual([4]);
// The original lines are left as they were; the tween's copy ends the timeline's block.
expect(result.replace(/\n?.*goodbye-2.*/g, "")).toBe(script.replace(/\n?.*goodbye-2.*/g, ""));
expect(
result.endsWith(`= tl;\ntl.from("#goodbye-2", { opacity: 0, y: 40, ease: EASE }, 4);`),
).toBe(true);
});

it("copies no tween whose start is not a number, rather than guess it", () => {
const unsure = `var tl = gsap.timeline();
tl.to("#title", { x: 1, duration: 2 });
tl.from("#goodbye", { opacity: 0 });
tl.addLabel("end");
tl.to("#goodbye", { x: 2 }, "end");`;
expect(copyAnimationsInScript(unsure, "#goodbye", "#goodbye-2", 3)).toBe(unsure);
});

it("keeps the copy in the block that declares the timeline", () => {
const guarded = `if (window.gsap) {
const tl = gsap.timeline();
tl.from("#goodbye", { opacity: 0 }, 1);
window.__timelines["main"] = tl;
}`;
expect(copyAnimationsInScript(guarded, "#goodbye", "#goodbye-2", 3)).toContain(
` window.__timelines["main"] = tl;\n tl.from("#goodbye-2", { opacity: 0 }, 4);\n}`,
);
});

it("copies no tween inside a callback or a guard, which may never run", () => {
const deferred = `var tl = gsap.timeline();
el.addEventListener("click", () => tl.to("#goodbye", { x: 1 }, 1));
window.go && tl.from("#goodbye", { opacity: 0 }, 1);
tl.call(() => tl.to("#goodbye", { y: 1 }, 1));
tl.to("#title", { x: 1 }, 0).from("#goodbye", { opacity: 0 }, 2);`;
const result = copyAnimationsInScript(deferred, "#goodbye", "#goodbye-2", 3);
expect(result.match(/goodbye-2/g)).toEqual(["goodbye-2"]);
expect(result).toContain(`tl.from("#goodbye-2", { opacity: 0 }, 5);`);
});

it("copies no set that is the bare body of an if or an else", () => {
const guarded = `var tl = gsap.timeline();
if (window.go) gsap.set("#goodbye", { x: 9 });
else gsap.set("#goodbye", { x: 1 });`;
expect(copyAnimationsInScript(guarded, "#goodbye", "#goodbye-2", 3)).toBe(guarded);
});

it("puts the copy before the block's return", () => {
const built = `function build() {
const tl = gsap.timeline();
tl.from("#goodbye", { opacity: 0, stagger: 0.1 }, 1);
return tl;
}`;
expect(copyAnimationsInScript(built, "#goodbye", "#goodbye-2", 3)).toContain(
` tl.from("#goodbye-2", { opacity: 0, stagger: 0.1 }, 4);\n return tl;`,
);
});

it("puts a set's copy right after it, where what it reads is defined", () => {
const late = `const tl = gsap.timeline();
const X = 40;
gsap.set("#goodbye", { x: X });
tl.from("#goodbye", { opacity: 0 }, 1);`;
expect(copyAnimationsInScript(late, "#goodbye", "#goodbye-2", 3)).toContain(
`gsap.set("#goodbye", { x: X });\ngsap.set("#goodbye-2", { x: X });`,
);
});

it("leaves the script as it was when nothing targets the original", () => {
expect(copyAnimationsInScript(script, "#tag", "#tag-2", 3)).toBe(script);
});
});
99 changes: 97 additions & 2 deletions packages/parsers/src/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,13 +461,108 @@ export function shiftPositionsInScript(
for (const entry of parsed.located) {
if (entry.animation.targetSelector !== targetSelector) continue;
if (typeof entry.animation.position !== "number") continue;
const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000);
overwritePosition(ms, entry.call, newPos);
overwritePosition(ms, entry.call, shiftedPosition(entry.animation.position, delta));
changed = true;
}
return changed ? ms.toString() : script;
}

/** Copies each tween on `fromSelector` for `toSelector`, `delta` seconds later, in its own argument text. Exact or
* absent: only a tween at a number, written straight in the timeline's own block, is copied. */
export function copyAnimationsInScript(
script: string,
fromSelector: string,
toSelector: string,
delta: number,
): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const block = timelineBlock(parsed);
const target = JSON.stringify(toSelector);
const ms = new MagicString(script);
const tweens: string[] = [];
for (const { animation, call } of parsed.located) {
if (animation.targetSelector !== fromSelector || !copyable(call)) continue;
const args = [target, ...argumentText(call, script)];
const statement = findEnclosingExpressionStatement(call.ancestors);
if (call.global && statement && inBlock(call, statement)) {
ms.appendLeft(
statement.end,
`\n${indentAt(script, statement.start)}gsap.set(${args.join(", ")});`,
);
} else if (block.body.includes(statement) && typeof call.positionArg?.value === "number") {
const position = valueToCode(shiftedPosition(call.positionArg.value, delta));
tweens.push(`${parsed.timelineVar}.${call.method}(${[...args, position].join(", ")});`);
}
}
appendAtBlockEnd(ms, script, block, tweens);
return ms.toString();
}

/** A tween in a loop runs once per pass, one on a variable reads a name bound elsewhere, and one inside a callback or a
* guard may never run: none is copied. The statement must be the tween's own chain. */
function copyable(call: TweenCallInfo): boolean {
if (call.node.arguments[0]?.type !== "Literal" || call.ancestors.some(isLoopOrForEach))
return false;
let link = findEnclosingExpressionStatement(call.ancestors)?.expression;
while (link?.type === "CallExpression" && link !== call.node) link = link.callee?.object;
return link === call.node;
}

/** A statement straight in a block or the script: one that is an `if`'s bare body runs only when the `if` does. */
function inBlock(call: TweenCallInfo, statement: Node): boolean {
return Array.isArray(call.ancestors[call.ancestors.indexOf(statement) - 1]?.body);
}

/** The call's own text for every argument but its target and position. */
function argumentText(call: TweenCallInfo, script: string): string[] {
return call.node.arguments
.slice(1)
.filter((arg: Node) => arg !== call.positionArg)
.map((arg: Node) => script.slice(arg.start, arg.end));
}

function indentAt(script: string, at: number): string {
return /^[ \t]*/.exec(script.slice(script.lastIndexOf("\n", at - 1) + 1))![0];
}

/** At the end of the timeline's own block, before its return, so nothing that follows in its scope is pushed back. */
function appendAtBlockEnd(
ms: MagicString,
script: string,
block: { body: Node[]; end: number },
lines: string[],
): void {
if (lines.length === 0) return;
const last = block.body.at(-1);
const indent = last ? indentAt(script, last.start) : "";
const code = lines.map((line) => `${indent}${line}`).join("\n");
if (last?.type === "ReturnStatement") ms.appendLeft(last.start, `${code.trimStart()}\n${indent}`);
else ms.appendLeft(last?.end ?? block.end, `\n${code}`);
}

function shiftedPosition(position: number, delta: number): number {
return Math.max(0, Math.round((position + delta) * 1000) / 1000);
}

/** The block (or program) whose statements declare the timeline; a timeline with no declaration lives at the top. */
function timelineBlock(parsed: ParsedGsapAcornForWrite): { body: Node[]; end: number } {
const declaration = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
let block: Node = parsed.ast;
if (declaration) {
acornWalk.ancestor(parsed.ast, {
VariableDeclaration(node: Node, _state: unknown, ancestors: Node[]) {
if (node === declaration) block = ancestors[ancestors.length - 2];
},
});
}
return Array.isArray(block?.body) ? block : parsed.ast;
}

function isLoopOrForEach(node: Node): boolean {
return isLoopNode(node) || isForEachStatement(node) || node?.type === "DoWhileStatement";
}

/**
* Linearly remap every tween targeting `targetSelector` from the old clip
* [oldStart, oldDuration] onto the new [newStart, newDuration] (position and,
Expand Down
32 changes: 25 additions & 7 deletions packages/studio/src/hooks/useClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
deduplicateIds,
insertAsSibling,
} from "../utils/clipboardPayload";
import { carryLook, renamedIds } from "../utils/clipboardLook";
import { collectHtmlIds } from "../utils/studioHelpers";
import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
import { extendRootDurationInSource } from "../utils/rootDuration";
Expand Down Expand Up @@ -165,15 +166,16 @@ export function pasteElementHtml(
return insertAsSibling(content, deduped, payload.originSelector, payload.originSelectorIndex);
}

/** Shared insertion path for paste and duplicate, anchored at the playhead or
* the selection's end respectively. Returns the final ids so the caller can
* select what it just placed, and the furthest end any clip lands at so the
* caller can grow the root composition's duration to cover it. */
/** Shared insertion path for paste and duplicate, anchored at the playhead or the selection's end. Returns the
* final ids so the caller can select what it placed, and the furthest end any clip lands at so it can grow the
* root duration to cover it. `fromThisFile`: the clips came from `content`, so a renamed copy takes its
* original's look. */
export function pasteTimelineClips(
content: string,
clips: readonly TimelineClipboardClip[],
anchorTime: number,
liveElements: readonly TimelineElement[],
fromThisFile = false,
): { content: string; ids: string[]; requiredEnd: number } {
const groupMinStart = Math.min(...clips.map((c) => c.start));
let existingIds = collectHtmlIds(content);
Expand Down Expand Up @@ -209,6 +211,11 @@ export function pasteTimelineClips(
.replace(/data-track-index="[^"]*"/, `data-track-index="${newTrack}"`);
const withPatched = patchedRootTag + deduped.slice(rootTagEnd + 1);
result = insertTimelineAssetIntoSource(result, withPatched);
if (fromThisFile) {
const authored = Number(rootTag.match(/data-start="([^"]*)"/)?.[1]);
const authoredStart = Number.isFinite(authored) ? authored : clip.start;
result = carryLook(result, renamedIds(reminted, deduped), newStart - authoredStart);
}

const id = patchedRootTag.match(ID_ATTR_RE)?.[1];
if (id) ids.push(id);
Expand Down Expand Up @@ -294,7 +301,12 @@ export function useClipboard({
const sourceFile = targets.elements[0]?.sourceFile || activeCompPath || "index.html";
return readClips(targets).then((clips) => {
showToast(clips.length > 1 ? `Copied ${clips.length} clips` : "Copied clip", "info");
return { kind: "timeline-clip", clips, sourceFile };
return {
kind: "timeline-clip",
clips,
sourceFile,
projectId: projectIdRef.current ?? undefined,
};
});
}, [activeCompPath, findSelectedClips, readClips, showToast]);

Expand Down Expand Up @@ -364,7 +376,13 @@ export function useClipboard({
const paste = (originalContent: string) => {
if (payload.kind !== "timeline-clip") return pasteElementHtml(originalContent, payload);
const { currentTime, elements } = usePlayerStore.getState();
const pasted = pasteTimelineClips(originalContent, payload.clips, currentTime, elements);
const pasted = pasteTimelineClips(
originalContent,
payload.clips,
currentTime,
elements,
payload.sourceFile === targetPath && payload.projectId === pid,
);
pastedIds = pasted.ids;
// A clip pasted past the current composition end would exist in the
// file but never appear on the timeline or in playback/export (the
Expand Down Expand Up @@ -433,7 +451,7 @@ export function useClipboard({
.getState()
.elements.filter((el) => pathOf(el) === targetPath)
.map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true);
ids = pasted.ids;
return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
};
Expand Down
Loading
Loading