Orca の「要素のコンテクストピッカー」が便利だったので、ブックマークレットにした

2026-09-29 02:25 (83 minutes ago)
Orca の「要素のコンテクストピッカー」が便利だったので、ブックマークレットにした

最近 Orca ADE を使っている。Orca には内蔵ブラウザがあり、ページ上の要素をクリックして選ぶと、その要素の情報 (セレクタ、React のコンポーネント階層、テキスト、周辺の文字列、スタイル、HTML など) をまとめて AI エージェントに渡せる。

「この表のこのセルの表示を直して」と言葉で説明するより、要素をクリックしてコンテクストごと渡すほうが早くて正確だ。これがとても便利だった。

ただ、普段使いの Chrome や、Orca 以外で動かしているエージェントでも同じことをしたい。そこで、同じ動きをするブックマークレットを作った (実装は Claude Code に作ってもらった)。クリックした要素のコンテクストをクリップボードにコピーするので、あとは好きなエージェントのチャット欄に貼るだけでよい。

インストール

  1. ブラウザで新しいブックマークを作る (Chrome ならブックマークバーを右クリック →「ページを追加」)
  2. 名前は好きなもの (例: 🎯 Pick Element) にする
  3. URL 欄に、下のコードを全部貼り付ける
javascript:%22use%20strict%22%3B(()%3D%3E%7Bconst%20h%3D%22__elementContextPicker%22%3Bif(window%5Bh%5D)%7Bwindow%5Bh%5D.stop()%3Breturn%7Dconst%20q%3D%222147483647%22%2CK%3D%22%232563eb%22%2Cg%3Ddocument.createElement(%22div%22)%3Bg.style.cssText%3D%60all%3Ainitial%3Bposition%3Afixed%3Binset%3A0%3Bpointer-events%3Anone%3Bz-index%3A%24%7Bq%7D%3B%60%3Bconst%20L%3Dg.attachShadow(%7Bmode%3A%22open%22%7D)%2CC%3De%3D%3E%7Bconst%20n%3Ddocument.createElement(%22div%22)%3Breturn%20n.style.cssText%3De%2CL.appendChild(n)%2Cn%7D%2C%24%3DC(%60position%3Afixed%3Bdisplay%3Anone%3Bbox-sizing%3Aborder-box%3Bpointer-events%3Anone%3Bborder%3A2px%20solid%20%24%7BK%7D%3Bbackground%3Argba(37%2C99%2C235%2C.08)%3Bborder-radius%3A2px%3B%60)%2Cb%3DC(%22position%3Afixed%3Bdisplay%3Anone%3Bpointer-events%3Anone%3Bmax-width%3A420px%3Boverflow%3Ahidden%3Bwhite-space%3Anowrap%3Btext-overflow%3Aellipsis%3Bpadding%3A3px%207px%3Bborder-radius%3A4px%3Bbackground%3A%231f2937%3Bcolor%3A%23fff%3Bfont%3A12px%2F1.4%20ui-monospace%2CSFMono-Regular%2CMenlo%2Cmonospace%3B%22)%2Cx%3DC('position%3Afixed%3Bdisplay%3Anone%3Bpointer-events%3Anone%3Bpadding%3A5px%2012px%3Bborder-radius%3A999px%3Bbackground%3A%23fff%3Bcolor%3A%23111827%3Bbox-shadow%3A0%202px%2010px%20rgba(0%2C0%2C0%2C.25)%3Bfont%3A600%2013px%2F1.4%20-apple-system%2CBlinkMacSystemFont%2C%22Segoe%20UI%22%2Csans-serif%3B')%3Blet%20m%3Dnull%3Btry%7Bm%3Dnew%20CSSStyleSheet%2Cm.replaceSync(%22*%7Bcursor%3Acrosshair%20!important%7Diframe%2Cframe%2Cembed%2Cobject%7Bpointer-events%3Anone%20!important%7D%22)%2Cdocument.adoptedStyleSheets%3D%5B...document.adoptedStyleSheets%2Cm%5D%7Dcatch%7Bm%3Dnull%7Ddocument.documentElement.appendChild(g)%3Blet%20d%3Dnull%2Cf%3D!0%2CT%3D!1%2CM%3D0%3Bconst%20v%3De%3D%3E(e%7C%7C%22%22).replace(%2F%5Cs%2B%2Fg%2C%22%20%22).trim()%2CS%3D(e%2Cn)%3D%3Ee.length%3En%3Fe.slice(0%2Cn)%2B%22%E2%80%A6%22%3Ae%2Cp%3De%3D%3Ee.tagName.toLowerCase()%2Ck%3De%3D%3Ewindow.CSS%26%26CSS.escape%3FCSS.escape(e)%3Ae.replace(%2F(%5B%5E%5Cw-%5D)%2Fg%2C%22%5C%5C%241%22)%2CN%3D(e%2Cn)%3D%3E%5B...e.classList%5D.filter(t%3D%3E!%2F%5B%3A%5B%5C%5D%2F()!%40%25%5D%2F.test(t)).slice(0%2Cn).map(t%3D%3E%22.%22%2Bk(t)).join(%22%22)%2Cj%3De%3D%3Ee.parentElement%7C%7C(e.parentNode%20instanceof%20ShadowRoot%3Fe.parentNode.host%3Anull)%2CU%3De%3D%3E%7Bconst%20n%3De.parentNode%3Bif(!n)return%200%3Bconst%20t%3D%5B...n.children%5D.filter(o%3D%3Eo.tagName%3D%3D%3De.tagName)%3Breturn%20t.length%3E1%3Ft.indexOf(e)%2B1%3A0%7D%2CR%3D(e%2Cn)%3D%3E%7Btry%7Breturn%20n.querySelectorAll(e).length%3D%3D%3D1%7Dcatch%7Breturn!1%7D%7D%2CV%3De%3D%3E%7Bconst%20n%3De.getRootNode()%2Ct%3D%5B%5D%3Bfor(let%20o%3De%3Bo%26%26o.nodeType%3D%3D%3D1%3Bo%3Do.parentElement)%7Bif(o.id%26%26R(%22%23%22%2Bk(o.id)%2Cn))%7Bt.unshift(%22%23%22%2Bk(o.id))%3Bbreak%7Dif(o%3D%3D%3Ddocument.body%7C%7Co%3D%3D%3Ddocument.documentElement)%7Bt.unshift(p(o))%3Bbreak%7Dconst%20r%3DU(o)%3Bif(t.unshift(p(o)%2BN(o%2C2)%2B(r%3F%60%3Anth-of-type(%24%7Br%7D)%60%3A%22%22))%2CR(t.join(%22%20%3E%20%22)%2Cn))break%7Dreturn%20t.join(%22%20%3E%20%22)%7D%2CY%3De%3D%3E%7Bconst%20n%3D%5B%5D%3Bfor(let%20t%3De%3Bt%3B)%7Bn.unshift(V(t))%3Bconst%20o%3Dt.getRootNode()%3Bt%3Do%20instanceof%20ShadowRoot%3Fo.host%3Anull%7Dreturn%20n.join(%22%20%3E%3E%3E%20%22)%7D%2CZ%3D%7Ba%3Ae%3D%3Ee.hasAttribute(%22href%22)%3F%22link%22%3A%22%22%2Carticle%3A%22article%22%2Caside%3A%22complementary%22%2Cbutton%3A%22button%22%2Cdialog%3A%22dialog%22%2Cfooter%3A%22contentinfo%22%2Cform%3A%22form%22%2Ch1%3A%22heading%22%2Ch2%3A%22heading%22%2Ch3%3A%22heading%22%2Ch4%3A%22heading%22%2Ch5%3A%22heading%22%2Ch6%3A%22heading%22%2Cheader%3A%22banner%22%2Chr%3A%22separator%22%2Cimg%3Ae%3D%3Ee.getAttribute(%22alt%22)%3D%3D%3D%22%22%3F%22presentation%22%3A%22img%22%2Cinput%3Ae%3D%3E%7Bconst%20n%3D(e.getAttribute(%22type%22)%7C%7C%22text%22).toLowerCase()%3Breturn%7Bcheckbox%3A%22checkbox%22%2Cradio%3A%22radio%22%2Crange%3A%22slider%22%2Cnumber%3A%22spinbutton%22%2Csearch%3A%22searchbox%22%2Cbutton%3A%22button%22%2Csubmit%3A%22button%22%2Creset%3A%22button%22%2Cimage%3A%22button%22%7D%5Bn%5D%7C%7C%22textbox%22%7D%2Cli%3A%22listitem%22%2Cmain%3A%22main%22%2Cnav%3A%22navigation%22%2Col%3A%22list%22%2Coption%3A%22option%22%2Cprogress%3A%22progressbar%22%2Csection%3A%22region%22%2Cselect%3A%22combobox%22%2Ctable%3A%22table%22%2Ctbody%3A%22rowgroup%22%2Cthead%3A%22rowgroup%22%2Ctfoot%3A%22rowgroup%22%2Ctd%3A%22cell%22%2Ctextarea%3A%22textbox%22%2Cth%3A%22columnheader%22%2Ctr%3A%22row%22%2Cul%3A%22list%22%7D%2CG%3De%3D%3E%7Bconst%20n%3De.getAttribute(%22role%22)%3Bif(n)return%20n%3Bconst%20t%3DZ%5Bp(e)%5D%3Breturn(typeof%20t%3D%3D%22function%22%3Ft(e)%3At)%7C%7Cp(e)%7D%2CA%3De%3D%3E%7Bif(!e)return%22%22%3Bif(typeof%20e%3D%3D%22function%22)return%20e.displayName%7C%7Ce.name%7C%7C%22%22%3Bif(typeof%20e%3D%3D%22object%22)%7Bif(e.displayName)return%20e.displayName%3Bif(e.render)return%20e.render.displayName%7C%7Ce.render.name%7C%7C%22%22%3Bif(e.type)return%20A(e.type)%7Dreturn%22%22%7D%2CJ%3De%3D%3E%7Bfor(let%20n%3De%3Bn%3Bn%3Dn.parentElement)%7Bconst%20t%3DObject.keys(n).find(r%3D%3Er.startsWith(%22__reactFiber%24%22)%7C%7Cr.startsWith(%22__reactInternalInstance%24%22))%3Bif(!t)continue%3Bconst%20o%3D%5B%5D%3Bfor(let%20r%3Dn%5Bt%5D%3Br%3Br%3Dr.return)%7Bconst%20a%3DA(r.type)%3Ba%26%26typeof%20r.type!%3D%22string%22%26%26o.unshift(a)%7Dreturn%20o%7Dreturn%5B%5D%7D%2CQ%3De%3D%3E%7Bfor(let%20n%3De%3Bn%3Bn%3Dn.parentElement)%7Blet%20t%3Dn.__vueParentComponent%3Bif(!t)continue%3Bconst%20o%3D%5B%5D%3Bfor(%3Bt%3Bt%3Dt.parent)%7Bconst%20r%3Dt.type%7C%7C%7B%7D%2Ca%3Dr.name%7C%7Cr.__name%7C%7C(r.__file%3Fr.__file.split(%22%2F%22).pop()%3A%22%22)%3Ba%26%26o.unshift(a)%7Dreturn%20o%7Dreturn%5B%5D%7D%2CX%3De%3D%3E%7Bfor(let%20n%3De%3Bn%3Bn%3Dn.parentElement)%7Bconst%20t%3Dn.__svelte_meta%26%26n.__svelte_meta.loc%3Bif(t)return%60%24%7Bt.file%7D%3A%24%7Bt.line%7D%3A%24%7Bt.column%7D%60%7Dreturn%22%22%7D%2Cee%3D(e%2Cn)%3D%3E%7Bconst%20t%3Dv(e.innerText)%2Co%3D%5B%5D%2Cr%3Dnew%20Set(%5Bt%5D)%2Ca%3Di%3D%3E%7Bconst%20s%3DS(v(i.innerText)%2C80)%3Bs%26%26!r.has(s)%26%26(r.add(s)%2Co.push(s))%7D%3Bfor(let%20i%3De%3Bi%26%26i!%3D%3Ddocument.body%26%26o.length%3Cn%3Bi%3Di.parentElement)%7Blet%20s%3Di.previousElementSibling%2Cc%3Di.nextElementSibling%3Bfor(%3B(s%7C%7Cc)%26%26o.length%3Cn%3B)s%26%26(a(s)%2Cs%3Ds.previousElementSibling)%2Cc%26%26o.length%3Cn%26%26(a(c)%2Cc%3Dc.nextElementSibling)%7Dreturn%20o%7D%2Cte%3De%3D%3E%7Bconst%20n%3DgetComputedStyle(e)%2Ct%3D%5B%60display%3A%20%24%7Bn.display%7D%60%2C%60font-size%3A%20%24%7Bn.fontSize%7D%60%2C%60color%3A%20%24%7Bn.color%7D%60%5D%3Breturn%20n.position!%3D%3D%22static%22%26%26t.push(%60position%3A%20%24%7Bn.position%7D%60)%2Cn.backgroundColor!%3D%3D%22rgba(0%2C%200%2C%200%2C%200)%22%26%26n.backgroundColor!%3D%3D%22transparent%22%26%26t.push(%60background-color%3A%20%24%7Bn.backgroundColor%7D%60)%2Ct%7D%2Cne%3De%3D%3E%7Bconst%20n%3D%5B%5D%3Bfor(let%20t%3Dj(e)%3Bt%26%26t!%3D%3Ddocument.documentElement%3Bt%3Dj(t))n.push(t)%3Breturn%20n%7D%2CO%3De%3D%3E%7Bconst%20n%3De.getBoundingClientRect()%2Ct%3D%5B%60Attached%20browser%20context%20from%20%24%7Blocation.href%7D%60%2C%22%22%2C%22Selected%20element%3A%22%2Cp(e)%5D%3Bt.push(%60Role%3A%20%24%7BG(e)%7D%60)%2Ct.push(%60Selector%3A%20%24%7BY(e)%7D%60)%3Bconst%20o%3DJ(e).slice(-6)%3Bo.length%26%26t.push(%60React%3A%20%24%7Bo.map(l%3D%3E%60%3C%24%7Bl%7D%3E%60).join(%22%20%22)%7D%60)%3Bconst%20r%3DQ(e).slice(-6)%3Br.length%26%26t.push(%60Vue%3A%20%24%7Br.map(l%3D%3E%60%3C%24%7Bl%7D%3E%60).join(%22%20%22)%7D%60)%3Bconst%20a%3DX(e)%3Ba%26%26t.push(%60Svelte%3A%20%24%7Ba%7D%60)%2Ct.push(%60Dimensions%3A%20%24%7BMath.round(n.width)%7Dx%24%7BMath.round(n.height)%7D%60)%3Bconst%20i%3DS(v(e.innerText)%2C1e3)%3Bi%26%26t.push(%22%22%2C%22Text%20content%3A%22%2Ci)%3Bconst%20s%3Dee(e%2C5)%3Bs.length%26%26t.push(%22%22%2C%22Nearby%20context%3A%22%2C...s.map(l%3D%3E%60-%20%24%7Bl%7D%60))%2Ct.push(%22%22%2C%22Computed%20styles%3A%22%2C...te(e).map(l%3D%3E%60%20%20%24%7Bl%7D%60))%2Ct.push(%22%22%2C%22HTML%3A%22%2CS(e.outerHTML%2C2e3))%3Bconst%20c%3Dne(e)%3Breturn%20t.push(%22%22%2C%60Ancestor%20path%3A%20%24%7Bc.map(p).join(%22%20%3E%20%22)%7D%60)%2Ct.push(%60Full%20DOM%20path%3A%20%24%7B%5B...c%5D.reverse().concat(e).map(l%3D%3Ep(l)%2BN(l%2C2)).join(%22%20%3E%20%22)%7D%60)%2Ct.join(%60%0A%60)%7D%2CI%3D(e%2Cn%2Ct)%3D%3E%7Be.style.left%3D%220px%22%2Ce.style.top%3D%220px%22%3Bconst%20o%3De.offsetWidth%2Cr%3De.offsetHeight%2Ca%3Ddocument.documentElement.clientWidth%2Ci%3Ddocument.documentElement.clientHeight%3Blet%20s%3Dn.bottom%2Bt%3Bs%2Br%3Ei%26%26(s%3Dn.top-t-r)%2Cs%3DMath.max(4%2CMath.min(s%2Ci-r-4))%3Bconst%20c%3DMath.max(4%2CMath.min(n.left%2Ca-o-4))%3Be.style.left%3Dc%2B%22px%22%2Ce.style.top%3Ds%2B%22px%22%7D%2CP%3D()%3D%3E%7Bif(!d%7C%7C!d.isConnected)%7B%24.style.display%3D%22none%22%2Cb.style.display%3D%22none%22%2Cx.style.display%3D%22none%22%3Breturn%7Dconst%20e%3Dd.getBoundingClientRect()%3Bif(Object.assign(%24.style%2C%7Bdisplay%3A%22block%22%2Cleft%3Ae.left%2B%22px%22%2Ctop%3Ae.top%2B%22px%22%2Cwidth%3Ae.width%2B%22px%22%2Cheight%3Ae.height%2B%22px%22%7D)%2C!f)%7Bb.style.display%3D%22none%22%2Cx.style.display!%3D%3D%22none%22%26%26I(x%2Ce%2C8)%3Breturn%7Dconst%20n%3DS(v(d.innerText)%2C30)%3Bb.textContent%3D%60%24%7Bp(d)%7D%24%7Bn%3F%60%20%22%24%7Bn%7D%22%60%3A%22%22%7D%20%24%7BMath.round(e.width)%7Dx%24%7BMath.round(e.height)%7D%60%2Cb.style.display%3D%22block%22%2CI(b%2Ce%2C6)%7D%3Blet%20y%3D0%3Bconst%20w%3D()%3D%3E%7By%7C%7C(y%3DrequestAnimationFrame(()%3D%3E%7By%3D0%2CP()%7D))%7D%2Coe%3Dasync%20e%3D%3E%7Btry%7Breturn%20await%20navigator.clipboard.writeText(e)%2C!0%7Dcatch%7Bconst%20t%3Ddocument.createElement(%22textarea%22)%3Bt.value%3De%2Ct.style.cssText%3D%22position%3Afixed%3Btop%3A0%3Bleft%3A0%3Bopacity%3A0%3B%22%2CL.appendChild(t)%2Ct.select()%3Blet%20o%3D!1%3Btry%7Bo%3Ddocument.execCommand(%22copy%22)%7Dcatch%7Bo%3D!1%7Dreturn%20t.remove()%2Co%7D%7D%2CE%3De%3D%3E%7Bf%26%26(e.preventDefault()%2Ce.stopPropagation()%2Ce.stopImmediatePropagation())%7D%2Cz%3De%3D%3E%7Bif(!f)return%3Bconst%20n%3De.composedPath%3Fe.composedPath()%5B0%5D%3Ae.target%2Ct%3Dn%26%26n.nodeType%3D%3D%3D1%3Fn%3Ae.target%3B!t%7C%7Ct%3D%3D%3Dg%7C%7Ct%3D%3D%3Ddocument.documentElement%7C%7Ct!%3D%3Dd%26%26(d%3Dt%2Cw())%7D%2CF%3Dasync%20e%3D%3E%7Bif(!f)return%3BE(e)%3Bconst%20n%3Dd%7C%7Ce.target%3Bif(!n%7C%7Cn.nodeType!%3D%3D1)return%3Bd%3Dn%2Cf%3D!1%2CH()%3Bconst%20t%3Dawait%20oe(O(n))%3BT%7C%7C(%24.style.borderColor%3Dt%3F%22%2316a34a%22%3A%22%23dc2626%22%2Cx.textContent%3Dt%3F%22%E2%9C%85%20Copied%22%3A%22%E2%9A%A0%EF%B8%8F%20Copy%20failed%22%2Cx.style.display%3D%22block%22%2CP()%2CM%3DsetTimeout(_%2C3e3))%7D%2CB%3De%3D%3E%7Be.key%3D%3D%3D%22Escape%22%26%26(E(e)%2C_())%7D%2CD%3D%5B%22mousedown%22%2C%22mouseup%22%2C%22pointerdown%22%2C%22pointerup%22%2C%22dblclick%22%2C%22auxclick%22%2C%22contextmenu%22%5D%2Cu%3D%7Bcapture%3A!0%2Cpassive%3A!1%7D%3Bfunction%20H()%7Bwindow.removeEventListener(%22mousemove%22%2Cz%2Cu)%2Cwindow.removeEventListener(%22click%22%2CF%2Cu)%2Cwindow.removeEventListener(%22keydown%22%2CB%2Cu)%2CD.forEach(e%3D%3Ewindow.removeEventListener(e%2CE%2Cu))%2Cm%26%26(document.adoptedStyleSheets%3Ddocument.adoptedStyleSheets.filter(e%3D%3Ee!%3D%3Dm)%2Cm%3Dnull)%7Dfunction%20_()%7BT%3D!0%2Cf%3D!1%2CclearTimeout(M)%2CH()%2Cwindow.removeEventListener(%22scroll%22%2Cw%2C!0)%2Cwindow.removeEventListener(%22resize%22%2Cw)%2Cy%26%26cancelAnimationFrame(y)%2Cg.remove()%2Cwindow%5Bh%5D%3D%3D%3DW%26%26delete%20window%5Bh%5D%7Dwindow.addEventListener(%22mousemove%22%2Cz%2Cu)%2Cwindow.addEventListener(%22click%22%2CF%2Cu)%2Cwindow.addEventListener(%22keydown%22%2CB%2Cu)%2CD.forEach(e%3D%3Ewindow.addEventListener(e%2CE%2Cu))%2Cwindow.addEventListener(%22scroll%22%2Cw%2C!0)%2Cwindow.addEventListener(%22resize%22%2Cw)%3Bconst%20W%3D%7Bstop%3A_%2CbuildContext%3AO%7D%3Bwindow%5Bh%5D%3DW%7D)()%3B

