feat(overlay): teach geometry and frame ownership about nested documents
Groundwork for picking inside a nested same-origin iframe (#17): the coordinate module learns how a chain of nested iframes composes, and the frame manager learns that a window or node deeper than a frame's own document still belongs to that frame. space.ts gains frameChain/nestOffset/intersectRects and an optional pre-composed offset on frameToScreen/screenToFrame — translation-only, since each nested iframe's rect is already in its parent document's coordinates, so the canvas scale applies once at the outer boundary. An empty chain composes to zero and the single-level path is byte-identical. The chain hop prefers frameElement and falls back to scanning the parent document's iframes (happy-dom implements window.parent but not frameElement), capped at MAX_NEST_DEPTH everywhere. frames.ts: frameOf keeps its exact-document fast path and then walks the window chain; frameOfWindow is the exported form of that walk. A Frame now carries agents — every agent in its realm tree keyed by window — while frame.agent stays the root's for every existing reader; a root re-registration clears the map, because an HMR reload kills the nested realms with it. onFrameReady hands the shell the agent as well as the frame, so it can subscribe per realm. Depth-0 behaviour is unchanged throughout.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { FrameAgent } from "../frame-agent";
|
||||
import { type Frame, FrameManager, MAX_FRAMES } from "./frames";
|
||||
|
||||
/*
|
||||
@@ -296,3 +297,106 @@ describe("FrameManager.reorder", () => {
|
||||
expect(stacked()).toEqual(["f1", "f2", "f3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested-document ownership", () => {
|
||||
/** A real same-origin iframe inside a frame's document. */
|
||||
function nestInside(frame: Frame): HTMLIFrameElement {
|
||||
const { doc } = frame;
|
||||
if (!doc) {
|
||||
throw new Error("frame has no document");
|
||||
}
|
||||
const nested = doc.createElement("iframe");
|
||||
doc.body.append(nested);
|
||||
return nested;
|
||||
}
|
||||
|
||||
function readyHook(): (agent: FrameAgent) => void {
|
||||
const hook = (
|
||||
window as unknown as {
|
||||
__airshipOnFrameReady?: (agent: FrameAgent) => void;
|
||||
}
|
||||
).__airshipOnFrameReady;
|
||||
if (!hook) {
|
||||
throw new Error("no __airshipOnFrameReady installed");
|
||||
}
|
||||
return hook;
|
||||
}
|
||||
|
||||
it("frameOf resolves a node in a nested document to its frame", () => {
|
||||
const frame = add("one");
|
||||
const nested = nestInside(frame);
|
||||
const nestedDoc = nested.contentDocument as Document;
|
||||
const node = nestedDoc.createElement("div");
|
||||
nestedDoc.body.append(node);
|
||||
|
||||
expect(frames.frameOf(node)).toBe(frame);
|
||||
});
|
||||
|
||||
it("frameOfWindow walks a nested window up to its frame", () => {
|
||||
const frame = add("one");
|
||||
const nested = nestInside(frame);
|
||||
|
||||
expect(frames.frameOfWindow(nested.contentWindow)).toBe(frame);
|
||||
expect(frames.frameOfWindow(frame.win)).toBe(frame);
|
||||
expect(frames.frameOfWindow(window)).toBeNull();
|
||||
});
|
||||
|
||||
it("registers a nested agent without clobbering the frame's own", () => {
|
||||
const frame = add("one");
|
||||
const nested = nestInside(frame);
|
||||
const nestedWin = nested.contentWindow as Window;
|
||||
const rootAgent = { window: frame.win } as unknown as FrameAgent;
|
||||
const nestedAgent = { window: nestedWin } as unknown as FrameAgent;
|
||||
|
||||
readyHook()(rootAgent);
|
||||
readyHook()(nestedAgent);
|
||||
|
||||
expect(frame.agent).toBe(rootAgent);
|
||||
expect(frame.agents.get(frame.win as Window)).toBe(rootAgent);
|
||||
expect(frame.agents.get(nestedWin)).toBe(nestedAgent);
|
||||
});
|
||||
|
||||
it("drops nested agents when the root realm re-registers", () => {
|
||||
// An HMR full reload rebuilds the frame's realm; the nested realms died
|
||||
// with it, so their agents must not linger in the map.
|
||||
const frame = add("one");
|
||||
const nested = nestInside(frame);
|
||||
const nestedAgent = {
|
||||
window: nested.contentWindow,
|
||||
} as unknown as FrameAgent;
|
||||
const rebuilt = { window: frame.win } as unknown as FrameAgent;
|
||||
|
||||
readyHook()(nestedAgent);
|
||||
expect(frame.agents.size).toBe(1);
|
||||
|
||||
readyHook()(rebuilt);
|
||||
expect(frame.agent).toBe(rebuilt);
|
||||
expect(Array.from(frame.agents.values())).toEqual([rebuilt]);
|
||||
});
|
||||
|
||||
it("hands the shell the agent that registered, not just the frame", () => {
|
||||
const seen: { frame: Frame; agent: FrameAgent }[] = [];
|
||||
const local = new FrameManager({
|
||||
onFrameReady: (frame, agent) => seen.push({ agent, frame }),
|
||||
pathname: "/",
|
||||
storageKey: "__airship-test:frames-nested",
|
||||
world,
|
||||
});
|
||||
try {
|
||||
const frame = local.add({ name: "one" });
|
||||
if (!frame) {
|
||||
throw new Error("add refused");
|
||||
}
|
||||
const nested = nestInside(frame);
|
||||
const nestedAgent = {
|
||||
window: nested.contentWindow,
|
||||
} as unknown as FrameAgent;
|
||||
|
||||
readyHook()(nestedAgent);
|
||||
|
||||
expect(seen).toEqual([{ agent: nestedAgent, frame }]);
|
||||
} finally {
|
||||
local.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { AIRSHIP_FRAME_NAME, AIRSHIP_MODE_PARAM } from "@airship/protocol";
|
||||
import { cls, el } from "../dom";
|
||||
import type { FrameAgent } from "../frame-agent";
|
||||
import { frameScreenRect, type Point, type Rect } from "./space";
|
||||
import {
|
||||
frameScreenRect,
|
||||
MAX_NEST_DEPTH,
|
||||
type Point,
|
||||
parentFrameElement,
|
||||
type Rect,
|
||||
} from "./space";
|
||||
|
||||
/**
|
||||
* Frames — the canvas's unit of content, in the design-tool sense of the word: a named,
|
||||
@@ -228,6 +234,14 @@ export function framePreset(
|
||||
export interface Frame {
|
||||
/** Resolved once the frame's agent has registered itself. */
|
||||
agent: FrameAgent | null;
|
||||
/**
|
||||
* Every agent living inside this frame, keyed by its realm's window — the
|
||||
* root document's agent plus one per nested same-origin document (a
|
||||
* Storybook preview, say). `agent` stays the root's, for every existing
|
||||
* reader; the map is cleared whenever the root realm is rebuilt, because
|
||||
* the nested realms died with it.
|
||||
*/
|
||||
readonly agents: Map<Window, FrameAgent>;
|
||||
/** The frame's document, or null before it has loaded. */
|
||||
readonly doc: Document | null;
|
||||
/** The world-space wrapper. Its screen rect is the frame's screen rect. */
|
||||
@@ -284,8 +298,9 @@ export interface RemovedFrame {
|
||||
export interface FrameManagerDeps {
|
||||
/** Frames were added, removed, moved or resized. */
|
||||
onChanged?: () => void;
|
||||
/** A frame's agent registered (initial load, or an HMR full reload). */
|
||||
onFrameReady?: (frame: Frame) => void;
|
||||
/** An agent registered (initial load, or an HMR full reload) — the frame's
|
||||
* own, or one booted in a nested same-origin document of that frame. */
|
||||
onFrameReady?: (frame: Frame, agent: FrameAgent) => void;
|
||||
/** App path every frame loads, carried through from the shell's own URL. */
|
||||
pathname: string;
|
||||
/** Storage key, so two projects on different ports keep separate layouts. */
|
||||
@@ -337,12 +352,20 @@ export class FrameManager {
|
||||
* global — see there.
|
||||
*/
|
||||
private readonly onFrameReady = (agent: FrameAgent): void => {
|
||||
const frame = this.frames.find((f) => f.win === agent.window);
|
||||
// The chain walk, not an exact match: a nested same-origin document's
|
||||
// agent belongs to the frame it sits inside, however deep it sits.
|
||||
const frame = this.frameOfWindow(agent.window);
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
frame.agent = agent;
|
||||
this.deps.onFrameReady?.(frame);
|
||||
if (frame.win === agent.window) {
|
||||
// The root realm was (re)built; every nested realm died with it, so
|
||||
// their agents go too — a nested agent re-registers when it reboots.
|
||||
frame.agents.clear();
|
||||
frame.agent = agent;
|
||||
}
|
||||
frame.agents.set(agent.window, agent);
|
||||
this.deps.onFrameReady?.(frame, agent);
|
||||
};
|
||||
|
||||
constructor(deps: FrameManagerDeps) {
|
||||
@@ -391,7 +414,35 @@ export class FrameManager {
|
||||
return null;
|
||||
}
|
||||
const doc = node.ownerDocument ?? (node as Document);
|
||||
return this.frames.find((f) => f.doc === doc) ?? null;
|
||||
// The exact match first, so the common single-level path is unchanged.
|
||||
const exact = this.frames.find((f) => f.doc === doc);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
// A node in a *nested* same-origin document matches no frame document;
|
||||
// its frame is up the window chain.
|
||||
return this.frameOfWindow(doc.defaultView);
|
||||
}
|
||||
|
||||
/** The frame whose realm contains `win`, walking nested iframes upward. */
|
||||
frameOfWindow(win: Window | null): Frame | null {
|
||||
let current: Window | null = win;
|
||||
for (let depth = 0; depth <= MAX_NEST_DEPTH && current; depth += 1) {
|
||||
const target = current;
|
||||
const frame = this.frames.find((f) => f.win === target);
|
||||
if (frame) {
|
||||
return frame;
|
||||
}
|
||||
let frameEl: Element | null;
|
||||
try {
|
||||
frameEl = parentFrameElement(current);
|
||||
} catch {
|
||||
// Cross-origin boundary: nothing above here can be ours.
|
||||
return null;
|
||||
}
|
||||
current = frameEl?.ownerDocument.defaultView ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Topmost frame under a screen point — later frames paint over earlier ones. */
|
||||
@@ -850,6 +901,7 @@ export class FrameManager {
|
||||
destroy(): void {
|
||||
for (const frame of this.frames) {
|
||||
frame.el.remove();
|
||||
frame.agents.clear();
|
||||
}
|
||||
this.frames.length = 0;
|
||||
// The global goes too. It is a closure over `this`, hung off `window` by
|
||||
@@ -918,6 +970,7 @@ export class FrameManager {
|
||||
|
||||
const frame: Frame = {
|
||||
agent: null,
|
||||
agents: new Map(),
|
||||
get doc(): Document | null {
|
||||
return iframe.contentDocument;
|
||||
},
|
||||
|
||||
@@ -18,9 +18,15 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
centerAt,
|
||||
fitTo,
|
||||
frameChain,
|
||||
frameToScreen,
|
||||
intersectRects,
|
||||
MAX_NEST_DEPTH,
|
||||
MIN_SCALE,
|
||||
nestOffset,
|
||||
projectInto,
|
||||
type Rect,
|
||||
screenToFrame,
|
||||
screenToWorld,
|
||||
type Viewport,
|
||||
visibleWorldRect,
|
||||
@@ -156,3 +162,150 @@ describe("projectInto", () => {
|
||||
expect(projectInto(bounds, { height: 10, width: 10 }, 8).scale).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -- nested documents --------------------------------------------------------
|
||||
|
||||
/** Pin an element's screen rect — happy-dom does no layout. */
|
||||
function stubRect(el: Element, rect: Partial<Rect>): void {
|
||||
(el as HTMLElement).getBoundingClientRect = () =>
|
||||
({ height: 0, left: 0, top: 0, width: 0, ...rect }) as DOMRect;
|
||||
}
|
||||
|
||||
function stubBorder(el: Element, left: number, top: number): void {
|
||||
Object.defineProperty(el, "clientLeft", { value: left });
|
||||
Object.defineProperty(el, "clientTop", { value: top });
|
||||
}
|
||||
|
||||
/** A real same-origin iframe appended into `doc`, the text-edit.test idiom. */
|
||||
function nestedIframe(doc: Document): HTMLIFrameElement {
|
||||
const iframe = doc.createElement("iframe");
|
||||
doc.body.append(iframe);
|
||||
return iframe;
|
||||
}
|
||||
|
||||
describe("frameChain", () => {
|
||||
it("is empty when the window already is the root", () => {
|
||||
expect(frameChain(window, window)).toEqual([]);
|
||||
});
|
||||
|
||||
it("collects the iframes between a nested document and the root, innermost first", () => {
|
||||
const outer = nestedIframe(document);
|
||||
const outerDoc = outer.contentDocument as Document;
|
||||
const inner = nestedIframe(outerDoc);
|
||||
const innerWin = inner.contentWindow as Window;
|
||||
|
||||
expect(frameChain(innerWin, window)).toEqual([inner, outer]);
|
||||
outer.remove();
|
||||
});
|
||||
|
||||
it("returns null when the root is not an ancestor", () => {
|
||||
const stray = nestedIframe(document);
|
||||
const strayWin = stray.contentWindow as Window;
|
||||
const other = nestedIframe(document);
|
||||
const otherWin = other.contentWindow as Window;
|
||||
|
||||
expect(frameChain(strayWin, otherWin)).toBeNull();
|
||||
stray.remove();
|
||||
other.remove();
|
||||
});
|
||||
|
||||
it("gives up past the depth cap", () => {
|
||||
let doc = document;
|
||||
let iframe: HTMLIFrameElement | null = null;
|
||||
const top = nestedIframe(doc);
|
||||
doc = top.contentDocument as Document;
|
||||
for (let i = 0; i < MAX_NEST_DEPTH + 1; i += 1) {
|
||||
iframe = nestedIframe(doc);
|
||||
doc = iframe.contentDocument as Document;
|
||||
}
|
||||
|
||||
expect(frameChain(doc.defaultView as Window, window)).toBeNull();
|
||||
top.remove();
|
||||
});
|
||||
|
||||
it("treats a throwing hop as a cross-origin boundary", () => {
|
||||
const iframe = nestedIframe(document);
|
||||
const win = iframe.contentWindow as Window;
|
||||
Object.defineProperty(win, "frameElement", {
|
||||
get() {
|
||||
throw new DOMException("cross-origin");
|
||||
},
|
||||
});
|
||||
|
||||
expect(frameChain(win, window)).toBeNull();
|
||||
iframe.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nestOffset", () => {
|
||||
it("is zero for an empty chain — the single-level path unchanged", () => {
|
||||
expect(nestOffset([])).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("composes positions and borders across levels", () => {
|
||||
const outer = nestedIframe(document);
|
||||
const inner = nestedIframe(outer.contentDocument as Document);
|
||||
stubRect(outer, { left: 200, top: 100 });
|
||||
stubBorder(outer, 2, 2);
|
||||
stubRect(inner, { left: 30, top: 40 });
|
||||
stubBorder(inner, 1, 3);
|
||||
|
||||
// Innermost first, the way frameChain hands them over.
|
||||
expect(nestOffset([inner, outer])).toEqual({ x: 233, y: 145 });
|
||||
outer.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("intersectRects", () => {
|
||||
it("returns the overlap", () => {
|
||||
const a: Rect = { height: 100, left: 0, top: 0, width: 100 };
|
||||
const b: Rect = { height: 100, left: 60, top: 40, width: 100 };
|
||||
expect(intersectRects(a, b)).toEqual({
|
||||
height: 60,
|
||||
left: 60,
|
||||
top: 40,
|
||||
width: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses to zero size when they miss, never negative", () => {
|
||||
const a: Rect = { height: 10, left: 0, top: 0, width: 10 };
|
||||
const b: Rect = { height: 10, left: 50, top: 50, width: 10 };
|
||||
const out = intersectRects(a, b);
|
||||
expect(out.width).toBe(0);
|
||||
expect(out.height).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("frame⇄screen with a nested offset", () => {
|
||||
it("round-trips at a zoom that is not 1×", () => {
|
||||
// Deliberately not 1: a bug that adds the offset after the scale is
|
||||
// invisible at 1× and glaring at 2×.
|
||||
const frameEl = document.createElement("div");
|
||||
stubRect(frameEl, { left: 100, top: 50 });
|
||||
const offset = { x: 30, y: 20 };
|
||||
const rect: Rect = { height: 40, left: 10, top: 5, width: 60 };
|
||||
|
||||
const screen = frameToScreen(frameEl, rect, 2, offset);
|
||||
expect(screen).toEqual({ height: 80, left: 180, top: 100, width: 120 });
|
||||
|
||||
const back = screenToFrame(
|
||||
frameEl,
|
||||
{ x: screen.left, y: screen.top },
|
||||
2,
|
||||
offset
|
||||
);
|
||||
expect(back.x).toBeCloseTo(rect.left, 6);
|
||||
expect(back.y).toBeCloseTo(rect.top, 6);
|
||||
});
|
||||
|
||||
it("reduces to the plain conversion when the offset is absent", () => {
|
||||
const frameEl = document.createElement("div");
|
||||
stubRect(frameEl, { left: 100, top: 50 });
|
||||
const rect: Rect = { height: 40, left: 10, top: 5, width: 60 };
|
||||
|
||||
expect(frameToScreen(frameEl, rect, 2)).toEqual(
|
||||
frameToScreen(frameEl, rect, 2, { x: 0, y: 0 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,31 +122,147 @@ export function frameScreenRect(frameEl: Element): Rect {
|
||||
return { height: r.height, left: r.left, top: r.top, width: r.width };
|
||||
}
|
||||
|
||||
/** A rect measured inside a frame → the shell's screen space. */
|
||||
const ORIGIN: Point = { x: 0, y: 0 };
|
||||
|
||||
/**
|
||||
* A rect measured inside a frame → the shell's screen space. `offset` is the
|
||||
* pre-composed translation of a nested document's viewport within the frame
|
||||
* (`nestOffset`); it defaults to zero so the single-level path is unchanged.
|
||||
*/
|
||||
export function frameToScreen(
|
||||
frameEl: Element,
|
||||
rect: Rect,
|
||||
scale: number
|
||||
scale: number,
|
||||
offset: Point = ORIGIN
|
||||
): Rect {
|
||||
const o = frameScreenRect(frameEl);
|
||||
return {
|
||||
height: snap(rect.height * scale),
|
||||
left: snap(o.left + rect.left * scale),
|
||||
top: snap(o.top + rect.top * scale),
|
||||
left: snap(o.left + (rect.left + offset.x) * scale),
|
||||
top: snap(o.top + (rect.top + offset.y) * scale),
|
||||
width: snap(rect.width * scale),
|
||||
};
|
||||
}
|
||||
|
||||
/** A point in the shell's screen space → that frame's viewport coordinates. */
|
||||
/** A point in the shell's screen space → that frame's viewport coordinates —
|
||||
* or, given a `nestOffset`, a nested document's viewport coordinates. */
|
||||
export function screenToFrame(
|
||||
frameEl: Element,
|
||||
point: Point,
|
||||
scale: number
|
||||
scale: number,
|
||||
offset: Point = ORIGIN
|
||||
): Point {
|
||||
const o = frameScreenRect(frameEl);
|
||||
return {
|
||||
x: (point.x - o.left) / scale,
|
||||
y: (point.y - o.top) / scale,
|
||||
x: (point.x - o.left) / scale - offset.x,
|
||||
y: (point.y - o.top) / scale - offset.y,
|
||||
};
|
||||
}
|
||||
|
||||
// -- nested documents --------------------------------------------------------
|
||||
// A same-origin iframe inside a frame (Storybook's preview is the common case)
|
||||
// holds its own document, whose rects are in its own viewport. Composition is
|
||||
// translation-only: each iframe's getBoundingClientRect() is already in its
|
||||
// parent document's coordinates, so the canvas scale applies once, at the
|
||||
// outermost boundary, and every level below is a plain add — CSS-transformed
|
||||
// nested iframes are out of scope.
|
||||
|
||||
/** How deep a same-origin iframe chain the editor follows. Storybook needs
|
||||
* one level; four is generous and stops a self-embedding page from walking
|
||||
* forever. Shared by every walk, up (agents, ownership) and down (hit
|
||||
* testing). */
|
||||
export const MAX_NEST_DEPTH = 4;
|
||||
|
||||
/**
|
||||
* The iframe holding `win` in its parent document, or null at a top window
|
||||
* or a cross-origin boundary. `frameElement` is the direct answer in a real
|
||||
* browser; happy-dom leaves it `undefined`, so the tests reach the fallback,
|
||||
* which scans the parent document's iframes for our own window. The
|
||||
* browser-tier stories cover the direct path.
|
||||
*/
|
||||
export function parentFrameElement(win: Window): Element | null {
|
||||
const direct = win.frameElement;
|
||||
if (direct !== undefined) {
|
||||
return direct;
|
||||
}
|
||||
const { parent } = win;
|
||||
if (!parent || parent === win) {
|
||||
return null;
|
||||
}
|
||||
for (const iframe of Array.from(parent.document.querySelectorAll("iframe"))) {
|
||||
if ((iframe as HTMLIFrameElement).contentWindow === win) {
|
||||
return iframe;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The iframe elements between `win` and the ancestor `root`, innermost first.
|
||||
* Empty for `win === root`. `null` when `root` is not an ancestor, the chain
|
||||
* exceeds `MAX_NEST_DEPTH`, or a hop is cross-origin (property access
|
||||
* throws) — the caller treats that boundary as an opaque leaf.
|
||||
*/
|
||||
export function frameChain(
|
||||
win: Window,
|
||||
root: Window
|
||||
): HTMLIFrameElement[] | null {
|
||||
const chain: HTMLIFrameElement[] = [];
|
||||
let current: Window = win;
|
||||
while (chain.length <= MAX_NEST_DEPTH) {
|
||||
if (current === root) {
|
||||
return chain;
|
||||
}
|
||||
let frameEl: Element | null;
|
||||
try {
|
||||
frameEl = parentFrameElement(current);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// A tag check, not instanceof: the element belongs to the parent
|
||||
// document's realm, and a cross-realm instanceof is always false.
|
||||
if (frameEl?.tagName !== "IFRAME") {
|
||||
return null;
|
||||
}
|
||||
const parent = frameEl.ownerDocument.defaultView;
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
chain.push(frameEl as HTMLIFrameElement);
|
||||
current = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composed translation from the innermost document's viewport to the
|
||||
* outermost frame document's viewport: each iframe's border-box position plus
|
||||
* its own border (`clientLeft`/`clientTop`). `{x:0,y:0}` for an empty chain,
|
||||
* which is what keeps the non-nested path byte-identical.
|
||||
*/
|
||||
export function nestOffset(chain: readonly HTMLIFrameElement[]): Point {
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (const iframe of chain) {
|
||||
const rect = iframe.getBoundingClientRect();
|
||||
x += rect.left + iframe.clientLeft;
|
||||
y += rect.top + iframe.clientTop;
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** The overlap of two rects; zero-size when they miss, which `clipTo`
|
||||
* already handles. */
|
||||
export function intersectRects(a: Rect, b: Rect): Rect {
|
||||
const left = Math.max(a.left, b.left);
|
||||
const top = Math.max(a.top, b.top);
|
||||
const right = Math.min(a.left + a.width, b.left + b.width);
|
||||
const bottom = Math.min(a.top + a.height, b.top + b.height);
|
||||
return {
|
||||
height: Math.max(0, bottom - top),
|
||||
left,
|
||||
top,
|
||||
width: Math.max(0, right - left),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user