| 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 | // Escape only reaches a keydown listener here if this document holds the |
| 48 | // keyboard focus. A page whose focus sits in a text field is fine, but one |
| 49 | // that has handed focus to an iframe is not: key events go to the frame and |
| 50 | // never surface in the top document. So the picker parks focus on a sink of |
| 51 | // its own for as long as it is up, and puts it back on the way out. |
| 52 | const sink = document.createElement("div"); |
| 53 | Object.assign(sink.style, { |
| 54 | position: "fixed", |
| 55 | top: "0", |
| 56 | left: "0", |
| 57 | width: "0", |
| 58 | height: "0", |
| 59 | opacity: "0", |
| 60 | outline: "none", |
| 61 | pointerEvents: "none", |
| 62 | }); |
| 63 | sink.tabIndex = -1; |
| 64 | |
| 65 | document.documentElement.append(box, label, sink); |
| 66 | |
| 67 | const wasFocused = /** @type {Element | null} */ (document.activeElement); |
| 68 | const grabFocus = () => { |
| 69 | if (document.activeElement !== sink) sink.focus({ preventScroll: true }); |
| 70 | }; |
| 71 | grabFocus(); |
| 72 | |
| 73 | /** Element under the cursor, so a click resolves what the box is drawn on. */ |
| 74 | let current = /** @type {Element | null} */ (null); |
| 75 | |
| 76 | const cleanup = () => { |
| 77 | window[PREV] = null; |
| 78 | box.remove(); |
| 79 | label.remove(); |
| 80 | sink.remove(); |
| 81 | // Removing the sink leaves the document itself focused; hand focus back |
| 82 | // to whatever had it, so a pick doesn't cost the page its caret. |
| 83 | if (wasFocused && wasFocused.isConnected && wasFocused instanceof HTMLElement) { |
| 84 | wasFocused.focus({ preventScroll: true }); |
| 85 | } |
| 86 | document.removeEventListener("mousemove", onMove, true); |
| 87 | document.removeEventListener("click", onClick, true); |
| 88 | window.removeEventListener("keydown", onKey, true); |
| 89 | window.removeEventListener("scroll", onScroll, true); |
| 90 | }; |
| 91 | window[PREV] = () => { |
| 92 | cleanup(); |
| 93 | resolve(null); |
| 94 | }; |
| 95 | |
| 96 | /** @param {Element} el */ |
| 97 | function paint(el) { |
| 98 | const r = el.getBoundingClientRect(); |
| 99 | Object.assign(box.style, { |
| 100 | left: `${r.left}px`, |
| 101 | top: `${r.top}px`, |
| 102 | width: `${r.width}px`, |
| 103 | height: `${r.height}px`, |
| 104 | }); |
| 105 | label.textContent = shortLabel(el); |
| 106 | const below = r.bottom + 22 < window.innerHeight; |
| 107 | label.style.left = `${Math.max(2, r.left)}px`; |
| 108 | label.style.top = below ? `${r.bottom + 3}px` : `${Math.max(2, r.top - 21)}px`; |
| 109 | } |
| 110 | |
| 111 | /** @param {MouseEvent} e */ |
| 112 | function onMove(e) { |
| 113 | // A click inside a cross-origin iframe never reaches this document, but |
| 114 | // it does move the focus there. Moving the mouse is the first thing that |
| 115 | // tells us we are still up, so take the focus back then. |
| 116 | grabFocus(); |
| 117 | const hit = /** @type {Element | null} */ (e.target); |
| 118 | const el = |
| 119 | hit && hit !== box && hit !== label |
| 120 | ? hit |
| 121 | : document.elementFromPoint(e.clientX ?? 0, e.clientY ?? 0); |
| 122 | if (!el || el.nodeType !== 1 || el === box || el === label) return; |
| 123 | current = el; |
| 124 | paint(el); |
| 125 | } |
| 126 | |
| 127 | // Scrolling moves the page under a box drawn in fixed coordinates. The |
| 128 | // element does not change, only where it is, so this repaints rather than |
| 129 | // re-hit-testing — a scroll event's target is the scroller, not what the |
| 130 | // cursor is over. |
| 131 | function onScroll() { |
| 132 | if (current) paint(current); |
| 133 | } |
| 134 | |
| 135 | /** @param {KeyboardEvent} e */ |
| 136 | function onKey(e) { |
| 137 | if (e.key === "Escape") { |
| 138 | e.preventDefault(); |
| 139 | e.stopPropagation(); |
| 140 | cleanup(); |
| 141 | resolve(null); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // Capture phase + preventDefault, so picking a link doesn't navigate. |
| 146 | /** @param {MouseEvent} e */ |
| 147 | function onClick(e) { |
| 148 | e.preventDefault(); |
| 149 | e.stopPropagation(); |
| 150 | const el = current || /** @type {Element | null} */ (e.target); |
| 151 | cleanup(); |
| 152 | resolve(el && el.nodeType === 1 ? describe(el) : null); |
| 153 | } |
| 154 | |
| 155 | document.addEventListener("mousemove", onMove, true); |
| 156 | document.addEventListener("click", onClick, true); |
| 157 | // On `window` rather than `document`: capture starts at the window, so this |
| 158 | // runs before any handler the page installed, even a capturing one. |
| 159 | window.addEventListener("keydown", onKey, true); |
| 160 | window.addEventListener("scroll", onScroll, true); |
| 161 | |
| 162 | // --- identifier construction --------------------------------------------- |
| 163 | |
| 164 | /** @param {string} v */ |
| 165 | function cssEscape(v) { |
| 166 | return window.CSS && CSS.escape ? CSS.escape(v) : String(v).replace(/[^\w-]/g, "\\$&"); |
| 167 | } |
| 168 | |
| 169 | /** @param {string} sel */ |
| 170 | function isUnique(sel) { |
| 171 | try { |
| 172 | return document.querySelectorAll(sel).length === 1; |
| 173 | } catch { |
| 174 | return false; |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // Attributes worth preferring: chosen by the app author, so they survive a |
| 179 | // redesign in a way that :nth-of-type chains do not. |
| 180 | const STABLE = [ |
| 181 | "data-testid", |
| 182 | "data-test-id", |
| 183 | "data-test", |
| 184 | "data-cy", |
| 185 | "data-qa", |
| 186 | "name", |
| 187 | "aria-label", |
| 188 | "role", |
| 189 | ]; |
| 190 | |
| 191 | /** @param {Element} el */ |
| 192 | function stableAttrSelector(el) { |
| 193 | for (const a of STABLE) { |
| 194 | const v = el.getAttribute && el.getAttribute(a); |
| 195 | if (v && v.length < 100) { |
| 196 | const sel = `${el.tagName.toLowerCase()}[${a}="${v.replace(/"/g, '\\"')}"]`; |
| 197 | if (isUnique(sel)) return sel; |
| 198 | } |
| 199 | } |
| 200 | return null; |
| 201 | } |
| 202 | |
| 203 | /** @param {Element} el */ |
| 204 | function cssSelector(el) { |
| 205 | if (el.id && isUnique(`#${cssEscape(el.id)}`)) return `#${cssEscape(el.id)}`; |
| 206 | const stable = stableAttrSelector(el); |
| 207 | if (stable) return stable; |
| 208 | |
| 209 | /** @type {string[]} */ |
| 210 | const parts = []; |
| 211 | let cur = /** @type {Element | null} */ (el); |
| 212 | while (cur && cur.nodeType === 1 && parts.length < 8) { |
| 213 | const tag = cur.tagName; |
| 214 | let part = tag.toLowerCase(); |
| 215 | if (cur.id && isUnique(`#${cssEscape(cur.id)}`)) { |
| 216 | parts.unshift(`#${cssEscape(cur.id)}`); |
| 217 | break; |
| 218 | } |
| 219 | let idx = 1; |
| 220 | for (let s = cur.previousElementSibling; s; s = s.previousElementSibling) { |
| 221 | if (s.tagName === tag) idx++; |
| 222 | } |
| 223 | const needsIndex = |
| 224 | cur.parentElement && |
| 225 | [...cur.parentElement.children].filter((c) => c.tagName === tag).length > 1; |
| 226 | if (needsIndex) part += `:nth-of-type(${idx})`; |
| 227 | parts.unshift(part); |
| 228 | if (isUnique(parts.join(" > "))) break; |
| 229 | cur = cur.parentElement; |
| 230 | } |
| 231 | return parts.join(" > "); |
| 232 | } |
| 233 | |
| 234 | /** @param {string} v */ |
| 235 | function xpathLiteral(v) { |
| 236 | if (!v.includes("'")) return `'${v}'`; |
| 237 | if (!v.includes('"')) return `"${v}"`; |
| 238 | return `concat('${v.split("'").join(`', "'", '`)}')`; |
| 239 | } |
| 240 | |
| 241 | /** @param {Element} el */ |
| 242 | function xpath(el) { |
| 243 | if (el.id) return `//*[@id=${xpathLiteral(el.id)}]`; |
| 244 | /** @type {string[]} */ |
| 245 | const parts = []; |
| 246 | for ( |
| 247 | let cur = /** @type {Element | null} */ (el); |
| 248 | cur && cur.nodeType === 1; |
| 249 | cur = cur.parentElement |
| 250 | ) { |
| 251 | let i = 1; |
| 252 | for (let s = cur.previousElementSibling; s; s = s.previousElementSibling) { |
| 253 | if (s.tagName === cur.tagName) i++; |
| 254 | } |
| 255 | parts.unshift(`${cur.tagName.toLowerCase()}[${i}]`); |
| 256 | } |
| 257 | return `/${parts.join("/")}`; |
| 258 | } |
| 259 | |
| 260 | /** @param {Element} el */ |
| 261 | function shortLabel(el) { |
| 262 | let s = el.tagName.toLowerCase(); |
| 263 | if (el.id) s += `#${el.id}`; |
| 264 | else if (el.classList && el.classList.length) s += `.${[...el.classList].slice(0, 2).join(".")}`; |
| 265 | const r = el.getBoundingClientRect(); |
| 266 | return `${s} ${Math.round(r.width)}×${Math.round(r.height)}`; |
| 267 | } |
| 268 | |
| 269 | /** @param {Element} el */ |
| 270 | function describe(el) { |
| 271 | const text = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 120); |
| 272 | // Geometry for the screenshot crop. Read here, at pick time, while the |
| 273 | // overlay is already gone and the page has not had a chance to move. |
| 274 | const r = el.getBoundingClientRect(); |
| 275 | return { |
| 276 | rect: { x: r.left, y: r.top, width: r.width, height: r.height }, |
| 277 | viewport: { w: window.innerWidth, h: window.innerHeight }, |
| 278 | // Where this viewport sits on the screen. Only the split view needs it: |
| 279 | // it says which half of the window the pick came from. |
| 280 | screenX: window.screenX, |
| 281 | css: cssSelector(el), |
| 282 | xpath: xpath(el), |
| 283 | id: el.id || null, |
| 284 | testid: |
| 285 | (el.getAttribute && (el.getAttribute("data-testid") || el.getAttribute("data-test"))) || |
| 286 | null, |
| 287 | tag: el.tagName.toLowerCase(), |
| 288 | text: text || null, |
| 289 | href: el.getAttribute && el.getAttribute("href"), |
| 290 | pageUrl: location.href, |
| 291 | }; |
| 292 | } |
| 293 | }); |
| 294 | } |