アドレスバーに直接貼ると、ブラウザが先頭の javascript: を消すことがある。貼るのはブックマークの URL 欄にする。

使い方

  1. 調べたいページでブックマークを押す
  2. マウスを動かすと、ホバー中の要素に青い枠が付き、下に td "Pending 2" 70x33 のようなツールチップが出る
  3. クリックすると、その要素のコンテクストがクリップボードにコピーされる。枠が緑になり、要素の下に「✅ Copied」が 3 秒間出たあと、自動で終了する
  4. やめたいときは Esc を押すか、もう一度ブックマークを押す

選択中はページ側のクリック動作 (リンクの遷移や onClick) は発火しない。

コピーされる内容

Orca の出力形式に合わせている。たとえば表のセルを選ぶとこうなる。

Attached browser context from http://localhost:3000/orders

Selected element:
td
Role: cell
Selector: td.px-2.py-1\.5:nth-of-type(5)
React: <OrdersPage> <OrderList> <TableCard> <DataTable>
Dimensions: 70x33

Text content:
Pending 2

Nearby context:
- Paid Express
- 3 items
- Tokyo
- 2026-09-25 16:16
- #1

Computed styles:
  display: table-cell
  font-size: 14px
  color: rgb(30, 30, 60)

HTML:
<td class="px-2 py-1.5 align-top text-sm">Pending 2</td>

