From d3fdd893ccd361e09ccd5f3f3b443d6b4aa4ee8d Mon Sep 17 00:00:00 2001
From: Brewen Couaran <45310490+brewcoua@users.noreply.github.com>
Date: Thu, 6 Jun 2024 10:18:53 +0200
Subject: [PATCH 1/5] docs(readme): add script explanation
---
README.md | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/README.md b/README.md
index dfbe9b0..e23ef37 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,26 @@ You can then use the `SoM` object in the `window` object to interact with the sc
window.SoM.display().then(() => console.log("Set-of-Marks displayed"));
```
+### How it works
+
+This is a step-by-step guide on how the script works:
+
+#### 1. Elements loading
+
+The script will first query all elements on the page (and inside shadow roots) that fit specific selectors (e.g. `a`, `button`, `input`, etc., see [src/constants.ts](src/constants.ts)). After that, it will go through all elements on the page and find the elements that display a pointer cursor. These elements will be stored in a list of elements that can be clicked, but are less likely to be right than the previously queried elements.
+
+#### 2. Elements filtering
+
+The script will then first proceed to filter out, in both lists, the elements that are not visible enough (see [src/constants.ts](src/constants.ts) for the threshold values, e.g. `0.7`). To do that, we first use an [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to check if the element is visible enough in the viewport, and if it is, we check the actual pixel-by-pixel visibility ratio of the element by first drawing it on a canvas, then drawing the overlapping elements on the same canvas, and finally counting the number of pixels that were not overlapped by other elements.
+
+After that, we take the elements in the second list (the ones that display a pointer cursor) and apply a nesting filter. This filter will remove all elements that are either inside a prioritized element (e.g. a button) or that have too many clickable children. Additionally, we consider elements disjoint if their size is different enough (see [src/constants.ts](src/constants.ts) for the threshold value, e.g. `0.7`).
+When applying this filter, we also consider the first list for reference, while not removing any element from that first list afterwards.
+
+#### 3. Elements rendering
+
+Finally, we proceed to render the boxes over the elements that passed the filters. We first render all the boxes, for which we calculate a contrasted color based on the element's background color and all surrounding boxes' colors (we also apply a min/max luminance and minimum saturation). After that, we render labels for the boxes, while calculating the label position that would overlap the least with other labels and boxes, while ignoring any box that fully overlaps that label's box (since some buttons may be completely inside cards, for example). If an element is editable, the box will have a stripped pattern, along with a border to make it more visible.
+All boxes ignore pointer events, so the user can still interact with the page.
+
## License
This project is licensed under either of the following, at your option:
From 32660dbda7394a0e8719f8a7ad10daaaadd7e710 Mon Sep 17 00:00:00 2001
From: Brewen Couaran <45310490+brewcoua@users.noreply.github.com>
Date: Sat, 8 Jun 2024 09:45:33 +0200
Subject: [PATCH 2/5] perf: use quadtree to optimize element mapping
---
build.ts | 13 +-
dist/SoM.js | 466 ++++++++++++++++++++----------
dist/SoM.min.js | 2 +-
src/constants.ts | 7 +
src/domain/Filter.ts | 5 +
src/domain/InteractiveElements.ts | 6 +
src/domain/filter.ts | 3 -
src/filters/nesting.ts | 21 +-
src/filters/visibility.ts | 385 ------------------------
src/filters/visibility/canvas.ts | 220 ++++++++++++++
src/filters/visibility/index.ts | 119 ++++++++
src/filters/visibility/quad.ts | 127 ++++++++
src/filters/visibility/utils.ts | 71 +++++
src/loader.ts | 59 ++--
src/ui.ts | 1 -
tsconfig.json | 31 ++
16 files changed, 957 insertions(+), 579 deletions(-)
create mode 100644 src/domain/Filter.ts
create mode 100644 src/domain/InteractiveElements.ts
delete mode 100644 src/domain/filter.ts
delete mode 100644 src/filters/visibility.ts
create mode 100644 src/filters/visibility/canvas.ts
create mode 100644 src/filters/visibility/index.ts
create mode 100644 src/filters/visibility/quad.ts
create mode 100644 src/filters/visibility/utils.ts
create mode 100644 tsconfig.json
diff --git a/build.ts b/build.ts
index 926ddc1..d4d601b 100644
--- a/build.ts
+++ b/build.ts
@@ -23,19 +23,28 @@ async function main() {
rmSync("dist", { recursive: true, force: true });
- await Bun.build({
+ const result1 = await Bun.build({
entrypoints: ["./src/main.ts"],
outdir: "./dist",
plugins: [InlineStylePlugin],
});
+ if (!result1.success) {
+ result1.logs.forEach(console.log);
+ process.exit(1);
+ }
renameSync("./dist/main.js", "./dist/SoM.js");
- await Bun.build({
+ const result2 = await Bun.build({
entrypoints: ["./src/main.ts"],
outdir: "./dist",
plugins: [InlineStylePlugin],
minify: true,
});
+ if (!result2.success) {
+ result2.logs.forEach(console.log);
+ process.exit(1);
+ }
+
renameSync("./dist/main.js", "./dist/SoM.min.js");
}
diff --git a/dist/SoM.js b/dist/SoM.js
index 91483e2..ae2ea08 100644
--- a/dist/SoM.js
+++ b/dist/SoM.js
@@ -46,184 +46,237 @@ var EDITABLE_SELECTORS = [
'[contenteditable="true"]'
];
var VISIBILITY_RATIO = 0.6;
-var ELEMENT_SAMPLING_RATE = 0.2;
+var MAX_COVER_RATIO = 0.8;
+var ELEMENT_BATCH_SIZE = 10;
var SURROUNDING_RADIUS = 200;
var MAX_LUMINANCE = 0.7;
var MIN_LUMINANCE = 0.25;
var MIN_SATURATION = 0.3;
-// src/domain/filter.ts
+// src/domain/Filter.ts
class Filter {
}
-// src/filters/visibility.ts
-class VisibilityFilter extends Filter {
- constructor() {
- super(...arguments);
+// src/filters/visibility/quad.ts
+class Rectangle {
+ x;
+ y;
+ width;
+ height;
+ element;
+ constructor(x, y, width, height, element = null) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ this.element = element;
}
- async apply(elements) {
- const visibleElements = await Promise.all(elements.map(async (element) => {
- if (element.offsetWidth === 0 && element.offsetHeight === 0) {
- return null;
- }
- const style = window.getComputedStyle(element);
- if (style.display === "none" || style.visibility === "hidden" || style.pointerEvents === "none") {
- return null;
- }
- let parent = element.parentElement;
- let passed = true;
- while (parent !== null) {
- const parentStyle = window.getComputedStyle(parent);
- if (parentStyle.display === "none" || parentStyle.visibility === "hidden" || parentStyle.pointerEvents === "none") {
- passed = false;
- break;
- }
- parent = parent.parentElement;
+ contains(rect) {
+ return rect.x >= this.x && rect.x + rect.width <= this.x + this.width && rect.y >= this.y && rect.y + rect.height <= this.y + this.height;
+ }
+ intersects(rect) {
+ return !(rect.x > this.x + this.width || rect.x + rect.width < this.x || rect.y > this.y + this.height || rect.y + rect.height < this.y);
+ }
+}
+
+class QuadTree {
+ boundary;
+ capacity;
+ elements;
+ divided;
+ northeast;
+ northwest;
+ southeast;
+ southwest;
+ constructor(boundary, capacity) {
+ this.boundary = boundary;
+ this.capacity = capacity;
+ this.elements = [];
+ this.divided = false;
+ this.northeast = null;
+ this.northwest = null;
+ this.southeast = null;
+ this.southwest = null;
+ }
+ subdivide() {
+ const x = this.boundary.x;
+ const y = this.boundary.y;
+ const w = this.boundary.width / 2;
+ const h = this.boundary.height / 2;
+ const ne = new Rectangle(x + w, y, w, h);
+ const nw = new Rectangle(x, y, w, h);
+ const se = new Rectangle(x + w, y + h, w, h);
+ const sw = new Rectangle(x, y + h, w, h);
+ this.northeast = new QuadTree(ne, this.capacity);
+ this.northwest = new QuadTree(nw, this.capacity);
+ this.southeast = new QuadTree(se, this.capacity);
+ this.southwest = new QuadTree(sw, this.capacity);
+ this.divided = true;
+ }
+ insert(element) {
+ if (!this.boundary.intersects(element)) {
+ return false;
+ }
+ if (this.elements.length < this.capacity) {
+ this.elements.push(element);
+ return true;
+ } else {
+ if (!this.divided) {
+ this.subdivide();
}
- if (!passed) {
- return null;
+ if (this.northeast.insert(element)) {
+ return true;
+ } else if (this.northwest.insert(element)) {
+ return true;
+ } else if (this.southeast.insert(element)) {
+ return true;
+ } else if (this.southwest.insert(element)) {
+ return true;
}
- const isVisible = await this.isElementVisible(element);
- if (!isVisible) {
- return null;
+ return false;
+ }
+ }
+ query(range, found = []) {
+ if (!this.boundary.intersects(range)) {
+ return found;
+ }
+ for (let element of this.elements) {
+ if (range.intersects(element)) {
+ found.push(element);
}
- return element;
- }));
- return visibleElements.filter((element) => element !== null);
+ }
+ if (this.divided) {
+ this.northwest.query(range, found);
+ this.northeast.query(range, found);
+ this.southwest.query(range, found);
+ this.southeast.query(range, found);
+ }
+ return found;
}
- async isElementVisible(element) {
- return new Promise((resolve) => {
- const observer = new IntersectionObserver(async (entries) => {
- const entry = entries[0];
- observer.disconnect();
- if (entry.intersectionRatio < VISIBILITY_RATIO) {
- resolve(false);
- return;
- }
- const rect = element.getBoundingClientRect();
- if (rect.width <= 1 || rect.height <= 1) {
- resolve(false);
- return;
- }
- if (rect.width >= window.innerWidth * 0.8 || rect.height >= window.innerHeight * 0.8) {
- resolve(false);
- return;
- }
- const visibleAreaRatio = await this.getVisibilityRatio(element, rect);
- resolve(visibleAreaRatio >= VISIBILITY_RATIO);
- });
- observer.observe(element);
- });
+}
+
+// src/filters/visibility/utils.ts
+function isAbove(element, referenceElement) {
+ const elementZIndex = window.getComputedStyle(element).zIndex;
+ const referenceElementZIndex = window.getComputedStyle(referenceElement).zIndex;
+ const elementPosition = element.compareDocumentPosition(referenceElement);
+ if (elementPosition & Node.DOCUMENT_POSITION_CONTAINS) {
+ return false;
+ }
+ if (elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) {
+ return true;
+ }
+ if (elementZIndex !== "auto" && referenceElementZIndex !== "auto") {
+ return parseInt(elementZIndex) > parseInt(referenceElementZIndex);
}
- async getVisibilityRatio(element, rect) {
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d", {
+ if (elementZIndex === "auto" || referenceElementZIndex === "auto") {
+ return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING);
+ }
+ return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING);
+}
+function isVisible(element) {
+ if (element.offsetWidth === 0 && element.offsetHeight === 0) {
+ return false;
+ }
+ const rect = element.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) {
+ return false;
+ }
+ const style = window.getComputedStyle(element);
+ if (style.display === "none" || style.visibility === "hidden" || style.pointerEvents === "none") {
+ return false;
+ }
+ let parent = element.parentElement;
+ while (parent !== null) {
+ const parentStyle = window.getComputedStyle(parent);
+ if (parentStyle.display === "none" || parentStyle.visibility === "hidden" || parentStyle.pointerEvents === "none") {
+ return false;
+ }
+ parent = parent.parentElement;
+ }
+ return true;
+}
+
+// src/filters/visibility/canvas.ts
+class VisibilityCanvas {
+ element;
+ canvas;
+ ctx;
+ rect;
+ visibleRect;
+ constructor(element) {
+ this.element = element;
+ this.element = element;
+ this.rect = this.element.getBoundingClientRect();
+ this.canvas = new OffscreenCanvas(this.rect.width, this.rect.height);
+ this.ctx = this.canvas.getContext("2d", {
willReadFrequently: true
});
- if (!ctx) {
- throw new Error("Could not get 2D context");
- }
- const elementZIndex = parseInt(window.getComputedStyle(element).zIndex || "0", 10);
- canvas.width = rect.width;
- canvas.height = rect.height;
- ctx.fillStyle = "black";
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- const visibleRect = {
- top: Math.max(0, rect.top),
- left: Math.max(0, rect.left),
- bottom: Math.min(window.innerHeight, rect.bottom),
- right: Math.min(window.innerWidth, rect.right),
- width: rect.width,
- height: rect.height
+ this.visibleRect = {
+ top: Math.max(0, this.rect.top),
+ left: Math.max(0, this.rect.left),
+ bottom: Math.min(window.innerHeight, this.rect.bottom),
+ right: Math.min(window.innerWidth, this.rect.right),
+ width: this.rect.width,
+ height: this.rect.height
};
- visibleRect.width = visibleRect.right - visibleRect.left;
- visibleRect.height = visibleRect.bottom - visibleRect.top;
- this.drawElement(element, ctx, rect, rect, "white");
- const totalPixels = this.countVisiblePixels(ctx, {
- top: visibleRect.top - rect.top,
- left: visibleRect.left - rect.left,
- width: canvas.width,
- height: canvas.height
- });
- const elements = await this.getIntersectingElements(element);
- await Promise.all(elements.map(async (el) => {
- const elRect = el.getBoundingClientRect();
- this.drawElement(el, ctx, elRect, rect, "black");
- }));
- const visiblePixels = this.countVisiblePixels(ctx, {
- top: visibleRect.top - rect.top,
- left: visibleRect.left - rect.left,
- width: visibleRect.width,
- height: visibleRect.height
- });
- canvas.remove();
- if (totalPixels === 0) {
+ this.visibleRect.width = this.visibleRect.right - this.visibleRect.left;
+ this.visibleRect.height = this.visibleRect.bottom - this.visibleRect.top;
+ }
+ async eval(qt) {
+ this.ctx.fillStyle = "black";
+ this.ctx.fillRect(0, 0, this.rect.width, this.rect.height);
+ this.drawElement(this.element, "white");
+ const canvasVisRect = {
+ top: this.visibleRect.top - this.rect.top,
+ bottom: this.visibleRect.bottom - this.rect.top,
+ left: this.visibleRect.left - this.rect.left,
+ right: this.visibleRect.right - this.rect.left,
+ width: this.canvas.width,
+ height: this.canvas.height
+ };
+ const totalPixels = await this.countVisiblePixels(canvasVisRect);
+ if (totalPixels === 0)
return 0;
+ const elements = this.getIntersectingElements(qt);
+ for (const el of elements) {
+ this.drawElement(el, "black");
}
+ const visiblePixels = await this.countVisiblePixels(canvasVisRect);
return visiblePixels / totalPixels;
}
- async getIntersectingElements(element) {
- const elementZIndex = parseInt(window.getComputedStyle(element).zIndex || "0", 10);
- const rect = element.getBoundingClientRect();
- const foundElements = await Promise.all(Array.from({ length: Math.ceil(1 / ELEMENT_SAMPLING_RATE) }).map(async (_, i) => {
- return Array.from({
- length: Math.ceil(1 / ELEMENT_SAMPLING_RATE)
- }).map((_2, j) => {
- const elements2 = document.elementsFromPoint(rect.left + rect.width * ELEMENT_SAMPLING_RATE * i, rect.top + rect.height * ELEMENT_SAMPLING_RATE * j);
- if (!elements2.includes(element)) {
- return [];
- }
- const currentIndex = elements2.indexOf(element);
- return elements2.slice(0, currentIndex);
- });
- }));
- const uniqueElements = Array.from(new Set(foundElements.flat(2).filter((el) => el !== element)));
- let elements = [];
- for (const el of uniqueElements) {
- const elZIndex = parseInt(window.getComputedStyle(el).zIndex || "0", 10);
- if (elZIndex < elementZIndex) {
- continue;
- }
- if (el.contains(element) || element.contains(el)) {
- continue;
- }
- elements.push(el);
- }
- elements = elements.filter((el) => {
- for (const other of elements) {
- if (el !== other && other.contains(el)) {
- return false;
- }
- }
- return true;
- });
- return elements;
+ getIntersectingElements(qt) {
+ const range = new Rectangle(this.rect.left, this.rect.right, this.rect.width, this.rect.height, this.element);
+ const candidates = qt.query(range);
+ return candidates.map((candidate) => candidate.element).filter((el) => el != this.element && isAbove(el, this.element) && isVisible(el));
}
- countVisiblePixels(ctx, rect) {
- const data = ctx.getImageData(rect.left, rect.top, rect.width, rect.height).data;
+ async countVisiblePixels(visibleRect) {
+ const imageData = this.ctx.getImageData(visibleRect.left, visibleRect.top, visibleRect.width, visibleRect.height);
let visiblePixels = 0;
- for (let i = 0;i < data.length; i += 4) {
- if (data[i] > 0) {
+ for (let i = 0;i < imageData.data.length; i += 4) {
+ const isWhite = imageData.data[i] === 255;
+ if (isWhite) {
visiblePixels++;
}
}
return visiblePixels;
}
- drawElement(element, ctx, rect, baseRect, color = "black") {
+ drawElement(element, color = "black") {
+ const rect = element.getBoundingClientRect();
const styles = window.getComputedStyle(element);
const radius = styles.borderRadius?.split(" ").map((r) => parseFloat(r));
const clipPath = styles.clipPath;
const offsetRect = {
- top: rect.top - baseRect.top,
- bottom: rect.bottom - baseRect.top,
- left: rect.left - baseRect.left,
- right: rect.right - baseRect.left,
+ top: rect.top - this.rect.top,
+ bottom: rect.bottom - this.rect.top,
+ left: rect.left - this.rect.left,
+ right: rect.right - this.rect.left,
width: rect.width,
height: rect.height
};
offsetRect.width = offsetRect.right - offsetRect.left;
offsetRect.height = offsetRect.bottom - offsetRect.top;
- ctx.fillStyle = color;
+ this.ctx.fillStyle = color;
if (clipPath && clipPath !== "none") {
const clips = clipPath.split(/,| /);
clips.forEach((clip) => {
@@ -234,7 +287,7 @@ class VisibilityFilter extends Filter {
switch (kind[0]) {
case "polygon":
const path = this.pathFromPolygon(clip, rect);
- ctx.fill(path);
+ this.ctx.fill(path);
break;
default:
console.log("Unknown clip path kind: " + kind);
@@ -254,9 +307,9 @@ class VisibilityFilter extends Filter {
path.arcTo(offsetRect.left, offsetRect.bottom, offsetRect.left, offsetRect.top, radius[3]);
path.arcTo(offsetRect.left, offsetRect.top, offsetRect.right, offsetRect.top, radius[0]);
path.closePath();
- ctx.fill(path);
+ this.ctx.fill(path);
} else {
- ctx.fillRect(offsetRect.left, offsetRect.top, offsetRect.width, offsetRect.height);
+ this.ctx.fillRect(offsetRect.left, offsetRect.top, offsetRect.width, offsetRect.height);
}
}
pathFromPolygon(polygon, rect) {
@@ -279,6 +332,83 @@ class VisibilityFilter extends Filter {
return path;
}
}
+
+// src/filters/visibility/index.ts
+class VisibilityFilter extends Filter {
+ constructor() {
+ super(...arguments);
+ }
+ qt;
+ async apply(elements) {
+ this.qt = this.mapQuadTree();
+ const results = await Promise.all([
+ this.applyScoped(elements.fixed),
+ this.applyScoped(elements.unknown)
+ ]);
+ return {
+ fixed: results[0],
+ unknown: results[1]
+ };
+ }
+ async applyScoped(elements) {
+ const results = await Promise.all(Array.from({
+ length: Math.ceil(elements.length / ELEMENT_BATCH_SIZE)
+ }).map(async (_, i) => {
+ const batch = elements.slice(i * ELEMENT_BATCH_SIZE, (i + 1) * ELEMENT_BATCH_SIZE).filter((el) => isVisible(el));
+ const visibleElements = [];
+ for (const element of batch) {
+ const isVisible2 = await this.isDeepVisible(element);
+ if (isVisible2) {
+ visibleElements.push(element);
+ }
+ }
+ return visibleElements;
+ }));
+ return results.flat();
+ }
+ mapQuadTree() {
+ const boundary = new Rectangle(0, 0, window.innerWidth, window.innerHeight);
+ const qt = new QuadTree(boundary, 4);
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
+ acceptNode: (node) => {
+ const element = node;
+ if (isVisible(element)) {
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ return NodeFilter.FILTER_REJECT;
+ }
+ });
+ let currentNode = walker.currentNode;
+ while (currentNode) {
+ const element = currentNode;
+ const rect = element.getBoundingClientRect();
+ qt.insert(new Rectangle(rect.left, rect.top, rect.width, rect.height, element));
+ currentNode = walker.nextNode();
+ }
+ return qt;
+ }
+ async isDeepVisible(element) {
+ return new Promise((resolve) => {
+ const observer = new IntersectionObserver(async (entries) => {
+ const entry = entries[0];
+ observer.disconnect();
+ if (entry.intersectionRatio < VISIBILITY_RATIO) {
+ resolve(false);
+ return;
+ }
+ const rect = element.getBoundingClientRect();
+ if (rect.width >= window.innerWidth * MAX_COVER_RATIO || rect.height >= window.innerHeight * MAX_COVER_RATIO) {
+ resolve(false);
+ return;
+ }
+ const canvas2 = new VisibilityCanvas(element);
+ const visibleAreaRatio = await canvas2.eval(this.qt);
+ resolve(visibleAreaRatio >= VISIBILITY_RATIO);
+ });
+ observer.observe(element);
+ });
+ }
+}
var visibility_default = VisibilityFilter;
// src/filters/nesting.ts
var SIZE_THRESHOLD = 0.9;
@@ -290,9 +420,13 @@ class NestingFilter extends Filter {
super(...arguments);
}
async apply(elements) {
- const { top, others } = this.getTopLevelElements(elements);
+ const fullElements = elements.fixed.concat(elements.unknown);
+ const { top, others } = this.getTopLevelElements(fullElements);
const results = await Promise.all(top.map(async (topElement) => this.compareTopWithChildren(topElement, others)));
- return results.flat();
+ return {
+ fixed: elements.fixed,
+ unknown: results.flat().filter((el) => elements.fixed.indexOf(el) === -1)
+ };
}
async compareTopWithChildren(top, children) {
if (PRIORITY_SELECTOR.some((selector) => top.matches(selector))) {
@@ -311,7 +445,7 @@ class NestingFilter extends Filter {
if (branch.children.length === 0) {
return [branch.top];
}
- return await this.compareTopWithChildren(branch.top, branch.children);
+ return this.compareTopWithChildren(branch.top, branch.children);
}));
const total = results.flat();
if (total.length > QUANTITY_THRESHOLD) {
@@ -355,23 +489,38 @@ class Loader {
};
async loadElements() {
const selector = SELECTORS.join(",");
- let preselectedElements = Array.from(document.querySelectorAll(selector));
+ let fixedElements = Array.from(document.querySelectorAll(selector));
const shadowRoots = this.shadowRoots();
for (let i = 0;i < shadowRoots.length; i++) {
- preselectedElements = preselectedElements.concat(Array.from(shadowRoots[i].querySelectorAll(selector)));
+ fixedElements = fixedElements.concat(Array.from(shadowRoots[i].querySelectorAll(selector)));
}
const allElements = document.querySelectorAll("*");
- let clickableElements = [];
- for (let i = 0;i < allElements.length; i++) {
- if (!allElements[i].matches(selector) && window.getComputedStyle(allElements[i]).cursor === "pointer") {
- clickableElements.push(allElements[i]);
+ let unknownElements = [];
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
+ acceptNode() {
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ });
+ let node;
+ while (node = walker.nextNode()) {
+ const el = node;
+ if (!el.matches(selector) && window.getComputedStyle(el).cursor === "pointer") {
+ unknownElements.push(el);
}
}
- clickableElements = Array.from(clickableElements).filter((element, index, self) => self.indexOf(element) === index).filter((element) => !element.closest("svg") && !preselectedElements.some((el) => el.contains(element)));
- const visiblePreselected = await this.filters.visibility.apply(preselectedElements);
- const visibleClickable = await this.filters.visibility.apply(clickableElements);
- const nestedAll = await this.filters.nesting.apply(visibleClickable.concat(visiblePreselected));
- return visiblePreselected.concat(nestedAll).filter((element, index, self) => self.indexOf(element) === index);
+ unknownElements = Array.from(unknownElements).filter((element, index, self) => self.indexOf(element) === index).filter((element) => !element.closest("svg") && !fixedElements.some((el) => el.contains(element)));
+ let interactive = {
+ fixed: fixedElements,
+ unknown: unknownElements
+ };
+ console.groupCollapsed("Elements");
+ console.log("Before filters", interactive);
+ interactive = await this.filters.visibility.apply(interactive);
+ console.log("After visibility filter", interactive);
+ interactive = await this.filters.nesting.apply(interactive);
+ console.log("After nesting filter", interactive);
+ console.groupEnd();
+ return interactive.fixed.concat(interactive.unknown);
}
shadowRoots() {
const shadowRoots = [];
@@ -764,7 +913,6 @@ class UI {
];
return distances.some((distance) => distance < SURROUNDING_RADIUS);
}).map((box) => box.color);
- console.groupCollapsed(`Element: ${element.tagName} (${i})`);
const color = this.colors.contrastColor(element, surroundingColors);
div.style.setProperty("--SoM-color", `${color.r}, ${color.g}, ${color.b}`);
document.body.appendChild(div);
diff --git a/dist/SoM.min.js b/dist/SoM.min.js
index 57b5a2c..97edd62 100644
--- a/dist/SoM.min.js
+++ b/dist/SoM.min.js
@@ -1 +1 @@
-var A=["a:not(:has(img))","a img","button",'input:not([type="hidden"])',"select","textarea",'[tabindex]:not([tabindex="-1"])','[contenteditable="true"]',".btn",'[role="button"]','[role="link"]','[role="checkbox"]','[role="radio"]','[role="input"]','[role="menuitem"]','[role="menuitemcheckbox"]','[role="menuitemradio"]','[role="option"]','[role="switch"]','[role="tab"]','[role="treeitem"]','[role="gridcell"]','[role="search"]','[role="combobox"]','[role="listbox"]','[role="slider"]','[role="spinbutton"]'],y=['input[type="text"]','input[type="password"]','input[type="email"]','input[type="tel"]','input[type="number"]','input[type="search"]','input[type="url"]','input[type="date"]','input[type="time"]','input[type="datetime-local"]','input[type="month"]','input[type="week"]','input[type="color"]',"textarea",'[contenteditable="true"]'],P=0.6,M=0.2,u=200,S=0.7,_=0.25,L=0.3;class q{}class I extends q{constructor(){super(...arguments)}async apply(K){return(await Promise.all(K.map(async($)=>{if($.offsetWidth===0&&$.offsetHeight===0)return null;const Q=window.getComputedStyle($);if(Q.display==="none"||Q.visibility==="hidden"||Q.pointerEvents==="none")return null;let W=$.parentElement,X=!0;while(W!==null){const Z=window.getComputedStyle(W);if(Z.display==="none"||Z.visibility==="hidden"||Z.pointerEvents==="none"){X=!1;break}W=W.parentElement}if(!X)return null;if(!await this.isElementVisible($))return null;return $}))).filter(($)=>$!==null)}async isElementVisible(K){return new Promise((J)=>{const $=new IntersectionObserver(async(Q)=>{const W=Q[0];if($.disconnect(),W.intersectionRatio
=window.innerWidth*0.8||X.height>=window.innerHeight*0.8){J(!1);return}const j=await this.getVisibilityRatio(K,X);J(j>=P)});$.observe(K)})}async getVisibilityRatio(K,J){const $=document.createElement("canvas"),Q=$.getContext("2d",{willReadFrequently:!0});if(!Q)throw new Error("Could not get 2D context");const W=parseInt(window.getComputedStyle(K).zIndex||"0",10);$.width=J.width,$.height=J.height,Q.fillStyle="black",Q.fillRect(0,0,$.width,$.height);const X={top:Math.max(0,J.top),left:Math.max(0,J.left),bottom:Math.min(window.innerHeight,J.bottom),right:Math.min(window.innerWidth,J.right),width:J.width,height:J.height};X.width=X.right-X.left,X.height=X.bottom-X.top,this.drawElement(K,Q,J,J,"white");const j=this.countVisiblePixels(Q,{top:X.top-J.top,left:X.left-J.left,width:$.width,height:$.height}),Z=await this.getIntersectingElements(K);await Promise.all(Z.map(async(Y)=>{const V=Y.getBoundingClientRect();this.drawElement(Y,Q,V,J,"black")}));const z=this.countVisiblePixels(Q,{top:X.top-J.top,left:X.left-J.left,width:X.width,height:X.height});if($.remove(),j===0)return 0;return z/j}async getIntersectingElements(K){const J=parseInt(window.getComputedStyle(K).zIndex||"0",10),$=K.getBoundingClientRect(),Q=await Promise.all(Array.from({length:Math.ceil(1/M)}).map(async(j,Z)=>{return Array.from({length:Math.ceil(1/M)}).map((z,Y)=>{const V=document.elementsFromPoint($.left+$.width*M*Z,$.top+$.height*M*Y);if(!V.includes(K))return[];const D=V.indexOf(K);return V.slice(0,D)})})),W=Array.from(new Set(Q.flat(2).filter((j)=>j!==K)));let X=[];for(let j of W){if(parseInt(window.getComputedStyle(j).zIndex||"0",10){for(let Z of X)if(j!==Z&&Z.contains(j))return!1;return!0}),X}countVisiblePixels(K,J){const $=K.getImageData(J.left,J.top,J.width,J.height).data;let Q=0;for(let W=0;W<$.length;W+=4)if($[W]>0)Q++;return Q}drawElement(K,J,$,Q,W="black"){const X=window.getComputedStyle(K),j=X.borderRadius?.split(" ").map((Y)=>parseFloat(Y)),Z=X.clipPath,z={top:$.top-Q.top,bottom:$.bottom-Q.top,left:$.left-Q.left,right:$.right-Q.left,width:$.width,height:$.height};if(z.width=z.right-z.left,z.height=z.bottom-z.top,J.fillStyle=W,Z&&Z!=="none")Z.split(/,| /).forEach((V)=>{const D=V.trim().match(/^([a-z]+)\((.*)\)$/);if(!D)return;switch(D[0]){case"polygon":const w=this.pathFromPolygon(V,$);J.fill(w);break;default:console.log("Unknown clip path kind: "+D)}});else if(j){const Y=new Path2D;if(j.length===1)j[1]=j[0];if(j.length===2)j[2]=j[0];if(j.length===3)j[3]=j[1];Y.moveTo(z.left+j[0],z.top),Y.arcTo(z.right,z.top,z.right,z.bottom,j[1]),Y.arcTo(z.right,z.bottom,z.left,z.bottom,j[2]),Y.arcTo(z.left,z.bottom,z.left,z.top,j[3]),Y.arcTo(z.left,z.top,z.right,z.top,j[0]),Y.closePath(),J.fill(Y)}else J.fillRect(z.left,z.top,z.width,z.height)}pathFromPolygon(K,J){if(!K||!K.match(/^polygon\((.*)\)$/))throw new Error("Invalid polygon format: "+K);const $=new Path2D,Q=K.match(/\d+(\.\d+)?%/g);if(Q&&Q.length>=2){const W=parseFloat(Q[0]),X=parseFloat(Q[1]);$.moveTo(W*J.width/100,X*J.height/100);for(let j=2;jthis.compareTopWithChildren(W,$)))).flat()}async compareTopWithChildren(K,J){if(d.some((j)=>K.matches(j)))return[K];const $=this.getBranches(K,J),Q=K.getBoundingClientRect();if($.length<=1)return[K];const X=(await Promise.all($.map(async(j)=>{const Z=j.top.getBoundingClientRect();if(Z.width/Q.widthp)return X;return[K,...X]}getBranches(K,J){const $=this.getFirstHitChildren(K,J);return $.map((Q)=>{const W=J.filter((X)=>!$.includes(X)&&Q.contains(X));return{top:Q,children:W}})}getFirstHitChildren(K,J){const $=K.querySelectorAll(":scope > *"),Q=Array.from($).filter((W)=>J.includes(W));if(Q.length>0)return Q;return Array.from($).flatMap((W)=>this.getFirstHitChildren(W,J))}getTopLevelElements(K){const J=[],$=[];for(let Q of K)if(!K.some((W)=>W!==Q&&W.contains(Q)))J.push(Q);else $.push(Q);return{top:J,others:$}}}var T=v;class k{filters={visibility:new N,nesting:new T};async loadElements(){const K=A.join(",");let J=Array.from(document.querySelectorAll(K));const $=this.shadowRoots();for(let z=0;z<$.length;z++)J=J.concat(Array.from($[z].querySelectorAll(K)));const Q=document.querySelectorAll("*");let W=[];for(let z=0;zV.indexOf(z)===Y).filter((z)=>!z.closest("svg")&&!J.some((Y)=>Y.contains(z)));const X=await this.filters.visibility.apply(J),j=await this.filters.visibility.apply(W),Z=await this.filters.nesting.apply(j.concat(X));return X.concat(Z).filter((z,Y,V)=>V.indexOf(z)===Y)}shadowRoots(){const K=[],J=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode(Q){return NodeFilter.FILTER_ACCEPT}});let $;while($=J.nextNode())if($&&$.shadowRoot)K.push($.shadowRoot);return K}}class O{contrastColor(K,J){const $=window.getComputedStyle(K),Q=G.fromCSS($.backgroundColor);return this.getBestContrastColor([Q,...J])}getBestContrastColor(K){const J=K.filter((Q)=>Q.a>0).map((Q)=>Q.complimentary());let $;if(J.length===0)$=new G(Math.floor(Math.random()*255),Math.floor(Math.random()*255),Math.floor(Math.random()*255));else $=this.getAverageColor(J);if($.luminance()>S)$=$.withLuminance(S);else if($.luminance()<_)$=$.withLuminance(_);if($.saturation()W+X.r,0)/K.length,$=K.reduce((W,X)=>W+X.g,0)/K.length,Q=K.reduce((W,X)=>W+X.b,0)/K.length;return new G(J,$,Q)}}class G{K;J;$;Q;constructor(K,J,$,Q=255){this.r=K;this.g=J;this.b=$;this.a=Q;if(K<0||K>255)throw new Error(`Invalid red value: ${K}`);if(J<0||J>255)throw new Error(`Invalid green value: ${J}`);if($<0||$>255)throw new Error(`Invalid blue value: ${$}`);if(Q<0||Q>255)throw new Error(`Invalid alpha value: ${Q}`);this.r=Math.round(K),this.g=Math.round(J),this.b=Math.round($),this.a=Math.round(Q)}static fromCSS(K){if(K.startsWith("#"))return G.fromHex(K);if(K.startsWith("rgb")){const $=K.replace(/rgba?\(/,"").replace(")","").split(",").map((Q)=>parseInt(Q.trim()));return new G(...$)}if(K.startsWith("hsl")){const $=K.replace(/hsla?\(/,"").replace(")","").split(",").map((Q)=>parseFloat(Q.trim()));return G.fromHSL({h:$[0],s:$[1],l:$[2]})}const J=x[K.toLowerCase()];if(J)return G.fromHex(J);throw new Error(`Unknown color format: ${K}`)}static fromHex(K){if(K=K.replace("#",""),K.length===3)K=K.split("").map((W)=>W+W).join("");const J=parseInt(K.substring(0,2),16),$=parseInt(K.substring(2,4),16),Q=parseInt(K.substring(4,6),16);if(K.length===8){const W=parseInt(K.substring(6,8),16);return new G(J,$,Q,W)}return new G(J,$,Q)}static fromHSL(K){const{h:J,s:$,l:Q}=K;let W,X,j;if($===0)W=X=j=Q;else{const Z=(V,D,w)=>{if(w<0)w+=1;if(w>1)w-=1;if(w<0.16666666666666666)return V+(D-V)*6*w;if(w<0.5)return D;if(w<0.6666666666666666)return V+(D-V)*(0.6666666666666666-w)*6;return V},z=Q<0.5?Q*(1+$):Q+$-Q*$,Y=2*Q-z;W=Z(Y,z,J+0.3333333333333333),X=Z(Y,z,J),j=Z(Y,z,J-0.3333333333333333)}return new G(W*255,X*255,j*255)}luminance(){const K=this.r/255,J=this.g/255,$=this.b/255,Q=[K,J,$].map((W)=>{if(W<=0.03928)return W/12.92;return Math.pow((W+0.055)/1.055,2.4)});return 0.2126*Q[0]+0.7152*Q[1]+0.0722*Q[2]}withLuminance(K){const J=this.luminance(),$=K/J,Q=Math.min(255,this.r*$),W=Math.min(255,this.g*$),X=Math.min(255,this.b*$);return new G(Q,W,X,this.a)}saturation(){return this.toHsl().s}withSaturation(K){const J=this.toHsl();return J.s=K,G.fromHSL(J)}contrast(K){const J=this.luminance(),$=K.luminance();return(Math.max(J,$)+0.05)/(Math.min(J,$)+0.05)}complimentary(){const K=this.toHsl();return K.h=(K.h+0.5)%1,G.fromHSL(K)}toHex(){const K=this.r.toString(16).padStart(2,"0"),J=this.g.toString(16).padStart(2,"0"),$=this.b.toString(16).padStart(2,"0");if(this.a<255){const Q=this.a.toString(16).padStart(2,"0");return`#${K}${J}${$}${Q}`}return`#${K}${J}${$}`}toHsl(){const K=this.r/255,J=this.g/255,$=this.b/255,Q=Math.max(K,J,$),W=Math.min(K,J,$);let X=(Q+W)/2,j=(Q+W)/2,Z=(Q+W)/2;if(Q===W)X=j=0;else{const z=Q-W;switch(j=Z>0.5?z/(2-Q-W):z/(Q+W),Q){case K:X=(J-$)/z+(J<$?6:0);break;case J:X=($-K)/z+2;break;case $:X=(K-J)/z+4;break}X/=6}return{h:X,s:j,l:Z,a:this.a/255}}toString(){return this.toHex()}}var x={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",honeydew:"#f0fff0",hotpink:"#ff69b4","indianred ":"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgrey:"#d3d3d3",lightgreen:"#90ee90",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370d8",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#d87093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"};class H{colors=new O;display(K){const J=[],$=[],Q=[];for(let W=0;WX.matches(V)))Z.classList.add("editable");const z=$.filter((V)=>{return[Math.sqrt(Math.pow(j.left-V.left,2)+Math.pow(j.top-V.top,2)),Math.sqrt(Math.pow(j.right-V.right,2)+Math.pow(j.top-V.top,2)),Math.sqrt(Math.pow(j.left-V.left,2)+Math.pow(j.bottom-V.bottom,2)),Math.sqrt(Math.pow(j.right-V.right,2)+Math.pow(j.bottom-V.bottom,2))].some((w)=>wV.color);console.groupCollapsed(`Element: ${X.tagName} (${W})`);const Y=this.colors.contrastColor(X,z);Z.style.setProperty("--SoM-color",`${Y.r}, ${Y.g}, ${Y.b}`),document.body.appendChild(Z),$.push({top:j.top,bottom:j.bottom,left:j.left,right:j.right,width:j.width,height:j.height,color:Y}),Q.push(Z)}for(let W=0;W{let U=0;if(F.top<0||F.top+z.height>window.innerHeight||F.left<0||F.left+z.width>window.innerWidth)U+=Infinity;else J.concat($).forEach((B)=>{if(B.top<=j.top&&B.bottom>=j.bottom&&B.left<=j.left&&B.right>=j.right)return;const f=Math.max(0,Math.min(F.left+z.width,B.left+B.width)-Math.max(F.left,B.left)),R=Math.max(0,Math.min(F.top+z.height,B.top+B.height)-Math.max(F.top,B.top));U+=f*R});return U}),w=V[D.indexOf(Math.min(...D))];Z.style.top=`${w.top-j.top}px`,Z.style.left=`${w.left-j.left}px`,J.push({top:w.top,left:w.left,right:w.left+z.width,bottom:w.top+z.height,width:z.width,height:z.height}),X.setAttribute("data-SoM",`${W}`)}}getColorByLuminance(K){return K.luminance()>0.5?"black":"white"}}var E=".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.45)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.45) 10px,rgba(var(--SoM-color),.45) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:\"Courier New\",Courier,monospace;background-color:rgba(var(--SoM-color),.7)}";class g{loader=new k;ui=new H;async display(){this.log("Displaying...");const K=performance.now(),J=await this.loader.loadElements();this.clear(),this.ui.display(J),this.log("Done!",`Took ${performance.now()-K}ms to display ${J.length} elements.`)}clear(){document.querySelectorAll(".SoM").forEach((K)=>{K.remove()}),document.querySelectorAll("[data-som]").forEach((K)=>{K.removeAttribute("data-som")})}hide(){document.querySelectorAll(".SoM").forEach((K)=>K.style.display="none")}show(){document.querySelectorAll(".SoM").forEach((K)=>K.style.display="block")}resolve(K){return document.querySelector(`[data-som="${K}"]`)}log(...K){console.log("%cSoM","color: white; background: #007bff; padding: 2px 5px; border-radius: 5px;",...K)}}if(!document.getElementById("SoM-styles")){const K=document.createElement("style");K.id="SoM-styles",K.innerHTML=E;const J=setInterval(()=>{if(document.head)clearInterval(J),document.head.appendChild(K)},100)}window.SoM=new g;window.SoM.log("Ready!");
+var U=["a:not(:has(img))","a img","button",'input:not([type="hidden"])',"select","textarea",'[tabindex]:not([tabindex="-1"])','[contenteditable="true"]',".btn",'[role="button"]','[role="link"]','[role="checkbox"]','[role="radio"]','[role="input"]','[role="menuitem"]','[role="menuitemcheckbox"]','[role="menuitemradio"]','[role="option"]','[role="switch"]','[role="tab"]','[role="treeitem"]','[role="gridcell"]','[role="search"]','[role="combobox"]','[role="listbox"]','[role="slider"]','[role="spinbutton"]'],H=['input[type="text"]','input[type="password"]','input[type="email"]','input[type="tel"]','input[type="number"]','input[type="search"]','input[type="url"]','input[type="date"]','input[type="time"]','input[type="datetime-local"]','input[type="month"]','input[type="week"]','input[type="color"]',"textarea",'[contenteditable="true"]'],Y=0.6,d=0.8,G=10;var O=200,q=0.7,s=0.25,B=0.3;class W{}class F{x;y;width;height;element;constructor(h,u,t,i,w=null){this.x=h,this.y=u,this.width=t,this.height=i,this.element=w}contains(h){return h.x>=this.x&&h.x+h.width<=this.x+this.width&&h.y>=this.y&&h.y+h.height<=this.y+this.height}intersects(h){return!(h.x>this.x+this.width||h.x+h.widththis.y+this.height||h.y+h.heightparseInt(i);if(t==="auto"||i==="auto")return!!(w&Node.DOCUMENT_POSITION_PRECEDING);return!!(w&Node.DOCUMENT_POSITION_PRECEDING)}function j(h){if(h.offsetWidth===0&&h.offsetHeight===0)return!1;const u=h.getBoundingClientRect();if(u.width<=0||u.height<=0)return!1;const t=window.getComputedStyle(h);if(t.display==="none"||t.visibility==="hidden"||t.pointerEvents==="none")return!1;let i=h.parentElement;while(i!==null){const w=window.getComputedStyle(i);if(w.display==="none"||w.visibility==="hidden"||w.pointerEvents==="none")return!1;i=i.parentElement}return!0}class z{h;canvas;ctx;rect;visibleRect;constructor(h){this.element=h;this.element=h,this.rect=this.element.getBoundingClientRect(),this.canvas=new OffscreenCanvas(this.rect.width,this.rect.height),this.ctx=this.canvas.getContext("2d",{willReadFrequently:!0}),this.visibleRect={top:Math.max(0,this.rect.top),left:Math.max(0,this.rect.left),bottom:Math.min(window.innerHeight,this.rect.bottom),right:Math.min(window.innerWidth,this.rect.right),width:this.rect.width,height:this.rect.height},this.visibleRect.width=this.visibleRect.right-this.visibleRect.left,this.visibleRect.height=this.visibleRect.bottom-this.visibleRect.top}async eval(h){this.ctx.fillStyle="black",this.ctx.fillRect(0,0,this.rect.width,this.rect.height),this.drawElement(this.element,"white");const u={top:this.visibleRect.top-this.rect.top,bottom:this.visibleRect.bottom-this.rect.top,left:this.visibleRect.left-this.rect.left,right:this.visibleRect.right-this.rect.left,width:this.canvas.width,height:this.canvas.height},t=await this.countVisiblePixels(u);if(t===0)return 0;const i=this.getIntersectingElements(h);for(let a of i)this.drawElement(a,"black");return await this.countVisiblePixels(u)/t}getIntersectingElements(h){const u=new F(this.rect.left,this.rect.right,this.rect.width,this.rect.height,this.element);return h.query(u).map((i)=>i.element).filter((i)=>i!=this.element&&T(i,this.element)&&j(i))}async countVisiblePixels(h){const u=this.ctx.getImageData(h.left,h.top,h.width,h.height);let t=0;for(let i=0;iparseFloat(y)),a=i.clipPath,f={top:t.top-this.rect.top,bottom:t.bottom-this.rect.top,left:t.left-this.rect.left,right:t.right-this.rect.left,width:t.width,height:t.height};if(f.width=f.right-f.left,f.height=f.bottom-f.top,this.ctx.fillStyle=u,a&&a!=="none")a.split(/,| /).forEach((p)=>{const g=p.trim().match(/^([a-z]+)\((.*)\)$/);if(!g)return;switch(g[0]){case"polygon":const v=this.pathFromPolygon(p,t);this.ctx.fill(v);break;default:console.log("Unknown clip path kind: "+g)}});else if(w){const y=new Path2D;if(w.length===1)w[1]=w[0];if(w.length===2)w[2]=w[0];if(w.length===3)w[3]=w[1];y.moveTo(f.left+w[0],f.top),y.arcTo(f.right,f.top,f.right,f.bottom,w[1]),y.arcTo(f.right,f.bottom,f.left,f.bottom,w[2]),y.arcTo(f.left,f.bottom,f.left,f.top,w[3]),y.arcTo(f.left,f.top,f.right,f.top,w[0]),y.closePath(),this.ctx.fill(y)}else this.ctx.fillRect(f.left,f.top,f.width,f.height)}pathFromPolygon(h,u){if(!h||!h.match(/^polygon\((.*)\)$/))throw new Error("Invalid polygon format: "+h);const t=new Path2D,i=h.match(/\d+(\.\d+)?%/g);if(i&&i.length>=2){const w=parseFloat(i[0]),a=parseFloat(i[1]);t.moveTo(w*u.width/100,a*u.height/100);for(let f=2;f{const w=h.slice(i*G,(i+1)*G).filter((f)=>j(f)),a=[];for(let f of w)if(await this.isDeepVisible(f))a.push(f);return a}))).flat()}mapQuadTree(){const h=new F(0,0,window.innerWidth,window.innerHeight),u=new J(h,4),t=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode:(w)=>{if(j(w))return NodeFilter.FILTER_ACCEPT;return NodeFilter.FILTER_REJECT}});let i=t.currentNode;while(i){const w=i,a=w.getBoundingClientRect();u.insert(new F(a.left,a.top,a.width,a.height,w)),i=t.nextNode()}return u}async isDeepVisible(h){return new Promise((u)=>{const t=new IntersectionObserver(async(i)=>{const w=i[0];if(t.disconnect(),w.intersectionRatio=window.innerWidth*d||a.height>=window.innerHeight*d){u(!1);return}const y=await new z(h).eval(this.qt);u(y>=Y)});t.observe(h)})}}var R=S;var L=0.9,b=3,E=["a","button","input","select","textarea"];class A extends W{constructor(){super(...arguments)}async apply(h){const u=h.fixed.concat(h.unknown),{top:t,others:i}=this.getTopLevelElements(u),w=await Promise.all(t.map(async(a)=>this.compareTopWithChildren(a,i)));return{fixed:h.fixed,unknown:w.flat().filter((a)=>h.fixed.indexOf(a)===-1)}}async compareTopWithChildren(h,u){if(E.some((f)=>h.matches(f)))return[h];const t=this.getBranches(h,u),i=h.getBoundingClientRect();if(t.length<=1)return[h];const a=(await Promise.all(t.map(async(f)=>{const y=f.top.getBoundingClientRect();if(y.width/i.widthb)return a;return[h,...a]}getBranches(h,u){const t=this.getFirstHitChildren(h,u);return t.map((i)=>{const w=u.filter((a)=>!t.includes(a)&&i.contains(a));return{top:i,children:w}})}getFirstHitChildren(h,u){const t=h.querySelectorAll(":scope > *"),i=Array.from(t).filter((w)=>u.includes(w));if(i.length>0)return i;return Array.from(t).flatMap((w)=>this.getFirstHitChildren(w,u))}getTopLevelElements(h){const u=[],t=[];for(let i of h)if(!h.some((w)=>w!==i&&w.contains(i)))u.push(i);else t.push(i);return{top:u,others:t}}}var Z=A;class k{filters={visibility:new R,nesting:new Z};async loadElements(){const h=U.join(",");let u=Array.from(document.querySelectorAll(h));const t=this.shadowRoots();for(let p=0;pv.indexOf(p)===g).filter((p)=>!p.closest("svg")&&!u.some((g)=>g.contains(p)));let y={fixed:u,unknown:w};return console.groupCollapsed("Elements"),console.log("Before filters",y),y=await this.filters.visibility.apply(y),console.log("After visibility filter",y),y=await this.filters.nesting.apply(y),console.log("After nesting filter",y),console.groupEnd(),y.fixed.concat(y.unknown)}shadowRoots(){const h=[],u=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode(i){return NodeFilter.FILTER_ACCEPT}});let t;while(t=u.nextNode())if(t&&t.shadowRoot)h.push(t.shadowRoot);return h}}class D{contrastColor(h,u){const t=window.getComputedStyle(h),i=$.fromCSS(t.backgroundColor);return this.getBestContrastColor([i,...u])}getBestContrastColor(h){const u=h.filter((i)=>i.a>0).map((i)=>i.complimentary());let t;if(u.length===0)t=new $(Math.floor(Math.random()*255),Math.floor(Math.random()*255),Math.floor(Math.random()*255));else t=this.getAverageColor(u);if(t.luminance()>q)t=t.withLuminance(q);else if(t.luminance()w+a.r,0)/h.length,t=h.reduce((w,a)=>w+a.g,0)/h.length,i=h.reduce((w,a)=>w+a.b,0)/h.length;return new $(u,t,i)}}class ${h;u;t;i;constructor(h,u,t,i=255){this.r=h;this.g=u;this.b=t;this.a=i;if(h<0||h>255)throw new Error(`Invalid red value: ${h}`);if(u<0||u>255)throw new Error(`Invalid green value: ${u}`);if(t<0||t>255)throw new Error(`Invalid blue value: ${t}`);if(i<0||i>255)throw new Error(`Invalid alpha value: ${i}`);this.r=Math.round(h),this.g=Math.round(u),this.b=Math.round(t),this.a=Math.round(i)}static fromCSS(h){if(h.startsWith("#"))return $.fromHex(h);if(h.startsWith("rgb")){const t=h.replace(/rgba?\(/,"").replace(")","").split(",").map((i)=>parseInt(i.trim()));return new $(...t)}if(h.startsWith("hsl")){const t=h.replace(/hsla?\(/,"").replace(")","").split(",").map((i)=>parseFloat(i.trim()));return $.fromHSL({h:t[0],s:t[1],l:t[2]})}const u=c[h.toLowerCase()];if(u)return $.fromHex(u);throw new Error(`Unknown color format: ${h}`)}static fromHex(h){if(h=h.replace("#",""),h.length===3)h=h.split("").map((w)=>w+w).join("");const u=parseInt(h.substring(0,2),16),t=parseInt(h.substring(2,4),16),i=parseInt(h.substring(4,6),16);if(h.length===8){const w=parseInt(h.substring(6,8),16);return new $(u,t,i,w)}return new $(u,t,i)}static fromHSL(h){const{h:u,s:t,l:i}=h;let w,a,f;if(t===0)w=a=f=i;else{const y=(v,K,P)=>{if(P<0)P+=1;if(P>1)P-=1;if(P<0.16666666666666666)return v+(K-v)*6*P;if(P<0.5)return K;if(P<0.6666666666666666)return v+(K-v)*(0.6666666666666666-P)*6;return v},p=i<0.5?i*(1+t):i+t-i*t,g=2*i-p;w=y(g,p,u+0.3333333333333333),a=y(g,p,u),f=y(g,p,u-0.3333333333333333)}return new $(w*255,a*255,f*255)}luminance(){const h=this.r/255,u=this.g/255,t=this.b/255,i=[h,u,t].map((w)=>{if(w<=0.03928)return w/12.92;return Math.pow((w+0.055)/1.055,2.4)});return 0.2126*i[0]+0.7152*i[1]+0.0722*i[2]}withLuminance(h){const u=this.luminance(),t=h/u,i=Math.min(255,this.r*t),w=Math.min(255,this.g*t),a=Math.min(255,this.b*t);return new $(i,w,a,this.a)}saturation(){return this.toHsl().s}withSaturation(h){const u=this.toHsl();return u.s=h,$.fromHSL(u)}contrast(h){const u=this.luminance(),t=h.luminance();return(Math.max(u,t)+0.05)/(Math.min(u,t)+0.05)}complimentary(){const h=this.toHsl();return h.h=(h.h+0.5)%1,$.fromHSL(h)}toHex(){const h=this.r.toString(16).padStart(2,"0"),u=this.g.toString(16).padStart(2,"0"),t=this.b.toString(16).padStart(2,"0");if(this.a<255){const i=this.a.toString(16).padStart(2,"0");return`#${h}${u}${t}${i}`}return`#${h}${u}${t}`}toHsl(){const h=this.r/255,u=this.g/255,t=this.b/255,i=Math.max(h,u,t),w=Math.min(h,u,t);let a=(i+w)/2,f=(i+w)/2,y=(i+w)/2;if(i===w)a=f=0;else{const p=i-w;switch(f=y>0.5?p/(2-i-w):p/(i+w),i){case h:a=(u-t)/p+(ua.matches(v)))y.classList.add("editable");const p=t.filter((v)=>{return[Math.sqrt(Math.pow(f.left-v.left,2)+Math.pow(f.top-v.top,2)),Math.sqrt(Math.pow(f.right-v.right,2)+Math.pow(f.top-v.top,2)),Math.sqrt(Math.pow(f.left-v.left,2)+Math.pow(f.bottom-v.bottom,2)),Math.sqrt(Math.pow(f.right-v.right,2)+Math.pow(f.bottom-v.bottom,2))].some((P)=>Pv.color),g=this.colors.contrastColor(a,p);y.style.setProperty("--SoM-color",`${g.r}, ${g.g}, ${g.b}`),document.body.appendChild(y),t.push({top:f.top,bottom:f.bottom,left:f.left,right:f.right,width:f.width,height:f.height,color:g}),i.push(y)}for(let w=0;w{let X=0;if(Q.top<0||Q.top+p.height>window.innerHeight||Q.left<0||Q.left+p.width>window.innerWidth)X+=Infinity;else u.concat(t).forEach((M)=>{if(M.top<=f.top&&M.bottom>=f.bottom&&M.left<=f.left&&M.right>=f.right)return;const I=Math.max(0,Math.min(Q.left+p.width,M.left+M.width)-Math.max(Q.left,M.left)),C=Math.max(0,Math.min(Q.top+p.height,M.top+M.height)-Math.max(Q.top,M.top));X+=I*C});return X}),P=v[K.indexOf(Math.min(...K))];y.style.top=`${P.top-f.top}px`,y.style.left=`${P.left-f.left}px`,u.push({top:P.top,left:P.left,right:P.left+p.width,bottom:P.top+p.height,width:p.width,height:p.height}),a.setAttribute("data-SoM",`${w}`)}}getColorByLuminance(h){return h.luminance()>0.5?"black":"white"}}var _=".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.45)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.45) 10px,rgba(var(--SoM-color),.45) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:\"Courier New\",Courier,monospace;background-color:rgba(var(--SoM-color),.7)}";class N{loader=new k;ui=new V;async display(){this.log("Displaying...");const h=performance.now(),u=await this.loader.loadElements();this.clear(),this.ui.display(u),this.log("Done!",`Took ${performance.now()-h}ms to display ${u.length} elements.`)}clear(){document.querySelectorAll(".SoM").forEach((h)=>{h.remove()}),document.querySelectorAll("[data-som]").forEach((h)=>{h.removeAttribute("data-som")})}hide(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="none")}show(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="block")}resolve(h){return document.querySelector(`[data-som="${h}"]`)}log(...h){console.log("%cSoM","color: white; background: #007bff; padding: 2px 5px; border-radius: 5px;",...h)}}if(!document.getElementById("SoM-styles")){const h=document.createElement("style");h.id="SoM-styles",h.innerHTML=_;const u=setInterval(()=>{if(document.head)clearInterval(u),document.head.appendChild(h)},100)}window.SoM=new N;window.SoM.log("Ready!");
diff --git a/src/constants.ts b/src/constants.ts
index 7a04eb6..00c8f1d 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -50,6 +50,13 @@ export const EDITABLE_SELECTORS = [
// Required visibility ratio for an element to be considered visible
export const VISIBILITY_RATIO = 0.6;
+// Maximum ratio of the screen that an element can cover to be considered visible (to avoid huge ads)
+export const MAX_COVER_RATIO = 0.8;
+
+// Size of batch for each promise when processing element visibility
+// Lower batch values may increase performance but in some cases, it can block the main thread
+export const ELEMENT_BATCH_SIZE = 10;
+
// Rate at which elements are sampled for elements on point
// e.g. 0.1 => Make a grid and check on every point every 10% of the size of the element
// This is used to make sure that every element that intersects with the element is checked
diff --git a/src/domain/Filter.ts b/src/domain/Filter.ts
new file mode 100644
index 0000000..70708ff
--- /dev/null
+++ b/src/domain/Filter.ts
@@ -0,0 +1,5 @@
+import InteractiveElements from "./InteractiveElements";
+
+export default abstract class Filter {
+ abstract apply(elements: InteractiveElements): Promise;
+}
diff --git a/src/domain/InteractiveElements.ts b/src/domain/InteractiveElements.ts
new file mode 100644
index 0000000..3c8b305
--- /dev/null
+++ b/src/domain/InteractiveElements.ts
@@ -0,0 +1,6 @@
+type InteractiveElements = {
+ fixed: HTMLElement[];
+ unknown: HTMLElement[];
+};
+
+export default InteractiveElements;
diff --git a/src/domain/filter.ts b/src/domain/filter.ts
deleted file mode 100644
index 1491d3b..0000000
--- a/src/domain/filter.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export abstract class Filter {
- abstract apply(elements: HTMLElement[]): Promise;
-}
diff --git a/src/filters/nesting.ts b/src/filters/nesting.ts
index c6a15a3..89c8864 100644
--- a/src/filters/nesting.ts
+++ b/src/filters/nesting.ts
@@ -1,4 +1,5 @@
-import { Filter } from "../domain/filter";
+import Filter from "@/domain/Filter";
+import InteractiveElements from "@/domain/InteractiveElements";
// Threshold to be considered disjoint from the top-level element
const SIZE_THRESHOLD = 0.9;
@@ -8,11 +9,13 @@ const QUANTITY_THRESHOLD = 3;
const PRIORITY_SELECTOR = ["a", "button", "input", "select", "textarea"];
class NestingFilter extends Filter {
- async apply(elements: HTMLElement[]): Promise {
+ async apply(elements: InteractiveElements): Promise {
// Basically, what we want to do it is compare the size of the top-level elements with the size of their children.
// For that, we make branches and compare with the first children of each of these branches.
// If there are other children beyond that, we'll recursively call this function on them.
- const { top, others } = this.getTopLevelElements(elements);
+
+ const fullElements = elements.fixed.concat(elements.unknown);
+ const { top, others } = this.getTopLevelElements(fullElements);
const results = await Promise.all(
top.map(async (topElement) =>
@@ -20,10 +23,16 @@ class NestingFilter extends Filter {
)
);
- return results.flat();
+ return {
+ fixed: elements.fixed,
+ unknown: results.flat().filter((el) => elements.fixed.indexOf(el) === -1),
+ };
}
- async compareTopWithChildren(top: HTMLElement, children: HTMLElement[]) {
+ async compareTopWithChildren(
+ top: HTMLElement,
+ children: HTMLElement[]
+ ): Promise {
if (PRIORITY_SELECTOR.some((selector) => top.matches(selector))) {
return [top];
}
@@ -53,7 +62,7 @@ class NestingFilter extends Filter {
return [branch.top];
}
- return await this.compareTopWithChildren(branch.top, branch.children);
+ return this.compareTopWithChildren(branch.top, branch.children);
})
);
diff --git a/src/filters/visibility.ts b/src/filters/visibility.ts
deleted file mode 100644
index 41a1f25..0000000
--- a/src/filters/visibility.ts
+++ /dev/null
@@ -1,385 +0,0 @@
-import { VISIBILITY_RATIO, ELEMENT_SAMPLING_RATE } from "../constants";
-import { Filter } from "../domain/filter";
-
-class VisibilityFilter extends Filter {
- async apply(elements: HTMLElement[]): Promise {
- const visibleElements = await Promise.all(
- elements.map(async (element) => {
- if (element.offsetWidth === 0 && element.offsetHeight === 0) {
- return null;
- }
-
- const style = window.getComputedStyle(element);
- if (
- style.display === "none" ||
- style.visibility === "hidden" ||
- style.pointerEvents === "none"
- ) {
- return null;
- }
-
- // Check if any of the element's parents are hidden
- let parent = element.parentElement;
- let passed = true;
- while (parent !== null) {
- const parentStyle = window.getComputedStyle(parent);
- if (
- parentStyle.display === "none" ||
- parentStyle.visibility === "hidden" ||
- parentStyle.pointerEvents === "none"
- ) {
- passed = false;
- break;
- }
- parent = parent.parentElement;
- }
- if (!passed) {
- return null;
- }
-
- // This checks if the element is in the viewport AND that is it visible more than VISIBILITY_RATIO (0.7)
- const isVisible = await this.isElementVisible(element);
- if (!isVisible) {
- return null;
- }
-
- return element;
- })
- );
-
- return visibleElements.filter(
- (element) => element !== null
- ) as HTMLElement[];
- }
-
- async isElementVisible(element: HTMLElement) {
- return new Promise((resolve) => {
- const observer = new IntersectionObserver(async (entries) => {
- const entry = entries[0];
- observer.disconnect();
-
- if (entry.intersectionRatio < VISIBILITY_RATIO) {
- resolve(false);
- return;
- }
-
- const rect = element.getBoundingClientRect();
-
- // If rect is way too small, ignore it
- if (rect.width <= 1 || rect.height <= 1) {
- resolve(false);
- return;
- }
-
- // If rect is covering more than 80% of the screen ignore it (we do not want to consider full screen ads)
- if (
- rect.width >= window.innerWidth * 0.8 ||
- rect.height >= window.innerHeight * 0.8
- ) {
- resolve(false);
- return;
- }
-
- // IntersectionObserver only checks intersection with the viewport, not with other elements
- // Thus, we need to calculate the visible area ratio relative to the intersecting elements
- const visibleAreaRatio = await this.getVisibilityRatio(element, rect);
- resolve(visibleAreaRatio >= VISIBILITY_RATIO);
- });
- observer.observe(element);
- });
- }
-
- async getVisibilityRatio(element: HTMLElement, rect: DOMRect) {
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d", {
- willReadFrequently: true,
- });
-
- if (!ctx) {
- throw new Error("Could not get 2D context");
- }
-
- const elementZIndex = parseInt(
- window.getComputedStyle(element).zIndex || "0",
- 10
- );
-
- // Ensure the canvas size matches the element's size
- canvas.width = rect.width;
- canvas.height = rect.height;
-
- ctx.fillStyle = "black";
- ctx.fillRect(0, 0, canvas.width, canvas.height);
-
- // The whole canvas may not be visibile => we need to check the visible area for the viewport
- const visibleRect = {
- top: Math.max(0, rect.top),
- left: Math.max(0, rect.left),
- bottom: Math.min(window.innerHeight, rect.bottom),
- right: Math.min(window.innerWidth, rect.right),
- width: rect.width,
- height: rect.height,
- };
- visibleRect.width = visibleRect.right - visibleRect.left;
- visibleRect.height = visibleRect.bottom - visibleRect.top;
-
- // Draw the element on the canvas
- this.drawElement(element, ctx, rect, rect, "white");
-
- // Count pixels that are visible after drawing the element
- const totalPixels = this.countVisiblePixels(ctx, {
- top: visibleRect.top - rect.top,
- left: visibleRect.left - rect.left,
- width: canvas.width,
- height: canvas.height,
- });
-
- const elements = await this.getIntersectingElements(element);
-
- // Then, we draw all the intersecting elements on our canvas
- await Promise.all(
- elements.map(async (el) => {
- const elRect = el.getBoundingClientRect();
-
- // Try drawing it on the canvas
- this.drawElement(el, ctx, elRect, rect, "black");
- })
- );
-
- // Finally, calculate the visible pixels after drawing all the elements
- const visiblePixels = this.countVisiblePixels(ctx, {
- top: visibleRect.top - rect.top,
- left: visibleRect.left - rect.left,
- width: visibleRect.width,
- height: visibleRect.height,
- });
-
- canvas.remove();
-
- // Prevent NaN
- if (totalPixels === 0) {
- return 0;
- }
-
- return visiblePixels / totalPixels;
- }
-
- async getIntersectingElements(element: HTMLElement) {
- const elementZIndex = parseInt(
- window.getComputedStyle(element).zIndex || "0",
- 10
- );
-
- const rect = element.getBoundingClientRect();
-
- // Find all elements that can possibly intersect with the element
- // For this, we make a simple grid of points following ELEMENT_SAMPLING_RATE and check at each of those points
- // Hence, we end up with (1 / ELEMENT_SAMPLING_RATE) ^ 2 points (or less if the element is too small)
- const foundElements = await Promise.all(
- Array.from({ length: Math.ceil(1 / ELEMENT_SAMPLING_RATE) }).map(
- async (_, i) => {
- return Array.from({
- length: Math.ceil(1 / ELEMENT_SAMPLING_RATE),
- }).map((_, j) => {
- const elements = document.elementsFromPoint(
- rect.left + rect.width * ELEMENT_SAMPLING_RATE * i,
- rect.top + rect.height * ELEMENT_SAMPLING_RATE * j
- );
-
- // Make sure the current element is included (point may miss if the element is not rectangular)
- if (!elements.includes(element)) {
- return [];
- }
-
- const currentIndex = elements.indexOf(element);
-
- return elements.slice(0, currentIndex);
- });
- }
- )
- );
-
- // We remove duplicates and flatten the array
- const uniqueElements = Array.from(
- new Set(foundElements.flat(2).filter((el) => el !== element))
- );
-
- // We also remove all elements that have a lower zIndex, or are parents (that have lower/no zIndex)
- let elements: Element[] = [];
- for (const el of uniqueElements) {
- const elZIndex = parseInt(window.getComputedStyle(el).zIndex || "0", 10);
- if (elZIndex < elementZIndex) {
- continue;
- }
-
- if (el.contains(element) || element.contains(el)) {
- continue;
- }
-
- elements.push(el);
- }
-
- // After that, remove anything that is a children of any other non-filtered element
- elements = elements.filter((el) => {
- for (const other of elements) {
- if (el !== other && other.contains(el)) {
- return false;
- }
- }
- return true;
- });
-
- return elements;
- }
-
- countVisiblePixels(
- ctx: CanvasRenderingContext2D,
- rect: {
- top: number;
- left: number;
- width: number;
- height: number;
- }
- ) {
- const data = ctx.getImageData(
- rect.left,
- rect.top,
- rect.width,
- rect.height
- ).data;
-
- let visiblePixels = 0;
- for (let i = 0; i < data.length; i += 4) {
- if (data[i] > 0) {
- // Just check R channel
- visiblePixels++;
- }
- }
- return visiblePixels;
- }
-
- drawElement(
- element: Element,
- ctx: CanvasRenderingContext2D,
- rect: DOMRect,
- baseRect: DOMRect,
- color = "black"
- ) {
- const styles = window.getComputedStyle(element);
-
- const radius = styles.borderRadius?.split(" ").map((r) => parseFloat(r));
- const clipPath = styles.clipPath;
-
- const offsetRect = {
- top: rect.top - baseRect.top,
- bottom: rect.bottom - baseRect.top,
- left: rect.left - baseRect.left,
- right: rect.right - baseRect.left,
- width: rect.width,
- height: rect.height,
- };
- offsetRect.width = offsetRect.right - offsetRect.left;
- offsetRect.height = offsetRect.bottom - offsetRect.top;
-
- ctx.fillStyle = color;
-
- if (clipPath && clipPath !== "none") {
- const clips = clipPath.split(/,| /);
-
- clips.forEach((clip) => {
- const kind = clip.trim().match(/^([a-z]+)\((.*)\)$/);
- if (!kind) {
- return;
- }
-
- switch (kind[0]) {
- case "polygon":
- const path = this.pathFromPolygon(clip, rect);
- ctx.fill(path);
- break;
- default:
- console.log("Unknown clip path kind: " + kind);
- }
- });
- } else if (radius) {
- const path = new Path2D();
-
- if (radius.length === 1) radius[1] = radius[0];
- if (radius.length === 2) radius[2] = radius[0];
- if (radius.length === 3) radius[3] = radius[1];
-
- // Go to the top left corner
- path.moveTo(offsetRect.left + radius[0], offsetRect.top);
-
- path.arcTo(
- // Arc to the top right corner
- offsetRect.right,
- offsetRect.top,
- offsetRect.right,
- offsetRect.bottom,
- radius[1]
- );
-
- path.arcTo(
- offsetRect.right,
- offsetRect.bottom,
- offsetRect.left,
- offsetRect.bottom,
- radius[2]
- );
-
- path.arcTo(
- offsetRect.left,
- offsetRect.bottom,
- offsetRect.left,
- offsetRect.top,
- radius[3]
- );
-
- path.arcTo(
- offsetRect.left,
- offsetRect.top,
- offsetRect.right,
- offsetRect.top,
- radius[0]
- );
- path.closePath();
-
- ctx.fill(path);
- } else {
- ctx.fillRect(
- offsetRect.left,
- offsetRect.top,
- offsetRect.width,
- offsetRect.height
- );
- }
- }
-
- pathFromPolygon(polygon: string, rect: DOMRect) {
- if (!polygon || !polygon.match(/^polygon\((.*)\)$/)) {
- throw new Error("Invalid polygon format: " + polygon);
- }
-
- const path = new Path2D();
- const points = polygon.match(/\d+(\.\d+)?%/g);
-
- if (points && points.length >= 2) {
- const startX = parseFloat(points[0]);
- const startY = parseFloat(points[1]);
- path.moveTo((startX * rect.width) / 100, (startY * rect.height) / 100);
-
- for (let i = 2; i < points.length; i += 2) {
- const x = parseFloat(points[i]);
- const y = parseFloat(points[i + 1]);
- path.lineTo((x * rect.width) / 100, (y * rect.height) / 100);
- }
-
- path.closePath();
- }
-
- return path;
- }
-}
-
-export default VisibilityFilter;
diff --git a/src/filters/visibility/canvas.ts b/src/filters/visibility/canvas.ts
new file mode 100644
index 0000000..b062ace
--- /dev/null
+++ b/src/filters/visibility/canvas.ts
@@ -0,0 +1,220 @@
+import QuadTree, { Rectangle } from "./quad";
+import { isAbove, isVisible } from "./utils";
+
+export default class VisibilityCanvas {
+ private readonly canvas: OffscreenCanvas;
+ private readonly ctx: OffscreenCanvasRenderingContext2D;
+
+ private readonly rect: AbstractRect;
+ private readonly visibleRect: AbstractRect;
+
+ constructor(private readonly element: HTMLElement) {
+ this.element = element;
+ this.rect = this.element.getBoundingClientRect();
+ this.canvas = new OffscreenCanvas(this.rect.width, this.rect.height);
+ this.ctx = this.canvas.getContext("2d", {
+ willReadFrequently: true,
+ })!;
+
+ this.visibleRect = {
+ top: Math.max(0, this.rect.top),
+ left: Math.max(0, this.rect.left),
+ bottom: Math.min(window.innerHeight, this.rect.bottom),
+ right: Math.min(window.innerWidth, this.rect.right),
+ width: this.rect.width,
+ height: this.rect.height,
+ };
+ this.visibleRect.width = this.visibleRect.right - this.visibleRect.left;
+ this.visibleRect.height = this.visibleRect.bottom - this.visibleRect.top;
+ }
+
+ async eval(qt: QuadTree): Promise {
+ this.ctx.fillStyle = "black";
+ this.ctx.fillRect(0, 0, this.rect.width, this.rect.height);
+
+ this.drawElement(this.element, "white");
+
+ const canvasVisRect: AbstractRect = {
+ top: this.visibleRect.top - this.rect.top,
+ bottom: this.visibleRect.bottom - this.rect.top,
+ left: this.visibleRect.left - this.rect.left,
+ right: this.visibleRect.right - this.rect.left,
+ width: this.canvas.width,
+ height: this.canvas.height,
+ };
+
+ const totalPixels = await this.countVisiblePixels(canvasVisRect);
+ if (totalPixels === 0) return 0;
+
+ const elements = this.getIntersectingElements(qt);
+ for (const el of elements) {
+ this.drawElement(el, "black");
+ }
+
+ const visiblePixels = await this.countVisiblePixels(canvasVisRect);
+ return visiblePixels / totalPixels;
+ }
+
+ private getIntersectingElements(qt: QuadTree): HTMLElement[] {
+ const range = new Rectangle(
+ this.rect.left,
+ this.rect.right,
+ this.rect.width,
+ this.rect.height,
+ this.element
+ );
+ const candidates = qt.query(range);
+
+ return candidates
+ .map((candidate) => candidate.element!)
+ .filter(
+ (el) => el != this.element && isAbove(el, this.element) && isVisible(el)
+ );
+ }
+
+ private async countVisiblePixels(visibleRect: AbstractRect): Promise {
+ const imageData = this.ctx.getImageData(
+ visibleRect.left,
+ visibleRect.top,
+ visibleRect.width,
+ visibleRect.height
+ );
+
+ let visiblePixels = 0;
+ for (let i = 0; i < imageData.data.length; i += 4) {
+ const isWhite = imageData.data[i] === 255;
+ if (isWhite) {
+ visiblePixels++;
+ }
+ }
+
+ return visiblePixels;
+ }
+
+ private drawElement(element: Element, color = "black") {
+ const rect = element.getBoundingClientRect();
+ const styles = window.getComputedStyle(element);
+
+ const radius = styles.borderRadius?.split(" ").map((r) => parseFloat(r));
+ const clipPath = styles.clipPath;
+
+ const offsetRect = {
+ top: rect.top - this.rect.top,
+ bottom: rect.bottom - this.rect.top,
+ left: rect.left - this.rect.left,
+ right: rect.right - this.rect.left,
+ width: rect.width,
+ height: rect.height,
+ };
+ offsetRect.width = offsetRect.right - offsetRect.left;
+ offsetRect.height = offsetRect.bottom - offsetRect.top;
+
+ this.ctx.fillStyle = color;
+
+ if (clipPath && clipPath !== "none") {
+ const clips = clipPath.split(/,| /);
+
+ clips.forEach((clip) => {
+ const kind = clip.trim().match(/^([a-z]+)\((.*)\)$/);
+ if (!kind) {
+ return;
+ }
+
+ switch (kind[0]) {
+ case "polygon":
+ const path = this.pathFromPolygon(clip, rect);
+ this.ctx.fill(path);
+ break;
+ default:
+ console.log("Unknown clip path kind: " + kind);
+ }
+ });
+ } else if (radius) {
+ const path = new Path2D();
+
+ if (radius.length === 1) radius[1] = radius[0];
+ if (radius.length === 2) radius[2] = radius[0];
+ if (radius.length === 3) radius[3] = radius[1];
+
+ // Go to the top left corner
+ path.moveTo(offsetRect.left + radius[0], offsetRect.top);
+
+ path.arcTo(
+ // Arc to the top right corner
+ offsetRect.right,
+ offsetRect.top,
+ offsetRect.right,
+ offsetRect.bottom,
+ radius[1]
+ );
+
+ path.arcTo(
+ offsetRect.right,
+ offsetRect.bottom,
+ offsetRect.left,
+ offsetRect.bottom,
+ radius[2]
+ );
+
+ path.arcTo(
+ offsetRect.left,
+ offsetRect.bottom,
+ offsetRect.left,
+ offsetRect.top,
+ radius[3]
+ );
+
+ path.arcTo(
+ offsetRect.left,
+ offsetRect.top,
+ offsetRect.right,
+ offsetRect.top,
+ radius[0]
+ );
+ path.closePath();
+
+ this.ctx.fill(path);
+ } else {
+ this.ctx.fillRect(
+ offsetRect.left,
+ offsetRect.top,
+ offsetRect.width,
+ offsetRect.height
+ );
+ }
+ }
+
+ private pathFromPolygon(polygon: string, rect: AbstractRect) {
+ if (!polygon || !polygon.match(/^polygon\((.*)\)$/)) {
+ throw new Error("Invalid polygon format: " + polygon);
+ }
+
+ const path = new Path2D();
+ const points = polygon.match(/\d+(\.\d+)?%/g);
+
+ if (points && points.length >= 2) {
+ const startX = parseFloat(points[0]);
+ const startY = parseFloat(points[1]);
+ path.moveTo((startX * rect.width) / 100, (startY * rect.height) / 100);
+
+ for (let i = 2; i < points.length; i += 2) {
+ const x = parseFloat(points[i]);
+ const y = parseFloat(points[i + 1]);
+ path.lineTo((x * rect.width) / 100, (y * rect.height) / 100);
+ }
+
+ path.closePath();
+ }
+
+ return path;
+ }
+}
+
+type AbstractRect = {
+ top: number;
+ left: number;
+ bottom: number;
+ right: number;
+ width: number;
+ height: number;
+};
diff --git a/src/filters/visibility/index.ts b/src/filters/visibility/index.ts
new file mode 100644
index 0000000..19c93c9
--- /dev/null
+++ b/src/filters/visibility/index.ts
@@ -0,0 +1,119 @@
+import {
+ VISIBILITY_RATIO,
+ ELEMENT_BATCH_SIZE,
+ MAX_COVER_RATIO,
+} from "@/constants";
+import Filter from "@/domain/Filter";
+import InteractiveElements from "@/domain/InteractiveElements";
+
+import QuadTree, { Rectangle } from "./quad";
+import { isVisible } from "./utils";
+import VisibilityCanvas from "./canvas";
+
+class VisibilityFilter extends Filter {
+ private qt!: QuadTree;
+
+ async apply(elements: InteractiveElements): Promise {
+ this.qt = this.mapQuadTree();
+
+ const results = await Promise.all([
+ this.applyScoped(elements.fixed),
+ this.applyScoped(elements.unknown),
+ ]);
+
+ return {
+ fixed: results[0],
+ unknown: results[1],
+ };
+ }
+
+ async applyScoped(elements: HTMLElement[]): Promise {
+ const results = await Promise.all(
+ Array.from({
+ length: Math.ceil(elements.length / ELEMENT_BATCH_SIZE),
+ }).map(async (_, i) => {
+ const batch = elements
+ .slice(i * ELEMENT_BATCH_SIZE, (i + 1) * ELEMENT_BATCH_SIZE)
+ .filter((el) => isVisible(el));
+
+ // Now, let's process the batch
+ const visibleElements: HTMLElement[] = [];
+ for (const element of batch) {
+ const isVisible = await this.isDeepVisible(element);
+ if (isVisible) {
+ visibleElements.push(element);
+ }
+ }
+
+ return visibleElements;
+ })
+ );
+
+ return results.flat();
+ }
+
+ mapQuadTree(): QuadTree {
+ const boundary = new Rectangle(0, 0, window.innerWidth, window.innerHeight);
+ const qt = new QuadTree(boundary, 4);
+
+ // use a tree walker and also filter out invisible elements
+ const walker = document.createTreeWalker(
+ document.body,
+ NodeFilter.SHOW_ELEMENT,
+ {
+ acceptNode: (node) => {
+ const element = node as HTMLElement;
+ if (isVisible(element)) {
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ return NodeFilter.FILTER_REJECT;
+ },
+ }
+ );
+
+ let currentNode: Node | null = walker.currentNode;
+ while (currentNode) {
+ const element = currentNode as HTMLElement;
+ const rect = element.getBoundingClientRect();
+ qt.insert(
+ new Rectangle(rect.left, rect.top, rect.width, rect.height, element)
+ );
+ currentNode = walker.nextNode();
+ }
+
+ return qt;
+ }
+
+ async isDeepVisible(element: HTMLElement) {
+ return new Promise((resolve) => {
+ const observer = new IntersectionObserver(async (entries) => {
+ const entry = entries[0];
+ observer.disconnect();
+
+ if (entry.intersectionRatio < VISIBILITY_RATIO) {
+ resolve(false);
+ return;
+ }
+
+ const rect = element.getBoundingClientRect();
+ // If rect is covering more than size * MAX_COVER_RATIO of the screen ignore it (we do not want to consider full screen ads)
+ if (
+ rect.width >= window.innerWidth * MAX_COVER_RATIO ||
+ rect.height >= window.innerHeight * MAX_COVER_RATIO
+ ) {
+ resolve(false);
+ return;
+ }
+
+ // IntersectionObserver only checks intersection with the viewport, not with other elements
+ // Thus, we need to calculate the visible area ratio relative to the intersecting elements
+ const canvas = new VisibilityCanvas(element);
+ const visibleAreaRatio = await canvas.eval(this.qt);
+ resolve(visibleAreaRatio >= VISIBILITY_RATIO);
+ });
+ observer.observe(element);
+ });
+ }
+}
+
+export default VisibilityFilter;
diff --git a/src/filters/visibility/quad.ts b/src/filters/visibility/quad.ts
new file mode 100644
index 0000000..ee3956a
--- /dev/null
+++ b/src/filters/visibility/quad.ts
@@ -0,0 +1,127 @@
+export class Rectangle {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ element: HTMLElement | null;
+
+ constructor(
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ element: HTMLElement | null = null
+ ) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ this.element = element;
+ }
+
+ contains(rect: Rectangle): boolean {
+ return (
+ rect.x >= this.x &&
+ rect.x + rect.width <= this.x + this.width &&
+ rect.y >= this.y &&
+ rect.y + rect.height <= this.y + this.height
+ );
+ }
+
+ intersects(rect: Rectangle): boolean {
+ return !(
+ rect.x > this.x + this.width ||
+ rect.x + rect.width < this.x ||
+ rect.y > this.y + this.height ||
+ rect.y + rect.height < this.y
+ );
+ }
+}
+
+export default class QuadTree {
+ boundary: Rectangle;
+ capacity: number;
+ elements: Rectangle[];
+ divided: boolean;
+ northeast: QuadTree | null;
+ northwest: QuadTree | null;
+ southeast: QuadTree | null;
+ southwest: QuadTree | null;
+
+ constructor(boundary: Rectangle, capacity: number) {
+ this.boundary = boundary;
+ this.capacity = capacity;
+ this.elements = [];
+ this.divided = false;
+ this.northeast = null;
+ this.northwest = null;
+ this.southeast = null;
+ this.southwest = null;
+ }
+
+ subdivide(): void {
+ const x = this.boundary.x;
+ const y = this.boundary.y;
+ const w = this.boundary.width / 2;
+ const h = this.boundary.height / 2;
+
+ const ne = new Rectangle(x + w, y, w, h);
+ const nw = new Rectangle(x, y, w, h);
+ const se = new Rectangle(x + w, y + h, w, h);
+ const sw = new Rectangle(x, y + h, w, h);
+
+ this.northeast = new QuadTree(ne, this.capacity);
+ this.northwest = new QuadTree(nw, this.capacity);
+ this.southeast = new QuadTree(se, this.capacity);
+ this.southwest = new QuadTree(sw, this.capacity);
+
+ this.divided = true;
+ }
+
+ insert(element: Rectangle): boolean {
+ if (!this.boundary.intersects(element)) {
+ return false;
+ }
+
+ if (this.elements.length < this.capacity) {
+ this.elements.push(element);
+ return true;
+ } else {
+ if (!this.divided) {
+ this.subdivide();
+ }
+
+ if (this.northeast!.insert(element)) {
+ return true;
+ } else if (this.northwest!.insert(element)) {
+ return true;
+ } else if (this.southeast!.insert(element)) {
+ return true;
+ } else if (this.southwest!.insert(element)) {
+ return true;
+ }
+ return false;
+ }
+ }
+
+ query(range: Rectangle, found: Rectangle[] = []): Rectangle[] {
+ if (!this.boundary.intersects(range)) {
+ return found;
+ }
+
+ for (let element of this.elements) {
+ if (range.intersects(element)) {
+ found.push(element);
+ }
+ }
+
+ if (this.divided) {
+ this.northwest!.query(range, found);
+ this.northeast!.query(range, found);
+ this.southwest!.query(range, found);
+ this.southeast!.query(range, found);
+ }
+
+ return found;
+ }
+}
diff --git a/src/filters/visibility/utils.ts b/src/filters/visibility/utils.ts
new file mode 100644
index 0000000..3525639
--- /dev/null
+++ b/src/filters/visibility/utils.ts
@@ -0,0 +1,71 @@
+/*
+ * Utility
+ */
+export function isAbove(
+ element: HTMLElement,
+ referenceElement: HTMLElement
+): boolean {
+ const elementZIndex = window.getComputedStyle(element).zIndex;
+ const referenceElementZIndex =
+ window.getComputedStyle(referenceElement).zIndex;
+
+ const elementPosition = element.compareDocumentPosition(referenceElement);
+
+ // Check if element is a child of referenceElement
+ if (elementPosition & Node.DOCUMENT_POSITION_CONTAINS) {
+ return false;
+ }
+
+ // Check if referenceElement is a child of element
+ if (elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) {
+ return true;
+ }
+
+ // Compare z-index if both are not 'auto'
+ if (elementZIndex !== "auto" && referenceElementZIndex !== "auto") {
+ return parseInt(elementZIndex) > parseInt(referenceElementZIndex);
+ }
+
+ // If one of them has z-index 'auto', we need to compare their DOM position
+ if (elementZIndex === "auto" || referenceElementZIndex === "auto") {
+ return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING);
+ }
+
+ // As a fallback, compare document order
+ return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING);
+}
+
+export function isVisible(element: HTMLElement): boolean {
+ if (element.offsetWidth === 0 && element.offsetHeight === 0) {
+ return false;
+ }
+
+ const rect = element.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) {
+ return false;
+ }
+
+ const style = window.getComputedStyle(element);
+ if (
+ style.display === "none" ||
+ style.visibility === "hidden" ||
+ style.pointerEvents === "none"
+ ) {
+ return false;
+ }
+
+ let parent = element.parentElement;
+ while (parent !== null) {
+ const parentStyle = window.getComputedStyle(parent);
+ if (
+ parentStyle.display === "none" ||
+ parentStyle.visibility === "hidden" ||
+ parentStyle.pointerEvents === "none"
+ ) {
+ return false;
+ }
+ parent = parent.parentElement;
+ }
+
+ return true;
+}
diff --git a/src/loader.ts b/src/loader.ts
index 8ca455a..38b4b1c 100644
--- a/src/loader.ts
+++ b/src/loader.ts
@@ -1,4 +1,5 @@
import { SELECTORS } from "./constants";
+import InteractiveElements from "./domain/InteractiveElements";
import { VisibilityFilter, NestingFilter } from "./filters";
export default class Loader {
@@ -10,50 +11,64 @@ export default class Loader {
async loadElements() {
const selector = SELECTORS.join(",");
- let preselectedElements = Array.from(document.querySelectorAll(selector));
+ let fixedElements = Array.from(
+ document.querySelectorAll(selector)
+ ) as HTMLElement[];
// Let's also do a querySelectorAll inside all the shadow roots (for custom elements, e.g. reddit)
const shadowRoots = this.shadowRoots();
for (let i = 0; i < shadowRoots.length; i++) {
- preselectedElements = preselectedElements.concat(
+ fixedElements = fixedElements.concat(
Array.from(shadowRoots[i].querySelectorAll(selector))
);
}
- const allElements = document.querySelectorAll("*");
+ let unknownElements: HTMLElement[] = [];
+ const walker = document.createTreeWalker(
+ document.body,
+ NodeFilter.SHOW_ELEMENT,
+ {
+ acceptNode() {
+ return NodeFilter.FILTER_ACCEPT;
+ },
+ }
+ );
- let clickableElements: HTMLElement[] = [];
- for (let i = 0; i < allElements.length; i++) {
+ let node: Node | null;
+ while ((node = walker.nextNode())) {
+ const el = node as HTMLElement;
if (
- // Make sure it does not match the selector too to avoid duplicates
- !allElements[i].matches(selector) &&
- window.getComputedStyle(allElements[i]).cursor === "pointer"
+ !el.matches(selector) &&
+ window.getComputedStyle(el).cursor === "pointer"
) {
- clickableElements.push(allElements[i] as HTMLElement);
+ unknownElements.push(el);
}
}
- clickableElements = Array.from(clickableElements)
+ unknownElements = Array.from(unknownElements)
.filter((element, index, self) => self.indexOf(element) === index)
.filter(
(element) =>
!element.closest("svg") &&
- !preselectedElements.some((el) => el.contains(element))
+ !fixedElements.some((el) => el.contains(element))
);
- const visiblePreselected = await this.filters.visibility.apply(
- preselectedElements as HTMLElement[]
- );
+ let interactive: InteractiveElements = {
+ fixed: fixedElements,
+ unknown: unknownElements,
+ };
- const visibleClickable =
- await this.filters.visibility.apply(clickableElements);
- const nestedAll = await this.filters.nesting.apply(
- visibleClickable.concat(visiblePreselected)
- );
+ console.groupCollapsed("Elements");
+ console.log("Before filters", interactive);
+
+ interactive = await this.filters.visibility.apply(interactive);
+ console.log("After visibility filter", interactive);
+
+ interactive = await this.filters.nesting.apply(interactive);
+ console.log("After nesting filter", interactive);
+ console.groupEnd();
- return visiblePreselected
- .concat(nestedAll)
- .filter((element, index, self) => self.indexOf(element) === index);
+ return interactive.fixed.concat(interactive.unknown);
}
shadowRoots() {
diff --git a/src/ui.ts b/src/ui.ts
index 0e3f1a3..781cd0e 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -69,7 +69,6 @@ export default class UI {
})
.map((box) => box.color);
- console.groupCollapsed(`Element: ${element.tagName} (${i})`);
const color = this.colors.contrastColor(element, surroundingColors);
// Set color as variable to be used in CSS
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..355aa34
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "Node",
+
+ "composite": true,
+ "strict": true,
+ "useDefineForClassFields": true,
+ "allowJs": false,
+ "skipLibCheck": false,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+
+ "lib": ["DOM", "ESNext"],
+
+ "baseUrl": ".",
+ "outDir": "dist",
+ "rootDir": "src",
+
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "types/**/*.d.ts"],
+ "exclude": ["node_modules", "dist"]
+}
From 428a3423b605dd7b0004eb9013dc965fd9abac58 Mon Sep 17 00:00:00 2001
From: Brewen Couaran <45310490+brewcoua@users.noreply.github.com>
Date: Sat, 8 Jun 2024 11:11:14 +0200
Subject: [PATCH 3/5] fix(visibility): missing candidates from QT query &
parent zIndex relationship for isAbove
---
bun.lockb | Bin 2699 -> 2337 bytes
dist/SoM.js | 63 +++--
dist/SoM.min.js | 2 +-
package.json | 21 +-
src/filters/visibility/canvas.ts | 431 ++++++++++++++++---------------
src/filters/visibility/index.ts | 225 ++++++++--------
src/filters/visibility/quad.ts | 249 +++++++++---------
src/filters/visibility/utils.ts | 118 +++++----
src/style.css | 44 ++--
9 files changed, 590 insertions(+), 563 deletions(-)
diff --git a/bun.lockb b/bun.lockb
index 560b4d23acb810091fa78c41e0e3d8e846e555a7..52e2bdb64288959ff6a62315a57b81b9778925f4 100755
GIT binary patch
delta 389
zcmeAcT_`j`PjjKAv9RI2-i1%sB`x{5F0%@F`PYH}lM&g2T#qg*?n?mxpixq{6`9%K*0zyA;b
zVuKv;1FD9DZSpxbBQ6UzkOTvR2Z*aC1yaET#2{yboLB*se!w=_k6mkW4Z9cPg2@lr
lCAqZNfwDlL#XgyZ!+|RT%I)Ns?8l)rIgdkO^8=36i~zPZQ=I?+
delta 617
zcmZ1|)GazePm|x^d+*FeCye)gRd|_mS^4nf^(^9tXYg4niOXFLE%D|0&B*`;Y!k!f
zz4F)~90rE+)Fj>F{9Fb(pa35OLqkDPYDr0EYLO_A57g1{2}pARX&}V_6l-AO*!%ly
zq7Gl-cdvu|8K>eVZpaYO0m``mR@6h>L*7sl{mycx%y|vrl{@zEi=#YTCJ~6
zV`Kece&gQeDZ3{0Oj;(`KflJRqxQK%S!Q0i-P_5!&JP2n4n9j*_{3Ay-nzl0nR7BP
zv&7`HOx}#llO>tMC!b*Eom|W8&B!|WBy;k_2O>aku}n5#;ov9$Iza&F<;fdaawe}~
zIXbz4$&hOUG*pkUO|E9Ok^1)^0zj?>`RW5ynuUGxHC7`&kcSM|L6X4G0|_a~fmAUA
zF%uAj{034|0afygeR2?+*5nE{KRZ}l!Qz04G0srWK+lkY0Tx?N-t3Y(pz!x16Jwo$
zo|&Ej&;|~mSwP^zG5Hmn16Ki*`XoEdZ7yS9%?JR%_m|!P
diff --git a/dist/SoM.js b/dist/SoM.js
index ae2ea08..bb90555 100644
--- a/dist/SoM.js
+++ b/dist/SoM.js
@@ -117,23 +117,27 @@ class QuadTree {
if (!this.boundary.intersects(element)) {
return false;
}
- if (this.elements.length < this.capacity) {
+ if (this.elements.length < this.capacity && !this.divided) {
this.elements.push(element);
return true;
} else {
if (!this.divided) {
this.subdivide();
}
- if (this.northeast.insert(element)) {
- return true;
- } else if (this.northwest.insert(element)) {
- return true;
- } else if (this.southeast.insert(element)) {
- return true;
- } else if (this.southwest.insert(element)) {
- return true;
+ let inserted = false;
+ if (this.northeast.boundary.intersects(element)) {
+ inserted = this.northeast.insert(element) || inserted;
}
- return false;
+ if (this.northwest.boundary.intersects(element)) {
+ inserted = this.northwest.insert(element) || inserted;
+ }
+ if (this.southeast.boundary.intersects(element)) {
+ inserted = this.southeast.insert(element) || inserted;
+ }
+ if (this.southwest.boundary.intersects(element)) {
+ inserted = this.southwest.insert(element) || inserted;
+ }
+ return inserted;
}
}
query(range, found = []) {
@@ -151,26 +155,31 @@ class QuadTree {
this.southwest.query(range, found);
this.southeast.query(range, found);
}
- return found;
+ return found.filter((el, i, arr) => arr.indexOf(el) === i);
}
}
// src/filters/visibility/utils.ts
function isAbove(element, referenceElement) {
- const elementZIndex = window.getComputedStyle(element).zIndex;
- const referenceElementZIndex = window.getComputedStyle(referenceElement).zIndex;
+ function getEffectiveZIndex(element2) {
+ while (element2) {
+ const zIndex = window.getComputedStyle(element2).zIndex;
+ if (zIndex !== "auto") {
+ const zIndexValue = parseInt(zIndex, 10);
+ return isNaN(zIndexValue) ? 0 : zIndexValue;
+ }
+ element2 = element2.parentElement;
+ }
+ return 0;
+ }
+ const elementZIndex = getEffectiveZIndex(element);
+ const referenceElementZIndex = getEffectiveZIndex(referenceElement);
const elementPosition = element.compareDocumentPosition(referenceElement);
- if (elementPosition & Node.DOCUMENT_POSITION_CONTAINS) {
+ if (elementPosition & Node.DOCUMENT_POSITION_CONTAINS || elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) {
return false;
}
- if (elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) {
- return true;
- }
- if (elementZIndex !== "auto" && referenceElementZIndex !== "auto") {
- return parseInt(elementZIndex) > parseInt(referenceElementZIndex);
- }
- if (elementZIndex === "auto" || referenceElementZIndex === "auto") {
- return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING);
+ if (elementZIndex !== referenceElementZIndex) {
+ return elementZIndex < referenceElementZIndex;
}
return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING);
}
@@ -212,6 +221,7 @@ class VisibilityCanvas {
this.ctx = this.canvas.getContext("2d", {
willReadFrequently: true
});
+ this.ctx.imageSmoothingEnabled = false;
this.visibleRect = {
top: Math.max(0, this.rect.top),
left: Math.max(0, this.rect.left),
@@ -225,7 +235,7 @@ class VisibilityCanvas {
}
async eval(qt) {
this.ctx.fillStyle = "black";
- this.ctx.fillRect(0, 0, this.rect.width, this.rect.height);
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.drawElement(this.element, "white");
const canvasVisRect = {
top: this.visibleRect.top - this.rect.top,
@@ -248,13 +258,13 @@ class VisibilityCanvas {
getIntersectingElements(qt) {
const range = new Rectangle(this.rect.left, this.rect.right, this.rect.width, this.rect.height, this.element);
const candidates = qt.query(range);
- return candidates.map((candidate) => candidate.element).filter((el) => el != this.element && isAbove(el, this.element) && isVisible(el));
+ return candidates.map((candidate) => candidate.element).filter((el) => el != this.element && isAbove(this.element, el) && isVisible(el));
}
async countVisiblePixels(visibleRect) {
const imageData = this.ctx.getImageData(visibleRect.left, visibleRect.top, visibleRect.width, visibleRect.height);
let visiblePixels = 0;
for (let i = 0;i < imageData.data.length; i += 4) {
- const isWhite = imageData.data[i] === 255;
+ const isWhite = imageData.data[i + 1] === 255;
if (isWhite) {
visiblePixels++;
}
@@ -494,7 +504,6 @@ class Loader {
for (let i = 0;i < shadowRoots.length; i++) {
fixedElements = fixedElements.concat(Array.from(shadowRoots[i].querySelectorAll(selector)));
}
- const allElements = document.querySelectorAll("*");
let unknownElements = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
acceptNode() {
@@ -991,7 +1000,7 @@ class UI {
}
// src/style.css
-var style_default = ".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.45)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.45) 10px,rgba(var(--SoM-color),.45) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:\"Courier New\",Courier,monospace;background-color:rgba(var(--SoM-color),.7)}";
+var style_default = ".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.35)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.35) 10px,rgba(var(--SoM-color),.35) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:'Courier New',Courier,monospace;background-color:rgba(var(--SoM-color),.7)}";
// src/main.ts
class SoM {
diff --git a/dist/SoM.min.js b/dist/SoM.min.js
index 97edd62..28bd9f9 100644
--- a/dist/SoM.min.js
+++ b/dist/SoM.min.js
@@ -1 +1 @@
-var U=["a:not(:has(img))","a img","button",'input:not([type="hidden"])',"select","textarea",'[tabindex]:not([tabindex="-1"])','[contenteditable="true"]',".btn",'[role="button"]','[role="link"]','[role="checkbox"]','[role="radio"]','[role="input"]','[role="menuitem"]','[role="menuitemcheckbox"]','[role="menuitemradio"]','[role="option"]','[role="switch"]','[role="tab"]','[role="treeitem"]','[role="gridcell"]','[role="search"]','[role="combobox"]','[role="listbox"]','[role="slider"]','[role="spinbutton"]'],H=['input[type="text"]','input[type="password"]','input[type="email"]','input[type="tel"]','input[type="number"]','input[type="search"]','input[type="url"]','input[type="date"]','input[type="time"]','input[type="datetime-local"]','input[type="month"]','input[type="week"]','input[type="color"]',"textarea",'[contenteditable="true"]'],Y=0.6,d=0.8,G=10;var O=200,q=0.7,s=0.25,B=0.3;class W{}class F{x;y;width;height;element;constructor(h,u,t,i,w=null){this.x=h,this.y=u,this.width=t,this.height=i,this.element=w}contains(h){return h.x>=this.x&&h.x+h.width<=this.x+this.width&&h.y>=this.y&&h.y+h.height<=this.y+this.height}intersects(h){return!(h.x>this.x+this.width||h.x+h.widththis.y+this.height||h.y+h.heightparseInt(i);if(t==="auto"||i==="auto")return!!(w&Node.DOCUMENT_POSITION_PRECEDING);return!!(w&Node.DOCUMENT_POSITION_PRECEDING)}function j(h){if(h.offsetWidth===0&&h.offsetHeight===0)return!1;const u=h.getBoundingClientRect();if(u.width<=0||u.height<=0)return!1;const t=window.getComputedStyle(h);if(t.display==="none"||t.visibility==="hidden"||t.pointerEvents==="none")return!1;let i=h.parentElement;while(i!==null){const w=window.getComputedStyle(i);if(w.display==="none"||w.visibility==="hidden"||w.pointerEvents==="none")return!1;i=i.parentElement}return!0}class z{h;canvas;ctx;rect;visibleRect;constructor(h){this.element=h;this.element=h,this.rect=this.element.getBoundingClientRect(),this.canvas=new OffscreenCanvas(this.rect.width,this.rect.height),this.ctx=this.canvas.getContext("2d",{willReadFrequently:!0}),this.visibleRect={top:Math.max(0,this.rect.top),left:Math.max(0,this.rect.left),bottom:Math.min(window.innerHeight,this.rect.bottom),right:Math.min(window.innerWidth,this.rect.right),width:this.rect.width,height:this.rect.height},this.visibleRect.width=this.visibleRect.right-this.visibleRect.left,this.visibleRect.height=this.visibleRect.bottom-this.visibleRect.top}async eval(h){this.ctx.fillStyle="black",this.ctx.fillRect(0,0,this.rect.width,this.rect.height),this.drawElement(this.element,"white");const u={top:this.visibleRect.top-this.rect.top,bottom:this.visibleRect.bottom-this.rect.top,left:this.visibleRect.left-this.rect.left,right:this.visibleRect.right-this.rect.left,width:this.canvas.width,height:this.canvas.height},t=await this.countVisiblePixels(u);if(t===0)return 0;const i=this.getIntersectingElements(h);for(let a of i)this.drawElement(a,"black");return await this.countVisiblePixels(u)/t}getIntersectingElements(h){const u=new F(this.rect.left,this.rect.right,this.rect.width,this.rect.height,this.element);return h.query(u).map((i)=>i.element).filter((i)=>i!=this.element&&T(i,this.element)&&j(i))}async countVisiblePixels(h){const u=this.ctx.getImageData(h.left,h.top,h.width,h.height);let t=0;for(let i=0;iparseFloat(y)),a=i.clipPath,f={top:t.top-this.rect.top,bottom:t.bottom-this.rect.top,left:t.left-this.rect.left,right:t.right-this.rect.left,width:t.width,height:t.height};if(f.width=f.right-f.left,f.height=f.bottom-f.top,this.ctx.fillStyle=u,a&&a!=="none")a.split(/,| /).forEach((p)=>{const g=p.trim().match(/^([a-z]+)\((.*)\)$/);if(!g)return;switch(g[0]){case"polygon":const v=this.pathFromPolygon(p,t);this.ctx.fill(v);break;default:console.log("Unknown clip path kind: "+g)}});else if(w){const y=new Path2D;if(w.length===1)w[1]=w[0];if(w.length===2)w[2]=w[0];if(w.length===3)w[3]=w[1];y.moveTo(f.left+w[0],f.top),y.arcTo(f.right,f.top,f.right,f.bottom,w[1]),y.arcTo(f.right,f.bottom,f.left,f.bottom,w[2]),y.arcTo(f.left,f.bottom,f.left,f.top,w[3]),y.arcTo(f.left,f.top,f.right,f.top,w[0]),y.closePath(),this.ctx.fill(y)}else this.ctx.fillRect(f.left,f.top,f.width,f.height)}pathFromPolygon(h,u){if(!h||!h.match(/^polygon\((.*)\)$/))throw new Error("Invalid polygon format: "+h);const t=new Path2D,i=h.match(/\d+(\.\d+)?%/g);if(i&&i.length>=2){const w=parseFloat(i[0]),a=parseFloat(i[1]);t.moveTo(w*u.width/100,a*u.height/100);for(let f=2;f{const w=h.slice(i*G,(i+1)*G).filter((f)=>j(f)),a=[];for(let f of w)if(await this.isDeepVisible(f))a.push(f);return a}))).flat()}mapQuadTree(){const h=new F(0,0,window.innerWidth,window.innerHeight),u=new J(h,4),t=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode:(w)=>{if(j(w))return NodeFilter.FILTER_ACCEPT;return NodeFilter.FILTER_REJECT}});let i=t.currentNode;while(i){const w=i,a=w.getBoundingClientRect();u.insert(new F(a.left,a.top,a.width,a.height,w)),i=t.nextNode()}return u}async isDeepVisible(h){return new Promise((u)=>{const t=new IntersectionObserver(async(i)=>{const w=i[0];if(t.disconnect(),w.intersectionRatio=window.innerWidth*d||a.height>=window.innerHeight*d){u(!1);return}const y=await new z(h).eval(this.qt);u(y>=Y)});t.observe(h)})}}var R=S;var L=0.9,b=3,E=["a","button","input","select","textarea"];class A extends W{constructor(){super(...arguments)}async apply(h){const u=h.fixed.concat(h.unknown),{top:t,others:i}=this.getTopLevelElements(u),w=await Promise.all(t.map(async(a)=>this.compareTopWithChildren(a,i)));return{fixed:h.fixed,unknown:w.flat().filter((a)=>h.fixed.indexOf(a)===-1)}}async compareTopWithChildren(h,u){if(E.some((f)=>h.matches(f)))return[h];const t=this.getBranches(h,u),i=h.getBoundingClientRect();if(t.length<=1)return[h];const a=(await Promise.all(t.map(async(f)=>{const y=f.top.getBoundingClientRect();if(y.width/i.widthb)return a;return[h,...a]}getBranches(h,u){const t=this.getFirstHitChildren(h,u);return t.map((i)=>{const w=u.filter((a)=>!t.includes(a)&&i.contains(a));return{top:i,children:w}})}getFirstHitChildren(h,u){const t=h.querySelectorAll(":scope > *"),i=Array.from(t).filter((w)=>u.includes(w));if(i.length>0)return i;return Array.from(t).flatMap((w)=>this.getFirstHitChildren(w,u))}getTopLevelElements(h){const u=[],t=[];for(let i of h)if(!h.some((w)=>w!==i&&w.contains(i)))u.push(i);else t.push(i);return{top:u,others:t}}}var Z=A;class k{filters={visibility:new R,nesting:new Z};async loadElements(){const h=U.join(",");let u=Array.from(document.querySelectorAll(h));const t=this.shadowRoots();for(let p=0;pv.indexOf(p)===g).filter((p)=>!p.closest("svg")&&!u.some((g)=>g.contains(p)));let y={fixed:u,unknown:w};return console.groupCollapsed("Elements"),console.log("Before filters",y),y=await this.filters.visibility.apply(y),console.log("After visibility filter",y),y=await this.filters.nesting.apply(y),console.log("After nesting filter",y),console.groupEnd(),y.fixed.concat(y.unknown)}shadowRoots(){const h=[],u=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode(i){return NodeFilter.FILTER_ACCEPT}});let t;while(t=u.nextNode())if(t&&t.shadowRoot)h.push(t.shadowRoot);return h}}class D{contrastColor(h,u){const t=window.getComputedStyle(h),i=$.fromCSS(t.backgroundColor);return this.getBestContrastColor([i,...u])}getBestContrastColor(h){const u=h.filter((i)=>i.a>0).map((i)=>i.complimentary());let t;if(u.length===0)t=new $(Math.floor(Math.random()*255),Math.floor(Math.random()*255),Math.floor(Math.random()*255));else t=this.getAverageColor(u);if(t.luminance()>q)t=t.withLuminance(q);else if(t.luminance()w+a.r,0)/h.length,t=h.reduce((w,a)=>w+a.g,0)/h.length,i=h.reduce((w,a)=>w+a.b,0)/h.length;return new $(u,t,i)}}class ${h;u;t;i;constructor(h,u,t,i=255){this.r=h;this.g=u;this.b=t;this.a=i;if(h<0||h>255)throw new Error(`Invalid red value: ${h}`);if(u<0||u>255)throw new Error(`Invalid green value: ${u}`);if(t<0||t>255)throw new Error(`Invalid blue value: ${t}`);if(i<0||i>255)throw new Error(`Invalid alpha value: ${i}`);this.r=Math.round(h),this.g=Math.round(u),this.b=Math.round(t),this.a=Math.round(i)}static fromCSS(h){if(h.startsWith("#"))return $.fromHex(h);if(h.startsWith("rgb")){const t=h.replace(/rgba?\(/,"").replace(")","").split(",").map((i)=>parseInt(i.trim()));return new $(...t)}if(h.startsWith("hsl")){const t=h.replace(/hsla?\(/,"").replace(")","").split(",").map((i)=>parseFloat(i.trim()));return $.fromHSL({h:t[0],s:t[1],l:t[2]})}const u=c[h.toLowerCase()];if(u)return $.fromHex(u);throw new Error(`Unknown color format: ${h}`)}static fromHex(h){if(h=h.replace("#",""),h.length===3)h=h.split("").map((w)=>w+w).join("");const u=parseInt(h.substring(0,2),16),t=parseInt(h.substring(2,4),16),i=parseInt(h.substring(4,6),16);if(h.length===8){const w=parseInt(h.substring(6,8),16);return new $(u,t,i,w)}return new $(u,t,i)}static fromHSL(h){const{h:u,s:t,l:i}=h;let w,a,f;if(t===0)w=a=f=i;else{const y=(v,K,P)=>{if(P<0)P+=1;if(P>1)P-=1;if(P<0.16666666666666666)return v+(K-v)*6*P;if(P<0.5)return K;if(P<0.6666666666666666)return v+(K-v)*(0.6666666666666666-P)*6;return v},p=i<0.5?i*(1+t):i+t-i*t,g=2*i-p;w=y(g,p,u+0.3333333333333333),a=y(g,p,u),f=y(g,p,u-0.3333333333333333)}return new $(w*255,a*255,f*255)}luminance(){const h=this.r/255,u=this.g/255,t=this.b/255,i=[h,u,t].map((w)=>{if(w<=0.03928)return w/12.92;return Math.pow((w+0.055)/1.055,2.4)});return 0.2126*i[0]+0.7152*i[1]+0.0722*i[2]}withLuminance(h){const u=this.luminance(),t=h/u,i=Math.min(255,this.r*t),w=Math.min(255,this.g*t),a=Math.min(255,this.b*t);return new $(i,w,a,this.a)}saturation(){return this.toHsl().s}withSaturation(h){const u=this.toHsl();return u.s=h,$.fromHSL(u)}contrast(h){const u=this.luminance(),t=h.luminance();return(Math.max(u,t)+0.05)/(Math.min(u,t)+0.05)}complimentary(){const h=this.toHsl();return h.h=(h.h+0.5)%1,$.fromHSL(h)}toHex(){const h=this.r.toString(16).padStart(2,"0"),u=this.g.toString(16).padStart(2,"0"),t=this.b.toString(16).padStart(2,"0");if(this.a<255){const i=this.a.toString(16).padStart(2,"0");return`#${h}${u}${t}${i}`}return`#${h}${u}${t}`}toHsl(){const h=this.r/255,u=this.g/255,t=this.b/255,i=Math.max(h,u,t),w=Math.min(h,u,t);let a=(i+w)/2,f=(i+w)/2,y=(i+w)/2;if(i===w)a=f=0;else{const p=i-w;switch(f=y>0.5?p/(2-i-w):p/(i+w),i){case h:a=(u-t)/p+(ua.matches(v)))y.classList.add("editable");const p=t.filter((v)=>{return[Math.sqrt(Math.pow(f.left-v.left,2)+Math.pow(f.top-v.top,2)),Math.sqrt(Math.pow(f.right-v.right,2)+Math.pow(f.top-v.top,2)),Math.sqrt(Math.pow(f.left-v.left,2)+Math.pow(f.bottom-v.bottom,2)),Math.sqrt(Math.pow(f.right-v.right,2)+Math.pow(f.bottom-v.bottom,2))].some((P)=>Pv.color),g=this.colors.contrastColor(a,p);y.style.setProperty("--SoM-color",`${g.r}, ${g.g}, ${g.b}`),document.body.appendChild(y),t.push({top:f.top,bottom:f.bottom,left:f.left,right:f.right,width:f.width,height:f.height,color:g}),i.push(y)}for(let w=0;w{let X=0;if(Q.top<0||Q.top+p.height>window.innerHeight||Q.left<0||Q.left+p.width>window.innerWidth)X+=Infinity;else u.concat(t).forEach((M)=>{if(M.top<=f.top&&M.bottom>=f.bottom&&M.left<=f.left&&M.right>=f.right)return;const I=Math.max(0,Math.min(Q.left+p.width,M.left+M.width)-Math.max(Q.left,M.left)),C=Math.max(0,Math.min(Q.top+p.height,M.top+M.height)-Math.max(Q.top,M.top));X+=I*C});return X}),P=v[K.indexOf(Math.min(...K))];y.style.top=`${P.top-f.top}px`,y.style.left=`${P.left-f.left}px`,u.push({top:P.top,left:P.left,right:P.left+p.width,bottom:P.top+p.height,width:p.width,height:p.height}),a.setAttribute("data-SoM",`${w}`)}}getColorByLuminance(h){return h.luminance()>0.5?"black":"white"}}var _=".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.45)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.45) 10px,rgba(var(--SoM-color),.45) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:\"Courier New\",Courier,monospace;background-color:rgba(var(--SoM-color),.7)}";class N{loader=new k;ui=new V;async display(){this.log("Displaying...");const h=performance.now(),u=await this.loader.loadElements();this.clear(),this.ui.display(u),this.log("Done!",`Took ${performance.now()-h}ms to display ${u.length} elements.`)}clear(){document.querySelectorAll(".SoM").forEach((h)=>{h.remove()}),document.querySelectorAll("[data-som]").forEach((h)=>{h.removeAttribute("data-som")})}hide(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="none")}show(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="block")}resolve(h){return document.querySelector(`[data-som="${h}"]`)}log(...h){console.log("%cSoM","color: white; background: #007bff; padding: 2px 5px; border-radius: 5px;",...h)}}if(!document.getElementById("SoM-styles")){const h=document.createElement("style");h.id="SoM-styles",h.innerHTML=_;const u=setInterval(()=>{if(document.head)clearInterval(u),document.head.appendChild(h)},100)}window.SoM=new N;window.SoM.log("Ready!");
+var O=["a:not(:has(img))","a img","button",'input:not([type="hidden"])',"select","textarea",'[tabindex]:not([tabindex="-1"])','[contenteditable="true"]',".btn",'[role="button"]','[role="link"]','[role="checkbox"]','[role="radio"]','[role="input"]','[role="menuitem"]','[role="menuitemcheckbox"]','[role="menuitemradio"]','[role="option"]','[role="switch"]','[role="tab"]','[role="treeitem"]','[role="gridcell"]','[role="search"]','[role="combobox"]','[role="listbox"]','[role="slider"]','[role="spinbutton"]'],U=['input[type="text"]','input[type="password"]','input[type="email"]','input[type="tel"]','input[type="number"]','input[type="search"]','input[type="url"]','input[type="date"]','input[type="time"]','input[type="datetime-local"]','input[type="month"]','input[type="week"]','input[type="color"]',"textarea",'[contenteditable="true"]'],Y=0.6,V=0.8,W=10;var H=200,z=0.7,q=0.25,B=0.3;class J{}class ${x;y;width;height;element;constructor(h,i,t,u,f=null){this.x=h,this.y=i,this.width=t,this.height=u,this.element=f}contains(h){return h.x>=this.x&&h.x+h.width<=this.x+this.width&&h.y>=this.y&&h.y+h.height<=this.y+this.height}intersects(h){return!(h.x>this.x+this.width||h.x+h.widththis.y+this.height||h.y+h.heightf.indexOf(t)===u)}}function S(h,i){function t(w){while(w){const y=window.getComputedStyle(w).zIndex;if(y!=="auto"){const p=parseInt(y,10);return isNaN(p)?0:p}w=w.parentElement}return 0}const u=t(h),f=t(i),a=h.compareDocumentPosition(i);if(a&Node.DOCUMENT_POSITION_CONTAINS||a&Node.DOCUMENT_POSITION_CONTAINED_BY)return!1;if(u!==f)return uu.element).filter((u)=>u!=this.element&&S(this.element,u)&&K(u))}async countVisiblePixels(h){const i=this.ctx.getImageData(h.left,h.top,h.width,h.height);let t=0;for(let u=0;uparseFloat(y)),a=u.clipPath,w={top:t.top-this.rect.top,bottom:t.bottom-this.rect.top,left:t.left-this.rect.left,right:t.right-this.rect.left,width:t.width,height:t.height};if(w.width=w.right-w.left,w.height=w.bottom-w.top,this.ctx.fillStyle=i,a&&a!=="none")a.split(/,| /).forEach((p)=>{const v=p.trim().match(/^([a-z]+)\((.*)\)$/);if(!v)return;switch(v[0]){case"polygon":const s=this.pathFromPolygon(p,t);this.ctx.fill(s);break;default:console.log("Unknown clip path kind: "+v)}});else if(f){const y=new Path2D;if(f.length===1)f[1]=f[0];if(f.length===2)f[2]=f[0];if(f.length===3)f[3]=f[1];y.moveTo(w.left+f[0],w.top),y.arcTo(w.right,w.top,w.right,w.bottom,f[1]),y.arcTo(w.right,w.bottom,w.left,w.bottom,f[2]),y.arcTo(w.left,w.bottom,w.left,w.top,f[3]),y.arcTo(w.left,w.top,w.right,w.top,f[0]),y.closePath(),this.ctx.fill(y)}else this.ctx.fillRect(w.left,w.top,w.width,w.height)}pathFromPolygon(h,i){if(!h||!h.match(/^polygon\((.*)\)$/))throw new Error("Invalid polygon format: "+h);const t=new Path2D,u=h.match(/\d+(\.\d+)?%/g);if(u&&u.length>=2){const f=parseFloat(u[0]),a=parseFloat(u[1]);t.moveTo(f*i.width/100,a*i.height/100);for(let w=2;w{const f=h.slice(u*W,(u+1)*W).filter((w)=>K(w)),a=[];for(let w of f)if(await this.isDeepVisible(w))a.push(w);return a}))).flat()}mapQuadTree(){const h=new $(0,0,window.innerWidth,window.innerHeight),i=new M(h,4),t=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode:(f)=>{if(K(f))return NodeFilter.FILTER_ACCEPT;return NodeFilter.FILTER_REJECT}});let u=t.currentNode;while(u){const f=u,a=f.getBoundingClientRect();i.insert(new $(a.left,a.top,a.width,a.height,f)),u=t.nextNode()}return i}async isDeepVisible(h){return new Promise((i)=>{const t=new IntersectionObserver(async(u)=>{const f=u[0];if(t.disconnect(),f.intersectionRatio=window.innerWidth*V||a.height>=window.innerHeight*V){i(!1);return}const y=await new j(h).eval(this.qt);i(y>=Y)});t.observe(h)})}}var R=T;var L=0.9,I=3,c=["a","button","input","select","textarea"];class A extends J{constructor(){super(...arguments)}async apply(h){const i=h.fixed.concat(h.unknown),{top:t,others:u}=this.getTopLevelElements(i),f=await Promise.all(t.map(async(a)=>this.compareTopWithChildren(a,u)));return{fixed:h.fixed,unknown:f.flat().filter((a)=>h.fixed.indexOf(a)===-1)}}async compareTopWithChildren(h,i){if(c.some((w)=>h.matches(w)))return[h];const t=this.getBranches(h,i),u=h.getBoundingClientRect();if(t.length<=1)return[h];const a=(await Promise.all(t.map(async(w)=>{const y=w.top.getBoundingClientRect();if(y.width/u.widthI)return a;return[h,...a]}getBranches(h,i){const t=this.getFirstHitChildren(h,i);return t.map((u)=>{const f=i.filter((a)=>!t.includes(a)&&u.contains(a));return{top:u,children:f}})}getFirstHitChildren(h,i){const t=h.querySelectorAll(":scope > *"),u=Array.from(t).filter((f)=>i.includes(f));if(u.length>0)return u;return Array.from(t).flatMap((f)=>this.getFirstHitChildren(f,i))}getTopLevelElements(h){const i=[],t=[];for(let u of h)if(!h.some((f)=>f!==u&&f.contains(u)))i.push(u);else t.push(u);return{top:i,others:t}}}var Z=A;class G{filters={visibility:new R,nesting:new Z};async loadElements(){const h=O.join(",");let i=Array.from(document.querySelectorAll(h));const t=this.shadowRoots();for(let y=0;yv.indexOf(y)===p).filter((y)=>!y.closest("svg")&&!i.some((p)=>p.contains(y)));let w={fixed:i,unknown:u};return console.groupCollapsed("Elements"),console.log("Before filters",w),w=await this.filters.visibility.apply(w),console.log("After visibility filter",w),w=await this.filters.nesting.apply(w),console.log("After nesting filter",w),console.groupEnd(),w.fixed.concat(w.unknown)}shadowRoots(){const h=[],i=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode(u){return NodeFilter.FILTER_ACCEPT}});let t;while(t=i.nextNode())if(t&&t.shadowRoot)h.push(t.shadowRoot);return h}}class k{contrastColor(h,i){const t=window.getComputedStyle(h),u=d.fromCSS(t.backgroundColor);return this.getBestContrastColor([u,...i])}getBestContrastColor(h){const i=h.filter((u)=>u.a>0).map((u)=>u.complimentary());let t;if(i.length===0)t=new d(Math.floor(Math.random()*255),Math.floor(Math.random()*255),Math.floor(Math.random()*255));else t=this.getAverageColor(i);if(t.luminance()>z)t=t.withLuminance(z);else if(t.luminance()f+a.r,0)/h.length,t=h.reduce((f,a)=>f+a.g,0)/h.length,u=h.reduce((f,a)=>f+a.b,0)/h.length;return new d(i,t,u)}}class d{h;i;t;u;constructor(h,i,t,u=255){this.r=h;this.g=i;this.b=t;this.a=u;if(h<0||h>255)throw new Error(`Invalid red value: ${h}`);if(i<0||i>255)throw new Error(`Invalid green value: ${i}`);if(t<0||t>255)throw new Error(`Invalid blue value: ${t}`);if(u<0||u>255)throw new Error(`Invalid alpha value: ${u}`);this.r=Math.round(h),this.g=Math.round(i),this.b=Math.round(t),this.a=Math.round(u)}static fromCSS(h){if(h.startsWith("#"))return d.fromHex(h);if(h.startsWith("rgb")){const t=h.replace(/rgba?\(/,"").replace(")","").split(",").map((u)=>parseInt(u.trim()));return new d(...t)}if(h.startsWith("hsl")){const t=h.replace(/hsla?\(/,"").replace(")","").split(",").map((u)=>parseFloat(u.trim()));return d.fromHSL({h:t[0],s:t[1],l:t[2]})}const i=E[h.toLowerCase()];if(i)return d.fromHex(i);throw new Error(`Unknown color format: ${h}`)}static fromHex(h){if(h=h.replace("#",""),h.length===3)h=h.split("").map((f)=>f+f).join("");const i=parseInt(h.substring(0,2),16),t=parseInt(h.substring(2,4),16),u=parseInt(h.substring(4,6),16);if(h.length===8){const f=parseInt(h.substring(6,8),16);return new d(i,t,u,f)}return new d(i,t,u)}static fromHSL(h){const{h:i,s:t,l:u}=h;let f,a,w;if(t===0)f=a=w=u;else{const y=(s,F,g)=>{if(g<0)g+=1;if(g>1)g-=1;if(g<0.16666666666666666)return s+(F-s)*6*g;if(g<0.5)return F;if(g<0.6666666666666666)return s+(F-s)*(0.6666666666666666-g)*6;return s},p=u<0.5?u*(1+t):u+t-u*t,v=2*u-p;f=y(v,p,i+0.3333333333333333),a=y(v,p,i),w=y(v,p,i-0.3333333333333333)}return new d(f*255,a*255,w*255)}luminance(){const h=this.r/255,i=this.g/255,t=this.b/255,u=[h,i,t].map((f)=>{if(f<=0.03928)return f/12.92;return Math.pow((f+0.055)/1.055,2.4)});return 0.2126*u[0]+0.7152*u[1]+0.0722*u[2]}withLuminance(h){const i=this.luminance(),t=h/i,u=Math.min(255,this.r*t),f=Math.min(255,this.g*t),a=Math.min(255,this.b*t);return new d(u,f,a,this.a)}saturation(){return this.toHsl().s}withSaturation(h){const i=this.toHsl();return i.s=h,d.fromHSL(i)}contrast(h){const i=this.luminance(),t=h.luminance();return(Math.max(i,t)+0.05)/(Math.min(i,t)+0.05)}complimentary(){const h=this.toHsl();return h.h=(h.h+0.5)%1,d.fromHSL(h)}toHex(){const h=this.r.toString(16).padStart(2,"0"),i=this.g.toString(16).padStart(2,"0"),t=this.b.toString(16).padStart(2,"0");if(this.a<255){const u=this.a.toString(16).padStart(2,"0");return`#${h}${i}${t}${u}`}return`#${h}${i}${t}`}toHsl(){const h=this.r/255,i=this.g/255,t=this.b/255,u=Math.max(h,i,t),f=Math.min(h,i,t);let a=(u+f)/2,w=(u+f)/2,y=(u+f)/2;if(u===f)a=w=0;else{const p=u-f;switch(w=y>0.5?p/(2-u-f):p/(u+f),u){case h:a=(i-t)/p+(ia.matches(s)))y.classList.add("editable");const p=t.filter((s)=>{return[Math.sqrt(Math.pow(w.left-s.left,2)+Math.pow(w.top-s.top,2)),Math.sqrt(Math.pow(w.right-s.right,2)+Math.pow(w.top-s.top,2)),Math.sqrt(Math.pow(w.left-s.left,2)+Math.pow(w.bottom-s.bottom,2)),Math.sqrt(Math.pow(w.right-s.right,2)+Math.pow(w.bottom-s.bottom,2))].some((g)=>gs.color),v=this.colors.contrastColor(a,p);y.style.setProperty("--SoM-color",`${v.r}, ${v.g}, ${v.b}`),document.body.appendChild(y),t.push({top:w.top,bottom:w.bottom,left:w.left,right:w.right,width:w.width,height:w.height,color:v}),u.push(y)}for(let f=0;f{let X=0;if(P.top<0||P.top+p.height>window.innerHeight||P.left<0||P.left+p.width>window.innerWidth)X+=Infinity;else i.concat(t).forEach((Q)=>{if(Q.top<=w.top&&Q.bottom>=w.bottom&&Q.left<=w.left&&Q.right>=w.right)return;const b=Math.max(0,Math.min(P.left+p.width,Q.left+Q.width)-Math.max(P.left,Q.left)),C=Math.max(0,Math.min(P.top+p.height,Q.top+Q.height)-Math.max(P.top,Q.top));X+=b*C});return X}),g=s[F.indexOf(Math.min(...F))];y.style.top=`${g.top-w.top}px`,y.style.left=`${g.left-w.left}px`,i.push({top:g.top,left:g.left,right:g.left+p.width,bottom:g.top+p.height,width:p.width,height:p.height}),a.setAttribute("data-SoM",`${f}`)}}getColorByLuminance(h){return h.luminance()>0.5?"black":"white"}}var _=".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.35)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.35) 10px,rgba(var(--SoM-color),.35) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:'Courier New',Courier,monospace;background-color:rgba(var(--SoM-color),.7)}";class N{loader=new G;ui=new D;async display(){this.log("Displaying...");const h=performance.now(),i=await this.loader.loadElements();this.clear(),this.ui.display(i),this.log("Done!",`Took ${performance.now()-h}ms to display ${i.length} elements.`)}clear(){document.querySelectorAll(".SoM").forEach((h)=>{h.remove()}),document.querySelectorAll("[data-som]").forEach((h)=>{h.removeAttribute("data-som")})}hide(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="none")}show(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="block")}resolve(h){return document.querySelector(`[data-som="${h}"]`)}log(...h){console.log("%cSoM","color: white; background: #007bff; padding: 2px 5px; border-radius: 5px;",...h)}}if(!document.getElementById("SoM-styles")){const h=document.createElement("style");h.id="SoM-styles",h.innerHTML=_;const i=setInterval(()=>{if(document.head)clearInterval(i),document.head.appendChild(h)},100)}window.SoM=new N;window.SoM.log("Ready!");
diff --git a/package.json b/package.json
index d523832..6967f77 100644
--- a/package.json
+++ b/package.json
@@ -1,9 +1,6 @@
{
"name": "web-som",
"version": "1.0.0",
- "description": "Set-of-Marks script for web grounding, used for web agent automation",
- "main": "dist/SoM.js",
- "license": "(MIT OR Apache-2.0)",
"author": {
"name": "Brewen Couaran",
"email": "contact@brewen.dev",
@@ -13,20 +10,20 @@
"type": "git",
"url": "git@github.com:brewcoua/web-som.git"
},
- "homepage": "https://github.com/brewcoua/web-som#readme",
+ "main": "dist/SoM.js",
+ "devDependencies": {
+ "@types/contrast-color": "^1.0.3",
+ "clean-css": "^5.3.3",
+ "prettier": "^3.2.5"
+ },
"bugs": {
"url": "https://github.com/brewcoua/web-som/issues",
"email": "contact@brewen.dev"
},
+ "description": "Set-of-Marks script for web grounding, used for web agent automation",
+ "homepage": "https://github.com/brewcoua/web-som#readme",
+ "license": "(MIT OR Apache-2.0)",
"scripts": {
"build": "bun ./build.ts"
- },
- "devDependencies": {
- "@types/contrast-color": "^1.0.3",
- "clean-css": "^5.3.3",
- "prettier": "^3.2.5"
- },
- "dependencies": {
- "contrast-color": "^1.0.1"
}
}
diff --git a/src/filters/visibility/canvas.ts b/src/filters/visibility/canvas.ts
index b062ace..1edb78e 100644
--- a/src/filters/visibility/canvas.ts
+++ b/src/filters/visibility/canvas.ts
@@ -1,220 +1,223 @@
-import QuadTree, { Rectangle } from "./quad";
-import { isAbove, isVisible } from "./utils";
+import QuadTree, { Rectangle } from './quad';
+import { isAbove, isVisible } from './utils';
export default class VisibilityCanvas {
- private readonly canvas: OffscreenCanvas;
- private readonly ctx: OffscreenCanvasRenderingContext2D;
-
- private readonly rect: AbstractRect;
- private readonly visibleRect: AbstractRect;
-
- constructor(private readonly element: HTMLElement) {
- this.element = element;
- this.rect = this.element.getBoundingClientRect();
- this.canvas = new OffscreenCanvas(this.rect.width, this.rect.height);
- this.ctx = this.canvas.getContext("2d", {
- willReadFrequently: true,
- })!;
-
- this.visibleRect = {
- top: Math.max(0, this.rect.top),
- left: Math.max(0, this.rect.left),
- bottom: Math.min(window.innerHeight, this.rect.bottom),
- right: Math.min(window.innerWidth, this.rect.right),
- width: this.rect.width,
- height: this.rect.height,
- };
- this.visibleRect.width = this.visibleRect.right - this.visibleRect.left;
- this.visibleRect.height = this.visibleRect.bottom - this.visibleRect.top;
- }
-
- async eval(qt: QuadTree): Promise {
- this.ctx.fillStyle = "black";
- this.ctx.fillRect(0, 0, this.rect.width, this.rect.height);
-
- this.drawElement(this.element, "white");
-
- const canvasVisRect: AbstractRect = {
- top: this.visibleRect.top - this.rect.top,
- bottom: this.visibleRect.bottom - this.rect.top,
- left: this.visibleRect.left - this.rect.left,
- right: this.visibleRect.right - this.rect.left,
- width: this.canvas.width,
- height: this.canvas.height,
- };
-
- const totalPixels = await this.countVisiblePixels(canvasVisRect);
- if (totalPixels === 0) return 0;
-
- const elements = this.getIntersectingElements(qt);
- for (const el of elements) {
- this.drawElement(el, "black");
- }
-
- const visiblePixels = await this.countVisiblePixels(canvasVisRect);
- return visiblePixels / totalPixels;
- }
-
- private getIntersectingElements(qt: QuadTree): HTMLElement[] {
- const range = new Rectangle(
- this.rect.left,
- this.rect.right,
- this.rect.width,
- this.rect.height,
- this.element
- );
- const candidates = qt.query(range);
-
- return candidates
- .map((candidate) => candidate.element!)
- .filter(
- (el) => el != this.element && isAbove(el, this.element) && isVisible(el)
- );
- }
-
- private async countVisiblePixels(visibleRect: AbstractRect): Promise {
- const imageData = this.ctx.getImageData(
- visibleRect.left,
- visibleRect.top,
- visibleRect.width,
- visibleRect.height
- );
-
- let visiblePixels = 0;
- for (let i = 0; i < imageData.data.length; i += 4) {
- const isWhite = imageData.data[i] === 255;
- if (isWhite) {
- visiblePixels++;
- }
- }
-
- return visiblePixels;
- }
-
- private drawElement(element: Element, color = "black") {
- const rect = element.getBoundingClientRect();
- const styles = window.getComputedStyle(element);
-
- const radius = styles.borderRadius?.split(" ").map((r) => parseFloat(r));
- const clipPath = styles.clipPath;
-
- const offsetRect = {
- top: rect.top - this.rect.top,
- bottom: rect.bottom - this.rect.top,
- left: rect.left - this.rect.left,
- right: rect.right - this.rect.left,
- width: rect.width,
- height: rect.height,
- };
- offsetRect.width = offsetRect.right - offsetRect.left;
- offsetRect.height = offsetRect.bottom - offsetRect.top;
-
- this.ctx.fillStyle = color;
-
- if (clipPath && clipPath !== "none") {
- const clips = clipPath.split(/,| /);
-
- clips.forEach((clip) => {
- const kind = clip.trim().match(/^([a-z]+)\((.*)\)$/);
- if (!kind) {
- return;
- }
-
- switch (kind[0]) {
- case "polygon":
- const path = this.pathFromPolygon(clip, rect);
- this.ctx.fill(path);
- break;
- default:
- console.log("Unknown clip path kind: " + kind);
- }
- });
- } else if (radius) {
- const path = new Path2D();
-
- if (radius.length === 1) radius[1] = radius[0];
- if (radius.length === 2) radius[2] = radius[0];
- if (radius.length === 3) radius[3] = radius[1];
-
- // Go to the top left corner
- path.moveTo(offsetRect.left + radius[0], offsetRect.top);
-
- path.arcTo(
- // Arc to the top right corner
- offsetRect.right,
- offsetRect.top,
- offsetRect.right,
- offsetRect.bottom,
- radius[1]
- );
-
- path.arcTo(
- offsetRect.right,
- offsetRect.bottom,
- offsetRect.left,
- offsetRect.bottom,
- radius[2]
- );
-
- path.arcTo(
- offsetRect.left,
- offsetRect.bottom,
- offsetRect.left,
- offsetRect.top,
- radius[3]
- );
-
- path.arcTo(
- offsetRect.left,
- offsetRect.top,
- offsetRect.right,
- offsetRect.top,
- radius[0]
- );
- path.closePath();
-
- this.ctx.fill(path);
- } else {
- this.ctx.fillRect(
- offsetRect.left,
- offsetRect.top,
- offsetRect.width,
- offsetRect.height
- );
- }
- }
-
- private pathFromPolygon(polygon: string, rect: AbstractRect) {
- if (!polygon || !polygon.match(/^polygon\((.*)\)$/)) {
- throw new Error("Invalid polygon format: " + polygon);
- }
-
- const path = new Path2D();
- const points = polygon.match(/\d+(\.\d+)?%/g);
-
- if (points && points.length >= 2) {
- const startX = parseFloat(points[0]);
- const startY = parseFloat(points[1]);
- path.moveTo((startX * rect.width) / 100, (startY * rect.height) / 100);
-
- for (let i = 2; i < points.length; i += 2) {
- const x = parseFloat(points[i]);
- const y = parseFloat(points[i + 1]);
- path.lineTo((x * rect.width) / 100, (y * rect.height) / 100);
- }
-
- path.closePath();
- }
-
- return path;
- }
+ private readonly canvas: OffscreenCanvas;
+ private readonly ctx: OffscreenCanvasRenderingContext2D;
+
+ private readonly rect: AbstractRect;
+ private readonly visibleRect: AbstractRect;
+
+ constructor(private readonly element: HTMLElement) {
+ this.element = element;
+ this.rect = this.element.getBoundingClientRect();
+ this.canvas = new OffscreenCanvas(this.rect.width, this.rect.height);
+ this.ctx = this.canvas.getContext('2d', {
+ willReadFrequently: true,
+ })!;
+ this.ctx.imageSmoothingEnabled = false;
+
+ this.visibleRect = {
+ top: Math.max(0, this.rect.top),
+ left: Math.max(0, this.rect.left),
+ bottom: Math.min(window.innerHeight, this.rect.bottom),
+ right: Math.min(window.innerWidth, this.rect.right),
+ width: this.rect.width,
+ height: this.rect.height,
+ };
+ this.visibleRect.width = this.visibleRect.right - this.visibleRect.left;
+ this.visibleRect.height = this.visibleRect.bottom - this.visibleRect.top;
+ }
+
+ async eval(qt: QuadTree): Promise {
+ this.ctx.fillStyle = 'black';
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+ this.drawElement(this.element, 'white');
+
+ const canvasVisRect: AbstractRect = {
+ top: this.visibleRect.top - this.rect.top,
+ bottom: this.visibleRect.bottom - this.rect.top,
+ left: this.visibleRect.left - this.rect.left,
+ right: this.visibleRect.right - this.rect.left,
+ width: this.canvas.width,
+ height: this.canvas.height,
+ };
+
+ const totalPixels = await this.countVisiblePixels(canvasVisRect);
+ if (totalPixels === 0) return 0;
+
+ const elements = this.getIntersectingElements(qt);
+
+ for (const el of elements) {
+ this.drawElement(el, 'black');
+ }
+
+ const visiblePixels = await this.countVisiblePixels(canvasVisRect);
+ return visiblePixels / totalPixels;
+ }
+
+ private getIntersectingElements(qt: QuadTree): HTMLElement[] {
+ const range = new Rectangle(
+ this.rect.left,
+ this.rect.right,
+ this.rect.width,
+ this.rect.height,
+ this.element
+ );
+ const candidates = qt.query(range);
+
+ return candidates
+ .map((candidate) => candidate.element!)
+ .filter(
+ (el) => el != this.element && isAbove(this.element, el) && isVisible(el)
+ );
+ }
+
+ private async countVisiblePixels(visibleRect: AbstractRect): Promise {
+ const imageData = this.ctx.getImageData(
+ visibleRect.left,
+ visibleRect.top,
+ visibleRect.width,
+ visibleRect.height
+ );
+
+ let visiblePixels = 0;
+ for (let i = 0; i < imageData.data.length; i += 4) {
+ const isWhite = imageData.data[i + 1] === 255;
+
+ if (isWhite) {
+ visiblePixels++;
+ }
+ }
+
+ return visiblePixels;
+ }
+
+ private drawElement(element: Element, color = 'black') {
+ const rect = element.getBoundingClientRect();
+ const styles = window.getComputedStyle(element);
+
+ const radius = styles.borderRadius?.split(' ').map((r) => parseFloat(r));
+ const clipPath = styles.clipPath;
+
+ const offsetRect = {
+ top: rect.top - this.rect.top,
+ bottom: rect.bottom - this.rect.top,
+ left: rect.left - this.rect.left,
+ right: rect.right - this.rect.left,
+ width: rect.width,
+ height: rect.height,
+ };
+ offsetRect.width = offsetRect.right - offsetRect.left;
+ offsetRect.height = offsetRect.bottom - offsetRect.top;
+
+ this.ctx.fillStyle = color;
+
+ if (clipPath && clipPath !== 'none') {
+ const clips = clipPath.split(/,| /);
+
+ clips.forEach((clip) => {
+ const kind = clip.trim().match(/^([a-z]+)\((.*)\)$/);
+ if (!kind) {
+ return;
+ }
+
+ switch (kind[0]) {
+ case 'polygon':
+ const path = this.pathFromPolygon(clip, rect);
+ this.ctx.fill(path);
+ break;
+ default:
+ console.log('Unknown clip path kind: ' + kind);
+ }
+ });
+ } else if (radius) {
+ const path = new Path2D();
+
+ if (radius.length === 1) radius[1] = radius[0];
+ if (radius.length === 2) radius[2] = radius[0];
+ if (radius.length === 3) radius[3] = radius[1];
+
+ // Go to the top left corner
+ path.moveTo(offsetRect.left + radius[0], offsetRect.top);
+
+ path.arcTo(
+ // Arc to the top right corner
+ offsetRect.right,
+ offsetRect.top,
+ offsetRect.right,
+ offsetRect.bottom,
+ radius[1]
+ );
+
+ path.arcTo(
+ offsetRect.right,
+ offsetRect.bottom,
+ offsetRect.left,
+ offsetRect.bottom,
+ radius[2]
+ );
+
+ path.arcTo(
+ offsetRect.left,
+ offsetRect.bottom,
+ offsetRect.left,
+ offsetRect.top,
+ radius[3]
+ );
+
+ path.arcTo(
+ offsetRect.left,
+ offsetRect.top,
+ offsetRect.right,
+ offsetRect.top,
+ radius[0]
+ );
+ path.closePath();
+
+ this.ctx.fill(path);
+ } else {
+ this.ctx.fillRect(
+ offsetRect.left,
+ offsetRect.top,
+ offsetRect.width,
+ offsetRect.height
+ );
+ }
+ }
+
+ private pathFromPolygon(polygon: string, rect: AbstractRect) {
+ if (!polygon || !polygon.match(/^polygon\((.*)\)$/)) {
+ throw new Error('Invalid polygon format: ' + polygon);
+ }
+
+ const path = new Path2D();
+ const points = polygon.match(/\d+(\.\d+)?%/g);
+
+ if (points && points.length >= 2) {
+ const startX = parseFloat(points[0]);
+ const startY = parseFloat(points[1]);
+ path.moveTo((startX * rect.width) / 100, (startY * rect.height) / 100);
+
+ for (let i = 2; i < points.length; i += 2) {
+ const x = parseFloat(points[i]);
+ const y = parseFloat(points[i + 1]);
+ path.lineTo((x * rect.width) / 100, (y * rect.height) / 100);
+ }
+
+ path.closePath();
+ }
+
+ return path;
+ }
}
type AbstractRect = {
- top: number;
- left: number;
- bottom: number;
- right: number;
- width: number;
- height: number;
+ top: number;
+ left: number;
+ bottom: number;
+ right: number;
+ width: number;
+ height: number;
};
diff --git a/src/filters/visibility/index.ts b/src/filters/visibility/index.ts
index 19c93c9..0d3e4f3 100644
--- a/src/filters/visibility/index.ts
+++ b/src/filters/visibility/index.ts
@@ -1,119 +1,120 @@
import {
- VISIBILITY_RATIO,
- ELEMENT_BATCH_SIZE,
- MAX_COVER_RATIO,
-} from "@/constants";
-import Filter from "@/domain/Filter";
-import InteractiveElements from "@/domain/InteractiveElements";
+ VISIBILITY_RATIO,
+ ELEMENT_BATCH_SIZE,
+ MAX_COVER_RATIO,
+} from '@/constants';
+import Filter from '@/domain/Filter';
+import InteractiveElements from '@/domain/InteractiveElements';
-import QuadTree, { Rectangle } from "./quad";
-import { isVisible } from "./utils";
-import VisibilityCanvas from "./canvas";
+import QuadTree, { Rectangle } from './quad';
+import { isVisible } from './utils';
+import VisibilityCanvas from './canvas';
class VisibilityFilter extends Filter {
- private qt!: QuadTree;
-
- async apply(elements: InteractiveElements): Promise {
- this.qt = this.mapQuadTree();
-
- const results = await Promise.all([
- this.applyScoped(elements.fixed),
- this.applyScoped(elements.unknown),
- ]);
-
- return {
- fixed: results[0],
- unknown: results[1],
- };
- }
-
- async applyScoped(elements: HTMLElement[]): Promise {
- const results = await Promise.all(
- Array.from({
- length: Math.ceil(elements.length / ELEMENT_BATCH_SIZE),
- }).map(async (_, i) => {
- const batch = elements
- .slice(i * ELEMENT_BATCH_SIZE, (i + 1) * ELEMENT_BATCH_SIZE)
- .filter((el) => isVisible(el));
-
- // Now, let's process the batch
- const visibleElements: HTMLElement[] = [];
- for (const element of batch) {
- const isVisible = await this.isDeepVisible(element);
- if (isVisible) {
- visibleElements.push(element);
- }
- }
-
- return visibleElements;
- })
- );
-
- return results.flat();
- }
-
- mapQuadTree(): QuadTree {
- const boundary = new Rectangle(0, 0, window.innerWidth, window.innerHeight);
- const qt = new QuadTree(boundary, 4);
-
- // use a tree walker and also filter out invisible elements
- const walker = document.createTreeWalker(
- document.body,
- NodeFilter.SHOW_ELEMENT,
- {
- acceptNode: (node) => {
- const element = node as HTMLElement;
- if (isVisible(element)) {
- return NodeFilter.FILTER_ACCEPT;
- }
- return NodeFilter.FILTER_REJECT;
- },
- }
- );
-
- let currentNode: Node | null = walker.currentNode;
- while (currentNode) {
- const element = currentNode as HTMLElement;
- const rect = element.getBoundingClientRect();
- qt.insert(
- new Rectangle(rect.left, rect.top, rect.width, rect.height, element)
- );
- currentNode = walker.nextNode();
- }
-
- return qt;
- }
-
- async isDeepVisible(element: HTMLElement) {
- return new Promise((resolve) => {
- const observer = new IntersectionObserver(async (entries) => {
- const entry = entries[0];
- observer.disconnect();
-
- if (entry.intersectionRatio < VISIBILITY_RATIO) {
- resolve(false);
- return;
- }
-
- const rect = element.getBoundingClientRect();
- // If rect is covering more than size * MAX_COVER_RATIO of the screen ignore it (we do not want to consider full screen ads)
- if (
- rect.width >= window.innerWidth * MAX_COVER_RATIO ||
- rect.height >= window.innerHeight * MAX_COVER_RATIO
- ) {
- resolve(false);
- return;
- }
-
- // IntersectionObserver only checks intersection with the viewport, not with other elements
- // Thus, we need to calculate the visible area ratio relative to the intersecting elements
- const canvas = new VisibilityCanvas(element);
- const visibleAreaRatio = await canvas.eval(this.qt);
- resolve(visibleAreaRatio >= VISIBILITY_RATIO);
- });
- observer.observe(element);
- });
- }
+ private qt!: QuadTree;
+
+ async apply(elements: InteractiveElements): Promise {
+ this.qt = this.mapQuadTree();
+
+ const results = await Promise.all([
+ this.applyScoped(elements.fixed),
+ this.applyScoped(elements.unknown),
+ ]);
+
+ return {
+ fixed: results[0],
+ unknown: results[1],
+ };
+ }
+
+ async applyScoped(elements: HTMLElement[]): Promise {
+ const results = await Promise.all(
+ Array.from({
+ length: Math.ceil(elements.length / ELEMENT_BATCH_SIZE),
+ }).map(async (_, i) => {
+ const batch = elements
+ .slice(i * ELEMENT_BATCH_SIZE, (i + 1) * ELEMENT_BATCH_SIZE)
+ .filter((el) => isVisible(el));
+
+ // Now, let's process the batch
+ const visibleElements: HTMLElement[] = [];
+ for (const element of batch) {
+ const isVisible = await this.isDeepVisible(element);
+ if (isVisible) {
+ visibleElements.push(element);
+ }
+ }
+
+ return visibleElements;
+ })
+ );
+
+ return results.flat();
+ }
+
+ mapQuadTree(): QuadTree {
+ const boundary = new Rectangle(0, 0, window.innerWidth, window.innerHeight);
+ const qt = new QuadTree(boundary, 4);
+
+ // use a tree walker and also filter out invisible elements
+ const walker = document.createTreeWalker(
+ document.body,
+ NodeFilter.SHOW_ELEMENT,
+ {
+ acceptNode: (node) => {
+ const element = node as HTMLElement;
+ if (isVisible(element)) {
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ return NodeFilter.FILTER_REJECT;
+ },
+ }
+ );
+
+ let currentNode: Node | null = walker.currentNode;
+ while (currentNode) {
+ const element = currentNode as HTMLElement;
+ const rect = element.getBoundingClientRect();
+ qt.insert(
+ new Rectangle(rect.left, rect.top, rect.width, rect.height, element)
+ );
+ currentNode = walker.nextNode();
+ }
+
+ return qt;
+ }
+
+ async isDeepVisible(element: HTMLElement) {
+ return new Promise((resolve) => {
+ const observer = new IntersectionObserver(async (entries) => {
+ const entry = entries[0];
+ observer.disconnect();
+
+ if (entry.intersectionRatio < VISIBILITY_RATIO) {
+ resolve(false);
+ return;
+ }
+
+ const rect = element.getBoundingClientRect();
+ // If rect is covering more than size * MAX_COVER_RATIO of the screen ignore it (we do not want to consider full screen ads)
+ if (
+ rect.width >= window.innerWidth * MAX_COVER_RATIO ||
+ rect.height >= window.innerHeight * MAX_COVER_RATIO
+ ) {
+ resolve(false);
+ return;
+ }
+
+ // IntersectionObserver only checks intersection with the viewport, not with other elements
+ // Thus, we need to calculate the visible area ratio relative to the intersecting elements
+ const canvas = new VisibilityCanvas(element);
+ const visibleAreaRatio = await canvas.eval(this.qt);
+
+ resolve(visibleAreaRatio >= VISIBILITY_RATIO);
+ });
+ observer.observe(element);
+ });
+ }
}
export default VisibilityFilter;
diff --git a/src/filters/visibility/quad.ts b/src/filters/visibility/quad.ts
index ee3956a..79ed3c6 100644
--- a/src/filters/visibility/quad.ts
+++ b/src/filters/visibility/quad.ts
@@ -1,127 +1,132 @@
export class Rectangle {
- x: number;
- y: number;
- width: number;
- height: number;
- element: HTMLElement | null;
-
- constructor(
- x: number,
- y: number,
- width: number,
- height: number,
- element: HTMLElement | null = null
- ) {
- this.x = x;
- this.y = y;
- this.width = width;
- this.height = height;
- this.element = element;
- }
-
- contains(rect: Rectangle): boolean {
- return (
- rect.x >= this.x &&
- rect.x + rect.width <= this.x + this.width &&
- rect.y >= this.y &&
- rect.y + rect.height <= this.y + this.height
- );
- }
-
- intersects(rect: Rectangle): boolean {
- return !(
- rect.x > this.x + this.width ||
- rect.x + rect.width < this.x ||
- rect.y > this.y + this.height ||
- rect.y + rect.height < this.y
- );
- }
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ element: HTMLElement | null;
+
+ constructor(
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ element: HTMLElement | null = null
+ ) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ this.element = element;
+ }
+
+ contains(rect: Rectangle): boolean {
+ return (
+ rect.x >= this.x &&
+ rect.x + rect.width <= this.x + this.width &&
+ rect.y >= this.y &&
+ rect.y + rect.height <= this.y + this.height
+ );
+ }
+
+ intersects(rect: Rectangle): boolean {
+ return !(
+ rect.x > this.x + this.width ||
+ rect.x + rect.width < this.x ||
+ rect.y > this.y + this.height ||
+ rect.y + rect.height < this.y
+ );
+ }
}
export default class QuadTree {
- boundary: Rectangle;
- capacity: number;
- elements: Rectangle[];
- divided: boolean;
- northeast: QuadTree | null;
- northwest: QuadTree | null;
- southeast: QuadTree | null;
- southwest: QuadTree | null;
-
- constructor(boundary: Rectangle, capacity: number) {
- this.boundary = boundary;
- this.capacity = capacity;
- this.elements = [];
- this.divided = false;
- this.northeast = null;
- this.northwest = null;
- this.southeast = null;
- this.southwest = null;
- }
-
- subdivide(): void {
- const x = this.boundary.x;
- const y = this.boundary.y;
- const w = this.boundary.width / 2;
- const h = this.boundary.height / 2;
-
- const ne = new Rectangle(x + w, y, w, h);
- const nw = new Rectangle(x, y, w, h);
- const se = new Rectangle(x + w, y + h, w, h);
- const sw = new Rectangle(x, y + h, w, h);
-
- this.northeast = new QuadTree(ne, this.capacity);
- this.northwest = new QuadTree(nw, this.capacity);
- this.southeast = new QuadTree(se, this.capacity);
- this.southwest = new QuadTree(sw, this.capacity);
-
- this.divided = true;
- }
-
- insert(element: Rectangle): boolean {
- if (!this.boundary.intersects(element)) {
- return false;
- }
-
- if (this.elements.length < this.capacity) {
- this.elements.push(element);
- return true;
- } else {
- if (!this.divided) {
- this.subdivide();
- }
-
- if (this.northeast!.insert(element)) {
- return true;
- } else if (this.northwest!.insert(element)) {
- return true;
- } else if (this.southeast!.insert(element)) {
- return true;
- } else if (this.southwest!.insert(element)) {
- return true;
- }
- return false;
- }
- }
-
- query(range: Rectangle, found: Rectangle[] = []): Rectangle[] {
- if (!this.boundary.intersects(range)) {
- return found;
- }
-
- for (let element of this.elements) {
- if (range.intersects(element)) {
- found.push(element);
- }
- }
-
- if (this.divided) {
- this.northwest!.query(range, found);
- this.northeast!.query(range, found);
- this.southwest!.query(range, found);
- this.southeast!.query(range, found);
- }
-
- return found;
- }
+ boundary: Rectangle;
+ capacity: number;
+ elements: Rectangle[];
+ divided: boolean;
+ northeast: QuadTree | null;
+ northwest: QuadTree | null;
+ southeast: QuadTree | null;
+ southwest: QuadTree | null;
+
+ constructor(boundary: Rectangle, capacity: number) {
+ this.boundary = boundary;
+ this.capacity = capacity;
+ this.elements = [];
+ this.divided = false;
+ this.northeast = null;
+ this.northwest = null;
+ this.southeast = null;
+ this.southwest = null;
+ }
+
+ subdivide(): void {
+ const x = this.boundary.x;
+ const y = this.boundary.y;
+ const w = this.boundary.width / 2;
+ const h = this.boundary.height / 2;
+
+ const ne = new Rectangle(x + w, y, w, h);
+ const nw = new Rectangle(x, y, w, h);
+ const se = new Rectangle(x + w, y + h, w, h);
+ const sw = new Rectangle(x, y + h, w, h);
+
+ this.northeast = new QuadTree(ne, this.capacity);
+ this.northwest = new QuadTree(nw, this.capacity);
+ this.southeast = new QuadTree(se, this.capacity);
+ this.southwest = new QuadTree(sw, this.capacity);
+
+ this.divided = true;
+ }
+
+ insert(element: Rectangle): boolean {
+ if (!this.boundary.intersects(element)) {
+ return false;
+ }
+
+ if (this.elements.length < this.capacity && !this.divided) {
+ this.elements.push(element);
+ return true;
+ } else {
+ if (!this.divided) {
+ this.subdivide();
+ }
+
+ let inserted = false;
+ if (this.northeast!.boundary.intersects(element)) {
+ inserted = this.northeast!.insert(element) || inserted;
+ }
+ if (this.northwest!.boundary.intersects(element)) {
+ inserted = this.northwest!.insert(element) || inserted;
+ }
+ if (this.southeast!.boundary.intersects(element)) {
+ inserted = this.southeast!.insert(element) || inserted;
+ }
+ if (this.southwest!.boundary.intersects(element)) {
+ inserted = this.southwest!.insert(element) || inserted;
+ }
+
+ return inserted;
+ }
+ }
+
+ query(range: Rectangle, found: Rectangle[] = []): Rectangle[] {
+ if (!this.boundary.intersects(range)) {
+ return found;
+ }
+
+ for (let element of this.elements) {
+ if (range.intersects(element)) {
+ found.push(element);
+ }
+ }
+
+ if (this.divided) {
+ this.northwest!.query(range, found);
+ this.northeast!.query(range, found);
+ this.southwest!.query(range, found);
+ this.southeast!.query(range, found);
+ }
+
+ return found.filter((el, i, arr) => arr.indexOf(el) === i);
+ }
}
diff --git a/src/filters/visibility/utils.ts b/src/filters/visibility/utils.ts
index 3525639..38b952a 100644
--- a/src/filters/visibility/utils.ts
+++ b/src/filters/visibility/utils.ts
@@ -1,71 +1,83 @@
/*
* Utility
*/
+
+/**
+ * Check if element is below referenceElement
+ * @param element The element to check
+ * @param referenceElement The reference element to check against
+ * @returns True if element is below referenceElement, false otherwise
+ */
export function isAbove(
- element: HTMLElement,
- referenceElement: HTMLElement
+ element: HTMLElement,
+ referenceElement: HTMLElement
): boolean {
- const elementZIndex = window.getComputedStyle(element).zIndex;
- const referenceElementZIndex =
- window.getComputedStyle(referenceElement).zIndex;
-
- const elementPosition = element.compareDocumentPosition(referenceElement);
+ // Helper function to get the effective z-index value
+ function getEffectiveZIndex(element: HTMLElement): number {
+ while (element) {
+ const zIndex = window.getComputedStyle(element).zIndex;
+ if (zIndex !== 'auto') {
+ const zIndexValue = parseInt(zIndex, 10);
+ return isNaN(zIndexValue) ? 0 : zIndexValue;
+ }
+ element = element.parentElement as HTMLElement;
+ }
+ return 0;
+ }
- // Check if element is a child of referenceElement
- if (elementPosition & Node.DOCUMENT_POSITION_CONTAINS) {
- return false;
- }
+ const elementZIndex = getEffectiveZIndex(element);
+ const referenceElementZIndex = getEffectiveZIndex(referenceElement);
- // Check if referenceElement is a child of element
- if (elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) {
- return true;
- }
+ const elementPosition = element.compareDocumentPosition(referenceElement);
- // Compare z-index if both are not 'auto'
- if (elementZIndex !== "auto" && referenceElementZIndex !== "auto") {
- return parseInt(elementZIndex) > parseInt(referenceElementZIndex);
- }
+ // Check if element is a child or a parent of referenceElement
+ if (
+ elementPosition & Node.DOCUMENT_POSITION_CONTAINS ||
+ elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY
+ ) {
+ return false;
+ }
- // If one of them has z-index 'auto', we need to compare their DOM position
- if (elementZIndex === "auto" || referenceElementZIndex === "auto") {
- return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING);
- }
+ // Compare z-index values
+ if (elementZIndex !== referenceElementZIndex) {
+ return elementZIndex < referenceElementZIndex;
+ }
- // As a fallback, compare document order
- return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING);
+ // As a fallback, compare document order
+ return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING);
}
export function isVisible(element: HTMLElement): boolean {
- if (element.offsetWidth === 0 && element.offsetHeight === 0) {
- return false;
- }
+ if (element.offsetWidth === 0 && element.offsetHeight === 0) {
+ return false;
+ }
- const rect = element.getBoundingClientRect();
- if (rect.width <= 0 || rect.height <= 0) {
- return false;
- }
+ const rect = element.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) {
+ return false;
+ }
- const style = window.getComputedStyle(element);
- if (
- style.display === "none" ||
- style.visibility === "hidden" ||
- style.pointerEvents === "none"
- ) {
- return false;
- }
+ const style = window.getComputedStyle(element);
+ if (
+ style.display === 'none' ||
+ style.visibility === 'hidden' ||
+ style.pointerEvents === 'none'
+ ) {
+ return false;
+ }
- let parent = element.parentElement;
- while (parent !== null) {
- const parentStyle = window.getComputedStyle(parent);
- if (
- parentStyle.display === "none" ||
- parentStyle.visibility === "hidden" ||
- parentStyle.pointerEvents === "none"
- ) {
- return false;
- }
- parent = parent.parentElement;
- }
+ let parent = element.parentElement;
+ while (parent !== null) {
+ const parentStyle = window.getComputedStyle(parent);
+ if (
+ parentStyle.display === 'none' ||
+ parentStyle.visibility === 'hidden' ||
+ parentStyle.pointerEvents === 'none'
+ ) {
+ return false;
+ }
+ parent = parent.parentElement;
+ }
- return true;
+ return true;
}
diff --git a/src/style.css b/src/style.css
index 7d6bc05..9d840fe 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,31 +1,31 @@
.SoM {
- position: fixed;
- z-index: 2147483646;
- pointer-events: none;
- background-color: rgba(var(--SoM-color), 0.45);
+ position: fixed;
+ z-index: 2147483646;
+ pointer-events: none;
+ background-color: rgba(var(--SoM-color), 0.35);
}
.SoM.editable {
- /* Apply stripes effect to display that the element is editable, while keeping the same colors */
- background: repeating-linear-gradient(
- 45deg,
- rgba(var(--SoM-color), 0.15),
- rgba(var(--SoM-color), 0.15) 10px,
- rgba(var(--SoM-color), 0.45) 10px,
- rgba(var(--SoM-color), 0.45) 20px
- );
+ /* Apply stripes effect to display that the element is editable, while keeping the same colors */
+ background: repeating-linear-gradient(
+ 45deg,
+ rgba(var(--SoM-color), 0.15),
+ rgba(var(--SoM-color), 0.15) 10px,
+ rgba(var(--SoM-color), 0.35) 10px,
+ rgba(var(--SoM-color), 0.35) 20px
+ );
- /* Add an outline to make the element more visible */
- outline: 2px solid rgba(var(--SoM-color), 0.7);
+ /* Add an outline to make the element more visible */
+ outline: 2px solid rgba(var(--SoM-color), 0.7);
}
.SoM > label {
- position: absolute;
- padding: 0 3px;
- font-size: 1rem;
- font-weight: bold;
- line-height: 1.2rem;
- white-space: nowrap;
- font-family: "Courier New", Courier, monospace;
- background-color: rgba(var(--SoM-color), 0.7);
+ position: absolute;
+ padding: 0 3px;
+ font-size: 1rem;
+ font-weight: bold;
+ line-height: 1.2rem;
+ white-space: nowrap;
+ font-family: 'Courier New', Courier, monospace;
+ background-color: rgba(var(--SoM-color), 0.7);
}
From 70af4f6ca978c2efe8a1e68082f72cac423a2675 Mon Sep 17 00:00:00 2001
From: Brewen Couaran <45310490+brewcoua@users.noreply.github.com>
Date: Sat, 8 Jun 2024 11:46:52 +0200
Subject: [PATCH 4/5] build: use rollup to build & add CI with semantic release
---
.github/workflows/ci.yml | 47 ++
.gitignore | 5 +-
build.ts | 51 --
bun.lockb | Bin 2337 -> 221847 bytes
dist/SoM.js | 1050 --------------------------------------
dist/SoM.min.js | 1 -
package.json | 26 +-
release.config.ts | 33 ++
rollup.config.ts | 27 +
9 files changed, 132 insertions(+), 1108 deletions(-)
create mode 100644 .github/workflows/ci.yml
delete mode 100644 build.ts
delete mode 100644 dist/SoM.js
delete mode 100644 dist/SoM.min.js
create mode 100644 release.config.ts
create mode 100644 rollup.config.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..217d15e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,47 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - master
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v1
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Build project
+ run: bun run build
+
+ publish:
+ needs: build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v1
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Build project
+ run: bun run build
+
+ - name: Release
+ run: bunx semantic-release
+ env:
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 40b878d..7dcffc0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,4 @@
-node_modules/
\ No newline at end of file
+node_modules/
+.rollup.cache
+*.tsbuildinfo
+dist/
\ No newline at end of file
diff --git a/build.ts b/build.ts
deleted file mode 100644
index d4d601b..0000000
--- a/build.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import CleanCSS from "clean-css";
-import { renameSync, rmSync } from "fs";
-import type { BunPlugin } from "bun";
-
-const InlineStylePlugin: BunPlugin = {
- name: "inline-style",
- setup(build) {
- // Bundle CSS files as text
- build.onLoad({ filter: /\.css$/ }, async (args) => {
- const value = await Bun.file(args.path).text();
- const minified = new CleanCSS().minify(value);
-
- return {
- contents: minified.styles,
- loader: "text",
- };
- });
- },
-};
-
-async function main() {
- process.env.FORCE_COLOR = "1";
-
- rmSync("dist", { recursive: true, force: true });
-
- const result1 = await Bun.build({
- entrypoints: ["./src/main.ts"],
- outdir: "./dist",
- plugins: [InlineStylePlugin],
- });
- if (!result1.success) {
- result1.logs.forEach(console.log);
- process.exit(1);
- }
- renameSync("./dist/main.js", "./dist/SoM.js");
-
- const result2 = await Bun.build({
- entrypoints: ["./src/main.ts"],
- outdir: "./dist",
- plugins: [InlineStylePlugin],
- minify: true,
- });
- if (!result2.success) {
- result2.logs.forEach(console.log);
- process.exit(1);
- }
-
- renameSync("./dist/main.js", "./dist/SoM.min.js");
-}
-
-main().catch(console.error);
diff --git a/bun.lockb b/bun.lockb
index 52e2bdb64288959ff6a62315a57b81b9778925f4..3dcf2c0cf3373eff3dbd76b441def2181be28740 100755
GIT binary patch
literal 221847
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