mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user