Ancestor path: tr > tbody > table > div > main > body
Full DOM path: body.flex.min-h-screen > main.flex-1 > div.w-full.overflow-auto > table.min-w-full.border-collapse > tbody > tr.cursor-pointer.border-b > td.px-2.py-1\.5
  • Selector は、要素から親へ tag.class1.class2:nth-of-type(n) を積み上げていき、querySelectorAll で 1 件に絞れた時点で止める。一意な id があればそこで止める。Tailwind の hover:bg-... のような記号入りのクラスは、読みにくいので使わない
  • React は DOM 要素に付いている __reactFiber$... からたどって、コンポーネント名を外側から順に並べる。Vue 3 (__vueParentComponent) のコンポーネント階層や、Svelte の開発ビルドのソース位置 (__svelte_meta) も取れれば出す
  • Nearby context は兄弟要素のテキストを、近い順に前後交互に最大 5 件拾う
  • Role は role 属性があればそれを、無ければ ARIA の暗黙ロール (td なら cell) を出す。ここは Orca の出力 (td なら td) とは違う

作りのポイント

枠を出してもレイアウトを崩さない

選んだ要素に outline や border を付けると、ページによってはレイアウトが動いたり、ページ側の CSS と干渉したりする。そこで対象要素には一切触らない。

<html> の直下に position: fixed; pointer-events: none の要素を 1 つ置き、その Shadow DOM の中に枠・ツールチップ・バッジを描く。位置は getBoundingClientRect() から計算し、スクロールやリサイズのたびに計算し直す。pointer-events: none なので、マウスイベントは下のページ要素にそのまま届く。

