diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index 8618b4a7f3..1b26d9856b 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -45,7 +45,44 @@ export function parseSourceDocument(raw: string): { return { document, wrappedFragment: !hasDocumentShell }; } +/** Drops the rules that style `id` alone (every selector in the list names it), in each style sheet of the page. */ +function dropCssRulesForId(document: Document, id: string): void { + const idToken = `#${id}`; + const namesId = (selector: string) => { + let found = false; + selectorParser((selectors) => { + selectors.walkIds((node) => { + // an id inside :not() excludes the element, so the rule styles the others + let up = node.parent; + while (up && !(up.type === "pseudo" && up.value === ":not")) up = up.parent as typeof up; + if (node.value === id && !up) found = true; + }); + }).processSync(selector); + return found; + }; + for (const styleEl of document.querySelectorAll("style")) { + const css = styleEl.textContent ?? ""; + if (!css.includes(idToken)) continue; + let root: postcss.Root; + try { + root = postcss.parse(css); + } catch { + continue; + } + let dropped = false; + root.walkRules((rule) => { + if (rule.selectors.length > 0 && rule.selectors.every(namesId)) { + rule.remove(); + dropped = true; + } + }); + if (dropped) styleEl.textContent = root.toString(); + } +} + +// A clone's rules replace whatever an earlier piece of that id left behind, so repeated cuts keep one set per id. function duplicateCssRulesForId(document: Document, originalId: string, newId: string): void { + dropCssRulesForId(document, newId); const idToken = `#${originalId}`; const transform = selectorParser((selectors) => { selectors.walkIds((node) => { @@ -161,7 +198,9 @@ export function removeElementsFromHtml( for (const target of targets) { const element = findTargetElement(document, target); if (!element) continue; + const id = element.getAttribute("id"); removeElementWithGsapCascade(document, element); + if (id && !document.getElementById(id)) dropCssRulesForId(document, id); removed = true; } if (!removed) return source; diff --git a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts index cdf9141284..8f8c589ac5 100644 --- a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts +++ b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts @@ -3,6 +3,7 @@ import { parseHTML } from "linkedom"; import { describe, expect, it } from "vitest"; import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration"; import { + removeElementFromHtml, splitElementInHtml, relinkSplitHalvesInHtml, unwrapElementsFromHtml, @@ -539,3 +540,23 @@ describe("relinkSplitHalvesInHtml", () => { expect(relinkSplitHalvesInHtml(source, ["i"])).toBe(source); }); }); + +describe("splitElementInHtml — id CSS rules across repeated cuts", () => { + it("keeps one rule set per live id when a clip is cut again and again", () => { + let html = + "
" + + ''; + for (let i = 0; i < 5; i++) { + const tail = splitElementInHtml(html, { id: "a" }, 20 + i, `a-${i + 2}`); + const middle = splitElementInHtml(tail.html, { id: "a" }, 10 + i, "a-cut"); + html = removeElementFromHtml(middle.html, { id: "a-cut" }); + } + const css = /