fix(cef): stop the vim page runtime caret drifting and tighten its hot paths
Return the caret to the last selected character when visual mode ends so v then Escape no longer moves it right. Compare focus positions instead of reading the whole selection per motion step, skip off-screen elements before measuring them, find the Escape fallback anchor in one pass and repaint the caret once per animation frame. Guard start and cancel like key, clean up on pagehide, resolve SVG link hrefs and click them without click(), and keep shadow DOM text out of caret anchors. The runtime harness decodes UTF-8 and the tests assert native modify() calls.
- commit
- c69f26a32ced22e7e2d4d4aa3384ee98b0a2d4a2
- author
- brice · · 2026-10-04 16:11 UTC
- committer
- brice · · 2026-10-10 08:27 UTC
- parent
- 1d8d6c0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +477 -111
M internal/infrastructure/cef/testdata/vim_page_harness.js +40 -15
| @@ -5,6 +5,12 @@ | |||
| 5 | 5 | // runtime must implement word motions itself (Vimium #1441). | |
| 6 | 6 | const posted = []; | |
| 7 | 7 | const scrolls = []; | |
| 8 | + | const modifyCalls = []; | |
| 9 | + | const frames = new Map(); | |
| 10 | + | let nextFrame = 1; | |
| 11 | + | let toStringCalls = 0; | |
| 12 | + | let closestCalls = 0; | |
| 13 | + | let rangeRectCalls = 0; | |
| 8 | 14 | const listeners = {}; | |
| 9 | 15 | const CHAR_WIDTH = 10; | |
| 10 | 16 | const LINE_HEIGHT = 20; | |
| @@ -24,7 +30,7 @@ class Node_ { | |||
| 24 | 30 | append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); } | |
| 25 | 31 | remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); this.parentElement = null; } | |
| 26 | 32 | setAttribute(name, value) { this.attrs[name] = value; } | |
| 27 | - | closest() { return null; } | |
| 33 | + | closest() { closestCalls++; return null; } | |
| 28 | 34 | contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; } | |
| 29 | 35 | getClientRects() { return this.rect ? [this.rect] : []; } | |
| 30 | 36 | getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); } | |
| @@ -37,6 +43,7 @@ class Node_ { | |||
| 37 | 43 | } | |
| 38 | 44 | focus() { document.activeElement = this; } | |
| 39 | 45 | click() { posted.push({ clicked: this.href }); } | |
| 46 | + | dispatchEvent(event) { posted.push({ dispatched: event.type, bubbles: event.bubbles, cancelable: event.cancelable, target: this.tagName }); return true; } | |
| 40 | 47 | } | |
| 41 | 48 | function matches(el, selector) { | |
| 42 | 49 | return selector.split(",").some((part) => { | |
| @@ -51,6 +58,11 @@ function matches(el, selector) { | |||
| 51 | 58 | class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } } | |
| 52 | 59 | class HTMLElement extends Element {} | |
| 53 | 60 | class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } } | |
| 61 | + | // SVGAElement_ models an SVG <a>: href is an SVGAnimatedString, there is no | |
| 62 | + | // click(), and the link is not an HTMLAnchorElement. | |
| 63 | + | class SVGAElement_ extends Element { | |
| 64 | + | constructor(href) { super("a"); this.href = { baseVal: href }; this.target = { baseVal: "" }; this.click = undefined; } | |
| 65 | + | } | |
| 54 | 66 | class HTMLInputElement extends HTMLElement {} | |
| 55 | 67 | class HTMLTextAreaElement extends HTMLElement {} | |
| 56 | 68 | class HTMLSelectElement extends HTMLElement {} | |
| @@ -71,10 +83,20 @@ const first = paragraph("Alpha bravo charlie", 10); | |||
| 71 | 83 | const second = paragraph(LONG, 40); | |
| 72 | 84 | const third = paragraph("Third line, end.", 70); | |
| 73 | 85 | const far = paragraph("Far below the fold", 2000); | |
| 74 | - | const p = first.p; // legacy name used by the yank test | |
| 75 | 86 | const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 120, 40, 20); | |
| 76 | 87 | const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 120, 40, 20); | |
| 77 | - | const TEXT = first.text.data; | |
| 88 | + | // shadowParagraph adds a host whose paragraph lives in an open shadow root; | |
| 89 | + | // its text node joins the selection model at the end of the document order. | |
| 90 | + | function shadowParagraph(data, top) { | |
| 91 | + | const host = body.appendChild(new HTMLElement("div")); | |
| 92 | + | const p = new HTMLElement("p"); | |
| 93 | + | p.rect = rect(10, top, 600, LINE_HEIGHT); | |
| 94 | + | p.parentElement = null; | |
| 95 | + | const text = p.appendChild(new Text_(data)); | |
| 96 | + | texts.push(text); | |
| 97 | + | host.shadowRoot = { children: [p], querySelectorAll: (selector) => Node_.prototype.querySelectorAll.call({ children: [p] }, selector), getRootNode: () => document }; | |
| 98 | + | return { host, p, text }; | |
| 99 | + | } | |
| 78 | 100 | ||
| 79 | 101 | const index = (node) => texts.indexOf(node); | |
| 80 | 102 | const order = (node, offset) => index(node) * 100000 + offset; | |
| @@ -92,6 +114,7 @@ class Range_ { | |||
| 92 | 114 | collapse(toStart) { if (toStart) { this.endContainer = this.startContainer; this.endOffset = this.startOffset; } else { this.startContainer = this.endContainer; this.startOffset = this.endOffset; } } | |
| 93 | 115 | selectNodeContents(node) { this.startContainer = this.endContainer = node; this.startOffset = 0; this.endOffset = node.data ? node.data.length : 0; } | |
| 94 | 116 | getClientRects() { | |
| 117 | + | rangeRectCalls++; | |
| 95 | 118 | const node = this.startContainer; | |
| 96 | 119 | const box = node && node.parentElement && node.parentElement.rect; | |
| 97 | 120 | if (!box) return []; | |
| @@ -107,8 +130,8 @@ const selection = { | |||
| 107 | 130 | get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); }, | |
| 108 | 131 | setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; }, | |
| 109 | 132 | collapse(node, offset) { this.setBaseAndExtent(node, offset, node, offset); }, | |
| 110 | - | setFocus(node, offset) { this.focusNode = node; this.focusOffset = offset; }, | |
| 111 | 133 | modify(alter, direction, granularity) { | |
| 134 | + | modifyCalls.push([alter, direction, granularity]); | |
| 112 | 135 | if (granularity === "word") throw new Error("native word granularity must not be used"); | |
| 113 | 136 | const step = direction === "forward" ? 1 : -1; | |
| 114 | 137 | let i = index(this.focusNode); | |
| @@ -124,12 +147,9 @@ const selection = { | |||
| 124 | 147 | if (j >= 0 && j < texts.length) { i = j; o = Math.min(o, len(j)); } else o = step > 0 ? len(i) : 0; | |
| 125 | 148 | } else if (granularity === "documentboundary") { | |
| 126 | 149 | i = step > 0 ? texts.length - 1 : 0; o = step > 0 ? len(i) : 0; | |
| 127 | - | } else if (granularity === "paragraph") { | |
| 128 | - | if (step > 0) { if (o < len(i)) o = len(i); else if (i < texts.length - 1) { i++; o = len(i); } } | |
| 129 | - | else if (o > 0) o = 0; else if (i > 0) { i--; o = 0; } | |
| 130 | - | } else if (granularity === "sentence") { | |
| 131 | - | o = step > 0 ? len(i) : 0; | |
| 132 | 150 | } | |
| 151 | + | // Sentence and paragraph granularities are native: this model leaves the | |
| 152 | + | // focus alone, and tests assert the modify() calls instead. | |
| 133 | 153 | this.focusNode = texts[i]; this.focusOffset = o; | |
| 134 | 154 | if (alter === "move") { this.anchorNode = this.focusNode; this.anchorOffset = this.focusOffset; } | |
| 135 | 155 | }, | |
| @@ -143,6 +163,7 @@ const selection = { | |||
| 143 | 163 | return range; | |
| 144 | 164 | }, | |
| 145 | 165 | toString() { | |
| 166 | + | toStringCalls++; | |
| 146 | 167 | if (!this.rangeCount) return ""; | |
| 147 | 168 | const r = this.getRangeAt(); | |
| 148 | 169 | const a = index(r.startContainer); | |
| @@ -168,8 +189,10 @@ global.document = { | |||
| 168 | 189 | let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null }; | |
| 169 | 190 | }, | |
| 170 | 191 | querySelectorAll: (selector) => html.querySelectorAll(selector), | |
| 171 | - | elementFromPoint: (x, y) => [two, one, ...texts.map((t) => t.parentElement)].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body, | |
| 192 | + | baseURI: "https://example.com/dir/page.html", | |
| 193 | + | elementFromPoint: (x, y) => [two, one, ...texts.map((t) => t.parentElement), ...body.children.slice().reverse()].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body, | |
| 172 | 194 | }; | |
| 195 | + | global.MouseEvent = class { constructor(type, init) { this.type = type; Object.assign(this, init); } }; | |
| 173 | 196 | global.window = { | |
| 174 | 197 | innerWidth: 800, innerHeight: 600, | |
| 175 | 198 | scrollBy(x, y) { scrolls.push([x, y]); }, | |
| @@ -177,10 +200,12 @@ global.window = { | |||
| 177 | 200 | removeEventListener(type, fn) { listeners[type] = (listeners[type] || []).filter((f) => f !== fn); }, | |
| 178 | 201 | getSelection: () => selection, | |
| 179 | 202 | getComputedStyle: () => ({ display: "block", visibility: "visible" }), | |
| 180 | - | fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("latin1"))); return Promise.resolve(); }, | |
| 203 | + | // The runtime sends btoa(unescape(encodeURIComponent(json))): base64 of the | |
| 204 | + | // UTF-8 bytes, which is what Go decodes. | |
| 205 | + | fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("utf8"))); return Promise.resolve(); }, | |
| 206 | + | requestAnimationFrame(fn) { const id = nextFrame++; frames.set(id, fn); return id; }, | |
| 207 | + | cancelAnimationFrame(id) { frames.delete(id); }, | |
| 181 | 208 | }; | |
| 182 | - | global.btoa = (s) => Buffer.from(s, "binary").toString("base64"); | |
| 183 | - | global.unescape = (s) => decodeURIComponent(s); | |
| 184 | 209 | global.setTimeout = (fn) => { fn(); return 0; }; | |
| 185 | 210 | ||
| 186 | 211 | // Helpers for drivers. | |
| @@ -194,10 +219,10 @@ const overlays = (name) => html.children.filter((c) => c.attrs && name in c.attr | |||
| 194 | 219 | const cursor = () => overlays("data-dumber-vim-caret")[0] || null; | |
| 195 | 220 | const hintLabels = () => overlays("data-dumber-vim-hints").flatMap((root) => root.children.map((s) => s.textContent)); | |
| 196 | 221 | const fire = (type) => (listeners[type] || []).slice().forEach((fn) => fn({})); | |
| 197 | - | const modes = () => posted.filter((m) => m.type === "mode").map((m) => m.mode); | |
| 222 | + | // flushFrames runs the animation frames requested so far. | |
| 223 | + | const flushFrames = () => { const pending = Array.from(frames.values()); frames.clear(); pending.forEach((fn) => fn()); }; | |
| 198 | 224 | const vp = () => window.__dumberVimPage; | |
| 199 | 225 | const keys = (token, ...list) => list.forEach((k) => window.__dumberVimPage.key(token, k)); | |
| 200 | - | const rec = (name, extra) => posted.push(Object.assign({ r: name }, extra)); | |
| 201 | 226 | // startCaret begins the visual flow and places the caret at the start of the | |
| 202 | 227 | // paragraph with the given hint label ("s", "a", "d" for the first, second, | |
| 203 | 228 | // third visible paragraph). | |
| @@ -5,6 +5,12 @@ | |||||
| 5 | // runtime must implement word motions itself (Vimium #1441). | 5 | // runtime must implement word motions itself (Vimium #1441). | ||
| 6 | const posted = []; | 6 | const posted = []; | ||
| 7 | const scrolls = []; | 7 | const scrolls = []; | ||
| 8 | + | const modifyCalls = []; | |||
| 9 | + | const frames = new Map(); | |||
| 10 | + | let nextFrame = 1; | |||
| 11 | + | let toStringCalls = 0; | |||
| 12 | + | let closestCalls = 0; | |||
| 13 | + | let rangeRectCalls = 0; | |||
| 8 | const listeners = {}; | 14 | const listeners = {}; | ||
| 9 | const CHAR_WIDTH = 10; | 15 | const CHAR_WIDTH = 10; | ||
| 10 | const LINE_HEIGHT = 20; | 16 | const LINE_HEIGHT = 20; | ||
| @@ -24,7 +30,7 @@ class Node_ { | |||||
| 24 | append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); } | 30 | append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); } | ||
| 25 | remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); this.parentElement = null; } | 31 | remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); this.parentElement = null; } | ||
| 26 | setAttribute(name, value) { this.attrs[name] = value; } | 32 | setAttribute(name, value) { this.attrs[name] = value; } | ||
| 27 | - | closest() { return null; } | 33 | + | closest() { closestCalls++; return null; } |
| 28 | contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; } | 34 | contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; } | ||
| 29 | getClientRects() { return this.rect ? [this.rect] : []; } | 35 | getClientRects() { return this.rect ? [this.rect] : []; } | ||
| 30 | getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); } | 36 | getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); } | ||
| @@ -37,6 +43,7 @@ class Node_ { | |||||
| 37 | } | 43 | } | ||
| 38 | focus() { document.activeElement = this; } | 44 | focus() { document.activeElement = this; } | ||
| 39 | click() { posted.push({ clicked: this.href }); } | 45 | click() { posted.push({ clicked: this.href }); } | ||
| 46 | + | dispatchEvent(event) { posted.push({ dispatched: event.type, bubbles: event.bubbles, cancelable: event.cancelable, target: this.tagName }); return true; } | |||
| 40 | } | 47 | } | ||
| 41 | function matches(el, selector) { | 48 | function matches(el, selector) { | ||
| 42 | return selector.split(",").some((part) => { | 49 | return selector.split(",").some((part) => { | ||
| @@ -51,6 +58,11 @@ function matches(el, selector) { | |||||
| 51 | class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } } | 58 | class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } } | ||
| 52 | class HTMLElement extends Element {} | 59 | class HTMLElement extends Element {} | ||
| 53 | class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } } | 60 | class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } } | ||
| 61 | + | // SVGAElement_ models an SVG <a>: href is an SVGAnimatedString, there is no | |||
| 62 | + | // click(), and the link is not an HTMLAnchorElement. | |||
| 63 | + | class SVGAElement_ extends Element { | |||
| 64 | + | constructor(href) { super("a"); this.href = { baseVal: href }; this.target = { baseVal: "" }; this.click = undefined; } | |||
| 65 | + | } | |||
| 54 | class HTMLInputElement extends HTMLElement {} | 66 | class HTMLInputElement extends HTMLElement {} | ||
| 55 | class HTMLTextAreaElement extends HTMLElement {} | 67 | class HTMLTextAreaElement extends HTMLElement {} | ||
| 56 | class HTMLSelectElement extends HTMLElement {} | 68 | class HTMLSelectElement extends HTMLElement {} | ||
| @@ -71,10 +83,20 @@ const first = paragraph("Alpha bravo charlie", 10); | |||||
| 71 | const second = paragraph(LONG, 40); | 83 | const second = paragraph(LONG, 40); | ||
| 72 | const third = paragraph("Third line, end.", 70); | 84 | const third = paragraph("Third line, end.", 70); | ||
| 73 | const far = paragraph("Far below the fold", 2000); | 85 | const far = paragraph("Far below the fold", 2000); | ||
| 74 | - | const p = first.p; // legacy name used by the yank test | |||
| 75 | const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 120, 40, 20); | 86 | const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 120, 40, 20); | ||
| 76 | const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 120, 40, 20); | 87 | const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 120, 40, 20); | ||
| 77 | - | const TEXT = first.text.data; | 88 | + | // shadowParagraph adds a host whose paragraph lives in an open shadow root; |
| 89 | + | // its text node joins the selection model at the end of the document order. | |||
| 90 | + | function shadowParagraph(data, top) { | |||
| 91 | + | const host = body.appendChild(new HTMLElement("div")); | |||
| 92 | + | const p = new HTMLElement("p"); | |||
| 93 | + | p.rect = rect(10, top, 600, LINE_HEIGHT); | |||
| 94 | + | p.parentElement = null; | |||
| 95 | + | const text = p.appendChild(new Text_(data)); | |||
| 96 | + | texts.push(text); | |||
| 97 | + | host.shadowRoot = { children: [p], querySelectorAll: (selector) => Node_.prototype.querySelectorAll.call({ children: [p] }, selector), getRootNode: () => document }; | |||
| 98 | + | return { host, p, text }; | |||
| 99 | + | } | |||
| 78 | 100 | ||||
| 79 | const index = (node) => texts.indexOf(node); | 101 | const index = (node) => texts.indexOf(node); | ||
| 80 | const order = (node, offset) => index(node) * 100000 + offset; | 102 | const order = (node, offset) => index(node) * 100000 + offset; | ||
| @@ -92,6 +114,7 @@ class Range_ { | |||||
| 92 | collapse(toStart) { if (toStart) { this.endContainer = this.startContainer; this.endOffset = this.startOffset; } else { this.startContainer = this.endContainer; this.startOffset = this.endOffset; } } | 114 | collapse(toStart) { if (toStart) { this.endContainer = this.startContainer; this.endOffset = this.startOffset; } else { this.startContainer = this.endContainer; this.startOffset = this.endOffset; } } | ||
| 93 | selectNodeContents(node) { this.startContainer = this.endContainer = node; this.startOffset = 0; this.endOffset = node.data ? node.data.length : 0; } | 115 | selectNodeContents(node) { this.startContainer = this.endContainer = node; this.startOffset = 0; this.endOffset = node.data ? node.data.length : 0; } | ||
| 94 | getClientRects() { | 116 | getClientRects() { | ||
| 117 | + | rangeRectCalls++; | |||
| 95 | const node = this.startContainer; | 118 | const node = this.startContainer; | ||
| 96 | const box = node && node.parentElement && node.parentElement.rect; | 119 | const box = node && node.parentElement && node.parentElement.rect; | ||
| 97 | if (!box) return []; | 120 | if (!box) return []; | ||
| @@ -107,8 +130,8 @@ const selection = { | |||||
| 107 | get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); }, | 130 | get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); }, | ||
| 108 | setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; }, | 131 | setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; }, | ||
| 109 | collapse(node, offset) { this.setBaseAndExtent(node, offset, node, offset); }, | 132 | collapse(node, offset) { this.setBaseAndExtent(node, offset, node, offset); }, | ||
| 110 | - | setFocus(node, offset) { this.focusNode = node; this.focusOffset = offset; }, | |||
| 111 | modify(alter, direction, granularity) { | 133 | modify(alter, direction, granularity) { | ||
| 134 | + | modifyCalls.push([alter, direction, granularity]); | |||
| 112 | if (granularity === "word") throw new Error("native word granularity must not be used"); | 135 | if (granularity === "word") throw new Error("native word granularity must not be used"); | ||
| 113 | const step = direction === "forward" ? 1 : -1; | 136 | const step = direction === "forward" ? 1 : -1; | ||
| 114 | let i = index(this.focusNode); | 137 | let i = index(this.focusNode); | ||
| @@ -124,12 +147,9 @@ const selection = { | |||||
| 124 | if (j >= 0 && j < texts.length) { i = j; o = Math.min(o, len(j)); } else o = step > 0 ? len(i) : 0; | 147 | if (j >= 0 && j < texts.length) { i = j; o = Math.min(o, len(j)); } else o = step > 0 ? len(i) : 0; | ||
| 125 | } else if (granularity === "documentboundary") { | 148 | } else if (granularity === "documentboundary") { | ||
| 126 | i = step > 0 ? texts.length - 1 : 0; o = step > 0 ? len(i) : 0; | 149 | i = step > 0 ? texts.length - 1 : 0; o = step > 0 ? len(i) : 0; | ||
| 127 | - | } else if (granularity === "paragraph") { | |||
| 128 | - | if (step > 0) { if (o < len(i)) o = len(i); else if (i < texts.length - 1) { i++; o = len(i); } } | |||
| 129 | - | else if (o > 0) o = 0; else if (i > 0) { i--; o = 0; } | |||
| 130 | - | } else if (granularity === "sentence") { | |||
| 131 | - | o = step > 0 ? len(i) : 0; | |||
| 132 | } | 150 | } | ||
| 151 | + | // Sentence and paragraph granularities are native: this model leaves the | |||
| 152 | + | // focus alone, and tests assert the modify() calls instead. | |||
| 133 | this.focusNode = texts[i]; this.focusOffset = o; | 153 | this.focusNode = texts[i]; this.focusOffset = o; | ||
| 134 | if (alter === "move") { this.anchorNode = this.focusNode; this.anchorOffset = this.focusOffset; } | 154 | if (alter === "move") { this.anchorNode = this.focusNode; this.anchorOffset = this.focusOffset; } | ||
| 135 | }, | 155 | }, | ||
| @@ -143,6 +163,7 @@ const selection = { | |||||
| 143 | return range; | 163 | return range; | ||
| 144 | }, | 164 | }, | ||
| 145 | toString() { | 165 | toString() { | ||
| 166 | + | toStringCalls++; | |||
| 146 | if (!this.rangeCount) return ""; | 167 | if (!this.rangeCount) return ""; | ||
| 147 | const r = this.getRangeAt(); | 168 | const r = this.getRangeAt(); | ||
| 148 | const a = index(r.startContainer); | 169 | const a = index(r.startContainer); | ||
| @@ -168,8 +189,10 @@ global.document = { | |||||
| 168 | let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null }; | 189 | let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null }; | ||
| 169 | }, | 190 | }, | ||
| 170 | querySelectorAll: (selector) => html.querySelectorAll(selector), | 191 | querySelectorAll: (selector) => html.querySelectorAll(selector), | ||
| 171 | - | elementFromPoint: (x, y) => [two, one, ...texts.map((t) => t.parentElement)].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body, | 192 | + | baseURI: "https://example.com/dir/page.html", |
| 193 | + | elementFromPoint: (x, y) => [two, one, ...texts.map((t) => t.parentElement), ...body.children.slice().reverse()].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body, | |||
| 172 | }; | 194 | }; | ||
| 195 | + | global.MouseEvent = class { constructor(type, init) { this.type = type; Object.assign(this, init); } }; | |||
| 173 | global.window = { | 196 | global.window = { | ||
| 174 | innerWidth: 800, innerHeight: 600, | 197 | innerWidth: 800, innerHeight: 600, | ||
| 175 | scrollBy(x, y) { scrolls.push([x, y]); }, | 198 | scrollBy(x, y) { scrolls.push([x, y]); }, | ||
| @@ -177,10 +200,12 @@ global.window = { | |||||
| 177 | removeEventListener(type, fn) { listeners[type] = (listeners[type] || []).filter((f) => f !== fn); }, | 200 | removeEventListener(type, fn) { listeners[type] = (listeners[type] || []).filter((f) => f !== fn); }, | ||
| 178 | getSelection: () => selection, | 201 | getSelection: () => selection, | ||
| 179 | getComputedStyle: () => ({ display: "block", visibility: "visible" }), | 202 | getComputedStyle: () => ({ display: "block", visibility: "visible" }), | ||
| 180 | - | fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("latin1"))); return Promise.resolve(); }, | 203 | + | // The runtime sends btoa(unescape(encodeURIComponent(json))): base64 of the |
| 204 | + | // UTF-8 bytes, which is what Go decodes. | |||
| 205 | + | fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("utf8"))); return Promise.resolve(); }, | |||
| 206 | + | requestAnimationFrame(fn) { const id = nextFrame++; frames.set(id, fn); return id; }, | |||
| 207 | + | cancelAnimationFrame(id) { frames.delete(id); }, | |||
| 181 | }; | 208 | }; | ||
| 182 | - | global.btoa = (s) => Buffer.from(s, "binary").toString("base64"); | |||
| 183 | - | global.unescape = (s) => decodeURIComponent(s); | |||
| 184 | global.setTimeout = (fn) => { fn(); return 0; }; | 209 | global.setTimeout = (fn) => { fn(); return 0; }; | ||
| 185 | 210 | ||||
| 186 | // Helpers for drivers. | 211 | // Helpers for drivers. | ||
| @@ -194,10 +219,10 @@ const overlays = (name) => html.children.filter((c) => c.attrs && name in c.attr | |||||
| 194 | const cursor = () => overlays("data-dumber-vim-caret")[0] || null; | 219 | const cursor = () => overlays("data-dumber-vim-caret")[0] || null; | ||
| 195 | const hintLabels = () => overlays("data-dumber-vim-hints").flatMap((root) => root.children.map((s) => s.textContent)); | 220 | const hintLabels = () => overlays("data-dumber-vim-hints").flatMap((root) => root.children.map((s) => s.textContent)); | ||
| 196 | const fire = (type) => (listeners[type] || []).slice().forEach((fn) => fn({})); | 221 | const fire = (type) => (listeners[type] || []).slice().forEach((fn) => fn({})); | ||
| 197 | - | const modes = () => posted.filter((m) => m.type === "mode").map((m) => m.mode); | 222 | + | // flushFrames runs the animation frames requested so far. |
| 223 | + | const flushFrames = () => { const pending = Array.from(frames.values()); frames.clear(); pending.forEach((fn) => fn()); }; | |||
| 198 | const vp = () => window.__dumberVimPage; | 224 | const vp = () => window.__dumberVimPage; | ||
| 199 | const keys = (token, ...list) => list.forEach((k) => window.__dumberVimPage.key(token, k)); | 225 | const keys = (token, ...list) => list.forEach((k) => window.__dumberVimPage.key(token, k)); | ||
| 200 | - | const rec = (name, extra) => posted.push(Object.assign({ r: name }, extra)); | |||
| 201 | // startCaret begins the visual flow and places the caret at the start of the | 226 | // startCaret begins the visual flow and places the caret at the start of the | ||
| 202 | // paragraph with the given hint label ("s", "a", "d" for the first, second, | 227 | // paragraph with the given hint label ("s", "a", "d" for the first, second, | ||
| 203 | // third visible paragraph). | 228 | // third visible paragraph). | ||
M internal/infrastructure/cef/vim_page_runtime.js +170 -74
| @@ -60,16 +60,14 @@ | |||
| 60 | 60 | return results; | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | - | // deepTextNodes yields non-blank text nodes in document order, descending | |
| 64 | - | // into open shadow roots. | |
| 65 | - | function* deepTextNodes(root) { | |
| 66 | - | const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); | |
| 63 | + | // lightTextNodes yields non-blank text nodes in document order. It does not | |
| 64 | + | // enter shadow roots on purpose: Chromium reports Selection positions inside | |
| 65 | + | // a shadow tree relative to its host, so a caret anchored there would sit in | |
| 66 | + | // the wrong place. Link hints and text-object yanks still search shadow DOM. | |
| 67 | + | function* lightTextNodes(root) { | |
| 68 | + | const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); | |
| 67 | 69 | for (let node = walker.currentNode; node; node = walker.nextNode()) { | |
| 68 | - | if (node.nodeType === Node.TEXT_NODE) { | |
| 69 | - | if (node.data.trim()) yield node; | |
| 70 | - | } else if (node.shadowRoot) { | |
| 71 | - | yield* deepTextNodes(node.shadowRoot); | |
| 72 | - | } | |
| 70 | + | if (node.nodeType === Node.TEXT_NODE && node.data.trim()) yield node; | |
| 73 | 71 | } | |
| 74 | 72 | } | |
| 75 | 73 | ||
| @@ -86,14 +84,22 @@ | |||
| 86 | 84 | }).catch(() => {}); | |
| 87 | 85 | } | |
| 88 | 86 | ||
| 87 | + | function onScreen(rect) { | |
| 88 | + | return rect.bottom > 0 && rect.right > 0 && rect.top < window.innerHeight && rect.left < window.innerWidth; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // visibleRect returns a client rect of element inside the viewport. The | |
| 92 | + | // cheap bounding-box test runs first: most elements of a long page are | |
| 93 | + | // off-screen, and closest/getComputedStyle are the expensive part. | |
| 89 | 94 | function visibleRect(element) { | |
| 90 | 95 | if (!(element instanceof Element) || !element.isConnected) return null; | |
| 96 | + | if (!onScreen(element.getBoundingClientRect())) return null; | |
| 91 | 97 | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return null; | |
| 92 | 98 | const style = window.getComputedStyle(element); | |
| 93 | 99 | if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return null; | |
| 94 | 100 | for (const rect of element.getClientRects()) { | |
| 95 | 101 | if (rect.width < 1 || rect.height < 1) continue; | |
| 96 | - | if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth) continue; | |
| 102 | + | if (!onScreen(rect)) continue; | |
| 97 | 103 | return rect; | |
| 98 | 104 | } | |
| 99 | 105 | return null; | |
| @@ -205,15 +211,42 @@ | |||
| 205 | 211 | } | |
| 206 | 212 | } | |
| 207 | 213 | ||
| 214 | + | // linkURL resolves the destination of an HTML or SVG anchor. An SVG <a> | |
| 215 | + | // exposes href as an SVGAnimatedString that is not resolved against the | |
| 216 | + | // document, so it is resolved here; Go validates the result before opening it. | |
| 217 | + | function linkURL(element) { | |
| 218 | + | if (element instanceof HTMLAnchorElement) return element.href; | |
| 219 | + | const animated = element.href; | |
| 220 | + | if (!animated || typeof animated.baseVal !== "string" || !animated.baseVal) return ""; | |
| 221 | + | try { | |
| 222 | + | return new URL(animated.baseVal, document.baseURI).href; | |
| 223 | + | } catch (_) { | |
| 224 | + | return ""; | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | function linkTarget(element) { | |
| 229 | + | const target = element.target; | |
| 230 | + | return target && typeof target === "object" ? target.baseVal : target; | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | function clickElement(element) { | |
| 234 | + | if (typeof element.click === "function") { | |
| 235 | + | element.click(); | |
| 236 | + | return; | |
| 237 | + | } | |
| 238 | + | element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); | |
| 239 | + | } | |
| 240 | + | ||
| 208 | 241 | // activateHint returns the terminal message for the chosen hint. | |
| 209 | 242 | function activateHint(kind, element) { | |
| 210 | - | const url = element instanceof HTMLAnchorElement ? element.href : ""; | |
| 243 | + | const url = linkURL(element); | |
| 211 | 244 | if (kind === "hint-yank-url") return url ? { type: "copy", text: url } : { type: "end" }; | |
| 212 | - | if (kind === "hint-follow-new" || (url && element.target === "_blank")) { | |
| 245 | + | if (kind === "hint-follow-new" || (url && linkTarget(element) === "_blank")) { | |
| 213 | 246 | return url ? { type: "open-new", url } : { type: "end" }; | |
| 214 | 247 | } | |
| 215 | - | element.focus({ preventScroll: true }); | |
| 216 | - | if (!isEditable(element)) element.click(); | |
| 248 | + | if (typeof element.focus === "function") element.focus({ preventScroll: true }); | |
| 249 | + | if (!isEditable(element)) clickElement(element); | |
| 217 | 250 | return { type: "end" }; | |
| 218 | 251 | } | |
| 219 | 252 | ||
| @@ -309,19 +342,30 @@ | |||
| 309 | 342 | return null; | |
| 310 | 343 | } | |
| 311 | 344 | ||
| 312 | - | // collectTextAnchors lists visible text starts in document order. perBlock | |
| 313 | - | // keeps the first anchor of each block; minLength skips short texts. | |
| 314 | - | function collectTextAnchors({ perBlock, minLength, limit }) { | |
| 315 | - | const anchors = []; | |
| 316 | - | const blocks = new Set(); | |
| 317 | - | for (const node of deepTextNodes(document.body || document.documentElement)) { | |
| 345 | + | // visibleTextAnchors yields visible text starts in document order. A text | |
| 346 | + | // node whose parent element is off-screen cannot be visible, so that cheap | |
| 347 | + | // check (cached per element) runs before the range measurements. | |
| 348 | + | function* visibleTextAnchors(minLength) { | |
| 349 | + | const onScreenElements = new Map(); | |
| 350 | + | for (const node of lightTextNodes(document.body || document.documentElement)) { | |
| 318 | 351 | const element = node.parentElement; | |
| 319 | 352 | if (!element || SKIP_TEXT_PARENTS.test(element.tagName) || isEditable(element)) continue; | |
| 320 | 353 | if (node.data.trim().length < minLength) continue; | |
| 354 | + | if (!onScreenElements.has(element)) onScreenElements.set(element, onScreen(element.getBoundingClientRect())); | |
| 355 | + | if (!onScreenElements.get(element)) continue; | |
| 321 | 356 | const anchor = firstVisibleAnchor(node, element); | |
| 322 | - | if (!anchor) continue; | |
| 357 | + | if (anchor) yield anchor; | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // collectTextAnchors lists visible text starts in document order. perBlock | |
| 362 | + | // keeps the first anchor of each block. | |
| 363 | + | function collectTextAnchors({ perBlock, limit }) { | |
| 364 | + | const anchors = []; | |
| 365 | + | const blocks = new Set(); | |
| 366 | + | for (const anchor of visibleTextAnchors(1)) { | |
| 323 | 367 | if (perBlock) { | |
| 324 | - | const block = blockOf(element); | |
| 368 | + | const block = blockOf(anchor.element); | |
| 325 | 369 | if (blocks.has(block)) continue; | |
| 326 | 370 | blocks.add(block); | |
| 327 | 371 | } | |
| @@ -333,11 +377,14 @@ | |||
| 333 | 377 | ||
| 334 | 378 | // Esc during the text-anchor hints: Vimium's heuristic picks the first | |
| 335 | 379 | // visible text of at least LONG_TEXT_LENGTH characters (shorter ones are | |
| 336 | - | // likely banners); any visible text is the fallback. | |
| 380 | + | // likely banners); any visible text is the fallback. One pass finds both. | |
| 337 | 381 | function fallbackAnchor() { | |
| 338 | - | const long = collectTextAnchors({ perBlock: false, minLength: LONG_TEXT_LENGTH, limit: 1 }); | |
| 339 | - | if (long.length > 0) return long[0]; | |
| 340 | - | return collectTextAnchors({ perBlock: false, minLength: 1, limit: 1 })[0] || null; | |
| 382 | + | let first = null; | |
| 383 | + | for (const anchor of visibleTextAnchors(1)) { | |
| 384 | + | if (anchor.node.data.trim().length >= LONG_TEXT_LENGTH) return anchor; | |
| 385 | + | if (!first) first = anchor; | |
| 386 | + | } | |
| 387 | + | return first; | |
| 341 | 388 | } | |
| 342 | 389 | ||
| 343 | 390 | function placeCaretAtFallback() { | |
| @@ -405,7 +452,15 @@ | |||
| 405 | 452 | cursor.animate([{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0.15 }], { duration: 1000, iterations: Infinity }); | |
| 406 | 453 | } | |
| 407 | 454 | state.cursor = cursor; | |
| 408 | - | state.onViewportChange = () => updateCursor(window.getSelection()); | |
| 455 | + | // Scroll and resize fire at frame rate or faster; repaint once per frame. | |
| 456 | + | state.onViewportChange = () => { | |
| 457 | + | if (!state || state.frame) return; | |
| 458 | + | state.frame = window.requestAnimationFrame(() => { | |
| 459 | + | if (!state) return; | |
| 460 | + | state.frame = 0; | |
| 461 | + | updateCursor(window.getSelection()); | |
| 462 | + | }); | |
| 463 | + | }; | |
| 409 | 464 | window.addEventListener("scroll", state.onViewportChange, { capture: true, passive: true }); | |
| 410 | 465 | window.addEventListener("resize", state.onViewportChange, { passive: true }); | |
| 411 | 466 | } | |
| @@ -464,7 +519,7 @@ | |||
| 464 | 519 | setPhase("visual"); | |
| 465 | 520 | return true; | |
| 466 | 521 | } | |
| 467 | - | const anchors = collectTextAnchors({ perBlock: true, minLength: 1, limit: HINT_CHARS.length * HINT_CHARS.length }); | |
| 522 | + | const anchors = collectTextAnchors({ perBlock: true, limit: HINT_CHARS.length * HINT_CHARS.length }); | |
| 468 | 523 | if (anchors.length === 0) return false; | |
| 469 | 524 | buildHintOverlay(anchors, color, true); | |
| 470 | 525 | setPhase("hints"); | |
| @@ -489,38 +544,65 @@ | |||
| 489 | 544 | selection.collapse(selection.focusNode, selection.focusOffset); | |
| 490 | 545 | } | |
| 491 | 546 | ||
| 492 | - | // extendByOne extends the focus one character and reports how the selected | |
| 493 | - | // text length changed; 0 means the focus could not move. | |
| 547 | + | function focusMoved(selection, node, offset) { | |
| 548 | + | return selection.focusNode !== node || selection.focusOffset !== offset; | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | // extendByOne extends the focus one character and reports whether it moved | |
| 552 | + | // (1) or could not (0). It compares focus positions: reading the whole | |
| 553 | + | // selection's text per step made long selections quadratic. | |
| 494 | 554 | function extendByOne(selection, direction) { | |
| 495 | - | const length = selection.toString().length; | |
| 555 | + | const node = selection.focusNode; | |
| 556 | + | const offset = selection.focusOffset; | |
| 496 | 557 | selection.modify("extend", direction, "character"); | |
| 497 | - | return selection.toString().length - length; | |
| 558 | + | return focusMoved(selection, node, offset) ? 1 : 0; | |
| 498 | 559 | } | |
| 499 | 560 | ||
| 500 | - | // nextCharacter returns the character after the focus without moving it. | |
| 501 | - | function nextCharacter(selection) { | |
| 502 | - | const before = selection.toString(); | |
| 503 | - | if (before.length === 0 || !isBackward(selection)) { | |
| 504 | - | selection.modify("extend", "forward", "character"); | |
| 505 | - | const after = selection.toString(); | |
| 506 | - | if (after === before) return undefined; | |
| 507 | - | selection.modify("extend", "backward", "character"); | |
| 508 | - | return after[after.length - 1]; | |
| 561 | + | // characterAtFocus reads the character next to the focus from its text node. | |
| 562 | + | // It returns null when the focus is not inside a text node or sits on its | |
| 563 | + | // boundary, where the neighbor lives in another node. | |
| 564 | + | function characterAtFocus(selection, forward) { | |
| 565 | + | const node = selection.focusNode; | |
| 566 | + | const offset = selection.focusOffset; | |
| 567 | + | if (!node || node.nodeType !== Node.TEXT_NODE) return null; | |
| 568 | + | const data = node.data; | |
| 569 | + | if (forward) return offset < data.length ? String.fromCodePoint(data.codePointAt(offset)) : null; | |
| 570 | + | if (offset <= 0) return null; | |
| 571 | + | const unit = data.charCodeAt(offset - 1); | |
| 572 | + | const paired = offset > 1 && unit >= 0xdc00 && unit <= 0xdfff; | |
| 573 | + | return paired ? data.slice(offset - 2, offset) : data[offset - 1]; | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | // probeCharacter finds the character across a node boundary by stepping the | |
| 577 | + | // focus over it and back. Landing on a text node boundary or outside a text | |
| 578 | + | // node means a block boundary was crossed, which counts as a line break. | |
| 579 | + | function probeCharacter(selection, direction) { | |
| 580 | + | const node = selection.focusNode; | |
| 581 | + | const offset = selection.focusOffset; | |
| 582 | + | selection.modify("extend", direction, "character"); | |
| 583 | + | if (!focusMoved(selection, node, offset)) return undefined; | |
| 584 | + | const landed = selection.focusNode; | |
| 585 | + | const landedOffset = selection.focusOffset; | |
| 586 | + | let character = "\n"; | |
| 587 | + | if (landed && landed.nodeType === Node.TEXT_NODE) { | |
| 588 | + | if (direction === "forward" && landedOffset > 0) character = landed.data[landedOffset - 1]; | |
| 589 | + | else if (direction === "backward" && landedOffset < landed.data.length) character = landed.data[landedOffset]; | |
| 509 | 590 | } | |
| 510 | - | return before[0]; | |
| 591 | + | selection.modify("extend", direction === "forward" ? "backward" : "forward", "character"); | |
| 592 | + | return character; | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | // nextCharacter returns the character after the focus without moving it, or | |
| 596 | + | // undefined at the end of the document. | |
| 597 | + | function nextCharacter(selection) { | |
| 598 | + | const character = characterAtFocus(selection, true); | |
| 599 | + | return character === null ? probeCharacter(selection, "forward") : character; | |
| 511 | 600 | } | |
| 512 | 601 | ||
| 513 | 602 | // previousCharacter returns the character before the focus without moving it. | |
| 514 | 603 | function previousCharacter(selection) { | |
| 515 | - | const before = selection.toString(); | |
| 516 | - | if (before.length === 0 || isBackward(selection)) { | |
| 517 | - | selection.modify("extend", "backward", "character"); | |
| 518 | - | const after = selection.toString(); | |
| 519 | - | if (after === before) return undefined; | |
| 520 | - | selection.modify("extend", "forward", "character"); | |
| 521 | - | return after[0]; | |
| 522 | - | } | |
| 523 | - | return before[before.length - 1]; | |
| 604 | + | const character = characterAtFocus(selection, false); | |
| 605 | + | return character === null ? probeCharacter(selection, "backward") : character; | |
| 524 | 606 | } | |
| 525 | 607 | ||
| 526 | 608 | function isWordCharacter(character) { | |
| @@ -566,21 +648,13 @@ | |||
| 566 | 648 | ||
| 567 | 649 | function firstNonBlank(selection, alter) { | |
| 568 | 650 | selection.modify(alter, "backward", "lineboundary"); | |
| 569 | - | if (alter === "move") { | |
| 570 | - | // Measure with extend, then settle the caret where it ended. | |
| 571 | - | for (let i = 0; i < MAX_MOTION_STEPS; i++) { | |
| 572 | - | const character = nextCharacter(selection); | |
| 573 | - | if (character === undefined || !/[ \t\u00a0]/.test(character)) break; | |
| 574 | - | if (extendByOne(selection, "forward") === 0) break; | |
| 575 | - | } | |
| 576 | - | collapseToFocus(selection); | |
| 577 | - | return; | |
| 578 | - | } | |
| 579 | 651 | for (let i = 0; i < MAX_MOTION_STEPS; i++) { | |
| 580 | 652 | const character = nextCharacter(selection); | |
| 581 | 653 | if (character === undefined || !/[ \t\u00a0]/.test(character)) break; | |
| 582 | 654 | if (extendByOne(selection, "forward") === 0) break; | |
| 583 | 655 | } | |
| 656 | + | // A caret measures with extend, then settles where the measuring ended. | |
| 657 | + | if (alter === "move") collapseToFocus(selection); | |
| 584 | 658 | } | |
| 585 | 659 | ||
| 586 | 660 | // extendToLines grows the selection so both ends sit on line boundaries. | |
| @@ -629,7 +703,11 @@ | |||
| 629 | 703 | ||
| 630 | 704 | // --- Phase transitions --- | |
| 631 | 705 | ||
| 706 | + | // toCaret collapses a selection onto its last selected character, so the | |
| 707 | + | // caret returns to the character that v included instead of drifting one | |
| 708 | + | // position right on every v then Escape. | |
| 632 | 709 | function toCaret(selection) { | |
| 710 | + | if (!selection.isCollapsed && !isBackward(selection)) extendByOne(selection, "backward"); | |
| 633 | 711 | collapseToFocus(selection); | |
| 634 | 712 | setPhase("caret"); | |
| 635 | 713 | revealFocus(selection); | |
| @@ -637,7 +715,11 @@ | |||
| 637 | 715 | ||
| 638 | 716 | function toVisual(selection) { | |
| 639 | 717 | // Include the character under the caret, or the one before at the end. | |
| 640 | - | if (extendByOne(selection, "forward") === 0) extendByOne(selection, "backward"); | |
| 718 | + | if (extendByOne(selection, "forward") === 0 && extendByOne(selection, "backward") !== 0) { | |
| 719 | + | // At the end of the text the last character is selected backward; flip | |
| 720 | + | // it so the focus stays at the end, like a forward selection. | |
| 721 | + | swapEnds(selection); | |
| 722 | + | } | |
| 641 | 723 | setPhase("visual"); | |
| 642 | 724 | revealFocus(selection); | |
| 643 | 725 | } | |
| @@ -768,6 +850,7 @@ | |||
| 768 | 850 | function cleanup() { | |
| 769 | 851 | if (state) { | |
| 770 | 852 | for (const node of [state.root, state.style, state.cursor]) if (node) node.remove(); | |
| 853 | + | if (state.frame) window.cancelAnimationFrame(state.frame); | |
| 771 | 854 | if (state.onViewportChange) { | |
| 772 | 855 | window.removeEventListener("scroll", state.onViewportChange, { capture: true }); | |
| 773 | 856 | window.removeEventListener("resize", state.onViewportChange); | |
| @@ -783,21 +866,30 @@ | |||
| 783 | 866 | if (token) post(token, message); | |
| 784 | 867 | } | |
| 785 | 868 | ||
| 869 | + | // A document leaving the page (navigation, bfcache) takes the overlay along. | |
| 870 | + | window.addEventListener("pagehide", () => { if (state) cleanup(); }); | |
| 871 | + | ||
| 786 | 872 | Object.defineProperty(window, "__dumberVimPage", { | |
| 787 | 873 | configurable: false, | |
| 788 | 874 | enumerable: false, | |
| 789 | 875 | value: Object.freeze({ | |
| 790 | 876 | start(token, request) { | |
| 791 | - | cleanup(); | |
| 792 | - | const color = request.color || "#fbbf24"; | |
| 793 | - | if (request.kind === "yank") { | |
| 794 | - | const text = objectText(request.object, color).trim(); | |
| 795 | - | post(token, text ? { type: "copy", text } : { type: "end" }); | |
| 796 | - | return; | |
| 877 | + | try { | |
| 878 | + | cleanup(); | |
| 879 | + | const color = request.color || "#fbbf24"; | |
| 880 | + | if (request.kind === "yank") { | |
| 881 | + | const text = objectText(request.object, color).trim(); | |
| 882 | + | post(token, text ? { type: "copy", text } : { type: "end" }); | |
| 883 | + | return; | |
| 884 | + | } | |
| 885 | + | state = { kind: request.kind, token, color }; | |
| 886 | + | const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color); | |
| 887 | + | if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" }); | |
| 888 | + | } catch (_) { | |
| 889 | + | // A page quirk must never leave the user's keys captured. | |
| 890 | + | cleanup(); | |
| 891 | + | post(token, { type: "end", reason: "error" }); | |
| 797 | 892 | } | |
| 798 | - | state = { kind: request.kind, token, color }; | |
| 799 | - | const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color); | |
| 800 | - | if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" }); | |
| 801 | 893 | }, | |
| 802 | 894 | key(token, key) { | |
| 803 | 895 | if (!state || state.token !== token) { | |
| @@ -816,9 +908,13 @@ | |||
| 816 | 908 | }, | |
| 817 | 909 | cancel(token) { | |
| 818 | 910 | if (!state || state.token !== token) return; | |
| 819 | - | if (state.kind === "visual" && state.phase !== "hints") { | |
| 820 | - | const selection = window.getSelection(); | |
| 821 | - | if (selection) selection.removeAllRanges(); | |
| 911 | + | try { | |
| 912 | + | if (state.kind === "visual" && state.phase !== "hints") { | |
| 913 | + | const selection = window.getSelection(); | |
| 914 | + | if (selection) selection.removeAllRanges(); | |
| 915 | + | } | |
| 916 | + | } catch (_) { | |
| 917 | + | // Fall through: the overlay must go even when the selection cannot. | |
| 822 | 918 | } | |
| 823 | 919 | cleanup(); | |
| 824 | 920 | }, | |
| @@ -60,16 +60,14 @@ | |||||
| 60 | return results; | 60 | return results; | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| 63 | - | // deepTextNodes yields non-blank text nodes in document order, descending | 63 | + | // lightTextNodes yields non-blank text nodes in document order. It does not |
| 64 | - | // into open shadow roots. | 64 | + | // enter shadow roots on purpose: Chromium reports Selection positions inside |
| 65 | - | function* deepTextNodes(root) { | 65 | + | // a shadow tree relative to its host, so a caret anchored there would sit in |
| 66 | - | const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); | 66 | + | // the wrong place. Link hints and text-object yanks still search shadow DOM. |
| 67 | + | function* lightTextNodes(root) { | |||
| 68 | + | const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); | |||
| 67 | for (let node = walker.currentNode; node; node = walker.nextNode()) { | 69 | for (let node = walker.currentNode; node; node = walker.nextNode()) { | ||
| 68 | - | if (node.nodeType === Node.TEXT_NODE) { | 70 | + | if (node.nodeType === Node.TEXT_NODE && node.data.trim()) yield node; |
| 69 | - | if (node.data.trim()) yield node; | |||
| 70 | - | } else if (node.shadowRoot) { | |||
| 71 | - | yield* deepTextNodes(node.shadowRoot); | |||
| 72 | - | } | |||
| 73 | } | 71 | } | ||
| 74 | } | 72 | } | ||
| 75 | 73 | ||||
| @@ -86,14 +84,22 @@ | |||||
| 86 | }).catch(() => {}); | 84 | }).catch(() => {}); | ||
| 87 | } | 85 | } | ||
| 88 | 86 | ||||
| 87 | + | function onScreen(rect) { | |||
| 88 | + | return rect.bottom > 0 && rect.right > 0 && rect.top < window.innerHeight && rect.left < window.innerWidth; | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // visibleRect returns a client rect of element inside the viewport. The | |||
| 92 | + | // cheap bounding-box test runs first: most elements of a long page are | |||
| 93 | + | // off-screen, and closest/getComputedStyle are the expensive part. | |||
| 89 | function visibleRect(element) { | 94 | function visibleRect(element) { | ||
| 90 | if (!(element instanceof Element) || !element.isConnected) return null; | 95 | if (!(element instanceof Element) || !element.isConnected) return null; | ||
| 96 | + | if (!onScreen(element.getBoundingClientRect())) return null; | |||
| 91 | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return null; | 97 | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return null; | ||
| 92 | const style = window.getComputedStyle(element); | 98 | const style = window.getComputedStyle(element); | ||
| 93 | if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return null; | 99 | if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return null; | ||
| 94 | for (const rect of element.getClientRects()) { | 100 | for (const rect of element.getClientRects()) { | ||
| 95 | if (rect.width < 1 || rect.height < 1) continue; | 101 | if (rect.width < 1 || rect.height < 1) continue; | ||
| 96 | - | if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth) continue; | 102 | + | if (!onScreen(rect)) continue; |
| 97 | return rect; | 103 | return rect; | ||
| 98 | } | 104 | } | ||
| 99 | return null; | 105 | return null; | ||
| @@ -205,15 +211,42 @@ | |||||
| 205 | } | 211 | } | ||
| 206 | } | 212 | } | ||
| 207 | 213 | ||||
| 214 | + | // linkURL resolves the destination of an HTML or SVG anchor. An SVG <a> | |||
| 215 | + | // exposes href as an SVGAnimatedString that is not resolved against the | |||
| 216 | + | // document, so it is resolved here; Go validates the result before opening it. | |||
| 217 | + | function linkURL(element) { | |||
| 218 | + | if (element instanceof HTMLAnchorElement) return element.href; | |||
| 219 | + | const animated = element.href; | |||
| 220 | + | if (!animated || typeof animated.baseVal !== "string" || !animated.baseVal) return ""; | |||
| 221 | + | try { | |||
| 222 | + | return new URL(animated.baseVal, document.baseURI).href; | |||
| 223 | + | } catch (_) { | |||
| 224 | + | return ""; | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | function linkTarget(element) { | |||
| 229 | + | const target = element.target; | |||
| 230 | + | return target && typeof target === "object" ? target.baseVal : target; | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | function clickElement(element) { | |||
| 234 | + | if (typeof element.click === "function") { | |||
| 235 | + | element.click(); | |||
| 236 | + | return; | |||
| 237 | + | } | |||
| 238 | + | element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); | |||
| 239 | + | } | |||
| 240 | + | ||||
| 208 | // activateHint returns the terminal message for the chosen hint. | 241 | // activateHint returns the terminal message for the chosen hint. | ||
| 209 | function activateHint(kind, element) { | 242 | function activateHint(kind, element) { | ||
| 210 | - | const url = element instanceof HTMLAnchorElement ? element.href : ""; | 243 | + | const url = linkURL(element); |
| 211 | if (kind === "hint-yank-url") return url ? { type: "copy", text: url } : { type: "end" }; | 244 | if (kind === "hint-yank-url") return url ? { type: "copy", text: url } : { type: "end" }; | ||
| 212 | - | if (kind === "hint-follow-new" || (url && element.target === "_blank")) { | 245 | + | if (kind === "hint-follow-new" || (url && linkTarget(element) === "_blank")) { |
| 213 | return url ? { type: "open-new", url } : { type: "end" }; | 246 | return url ? { type: "open-new", url } : { type: "end" }; | ||
| 214 | } | 247 | } | ||
| 215 | - | element.focus({ preventScroll: true }); | 248 | + | if (typeof element.focus === "function") element.focus({ preventScroll: true }); |
| 216 | - | if (!isEditable(element)) element.click(); | 249 | + | if (!isEditable(element)) clickElement(element); |
| 217 | return { type: "end" }; | 250 | return { type: "end" }; | ||
| 218 | } | 251 | } | ||
| 219 | 252 | ||||
| @@ -309,19 +342,30 @@ | |||||
| 309 | return null; | 342 | return null; | ||
| 310 | } | 343 | } | ||
| 311 | 344 | ||||
| 312 | - | // collectTextAnchors lists visible text starts in document order. perBlock | 345 | + | // visibleTextAnchors yields visible text starts in document order. A text |
| 313 | - | // keeps the first anchor of each block; minLength skips short texts. | 346 | + | // node whose parent element is off-screen cannot be visible, so that cheap |
| 314 | - | function collectTextAnchors({ perBlock, minLength, limit }) { | 347 | + | // check (cached per element) runs before the range measurements. |
| 315 | - | const anchors = []; | 348 | + | function* visibleTextAnchors(minLength) { |
| 316 | - | const blocks = new Set(); | 349 | + | const onScreenElements = new Map(); |
| 317 | - | for (const node of deepTextNodes(document.body || document.documentElement)) { | 350 | + | for (const node of lightTextNodes(document.body || document.documentElement)) { |
| 318 | const element = node.parentElement; | 351 | const element = node.parentElement; | ||
| 319 | if (!element || SKIP_TEXT_PARENTS.test(element.tagName) || isEditable(element)) continue; | 352 | if (!element || SKIP_TEXT_PARENTS.test(element.tagName) || isEditable(element)) continue; | ||
| 320 | if (node.data.trim().length < minLength) continue; | 353 | if (node.data.trim().length < minLength) continue; | ||
| 354 | + | if (!onScreenElements.has(element)) onScreenElements.set(element, onScreen(element.getBoundingClientRect())); | |||
| 355 | + | if (!onScreenElements.get(element)) continue; | |||
| 321 | const anchor = firstVisibleAnchor(node, element); | 356 | const anchor = firstVisibleAnchor(node, element); | ||
| 322 | - | if (!anchor) continue; | 357 | + | if (anchor) yield anchor; |
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | // collectTextAnchors lists visible text starts in document order. perBlock | |||
| 362 | + | // keeps the first anchor of each block. | |||
| 363 | + | function collectTextAnchors({ perBlock, limit }) { | |||
| 364 | + | const anchors = []; | |||
| 365 | + | const blocks = new Set(); | |||
| 366 | + | for (const anchor of visibleTextAnchors(1)) { | |||
| 323 | if (perBlock) { | 367 | if (perBlock) { | ||
| 324 | - | const block = blockOf(element); | 368 | + | const block = blockOf(anchor.element); |
| 325 | if (blocks.has(block)) continue; | 369 | if (blocks.has(block)) continue; | ||
| 326 | blocks.add(block); | 370 | blocks.add(block); | ||
| 327 | } | 371 | } | ||
| @@ -333,11 +377,14 @@ | |||||
| 333 | 377 | ||||
| 334 | // Esc during the text-anchor hints: Vimium's heuristic picks the first | 378 | // Esc during the text-anchor hints: Vimium's heuristic picks the first | ||
| 335 | // visible text of at least LONG_TEXT_LENGTH characters (shorter ones are | 379 | // visible text of at least LONG_TEXT_LENGTH characters (shorter ones are | ||
| 336 | - | // likely banners); any visible text is the fallback. | 380 | + | // likely banners); any visible text is the fallback. One pass finds both. |
| 337 | function fallbackAnchor() { | 381 | function fallbackAnchor() { | ||
| 338 | - | const long = collectTextAnchors({ perBlock: false, minLength: LONG_TEXT_LENGTH, limit: 1 }); | 382 | + | let first = null; |
| 339 | - | if (long.length > 0) return long[0]; | 383 | + | for (const anchor of visibleTextAnchors(1)) { |
| 340 | - | return collectTextAnchors({ perBlock: false, minLength: 1, limit: 1 })[0] || null; | 384 | + | if (anchor.node.data.trim().length >= LONG_TEXT_LENGTH) return anchor; |
| 385 | + | if (!first) first = anchor; | |||
| 386 | + | } | |||
| 387 | + | return first; | |||
| 341 | } | 388 | } | ||
| 342 | 389 | ||||
| 343 | function placeCaretAtFallback() { | 390 | function placeCaretAtFallback() { | ||
| @@ -405,7 +452,15 @@ | |||||
| 405 | cursor.animate([{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0.15 }], { duration: 1000, iterations: Infinity }); | 452 | cursor.animate([{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0.15 }], { duration: 1000, iterations: Infinity }); | ||
| 406 | } | 453 | } | ||
| 407 | state.cursor = cursor; | 454 | state.cursor = cursor; | ||
| 408 | - | state.onViewportChange = () => updateCursor(window.getSelection()); | 455 | + | // Scroll and resize fire at frame rate or faster; repaint once per frame. |
| 456 | + | state.onViewportChange = () => { | |||
| 457 | + | if (!state || state.frame) return; | |||
| 458 | + | state.frame = window.requestAnimationFrame(() => { | |||
| 459 | + | if (!state) return; | |||
| 460 | + | state.frame = 0; | |||
| 461 | + | updateCursor(window.getSelection()); | |||
| 462 | + | }); | |||
| 463 | + | }; | |||
| 409 | window.addEventListener("scroll", state.onViewportChange, { capture: true, passive: true }); | 464 | window.addEventListener("scroll", state.onViewportChange, { capture: true, passive: true }); | ||
| 410 | window.addEventListener("resize", state.onViewportChange, { passive: true }); | 465 | window.addEventListener("resize", state.onViewportChange, { passive: true }); | ||
| 411 | } | 466 | } | ||
| @@ -464,7 +519,7 @@ | |||||
| 464 | setPhase("visual"); | 519 | setPhase("visual"); | ||
| 465 | return true; | 520 | return true; | ||
| 466 | } | 521 | } | ||
| 467 | - | const anchors = collectTextAnchors({ perBlock: true, minLength: 1, limit: HINT_CHARS.length * HINT_CHARS.length }); | 522 | + | const anchors = collectTextAnchors({ perBlock: true, limit: HINT_CHARS.length * HINT_CHARS.length }); |
| 468 | if (anchors.length === 0) return false; | 523 | if (anchors.length === 0) return false; | ||
| 469 | buildHintOverlay(anchors, color, true); | 524 | buildHintOverlay(anchors, color, true); | ||
| 470 | setPhase("hints"); | 525 | setPhase("hints"); | ||
| @@ -489,38 +544,65 @@ | |||||
| 489 | selection.collapse(selection.focusNode, selection.focusOffset); | 544 | selection.collapse(selection.focusNode, selection.focusOffset); | ||
| 490 | } | 545 | } | ||
| 491 | 546 | ||||
| 492 | - | // extendByOne extends the focus one character and reports how the selected | 547 | + | function focusMoved(selection, node, offset) { |
| 493 | - | // text length changed; 0 means the focus could not move. | 548 | + | return selection.focusNode !== node || selection.focusOffset !== offset; |
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | // extendByOne extends the focus one character and reports whether it moved | |||
| 552 | + | // (1) or could not (0). It compares focus positions: reading the whole | |||
| 553 | + | // selection's text per step made long selections quadratic. | |||
| 494 | function extendByOne(selection, direction) { | 554 | function extendByOne(selection, direction) { | ||
| 495 | - | const length = selection.toString().length; | 555 | + | const node = selection.focusNode; |
| 556 | + | const offset = selection.focusOffset; | |||
| 496 | selection.modify("extend", direction, "character"); | 557 | selection.modify("extend", direction, "character"); | ||
| 497 | - | return selection.toString().length - length; | 558 | + | return focusMoved(selection, node, offset) ? 1 : 0; |
| 498 | } | 559 | } | ||
| 499 | 560 | ||||
| 500 | - | // nextCharacter returns the character after the focus without moving it. | 561 | + | // characterAtFocus reads the character next to the focus from its text node. |
| 501 | - | function nextCharacter(selection) { | 562 | + | // It returns null when the focus is not inside a text node or sits on its |
| 502 | - | const before = selection.toString(); | 563 | + | // boundary, where the neighbor lives in another node. |
| 503 | - | if (before.length === 0 || !isBackward(selection)) { | 564 | + | function characterAtFocus(selection, forward) { |
| 504 | - | selection.modify("extend", "forward", "character"); | 565 | + | const node = selection.focusNode; |
| 505 | - | const after = selection.toString(); | 566 | + | const offset = selection.focusOffset; |
| 506 | - | if (after === before) return undefined; | 567 | + | if (!node || node.nodeType !== Node.TEXT_NODE) return null; |
| 507 | - | selection.modify("extend", "backward", "character"); | 568 | + | const data = node.data; |
| 508 | - | return after[after.length - 1]; | 569 | + | if (forward) return offset < data.length ? String.fromCodePoint(data.codePointAt(offset)) : null; |
| 570 | + | if (offset <= 0) return null; | |||
| 571 | + | const unit = data.charCodeAt(offset - 1); | |||
| 572 | + | const paired = offset > 1 && unit >= 0xdc00 && unit <= 0xdfff; | |||
| 573 | + | return paired ? data.slice(offset - 2, offset) : data[offset - 1]; | |||
| 574 | + | } | |||
| 575 | + | ||||
| 576 | + | // probeCharacter finds the character across a node boundary by stepping the | |||
| 577 | + | // focus over it and back. Landing on a text node boundary or outside a text | |||
| 578 | + | // node means a block boundary was crossed, which counts as a line break. | |||
| 579 | + | function probeCharacter(selection, direction) { | |||
| 580 | + | const node = selection.focusNode; | |||
| 581 | + | const offset = selection.focusOffset; | |||
| 582 | + | selection.modify("extend", direction, "character"); | |||
| 583 | + | if (!focusMoved(selection, node, offset)) return undefined; | |||
| 584 | + | const landed = selection.focusNode; | |||
| 585 | + | const landedOffset = selection.focusOffset; | |||
| 586 | + | let character = "\n"; | |||
| 587 | + | if (landed && landed.nodeType === Node.TEXT_NODE) { | |||
| 588 | + | if (direction === "forward" && landedOffset > 0) character = landed.data[landedOffset - 1]; | |||
| 589 | + | else if (direction === "backward" && landedOffset < landed.data.length) character = landed.data[landedOffset]; | |||
| 509 | } | 590 | } | ||
| 510 | - | return before[0]; | 591 | + | selection.modify("extend", direction === "forward" ? "backward" : "forward", "character"); |
| 592 | + | return character; | |||
| 593 | + | } | |||
| 594 | + | ||||
| 595 | + | // nextCharacter returns the character after the focus without moving it, or | |||
| 596 | + | // undefined at the end of the document. | |||
| 597 | + | function nextCharacter(selection) { | |||
| 598 | + | const character = characterAtFocus(selection, true); | |||
| 599 | + | return character === null ? probeCharacter(selection, "forward") : character; | |||
| 511 | } | 600 | } | ||
| 512 | 601 | ||||
| 513 | // previousCharacter returns the character before the focus without moving it. | 602 | // previousCharacter returns the character before the focus without moving it. | ||
| 514 | function previousCharacter(selection) { | 603 | function previousCharacter(selection) { | ||
| 515 | - | const before = selection.toString(); | 604 | + | const character = characterAtFocus(selection, false); |
| 516 | - | if (before.length === 0 || isBackward(selection)) { | 605 | + | return character === null ? probeCharacter(selection, "backward") : character; |
| 517 | - | selection.modify("extend", "backward", "character"); | |||
| 518 | - | const after = selection.toString(); | |||
| 519 | - | if (after === before) return undefined; | |||
| 520 | - | selection.modify("extend", "forward", "character"); | |||
| 521 | - | return after[0]; | |||
| 522 | - | } | |||
| 523 | - | return before[before.length - 1]; | |||
| 524 | } | 606 | } | ||
| 525 | 607 | ||||
| 526 | function isWordCharacter(character) { | 608 | function isWordCharacter(character) { | ||
| @@ -566,21 +648,13 @@ | |||||
| 566 | 648 | ||||
| 567 | function firstNonBlank(selection, alter) { | 649 | function firstNonBlank(selection, alter) { | ||
| 568 | selection.modify(alter, "backward", "lineboundary"); | 650 | selection.modify(alter, "backward", "lineboundary"); | ||
| 569 | - | if (alter === "move") { | |||
| 570 | - | // Measure with extend, then settle the caret where it ended. | |||
| 571 | - | for (let i = 0; i < MAX_MOTION_STEPS; i++) { | |||
| 572 | - | const character = nextCharacter(selection); | |||
| 573 | - | if (character === undefined || !/[ \t\u00a0]/.test(character)) break; | |||
| 574 | - | if (extendByOne(selection, "forward") === 0) break; | |||
| 575 | - | } | |||
| 576 | - | collapseToFocus(selection); | |||
| 577 | - | return; | |||
| 578 | - | } | |||
| 579 | for (let i = 0; i < MAX_MOTION_STEPS; i++) { | 651 | for (let i = 0; i < MAX_MOTION_STEPS; i++) { | ||
| 580 | const character = nextCharacter(selection); | 652 | const character = nextCharacter(selection); | ||
| 581 | if (character === undefined || !/[ \t\u00a0]/.test(character)) break; | 653 | if (character === undefined || !/[ \t\u00a0]/.test(character)) break; | ||
| 582 | if (extendByOne(selection, "forward") === 0) break; | 654 | if (extendByOne(selection, "forward") === 0) break; | ||
| 583 | } | 655 | } | ||
| 656 | + | // A caret measures with extend, then settles where the measuring ended. | |||
| 657 | + | if (alter === "move") collapseToFocus(selection); | |||
| 584 | } | 658 | } | ||
| 585 | 659 | ||||
| 586 | // extendToLines grows the selection so both ends sit on line boundaries. | 660 | // extendToLines grows the selection so both ends sit on line boundaries. | ||
| @@ -629,7 +703,11 @@ | |||||
| 629 | 703 | ||||
| 630 | // --- Phase transitions --- | 704 | // --- Phase transitions --- | ||
| 631 | 705 | ||||
| 706 | + | // toCaret collapses a selection onto its last selected character, so the | |||
| 707 | + | // caret returns to the character that v included instead of drifting one | |||
| 708 | + | // position right on every v then Escape. | |||
| 632 | function toCaret(selection) { | 709 | function toCaret(selection) { | ||
| 710 | + | if (!selection.isCollapsed && !isBackward(selection)) extendByOne(selection, "backward"); | |||
| 633 | collapseToFocus(selection); | 711 | collapseToFocus(selection); | ||
| 634 | setPhase("caret"); | 712 | setPhase("caret"); | ||
| 635 | revealFocus(selection); | 713 | revealFocus(selection); | ||
| @@ -637,7 +715,11 @@ | |||||
| 637 | 715 | ||||
| 638 | function toVisual(selection) { | 716 | function toVisual(selection) { | ||
| 639 | // Include the character under the caret, or the one before at the end. | 717 | // Include the character under the caret, or the one before at the end. | ||
| 640 | - | if (extendByOne(selection, "forward") === 0) extendByOne(selection, "backward"); | 718 | + | if (extendByOne(selection, "forward") === 0 && extendByOne(selection, "backward") !== 0) { |
| 719 | + | // At the end of the text the last character is selected backward; flip | |||
| 720 | + | // it so the focus stays at the end, like a forward selection. | |||
| 721 | + | swapEnds(selection); | |||
| 722 | + | } | |||
| 641 | setPhase("visual"); | 723 | setPhase("visual"); | ||
| 642 | revealFocus(selection); | 724 | revealFocus(selection); | ||
| 643 | } | 725 | } | ||
| @@ -768,6 +850,7 @@ | |||||
| 768 | function cleanup() { | 850 | function cleanup() { | ||
| 769 | if (state) { | 851 | if (state) { | ||
| 770 | for (const node of [state.root, state.style, state.cursor]) if (node) node.remove(); | 852 | for (const node of [state.root, state.style, state.cursor]) if (node) node.remove(); | ||
| 853 | + | if (state.frame) window.cancelAnimationFrame(state.frame); | |||
| 771 | if (state.onViewportChange) { | 854 | if (state.onViewportChange) { | ||
| 772 | window.removeEventListener("scroll", state.onViewportChange, { capture: true }); | 855 | window.removeEventListener("scroll", state.onViewportChange, { capture: true }); | ||
| 773 | window.removeEventListener("resize", state.onViewportChange); | 856 | window.removeEventListener("resize", state.onViewportChange); | ||
| @@ -783,21 +866,30 @@ | |||||
| 783 | if (token) post(token, message); | 866 | if (token) post(token, message); | ||
| 784 | } | 867 | } | ||
| 785 | 868 | ||||
| 869 | + | // A document leaving the page (navigation, bfcache) takes the overlay along. | |||
| 870 | + | window.addEventListener("pagehide", () => { if (state) cleanup(); }); | |||
| 871 | + | ||||
| 786 | Object.defineProperty(window, "__dumberVimPage", { | 872 | Object.defineProperty(window, "__dumberVimPage", { | ||
| 787 | configurable: false, | 873 | configurable: false, | ||
| 788 | enumerable: false, | 874 | enumerable: false, | ||
| 789 | value: Object.freeze({ | 875 | value: Object.freeze({ | ||
| 790 | start(token, request) { | 876 | start(token, request) { | ||
| 791 | - | cleanup(); | 877 | + | try { |
| 792 | - | const color = request.color || "#fbbf24"; | 878 | + | cleanup(); |
| 793 | - | if (request.kind === "yank") { | 879 | + | const color = request.color || "#fbbf24"; |
| 794 | - | const text = objectText(request.object, color).trim(); | 880 | + | if (request.kind === "yank") { |
| 795 | - | post(token, text ? { type: "copy", text } : { type: "end" }); | 881 | + | const text = objectText(request.object, color).trim(); |
| 796 | - | return; | 882 | + | post(token, text ? { type: "copy", text } : { type: "end" }); |
| 883 | + | return; | |||
| 884 | + | } | |||
| 885 | + | state = { kind: request.kind, token, color }; | |||
| 886 | + | const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color); | |||
| 887 | + | if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" }); | |||
| 888 | + | } catch (_) { | |||
| 889 | + | // A page quirk must never leave the user's keys captured. | |||
| 890 | + | cleanup(); | |||
| 891 | + | post(token, { type: "end", reason: "error" }); | |||
| 797 | } | 892 | } | ||
| 798 | - | state = { kind: request.kind, token, color }; | |||
| 799 | - | const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color); | |||
| 800 | - | if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" }); | |||
| 801 | }, | 893 | }, | ||
| 802 | key(token, key) { | 894 | key(token, key) { | ||
| 803 | if (!state || state.token !== token) { | 895 | if (!state || state.token !== token) { | ||
| @@ -816,9 +908,13 @@ | |||||
| 816 | }, | 908 | }, | ||
| 817 | cancel(token) { | 909 | cancel(token) { | ||
| 818 | if (!state || state.token !== token) return; | 910 | if (!state || state.token !== token) return; | ||
| 819 | - | if (state.kind === "visual" && state.phase !== "hints") { | 911 | + | try { |
| 820 | - | const selection = window.getSelection(); | 912 | + | if (state.kind === "visual" && state.phase !== "hints") { |
| 821 | - | if (selection) selection.removeAllRanges(); | 913 | + | const selection = window.getSelection(); |
| 914 | + | if (selection) selection.removeAllRanges(); | |||
| 915 | + | } | |||
| 916 | + | } catch (_) { | |||
| 917 | + | // Fall through: the overlay must go even when the selection cannot. | |||
| 822 | } | 918 | } | ||
| 823 | cleanup(); | 919 | cleanup(); | ||
| 824 | }, | 920 | }, | ||
M internal/infrastructure/cef/vim_page_runtime_test.go +267 -22
| @@ -3,6 +3,7 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | _ "embed" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "os" | |
| 6 | 7 | "os/exec" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "testing" | |
| @@ -25,6 +26,9 @@ func runVimPageRuntime(t *testing.T, driver string) []map[string]any { | |||
| 25 | 26 | t.Helper() | |
| 26 | 27 | node, err := exec.LookPath("node") | |
| 27 | 28 | if err != nil { | |
| 29 | + | if os.Getenv("DUMBER_REQUIRE_NODE") == "1" { | |
| 30 | + | t.Fatal("node is required (DUMBER_REQUIRE_NODE=1) to execute the vim page runtime") | |
| 31 | + | } | |
| 28 | 32 | t.Skip("node is required to execute the vim page runtime") | |
| 29 | 33 | } | |
| 30 | 34 | script := vimPageRuntimeHarness + "\n" + vimPageRuntimeTemplateJS + "\n" + driver + | |
| @@ -69,16 +73,6 @@ func modeSequence(posted []map[string]any) []any { | |||
| 69 | 73 | return out | |
| 70 | 74 | } | |
| 71 | 75 | ||
| 72 | - | func TestVimPageRuntimeStartDoesNotEndImmediately(t *testing.T) { | |
| 73 | - | posted := runVimPageRuntime(t, ` | |
| 74 | - | window.__dumberVimPage.start("tok", { kind: "visual" }); | |
| 75 | - | window.__dumberVimPage.start("hint", { kind: "hint-follow" }); | |
| 76 | - | `) | |
| 77 | - | for _, message := range bridgeMessages(posted) { | |
| 78 | - | require.NotEqual(t, "end", message["type"], "starting with visible content never ends the interaction") | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | ||
| 82 | 76 | func TestVimPageRuntimeVisualStartsWithTextAnchorHints(t *testing.T) { | |
| 83 | 77 | posted := runVimPageRuntime(t, ` | |
| 84 | 78 | window.__dumberVimPage.start("tok", { kind: "visual", color: "#fbbf24" }); | |
| @@ -213,10 +207,6 @@ func TestVimPageRuntimeCaretMotionsMoveWithoutSelecting(t *testing.T) { | |||
| 213 | 207 | {"k", "a", []string{"l", "k"}, "0:1"}, | |
| 214 | 208 | {"G", "s", []string{"G"}, "3:18"}, | |
| 215 | 209 | {"gg", "a", []string{"G", "g", "g"}, "0:0"}, | |
| 216 | - | {"}", "s", []string{"}"}, "0:19"}, | |
| 217 | - | {"{", "a", []string{"l", "{"}, "1:0"}, | |
| 218 | - | {")", "s", []string{")"}, "0:19"}, | |
| 219 | - | {"(", "s", []string{"$", "("}, "0:0"}, | |
| 220 | 210 | } | |
| 221 | 211 | for _, tt := range tests { | |
| 222 | 212 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -236,6 +226,42 @@ S("end"); | |||
| 236 | 226 | } | |
| 237 | 227 | } | |
| 238 | 228 | ||
| 229 | + | // Sentence and paragraph motions are native Selection.modify granularities | |
| 230 | + | // the DOM shim cannot emulate, so the contract checked here is the call: the | |
| 231 | + | // alter mode follows the phase and the direction and granularity are exact. | |
| 232 | + | func TestVimPageRuntimeNativeGranularityMotions(t *testing.T) { | |
| 233 | + | tests := []struct { | |
| 234 | + | key string | |
| 235 | + | direction string | |
| 236 | + | granularity string | |
| 237 | + | }{ | |
| 238 | + | {"(", "backward", "sentence"}, | |
| 239 | + | {")", "forward", "sentence"}, | |
| 240 | + | {"{", "backward", "paragraph"}, | |
| 241 | + | {"}", "forward", "paragraph"}, | |
| 242 | + | {"0", "backward", "lineboundary"}, | |
| 243 | + | {"$", "forward", "lineboundary"}, | |
| 244 | + | {"G", "forward", "documentboundary"}, | |
| 245 | + | } | |
| 246 | + | for _, tt := range tests { | |
| 247 | + | for _, phase := range []struct{ name, setup, alter string }{ | |
| 248 | + | {"caret", "", "move"}, | |
| 249 | + | {"visual", `keys("tok", "v");`, "extend"}, | |
| 250 | + | } { | |
| 251 | + | t.Run(phase.name+" "+tt.key, func(t *testing.T) { | |
| 252 | + | posted := runVimPageRuntime(t, ` | |
| 253 | + | startCaret("tok", "s"); | |
| 254 | + | `+phase.setup+` | |
| 255 | + | modifyCalls.length = 0; | |
| 256 | + | keys("tok", `+jsString(tt.key)+`); | |
| 257 | + | posted.push({ n: "calls", calls: modifyCalls }); | |
| 258 | + | `) | |
| 259 | + | require.Equal(t, []any{[]any{phase.alter, tt.direction, tt.granularity}}, named(t, posted, "calls")["calls"]) | |
| 260 | + | }) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 239 | 265 | func TestVimPageRuntimeWordMotionsSkipPunctuation(t *testing.T) { | |
| 240 | 266 | posted := runVimPageRuntime(t, ` | |
| 241 | 267 | startCaret("tok", "d"); | |
| @@ -271,9 +297,11 @@ startCaret("tok", "s"); | |||
| 271 | 297 | const bar = cursor(); | |
| 272 | 298 | first.p.rect = rect(10, -300, 600, 20); // the page scrolled the paragraph away | |
| 273 | 299 | fire("scroll"); | |
| 300 | + | flushFrames(); | |
| 274 | 301 | const hiddenWhileOffscreen = bar.style.display; | |
| 275 | 302 | first.p.rect = rect(10, 100, 600, 20); | |
| 276 | 303 | fire("scroll"); | |
| 304 | + | flushFrames(); | |
| 277 | 305 | posted.push({ n: "follow", hidden: hiddenWhileOffscreen, top: bar.style.top, display: bar.style.display }); | |
| 278 | 306 | keys("tok", "<Escape>"); | |
| 279 | 307 | posted.push({ n: "cleanup", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, scrollListeners: (listeners.scroll || []).length, resize: (listeners.resize || []).length, selection: selection.rangeCount }); | |
| @@ -316,15 +344,15 @@ S("afterReturn"); | |||
| 316 | 344 | ||
| 317 | 345 | after := named(t, posted, "after") | |
| 318 | 346 | require.Empty(t, after["text"], "y returns to a collapsed caret") | |
| 319 | - | require.Equal(t, "0:6", after["focus"], "at the focus end of the selection") | |
| 347 | + | require.Equal(t, "0:5", after["focus"], "on the last selected character, where v put the caret") | |
| 320 | 348 | require.Equal(t, after["focus"], after["anchor"]) | |
| 321 | - | require.Equal(t, "0:7", named(t, posted, "moved")["focus"], "the caret keeps moving after a yank") | |
| 322 | - | require.Equal(t, "0:7", named(t, posted, "caretYank")["focus"], "y in caret mode does nothing") | |
| 349 | + | require.Equal(t, "0:6", named(t, posted, "moved")["focus"], "the caret keeps moving after a yank") | |
| 350 | + | require.Equal(t, "0:6", named(t, posted, "caretYank")["focus"], "y in caret mode does nothing") | |
| 323 | 351 | ||
| 324 | 352 | copies := bridgeMessagesOfType(posted, "copy") | |
| 325 | 353 | require.Len(t, copies, 2, "y in caret mode copies nothing; Enter chains a second copy") | |
| 326 | 354 | require.Equal(t, map[string]any{"token": "tok", "type": "copy", "text": "Alpha "}, copies[0]) | |
| 327 | - | require.Equal(t, "ravo", copies[1]["text"], "v includes the character under the caret and e extends to the word end") | |
| 355 | + | require.Equal(t, "bravo", copies[1]["text"], "v includes the character under the caret and e extends to the word end") | |
| 328 | 356 | require.Empty(t, named(t, posted, "afterReturn")["text"]) | |
| 329 | 357 | require.Empty(t, bridgeMessagesOfType(posted, "end"), "copying never ends the interaction") | |
| 330 | 358 | require.Equal(t, []any{"hints", "caret", "visual", "caret", "visual", "caret"}, modeSequence(posted)) | |
| @@ -482,7 +510,7 @@ vpage.start("d", { kind: "hint-follow" }); | |||
| 482 | 510 | vpage.key("d", "<Escape>"); | |
| 483 | 511 | `) | |
| 484 | 512 | require.ElementsMatch(t, []any{"s", "a"}, named(t, posted, "labels")["labels"], "two visible links get one-letter labels") | |
| 485 | - | require.Equal(t, "https://example.com/two", named2(posted, "clicked"), "follow clicks the chosen link") | |
| 513 | + | require.Equal(t, "https://example.com/two", recordedField(posted, "clicked"), "follow clicks the chosen link") | |
| 486 | 514 | messages := bridgeMessages(posted) | |
| 487 | 515 | require.Equal(t, []map[string]any{ | |
| 488 | 516 | {"token": "a", "type": "end"}, | |
| @@ -493,8 +521,8 @@ vpage.key("d", "<Escape>"); | |||
| 493 | 521 | require.NotContains(t, modeSequence(posted), "caret", "link hints never report caret modes") | |
| 494 | 522 | } | |
| 495 | 523 | ||
| 496 | - | // named2 returns a field recorded by a harness side effect (such as a click). | |
| 497 | - | func named2(posted []map[string]any, key string) any { | |
| 524 | + | // recordedField returns a field recorded by a harness side effect (such as a click). | |
| 525 | + | func recordedField(posted []map[string]any, key string) any { | |
| 498 | 526 | for _, entry := range posted { | |
| 499 | 527 | if value, ok := entry[key]; ok { | |
| 500 | 528 | return value | |
| @@ -505,7 +533,7 @@ func named2(posted []map[string]any, key string) any { | |||
| 505 | 533 | ||
| 506 | 534 | func TestVimPageRuntimeYankParagraph(t *testing.T) { | |
| 507 | 535 | posted := runVimPageRuntime(t, ` | |
| 508 | - | p.innerText = TEXT; | |
| 536 | + | first.p.innerText = first.text.data; | |
| 509 | 537 | window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" }); | |
| 510 | 538 | `) | |
| 511 | 539 | messages := bridgeMessages(posted) | |
| @@ -523,3 +551,220 @@ posted.push({ n: "live", selection: selection.rangeCount }); | |||
| 523 | 551 | require.Equal(t, []map[string]any{{"token": "stale", "type": "end"}}, bridgeMessagesOfType(posted, "end")) | |
| 524 | 552 | require.EqualValues(t, 1, named(t, posted, "live")["selection"], "a stale key must not disturb the live interaction") | |
| 525 | 553 | } | |
| 554 | + | ||
| 555 | + | // Entering visual selects the character under the caret and leaving it must | |
| 556 | + | // put the caret back on that character; the caret used to move one position | |
| 557 | + | // right on every v then Escape. | |
| 558 | + | func TestVimPageRuntimeVisualRoundTripDoesNotDriftCaret(t *testing.T) { | |
| 559 | + | posted := runVimPageRuntime(t, ` | |
| 560 | + | startCaret("tok", "s"); | |
| 561 | + | keys("tok", "l", "l"); | |
| 562 | + | S("start"); | |
| 563 | + | for (let i = 0; i < 5; i++) keys("tok", "v", "<Escape>"); | |
| 564 | + | S("afterEscapes"); | |
| 565 | + | for (let i = 0; i < 3; i++) keys("tok", "v", "v"); | |
| 566 | + | S("afterToggles"); | |
| 567 | + | for (let i = 0; i < 3; i++) keys("tok", "v", "y"); | |
| 568 | + | S("afterYanks"); | |
| 569 | + | keys("tok", "V", "V"); | |
| 570 | + | S("afterLine"); | |
| 571 | + | `) | |
| 572 | + | start := named(t, posted, "start") | |
| 573 | + | require.Equal(t, "0:2", start["focus"]) | |
| 574 | + | for _, name := range []string{"afterEscapes", "afterToggles", "afterYanks"} { | |
| 575 | + | after := named(t, posted, name) | |
| 576 | + | require.Equal(t, start["focus"], after["focus"], name) | |
| 577 | + | require.Equal(t, start["anchor"], after["anchor"], name) | |
| 578 | + | require.Empty(t, after["text"], name) | |
| 579 | + | } | |
| 580 | + | copies := bridgeMessagesOfType(posted, "copy") | |
| 581 | + | require.Len(t, copies, 3) | |
| 582 | + | for _, c := range copies { | |
| 583 | + | require.Equal(t, "p", c["text"], "each yank copies the character under the caret") | |
| 584 | + | } | |
| 585 | + | require.Equal(t, "0:18", named(t, posted, "afterLine")["focus"], "V then V returns to the end of the line") | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | func TestVimPageRuntimeVisualRoundTripAtEndOfTextKeepsCaret(t *testing.T) { | |
| 589 | + | posted := runVimPageRuntime(t, ` | |
| 590 | + | startCaret("tok", "d"); | |
| 591 | + | keys("tok", "G"); | |
| 592 | + | S("end"); | |
| 593 | + | keys("tok", "v"); | |
| 594 | + | S("visual"); | |
| 595 | + | keys("tok", "<Escape>"); | |
| 596 | + | S("back"); | |
| 597 | + | `) | |
| 598 | + | end := named(t, posted, "end") | |
| 599 | + | require.Equal(t, "3:18", end["focus"]) | |
| 600 | + | visual := named(t, posted, "visual") | |
| 601 | + | require.Equal(t, "d", visual["text"], "at the end of the text v selects the last character") | |
| 602 | + | require.Equal(t, "3:18", visual["focus"]) | |
| 603 | + | require.Equal(t, "3:17", named(t, posted, "back")["focus"], "the caret returns onto the last character") | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | // Motions must not read the whole selection text per step: that made long | |
| 607 | + | // selections quadratic. | |
| 608 | + | func TestVimPageRuntimeMotionsDoNotReadSelectionText(t *testing.T) { | |
| 609 | + | posted := runVimPageRuntime(t, ` | |
| 610 | + | startCaret("tok", "s"); | |
| 611 | + | keys("tok", "v"); | |
| 612 | + | toStringCalls = 0; | |
| 613 | + | keys("tok", "w", "w", "e", "b", "$", "^"); | |
| 614 | + | posted.push({ n: "reads", calls: toStringCalls }); | |
| 615 | + | S("sel"); | |
| 616 | + | `) | |
| 617 | + | require.EqualValues(t, 0, named(t, posted, "reads")["calls"]) | |
| 618 | + | require.NotEmpty(t, named(t, posted, "sel")["focus"]) | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | func TestVimPageRuntimeStartAndCancelFailuresReleaseCapture(t *testing.T) { | |
| 622 | + | posted := runVimPageRuntime(t, ` | |
| 623 | + | const realGetSelection = window.getSelection; | |
| 624 | + | window.getSelection = () => { throw new Error("page quirk"); }; | |
| 625 | + | window.__dumberVimPage.start("bad", { kind: "visual" }); | |
| 626 | + | window.getSelection = realGetSelection; | |
| 627 | + | posted.push({ n: "afterStart", hints: hintLabels().length, caret: cursor() === null }); | |
| 628 | + | ||
| 629 | + | startCaret("tok", "s"); | |
| 630 | + | selection.removeAllRanges = () => { throw new Error("page quirk"); }; | |
| 631 | + | vp().cancel("tok"); | |
| 632 | + | posted.push({ n: "afterCancel", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, listeners: (listeners.scroll || []).length }); | |
| 633 | + | `) | |
| 634 | + | ends := bridgeMessagesOfType(posted, "end") | |
| 635 | + | require.Equal(t, []map[string]any{{"token": "bad", "type": "end", "reason": "error"}}, ends, "a failing start reports the end") | |
| 636 | + | afterStart := named(t, posted, "afterStart") | |
| 637 | + | require.EqualValues(t, 0, afterStart["hints"]) | |
| 638 | + | cancel := named(t, posted, "afterCancel") | |
| 639 | + | require.Equal(t, true, cancel["caret"], "cancel removes the overlay even when the selection cannot be cleared") | |
| 640 | + | require.EqualValues(t, 0, cancel["styles"]) | |
| 641 | + | require.EqualValues(t, 0, cancel["listeners"]) | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | func TestVimPageRuntimePageHideCleansUp(t *testing.T) { | |
| 645 | + | posted := runVimPageRuntime(t, ` | |
| 646 | + | startCaret("tok", "s"); | |
| 647 | + | fire("pagehide"); | |
| 648 | + | posted.push({ n: "gone", caret: cursor() === null, scroll: (listeners.scroll || []).length, frames: frames.size }); | |
| 649 | + | fire("pagehide"); // without an interaction it is a no-op | |
| 650 | + | `) | |
| 651 | + | gone := named(t, posted, "gone") | |
| 652 | + | require.Equal(t, true, gone["caret"]) | |
| 653 | + | require.EqualValues(t, 0, gone["scroll"]) | |
| 654 | + | require.Empty(t, bridgeMessagesOfType(posted, "end"), "the document is leaving; nothing is reported") | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | // SVG anchors expose href as an SVGAnimatedString and have no click(). | |
| 658 | + | func TestVimPageRuntimeSVGLinks(t *testing.T) { | |
| 659 | + | posted := runVimPageRuntime(t, ` | |
| 660 | + | const svg = body.appendChild(new SVGAElement_("../rel.html")); | |
| 661 | + | svg.rect = rect(150, 120, 40, 20); | |
| 662 | + | const w = window.__dumberVimPage; | |
| 663 | + | w.start("y", { kind: "hint-yank-url" }); | |
| 664 | + | w.key("y", "d"); | |
| 665 | + | w.start("n", { kind: "hint-follow-new" }); | |
| 666 | + | w.key("n", "d"); | |
| 667 | + | w.start("f", { kind: "hint-follow" }); | |
| 668 | + | w.key("f", "d"); | |
| 669 | + | `) | |
| 670 | + | require.Equal(t, []map[string]any{ | |
| 671 | + | {"token": "y", "type": "copy", "text": "https://example.com/rel.html"}, | |
| 672 | + | {"token": "n", "type": "open-new", "url": "https://example.com/rel.html"}, | |
| 673 | + | {"token": "f", "type": "end"}, | |
| 674 | + | }, bridgeMessages(posted), "relative SVG hrefs resolve against the document") | |
| 675 | + | require.Equal(t, "click", recordedField(posted, "dispatched"), "following dispatches a click when the element has no click()") | |
| 676 | + | require.Equal(t, true, recordedField(posted, "bubbles")) | |
| 677 | + | require.Equal(t, true, recordedField(posted, "cancelable")) | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | // Chromium reports Selection positions inside a shadow tree relative to the | |
| 681 | + | // host, so shadow text never anchors the caret, while link hints and text | |
| 682 | + | // objects keep reaching into shadow roots. | |
| 683 | + | func TestVimPageRuntimeShadowTextIsNotACaretAnchor(t *testing.T) { | |
| 684 | + | posted := runVimPageRuntime(t, ` | |
| 685 | + | shadowParagraph("Shadow text that is visible", 200); | |
| 686 | + | window.__dumberVimPage.start("tok", { kind: "visual" }); | |
| 687 | + | posted.push({ n: "hints", labels: hintLabels() }); | |
| 688 | + | vp().key("tok", "<Escape>"); | |
| 689 | + | S("fallback"); | |
| 690 | + | vp().cancel("tok"); | |
| 691 | + | ||
| 692 | + | for (const t of texts) if (t !== texts[texts.length - 1]) t.parentElement.rect = rect(10, 5000, 600, 20); | |
| 693 | + | window.__dumberVimPage.start("only", { kind: "visual" }); | |
| 694 | + | `) | |
| 695 | + | require.Equal(t, []any{"s", "a", "d"}, named(t, posted, "hints")["labels"], "no label for the shadow paragraph") | |
| 696 | + | require.Equal(t, "1:0", named(t, posted, "fallback")["focus"], "the fallback anchor ignores shadow text too") | |
| 697 | + | ends := bridgeMessagesOfType(posted, "end") | |
| 698 | + | require.Len(t, ends, 1) | |
| 699 | + | require.Equal(t, "no-visible-text", ends[0]["reason"], "shadow text alone offers no anchor") | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | func TestVimPageRuntimeYankObjectStillReadsShadowDOM(t *testing.T) { | |
| 703 | + | posted := runVimPageRuntime(t, ` | |
| 704 | + | for (const t of texts.slice(0, 4)) t.parentElement.rect = rect(10, 5000, 600, 20); | |
| 705 | + | const shadow = shadowParagraph("Inside shadow", 200); | |
| 706 | + | shadow.p.innerText = "Inside shadow"; | |
| 707 | + | window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" }); | |
| 708 | + | `) | |
| 709 | + | require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": "Inside shadow"}}, bridgeMessages(posted)) | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | // Off-screen elements are rejected from their bounding box, before the costly | |
| 713 | + | // closest() and range measurements run. | |
| 714 | + | func TestVimPageRuntimeSkipsOffscreenWork(t *testing.T) { | |
| 715 | + | posted := runVimPageRuntime(t, ` | |
| 716 | + | for (let i = 0; i < 5; i++) { | |
| 717 | + | const link = body.appendChild(new HTMLAnchorElement("https://example.com/" + i)); | |
| 718 | + | link.rect = rect(10, 4000 + i * 30, 40, 20); | |
| 719 | + | } | |
| 720 | + | closestCalls = 0; | |
| 721 | + | window.__dumberVimPage.start("h", { kind: "hint-follow" }); | |
| 722 | + | posted.push({ n: "hints", closest: closestCalls, labels: hintLabels().length }); | |
| 723 | + | vp().cancel("h"); | |
| 724 | + | rangeRectCalls = 0; | |
| 725 | + | window.__dumberVimPage.start("v", { kind: "visual" }); | |
| 726 | + | posted.push({ n: "anchors", ranges: rangeRectCalls, labels: hintLabels().length }); | |
| 727 | + | `) | |
| 728 | + | hints := named(t, posted, "hints") | |
| 729 | + | require.EqualValues(t, 2, hints["labels"]) | |
| 730 | + | require.EqualValues(t, 2, hints["closest"], "only the two on-screen links reach closest()") | |
| 731 | + | anchors := named(t, posted, "anchors") | |
| 732 | + | require.EqualValues(t, 3, anchors["labels"]) | |
| 733 | + | require.EqualValues(t, 3, anchors["ranges"], "the paragraph below the fold is never measured") | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | // Scroll and resize repaint the caret once per animation frame, and a pending | |
| 737 | + | // frame is canceled with the interaction. | |
| 738 | + | func TestVimPageRuntimeBatchesViewportUpdatesPerFrame(t *testing.T) { | |
| 739 | + | posted := runVimPageRuntime(t, ` | |
| 740 | + | startCaret("tok", "s"); | |
| 741 | + | const bar = cursor(); | |
| 742 | + | first.p.rect = rect(10, 100, 600, 20); | |
| 743 | + | for (let i = 0; i < 10; i++) { fire("scroll"); fire("resize"); } | |
| 744 | + | const pending = frames.size; | |
| 745 | + | const stale = bar.style.top; | |
| 746 | + | flushFrames(); | |
| 747 | + | posted.push({ n: "batched", pending, stale, top: bar.style.top }); | |
| 748 | + | fire("scroll"); | |
| 749 | + | const queued = frames.size; | |
| 750 | + | vp().cancel("tok"); | |
| 751 | + | posted.push({ n: "canceled", queued, left: frames.size }); | |
| 752 | + | `) | |
| 753 | + | batched := named(t, posted, "batched") | |
| 754 | + | require.EqualValues(t, 1, batched["pending"], "a burst of events schedules one repaint") | |
| 755 | + | require.NotEqual(t, batched["stale"], batched["top"], "the repaint happens in the frame") | |
| 756 | + | canceled := named(t, posted, "canceled") | |
| 757 | + | require.EqualValues(t, 1, canceled["queued"]) | |
| 758 | + | require.EqualValues(t, 0, canceled["left"], "cleanup cancels a pending frame") | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | // The bridge carries UTF-8: accents, CJK and astral characters must reach Go | |
| 762 | + | // exactly as the page holds them. | |
| 763 | + | func TestVimPageRuntimeCopiesNonASCIIText(t *testing.T) { | |
| 764 | + | const text = "héllo wörld — 日本語 😀 ñ" | |
| 765 | + | posted := runVimPageRuntime(t, ` | |
| 766 | + | first.p.innerText = `+jsString(text)+`; | |
| 767 | + | window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" }); | |
| 768 | + | `) | |
| 769 | + | require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": text}}, bridgeMessages(posted)) | |
| 770 | + | } | |
| @@ -3,6 +3,7 @@ package cef | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | _ "embed" | 4 | _ "embed" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "os" | |||
| 6 | "os/exec" | 7 | "os/exec" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "testing" | 9 | "testing" | ||
| @@ -25,6 +26,9 @@ func runVimPageRuntime(t *testing.T, driver string) []map[string]any { | |||||
| 25 | t.Helper() | 26 | t.Helper() | ||
| 26 | node, err := exec.LookPath("node") | 27 | node, err := exec.LookPath("node") | ||
| 27 | if err != nil { | 28 | if err != nil { | ||
| 29 | + | if os.Getenv("DUMBER_REQUIRE_NODE") == "1" { | |||
| 30 | + | t.Fatal("node is required (DUMBER_REQUIRE_NODE=1) to execute the vim page runtime") | |||
| 31 | + | } | |||
| 28 | t.Skip("node is required to execute the vim page runtime") | 32 | t.Skip("node is required to execute the vim page runtime") | ||
| 29 | } | 33 | } | ||
| 30 | script := vimPageRuntimeHarness + "\n" + vimPageRuntimeTemplateJS + "\n" + driver + | 34 | script := vimPageRuntimeHarness + "\n" + vimPageRuntimeTemplateJS + "\n" + driver + | ||
| @@ -69,16 +73,6 @@ func modeSequence(posted []map[string]any) []any { | |||||
| 69 | return out | 73 | return out | ||
| 70 | } | 74 | } | ||
| 71 | 75 | ||||
| 72 | - | func TestVimPageRuntimeStartDoesNotEndImmediately(t *testing.T) { | |||
| 73 | - | posted := runVimPageRuntime(t, ` | |||
| 74 | - | window.__dumberVimPage.start("tok", { kind: "visual" }); | |||
| 75 | - | window.__dumberVimPage.start("hint", { kind: "hint-follow" }); | |||
| 76 | - | `) | |||
| 77 | - | for _, message := range bridgeMessages(posted) { | |||
| 78 | - | require.NotEqual(t, "end", message["type"], "starting with visible content never ends the interaction") | |||
| 79 | - | } | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | func TestVimPageRuntimeVisualStartsWithTextAnchorHints(t *testing.T) { | 76 | func TestVimPageRuntimeVisualStartsWithTextAnchorHints(t *testing.T) { | ||
| 83 | posted := runVimPageRuntime(t, ` | 77 | posted := runVimPageRuntime(t, ` | ||
| 84 | window.__dumberVimPage.start("tok", { kind: "visual", color: "#fbbf24" }); | 78 | window.__dumberVimPage.start("tok", { kind: "visual", color: "#fbbf24" }); | ||
| @@ -213,10 +207,6 @@ func TestVimPageRuntimeCaretMotionsMoveWithoutSelecting(t *testing.T) { | |||||
| 213 | {"k", "a", []string{"l", "k"}, "0:1"}, | 207 | {"k", "a", []string{"l", "k"}, "0:1"}, | ||
| 214 | {"G", "s", []string{"G"}, "3:18"}, | 208 | {"G", "s", []string{"G"}, "3:18"}, | ||
| 215 | {"gg", "a", []string{"G", "g", "g"}, "0:0"}, | 209 | {"gg", "a", []string{"G", "g", "g"}, "0:0"}, | ||
| 216 | - | {"}", "s", []string{"}"}, "0:19"}, | |||
| 217 | - | {"{", "a", []string{"l", "{"}, "1:0"}, | |||
| 218 | - | {")", "s", []string{")"}, "0:19"}, | |||
| 219 | - | {"(", "s", []string{"$", "("}, "0:0"}, | |||
| 220 | } | 210 | } | ||
| 221 | for _, tt := range tests { | 211 | for _, tt := range tests { | ||
| 222 | t.Run(tt.name, func(t *testing.T) { | 212 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -236,6 +226,42 @@ S("end"); | |||||
| 236 | } | 226 | } | ||
| 237 | } | 227 | } | ||
| 238 | 228 | ||||
| 229 | + | // Sentence and paragraph motions are native Selection.modify granularities | |||
| 230 | + | // the DOM shim cannot emulate, so the contract checked here is the call: the | |||
| 231 | + | // alter mode follows the phase and the direction and granularity are exact. | |||
| 232 | + | func TestVimPageRuntimeNativeGranularityMotions(t *testing.T) { | |||
| 233 | + | tests := []struct { | |||
| 234 | + | key string | |||
| 235 | + | direction string | |||
| 236 | + | granularity string | |||
| 237 | + | }{ | |||
| 238 | + | {"(", "backward", "sentence"}, | |||
| 239 | + | {")", "forward", "sentence"}, | |||
| 240 | + | {"{", "backward", "paragraph"}, | |||
| 241 | + | {"}", "forward", "paragraph"}, | |||
| 242 | + | {"0", "backward", "lineboundary"}, | |||
| 243 | + | {"$", "forward", "lineboundary"}, | |||
| 244 | + | {"G", "forward", "documentboundary"}, | |||
| 245 | + | } | |||
| 246 | + | for _, tt := range tests { | |||
| 247 | + | for _, phase := range []struct{ name, setup, alter string }{ | |||
| 248 | + | {"caret", "", "move"}, | |||
| 249 | + | {"visual", `keys("tok", "v");`, "extend"}, | |||
| 250 | + | } { | |||
| 251 | + | t.Run(phase.name+" "+tt.key, func(t *testing.T) { | |||
| 252 | + | posted := runVimPageRuntime(t, ` | |||
| 253 | + | startCaret("tok", "s"); | |||
| 254 | + | `+phase.setup+` | |||
| 255 | + | modifyCalls.length = 0; | |||
| 256 | + | keys("tok", `+jsString(tt.key)+`); | |||
| 257 | + | posted.push({ n: "calls", calls: modifyCalls }); | |||
| 258 | + | `) | |||
| 259 | + | require.Equal(t, []any{[]any{phase.alter, tt.direction, tt.granularity}}, named(t, posted, "calls")["calls"]) | |||
| 260 | + | }) | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | ||||
| 239 | func TestVimPageRuntimeWordMotionsSkipPunctuation(t *testing.T) { | 265 | func TestVimPageRuntimeWordMotionsSkipPunctuation(t *testing.T) { | ||
| 240 | posted := runVimPageRuntime(t, ` | 266 | posted := runVimPageRuntime(t, ` | ||
| 241 | startCaret("tok", "d"); | 267 | startCaret("tok", "d"); | ||
| @@ -271,9 +297,11 @@ startCaret("tok", "s"); | |||||
| 271 | const bar = cursor(); | 297 | const bar = cursor(); | ||
| 272 | first.p.rect = rect(10, -300, 600, 20); // the page scrolled the paragraph away | 298 | first.p.rect = rect(10, -300, 600, 20); // the page scrolled the paragraph away | ||
| 273 | fire("scroll"); | 299 | fire("scroll"); | ||
| 300 | + | flushFrames(); | |||
| 274 | const hiddenWhileOffscreen = bar.style.display; | 301 | const hiddenWhileOffscreen = bar.style.display; | ||
| 275 | first.p.rect = rect(10, 100, 600, 20); | 302 | first.p.rect = rect(10, 100, 600, 20); | ||
| 276 | fire("scroll"); | 303 | fire("scroll"); | ||
| 304 | + | flushFrames(); | |||
| 277 | posted.push({ n: "follow", hidden: hiddenWhileOffscreen, top: bar.style.top, display: bar.style.display }); | 305 | posted.push({ n: "follow", hidden: hiddenWhileOffscreen, top: bar.style.top, display: bar.style.display }); | ||
| 278 | keys("tok", "<Escape>"); | 306 | keys("tok", "<Escape>"); | ||
| 279 | posted.push({ n: "cleanup", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, scrollListeners: (listeners.scroll || []).length, resize: (listeners.resize || []).length, selection: selection.rangeCount }); | 307 | posted.push({ n: "cleanup", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, scrollListeners: (listeners.scroll || []).length, resize: (listeners.resize || []).length, selection: selection.rangeCount }); | ||
| @@ -316,15 +344,15 @@ S("afterReturn"); | |||||
| 316 | 344 | ||||
| 317 | after := named(t, posted, "after") | 345 | after := named(t, posted, "after") | ||
| 318 | require.Empty(t, after["text"], "y returns to a collapsed caret") | 346 | require.Empty(t, after["text"], "y returns to a collapsed caret") | ||
| 319 | - | require.Equal(t, "0:6", after["focus"], "at the focus end of the selection") | 347 | + | require.Equal(t, "0:5", after["focus"], "on the last selected character, where v put the caret") |
| 320 | require.Equal(t, after["focus"], after["anchor"]) | 348 | require.Equal(t, after["focus"], after["anchor"]) | ||
| 321 | - | require.Equal(t, "0:7", named(t, posted, "moved")["focus"], "the caret keeps moving after a yank") | 349 | + | require.Equal(t, "0:6", named(t, posted, "moved")["focus"], "the caret keeps moving after a yank") |
| 322 | - | require.Equal(t, "0:7", named(t, posted, "caretYank")["focus"], "y in caret mode does nothing") | 350 | + | require.Equal(t, "0:6", named(t, posted, "caretYank")["focus"], "y in caret mode does nothing") |
| 323 | 351 | ||||
| 324 | copies := bridgeMessagesOfType(posted, "copy") | 352 | copies := bridgeMessagesOfType(posted, "copy") | ||
| 325 | require.Len(t, copies, 2, "y in caret mode copies nothing; Enter chains a second copy") | 353 | require.Len(t, copies, 2, "y in caret mode copies nothing; Enter chains a second copy") | ||
| 326 | require.Equal(t, map[string]any{"token": "tok", "type": "copy", "text": "Alpha "}, copies[0]) | 354 | require.Equal(t, map[string]any{"token": "tok", "type": "copy", "text": "Alpha "}, copies[0]) | ||
| 327 | - | require.Equal(t, "ravo", copies[1]["text"], "v includes the character under the caret and e extends to the word end") | 355 | + | require.Equal(t, "bravo", copies[1]["text"], "v includes the character under the caret and e extends to the word end") |
| 328 | require.Empty(t, named(t, posted, "afterReturn")["text"]) | 356 | require.Empty(t, named(t, posted, "afterReturn")["text"]) | ||
| 329 | require.Empty(t, bridgeMessagesOfType(posted, "end"), "copying never ends the interaction") | 357 | require.Empty(t, bridgeMessagesOfType(posted, "end"), "copying never ends the interaction") | ||
| 330 | require.Equal(t, []any{"hints", "caret", "visual", "caret", "visual", "caret"}, modeSequence(posted)) | 358 | require.Equal(t, []any{"hints", "caret", "visual", "caret", "visual", "caret"}, modeSequence(posted)) | ||
| @@ -482,7 +510,7 @@ vpage.start("d", { kind: "hint-follow" }); | |||||
| 482 | vpage.key("d", "<Escape>"); | 510 | vpage.key("d", "<Escape>"); | ||
| 483 | `) | 511 | `) | ||
| 484 | require.ElementsMatch(t, []any{"s", "a"}, named(t, posted, "labels")["labels"], "two visible links get one-letter labels") | 512 | require.ElementsMatch(t, []any{"s", "a"}, named(t, posted, "labels")["labels"], "two visible links get one-letter labels") | ||
| 485 | - | require.Equal(t, "https://example.com/two", named2(posted, "clicked"), "follow clicks the chosen link") | 513 | + | require.Equal(t, "https://example.com/two", recordedField(posted, "clicked"), "follow clicks the chosen link") |
| 486 | messages := bridgeMessages(posted) | 514 | messages := bridgeMessages(posted) | ||
| 487 | require.Equal(t, []map[string]any{ | 515 | require.Equal(t, []map[string]any{ | ||
| 488 | {"token": "a", "type": "end"}, | 516 | {"token": "a", "type": "end"}, | ||
| @@ -493,8 +521,8 @@ vpage.key("d", "<Escape>"); | |||||
| 493 | require.NotContains(t, modeSequence(posted), "caret", "link hints never report caret modes") | 521 | require.NotContains(t, modeSequence(posted), "caret", "link hints never report caret modes") | ||
| 494 | } | 522 | } | ||
| 495 | 523 | ||||
| 496 | - | // named2 returns a field recorded by a harness side effect (such as a click). | 524 | + | // recordedField returns a field recorded by a harness side effect (such as a click). |
| 497 | - | func named2(posted []map[string]any, key string) any { | 525 | + | func recordedField(posted []map[string]any, key string) any { |
| 498 | for _, entry := range posted { | 526 | for _, entry := range posted { | ||
| 499 | if value, ok := entry[key]; ok { | 527 | if value, ok := entry[key]; ok { | ||
| 500 | return value | 528 | return value | ||
| @@ -505,7 +533,7 @@ func named2(posted []map[string]any, key string) any { | |||||
| 505 | 533 | ||||
| 506 | func TestVimPageRuntimeYankParagraph(t *testing.T) { | 534 | func TestVimPageRuntimeYankParagraph(t *testing.T) { | ||
| 507 | posted := runVimPageRuntime(t, ` | 535 | posted := runVimPageRuntime(t, ` | ||
| 508 | - | p.innerText = TEXT; | 536 | + | first.p.innerText = first.text.data; |
| 509 | window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" }); | 537 | window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" }); | ||
| 510 | `) | 538 | `) | ||
| 511 | messages := bridgeMessages(posted) | 539 | messages := bridgeMessages(posted) | ||
| @@ -523,3 +551,220 @@ posted.push({ n: "live", selection: selection.rangeCount }); | |||||
| 523 | require.Equal(t, []map[string]any{{"token": "stale", "type": "end"}}, bridgeMessagesOfType(posted, "end")) | 551 | require.Equal(t, []map[string]any{{"token": "stale", "type": "end"}}, bridgeMessagesOfType(posted, "end")) | ||
| 524 | require.EqualValues(t, 1, named(t, posted, "live")["selection"], "a stale key must not disturb the live interaction") | 552 | require.EqualValues(t, 1, named(t, posted, "live")["selection"], "a stale key must not disturb the live interaction") | ||
| 525 | } | 553 | } | ||
| 554 | + | ||||
| 555 | + | // Entering visual selects the character under the caret and leaving it must | |||
| 556 | + | // put the caret back on that character; the caret used to move one position | |||
| 557 | + | // right on every v then Escape. | |||
| 558 | + | func TestVimPageRuntimeVisualRoundTripDoesNotDriftCaret(t *testing.T) { | |||
| 559 | + | posted := runVimPageRuntime(t, ` | |||
| 560 | + | startCaret("tok", "s"); | |||
| 561 | + | keys("tok", "l", "l"); | |||
| 562 | + | S("start"); | |||
| 563 | + | for (let i = 0; i < 5; i++) keys("tok", "v", "<Escape>"); | |||
| 564 | + | S("afterEscapes"); | |||
| 565 | + | for (let i = 0; i < 3; i++) keys("tok", "v", "v"); | |||
| 566 | + | S("afterToggles"); | |||
| 567 | + | for (let i = 0; i < 3; i++) keys("tok", "v", "y"); | |||
| 568 | + | S("afterYanks"); | |||
| 569 | + | keys("tok", "V", "V"); | |||
| 570 | + | S("afterLine"); | |||
| 571 | + | `) | |||
| 572 | + | start := named(t, posted, "start") | |||
| 573 | + | require.Equal(t, "0:2", start["focus"]) | |||
| 574 | + | for _, name := range []string{"afterEscapes", "afterToggles", "afterYanks"} { | |||
| 575 | + | after := named(t, posted, name) | |||
| 576 | + | require.Equal(t, start["focus"], after["focus"], name) | |||
| 577 | + | require.Equal(t, start["anchor"], after["anchor"], name) | |||
| 578 | + | require.Empty(t, after["text"], name) | |||
| 579 | + | } | |||
| 580 | + | copies := bridgeMessagesOfType(posted, "copy") | |||
| 581 | + | require.Len(t, copies, 3) | |||
| 582 | + | for _, c := range copies { | |||
| 583 | + | require.Equal(t, "p", c["text"], "each yank copies the character under the caret") | |||
| 584 | + | } | |||
| 585 | + | require.Equal(t, "0:18", named(t, posted, "afterLine")["focus"], "V then V returns to the end of the line") | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | func TestVimPageRuntimeVisualRoundTripAtEndOfTextKeepsCaret(t *testing.T) { | |||
| 589 | + | posted := runVimPageRuntime(t, ` | |||
| 590 | + | startCaret("tok", "d"); | |||
| 591 | + | keys("tok", "G"); | |||
| 592 | + | S("end"); | |||
| 593 | + | keys("tok", "v"); | |||
| 594 | + | S("visual"); | |||
| 595 | + | keys("tok", "<Escape>"); | |||
| 596 | + | S("back"); | |||
| 597 | + | `) | |||
| 598 | + | end := named(t, posted, "end") | |||
| 599 | + | require.Equal(t, "3:18", end["focus"]) | |||
| 600 | + | visual := named(t, posted, "visual") | |||
| 601 | + | require.Equal(t, "d", visual["text"], "at the end of the text v selects the last character") | |||
| 602 | + | require.Equal(t, "3:18", visual["focus"]) | |||
| 603 | + | require.Equal(t, "3:17", named(t, posted, "back")["focus"], "the caret returns onto the last character") | |||
| 604 | + | } | |||
| 605 | + | ||||
| 606 | + | // Motions must not read the whole selection text per step: that made long | |||
| 607 | + | // selections quadratic. | |||
| 608 | + | func TestVimPageRuntimeMotionsDoNotReadSelectionText(t *testing.T) { | |||
| 609 | + | posted := runVimPageRuntime(t, ` | |||
| 610 | + | startCaret("tok", "s"); | |||
| 611 | + | keys("tok", "v"); | |||
| 612 | + | toStringCalls = 0; | |||
| 613 | + | keys("tok", "w", "w", "e", "b", "$", "^"); | |||
| 614 | + | posted.push({ n: "reads", calls: toStringCalls }); | |||
| 615 | + | S("sel"); | |||
| 616 | + | `) | |||
| 617 | + | require.EqualValues(t, 0, named(t, posted, "reads")["calls"]) | |||
| 618 | + | require.NotEmpty(t, named(t, posted, "sel")["focus"]) | |||
| 619 | + | } | |||
| 620 | + | ||||
| 621 | + | func TestVimPageRuntimeStartAndCancelFailuresReleaseCapture(t *testing.T) { | |||
| 622 | + | posted := runVimPageRuntime(t, ` | |||
| 623 | + | const realGetSelection = window.getSelection; | |||
| 624 | + | window.getSelection = () => { throw new Error("page quirk"); }; | |||
| 625 | + | window.__dumberVimPage.start("bad", { kind: "visual" }); | |||
| 626 | + | window.getSelection = realGetSelection; | |||
| 627 | + | posted.push({ n: "afterStart", hints: hintLabels().length, caret: cursor() === null }); | |||
| 628 | + | ||||
| 629 | + | startCaret("tok", "s"); | |||
| 630 | + | selection.removeAllRanges = () => { throw new Error("page quirk"); }; | |||
| 631 | + | vp().cancel("tok"); | |||
| 632 | + | posted.push({ n: "afterCancel", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, listeners: (listeners.scroll || []).length }); | |||
| 633 | + | `) | |||
| 634 | + | ends := bridgeMessagesOfType(posted, "end") | |||
| 635 | + | require.Equal(t, []map[string]any{{"token": "bad", "type": "end", "reason": "error"}}, ends, "a failing start reports the end") | |||
| 636 | + | afterStart := named(t, posted, "afterStart") | |||
| 637 | + | require.EqualValues(t, 0, afterStart["hints"]) | |||
| 638 | + | cancel := named(t, posted, "afterCancel") | |||
| 639 | + | require.Equal(t, true, cancel["caret"], "cancel removes the overlay even when the selection cannot be cleared") | |||
| 640 | + | require.EqualValues(t, 0, cancel["styles"]) | |||
| 641 | + | require.EqualValues(t, 0, cancel["listeners"]) | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | func TestVimPageRuntimePageHideCleansUp(t *testing.T) { | |||
| 645 | + | posted := runVimPageRuntime(t, ` | |||
| 646 | + | startCaret("tok", "s"); | |||
| 647 | + | fire("pagehide"); | |||
| 648 | + | posted.push({ n: "gone", caret: cursor() === null, scroll: (listeners.scroll || []).length, frames: frames.size }); | |||
| 649 | + | fire("pagehide"); // without an interaction it is a no-op | |||
| 650 | + | `) | |||
| 651 | + | gone := named(t, posted, "gone") | |||
| 652 | + | require.Equal(t, true, gone["caret"]) | |||
| 653 | + | require.EqualValues(t, 0, gone["scroll"]) | |||
| 654 | + | require.Empty(t, bridgeMessagesOfType(posted, "end"), "the document is leaving; nothing is reported") | |||
| 655 | + | } | |||
| 656 | + | ||||
| 657 | + | // SVG anchors expose href as an SVGAnimatedString and have no click(). | |||
| 658 | + | func TestVimPageRuntimeSVGLinks(t *testing.T) { | |||
| 659 | + | posted := runVimPageRuntime(t, ` | |||
| 660 | + | const svg = body.appendChild(new SVGAElement_("../rel.html")); | |||
| 661 | + | svg.rect = rect(150, 120, 40, 20); | |||
| 662 | + | const w = window.__dumberVimPage; | |||
| 663 | + | w.start("y", { kind: "hint-yank-url" }); | |||
| 664 | + | w.key("y", "d"); | |||
| 665 | + | w.start("n", { kind: "hint-follow-new" }); | |||
| 666 | + | w.key("n", "d"); | |||
| 667 | + | w.start("f", { kind: "hint-follow" }); | |||
| 668 | + | w.key("f", "d"); | |||
| 669 | + | `) | |||
| 670 | + | require.Equal(t, []map[string]any{ | |||
| 671 | + | {"token": "y", "type": "copy", "text": "https://example.com/rel.html"}, | |||
| 672 | + | {"token": "n", "type": "open-new", "url": "https://example.com/rel.html"}, | |||
| 673 | + | {"token": "f", "type": "end"}, | |||
| 674 | + | }, bridgeMessages(posted), "relative SVG hrefs resolve against the document") | |||
| 675 | + | require.Equal(t, "click", recordedField(posted, "dispatched"), "following dispatches a click when the element has no click()") | |||
| 676 | + | require.Equal(t, true, recordedField(posted, "bubbles")) | |||
| 677 | + | require.Equal(t, true, recordedField(posted, "cancelable")) | |||
| 678 | + | } | |||
| 679 | + | ||||
| 680 | + | // Chromium reports Selection positions inside a shadow tree relative to the | |||
| 681 | + | // host, so shadow text never anchors the caret, while link hints and text | |||
| 682 | + | // objects keep reaching into shadow roots. | |||
| 683 | + | func TestVimPageRuntimeShadowTextIsNotACaretAnchor(t *testing.T) { | |||
| 684 | + | posted := runVimPageRuntime(t, ` | |||
| 685 | + | shadowParagraph("Shadow text that is visible", 200); | |||
| 686 | + | window.__dumberVimPage.start("tok", { kind: "visual" }); | |||
| 687 | + | posted.push({ n: "hints", labels: hintLabels() }); | |||
| 688 | + | vp().key("tok", "<Escape>"); | |||
| 689 | + | S("fallback"); | |||
| 690 | + | vp().cancel("tok"); | |||
| 691 | + | ||||
| 692 | + | for (const t of texts) if (t !== texts[texts.length - 1]) t.parentElement.rect = rect(10, 5000, 600, 20); | |||
| 693 | + | window.__dumberVimPage.start("only", { kind: "visual" }); | |||
| 694 | + | `) | |||
| 695 | + | require.Equal(t, []any{"s", "a", "d"}, named(t, posted, "hints")["labels"], "no label for the shadow paragraph") | |||
| 696 | + | require.Equal(t, "1:0", named(t, posted, "fallback")["focus"], "the fallback anchor ignores shadow text too") | |||
| 697 | + | ends := bridgeMessagesOfType(posted, "end") | |||
| 698 | + | require.Len(t, ends, 1) | |||
| 699 | + | require.Equal(t, "no-visible-text", ends[0]["reason"], "shadow text alone offers no anchor") | |||
| 700 | + | } | |||
| 701 | + | ||||
| 702 | + | func TestVimPageRuntimeYankObjectStillReadsShadowDOM(t *testing.T) { | |||
| 703 | + | posted := runVimPageRuntime(t, ` | |||
| 704 | + | for (const t of texts.slice(0, 4)) t.parentElement.rect = rect(10, 5000, 600, 20); | |||
| 705 | + | const shadow = shadowParagraph("Inside shadow", 200); | |||
| 706 | + | shadow.p.innerText = "Inside shadow"; | |||
| 707 | + | window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" }); | |||
| 708 | + | `) | |||
| 709 | + | require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": "Inside shadow"}}, bridgeMessages(posted)) | |||
| 710 | + | } | |||
| 711 | + | ||||
| 712 | + | // Off-screen elements are rejected from their bounding box, before the costly | |||
| 713 | + | // closest() and range measurements run. | |||
| 714 | + | func TestVimPageRuntimeSkipsOffscreenWork(t *testing.T) { | |||
| 715 | + | posted := runVimPageRuntime(t, ` | |||
| 716 | + | for (let i = 0; i < 5; i++) { | |||
| 717 | + | const link = body.appendChild(new HTMLAnchorElement("https://example.com/" + i)); | |||
| 718 | + | link.rect = rect(10, 4000 + i * 30, 40, 20); | |||
| 719 | + | } | |||
| 720 | + | closestCalls = 0; | |||
| 721 | + | window.__dumberVimPage.start("h", { kind: "hint-follow" }); | |||
| 722 | + | posted.push({ n: "hints", closest: closestCalls, labels: hintLabels().length }); | |||
| 723 | + | vp().cancel("h"); | |||
| 724 | + | rangeRectCalls = 0; | |||
| 725 | + | window.__dumberVimPage.start("v", { kind: "visual" }); | |||
| 726 | + | posted.push({ n: "anchors", ranges: rangeRectCalls, labels: hintLabels().length }); | |||
| 727 | + | `) | |||
| 728 | + | hints := named(t, posted, "hints") | |||
| 729 | + | require.EqualValues(t, 2, hints["labels"]) | |||
| 730 | + | require.EqualValues(t, 2, hints["closest"], "only the two on-screen links reach closest()") | |||
| 731 | + | anchors := named(t, posted, "anchors") | |||
| 732 | + | require.EqualValues(t, 3, anchors["labels"]) | |||
| 733 | + | require.EqualValues(t, 3, anchors["ranges"], "the paragraph below the fold is never measured") | |||
| 734 | + | } | |||
| 735 | + | ||||
| 736 | + | // Scroll and resize repaint the caret once per animation frame, and a pending | |||
| 737 | + | // frame is canceled with the interaction. | |||
| 738 | + | func TestVimPageRuntimeBatchesViewportUpdatesPerFrame(t *testing.T) { | |||
| 739 | + | posted := runVimPageRuntime(t, ` | |||
| 740 | + | startCaret("tok", "s"); | |||
| 741 | + | const bar = cursor(); | |||
| 742 | + | first.p.rect = rect(10, 100, 600, 20); | |||
| 743 | + | for (let i = 0; i < 10; i++) { fire("scroll"); fire("resize"); } | |||
| 744 | + | const pending = frames.size; | |||
| 745 | + | const stale = bar.style.top; | |||
| 746 | + | flushFrames(); | |||
| 747 | + | posted.push({ n: "batched", pending, stale, top: bar.style.top }); | |||
| 748 | + | fire("scroll"); | |||
| 749 | + | const queued = frames.size; | |||
| 750 | + | vp().cancel("tok"); | |||
| 751 | + | posted.push({ n: "canceled", queued, left: frames.size }); | |||
| 752 | + | `) | |||
| 753 | + | batched := named(t, posted, "batched") | |||
| 754 | + | require.EqualValues(t, 1, batched["pending"], "a burst of events schedules one repaint") | |||
| 755 | + | require.NotEqual(t, batched["stale"], batched["top"], "the repaint happens in the frame") | |||
| 756 | + | canceled := named(t, posted, "canceled") | |||
| 757 | + | require.EqualValues(t, 1, canceled["queued"]) | |||
| 758 | + | require.EqualValues(t, 0, canceled["left"], "cleanup cancels a pending frame") | |||
| 759 | + | } | |||
| 760 | + | ||||
| 761 | + | // The bridge carries UTF-8: accents, CJK and astral characters must reach Go | |||
| 762 | + | // exactly as the page holds them. | |||
| 763 | + | func TestVimPageRuntimeCopiesNonASCIIText(t *testing.T) { | |||
| 764 | + | const text = "héllo wörld — 日本語 😀 ñ" | |||
| 765 | + | posted := runVimPageRuntime(t, ` | |||
| 766 | + | first.p.innerText = `+jsString(text)+`; | |||
| 767 | + | window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" }); | |||
| 768 | + | `) | |||
| 769 | + | require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": text}}, bridgeMessages(posted)) | |||
| 770 | + | } | |||