mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(studio): a copied clip keeps its look and motion, and a duplicated section keeps its name (#4756)
* fix(studio): a copied clip keeps its look and motion, and a duplicated section keeps its name * fix(studio): a copy's tweens start where their originals do and stay in the timeline's scope * fix(studio): a copy takes only tweens it can place exactly, and never moves the film's own * fix(studio): a copy leaves out a tween that sits inside a callback or a guard * fix(studio): a copy leaves out a set that runs only inside an if or an else * chore(studio): keep the copy change under the comment ratchet
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 =
|
||||
'<h2 id="goodbye" class="clip" data-start="1" data-duration="3" data-track-index="3">Goodbye</h2>';
|
||||
const FILM = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
#goodbye {
|
||||
color: #f97316;
|
||||
font-size: 96px;
|
||||
}
|
||||
#title { font-size: 72px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="10">
|
||||
<h1 id="title" class="clip" data-start="0" data-duration="10" data-track-index="0">Title</h1>
|
||||
${GOODBYE}
|
||||
</div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from("#goodbye", { opacity: 0, y: 40, duration: 0.5 }, 1);
|
||||
tl.to("#title", { opacity: 0.5, duration: 2 });
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
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<string, gsap.core.Timeline> };
|
||||
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(
|
||||
/ <script>[\s\S]*<\/script>/,
|
||||
` <script>
|
||||
if (gsap) {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const title = "#title";
|
||||
tl.from("#goodbye", { opacity: 0, duration: 0.5, stagger: 0.1, delay: 0.2 }, 1);
|
||||
tl.to(title, { opacity: 0.5, duration: 1 });
|
||||
tl.addLabel("late", "+=0.5");
|
||||
tl.to("#goodbye", { y: 10, duration: 0.5 }, "late");
|
||||
for (const id of ["#title"]) tl.to(id, { x: 5, duration: 0.5 });
|
||||
tl.to("#goodbye", { scale: 1.1, duration: 0.5 });
|
||||
tl.to(title, { y: 5, duration: 0.5 });
|
||||
window.__timelines["main"] = tl;
|
||||
}
|
||||
</script>`,
|
||||
);
|
||||
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", "<style>#goodbye{color:red}</style>");`;
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 `<style>` written inside a script's text is left as script.
|
||||
const BLOCKS =
|
||||
/(<script\b(?![^>]*\bsrc=)[^>]*>)([\s\S]*?)(<\/script>)|(<style\b[^>]*>)([\s\S]*?)(<\/style>)/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<string, string> {
|
||||
const ids = (html: string) => Array.from(html.matchAll(ID_ATTRS), (match) => match[1] as string);
|
||||
const renamed = ids(after);
|
||||
const renames = new Map<string, string>();
|
||||
ids(before).forEach((from, index) => {
|
||||
const to = renamed[index];
|
||||
if (to && to !== from) renames.set(from, 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<string, string>,
|
||||
delta: number,
|
||||
): string {
|
||||
let result = html;
|
||||
for (const [from, to] of renames) {
|
||||
result = result.replace(
|
||||
BLOCKS,
|
||||
(block, scriptOpen, script, scriptClose, styleOpen, css, styleClose) =>
|
||||
scriptOpen !== undefined
|
||||
? scriptOpen + copyAnimationsInScript(script, `#${from}`, `#${to}`, delta) + scriptClose
|
||||
: styleOpen !== undefined
|
||||
? styleOpen + withCopiedRules(css, from, to) + styleClose
|
||||
: block,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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 excluded = new RegExp(`:not\\([^)]*#${escapeRegex(from)}(?![\\w-])`);
|
||||
const indent = css.match(/\n([ \t]*)\S/)?.[1] ?? "";
|
||||
const copies = Array.from(sheet.cssRules).flatMap((rule) => {
|
||||
if (!(rule instanceof CSSStyleRule)) return [];
|
||||
// Only the selectors naming the original, so the copy restyles nothing else.
|
||||
const selectors = selectorList(rule.selectorText).filter(
|
||||
(selector) => selector.match(id) && !excluded.test(selector),
|
||||
);
|
||||
if (selectors.length === 0) return [];
|
||||
const copied = selectors.map((selector) => selector.replace(id, `#${to}`)).join(", ");
|
||||
return [`\n${indent}${copied}${rule.cssText.slice(rule.selectorText.length)}`];
|
||||
});
|
||||
if (copies.length === 0) return css;
|
||||
const body = css.trimEnd();
|
||||
return body + copies.join("") + css.slice(body.length);
|
||||
}
|
||||
|
||||
/** A selector list's own entries: its commas outside brackets and parentheses. */
|
||||
function selectorList(text: string): string[] {
|
||||
const selectors: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
if (char === "(" || char === "[") depth++;
|
||||
else if (char === ")" || char === "]") depth--;
|
||||
else if (char === "," && depth === 0) {
|
||||
selectors.push(text.slice(start, index).trim());
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
selectors.push(text.slice(start).trim());
|
||||
return selectors;
|
||||
}
|
||||
@@ -12,7 +12,12 @@ export interface TimelineClipboardClip {
|
||||
}
|
||||
|
||||
export type ClipboardPayload =
|
||||
| { kind: "timeline-clip"; clips: TimelineClipboardClip[]; sourceFile: string }
|
||||
| {
|
||||
kind: "timeline-clip";
|
||||
clips: TimelineClipboardClip[];
|
||||
sourceFile: string;
|
||||
projectId?: string;
|
||||
}
|
||||
| {
|
||||
kind: "dom-element";
|
||||
html: string;
|
||||
@@ -56,7 +61,8 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
|
||||
);
|
||||
});
|
||||
if (clips.length === 0) return null;
|
||||
return { kind: "timeline-clip", clips, sourceFile: obj.sourceFile };
|
||||
const projectId = typeof obj.projectId === "string" ? obj.projectId : undefined;
|
||||
return { kind: "timeline-clip", clips, sourceFile: obj.sourceFile, projectId };
|
||||
}
|
||||
if (obj.kind === "dom-element") {
|
||||
if (typeof obj.html !== "string") return null;
|
||||
|
||||
Reference in New Issue
Block a user