From 02adcc524346ddb03bb59c1cbeb37176bfd0e0d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 02:26:12 -0400 Subject: [PATCH 1/6] fix(studio): a copied clip keeps its look and motion, and a duplicated section keeps its name --- packages/core/src/runtime/timeline.test.ts | 13 +++ packages/core/src/runtime/timeline.ts | 14 ++- packages/parsers/src/gsapWriter.acorn.test.ts | 29 ++++++ packages/parsers/src/gsapWriterAcorn.ts | 57 ++++++++++++ packages/studio/src/hooks/useClipboard.ts | 19 +++- .../studio/src/utils/clipboardLook.test.ts | 89 +++++++++++++++++++ packages/studio/src/utils/clipboardLook.ts | 54 +++++++++++ 7 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 packages/studio/src/utils/clipboardLook.test.ts create mode 100644 packages/studio/src/utils/clipboardLook.ts 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..f26e69a1f3 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -209,6 +209,13 @@ function humanizeTimelineToken(value: string): string { return normalized.replace(/\b\w/g, (char) => char.toUpperCase()); } +// A second instance of a scene is renamed at load (`intro__hf2`); its name is still the authored id. +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 +224,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 +686,10 @@ export function collectRuntimeTimelinePayload(params: { if (clampedDuration <= 0) continue; scenes.push({ id: compositionId, - label: compositionNode.getAttribute("data-label") ?? compositionId, + label: + compositionNode.getAttribute("data-label") ?? + compositionNode.getAttribute("data-hf-original-composition-id") ?? + 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..d77f1df28e 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,31 @@ 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 copy of a tween comes after every top-level one. + expect(result.replace(/.*goodbye-2.*\n?/g, "")).toBe(script); + expect(result).toMatch(/function pop.*\ntl\.from\("#goodbye-2"/); + }); + + 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..0339642c9c 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -468,6 +468,63 @@ export function shiftPositionsInScript( return changed ? ms.toString() : script; } +/** + * Add a copy of every tween targeting `fromSelector`, retargeted to `toSelector` and moved by `delta` + * seconds, so a pasted clip moves like its original. Each copy keeps the original's own argument text. + */ +export function copyAnimationsInScript( + script: string, + fromSelector: string, + toSelector: string, + delta: number, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = JSON.stringify(toSelector); + const sets: string[] = []; + const tweens: string[] = []; + for (const { animation, call } of parsed.located) { + if (animation.targetSelector !== fromSelector) continue; + // ponytail: a tween inside a function or loop reads names only in scope there; it is not copied. + if (call.ancestors.some(isFunctionOrLoop)) continue; + const args = call.node.arguments + .slice(1) + .filter((arg: Node) => arg !== call.positionArg) + .map((arg: Node) => script.slice(arg.start, arg.end)); + if (call.global) { + sets.push(`gsap.set(${[target, ...args].join(", ")});`); + } else if (typeof animation.position === "number") { + const position = Math.max(0, Math.round((animation.position + delta) * 1000) / 1000); + const code = [target, ...args, valueToCode(position)].join(", "); + tweens.push(`${parsed.timelineVar}.${call.method}(${code});`); + } + } + if (sets.length === 0 && tweens.length === 0) return script; + const indentAt = (at: number) => + /^[ \t]*/.exec(script.slice(script.lastIndexOf("\n", at - 1) + 1))![0]; + const ms = new MagicString(script); + const setsAt = findGlobalSetInsertionPoint(parsed, script); + if (sets.length > 0 && setsAt !== null) { + ms.appendLeft(setsAt, sets.map((set) => `${indentAt(setsAt)}${set}\n`).join("")); + } + if (tweens.length > 0) { + // After every top-level statement that adds a tween, so no tween placed after the timeline's end moves. + const last = lastTweenStatement(parsed); + ms.appendLeft(last.end, tweens.map((tween) => `\n${indentAt(last.start)}${tween}`).join("")); + } + return ms.hasChanged() ? ms.toString() : script; +} + +function lastTweenStatement(parsed: ParsedGsapAcornForWrite): Node { + return parsed.located + .map(({ call }) => call.ancestors[1]) + .reduce((last, statement) => (statement.end > last.end ? statement : last)); +} + +function isFunctionOrLoop(node: Node): boolean { + return /Function|^For|^While|^DoWhile/.test(node?.type ?? "") || isForEachStatement(node); +} + /** * 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..dd74b03eef 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"; @@ -168,12 +169,14 @@ export function pasteElementHtml( /** 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. */ + * caller can grow the root composition's duration to cover it. `fromThisFile` + * says the clips were copied 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 +212,10 @@ 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 authoredStart = Number(rootTag.match(/data-start="([^"]*)"/)?.[1] ?? clip.start); + result = carryLook(result, renamedIds(reminted, deduped), newStart - authoredStart); + } const id = patchedRootTag.match(ID_ATTR_RE)?.[1]; if (id) ids.push(id); @@ -364,7 +371,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, + ); 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 +446,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..5f309efd48 --- /dev/null +++ b/packages/studio/src/utils/clipboardLook.test.ts @@ -0,0 +1,89 @@ +// @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("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..4a4b0b9c59 --- /dev/null +++ b/packages/studio/src/utils/clipboardLook.ts @@ -0,0 +1,54 @@ +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"); +const STYLE_BLOCK = /(]*>)([\s\S]*?)(<\/style>)/gi; +const INLINE_SCRIPT = /(]*\bsrc=)[^>]*>)([\s\S]*?)(<\/script>)/gi; + +/** Each id a paste renamed (`goodbye` to `goodbye-2`), from the same markup before and after the rename. */ +export function renamedIds(before: string, after: string): Map { + const renamed = Array.from(after.matchAll(ID_ATTRS), (match) => match[1] as string); + const renames = new Map(); + Array.from(before.matchAll(ID_ATTRS), (match, index) => { + const to = renamed[index]; + if (to && to !== match[1]) renames.set(match[1] as string, to); + }); + return renames; +} + +/** Gives each renamed copy the look and motion its original has in `html`: a copy of every CSS rule keyed to + * the original's id, and of every tween on it, moved by `delta` seconds. */ +export function carryLook( + html: string, + renames: ReadonlyMap, + delta: number, +): string { + let result = html; + for (const [from, to] of renames) { + result = result + .replace(STYLE_BLOCK, (_, open, css, close) => open + withCopiedRules(css, from, to) + close) + .replace( + INLINE_SCRIPT, + (_, open, script, close) => + open + copyAnimationsInScript(script, `#${from}`, `#${to}`, delta) + close, + ); + } + return result; +} + +// ponytail: top-level rules only; a rule inside @media or @supports is not copied. +function withCopiedRules(css: string, from: string, to: string): string { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(css); + const id = new RegExp(`#${escapeRegex(from)}(?![\\w-])`, "g"); + const indent = css.match(/\n([ \t]*)\S/)?.[1] ?? ""; + const copies = Array.from(sheet.cssRules).flatMap((rule) => { + if (!(rule instanceof CSSStyleRule) || !rule.selectorText.match(id)) return []; + const selector = rule.selectorText.replace(id, `#${to}`); + return [`\n${indent}${selector}${rule.cssText.slice(rule.selectorText.length)}`]; + }); + if (copies.length === 0) return css; + const body = css.trimEnd(); + return body + copies.join("") + css.slice(body.length); +} From 9dcd46a87657cdfb10c10eb5e14c0360d49b937c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 03:40:33 -0400 Subject: [PATCH 2/6] fix(studio): a copy's tweens start where their originals do and stay in the timeline's scope --- packages/core/src/runtime/timeline.ts | 2 +- packages/parsers/src/gsapWriter.acorn.test.ts | 34 ++++++++- packages/parsers/src/gsapWriterAcorn.ts | 74 +++++++++++-------- packages/studio/src/hooks/useClipboard.ts | 13 +++- .../studio/src/utils/clipboardLook.test.ts | 36 +++++++++ packages/studio/src/utils/clipboardLook.ts | 58 +++++++++++---- packages/studio/src/utils/clipboardPayload.ts | 11 ++- 7 files changed, 173 insertions(+), 55 deletions(-) diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index f26e69a1f3..21772b6948 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -688,7 +688,7 @@ export function collectRuntimeTimelinePayload(params: { id: compositionId, label: compositionNode.getAttribute("data-label") ?? - compositionNode.getAttribute("data-hf-original-composition-id") ?? + authoredCompositionId(compositionNode) ?? compositionId, start, duration: clampedDuration, diff --git a/packages/parsers/src/gsapWriter.acorn.test.ts b/packages/parsers/src/gsapWriter.acorn.test.ts index d77f1df28e..17f5e3d9ac 100644 --- a/packages/parsers/src/gsapWriter.acorn.test.ts +++ b/packages/parsers/src/gsapWriter.acorn.test.ts @@ -465,9 +465,39 @@ window.__timelines["t"] = tl;`; (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 copy of a tween comes after every top-level one. + // The original lines are left as they were; the tween's copy follows the block's last tween. expect(result.replace(/.*goodbye-2.*\n?/g, "")).toBe(script); - expect(result).toMatch(/function pop.*\ntl\.from\("#goodbye-2"/); + expect(result).toMatch(/tl\.to\("#title".*\);\ntl\.from\("#goodbye-2"/); + }); + + it("starts a tween written without a position at its own start, moved", () => { + const sequenced = `var tl = gsap.timeline(); +tl.to("#title", { x: 1, duration: 2 }); +tl.from("#goodbye", { opacity: 0 });`; + expect(copyAnimationsInScript(sequenced, "#goodbye", "#goodbye-2", 3)).toContain( + `tl.from("#goodbye-2", { opacity: 0 }, 5);`, + ); + }); + + 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( + ` tl.from("#goodbye", { opacity: 0 }, 1);\n tl.from("#goodbye-2", { opacity: 0 }, 4);\n window`, + ); + }); + + 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", () => { diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 0339642c9c..fd60c64a4c 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -461,8 +461,7 @@ 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; @@ -480,49 +479,60 @@ export function copyAnimationsInScript( ): string { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; + // A tween in a loop runs once per pass, and one on a variable reads a name bound elsewhere: neither is copied. + const copyable = parsed.located.filter( + ({ call }) => + call.node.arguments[0]?.type === "Literal" && !call.ancestors.some(isLoopOrForEach), + ); + const timed = copyable.filter(({ call }) => !call.global); + if (timed.length === 0) return script; + // Copies go after the last tween of its block, where the timeline and what the tweens read are in scope, + // and a tween placed after the timeline's end moves nothing that follows it. + const anchor = timed.reduce((last, entry) => + statementOf(entry.call).end > statementOf(last.call).end ? entry : last, + ); + const block = blockOf(anchor.call); + const indentAt = (at: number) => + /^[ \t]*/.exec(script.slice(script.lastIndexOf("\n", at - 1) + 1))![0]; const target = JSON.stringify(toSelector); - const sets: string[] = []; + const ms = new MagicString(script); const tweens: string[] = []; - for (const { animation, call } of parsed.located) { - if (animation.targetSelector !== fromSelector) continue; - // ponytail: a tween inside a function or loop reads names only in scope there; it is not copied. - if (call.ancestors.some(isFunctionOrLoop)) continue; + for (const { animation, call } of copyable) { + if (animation.targetSelector !== fromSelector || blockOf(call) !== block) continue; const args = call.node.arguments .slice(1) .filter((arg: Node) => arg !== call.positionArg) .map((arg: Node) => script.slice(arg.start, arg.end)); + const statement = statementOf(call); if (call.global) { - sets.push(`gsap.set(${[target, ...args].join(", ")});`); - } else if (typeof animation.position === "number") { - const position = Math.max(0, Math.round((animation.position + delta) * 1000) / 1000); - const code = [target, ...args, valueToCode(position)].join(", "); - tweens.push(`${parsed.timelineVar}.${call.method}(${code});`); + const set = `gsap.set(${[target, ...args].join(", ")});`; + ms.appendLeft(statement.end, `\n${indentAt(statement.start)}${set}`); + } else if (typeof animation.resolvedStart === "number") { + const position = valueToCode(shiftedPosition(animation.resolvedStart, delta)); + tweens.push( + `${parsed.timelineVar}.${call.method}(${[target, ...args, position].join(", ")});`, + ); } } - if (sets.length === 0 && tweens.length === 0) return script; - const indentAt = (at: number) => - /^[ \t]*/.exec(script.slice(script.lastIndexOf("\n", at - 1) + 1))![0]; - const ms = new MagicString(script); - const setsAt = findGlobalSetInsertionPoint(parsed, script); - if (sets.length > 0 && setsAt !== null) { - ms.appendLeft(setsAt, sets.map((set) => `${indentAt(setsAt)}${set}\n`).join("")); - } - if (tweens.length > 0) { - // After every top-level statement that adds a tween, so no tween placed after the timeline's end moves. - const last = lastTweenStatement(parsed); - ms.appendLeft(last.end, tweens.map((tween) => `\n${indentAt(last.start)}${tween}`).join("")); - } - return ms.hasChanged() ? ms.toString() : script; + const at = statementOf(anchor.call); + ms.appendLeft(at.end, tweens.map((tween) => `\n${indentAt(at.start)}${tween}`).join("")); + return ms.toString(); +} + +function shiftedPosition(position: number, delta: number): number { + return Math.max(0, Math.round((position + delta) * 1000) / 1000); +} + +function statementOf(call: TweenCallInfo): Node { + return findEnclosingExpressionStatement(call.ancestors) ?? call.node; } -function lastTweenStatement(parsed: ParsedGsapAcornForWrite): Node { - return parsed.located - .map(({ call }) => call.ancestors[1]) - .reduce((last, statement) => (statement.end > last.end ? statement : last)); +function blockOf(call: TweenCallInfo): Node { + return call.ancestors[call.ancestors.indexOf(statementOf(call)) - 1]; } -function isFunctionOrLoop(node: Node): boolean { - return /Function|^For|^While|^DoWhile/.test(node?.type ?? "") || isForEachStatement(node); +function isLoopOrForEach(node: Node): boolean { + return isLoopNode(node) || isForEachStatement(node) || node?.type === "DoWhileStatement"; } /** diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts index dd74b03eef..aecd57adf4 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -213,7 +213,9 @@ export function pasteTimelineClips( const withPatched = patchedRootTag + deduped.slice(rootTagEnd + 1); result = insertTimelineAssetIntoSource(result, withPatched); if (fromThisFile) { - const authoredStart = Number(rootTag.match(/data-start="([^"]*)"/)?.[1] ?? clip.start); + // A start written as a reference ("title + 1") has no number here; the clip's own start stands in. + const authored = Number(rootTag.match(/data-start="([^"]*)"/)?.[1]); + const authoredStart = Number.isFinite(authored) ? authored : clip.start; result = carryLook(result, renamedIds(reminted, deduped), newStart - authoredStart); } @@ -301,7 +303,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]); @@ -376,7 +383,7 @@ export function useClipboard({ payload.clips, currentTime, elements, - payload.sourceFile === targetPath, + payload.sourceFile === targetPath && payload.projectId === pid, ); pastedIds = pasted.ids; // A clip pasted past the current composition end would exist in the diff --git a/packages/studio/src/utils/clipboardLook.test.ts b/packages/studio/src/utils/clipboardLook.test.ts index 5f309efd48..fd5f3d18df 100644 --- a/packages/studio/src/utils/clipboardLook.test.ts +++ b/packages/studio/src/utils/clipboardLook.test.ts @@ -81,6 +81,42 @@ describe("a pasted clip takes its original's look and motion", () => { ]); }); + it("moves a sequenced tween's copy from its own start, and runs a timeline declared in a block", () => { + const film = FILM.replace( + / `, + ); + const { content } = pasteTimelineClips(film, [goodbye], 4, [], true); + expect(tweenStarts(content)).toEqual([ + ["goodbye", 1], + ["goodbye-2", 4], + ["title", 0], + ]); + }); + + 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([]); diff --git a/packages/studio/src/utils/clipboardLook.ts b/packages/studio/src/utils/clipboardLook.ts index 4a4b0b9c59..9e43f572fb 100644 --- a/packages/studio/src/utils/clipboardLook.ts +++ b/packages/studio/src/utils/clipboardLook.ts @@ -3,16 +3,18 @@ import { ID_ATTR_RE } from "./clipboardPayload"; import { escapeRegex } from "./sourcePatcher"; const ID_ATTRS = new RegExp(ID_ATTR_RE.source, "g"); -const STYLE_BLOCK = /(]*>)([\s\S]*?)(<\/style>)/gi; -const INLINE_SCRIPT = /(]*\bsrc=)[^>]*>)([\s\S]*?)(<\/script>)/gi; +// One pass over both, so a `