fix(core): a clip gets its own inline display back when the runtime shows it (#4498)

* fix(core): a clip gets its own inline display back when the runtime shows it

The visibility pass wrote a plain display:none over a hidden clip's inline display and removed
the property when showing it, so an author's display:flex came back as block and an author's
display:none !important showed the clip. The data-hidden path kept the value but not its
priority. Both paths now save the author's value and priority once and put exactly that back.

* fix(core): a plain inline display:none still shows at the clip's start

A plain none on a hidden clip can be a runtime hide left behind (a Studio reveal restoring it),
so it is not saved as the author's value; the clip shows at its start as before. Values with a
priority, and every other value, are still put back.

* fix(core): keep an author's plain display:none through a data-hidden toggle

The leftover-hide rule applies to the timed hide only; a data-hidden hide saves the author's
plain none as it is, so un-hiding keeps the clip hidden, as on main.

* docs(core): say the leftover rule belongs to the timed hide
This commit is contained in:
Miguel Ángel
2026-09-25 20:18:51 +00:00
committed by GitHub
parent 3746c9821a
commit 02147b8d15
2 changed files with 95 additions and 24 deletions
+69
View File
@@ -1771,6 +1771,75 @@ describe("initSandboxRuntimeModular", () => {
expect(hiddenClip.style.display).toBe("");
});
describe("a clip the visibility pass hides gets the author's inline display back", () => {
// A relative clip under a plain wrapper stays in flow (applyClipLayout only touches
// root children), so the pass hides it with display:none rather than visibility.
const mountClip = (display: string, priority = "", start = "2") => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const wrapper = document.createElement("div");
root.appendChild(wrapper);
const clip = document.createElement("div");
clip.style.position = "relative";
clip.style.setProperty("display", display, priority);
clip.setAttribute("data-start", start);
clip.setAttribute("data-duration", "4");
wrapper.appendChild(clip);
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
return clip;
};
it("keeps the author's display:flex when a later clip appears", () => {
const clip = mountClip("flex");
window.__player?.seek(0);
expect(clip.style.display).toBe("none");
window.__player?.seek(3);
expect(clip.style.display).toBe("flex");
});
it("keeps an author's display:none !important once the clip's time comes", () => {
const clip = mountClip("none", "important");
window.__player?.seek(0);
window.__player?.seek(3);
expect(clip.style.getPropertyValue("display")).toBe("none");
expect(clip.style.getPropertyPriority("display")).toBe("important");
});
it("shows a clip whose inline display is a plain none at its start", () => {
const clip = mountClip("none");
window.__player?.seek(0);
expect(clip.style.display).toBe("none");
window.__player?.seek(3);
expect(clip.style.display).toBe("");
});
it("keeps an author's plain display:none through a data-hidden toggle", () => {
const clip = mountClip("none", "", "0");
window.__player?.seek(1);
clip.setAttribute("data-hidden", "");
window.__player?.seek(1);
clip.removeAttribute("data-hidden");
window.__player?.seek(1);
expect(clip.style.display).toBe("none");
});
it("keeps a data-hidden clip's own display, priority included, when the attribute goes", () => {
const clip = mountClip("grid", "important");
clip.setAttribute("data-hidden", "");
window.__player?.seek(3);
expect(clip.style.display).toBe("none");
clip.removeAttribute("data-hidden");
window.__player?.seek(3);
expect(clip.style.getPropertyValue("display")).toBe("grid");
expect(clip.style.getPropertyPriority("display")).toBe("important");
});
});
it("excludes a data-hidden audio clip from Web Audio scheduling", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
+26 -24
View File
@@ -2390,15 +2390,28 @@ export function initSandboxRuntimeModular(): void {
timedClipIsLeaf = new WeakMap<Element, boolean>();
};
// Which elements carry a `display:none` the visibility pass itself applied, so
// the un-hide branch can undo exactly that instead of re-deriving
// `isTimedClipInFlow`. That derived answer can flip between the hide and the
// show pass for the SAME element — `applyClipLayout` force-absolutizes a
// root-level clip after an earlier pass already cached it as in-flow and hid
// it — and the corrected, no-longer-in-flow reading then skips the removal,
// stranding the clip hidden for the rest of the render.
const timedClipDisplayNoneApplied = new WeakSet<HTMLElement>();
const dataHiddenDisplayRestores = new WeakMap<HTMLElement, string>();
// The author's inline display (value and priority) under each `display:none` the
// visibility pass applied, so showing the element puts exactly that back. Keyed on
// what was applied, not on `isTimedClipInFlow`: that answer can flip between the hide
// and the show pass once `applyClipLayout` force-absolutizes the clip.
const displayBeforeHide = new WeakMap<HTMLElement, { value: string; priority: string }>();
const hideByDisplay = (el: HTMLElement, plainNoneMayBeLeftover: boolean) => {
if (!displayBeforeHide.has(el)) {
const value = el.style.getPropertyValue("display");
const priority = el.style.getPropertyPriority("display");
// On the timed hide, a plain none may be a hide left behind (a Studio reveal restoring ours).
const isLeftoverHide = plainNoneMayBeLeftover && value === "none" && !priority;
displayBeforeHide.set(el, isLeftoverHide ? { value: "", priority: "" } : { value, priority });
}
el.style.display = "none";
};
const restoreDisplay = (el: HTMLElement) => {
const before = displayBeforeHide.get(el);
if (!before) return;
displayBeforeHide.delete(el);
if (before.value) el.style.setProperty("display", before.value, before.priority);
else el.style.removeProperty("display");
};
const dataHiddenDisplayNodes = new WeakSet<HTMLElement>();
// A data-hidden toggle on (or affecting) an audio element must re-schedule
// WebAudio playback so the hidden clip's source is dropped/restored mid-
@@ -2455,12 +2468,11 @@ export function initSandboxRuntimeModular(): void {
if (rawNode.hasAttribute("data-hidden")) {
if (!dataHiddenDisplayNodes.has(rawNode)) {
dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display"));
dataHiddenDisplayNodes.add(rawNode);
if (nodeAffectsAudio(rawNode)) hiddenAudioDirty = true;
groupMuteDirty = true;
}
rawNode.style.display = "none";
hideByDisplay(rawNode, false);
if (isVideoElement(rawNode) || isImageElement(rawNode)) {
colorGradingRuntime?.setSourceVisibility(rawNode, false);
}
@@ -2468,13 +2480,7 @@ export function initSandboxRuntimeModular(): void {
}
if (dataHiddenDisplayNodes.has(rawNode)) {
const previousDisplay = dataHiddenDisplayRestores.get(rawNode);
if (previousDisplay) {
rawNode.style.display = previousDisplay;
} else {
rawNode.style.removeProperty("display");
}
dataHiddenDisplayRestores.delete(rawNode);
restoreDisplay(rawNode);
dataHiddenDisplayNodes.delete(rawNode);
if (nodeAffectsAudio(rawNode)) hiddenAudioDirty = true;
groupMuteDirty = true;
@@ -2519,13 +2525,9 @@ export function initSandboxRuntimeModular(): void {
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
}
if (isVisibleNow) {
if (timedClipDisplayNoneApplied.has(rawNode)) {
rawNode.style.removeProperty("display");
timedClipDisplayNoneApplied.delete(rawNode);
}
restoreDisplay(rawNode);
} else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) {
rawNode.style.display = "none";
timedClipDisplayNoneApplied.add(rawNode);
hideByDisplay(rawNode, true);
}
}
if (decidedTimedClip && revealTimedClipsAfterFirstPass()) colorGradingRuntime?.refresh();