mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(core): faint SVG grain is not a click target, and a page reset is not an element's CSS (#5628)
* fix(core): a faint SVG grain is not the click target, and a page reset is not an element's CSS A filter rect under an svg at opacity 0.07 was still picked. The veil check required that element's own opacity and a background-image. A `*` rule matched every element, so a rect was given the page reset as its CSS. fails without the fix: clicking the grain selects the rect, and inspection attaches `*`. * fix(core): keep picker comments within the source ratchet * fix(core): preserve selector literals and SVG veil boundaries
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { authoredCssSelector, installInspection } from "./inspection";
|
||||
|
||||
describe("authoredCssSelector", () => {
|
||||
it.each([
|
||||
'[data-label="a,b"]',
|
||||
"[data-label='a b']",
|
||||
String.raw`[data-label="a\",b"]`,
|
||||
String.raw`.a\,b`,
|
||||
String.raw`[data-label=a\,b]`,
|
||||
':is([data-label="a,b"], .card)',
|
||||
".card/* , * */",
|
||||
])("preserves literal content when removing resets from %s", (selector) => {
|
||||
expect(authoredCssSelector(selector)).toBe(selector);
|
||||
expect(authoredCssSelector(`*, ${selector}, body *`)).toBe(selector);
|
||||
});
|
||||
|
||||
it("drops page resets and keeps a rule that also names the element", () => {
|
||||
expect(authoredCssSelector("*")).toBeNull();
|
||||
expect(authoredCssSelector("html, body")).toBeNull();
|
||||
expect(authoredCssSelector("html *")).toBeNull();
|
||||
expect(authoredCssSelector("rect, *")).toBe("rect");
|
||||
expect(authoredCssSelector(".Card")).toBe(".Card");
|
||||
});
|
||||
});
|
||||
|
||||
describe("installInspection CSS matching", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
delete (window as { __hfInspection?: unknown }).__hfInspection;
|
||||
});
|
||||
|
||||
it.each(["a,b", "a b"])("retains authored CSS for literal %s", (value) => {
|
||||
const target = document.createElement("div");
|
||||
target.id = "target";
|
||||
target.dataset.label = value;
|
||||
document.body.append(target);
|
||||
const selector = `[data-label="${value}"]`;
|
||||
expect(target.matches(selector)).toBe(true);
|
||||
const api = installInspection(window, "gen");
|
||||
try {
|
||||
const [result] = api.query({
|
||||
targets: [{ selector: "#target" }],
|
||||
descendants: false,
|
||||
cssRules: [
|
||||
{ id: "plain", selector },
|
||||
{ id: "mixed", selector: `${selector}, *` },
|
||||
{ id: "unrelated", selector: '[data-label="other"], *' },
|
||||
],
|
||||
}).results;
|
||||
expect(result?.css).toEqual(["plain", "mixed"]);
|
||||
} finally {
|
||||
api.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not attach a universal reset to a rect", () => {
|
||||
document.body.innerHTML = '<svg id="world-grain"><rect id="grain"></rect></svg>';
|
||||
const api = installInspection(window, "gen");
|
||||
const [result] = api.query({
|
||||
targets: [{ selector: "rect" }],
|
||||
descendants: false,
|
||||
cssRules: [
|
||||
{ id: "reset", selector: "*" },
|
||||
{ id: "page", selector: "html, body" },
|
||||
{ id: "desc", selector: "body *" },
|
||||
{ id: "own", selector: "rect" },
|
||||
{ id: "shared", selector: "rect, *" },
|
||||
],
|
||||
}).results;
|
||||
|
||||
expect(result?.status).toBe("found");
|
||||
expect(result?.css).toEqual(["own", "shared"]);
|
||||
api.dispose();
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,50 @@ const reserved = new Set([
|
||||
"defaults",
|
||||
]);
|
||||
|
||||
const PAGE_RESET = new Set(["*", "html", "body", ":root", "html *", "body *", ":root *", "* *"]);
|
||||
|
||||
function selectorAlternatives(selector: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let quote: string | undefined;
|
||||
let start = 0;
|
||||
for (let i = 0; i < selector.length; i += 1) {
|
||||
const ch = selector[i];
|
||||
if (ch === "\\") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === quote) quote = undefined;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "/" && selector[i + 1] === "*") {
|
||||
const end = selector.indexOf("*/", i + 2);
|
||||
i = end < 0 ? selector.length : end + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(" || ch === "[") depth += 1;
|
||||
else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
|
||||
else if (ch === "," && depth === 0) {
|
||||
parts.push(selector.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(selector.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function authoredCssSelector(selector: string): string | null {
|
||||
const specific = selectorAlternatives(selector)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part && !PAGE_RESET.has(part.replace(/\s+/g, " ").toLowerCase()));
|
||||
return specific.length > 0 ? specific.join(", ") : null;
|
||||
}
|
||||
|
||||
export function installInspection(win: InspectorWindow, generation: string, limit = 20_000) {
|
||||
const records = new WeakMap<Animation, string>();
|
||||
const attachments = new WeakMap<Animation, string>();
|
||||
@@ -262,8 +306,10 @@ export function installInspection(win: InspectorWindow, generation: string, limi
|
||||
});
|
||||
const css = q.cssRules
|
||||
.filter((rule) => {
|
||||
const selector = authoredCssSelector(rule.selector);
|
||||
if (!selector) return false;
|
||||
try {
|
||||
return scope.some((el) => el.matches(rule.selector));
|
||||
return scope.some((el) => el.matches(selector));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -301,6 +301,36 @@ describe("createPickerModule", () => {
|
||||
expect(candidatesUnder(() => [el("photo"), el("wash"), el("scene")])).toContain("#photo");
|
||||
});
|
||||
|
||||
it("picks the picture under a faint SVG filter grain", () => {
|
||||
const picker = createPickerModule({ postMessage: createMockPostMessage() });
|
||||
picker.installPickerApi();
|
||||
document.head.innerHTML = "<style>#world-grain { opacity: 0.07 }</style>";
|
||||
document.body.innerHTML =
|
||||
'<div id="scene"><img id="photo" alt=""><svg id="world-grain"><filter id="world-noise"><feTurbulence /></filter><rect id="grain-rect" filter="url(#world-noise)" /></svg></div>';
|
||||
const el = (id: string) => document.getElementById(id)!;
|
||||
expect(
|
||||
candidatesUnder(() => [el("grain-rect"), el("world-grain"), el("photo"), el("scene")]),
|
||||
).toEqual(["#photo", "#scene"]);
|
||||
});
|
||||
|
||||
it("keeps faint HTML custom elements whose names begin with fe pickable", () => {
|
||||
const picker = createPickerModule({ postMessage: createMockPostMessage() });
|
||||
picker.installPickerApi();
|
||||
document.body.innerHTML =
|
||||
'<div id="scene"><feed-card id="card" style="opacity:0.07"></feed-card></div>';
|
||||
const el = (id: string) => document.getElementById(id)!;
|
||||
expect(candidatesUnder(() => [el("card"), el("scene")])).toEqual(["#card", "#scene"]);
|
||||
});
|
||||
|
||||
it("still picks a filtered rect in an opaque SVG", () => {
|
||||
const picker = createPickerModule({ postMessage: createMockPostMessage() });
|
||||
picker.installPickerApi();
|
||||
document.body.innerHTML =
|
||||
'<svg id="chart"><filter id="glow"><feGaussianBlur /></filter><rect id="bar" filter="url(#glow)" /></svg>';
|
||||
const el = (id: string) => document.getElementById(id)!;
|
||||
expect(candidatesUnder(() => [el("bar"), el("chart")])).toEqual(["#bar", "#chart"]);
|
||||
});
|
||||
|
||||
it("does not pick a faint texture veil that covers the frame", () => {
|
||||
const picker = createPickerModule({ postMessage: createMockPostMessage() });
|
||||
picker.installPickerApi();
|
||||
|
||||
@@ -130,10 +130,51 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
function isDecorativeVeil(el: HTMLElement, win: Window): boolean {
|
||||
const style = win.getComputedStyle(el);
|
||||
const opacity = Number.parseFloat(style.opacity || "1");
|
||||
if (!Number.isFinite(opacity) || opacity >= 0.2) return false;
|
||||
const faint = Number.isFinite(opacity) && opacity < 0.2;
|
||||
if (faint && !(el.textContent ?? "").trim()) {
|
||||
const image = style.backgroundImage;
|
||||
if (image && image !== "none") return true;
|
||||
}
|
||||
return faintFilterPaint(el, win);
|
||||
}
|
||||
|
||||
function isFilterDefinition(el: Element): boolean {
|
||||
if (el.namespaceURI !== "http://www.w3.org/2000/svg") return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === "filter" || tag === "defs" || tag.startsWith("fe");
|
||||
}
|
||||
|
||||
function isFilterRect(el: Element): boolean {
|
||||
if (el.namespaceURI !== "http://www.w3.org/2000/svg") return false;
|
||||
if (el.tagName.toLowerCase() !== "rect") return false;
|
||||
const attr = (el.getAttribute("filter") ?? "").trim();
|
||||
if (attr && attr !== "none") return true;
|
||||
const painted = el.ownerDocument.defaultView?.getComputedStyle(el).filter ?? "none";
|
||||
return painted.startsWith("url(");
|
||||
}
|
||||
|
||||
function isOnlyFilterPaint(el: Element): boolean {
|
||||
if ((el.textContent ?? "").trim()) return false;
|
||||
const image = style.backgroundImage;
|
||||
return Boolean(image && image !== "none");
|
||||
if (isFilterDefinition(el) || isFilterRect(el)) return true;
|
||||
const children = [...el.children];
|
||||
return children.length > 0 && children.every((child) => isOnlyFilterPaint(child));
|
||||
}
|
||||
|
||||
function isFaint(el: Element, win: Window): boolean {
|
||||
const opacity = Number.parseFloat(win.getComputedStyle(el).opacity || "1");
|
||||
return Number.isFinite(opacity) && opacity < 0.2;
|
||||
}
|
||||
|
||||
function faintFilterPaint(el: Element, win: Window): boolean {
|
||||
if (!isOnlyFilterPaint(el)) return false;
|
||||
for (
|
||||
let current: Element | null = el;
|
||||
current && current !== document.body && current !== document.documentElement;
|
||||
current = current.parentElement
|
||||
) {
|
||||
if (isFaint(current, win) && isOnlyFilterPaint(current)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isEffectivelyHidden(el: HTMLElement): boolean {
|
||||
|
||||
Reference in New Issue
Block a user