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:
Miguel Ángel
2026-07-03 17:41:01 -07:00
committed by GitHub
parent 4d199c0f3a
commit 02e9d6142d
11 changed files with 831 additions and 52 deletions
+109 -10
View File
@@ -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];