| 1 | // The element picker that gets injected into the page. |
| 2 | // |
| 3 | // This function is serialised and executed in the page's world via |
| 4 | // scripting.executeScript, so it must be entirely self-contained — no imports, |
| 5 | // no closures over extension state. It has no extension privileges. |
| 6 | // |
| 7 | // It returns a Promise, and executeScript resolves to that promise's value. |
| 8 | // That means the result comes back as a *return value* rather than through |
| 9 | // runtime.sendMessage, so the sidebar needs no inbound message listener that a |
| 10 | // content script could reach. |
| 11 | |
| 12 | function tbPickElement() { |
| 13 | return new Promise((resolve) => { |
| 14 | const PREV = "__tbPickerActive"; |
| 15 | if (window[PREV]) { |
| 16 | window[PREV](); |
| 17 | } |
| 18 | |
| 19 | const box = document.createElement("div"); |
| 20 | Object.assign(box.style, { |
| 21 | position: "fixed", |
| 22 | zIndex: "2147483647", |
| 23 | pointerEvents: "none", |
| 24 | border: "2px solid #3b6ea5", |
| 25 | background: "rgba(59,110,165,0.15)", |
| 26 | borderRadius: "2px", |
| 27 | transition: "all 40ms linear", |
| 28 | }); |
| 29 | |
| 30 | const label = document.createElement("div"); |
| 31 | Object.assign(label.style, { |
| 32 | position: "fixed", |
| 33 | zIndex: "2147483647", |
| 34 | pointerEvents: "none", |
| 35 | font: "12px ui-monospace, monospace", |
| 36 | background: "#14161a", |
| 37 | color: "#d7dae0", |
| 38 | padding: "3px 6px", |
| 39 | borderRadius: "3px", |
| 40 | maxWidth: "60vw", |
| 41 | overflow: "hidden", |
| 42 | textOverflow: "ellipsis", |
| 43 | whiteSpace: "nowrap", |
| 44 | boxShadow: "0 2px 8px rgba(0,0,0,.4)", |
| 45 | }); |
| 46 | |
| 47 | document.documentElement.append(box, label); |
| 48 | |
| 49 | /** Element under the cursor, so a click resolves what the box is drawn on. */ |
| 50 | let current = /** @type {Element | null} */ (null); |
| 51 | |
| 52 | const cleanup = () => { |
| 53 | window[PREV] = null; |
| 54 | box.remove(); |
| 55 | label.remove(); |
| 56 | document.removeEventListener("mousemove", onMove, true); |
| 57 | document.removeEventListener("click", onClick, true); |
| 58 | document.removeEventListener("keydown", onKey, true); |
| 59 | window.removeEventListener("scroll", onScroll, true); |
| 60 | }; |
| 61 | window[PREV] = () => { |
| 62 | cleanup(); |
| 63 | resolve(null); |
| 64 | }; |
| 65 | |
| 66 | /** @param {Element} el */ |
| 67 | function paint(el) { |
| 68 | const r = el.getBoundingClientRect(); |
| 69 | Object.assign(box.style, { |
| 70 | left: `${r.left}px`, |
| 71 | top: `${r.top}px`, |
| 72 | width: `${r.width}px`, |
| 73 | height: `${r.height}px`, |
| 74 | }); |
| 75 | label.textContent = shortLabel(el); |
| 76 | const below = r.bottom + 22 < window.innerHeight; |
| 77 | label.style.left = `${Math.max(2, r.left)}px`; |
| 78 | label.style.top = below ? `${r.bottom + 3}px` : `${Math.max(2, r.top - 21)}px`; |
| 79 | } |
| 80 | |
| 81 | /** @param {MouseEvent} e */ |
| 82 | function onMove(e) { |
| 83 | const hit = /** @type {Element | null} */ (e.target); |
| 84 | const el = |
| 85 | hit && hit !== box && hit !== label |
| 86 | ? hit |
| 87 | : document.elementFromPoint(e.clientX ?? 0, e.clientY ?? 0); |
| 88 | if (!el || el.nodeType !== 1 || el === box || el === label) return; |
| 89 | current = el; |
| 90 | paint(el); |
| 91 | } |
| 92 | |
| 93 | // Scrolling moves the page under a box drawn in fixed coordinates. The |
| 94 | // element does not change, only where it is, so this repaints rather than |
| 95 | // re-hit-testing — a scroll event's target is the scroller, not what the |
| 96 | // cursor is over. |
| 97 | function onScroll() { |
| 98 | if (current) paint(current); |
| 99 | } |
| 100 | |
| 101 | /** @param {KeyboardEvent} e */ |
| 102 | function onKey(e) { |
| 103 | if (e.key === "Escape") { |
| 104 | e.preventDefault(); |
| 105 | e.stopPropagation(); |
| 106 | cleanup(); |
| 107 | resolve(null); |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // Capture phase + preventDefault, so picking a link doesn't navigate. |
| 112 | /** @param {MouseEvent} e */ |
| 113 | function onClick(e) { |
| 114 | e.preventDefault(); |
| 115 | e.stopPropagation(); |
| 116 | const el = current || /** @type {Element | null} */ (e.target); |
| 117 | cleanup(); |
| 118 | resolve(el && el.nodeType === 1 ? describe(el) : null); |
| 119 | } |
| 120 | |
| 121 | document.addEventListener("mousemove", onMove, true); |
| 122 | document.addEventListener("click", onClick, true); |
| 123 | document.addEventListener("keydown", onKey, true); |
| 124 | window.addEventListener("scroll", onScroll, true); |
| 125 | |
| 126 | // --- identifier construction --------------------------------------------- |
| 127 | |
| 128 | /** @param {string} v */ |
| 129 | function cssEscape(v) { |
| 130 | return window.CSS && CSS.escape ? CSS.escape(v) : String(v).replace(/[^\w-]/g, "\\$&"); |
| 131 | } |
| 132 | |
| 133 | /** @param {string} sel */ |
| 134 | function isUnique(sel) { |
| 135 | try { |
| 136 | return document.querySelectorAll(sel).length === 1; |
| 137 | } catch { |
| 138 | return false; |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // Attributes worth preferring: chosen by the app author, so they survive a |
| 143 | // redesign in a way that :nth-of-type chains do not. |
| 144 | const STABLE = [ |
| 145 | "data-testid", |
| 146 | "data-test-id", |
| 147 | "data-test", |
| 148 | "data-cy", |
| 149 | "data-qa", |
| 150 | "name", |
| 151 | "aria-label", |
| 152 | "role", |
| 153 | ]; |
| 154 | |
| 155 | /** @param {Element} el */ |
| 156 | function stableAttrSelector(el) { |
| 157 | for (const a of STABLE) { |
| 158 | const v = el.getAttribute && el.getAttribute(a); |
| 159 | if (v && v.length < 100) { |
| 160 | const sel = `${el.tagName.toLowerCase()}[${a}="${v.replace(/"/g, '\\"')}"]`; |
| 161 | if (isUnique(sel)) return sel; |
| 162 | } |
| 163 | } |
| 164 | return null; |
| 165 | } |
| 166 | |
| 167 | /** @param {Element} el */ |
| 168 | function cssSelector(el) { |
| 169 | if (el.id && isUnique(`#${cssEscape(el.id)}`)) return `#${cssEscape(el.id)}`; |
| 170 | const stable = stableAttrSelector(el); |
| 171 | if (stable) return stable; |
| 172 | |
| 173 | /** @type {string[]} */ |
| 174 | const parts = []; |
| 175 | let cur = /** @type {Element | null} */ (el); |
| 176 | while (cur && cur.nodeType === 1 && parts.length < 8) { |
| 177 | const tag = cur.tagName; |
| 178 | let part = tag.toLowerCase(); |
| 179 | if (cur.id && isUnique(`#${cssEscape(cur.id)}`)) { |
| 180 | parts.unshift(`#${cssEscape(cur.id)}`); |
| 181 | break; |
| 182 | } |
| 183 | let idx = 1; |
| 184 | for (let s = cur.previousElementSibling; s; s = s.previousElementSibling) { |
| 185 | if (s.tagName === tag) idx++; |
| 186 | } |
| 187 | const needsIndex = |
| 188 | cur.parentElement && |
| 189 | [...cur.parentElement.children].filter((c) => c.tagName === tag).length > 1; |
| 190 | if (needsIndex) part += `:nth-of-type(${idx})`; |
| 191 | parts.unshift(part); |
| 192 | if (isUnique(parts.join(" > "))) break; |
| 193 | cur = cur.parentElement; |
| 194 | } |
| 195 | return parts.join(" > "); |
| 196 | } |
| 197 | |
| 198 | /** @param {string} v */ |
| 199 | function xpathLiteral(v) { |
| 200 | if (!v.includes("'")) return `'${v}'`; |
| 201 | if (!v.includes('"')) return `"${v}"`; |
| 202 | return `concat('${v.split("'").join(`', "'", '`)}')`; |
| 203 | } |
| 204 | |
| 205 | /** @param {Element} el */ |
| 206 | function xpath(el) { |
| 207 | if (el.id) return `//*[@id=${xpathLiteral(el.id)}]`; |
| 208 | /** @type {string[]} */ |
| 209 | const parts = []; |
| 210 | for ( |
| 211 | let cur = /** @type {Element | null} */ (el); |
| 212 | cur && cur.nodeType === 1; |
| 213 | cur = cur.parentElement |
| 214 | ) { |
| 215 | let i = 1; |
| 216 | for (let s = cur.previousElementSibling; s; s = s.previousElementSibling) { |
| 217 | if (s.tagName === cur.tagName) i++; |
| 218 | } |
| 219 | parts.unshift(`${cur.tagName.toLowerCase()}[${i}]`); |
| 220 | } |
| 221 | return `/${parts.join("/")}`; |
| 222 | } |
| 223 | |
| 224 | /** @param {Element} el */ |
| 225 | function shortLabel(el) { |
| 226 | let s = el.tagName.toLowerCase(); |
| 227 | if (el.id) s += `#${el.id}`; |
| 228 | else if (el.classList && el.classList.length) s += `.${[...el.classList].slice(0, 2).join(".")}`; |
| 229 | const r = el.getBoundingClientRect(); |
| 230 | return `${s} ${Math.round(r.width)}×${Math.round(r.height)}`; |
| 231 | } |
| 232 | |
| 233 | /** @param {Element} el */ |
| 234 | function describe(el) { |
| 235 | const text = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 120); |
| 236 | // Geometry for the screenshot crop. Read here, at pick time, while the |
| 237 | // overlay is already gone and the page has not had a chance to move. |
| 238 | const r = el.getBoundingClientRect(); |
| 239 | return { |
| 240 | rect: { x: r.left, y: r.top, width: r.width, height: r.height }, |
| 241 | viewport: { w: window.innerWidth, h: window.innerHeight }, |
| 242 | // Where this viewport sits on the screen. Only the split view needs it: |
| 243 | // it says which half of the window the pick came from. |
| 244 | screenX: window.screenX, |
| 245 | css: cssSelector(el), |
| 246 | xpath: xpath(el), |
| 247 | id: el.id || null, |
| 248 | testid: |
| 249 | (el.getAttribute && (el.getAttribute("data-testid") || el.getAttribute("data-test"))) || |
| 250 | null, |
| 251 | tag: el.tagName.toLowerCase(), |
| 252 | text: text || null, |
| 253 | href: el.getAttribute && el.getAttribute("href"), |
| 254 | pageUrl: location.href, |
| 255 | }; |
| 256 | } |
| 257 | }); |
| 258 | } |