fix(studio): a pasted element keeps the look its id gave the original

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-01 05:48:46 -07:00
parent d0404b450c
commit 4fee4552a4
3 changed files with 58 additions and 7 deletions
@@ -52,12 +52,16 @@ afterEach(() => {
// The preview page: the saved markup with the preview's in-memory ids, a sub-composition
// mounted inline, and the runtime's hide on both clips.
function mountPreview(sub: string, bundle: (host: Element) => void): HTMLIFrameElement {
function mountPreview(
sub: string,
bundle: (host: Element) => void,
saved = SAVED,
): HTMLIFrameElement {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const doc = iframe.contentDocument as Document;
doc.open();
doc.write(ensureHfIds(SAVED));
doc.write(ensureHfIds(saved));
doc.close();
const parsed = new DOMParser().parseFromString(ensureHfIds(sub), "text/html");
const subRoot = (parsed.querySelector("template") as HTMLTemplateElement).content
@@ -93,14 +97,15 @@ function mountClipboard(
domSelection: DomEditSelection | null = null,
sub = SUB,
bundle: (host: Element) => void = () => {},
saved = SAVED,
) {
const files: Record<string, string> = { "index.html": SAVED, "compositions/sub.html": sub };
const files: Record<string, string> = { "index.html": saved, "compositions/sub.html": sub };
const fail = { on: false };
const delayMs: Record<string, number> = {};
const domEditSave = { pending: Promise.resolve() };
const domSelectionRef = { current: domSelection };
stubFiles(files, fail, delayMs);
const iframe = mountPreview(sub, bundle);
const iframe = mountPreview(sub, bundle, saved);
const writes: string[] = [];
const deleted: string[] = [];
const writeProjectFile = async (_path: string, content: string) => {
@@ -248,6 +253,39 @@ const SUB_SELECTION = {
sourceFile: "compositions/sub.html",
} as DomEditSelection;
describe("paste of an element styled by its id", () => {
it("gives the copy the look its original has", async () => {
clearSelection();
const saved = SAVED.replace(
"<body>",
"<body><style>#badge { color: rgb(200, 30, 40); letter-spacing: 3px; }</style>",
).replace("</h1>", '</h1><p id="badge">New</p>');
const selection = {
hfId: stampedHfId(saved, "#badge"),
selector: "#badge",
selectorIndex: 0,
sourceFile: "index.html",
} as DomEditSelection;
const { clipboard, writes } = mountClipboard(selection, SUB, () => {}, saved);
clipboard().handleCopy();
await clipboard().handlePaste();
const page = document.createElement("iframe");
document.body.appendChild(page);
const doc = page.contentDocument as Document;
doc.open();
doc.write(writes[0] ?? "");
doc.close();
const look = (id: string) => {
const style = (page.contentWindow as Window).getComputedStyle(
doc.getElementById(id) as Element,
);
return { color: style.color, letterSpacing: style.letterSpacing };
};
expect(look("badge")).toEqual({ color: "rgb(200, 30, 40)", letterSpacing: "3px" });
expect(look("badge-2")).toEqual(look("badge"));
});
});
describe("copy of a sub-composition clip", () => {
it("rebases its relative asset paths to the project root, as the preview does", async () => {
clearSelection();
+14 -3
View File
@@ -157,13 +157,21 @@ const HF_ID_ATTR_RE = /\bdata-hf-id\s*=\s*["']?([^"'\s>]+)/gi;
const hfIdsInFile = (content: string) =>
new Set(Array.from(content.matchAll(HF_ID_ATTR_RE), (match) => match[1]));
/** `fromThisFile`: the element came from `content`, so a renamed copy takes its original's look, as a clip's does. */
export function pasteElementHtml(
content: string,
payload: { html: string; originSelector?: string; originSelectorIndex?: number },
fromThisFile = false,
): string {
const reminted = remintHfIds(payload.html, new DOMParser(), hfIdsInFile(content));
const deduped = deduplicateIds(reminted, collectHtmlIds(content));
return insertAsSibling(content, deduped, payload.originSelector, payload.originSelectorIndex);
const result = insertAsSibling(
content,
deduped,
payload.originSelector,
payload.originSelectorIndex,
);
return fromThisFile ? carryLook(result, renamedIds(reminted, deduped), 0) : result;
}
/** Shared insertion path for paste and duplicate, anchored at the playhead or the selection's end. Returns the
@@ -324,6 +332,7 @@ export function useClipboard({
kind: "dom-element",
html: savedMarkupElseLive(parseSavedSource(content), live, sourceFile),
sourceFile,
projectId: projectIdRef.current ?? undefined,
originSelector: domSelection.selector,
originSelectorIndex: domSelection.selectorIndex,
};
@@ -373,15 +382,17 @@ export function useClipboard({
const targetPath = activeCompPath || "index.html";
try {
let pastedIds: string[] = [];
const fromThisFile = payload.sourceFile === targetPath && payload.projectId === pid;
const paste = (originalContent: string) => {
if (payload.kind !== "timeline-clip") return pasteElementHtml(originalContent, payload);
if (payload.kind !== "timeline-clip")
return pasteElementHtml(originalContent, payload, fromThisFile);
const { currentTime, elements } = usePlayerStore.getState();
const pasted = pasteTimelineClips(
originalContent,
payload.clips,
currentTime,
elements,
payload.sourceFile === targetPath && payload.projectId === pid,
fromThisFile,
);
pastedIds = pasted.ids;
// A clip pasted past the current composition end would exist in the
@@ -22,6 +22,7 @@ export type ClipboardPayload =
kind: "dom-element";
html: string;
sourceFile: string;
projectId?: string;
originSelector?: string;
originSelectorIndex?: number;
};
@@ -70,6 +71,7 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
kind: "dom-element",
html: obj.html,
sourceFile: obj.sourceFile,
projectId: typeof obj.projectId === "string" ? obj.projectId : undefined,
originSelector: typeof obj.originSelector === "string" ? obj.originSelector : undefined,
originSelectorIndex:
typeof obj.originSelectorIndex === "number" ? obj.originSelectorIndex : undefined,