ページのクリック動作を止める

window にキャプチャフェーズで click、mousedown、pointerdown などを登録し、preventDefault() と stopImmediatePropagation() で止める。React はルート要素でイベントを受けるので、それより先に止まる。

CSP で止められないようにする

<style> 要素を挿入したり innerHTML を使ったりすると、厳しい CSP のサイトでブロックされる。そこでスタイルは el.style.cssText (CSSOM) で当て、カーソルを十字にする CSS は Constructable Stylesheet (document.adoptedStyleSheets) で入れている。

クリップボード

navigator.clipboard.writeText は、localhost 以外の http ページ (セキュアでないコンテキスト) では使えない。失敗したら textarea と document.execCommand('copy') の古い方法にフォールバックする。

Shadow DOM と iframe

  • Web Components の中の要素を選んだときは、shadow root ごとにセレクタを作って >>> でつなぐ (例: x-card >>> span.label:nth-of-type(2))。CSS としては無効な表記だが、どこから shadow root に入るかが分かる
  • iframe の中のイベントは親の window に届かないので、選択中は iframe { pointer-events: none } にして、iframe の親要素を選ぶようにした。iframe の中の要素そのものは選べない

制限

  • iframe の中の要素は選べない
  • CSP がとても厳しいサイトでは、ブラウザがブックマークレット (javascript: URL) の実行自体を止めることがある
  • React のコンポーネント名がちゃんと出るのは開発ビルドだけ。本番ビルドでは minify されて 1〜2 文字の名前になる。Svelte のソース位置も開発ビルドでしか出ない

