diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts
index eb0ae2798a..94da4663c0 100644
--- a/packages/core/src/runtime/timeline.test.ts
+++ b/packages/core/src/runtime/timeline.test.ts
@@ -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 = `
`;
+
+ 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");
diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts
index 4768776560..f8bb5c88f2 100644
--- a/packages/core/src/runtime/timeline.ts
+++ b/packages/core/src/runtime/timeline.ts
@@ -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") ??
@@ -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;
@@ -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")),
diff --git a/packages/parsers/src/gsapWriter.acorn.test.ts b/packages/parsers/src/gsapWriter.acorn.test.ts
index 96d7147750..d32cf2f00e 100644
--- a/packages/parsers/src/gsapWriter.acorn.test.ts
+++ b/packages/parsers/src/gsapWriter.acorn.test.ts
@@ -10,6 +10,7 @@ import {
addAnimationToScript,
addKeyframeToScript,
convertToKeyframesFromScript,
+ copyAnimationsInScript,
removeAnimationFromScript,
removeKeyframeFromScript,
updateAnimationInScript,
@@ -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);
+ });
+});
diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts
index 484c9f95a7..b1dd333919 100644
--- a/packages/parsers/src/gsapWriterAcorn.ts
+++ b/packages/parsers/src/gsapWriterAcorn.ts
@@ -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,
diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts
index 02a1d2e1f5..0f67500786 100644
--- a/packages/studio/src/hooks/useClipboard.ts
+++ b/packages/studio/src/hooks/useClipboard.ts
@@ -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";
@@ -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);
@@ -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);
@@ -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]);
@@ -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
@@ -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);
};
diff --git a/packages/studio/src/utils/clipboardLook.test.ts b/packages/studio/src/utils/clipboardLook.test.ts
new file mode 100644
index 0000000000..1a5cf22b9a
--- /dev/null
+++ b/packages/studio/src/utils/clipboardLook.test.ts
@@ -0,0 +1,130 @@
+// @vitest-environment jsdom
+import gsap from "gsap";
+import { afterEach, describe, expect, it } from "vitest";
+import { pasteTimelineClips } from "../hooks/useClipboard";
+
+const GOODBYE =
+ 'Goodbye
';
+const FILM = `
+
+
+
+
+
+
+
Title
+ ${GOODBYE}
+
+
+
+`;
+
+const goodbye = { html: GOODBYE, start: 1, duration: 3, track: 3 };
+
+/** Runs the film's own script with real GSAP and says when each tween starts, by its target's id. */
+function tweenStarts(html: string): Array<[string, number]> {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ document.body.innerHTML = doc.body.innerHTML;
+ const win = window as unknown as { __timelines: Record };
+ win.__timelines = {};
+ new Function("gsap", doc.querySelector("script")?.textContent ?? "")(gsap);
+ return win.__timelines
+ .main!.getChildren()
+ .map((tween) => [(tween.targets()[0] as Element).id, tween.startTime()] as [string, number])
+ .sort(([a], [b]) => a.localeCompare(b));
+}
+
+/** The declarations the film's styles give the rules for `selector`. */
+function rulesFor(html: string, selector: string): string[] {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(
+ new DOMParser().parseFromString(html, "text/html").querySelector("style")!.textContent!,
+ );
+ return Array.from(sheet.cssRules)
+ .filter(
+ (rule): rule is CSSStyleRule =>
+ rule instanceof CSSStyleRule && rule.selectorText === selector,
+ )
+ .map((rule) => rule.style.cssText);
+}
+
+describe("a pasted clip takes its original's look and motion", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("copies the id's rules and tweens for the renamed copy, moved to where it lands", () => {
+ const { content } = pasteTimelineClips(FILM, [goodbye], 4, [], true);
+
+ expect(content).toContain('id="goodbye-2"');
+ expect(rulesFor(content, "#goodbye-2")).toEqual(rulesFor(FILM, "#goodbye"));
+ expect(tweenStarts(content)).toEqual([
+ ["goodbye", 1],
+ ["goodbye-2", 4],
+ ["title", 1.5],
+ ]);
+ // The original's own motion is untouched: the copy's tween does not push the title's.
+ expect(tweenStarts(FILM)).toEqual([
+ ["goodbye", 1],
+ ["title", 1.5],
+ ]);
+ });
+
+ it("never moves the film's own tweens, whatever their timing, and runs a timeline declared in a block", () => {
+ const film = FILM.replace(
+ / `,
+ );
+ const { content } = pasteTimelineClips(film, [goodbye], 4, [], true);
+ const own = (html: string) => tweenStarts(html).filter(([id]) => id !== "goodbye-2");
+ expect(own(content)).toEqual(own(film));
+ // Only the tween at a number is copied: its copy starts 3 s after it (1.2 s with its delay), like the clip.
+ expect(tweenStarts(content).filter(([id]) => id === "goodbye-2")).toEqual([["goodbye-2", 4.2]]);
+ });
+
+ it("copies only the selectors naming the original, and leaves a style inside a script alone", () => {
+ const styleInScript = `document.head.insertAdjacentHTML("beforeend", "");`;
+ const film = FILM.replace(
+ " #title { font-size: 72px; }",
+ " #title, #goodbye { letter-spacing: 2px; }\n #title { letter-spacing: 9px; }",
+ ).replace(" window.__timelines", ` ${styleInScript}\n window.__timelines`);
+ const { content } = pasteTimelineClips(film, [goodbye], 4, [], true);
+
+ expect(rulesFor(content, "#goodbye-2")).toEqual([
+ ...rulesFor(film, "#goodbye"),
+ "letter-spacing: 2px;",
+ ]);
+ expect(content.match(/#title/g)?.length).toBe(film.match(/#title/g)?.length);
+ expect(content).toContain(styleInScript);
+ });
+
+ it("copies nothing for a clip from another file, whose id means something else here", () => {
+ const { content } = pasteTimelineClips(FILM, [goodbye], 4, []);
+ expect(rulesFor(content, "#goodbye-2")).toEqual([]);
+ expect(tweenStarts(content).map(([id]) => id)).toEqual(["goodbye", "title"]);
+ });
+});
diff --git a/packages/studio/src/utils/clipboardLook.ts b/packages/studio/src/utils/clipboardLook.ts
new file mode 100644
index 0000000000..839fce11f9
--- /dev/null
+++ b/packages/studio/src/utils/clipboardLook.ts
@@ -0,0 +1,82 @@
+import { copyAnimationsInScript } from "@hyperframes/parsers/gsap-writer-acorn";
+import { ID_ATTR_RE } from "./clipboardPayload";
+import { escapeRegex } from "./sourcePatcher";
+
+const ID_ATTRS = new RegExp(ID_ATTR_RE.source, "g");
+// One pass over both, so a `