perf(studio): clicking an element in the preview selects it without restyling the whole film (#4707)

* perf(studio): clicking an element in the preview selects it without restyling the whole film

Every hit test added a `* { pointer-events: auto !important }` style element to the
preview document and removed it again. Each insert and removal restyled every element,
and the head mutation made the off-canvas indicators rebuild their layer walk from
scratch. The override only let pointer-events:none elements into the hit list, and the
author pointer-events filter removed them again, so the hit test now uses the browser's
own result: the candidates are what Chrome itself hit-tests.

* docs(studio): the double-press hit test comment no longer claims it lifts pointer suppression
This commit is contained in:
Miguel Ángel
2026-09-29 02:04:09 +00:00
committed by GitHub
parent 9e1dbca7a2
commit 1bede4742a
3 changed files with 68 additions and 84 deletions
@@ -61,10 +61,8 @@ export function useInlineTextEditing(
const elementUnderPress = (event: { clientX: number; clientY: number }) => {
const iframe = actions?.previewIframeRef?.current;
if (!iframe) return null;
// Studio suppresses pointer events inside the composition so the canvas
// overlay can own input, which means a plain elementFromPoint only ever
// finds wrappers. This helper lifts that for the length of the hit test,
// and is the same one the canvas uses to decide what was clicked.
// The same hit test the canvas uses to decide what was clicked; a plain
// elementFromPoint only finds wrappers, since Studio suppresses pointer events.
return getPreviewTargetFromPointer(
iframe,
event.clientX,
@@ -3,6 +3,7 @@
import { describe, expect, it, vi } from "vitest";
import {
coversComposition,
getAllPreviewTargetsFromPointer,
getPreviewTargetFromPointer,
pauseStudioPreviewPlayback,
} from "./studioPreviewHelpers";
@@ -141,6 +142,34 @@ describe("getPreviewTargetFromPointer", () => {
iframe.remove();
});
it("hit-tests without adding styles to the preview document", () => {
const { iframe, doc } = createPreviewIframe();
doc.body.innerHTML = `<main data-composition-id="scene"><h1 id="headline">Title</h1></main>`;
const scene = doc.querySelector<HTMLElement>("main")!;
const headline = doc.getElementById("headline")!;
stubRect(iframe, domRect(0, 0, 400, 300));
stubRect(scene, domRect(0, 0, 400, 300));
stubRect(headline, domRect(40, 40, 160, 48));
const stylesAtHitTest: number[] = [];
doc.elementsFromPoint = () => {
stylesAtHitTest.push(doc.querySelectorAll("style").length + doc.adoptedStyleSheets.length);
return [headline, scene];
};
// First call runs the once-per-document inheritance probe, a body mutation.
expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
const observer = new MutationObserver(() => {});
observer.observe(doc, { subtree: true, childList: true, attributes: true });
expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
expect(getAllPreviewTargetsFromPointer(iframe, 80, 64, "index.html")).toEqual([headline]);
const mutations = observer.takeRecords();
observer.disconnect();
expect(stylesAtHitTest).toEqual([0, 0, 0]);
expect(mutations).toEqual([]);
iframe.remove();
});
it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
const { iframe, doc } = createPreviewIframe();
@@ -70,28 +70,6 @@ function resolvePreviewLocalPointer(
};
}
const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
try {
const style = doc.createElement("style");
style.id = POINTER_EVENTS_OVERRIDE_ID;
style.textContent = "* { pointer-events: auto !important; }";
doc.head.appendChild(style);
return style;
} catch {
return null;
}
}
function removePointerEventsOverride(style: HTMLStyleElement | null): void {
try {
style?.remove();
} catch {
// cross-origin or detached doc
}
}
const pointerEventsInheritanceFallbackByDocument = new WeakMap<Document, boolean>();
function needsPointerEventsInheritanceFallback(doc: Document, win: Window): boolean {
@@ -222,45 +200,33 @@ export function getPreviewTargetFromPointer(
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
if (!localPointer) return null;
let overrideStyle = forcePointerEventsAuto(doc);
try {
if (typeof doc.elementsFromPoint === "function") {
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
removePointerEventsOverride(overrideStyle);
overrideStyle = null;
const candidates = filterAuthorInteractiveTargets(elements, activeCompositionPath);
const visualTarget =
candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null;
if (visualTarget) return visualTarget;
}
// Belt-and-suspenders: elementsFromPoint is universally supported in the
// browsers this ships in, so the override is already removed by this
// point in practice — but guard the environment without it too, so
// hasAuthorPointerEventsNone below never reads a forced-auto value.
removePointerEventsOverride(overrideStyle);
overrideStyle = null;
// No element hit (e.g. empty space inside an animated group's overlay) — fall
// back to the group whose member-union contains the point, so the whole group
// area is hoverable/selectable, not just where a member currently sits.
const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
if (
groupHit &&
!hasAuthorPointerEventsNone(groupHit) &&
getDomLayerPatchTarget(groupHit, activeCompositionPath)
)
return groupHit;
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
if (hasAuthorPointerEventsNone(fallback)) return null;
if (!isElementComputedVisible(fallback)) return null;
if (isFullBleedTarget(fallback, localPointer.viewport)) return null;
return fallback;
} finally {
removePointerEventsOverride(overrideStyle);
// Hit-test without touching the preview document: any style added here, even
// briefly, restyles every element in the film on each click and hover.
if (typeof doc.elementsFromPoint === "function") {
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
const candidates = filterAuthorInteractiveTargets(elements, activeCompositionPath);
const visualTarget =
candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null;
if (visualTarget) return visualTarget;
}
// No element hit (e.g. empty space inside an animated group's overlay) — fall
// back to the group whose member-union contains the point, so the whole group
// area is hoverable/selectable, not just where a member currently sits.
const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
if (
groupHit &&
!hasAuthorPointerEventsNone(groupHit) &&
getDomLayerPatchTarget(groupHit, activeCompositionPath)
)
return groupHit;
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
if (hasAuthorPointerEventsNone(fallback)) return null;
if (!isElementComputedVisible(fallback)) return null;
if (isFullBleedTarget(fallback, localPointer.viewport)) return null;
return fallback;
}
/** Returns all independently-selectable elements at the pointer (topmost first). */
@@ -283,27 +249,18 @@ export function getAllPreviewTargetsFromPointer(
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
if (!localPointer) return [];
let overrideStyle = forcePointerEventsAuto(doc);
try {
if (typeof doc.elementsFromPoint === "function") {
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
removePointerEventsOverride(overrideStyle);
overrideStyle = null;
return filterAuthorInteractiveTargets(elements, activeCompositionPath).filter(
(el) => !isFullBleedTarget(el, localPointer.viewport),
);
}
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
removePointerEventsOverride(overrideStyle);
overrideStyle = null;
if (hasAuthorPointerEventsNone(fallback)) return [];
if (!isElementComputedVisible(fallback)) return [];
if (isFullBleedTarget(fallback, localPointer.viewport)) return [];
return [fallback];
} finally {
removePointerEventsOverride(overrideStyle);
if (typeof doc.elementsFromPoint === "function") {
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
return filterAuthorInteractiveTargets(elements, activeCompositionPath).filter(
(el) => !isFullBleedTarget(el, localPointer.viewport),
);
}
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
if (hasAuthorPointerEventsNone(fallback)) return [];
if (!isElementComputedVisible(fallback)) return [];
if (isFullBleedTarget(fallback, localPointer.viewport)) return [];
return [fallback];
}
function objectLike(value: unknown): object | null {