テスト

Playwright (headless Chromium) で 8 件のテストを書いた。

  • ホバー前後で、ページ内の全要素の位置とサイズが変わらないこと (レイアウトを崩さない)
  • コピーした内容の各項目。出力された Selector で検索すると、対象の要素 1 件だけにマッチすること
  • ページ側の click ハンドラが発火しないこと
  • Clipboard API が使えないときのフォールバック
  • Shadow DOM、iframe、コピー後のスクロール、連続実行

テストが本当にバグを検出できるかも確かめた。フォールバック処理をわざと壊した版や修正前のコードで実行し、該当するテストがちゃんと fail することを見ている。ヘッドレスのクリップボードはブラウザコンテキスト間で共有されることがあり、前のテストの値を読んで通ってしまう穴があった。この穴はこの確認で見つけた。

ソースコード

ブックマークレットは、これを esbuild で minify して encodeURIComponent し、先頭に javascript: を付けたもの。

npx esbuild element-context-picker.js --minify --target=es2019 --charset=utf8
(() => {
  const KEY = '__elementContextPicker';
  if (window[KEY]) {
    window[KEY].stop();
    return;
  }

  const Z = '2147483647';
  const ACCENT = '#2563eb';

  // Constructable stylesheets and CSSOM writes are not blocked by a page's
  // style-src CSP, unlike <style> elements or innerHTML.
  const host = document.createElement('div');
  host.style.cssText = `all:initial;position:fixed;inset:0;pointer-events:none;z-index:${Z};`;
  const root = host.attachShadow({ mode: 'open' });

  const make = (css) => {
    const el = document.createElement('div');
    el.style.cssText = css;
    root.appendChild(el);
    return el;
  };
  const box = make(
    `position:fixed;display:none;box-sizing:border-box;pointer-events:none;` +
      `border:2px solid ${ACCENT};background:rgba(37,99,235,.08);border-radius:2px;`,
  );
  const tip = make(
    `position:fixed;display:none;pointer-events:none;max-width:420px;overflow:hidden;` +
      `white-space:nowrap;text-overflow:ellipsis;padding:3px 7px;border-radius:4px;` +
      `background:#1f2937;color:#fff;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;`,
  );
  const badge = make(
    `position:fixed;display:none;pointer-events:none;padding:5px 12px;border-radius:999px;` +
      `background:#fff;color:#111827;box-shadow:0 2px 10px rgba(0,0,0,.25);` +
      `font:600 13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;`,
  );

  let cursorSheet = null;
  try {
    cursorSheet = new CSSStyleSheet();
    // Events inside frames never reach this window, so frames are made inert
    // and picking lands on their container instead.
    cursorSheet.replaceSync(
      '*{cursor:crosshair !important}iframe,frame,embed,object{pointer-events:none !important}',
    );
    document.adoptedStyleSheets = [...document.adoptedStyleSheets, cursorSheet];
  } catch (_) {
    cursorSheet = null;
  }

  document.documentElement.appendChild(host);

  let current = null;
  let picking = true;
  let stopped = false;
  let exitTimer = 0;

  const clean = (s) => (s || '').replace(/\s+/g, ' ').trim();
  const cut = (s, n) => (s.length > n ? s.slice(0, n) + '…' : s);
  const tag = (el) => el.tagName.toLowerCase();
  const esc = (s) => (window.CSS && CSS.escape ? CSS.escape(s) : s.replace(/([^\w-])/g, '\\$1'));

  const classesOf = (el, max) =>
    [...el.classList]
      .filter((c) => !/[:[\]/()!@%]/.test(c))
      .slice(0, max)
      .map((c) => '.' + esc(c))
      .join('');

  const parentOf = (node) =>
    node.parentElement || (node.parentNode instanceof ShadowRoot ? node.parentNode.host : null);

  const nthOfType = (el) => {
    const p = el.parentNode;
    if (!p) return 0;
    const same = [...p.children].filter((c) => c.tagName === el.tagName);
    return same.length > 1 ? same.indexOf(el) + 1 : 0;
  };

  const isUnique = (sel, root) => {
    try {
      return root.querySelectorAll(sel).length === 1;
    } catch (_) {
      return false;
    }
  };

  const selectorInRoot = (el) => {
    const root = el.getRootNode();
    const parts = [];
    for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
      if (node.id && isUnique('#' + esc(node.id), root)) {
        parts.unshift('#' + esc(node.id));
        break;
      }
      if (node === document.body || node === document.documentElement) {
        parts.unshift(tag(node));
        break;
      }
      const n = nthOfType(node);
      parts.unshift(tag(node) + classesOf(node, 2) + (n ? `:nth-of-type(${n})` : ''));
      if (isUnique(parts.join(' > '), root)) break;
    }
    return parts.join(' > ');
  };

  // `>>>` is not valid CSS; it marks where a selector continues inside a shadow root.
  const buildSelector = (el) => {
    const segments = [];
    for (let node = el; node; ) {
      segments.unshift(selectorInRoot(node));
      const root = node.getRootNode();
      node = root instanceof ShadowRoot ? root.host : null;
    }
    return segments.join(' >>> ');
  };

  const IMPLICIT_ROLES = {
    a: (el) => (el.hasAttribute('href') ? 'link' : ''),
    article: 'article', aside: 'complementary', button: 'button', dialog: 'dialog',
    footer: 'contentinfo', form: 'form', h1: 'heading', h2: 'heading', h3: 'heading',
    h4: 'heading', h5: 'heading', h6: 'heading', header: 'banner', hr: 'separator',
    img: (el) => (el.getAttribute('alt') === '' ? 'presentation' : 'img'),
    input: (el) => {
      const t = (el.getAttribute('type') || 'text').toLowerCase();
      return (
        { checkbox: 'checkbox', radio: 'radio', range: 'slider', number: 'spinbutton',
          search: 'searchbox', button: 'button', submit: 'button', reset: 'button',
          image: 'button' }[t] || 'textbox'
      );
    },
    li: 'listitem', main: 'main', nav: 'navigation', ol: 'list', option: 'option',
    progress: 'progressbar', section: 'region', select: 'combobox', table: 'table',
    tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup', td: 'cell',
    textarea: 'textbox', th: 'columnheader', tr: 'row', ul: 'list',
  };
  const roleOf = (el) => {
    const explicit = el.getAttribute('role');
    if (explicit) return explicit;
    const r = IMPLICIT_ROLES[tag(el)];
    return (typeof r === 'function' ? r(el) : r) || tag(el);
  };

  const componentName = (type) => {
    if (!type) return '';
    if (typeof type === 'function') return type.displayName || type.name || '';
    if (typeof type === 'object') {
      if (type.displayName) return type.displayName;
      if (type.render) return type.render.displayName || type.render.name || '';
      if (type.type) return componentName(type.type);
    }
    return '';
  };

  const reactComponents = (el) => {
    for (let node = el; node; node = node.parentElement) {
      const key = Object.keys(node).find(
        (k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'),
      );
      if (!key) continue;
      const names = [];
      for (let f = node[key]; f; f = f.return) {
        const name = componentName(f.type);
        if (name && typeof f.type !== 'string') names.unshift(name);
      }
      return names;
    }
    return [];
  };

  const vueComponents = (el) => {
    for (let node = el; node; node = node.parentElement) {
      let inst = node.__vueParentComponent;
      if (!inst) continue;
      const names = [];
      for (; inst; inst = inst.parent) {
        const t = inst.type || {};
        const name = t.name || t.__name || (t.__file ? t.__file.split('/').pop() : '');
        if (name) names.unshift(name);
      }
      return names;
    }
    return [];
  };

  // Svelte attaches __svelte_meta only in dev builds.
  const svelteLocation = (el) => {
    for (let node = el; node; node = node.parentElement) {
      const loc = node.__svelte_meta && node.__svelte_meta.loc;
      if (loc) return `${loc.file}:${loc.line}:${loc.column}`;
    }
    return '';
  };

  const nearbyContext = (el, limit) => {
    const own = clean(el.innerText);
    const out = [];
    const seen = new Set([own]);
    const push = (node) => {
      const t = cut(clean(node.innerText), 80);
      if (t && !seen.has(t)) {
        seen.add(t);
        out.push(t);
      }
    };
    for (let node = el; node && node !== document.body && out.length < limit; node = node.parentElement) {
      let prev = node.previousElementSibling;
      let next = node.nextElementSibling;
      while ((prev || next) && out.length < limit) {
        if (prev) {
          push(prev);
          prev = prev.previousElementSibling;
        }
        if (next && out.length < limit) {
          push(next);
          next = next.nextElementSibling;
        }
      }
    }
    return out;
  };

  const computedStyles = (el) => {
    const cs = getComputedStyle(el);
    const lines = [`display: ${cs.display}`, `font-size: ${cs.fontSize}`, `color: ${cs.color}`];
    if (cs.position !== 'static') lines.push(`position: ${cs.position}`);
    if (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
      lines.push(`background-color: ${cs.backgroundColor}`);
    }
    return lines;
  };

  const ancestors = (el) => {
    const list = [];
    for (let node = parentOf(el); node && node !== document.documentElement; node = parentOf(node)) {
      list.push(node);
    }
    return list;
  };

  const buildContext = (el) => {
    const rect = el.getBoundingClientRect();
    const lines = [`Attached browser context from ${location.href}`, '', 'Selected element:', tag(el)];
    lines.push(`Role: ${roleOf(el)}`);
    lines.push(`Selector: ${buildSelector(el)}`);
    const react = reactComponents(el).slice(-6);
    if (react.length) lines.push(`React: ${react.map((n) => `<${n}>`).join(' ')}`);
    const vue = vueComponents(el).slice(-6);
    if (vue.length) lines.push(`Vue: ${vue.map((n) => `<${n}>`).join(' ')}`);
    const svelte = svelteLocation(el);
    if (svelte) lines.push(`Svelte: ${svelte}`);
    lines.push(`Dimensions: ${Math.round(rect.width)}x${Math.round(rect.height)}`);

    const text = cut(clean(el.innerText), 1000);
    if (text) lines.push('', 'Text content:', text);

    const nearby = nearbyContext(el, 5);
    if (nearby.length) lines.push('', 'Nearby context:', ...nearby.map((t) => `- ${t}`));

    lines.push('', 'Computed styles:', ...computedStyles(el).map((l) => `  ${l}`));
    lines.push('', 'HTML:', cut(el.outerHTML, 2000));

    const anc = ancestors(el);
    lines.push('', `Ancestor path: ${anc.map(tag).join(' > ')}`);
    lines.push(
      `Full DOM path: ${[...anc].reverse().concat(el).map((n) => tag(n) + classesOf(n, 2)).join(' > ')}`,
    );
    return lines.join('\n');
  };

  const placeBelow = (el, rect, gap) => {
    el.style.left = '0px';
    el.style.top = '0px';
    const w = el.offsetWidth;
    const h = el.offsetHeight;
    const vw = document.documentElement.clientWidth;
    const vh = document.documentElement.clientHeight;
    let top = rect.bottom + gap;
    if (top + h > vh) top = rect.top - gap - h;
    top = Math.max(4, Math.min(top, vh - h - 4));
    const left = Math.max(4, Math.min(rect.left, vw - w - 4));
    el.style.left = left + 'px';
    el.style.top = top + 'px';
  };

  const render = () => {
    if (!current || !current.isConnected) {
      box.style.display = 'none';
      tip.style.display = 'none';
      badge.style.display = 'none';
      return;
    }
    const r = current.getBoundingClientRect();
    Object.assign(box.style, {
      display: 'block',
      left: r.left + 'px',
      top: r.top + 'px',
      width: r.width + 'px',
      height: r.height + 'px',
    });
    if (!picking) {
      tip.style.display = 'none';
      if (badge.style.display !== 'none') placeBelow(badge, r, 8);
      return;
    }
    const text = cut(clean(current.innerText), 30);
    tip.textContent = `${tag(current)}${text ? ` "${text}"` : ''} ${Math.round(r.width)}x${Math.round(r.height)}`;
    tip.style.display = 'block';
    placeBelow(tip, r, 6);
  };

  let raf = 0;
  const scheduleRender = () => {
    if (raf) return;
    raf = requestAnimationFrame(() => {
      raf = 0;
      render();
    });
  };

  const copyText = async (text) => {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (_) {
      // navigator.clipboard is unavailable on non-secure origins (plain http other than localhost).
      const ta = document.createElement('textarea');
      ta.value = text;
      ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;';
      root.appendChild(ta);
      ta.select();
      let ok = false;
      try {
        ok = document.execCommand('copy');
      } catch (_) {
        ok = false;
      }
      ta.remove();
      return ok;
    }
  };

  const block = (e) => {
    if (!picking) return;
    e.preventDefault();
    e.stopPropagation();
    e.stopImmediatePropagation();
  };

  const onMove = (e) => {
    if (!picking) return;
    const el = e.composedPath ? e.composedPath()[0] : e.target;
    const target = el && el.nodeType === 1 ? el : e.target;
    if (!target || target === host || target === document.documentElement) return;
    if (target !== current) {
      current = target;
      scheduleRender();
    }
  };

  const onClick = async (e) => {
    if (!picking) return;
    block(e);
    const el = current || e.target;
    if (!el || el.nodeType !== 1) return;
    current = el;
    picking = false;
    detachInput();
    const ok = await copyText(buildContext(el));
    if (stopped) return;
    box.style.borderColor = ok ? '#16a34a' : '#dc2626';
    badge.textContent = ok ? '✅ Copied' : '⚠️ Copy failed';
    badge.style.display = 'block';
    render();
    exitTimer = setTimeout(stop, 3000);
  };

  const onKey = (e) => {
    if (e.key === 'Escape') {
      block(e);
      stop();
    }
  };

  const BLOCKED = ['mousedown', 'mouseup', 'pointerdown', 'pointerup', 'dblclick', 'auxclick', 'contextmenu'];
  const opts = { capture: true, passive: false };

  function detachInput() {
    window.removeEventListener('mousemove', onMove, opts);
    window.removeEventListener('click', onClick, opts);
    window.removeEventListener('keydown', onKey, opts);
    BLOCKED.forEach((t) => window.removeEventListener(t, block, opts));
    if (cursorSheet) {
      document.adoptedStyleSheets = document.adoptedStyleSheets.filter((s) => s !== cursorSheet);
      cursorSheet = null;
    }
  }

  function stop() {
    stopped = true;
    picking = false;
    clearTimeout(exitTimer);
    detachInput();
    window.removeEventListener('scroll', scheduleRender, true);
    window.removeEventListener('resize', scheduleRender);
    if (raf) cancelAnimationFrame(raf);
    host.remove();
    if (window[KEY] === api) delete window[KEY];
  }

  window.addEventListener('mousemove', onMove, opts);
  window.addEventListener('click', onClick, opts);
  window.addEventListener('keydown', onKey, opts);
  BLOCKED.forEach((t) => window.addEventListener(t, block, opts));
  window.addEventListener('scroll', scheduleRender, true);
  window.addEventListener('resize', scheduleRender);

  const api = { stop, buildContext };
  window[KEY] = api;
})();
評価をお願いします (会員登録・ログイン不要)
まだ評価がありません
著者は、アプリケーション開発会社 Cyberneura を運営しています。
開発相談をお待ちしています。

アーカイブ