This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

7.9 kB 253 lines
1/* Browser MCP content-side helpers. 2 * 3 * Injected on demand into the extension's isolated content-script world of a 4 * page. Element refs are kept here (not as DOM attributes), so the page cannot 5 * observe them; they live as long as the document. 6 */ 7 8// eslint-disable-next-line no-var 9var __browserMcp = (() => { 10 const refs = new Map(); // ref -> WeakRef(element) 11 const elementRefs = new WeakMap(); // element -> ref 12 let nextRef = 1; 13 14 const INTERACTIVE = [ 15 "a[href]", 16 "button", 17 "input:not([type=hidden])", 18 "select", 19 "textarea", 20 "summary", 21 "[contenteditable=''], [contenteditable=true]", 22 "[role=button], [role=link], [role=checkbox], [role=radio], [role=switch]", 23 "[role=tab], [role=menuitem], [role=menuitemcheckbox], [role=option]", 24 "[role=combobox], [role=textbox], [role=searchbox], [role=slider]", 25 "[tabindex]:not([tabindex='-1'])", 26 "[onclick]", 27 ].join(", "); 28 const HEADINGS = "h1, h2, h3, h4, h5, h6"; 29 30 function refFor(el) { 31 let ref = elementRefs.get(el); 32 if (ref === undefined) { 33 ref = nextRef++; 34 elementRefs.set(el, ref); 35 refs.set(ref, new WeakRef(el)); 36 } 37 return ref; 38 } 39 40 function lookup(ref) { 41 const el = refs.get(ref)?.deref(); 42 if (!el || !el.isConnected) { 43 throw new Error(`ref ${ref} is gone; take a new snapshot`); 44 } 45 return el; 46 } 47 48 function clip(s, n) { 49 s = (s || "").replace(/\s+/g, " ").trim(); 50 return s.length > n ? s.slice(0, n - 1) + "…" : s; 51 } 52 53 function accessibleName(el) { 54 const labelledby = el.getAttribute("aria-labelledby"); 55 if (labelledby) { 56 const text = labelledby 57 .split(/\s+/) 58 .map((id) => document.getElementById(id)?.innerText || "") 59 .join(" "); 60 if (text.trim()) return text; 61 } 62 const direct = 63 el.getAttribute("aria-label") || 64 (el.labels && [...el.labels].map((l) => l.innerText).join(" ")); 65 if (direct && direct.trim()) return direct; 66 if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { 67 return el.placeholder || el.title || ""; 68 } 69 return ( 70 el.innerText || 71 el.getAttribute("title") || 72 el.getAttribute("alt") || 73 el.querySelector?.("img[alt]")?.alt || 74 "" 75 ); 76 } 77 78 function roleOf(el) { 79 const role = el.getAttribute("role"); 80 if (role) return role; 81 const tag = el.tagName.toLowerCase(); 82 if (tag === "a") return "link"; 83 if (tag === "input") return `input[${el.type}]`; 84 if (/^h[1-6]$/.test(tag)) return `heading${tag[1]}`; 85 if (el.isContentEditable) return "editable"; 86 return tag; 87 } 88 89 function visible(el) { 90 if ( 91 el.checkVisibility && 92 !el.checkVisibility({ visibilityProperty: true }) 93 ) { 94 return false; 95 } 96 const r = el.getBoundingClientRect(); 97 return r.width > 0 && r.height > 0; 98 } 99 100 // Walks the document and open shadow roots in document order. 101 function* walk(root) { 102 for (const el of root.querySelectorAll("*")) { 103 yield el; 104 if (el.shadowRoot) yield* walk(el.shadowRoot); 105 } 106 } 107 108 function describe(el) { 109 const parts = [`[${refFor(el)}]`, roleOf(el)]; 110 const name = clip(accessibleName(el), 80); 111 if (name) parts.push(JSON.stringify(name)); 112 const tag = el.tagName; 113 if (tag === "INPUT" && (el.type === "checkbox" || el.type === "radio")) { 114 parts.push(el.checked ? "checked" : "unchecked"); 115 } else if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") { 116 if (el.value) parts.push(`value=${JSON.stringify(clip(el.value, 80))}`); 117 } 118 if (tag === "A") parts.push(`-> ${clip(el.getAttribute("href"), 100)}`); 119 if (el.disabled) parts.push("disabled"); 120 return parts.join(" "); 121 } 122 123 function snapshot({ max_elements }) { 124 const lines = []; 125 let total = 0; 126 for (const el of walk(document)) { 127 if (!el.matches(INTERACTIVE) && !el.matches(HEADINGS)) continue; 128 if (!visible(el)) continue; 129 total++; 130 if (lines.length < max_elements) lines.push(describe(el)); 131 } 132 const header = [`title: ${document.title}`, `url: ${location.href}`]; 133 if (total > lines.length) { 134 header.push(`(showing ${lines.length} of ${total} elements)`); 135 } 136 return [...header, "", ...lines].join("\n"); 137 } 138 139 function text({ max_length }) { 140 const t = document.body ? document.body.innerText : ""; 141 return t.length > max_length 142 ? t.slice(0, max_length) + `\n… (${t.length - max_length} more chars)` 143 : t; 144 } 145 146 function mouse(el, type, x, y) { 147 const Ctor = type.startsWith("pointer") ? PointerEvent : MouseEvent; 148 el.dispatchEvent( 149 new Ctor(type, { 150 bubbles: true, 151 cancelable: true, 152 composed: true, 153 clientX: x, 154 clientY: y, 155 button: 0, 156 buttons: type.endsWith("down") ? 1 : 0, 157 view: window, 158 }), 159 ); 160 } 161 162 function click({ ref }) { 163 const el = lookup(ref); 164 el.scrollIntoView({ block: "center", inline: "center" }); 165 const r = el.getBoundingClientRect(); 166 const x = r.left + r.width / 2; 167 const y = r.top + r.height / 2; 168 mouse(el, "pointerdown", x, y); 169 mouse(el, "mousedown", x, y); 170 el.focus?.({ preventScroll: true }); 171 mouse(el, "pointerup", x, y); 172 mouse(el, "mouseup", x, y); 173 el.click(); 174 return `clicked ${describe(el)}`; 175 } 176 177 function fill({ ref, value }) { 178 const el = lookup(ref); 179 el.scrollIntoView({ block: "center" }); 180 el.focus(); 181 if (el.tagName === "SELECT") { 182 const option = [...el.options].find( 183 (o) => o.value === value || o.text.trim() === value, 184 ); 185 if (!option) throw new Error(`no option ${JSON.stringify(value)}`); 186 el.value = option.value; 187 } else if ( 188 el.tagName === "INPUT" && 189 (el.type === "checkbox" || el.type === "radio") 190 ) { 191 const want = value === "true" || value === "on" || value === "checked"; 192 if (el.checked !== want) el.click(); 193 return `set ${describe(el)}`; 194 } else { 195 // insertText goes through the editor like real typing (trusted input 196 // events, works for frameworks and contenteditable editors). 197 if (el.select) el.select(); 198 else document.getSelection().selectAllChildren(el); 199 const inserted = 200 value === "" 201 ? document.execCommand("delete") 202 : document.execCommand("insertText", false, value); 203 if (!inserted) { 204 if (el.isContentEditable) el.textContent = value; 205 else el.value = value; 206 el.dispatchEvent(new Event("input", { bubbles: true, composed: true })); 207 } 208 } 209 el.dispatchEvent(new Event("change", { bubbles: true })); 210 return `filled ${describe(el)}`; 211 } 212 213 const KEY_CODES = { 214 Enter: 13, 215 Escape: 27, 216 Tab: 9, 217 Backspace: 8, 218 Delete: 46, 219 ArrowUp: 38, 220 ArrowDown: 40, 221 ArrowLeft: 37, 222 ArrowRight: 39, 223 " ": 32, 224 }; 225 226 function pressKey({ key, ref }) { 227 const el = 228 ref == null ? document.activeElement || document.body : lookup(ref); 229 const init = { 230 key, 231 code: key.length === 1 ? `Key${key.toUpperCase()}` : key, 232 keyCode: 233 KEY_CODES[key] || 234 (key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0), 235 bubbles: true, 236 cancelable: true, 237 composed: true, 238 }; 239 const notCancelled = el.dispatchEvent(new KeyboardEvent("keydown", init)); 240 if (key.length === 1 || key === "Enter") { 241 el.dispatchEvent(new KeyboardEvent("keypress", init)); 242 } 243 el.dispatchEvent(new KeyboardEvent("keyup", init)); 244 // Synthetic key events have no default action; do the one that matters. 245 if (notCancelled && key === "Enter" && el.form && el.tagName === "INPUT") { 246 el.form.requestSubmit(); 247 return `pressed Enter on ${describe(el)}, submitted form`; 248 } 249 return `pressed ${key} on ${describe(el)}`; 250 } 251 252 return { snapshot, text, click, fill, pressKey }; 253})();