mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(studio): make canvas selection hit intended elements (#1907)
* test(studio): add design-panel QA fixture and triage matrix Fixture project covering all panel-editable element archetypes, plus the QA findings matrix from the design-panel bug campaign. * fix(studio): make canvas selection hit intended elements - honor author pointer-events:none in hit-testing (was selecting invisible overlays) - pause playback before mousedown sampling; fall back to hover selection on null resolve - invalidate committed selection when the active composition changes - double-click keeps selection and defers to multi-candidate click cycling * fix(studio): close remaining selection-layer review findings - hoverSelection fallback now wired at all 3 mousedown call sites (box-click, blocked-drag, plain overlay click) instead of just the overlay path - pointer-events override detection reads computed style, not inline style, so a CSS-class opt-in (not just inline style=) on a descendant is honored - defensively remove the pointer-events override before the group-fallback check too, closing a theoretical gap in the no-elementsFromPoint branch - a click that resolves to nothing (dead-zone / deselect) no longer leaves playback paused if it was already playing
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
isElementComputedVisible,
|
||||
resolveAllVisualDomEditTargets,
|
||||
} from "../components/editor/domEditingElement";
|
||||
import { isHtmlElement } from "../components/editor/domEditingDom";
|
||||
import { getEventTargetElement } from "./studioHelpers";
|
||||
|
||||
interface PreviewLocalPointer {
|
||||
@@ -81,11 +82,90 @@ function removePointerEventsOverride(style: HTMLStyleElement | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
const pointerEventsInheritanceFallbackByDocument = new WeakMap<Document, boolean>();
|
||||
|
||||
function needsPointerEventsInheritanceFallback(doc: Document, win: Window): boolean {
|
||||
const cached = pointerEventsInheritanceFallbackByDocument.get(doc);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const parent = doc.createElement("div");
|
||||
const child = doc.createElement("div");
|
||||
parent.style.pointerEvents = "none";
|
||||
parent.appendChild(child);
|
||||
const host = doc.body ?? doc.documentElement;
|
||||
if (!host) return false;
|
||||
|
||||
host.appendChild(parent);
|
||||
const needsFallback = win.getComputedStyle(child).pointerEvents !== "none";
|
||||
parent.remove();
|
||||
pointerEventsInheritanceFallbackByDocument.set(doc, needsFallback);
|
||||
return needsFallback;
|
||||
}
|
||||
|
||||
// Own declared pointer-events value, via computed style rather than inline
|
||||
// style, so a CSS-class opt-in/opt-out (not just an inline style attribute)
|
||||
// is honored when walking back down from a pointer-events:none ancestor.
|
||||
function hasOwnPointerEventsOverride(el: HTMLElement, win: Window): boolean {
|
||||
const value = win.getComputedStyle(el).pointerEvents;
|
||||
return value !== "" && value !== "inherit" && value !== "unset";
|
||||
}
|
||||
|
||||
function inheritsPointerEventsNoneFromAncestor(el: HTMLElement, win: Window): boolean {
|
||||
let current = el.parentElement;
|
||||
while (current) {
|
||||
if (win.getComputedStyle(current).pointerEvents === "none") {
|
||||
let descendant: HTMLElement | null = el;
|
||||
while (descendant && descendant !== current) {
|
||||
if (hasOwnPointerEventsOverride(descendant, win)) {
|
||||
return win.getComputedStyle(descendant).pointerEvents === "none";
|
||||
}
|
||||
descendant = descendant.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAuthorPointerEventsNone(el: HTMLElement): boolean {
|
||||
const win = el.ownerDocument.defaultView;
|
||||
if (!win) return false;
|
||||
if (win.getComputedStyle(el).pointerEvents === "none") return true;
|
||||
if (!needsPointerEventsInheritanceFallback(el.ownerDocument, win)) return false;
|
||||
return inheritsPointerEventsNoneFromAncestor(el, win);
|
||||
}
|
||||
|
||||
function collectPointerEventsNoneTargets(
|
||||
elements: Iterable<Element | null | undefined>,
|
||||
): WeakSet<HTMLElement> {
|
||||
const disabled = new WeakSet<HTMLElement>();
|
||||
for (const entry of elements) {
|
||||
if (isHtmlElement(entry) && hasAuthorPointerEventsNone(entry)) {
|
||||
disabled.add(entry);
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
}
|
||||
|
||||
// Shared tail of both pointer resolvers: hit-test candidates minus elements the
|
||||
// author hid from hit-testing via pointer-events:none.
|
||||
function filterAuthorInteractiveTargets(
|
||||
elements: Element[],
|
||||
activeCompositionPath: string | null,
|
||||
): HTMLElement[] {
|
||||
const pointerEventsNoneTargets = collectPointerEventsNoneTargets(elements);
|
||||
return resolveAllVisualDomEditTargets(elements, { activeCompositionPath }).filter(
|
||||
(el) => !pointerEventsNoneTargets.has(el),
|
||||
);
|
||||
}
|
||||
|
||||
// Animated group members can move outside their wrapper's static layout box, so
|
||||
// the empty space inside a group's *visual* bounds (the member-union the overlay
|
||||
// draws) doesn't hit-test to the group via elementsFromPoint. Recover it: if the
|
||||
// point falls within a group's live member-union rect, return that wrapper.
|
||||
// Innermost (smallest-area) group wins for nested groups.
|
||||
// fallow-ignore-next-line complexity
|
||||
function findGroupAtPoint(doc: Document, x: number, y: number): HTMLElement | null {
|
||||
let best: HTMLElement | null = null;
|
||||
let bestArea = Infinity;
|
||||
@@ -132,26 +212,39 @@ export function getPreviewTargetFromPointer(
|
||||
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
||||
if (!localPointer) return null;
|
||||
|
||||
const overrideStyle = forcePointerEventsAuto(doc);
|
||||
let overrideStyle = forcePointerEventsAuto(doc);
|
||||
try {
|
||||
if (typeof doc.elementsFromPoint === "function") {
|
||||
const candidates = resolveAllVisualDomEditTargets(
|
||||
doc.elementsFromPoint(localPointer.x, localPointer.y),
|
||||
{ activeCompositionPath },
|
||||
);
|
||||
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 && getDomLayerPatchTarget(groupHit, activeCompositionPath)) return groupHit;
|
||||
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;
|
||||
@@ -180,15 +273,21 @@ export function getAllPreviewTargetsFromPointer(
|
||||
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
||||
if (!localPointer) return [];
|
||||
|
||||
const overrideStyle = forcePointerEventsAuto(doc);
|
||||
let overrideStyle = forcePointerEventsAuto(doc);
|
||||
try {
|
||||
if (typeof doc.elementsFromPoint === "function") {
|
||||
return resolveAllVisualDomEditTargets(doc.elementsFromPoint(localPointer.x, localPointer.y), {
|
||||
activeCompositionPath,
|
||||
}).filter((el) => !isFullBleedTarget(el, localPointer.viewport));
|
||||
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];
|
||||
|
||||
Reference in New Issue
Block a user