fix(vim): reach shadow DOM content in hints, visual, and yanks
Component sites such as Reddit render links and text inside open shadow roots, which document-level queries missed. Early ends now log why the page interaction stopped.
- commit
- 7db1f11e078d5a1a4cd095238d37f110c5bc3cdc
- author
- brice · · 2026-09-26 13:51 UTC
- committer
- brice · · 2026-10-10 08:27 UTC
- parent
- 2bc6356
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +37 -10
M internal/infrastructure/cef/vim_page.go +6 -0
| @@ -31,6 +31,8 @@ type vimPageBridgePayload struct { | |||
| 31 | 31 | Token string `json:"token"` | |
| 32 | 32 | Text string `json:"text,omitempty"` | |
| 33 | 33 | URL string `json:"url,omitempty"` | |
| 34 | + | // Reason explains an early end (for example no visible targets). | |
| 35 | + | Reason string `json:"reason,omitempty"` | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | const ( | |
| @@ -305,6 +307,10 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) { | |||
| 305 | 307 | return | |
| 306 | 308 | } | |
| 307 | 309 | ||
| 310 | + | logging.FromContext(wv.ctx).Debug(). | |
| 311 | + | Str("type", payload.Type). | |
| 312 | + | Str("reason", payload.Reason). | |
| 313 | + | Msg("cef: vim page interaction finished") | |
| 308 | 314 | if kind.CapturesKeys() { | |
| 309 | 315 | wv.notifyVimPageInteractionEnded(generation) | |
| 310 | 316 | } | |
| @@ -31,6 +31,8 @@ type vimPageBridgePayload struct { | |||||
| 31 | Token string `json:"token"` | 31 | Token string `json:"token"` | ||
| 32 | Text string `json:"text,omitempty"` | 32 | Text string `json:"text,omitempty"` | ||
| 33 | URL string `json:"url,omitempty"` | 33 | URL string `json:"url,omitempty"` | ||
| 34 | + | // Reason explains an early end (for example no visible targets). | |||
| 35 | + | Reason string `json:"reason,omitempty"` | |||
| 34 | } | 36 | } | ||
| 35 | 37 | ||||
| 36 | const ( | 38 | const ( | ||
| @@ -305,6 +307,10 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) { | |||||
| 305 | return | 307 | return | ||
| 306 | } | 308 | } | ||
| 307 | 309 | ||||
| 310 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 311 | + | Str("type", payload.Type). | |||
| 312 | + | Str("reason", payload.Reason). | |||
| 313 | + | Msg("cef: vim page interaction finished") | |||
| 308 | if kind.CapturesKeys() { | 314 | if kind.CapturesKeys() { | ||
| 309 | wv.notifyVimPageInteractionEnded(generation) | 315 | wv.notifyVimPageInteractionEnded(generation) | ||
| 310 | } | 316 | } | ||
M internal/infrastructure/cef/vim_page_runtime.js +31 -10
| @@ -35,6 +35,29 @@ | |||
| 35 | 35 | ||
| 36 | 36 | let state = null; | |
| 37 | 37 | ||
| 38 | + | // deepQueryAll matches selector across the document and every open shadow | |
| 39 | + | // root; component-heavy sites (for example Reddit) render content there. | |
| 40 | + | function deepQueryAll(selector, root = document) { | |
| 41 | + | const results = Array.from(root.querySelectorAll(selector)); | |
| 42 | + | for (const element of root.querySelectorAll("*")) { | |
| 43 | + | if (element.shadowRoot) results.push(...deepQueryAll(selector, element.shadowRoot)); | |
| 44 | + | } | |
| 45 | + | return results; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // deepTextNodes yields non-blank text nodes in document order, descending | |
| 49 | + | // into open shadow roots. | |
| 50 | + | function* deepTextNodes(root) { | |
| 51 | + | const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); | |
| 52 | + | for (let node = walker.currentNode; node; node = walker.nextNode()) { | |
| 53 | + | if (node.nodeType === Node.TEXT_NODE) { | |
| 54 | + | if (node.data.trim()) yield node; | |
| 55 | + | } else if (node.shadowRoot) { | |
| 56 | + | yield* deepTextNodes(node.shadowRoot); | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 38 | 61 | function post(token, message) { | |
| 39 | 62 | let encoded = ""; | |
| 40 | 63 | try { | |
| @@ -101,14 +124,15 @@ | |||
| 101 | 124 | function hintTargets(linksOnly) { | |
| 102 | 125 | const seen = new Set(); | |
| 103 | 126 | const targets = []; | |
| 104 | - | for (const element of document.querySelectorAll(linksOnly ? "a[href]" : ALL_TARGETS)) { | |
| 127 | + | for (const element of deepQueryAll(linksOnly ? "a[href]" : ALL_TARGETS)) { | |
| 105 | 128 | if (seen.has(element) || element.disabled) continue; | |
| 106 | 129 | const rect = visibleRect(element); | |
| 107 | 130 | if (!rect) continue; | |
| 108 | 131 | const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1); | |
| 109 | 132 | const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1); | |
| 110 | - | const hit = document.elementFromPoint(x, y); | |
| 111 | - | if (hit && !element.contains(hit) && !hit.contains(element)) continue; | |
| 133 | + | const root = element.getRootNode(); | |
| 134 | + | const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y); | |
| 135 | + | if (hit && !element.contains(hit) && !hit.contains(element) && !(hit.shadowRoot && hit.shadowRoot.contains(element))) continue; | |
| 112 | 136 | seen.add(element); | |
| 113 | 137 | targets.push({ element, rect }); | |
| 114 | 138 | } | |
| @@ -192,10 +216,7 @@ | |||
| 192 | 216 | // --- Visual selection ----------------------------------------------------- | |
| 193 | 217 | ||
| 194 | 218 | function firstVisibleTextPosition() { | |
| 195 | - | const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT, { | |
| 196 | - | acceptNode: (node) => (node.data.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT), | |
| 197 | - | }); | |
| 198 | - | for (let node = walker.nextNode(); node; node = walker.nextNode()) { | |
| 219 | + | for (const node of deepTextNodes(document.body || document.documentElement)) { | |
| 199 | 220 | if (!visibleRect(node.parentElement)) continue; | |
| 200 | 221 | const range = document.createRange(); | |
| 201 | 222 | range.selectNodeContents(node); | |
| @@ -300,7 +321,7 @@ | |||
| 300 | 321 | } | |
| 301 | 322 | ||
| 302 | 323 | function sectionText(color) { | |
| 303 | - | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | |
| 324 | + | const headings = deepQueryAll("h1,h2,h3,h4,h5,h6") | |
| 304 | 325 | .filter((heading) => heading.getClientRects().length > 0); | |
| 305 | 326 | if (headings.length === 0) return ""; | |
| 306 | 327 | const threshold = window.innerHeight * 0.25; | |
| @@ -323,7 +344,7 @@ | |||
| 323 | 344 | function objectText(object, color) { | |
| 324 | 345 | if (object === "section") return sectionText(color); | |
| 325 | 346 | for (const selector of OBJECT_SELECTORS[object] || []) { | |
| 326 | - | const candidates = Array.from(document.querySelectorAll(selector)); | |
| 347 | + | const candidates = deepQueryAll(selector); | |
| 327 | 348 | const element = candidates.find((candidate) => | |
| 328 | 349 | visibleRect(candidate) && !candidates.some((other) => other !== candidate && other.contains(candidate))); | |
| 329 | 350 | if (element) { | |
| @@ -362,7 +383,7 @@ | |||
| 362 | 383 | } | |
| 363 | 384 | state = { kind: request.kind, token }; | |
| 364 | 385 | const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color); | |
| 365 | - | if (!started) finish({ type: "end" }); | |
| 386 | + | if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" }); | |
| 366 | 387 | }, | |
| 367 | 388 | key(token, key) { | |
| 368 | 389 | if (!state || state.token !== token) { | |
| @@ -35,6 +35,29 @@ | |||||
| 35 | 35 | ||||
| 36 | let state = null; | 36 | let state = null; | ||
| 37 | 37 | ||||
| 38 | + | // deepQueryAll matches selector across the document and every open shadow | |||
| 39 | + | // root; component-heavy sites (for example Reddit) render content there. | |||
| 40 | + | function deepQueryAll(selector, root = document) { | |||
| 41 | + | const results = Array.from(root.querySelectorAll(selector)); | |||
| 42 | + | for (const element of root.querySelectorAll("*")) { | |||
| 43 | + | if (element.shadowRoot) results.push(...deepQueryAll(selector, element.shadowRoot)); | |||
| 44 | + | } | |||
| 45 | + | return results; | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // deepTextNodes yields non-blank text nodes in document order, descending | |||
| 49 | + | // into open shadow roots. | |||
| 50 | + | function* deepTextNodes(root) { | |||
| 51 | + | const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); | |||
| 52 | + | for (let node = walker.currentNode; node; node = walker.nextNode()) { | |||
| 53 | + | if (node.nodeType === Node.TEXT_NODE) { | |||
| 54 | + | if (node.data.trim()) yield node; | |||
| 55 | + | } else if (node.shadowRoot) { | |||
| 56 | + | yield* deepTextNodes(node.shadowRoot); | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 38 | function post(token, message) { | 61 | function post(token, message) { | ||
| 39 | let encoded = ""; | 62 | let encoded = ""; | ||
| 40 | try { | 63 | try { | ||
| @@ -101,14 +124,15 @@ | |||||
| 101 | function hintTargets(linksOnly) { | 124 | function hintTargets(linksOnly) { | ||
| 102 | const seen = new Set(); | 125 | const seen = new Set(); | ||
| 103 | const targets = []; | 126 | const targets = []; | ||
| 104 | - | for (const element of document.querySelectorAll(linksOnly ? "a[href]" : ALL_TARGETS)) { | 127 | + | for (const element of deepQueryAll(linksOnly ? "a[href]" : ALL_TARGETS)) { |
| 105 | if (seen.has(element) || element.disabled) continue; | 128 | if (seen.has(element) || element.disabled) continue; | ||
| 106 | const rect = visibleRect(element); | 129 | const rect = visibleRect(element); | ||
| 107 | if (!rect) continue; | 130 | if (!rect) continue; | ||
| 108 | const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1); | 131 | const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1); | ||
| 109 | const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1); | 132 | const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1); | ||
| 110 | - | const hit = document.elementFromPoint(x, y); | 133 | + | const root = element.getRootNode(); |
| 111 | - | if (hit && !element.contains(hit) && !hit.contains(element)) continue; | 134 | + | const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y); |
| 135 | + | if (hit && !element.contains(hit) && !hit.contains(element) && !(hit.shadowRoot && hit.shadowRoot.contains(element))) continue; | |||
| 112 | seen.add(element); | 136 | seen.add(element); | ||
| 113 | targets.push({ element, rect }); | 137 | targets.push({ element, rect }); | ||
| 114 | } | 138 | } | ||
| @@ -192,10 +216,7 @@ | |||||
| 192 | // --- Visual selection ----------------------------------------------------- | 216 | // --- Visual selection ----------------------------------------------------- | ||
| 193 | 217 | ||||
| 194 | function firstVisibleTextPosition() { | 218 | function firstVisibleTextPosition() { | ||
| 195 | - | const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT, { | 219 | + | for (const node of deepTextNodes(document.body || document.documentElement)) { |
| 196 | - | acceptNode: (node) => (node.data.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT), | |||
| 197 | - | }); | |||
| 198 | - | for (let node = walker.nextNode(); node; node = walker.nextNode()) { | |||
| 199 | if (!visibleRect(node.parentElement)) continue; | 220 | if (!visibleRect(node.parentElement)) continue; | ||
| 200 | const range = document.createRange(); | 221 | const range = document.createRange(); | ||
| 201 | range.selectNodeContents(node); | 222 | range.selectNodeContents(node); | ||
| @@ -300,7 +321,7 @@ | |||||
| 300 | } | 321 | } | ||
| 301 | 322 | ||||
| 302 | function sectionText(color) { | 323 | function sectionText(color) { | ||
| 303 | - | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | 324 | + | const headings = deepQueryAll("h1,h2,h3,h4,h5,h6") |
| 304 | .filter((heading) => heading.getClientRects().length > 0); | 325 | .filter((heading) => heading.getClientRects().length > 0); | ||
| 305 | if (headings.length === 0) return ""; | 326 | if (headings.length === 0) return ""; | ||
| 306 | const threshold = window.innerHeight * 0.25; | 327 | const threshold = window.innerHeight * 0.25; | ||
| @@ -323,7 +344,7 @@ | |||||
| 323 | function objectText(object, color) { | 344 | function objectText(object, color) { | ||
| 324 | if (object === "section") return sectionText(color); | 345 | if (object === "section") return sectionText(color); | ||
| 325 | for (const selector of OBJECT_SELECTORS[object] || []) { | 346 | for (const selector of OBJECT_SELECTORS[object] || []) { | ||
| 326 | - | const candidates = Array.from(document.querySelectorAll(selector)); | 347 | + | const candidates = deepQueryAll(selector); |
| 327 | const element = candidates.find((candidate) => | 348 | const element = candidates.find((candidate) => | ||
| 328 | visibleRect(candidate) && !candidates.some((other) => other !== candidate && other.contains(candidate))); | 349 | visibleRect(candidate) && !candidates.some((other) => other !== candidate && other.contains(candidate))); | ||
| 329 | if (element) { | 350 | if (element) { | ||
| @@ -362,7 +383,7 @@ | |||||
| 362 | } | 383 | } | ||
| 363 | state = { kind: request.kind, token }; | 384 | state = { kind: request.kind, token }; | ||
| 364 | const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color); | 385 | const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color); | ||
| 365 | - | if (!started) finish({ type: "end" }); | 386 | + | if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" }); |
| 366 | }, | 387 | }, | ||
| 367 | key(token, key) { | 388 | key(token, key) { | ||
| 368 | if (!state || state.token !== token) { | 389 | if (!state || state.token !== token) { | ||