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 `