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:
Miguel Ángel
2026-09-30 12:58:02 +00:00
committed by GitHub
parent 14e87005d9
commit 7734697872
8 changed files with 456 additions and 13 deletions
@@ -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");
+11 -2
View File
@@ -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);
});
});
+97 -2
View File
@@ -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,
+25 -7
View File
@@ -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;