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/README.md b/README.md index dfbe9b0..9afbdff 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,29 @@ You can then use the `SoM` object in the `window` object to interact with the sc ### Example ```js -window.SoM.display().then(() => console.log("Set-of-Marks displayed")); +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 find the elements that are possibly intersecting with the element, using a QuadTree that we previously built with all elements on the page by their bounding boxes. We then query the QuadTree for elements that are possibly intersecting with the element, and we draw them on a canvas, after drawing the original element. We then calculate the pixel-by-pixel visibility ratio by counting the number of pixels that were not overlapped by other elements. If the ratio is above the threshold, we consider the element visible enough. + +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: diff --git a/build.ts b/build.ts deleted file mode 100644 index 926ddc1..0000000 --- a/build.ts +++ /dev/null @@ -1,42 +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 }); - - await Bun.build({ - entrypoints: ["./src/main.ts"], - outdir: "./dist", - plugins: [InlineStylePlugin], - }); - renameSync("./dist/main.js", "./dist/SoM.js"); - - await Bun.build({ - entrypoints: ["./src/main.ts"], - outdir: "./dist", - plugins: [InlineStylePlugin], - minify: true, - }); - renameSync("./dist/main.js", "./dist/SoM.min.js"); -} - -main().catch(console.error); diff --git a/bun.lockb b/bun.lockb index 560b4d2..3dcf2c0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/dist/SoM.js b/dist/SoM.js deleted file mode 100644 index 91483e2..0000000 --- a/dist/SoM.js +++ /dev/null @@ -1,893 +0,0 @@ -// src/constants.ts -var SELECTORS = [ - "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"]' -]; -var EDITABLE_SELECTORS = [ - '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"]' -]; -var VISIBILITY_RATIO = 0.6; -var ELEMENT_SAMPLING_RATE = 0.2; -var SURROUNDING_RADIUS = 200; -var MAX_LUMINANCE = 0.7; -var MIN_LUMINANCE = 0.25; -var MIN_SATURATION = 0.3; - -// src/domain/filter.ts -class Filter { -} - -// src/filters/visibility.ts -class VisibilityFilter extends Filter { - constructor() { - super(...arguments); - } - 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; - } - if (!passed) { - return null; - } - const isVisible = await this.isElementVisible(element); - if (!isVisible) { - return null; - } - return element; - })); - return visibleElements.filter((element) => element !== null); - } - 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); - }); - } - async getVisibilityRatio(element, rect) { - 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); - 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 - }; - 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) { - return 0; - } - 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; - } - countVisiblePixels(ctx, rect) { - 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) { - visiblePixels++; - } - } - return visiblePixels; - } - drawElement(element, ctx, rect, baseRect, 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]; - path.moveTo(offsetRect.left + radius[0], offsetRect.top); - path.arcTo(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, rect) { - 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; - } -} -var visibility_default = VisibilityFilter; -// src/filters/nesting.ts -var SIZE_THRESHOLD = 0.9; -var QUANTITY_THRESHOLD = 3; -var PRIORITY_SELECTOR = ["a", "button", "input", "select", "textarea"]; - -class NestingFilter extends Filter { - constructor() { - super(...arguments); - } - async apply(elements) { - const { top, others } = this.getTopLevelElements(elements); - const results = await Promise.all(top.map(async (topElement) => this.compareTopWithChildren(topElement, others))); - return results.flat(); - } - async compareTopWithChildren(top, children) { - if (PRIORITY_SELECTOR.some((selector) => top.matches(selector))) { - return [top]; - } - const branches = this.getBranches(top, children); - const rect = top.getBoundingClientRect(); - if (branches.length <= 1) { - return [top]; - } - const results = await Promise.all(branches.map(async (branch) => { - const firstHitRect = branch.top.getBoundingClientRect(); - if (firstHitRect.width / rect.width < SIZE_THRESHOLD && firstHitRect.height / rect.height < SIZE_THRESHOLD) { - return []; - } - if (branch.children.length === 0) { - return [branch.top]; - } - return await this.compareTopWithChildren(branch.top, branch.children); - })); - const total = results.flat(); - if (total.length > QUANTITY_THRESHOLD) { - return total; - } - return [top, ...total]; - } - getBranches(element, elements) { - const firstHits = this.getFirstHitChildren(element, elements); - return firstHits.map((firstHit) => { - const children = elements.filter((child) => !firstHits.includes(child) && firstHit.contains(child)); - return { top: firstHit, children }; - }); - } - getFirstHitChildren(element, elements) { - const directChildren = element.querySelectorAll(":scope > *"); - const clickableDirectChildren = Array.from(directChildren).filter((child) => elements.includes(child)); - if (clickableDirectChildren.length > 0) { - return clickableDirectChildren; - } - return Array.from(directChildren).flatMap((child) => this.getFirstHitChildren(child, elements)); - } - getTopLevelElements(elements) { - const topLevelElements = [], nonTopLevelElements = []; - for (const element of elements) { - if (!elements.some((otherElement) => otherElement !== element && otherElement.contains(element))) { - topLevelElements.push(element); - } else { - nonTopLevelElements.push(element); - } - } - return { top: topLevelElements, others: nonTopLevelElements }; - } -} -var nesting_default = NestingFilter; -// src/loader.ts -class Loader { - filters = { - visibility: new visibility_default, - nesting: new nesting_default - }; - async loadElements() { - const selector = SELECTORS.join(","); - let preselectedElements = 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))); - } - 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]); - } - } - 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); - } - shadowRoots() { - const shadowRoots = []; - const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { - acceptNode(node2) { - return NodeFilter.FILTER_ACCEPT; - } - }); - let node; - while (node = walker.nextNode()) { - if (node && node.shadowRoot) { - shadowRoots.push(node.shadowRoot); - } - } - return shadowRoots; - } -} - -// src/ui/colors.ts -class UIColors { - contrastColor(element, surroundingColors) { - const style = window.getComputedStyle(element); - const bgColor = Color.fromCSS(style.backgroundColor); - return this.getBestContrastColor([bgColor, ...surroundingColors]); - } - getBestContrastColor(colors) { - const complimentaryColors = colors.filter((color2) => color2.a > 0).map((color2) => color2.complimentary()); - let color; - if (complimentaryColors.length === 0) { - color = new Color(Math.floor(Math.random() * 255), Math.floor(Math.random() * 255), Math.floor(Math.random() * 255)); - } else { - color = this.getAverageColor(complimentaryColors); - } - if (color.luminance() > MAX_LUMINANCE) { - color = color.withLuminance(MAX_LUMINANCE); - } else if (color.luminance() < MIN_LUMINANCE) { - color = color.withLuminance(MIN_LUMINANCE); - } - if (color.saturation() < MIN_SATURATION) { - color = color.withSaturation(MIN_SATURATION); - } - return color; - } - getAverageColor(colors) { - const r = colors.reduce((acc, color) => acc + color.r, 0) / colors.length; - const g = colors.reduce((acc, color) => acc + color.g, 0) / colors.length; - const b = colors.reduce((acc, color) => acc + color.b, 0) / colors.length; - return new Color(r, g, b); - } -} - -class Color { - r; - g; - b; - a; - constructor(r, g, b, a = 255) { - this.r = r; - this.g = g; - this.b = b; - this.a = a; - if (r < 0 || r > 255) { - throw new Error(`Invalid red value: ${r}`); - } - if (g < 0 || g > 255) { - throw new Error(`Invalid green value: ${g}`); - } - if (b < 0 || b > 255) { - throw new Error(`Invalid blue value: ${b}`); - } - if (a < 0 || a > 255) { - throw new Error(`Invalid alpha value: ${a}`); - } - this.r = Math.round(r); - this.g = Math.round(g); - this.b = Math.round(b); - this.a = Math.round(a); - } - static fromCSS(css) { - if (css.startsWith("#")) { - return Color.fromHex(css); - } - if (css.startsWith("rgb")) { - const rgb = css.replace(/rgba?\(/, "").replace(")", "").split(",").map((c) => parseInt(c.trim())); - return new Color(...rgb); - } - if (css.startsWith("hsl")) { - const hsl = css.replace(/hsla?\(/, "").replace(")", "").split(",").map((c) => parseFloat(c.trim())); - return Color.fromHSL({ h: hsl[0], s: hsl[1], l: hsl[2] }); - } - const hex = NamedColors[css.toLowerCase()]; - if (hex) { - return Color.fromHex(hex); - } - throw new Error(`Unknown color format: ${css}`); - } - static fromHex(hex) { - hex = hex.replace("#", ""); - if (hex.length === 3) { - hex = hex.split("").map((char) => char + char).join(""); - } - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - if (hex.length === 8) { - const a = parseInt(hex.substring(6, 8), 16); - return new Color(r, g, b, a); - } - return new Color(r, g, b); - } - static fromHSL(hsl) { - const h = hsl.h; - const s = hsl.s; - const l = hsl.l; - let r, g, b; - if (s === 0) { - r = g = b = l; - } else { - const hue2rgb = (p2, q2, t) => { - if (t < 0) - t += 1; - if (t > 1) - t -= 1; - if (t < 0.16666666666666666) - return p2 + (q2 - p2) * 6 * t; - if (t < 0.5) - return q2; - if (t < 0.6666666666666666) - return p2 + (q2 - p2) * (0.6666666666666666 - t) * 6; - return p2; - }; - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - r = hue2rgb(p, q, h + 0.3333333333333333); - g = hue2rgb(p, q, h); - b = hue2rgb(p, q, h - 0.3333333333333333); - } - return new Color(r * 255, g * 255, b * 255); - } - luminance() { - const r = this.r / 255; - const g = this.g / 255; - const b = this.b / 255; - const a = [r, g, b].map((c) => { - if (c <= 0.03928) { - return c / 12.92; - } - return Math.pow((c + 0.055) / 1.055, 2.4); - }); - return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]; - } - withLuminance(luminance) { - const l = this.luminance(); - const ratio = luminance / l; - const r = Math.min(255, this.r * ratio); - const g = Math.min(255, this.g * ratio); - const b = Math.min(255, this.b * ratio); - return new Color(r, g, b, this.a); - } - saturation() { - return this.toHsl().s; - } - withSaturation(saturation) { - const hsl = this.toHsl(); - hsl.s = saturation; - return Color.fromHSL(hsl); - } - contrast(color) { - const l1 = this.luminance(); - const l2 = color.luminance(); - return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); - } - complimentary() { - const hsl = this.toHsl(); - hsl.h = (hsl.h + 0.5) % 1; - return Color.fromHSL(hsl); - } - toHex() { - const r = this.r.toString(16).padStart(2, "0"); - const g = this.g.toString(16).padStart(2, "0"); - const b = this.b.toString(16).padStart(2, "0"); - if (this.a < 255) { - const a = this.a.toString(16).padStart(2, "0"); - return `#${r}${g}${b}${a}`; - } - return `#${r}${g}${b}`; - } - toHsl() { - const r = this.r / 255; - const g = this.g / 255; - const b = this.b / 255; - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - let h = (max + min) / 2; - let s = (max + min) / 2; - let l = (max + min) / 2; - if (max === min) { - h = s = 0; - } else { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - switch (max) { - case r: - h = (g - b) / d + (g < b ? 6 : 0); - break; - case g: - h = (b - r) / d + 2; - break; - case b: - h = (r - g) / d + 4; - break; - } - h /= 6; - } - return { h, s, l, a: this.a / 255 }; - } - toString() { - return this.toHex(); - } -} -var NamedColors = { - 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" -}; - -// src/ui.ts -class UI { - colors = new UIColors; - display(elements) { - const labels = []; - const boundingBoxes = []; - const rawBoxes = []; - for (let i = 0;i < elements.length; i++) { - const element = elements[i]; - const rect = element.getBoundingClientRect(); - const div = document.createElement("div"); - div.style.left = `${rect.left}px`; - div.style.top = `${rect.top}px`; - div.style.width = `${rect.width}px`; - div.style.height = `${rect.height}px`; - div.classList.add("SoM"); - if (element.isContentEditable || EDITABLE_SELECTORS.some((selector) => element.matches(selector))) { - div.classList.add("editable"); - } - const surroundingColors = boundingBoxes.filter((box) => { - const distances = [ - Math.sqrt(Math.pow(rect.left - box.left, 2) + Math.pow(rect.top - box.top, 2)), - Math.sqrt(Math.pow(rect.right - box.right, 2) + Math.pow(rect.top - box.top, 2)), - Math.sqrt(Math.pow(rect.left - box.left, 2) + Math.pow(rect.bottom - box.bottom, 2)), - Math.sqrt(Math.pow(rect.right - box.right, 2) + Math.pow(rect.bottom - box.bottom, 2)) - ]; - 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); - boundingBoxes.push({ - top: rect.top, - bottom: rect.bottom, - left: rect.left, - right: rect.right, - width: rect.width, - height: rect.height, - color - }); - rawBoxes.push(div); - } - for (let i = 0;i < elements.length; i++) { - const element = elements[i]; - const box = boundingBoxes[i]; - const label = document.createElement("label"); - label.textContent = `${i}`; - label.style.color = this.getColorByLuminance(box.color); - rawBoxes[i].appendChild(label); - const labelRect = label.getBoundingClientRect(); - const gridSize = 10; - const positions = []; - for (let i2 = 0;i2 <= gridSize; i2++) { - positions.push({ - top: box.top - labelRect.height, - left: box.left + box.width / gridSize * i2 - labelRect.width / 2 - }); - positions.push({ - top: box.bottom, - left: box.left + box.width / gridSize * i2 - labelRect.width / 2 - }); - positions.push({ - top: box.top + box.height / gridSize * i2 - labelRect.height / 2, - left: box.left - labelRect.width - }); - positions.push({ - top: box.top + box.height / gridSize * i2 - labelRect.height / 2, - left: box.right - }); - } - const scores = positions.map((position) => { - let score = 0; - if (position.top < 0 || position.top + labelRect.height > window.innerHeight || position.left < 0 || position.left + labelRect.width > window.innerWidth) { - score += Infinity; - } else { - labels.concat(boundingBoxes).forEach((existing) => { - if (existing.top <= box.top && existing.bottom >= box.bottom && existing.left <= box.left && existing.right >= box.right) { - return; - } - const overlapWidth = Math.max(0, Math.min(position.left + labelRect.width, existing.left + existing.width) - Math.max(position.left, existing.left)); - const overlapHeight = Math.max(0, Math.min(position.top + labelRect.height, existing.top + existing.height) - Math.max(position.top, existing.top)); - score += overlapWidth * overlapHeight; - }); - } - return score; - }); - const bestPosition = positions[scores.indexOf(Math.min(...scores))]; - label.style.top = `${bestPosition.top - box.top}px`; - label.style.left = `${bestPosition.left - box.left}px`; - labels.push({ - top: bestPosition.top, - left: bestPosition.left, - right: bestPosition.left + labelRect.width, - bottom: bestPosition.top + labelRect.height, - width: labelRect.width, - height: labelRect.height - }); - element.setAttribute("data-SoM", `${i}`); - } - } - getColorByLuminance(color) { - return color.luminance() > 0.5 ? "black" : "white"; - } -} - -// 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)}"; - -// src/main.ts -class SoM { - loader = new Loader; - ui = new UI; - async display() { - this.log("Displaying..."); - const startTime = performance.now(); - const elements = await this.loader.loadElements(); - this.clear(); - this.ui.display(elements); - this.log("Done!", `Took ${performance.now() - startTime}ms to display ${elements.length} elements.`); - } - clear() { - document.querySelectorAll(".SoM").forEach((element) => { - element.remove(); - }); - document.querySelectorAll("[data-som]").forEach((element) => { - element.removeAttribute("data-som"); - }); - } - hide() { - document.querySelectorAll(".SoM").forEach((element) => element.style.display = "none"); - } - show() { - document.querySelectorAll(".SoM").forEach((element) => element.style.display = "block"); - } - resolve(id) { - return document.querySelector(`[data-som="${id}"]`); - } - log(...args) { - console.log("%cSoM", "color: white; background: #007bff; padding: 2px 5px; border-radius: 5px;", ...args); - } -} -if (!document.getElementById("SoM-styles")) { - const styleElement = document.createElement("style"); - styleElement.id = "SoM-styles"; - styleElement.innerHTML = style_default; - const interval = setInterval(() => { - if (document.head) { - clearInterval(interval); - document.head.appendChild(styleElement); - } - }, 100); -} -window.SoM = new SoM; -window.SoM.log("Ready!"); diff --git a/dist/SoM.min.js b/dist/SoM.min.js deleted file mode 100644 index 57b5a2c..0000000 --- a/dist/SoM.min.js +++ /dev/null @@ -1 +0,0 @@ -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!"); diff --git a/package.json b/package.json index d523832..6d189cc 100644 --- a/package.json +++ b/package.json @@ -1,32 +1,45 @@ { - "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", - "url": "https://brewen.dev" - }, - "repository": { - "type": "git", - "url": "git@github.com:brewcoua/web-som.git" - }, - "homepage": "https://github.com/brewcoua/web-som#readme", - "bugs": { - "url": "https://github.com/brewcoua/web-som/issues", - "email": "contact@brewen.dev" - }, - "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" - } + "name": "@brewcoua/web-som", + "version": "1.0.0", + "author": { + "name": "Brewen Couaran", + "email": "contact@brewen.dev", + "url": "https://brewen.dev" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/brewcoua/web-som.git" + }, + "main": "./SoM.min.js", + "files": [ + "SoM.min.js", + "SoM.min.js.map", + "SoM.js", + "SoM.js.map" + ], + "devDependencies": { + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^11.1.6", + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/exec": "^6.0.3", + "@semantic-release/git": "^10.0.1", + "@semantic-release/npm": "^12.0.1", + "conventional-changelog-conventionalcommits": "^8.0.0", + "prettier": "^3.2.5", + "rollup": "^4.18.0", + "rollup-plugin-string": "^3.0.0", + "semantic-release": "^24.0.0", + "tslib": "^2.6.3", + "typescript": "^5.4.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": "rollup -c" + } } diff --git a/release.config.ts b/release.config.ts new file mode 100644 index 0000000..b93da5d --- /dev/null +++ b/release.config.ts @@ -0,0 +1,33 @@ +export default { + branches: ['master'], + plugins: [ + [ + '@semantic-release/commit-analyzer', + { + preset: 'conventionalcommits', + }, + ], + '@semantic-release/release-notes-generator', + '@semantic-release/changelog', + [ + '@semantic-release/exec', + { + prepareCmd: 'cp package.json README.md LICENSE* dist', + }, + ], + '@semantic-release/git', + [ + '@semantic-release/github', + { + assets: 'dist/*.js', + }, + ], + [ + '@semantic-release/npm', + { + npmPublish: true, + pkgRoot: 'dist', + }, + ], + ], +}; diff --git a/rollup.config.ts b/rollup.config.ts new file mode 100644 index 0000000..ea31297 --- /dev/null +++ b/rollup.config.ts @@ -0,0 +1,27 @@ +import typescript from '@rollup/plugin-typescript'; +import terser from '@rollup/plugin-terser'; +import { string } from 'rollup-plugin-string'; + +export default { + input: 'src/main.ts', + output: [ + { + file: 'dist/SoM.js', + format: 'cjs', + sourcemap: true, + }, + { + file: 'dist/SoM.min.js', + format: 'cjs', + sourcemap: true, + plugins: [terser()], + }, + ], + plugins: [ + typescript(), + string({ + include: '**/*.css', + exclude: ['node_modules/**'], + }), + ], +}; 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..1edb78e --- /dev/null +++ b/src/filters/visibility/canvas.ts @@ -0,0 +1,223 @@ +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.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; +}; diff --git a/src/filters/visibility/index.ts b/src/filters/visibility/index.ts new file mode 100644 index 0000000..0d3e4f3 --- /dev/null +++ b/src/filters/visibility/index.ts @@ -0,0 +1,120 @@ +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..79ed3c6 --- /dev/null +++ b/src/filters/visibility/quad.ts @@ -0,0 +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 + ); + } +} + +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.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 new file mode 100644 index 0000000..38b952a --- /dev/null +++ b/src/filters/visibility/utils.ts @@ -0,0 +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 +): boolean { + // 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; + } + + const elementZIndex = getEffectiveZIndex(element); + const referenceElementZIndex = getEffectiveZIndex(referenceElement); + + const elementPosition = element.compareDocumentPosition(referenceElement); + + // 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; + } + + // Compare z-index values + if (elementZIndex !== referenceElementZIndex) { + return elementZIndex < referenceElementZIndex; + } + + // 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/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); } 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"] +}