anvilsign in

collin/browser-terminal-extension

main / extension / picker.js
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
12function 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}