mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import {
|
|
type GeometryProjection,
|
|
type Polygon,
|
|
projectPolygon,
|
|
rectPolygon,
|
|
regionBounds,
|
|
type ViewportRect,
|
|
} from "../geometry";
|
|
import type { DocumentGeometry, DocumentIdentity } from "./facts";
|
|
|
|
export type VisualIssueReason =
|
|
| "ancestry-incomplete"
|
|
| "facts-unavailable"
|
|
| "geometry-unavailable"
|
|
| "geometry-unsupported"
|
|
| "identity-unverified"
|
|
| "ownership-unresolved";
|
|
|
|
/** Top viewport CSS coordinates. Unconstrained axes have infinite bounds. */
|
|
export interface VisualClip {
|
|
readonly left: number;
|
|
readonly top: number;
|
|
readonly right: number;
|
|
readonly bottom: number;
|
|
}
|
|
|
|
/** Shared persistent chain: one entry per clipping ancestor, not per Canvas. */
|
|
export interface VisualClipSource {
|
|
readonly document: DocumentIdentity;
|
|
readonly backendNodeId: number;
|
|
readonly x: boolean;
|
|
readonly y: boolean;
|
|
readonly overflowX: string;
|
|
readonly overflowY: string;
|
|
readonly box: ViewportRect;
|
|
readonly parent?: VisualClipSource;
|
|
}
|
|
|
|
export interface VisualContext {
|
|
readonly clip: VisualClip;
|
|
readonly clips?: VisualClipSource;
|
|
readonly hidden: boolean;
|
|
/** A local CSS scale/zoom makes snapshot client offsets ambiguous. */
|
|
readonly transformed: boolean;
|
|
readonly localClip?: boolean;
|
|
/** Overflow between an absolute target and its nearest positioned ancestor. */
|
|
readonly unpositionedClip?: boolean;
|
|
readonly issue?: VisualIssueReason;
|
|
}
|
|
|
|
export const EMPTY_VISUAL_CONTEXT: VisualContext = {
|
|
clip: { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity },
|
|
hidden: false,
|
|
transformed: false,
|
|
};
|
|
|
|
function axisRect(polygon: Polygon): boolean {
|
|
if (polygon.length !== 4 || polygon.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y)))
|
|
return false;
|
|
const [a, b, c, d] = polygon;
|
|
return a.y === b.y && b.x === c.x && c.y === d.y && d.x === a.x && b.x > a.x && d.y > a.y;
|
|
}
|
|
|
|
/** Check evidence before projectRectToViewport can reduce a polygon to its bounds. */
|
|
export function visualProjectionIssue(
|
|
geometry: DocumentGeometry | undefined,
|
|
): VisualIssueReason | undefined {
|
|
if (!geometry || !geometry.projections.length || geometry.pageScale === undefined)
|
|
return "geometry-unavailable";
|
|
if (!Number.isFinite(geometry.pageScale) || geometry.pageScale <= 0)
|
|
return "geometry-unavailable";
|
|
if (geometry.pageScale !== 1) return "geometry-unsupported";
|
|
const { layoutUnitsPerCssPixel, scrollCss } = geometry.coordinates;
|
|
if (
|
|
!Number.isFinite(layoutUnitsPerCssPixel) ||
|
|
layoutUnitsPerCssPixel <= 0 ||
|
|
![scrollCss.x, scrollCss.y].every(Number.isFinite)
|
|
)
|
|
return "geometry-unavailable";
|
|
for (const projection of geometry.projections) {
|
|
if (
|
|
![projection.topViewport.width, projection.topViewport.height].every(
|
|
(n) => Number.isFinite(n) && n > 0,
|
|
)
|
|
)
|
|
return "geometry-unavailable";
|
|
if (projection.sourceClips.some((clip) => !axisRect(clip))) return "geometry-unsupported";
|
|
for (const edge of projection.edges) {
|
|
if (
|
|
![edge.sourceViewport.width, edge.sourceViewport.height].every(
|
|
(n) => Number.isFinite(n) && n > 0,
|
|
)
|
|
)
|
|
return "geometry-unavailable";
|
|
if (!axisRect(edge.destinationQuad) || edge.destinationClips?.some((clip) => !axisRect(clip)))
|
|
return "geometry-unsupported";
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Uses the same projection math as DOM geometry, without clipping away box origins. */
|
|
export function projectVisualBox(
|
|
box: ViewportRect,
|
|
projections: readonly GeometryProjection[],
|
|
): ViewportRect | null {
|
|
let polygon: Polygon = rectPolygon({ x: box.x, y: box.y, w: box.width, h: box.height });
|
|
for (const projection of projections)
|
|
for (const edge of projection.edges) polygon = projectPolygon(polygon, edge);
|
|
// Preserve zero-size client boxes: they can prove a fully clipped axis.
|
|
if (box.width === 0 || box.height === 0) {
|
|
const [a, , c] = polygon;
|
|
return { x: a.x, y: a.y, width: c.x - a.x, height: c.y - a.y };
|
|
}
|
|
return regionBounds([polygon]);
|
|
}
|
|
|
|
function axisTransformScale(value: string): [number, number] | null {
|
|
if (value === "none") return [1, 1];
|
|
const match = /^matrix(3d)?\(([^)]+)\)$/.exec(value);
|
|
if (!match) return null;
|
|
const values = match[2].split(",").map((part) => Number(part.trim()));
|
|
if (!values.every(Number.isFinite)) return null;
|
|
if (match[1])
|
|
return values.length === 16 &&
|
|
values[0] > 0 &&
|
|
values[5] > 0 &&
|
|
values[10] === 1 &&
|
|
values[15] === 1 &&
|
|
[1, 2, 3, 4, 6, 7, 8, 9, 11, 14].every((index) => values[index] === 0)
|
|
? [values[0], values[5]]
|
|
: null;
|
|
return values.length === 6 && values[0] > 0 && values[3] > 0 && values[1] === 0 && values[2] === 0
|
|
? [values[0], values[3]]
|
|
: null;
|
|
}
|
|
|
|
export interface VisualAncestor {
|
|
readonly document: DocumentIdentity;
|
|
readonly backendNodeId: number;
|
|
readonly styles: Readonly<Record<string, string>>;
|
|
/** Already in top viewport CSS units, from a source-specific adapter. */
|
|
readonly clientBox?: ViewportRect | null;
|
|
}
|
|
|
|
/** One ancestor step, reusable by discovery and the future live local adapter. */
|
|
export function extendVisualContext(
|
|
parent: VisualContext,
|
|
node: VisualAncestor,
|
|
clipContents = true,
|
|
): VisualContext {
|
|
const styles = node.styles;
|
|
const opacity = styles.opacity?.trim() ? Number(styles.opacity) : NaN;
|
|
const zoom = styles.zoom === "normal" ? 1 : styles.zoom?.trim() ? Number(styles.zoom) : NaN;
|
|
const scale =
|
|
styles.scale === "none" ? [1] : (styles.scale ?? "").trim().split(/\s+/).map(Number);
|
|
const scaleSupported =
|
|
scale.length >= 1 &&
|
|
scale.length <= 3 &&
|
|
scale.every((n) => Number.isFinite(n) && n > 0) &&
|
|
(scale.length < 3 || scale[2] === 1);
|
|
const transformScale = axisTransformScale(styles.transform ?? "");
|
|
// Identity/translation changes origins, which bounds already capture, but
|
|
// leaves client offsets in the same units. Only scaling makes them ambiguous.
|
|
const transformed =
|
|
parent.transformed ||
|
|
transformScale?.some((n) => n !== 1) === true ||
|
|
zoom !== 1 ||
|
|
scale.some((n) => n !== 1);
|
|
const hidden = parent.hidden || opacity === 0 || styles.display === "none";
|
|
let issue = parent.issue;
|
|
if (
|
|
!Number.isFinite(opacity) ||
|
|
!Number.isFinite(zoom) ||
|
|
zoom <= 0 ||
|
|
!styles.display ||
|
|
!styles.visibility
|
|
)
|
|
issue ??= "facts-unavailable";
|
|
if (!styles.transform || !styles["clip-path"] || !styles["mask-image"])
|
|
issue ??= "facts-unavailable";
|
|
else if (!transformScale || styles["clip-path"] !== "none" || styles["mask-image"] !== "none")
|
|
issue ??= "geometry-unsupported";
|
|
|
|
if (
|
|
!["rotate", "scale", "perspective", "clip", "contain", "overflow-clip-margin"].every(
|
|
(key) => styles[key],
|
|
)
|
|
)
|
|
issue ??= "facts-unavailable";
|
|
else if (
|
|
!scaleSupported ||
|
|
!["none", "0deg"].includes(styles.rotate) ||
|
|
styles.perspective !== "none" ||
|
|
styles.clip !== "auto" ||
|
|
/(?:^|\s)(paint|strict|content)(?:$|\s)/.test(styles.contain)
|
|
)
|
|
issue ??= "geometry-unsupported";
|
|
// Do not pretend every DOM ancestor clips out-of-flow descendants. Support
|
|
// the ordinary positioned-container case; other containing-block cases stay explicit.
|
|
if (
|
|
(styles.position === "absolute" && parent.unpositionedClip) ||
|
|
(styles.position === "fixed" && parent.localClip)
|
|
)
|
|
issue ??= "geometry-unsupported";
|
|
const overflow = [styles["overflow-x"], styles["overflow-y"]];
|
|
if (clipContents && overflow.includes("clip") && styles["overflow-clip-margin"] !== "0px")
|
|
issue ??= "geometry-unsupported";
|
|
if (
|
|
overflow.some(
|
|
(value) => !["visible", "hidden", "clip", "scroll", "auto", "overlay"].includes(value),
|
|
)
|
|
)
|
|
issue ??= "facts-unavailable";
|
|
const [x, y] = overflow.map((value) => value !== "visible");
|
|
let clip = parent.clip;
|
|
let clips = parent.clips;
|
|
if (clipContents && (x || y)) {
|
|
if (transformed) issue ??= "geometry-unsupported";
|
|
const box = node.clientBox;
|
|
if (
|
|
!box ||
|
|
![box.x, box.y, box.width, box.height].every(Number.isFinite) ||
|
|
box.width < 0 ||
|
|
box.height < 0
|
|
)
|
|
issue ??= "geometry-unavailable";
|
|
else {
|
|
clip = {
|
|
left: x ? Math.max(clip.left, box.x) : clip.left,
|
|
right: x ? Math.min(clip.right, box.x + box.width) : clip.right,
|
|
top: y ? Math.max(clip.top, box.y) : clip.top,
|
|
bottom: y ? Math.min(clip.bottom, box.y + box.height) : clip.bottom,
|
|
};
|
|
clips = {
|
|
document: node.document,
|
|
backendNodeId: node.backendNodeId,
|
|
x,
|
|
y,
|
|
overflowX: styles["overflow-x"],
|
|
overflowY: styles["overflow-y"],
|
|
box,
|
|
parent: clips,
|
|
};
|
|
}
|
|
}
|
|
const clipsHere = clipContents && (x || y);
|
|
return {
|
|
hidden,
|
|
transformed,
|
|
issue,
|
|
clip,
|
|
clips,
|
|
localClip: parent.localClip || clipsHere,
|
|
unpositionedClip:
|
|
styles.position !== "static" ||
|
|
styles.transform !== "none" ||
|
|
/(?:^|\s)layout(?:$|\s)/.test(styles.contain ?? "")
|
|
? false
|
|
: parent.unpositionedClip || clipsHere,
|
|
};
|
|
}
|
|
|
|
export type VisualRegionResult =
|
|
| { status: "available"; borderBox: ViewportRect; crop: ViewportRect; clips?: VisualClipSource }
|
|
| { status: "empty" }
|
|
| { status: "unavailable"; reason: VisualIssueReason };
|
|
|
|
/** Box and frameVisibleBox must come from the same box kind (Canvas border-box).
|
|
* Frame/viewport clipping is supplied by the shared geometry adapter. */
|
|
export function resolveVisualRegion(input: {
|
|
borderBox: ViewportRect | null;
|
|
frameVisibleBox: ViewportRect | null;
|
|
context: VisualContext;
|
|
visibility: string;
|
|
}): VisualRegionResult {
|
|
const { borderBox, frameVisibleBox, context, visibility } = input;
|
|
if (context.hidden || visibility === "hidden" || visibility === "collapse")
|
|
return { status: "empty" };
|
|
if (context.issue) return { status: "unavailable", reason: context.issue };
|
|
if (visibility !== "visible") return { status: "unavailable", reason: "facts-unavailable" };
|
|
if (!borderBox) return { status: "unavailable", reason: "geometry-unavailable" };
|
|
if (!frameVisibleBox) return { status: "empty" };
|
|
if (
|
|
![
|
|
borderBox.x,
|
|
borderBox.y,
|
|
borderBox.width,
|
|
borderBox.height,
|
|
frameVisibleBox.x,
|
|
frameVisibleBox.y,
|
|
frameVisibleBox.width,
|
|
frameVisibleBox.height,
|
|
].every(Number.isFinite)
|
|
)
|
|
return { status: "unavailable", reason: "geometry-unavailable" };
|
|
if (
|
|
borderBox.width <= 0 ||
|
|
borderBox.height <= 0 ||
|
|
frameVisibleBox.width < 0 ||
|
|
frameVisibleBox.height < 0 ||
|
|
Object.values(context.clip).some(Number.isNaN)
|
|
)
|
|
return { status: "unavailable", reason: "geometry-unavailable" };
|
|
const x = Math.max(frameVisibleBox.x, context.clip.left);
|
|
const y = Math.max(frameVisibleBox.y, context.clip.top);
|
|
const right = Math.min(frameVisibleBox.x + frameVisibleBox.width, context.clip.right);
|
|
const bottom = Math.min(frameVisibleBox.y + frameVisibleBox.height, context.clip.bottom);
|
|
return right > x && bottom > y
|
|
? {
|
|
status: "available",
|
|
borderBox,
|
|
crop: { x, y, width: right - x, height: bottom - y },
|
|
clips: context.clips,
|
|
}
|
|
: { status: "empty" };
|
|
}
|