anvilsign in

collin/browser-terminal-extension

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 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}