1// Dumber Vim Mode page runtime: link hints, caret and visual selection,
2// text-object yanks. Installed once per document; Go calls
3// window.__dumberVimPage.* with a per-interaction token and accepts results
4// at dumb:///api/vim-page only while that token is armed.
5//
6// Link hints and yanks report exactly one terminal message (copy, open-new, or
7// end). The caret/visual interaction is long-lived: it reports "mode" messages
8// as it moves between hints, caret, visual, and visual line, "copy" messages
9// that do not end it, and finally one "end".
10//
11// The caret and visual selection logic follows Vimium's mode_visual.js
12// (MIT, Copyright (c) Phil Crosby, Ilya Sukhar): character-by-character word
13// motions to avoid the native selection.modify word bugs on Linux (Vimium
14// #1441), focus-direction detection, and the line-mode extension. The text
15// anchor hints and the visible caret follow the flow of Surfingkeys' visual
16// mode.
17(() => {
18 if (window.__dumberVimPage) return;
19
20 const nativeFetch = window.fetch.bind(window);
21 const HINT_CHARS = "sadfjklewcmpgh";
22 const ALL_TARGETS = [
23 "a[href]", "button", "input:not([type=\"hidden\"])", "select", "textarea", "summary",
24 "[role=\"button\"]", "[role=\"link\"]", "[role=\"tab\"]", "[role=\"checkbox\"]",
25 "[role=\"menuitem\"]", "[onclick]", "[contenteditable=\"\"]", "[contenteditable=\"true\"]",
26 "[tabindex]:not([tabindex=\"-1\"])",
27 ].join(",");
28 const OBJECT_SELECTORS = {
29 paragraph: ["p"],
30 code: ["pre", "code"],
31 table: ["table"],
32 list: ["ul,ol,dl"],
33 };
34 // Motions that map directly to Selection.modify. w, e, b, ^ and gg are
35 // handled in code.
36 const CARET_MOTIONS = {
37 h: ["backward", "character"], "<Left>": ["backward", "character"],
38 l: ["forward", "character"], "<Right>": ["forward", "character"],
39 j: ["forward", "line"], "<Down>": ["forward", "line"],
40 k: ["backward", "line"], "<Up>": ["backward", "line"],
41 "0": ["backward", "lineboundary"], "$": ["forward", "lineboundary"],
42 "(": ["backward", "sentence"], ")": ["forward", "sentence"],
43 "{": ["backward", "paragraph"], "}": ["forward", "paragraph"],
44 G: ["forward", "documentboundary"],
45 };
46 const WORD_CHARACTER = /[\p{L}\p{N}_]/u;
47 const MAX_MOTION_STEPS = 20000;
48 const LONG_TEXT_LENGTH = 50;
49 const MODE_NAMES = { hints: "hints", caret: "caret", visual: "visual", line: "visual-line" };
50
51 let state = null;
52
53 // deepQueryAll matches selector across the document and every open shadow
54 // root; component-heavy sites (for example Reddit) render content there.
55 function deepQueryAll(selector, root = document) {
56 const results = Array.from(root.querySelectorAll(selector));
57 for (const element of root.querySelectorAll("*")) {
58 if (element.shadowRoot) results.push(...deepQueryAll(selector, element.shadowRoot));
59 }
60 return results;
61 }
62
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);
69 for (let node = walker.currentNode; node; node = walker.nextNode()) {
70 if (node.nodeType === Node.TEXT_NODE && node.data.trim()) yield node;
71 }
72 }
73
74 function post(token, message) {
75 let encoded = "";
76 try {
77 encoded = btoa(unescape(encodeURIComponent(JSON.stringify(Object.assign({ token }, message)))));
78 } catch (_) {
79 return;
80 }
81 nativeFetch("dumb:///api/vim-page", {
82 method: "POST",
83 headers: { "X-Dumber-Body": encoded },
84 }).catch(() => {});
85 }
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.
94 function visibleRect(element) {
95 if (!(element instanceof Element) || !element.isConnected) return null;
96 if (!onScreen(element.getBoundingClientRect())) return null;
97 if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return null;
98 const style = window.getComputedStyle(element);
99 if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return null;
100 for (const rect of element.getClientRects()) {
101 if (rect.width < 1 || rect.height < 1) continue;
102 if (!onScreen(rect)) continue;
103 return rect;
104 }
105 return null;
106 }
107
108 function isEditable(element) {
109 if (element.isContentEditable) return true;
110 if (element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) return true;
111 if (!(element instanceof HTMLInputElement)) return false;
112 return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(element.type);
113 }
114
115 function flash(element, color) {
116 if (!(element instanceof HTMLElement)) return;
117 const previousOutline = element.style.outline;
118 const previousOffset = element.style.outlineOffset;
119 element.style.outline = "3px solid " + color;
120 element.style.outlineOffset = "3px";
121 setTimeout(() => {
122 element.style.outline = previousOutline;
123 element.style.outlineOffset = previousOffset;
124 }, 400);
125 }
126
127 // --- Link hints -----------------------------------------------------------
128
129 function hintLabels(count) {
130 let length = 1;
131 while (Math.pow(HINT_CHARS.length, length) < count) length++;
132 const labels = [];
133 for (let i = 0; i < count; i++) {
134 let n = i;
135 let label = "";
136 for (let j = 0; j < length; j++) {
137 label = HINT_CHARS[n % HINT_CHARS.length] + label;
138 n = Math.floor(n / HINT_CHARS.length);
139 }
140 labels.push(label);
141 }
142 return labels;
143 }
144
145 function hintTargets(linksOnly) {
146 const seen = new Set();
147 const targets = [];
148 for (const element of deepQueryAll(linksOnly ? "a[href]" : ALL_TARGETS)) {
149 if (seen.has(element) || element.disabled) continue;
150 const rect = visibleRect(element);
151 if (!rect) continue;
152 if (!reachable(element, rect)) continue;
153 seen.add(element);
154 targets.push({ element, rect });
155 }
156 return targets;
157 }
158
159 // buildHintOverlay renders one label per item at its rect and arms the
160 // typed-label filter. offsetLeft puts labels before the rect so they do not
161 // cover the text a hint points at.
162 function buildHintOverlay(items, color, offsetLeft) {
163 const labels = hintLabels(items.length);
164 const root = document.createElement("div");
165 root.setAttribute("data-dumber-vim-hints", "");
166 root.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;";
167 const labelWidth = labels.length > 0 ? labels[0].length * 8 + 10 : 0;
168 const hints = items.map((item, index) => {
169 const node = document.createElement("span");
170 const left = offsetLeft && item.rect.left >= labelWidth ? item.rect.left - labelWidth : Math.max(item.rect.left, 0);
171 node.style.cssText = [
172 "position:fixed", "left:" + left + "px",
173 "top:" + Math.max(item.rect.top, 0) + "px", "padding:1px 4px", "border-radius:3px",
174 "font:bold 11px/1.3 monospace", "text-transform:uppercase", "color:#111",
175 "background:" + color, "box-shadow:0 1px 3px rgba(0,0,0,.4)",
176 ].join(";");
177 node.textContent = labels[index];
178 root.appendChild(node);
179 return { label: labels[index], item, node };
180 });
181 document.documentElement.appendChild(root);
182 state.root = root;
183 state.hints = hints;
184 state.typed = "";
185 }
186
187 function removeHintOverlay() {
188 if (state.root) state.root.remove();
189 state.root = null;
190 state.hints = [];
191 }
192
193 function startHints(kind, color) {
194 const targets = hintTargets(kind !== "hint-follow");
195 if (targets.length === 0) return false;
196 buildHintOverlay(targets, color, false);
197 return true;
198 }
199
200 function renderHintFilter() {
201 for (const hint of state.hints) {
202 const visible = hint.label.startsWith(state.typed);
203 hint.node.style.display = visible ? "" : "none";
204 if (visible) {
205 hint.node.textContent = "";
206 const typed = document.createElement("span");
207 typed.style.opacity = "0.45";
208 typed.textContent = state.typed;
209 hint.node.append(typed, hint.label.slice(state.typed.length));
210 }
211 }
212 }
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
241 // activateHint returns the terminal message for the chosen hint.
242 function activateHint(kind, element) {
243 const url = linkURL(element);
244 if (kind === "hint-yank-url") return url ? { type: "copy", text: url } : { type: "end" };
245 if (kind === "hint-follow-new" || (url && linkTarget(element) === "_blank")) {
246 return url ? { type: "open-new", url } : { type: "end" };
247 }
248 if (typeof element.focus === "function") element.focus({ preventScroll: true });
249 if (!isEditable(element)) clickElement(element);
250 return { type: "end" };
251 }
252
253 // hintKey handles typing labels for link hints and for the caret's
254 // text-anchor hints (the "visual" kind), where Escape places the caret at a
255 // default position instead of ending.
256 function hintKey(key) {
257 const textAnchors = state.kind === "visual";
258 if (key === "<Escape>") return textAnchors ? placeCaretAtFallback() : finish({ type: "end" });
259 if (key === "<BackSpace>") {
260 state.typed = state.typed.slice(0, -1);
261 return renderHintFilter();
262 }
263 if (key.length !== 1) return;
264 const typed = state.typed + key.toLowerCase();
265 const matches = state.hints.filter((hint) => hint.label.startsWith(typed));
266 if (matches.length === 0) return;
267 state.typed = typed;
268 if (matches.length === 1 && matches[0].label === typed) {
269 const item = matches[0].item;
270 // Remove the overlay before activation so the click hits the page.
271 removeHintOverlay();
272 if (textAnchors) return enterCaretAt(item.node, item.offset);
273 finish(activateHint(state.kind, item.element));
274 return;
275 }
276 renderHintFilter();
277 }
278
279 // --- Caret and visual selection ------------------------------------------
280 //
281 // One interaction moves between four phases: "hints" (text-anchor labels),
282 // "caret" (collapsed selection plus a visible cursor), "visual" (charwise
283 // selection), and "line" (linewise selection). Each phase change is reported
284 // to Go as a "mode" message so the indicator follows it.
285
286 const SKIP_TEXT_PARENTS = /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|TEXTAREA|OPTION)$/;
287
288 function setPhase(phase) {
289 if (state.phase === phase) return;
290 state.phase = phase;
291 post(state.token, { type: "mode", mode: MODE_NAMES[phase] });
292 }
293
294 // reachable reports whether element is what the user would hit when pointing
295 // at rect, so hints skip content covered by overlays.
296 function reachable(element, rect) {
297 const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
298 const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
299 const root = element.getRootNode();
300 const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y);
301 return !hit || element.contains(hit) || hit.contains(element) || !!(hit.shadowRoot && hit.shadowRoot.contains(element));
302 }
303
304 // blockOf returns the nearest non-inline ancestor, so a paragraph with inline
305 // markup offers one anchor instead of one per fragment.
306 function blockOf(element) {
307 let current = element;
308 while (current.parentElement && /^(inline|contents)$/.test(window.getComputedStyle(current).display || "")) {
309 current = current.parentElement;
310 }
311 return current;
312 }
313
314 function offsetAtPoint(node, x, y) {
315 let container = null;
316 let offset = 0;
317 if (document.caretRangeFromPoint) {
318 const range = document.caretRangeFromPoint(x, y);
319 if (range) { container = range.startContainer; offset = range.startOffset; }
320 } else if (document.caretPositionFromPoint) {
321 const position = document.caretPositionFromPoint(x, y);
322 if (position) { container = position.offsetNode; offset = position.offset; }
323 }
324 return container === node ? offset : -1;
325 }
326
327 // firstVisibleAnchor finds where visible text of node starts: the first
328 // non-blank character when its first line is visible, otherwise the start of
329 // the first fully visible line.
330 function firstVisibleAnchor(node, element) {
331 const range = document.createRange();
332 range.selectNodeContents(node);
333 const rects = Array.from(range.getClientRects());
334 for (let i = 0; i < rects.length; i++) {
335 const rect = rects[i];
336 if (rect.width < 1 || rect.height < 1) continue;
337 if (rect.top < 0 || rect.left < 0 || rect.bottom > window.innerHeight || rect.right > window.innerWidth) continue;
338 const offset = i === 0 ? Math.max(node.data.search(/\S/), 0) : offsetAtPoint(node, rect.left + 1, rect.top + rect.height / 2);
339 if (offset < 0 || !visibleRect(element) || !reachable(element, rect)) continue;
340 return { node, offset, rect, element };
341 }
342 return null;
343 }
344
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)) {
351 const element = node.parentElement;
352 if (!element || SKIP_TEXT_PARENTS.test(element.tagName) || isEditable(element)) 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;
356 const anchor = firstVisibleAnchor(node, element);
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)) {
367 if (perBlock) {
368 const block = blockOf(anchor.element);
369 if (blocks.has(block)) continue;
370 blocks.add(block);
371 }
372 anchors.push(anchor);
373 if (anchors.length >= limit) break;
374 }
375 return anchors;
376 }
377
378 // Esc during the text-anchor hints: Vimium's heuristic picks the first
379 // visible text of at least LONG_TEXT_LENGTH characters (shorter ones are
380 // likely banners); any visible text is the fallback. One pass finds both.
381 function fallbackAnchor() {
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;
388 }
389
390 function placeCaretAtFallback() {
391 const anchor = fallbackAnchor();
392 removeHintOverlay();
393 if (!anchor) return finish({ type: "end", reason: "no-visible-text" });
394 enterCaretAt(anchor.node, anchor.offset);
395 }
396
397 // The accent paints selections so they stand out on pages that restyle
398 // ::selection; the style is removed with the interaction.
399 function styleVisual(color) {
400 const style = document.createElement("style");
401 style.setAttribute("data-dumber-vim-visual", "");
402 style.textContent = "::selection { background: " + color + " !important; color: #111 !important; }";
403 document.documentElement.appendChild(style);
404 state.style = style;
405 }
406
407 // focusRect measures the selection focus. A collapsed range can report an
408 // empty rect at node boundaries, so the adjacent character is measured then.
409 function focusRect(selection) {
410 const node = selection.focusNode;
411 if (!node) return null;
412 const offset = selection.focusOffset;
413 const range = document.createRange();
414 range.setStart(node, offset);
415 range.collapse(true);
416 const first = (r) => Array.from(r.getClientRects()).find((rect) => rect.height > 0) || null;
417 const direct = first(range);
418 if (direct) return direct;
419 if (node.nodeType === Node.TEXT_NODE) {
420 if (offset < node.data.length) {
421 range.setEnd(node, offset + 1);
422 const next = first(range);
423 if (next) return { left: next.left, right: next.left, top: next.top, bottom: next.bottom, width: 0, height: next.height };
424 }
425 if (offset > 0) {
426 const before = document.createRange();
427 before.setStart(node, offset - 1);
428 before.setEnd(node, offset);
429 const previous = first(before);
430 if (previous) return { left: previous.right, right: previous.right, top: previous.top, bottom: previous.bottom, width: 0, height: previous.height };
431 }
432 }
433 const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
434 const rect = element && element.getBoundingClientRect();
435 return rect && rect.height > 0 ? rect : null;
436 }
437
438 // The caret overlay is a blinking accent bar at the focus. It is page
439 // content, so it follows scrolling and is removed with the interaction.
440 function beginCaretUI(selection) {
441 if (!state.style) styleVisual(state.color);
442 if (!state.cursor) {
443 const cursor = document.createElement("div");
444 cursor.setAttribute("data-dumber-vim-caret", "");
445 cursor.style.cssText = [
446 "position:fixed", "width:4px", "pointer-events:none", "z-index:2147483647",
447 "background:" + state.color, "border-radius:2px",
448 "box-shadow:0 0 0 2px rgba(17,17,17,.85), 0 0 10px 2px " + state.color,
449 ].join(";");
450 document.documentElement.appendChild(cursor);
451 if (typeof cursor.animate === "function") {
452 cursor.animate([{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0.15 }], { duration: 1000, iterations: Infinity });
453 }
454 state.cursor = cursor;
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 };
464 window.addEventListener("scroll", state.onViewportChange, { capture: true, passive: true });
465 window.addEventListener("resize", state.onViewportChange, { passive: true });
466 }
467 updateCursor(selection);
468 }
469
470 function updateCursor(selection) {
471 if (!state || !state.cursor || !selection) return;
472 const rect = focusRect(selection);
473 const visible = rect && rect.bottom > 0 && rect.top < window.innerHeight && rect.right > -4 && rect.left < window.innerWidth;
474 state.cursor.style.display = visible ? "" : "none";
475 if (!visible) return;
476 // Taller than the text line so the caret reads at a glance.
477 const height = Math.max(rect.height, 14) + 6;
478 state.cursor.style.left = Math.round(rect.left - 2) + "px";
479 state.cursor.style.top = Math.round(rect.top + (rect.height - height) / 2) + "px";
480 state.cursor.style.height = Math.round(height) + "px";
481 }
482
483 function revealFocus(selection) {
484 const rect = focusRect(selection);
485 if (rect) {
486 const margin = 40;
487 if (rect.top < margin) window.scrollBy(0, rect.top - margin);
488 else if (rect.bottom > window.innerHeight - margin) window.scrollBy(0, rect.bottom - window.innerHeight + margin);
489 }
490 updateCursor(selection);
491 }
492
493 function enterCaretAt(node, offset) {
494 const selection = window.getSelection();
495 selection.setBaseAndExtent(node, offset, node, offset);
496 state.count = "";
497 state.pendingG = false;
498 beginCaretUI(selection);
499 setPhase("caret");
500 revealFocus(selection);
501 }
502
503 // A visible range selection is used as is, as in Vimium; a selection that
504 // scrolled out of view is stale and ignored.
505 function hasVisibleRangeSelection(selection) {
506 if (selection.rangeCount === 0 || selection.isCollapsed) return false;
507 const rect = selection.getRangeAt(0).getBoundingClientRect();
508 return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
509 }
510
511 function startVisual(color) {
512 const selection = window.getSelection();
513 if (!selection) return false;
514 state.color = color;
515 state.count = "";
516 state.pendingG = false;
517 if (hasVisibleRangeSelection(selection)) {
518 beginCaretUI(selection);
519 setPhase("visual");
520 return true;
521 }
522 const anchors = collectTextAnchors({ perBlock: true, limit: HINT_CHARS.length * HINT_CHARS.length });
523 if (anchors.length === 0) return false;
524 buildHintOverlay(anchors, color, true);
525 setPhase("hints");
526 return true;
527 }
528
529 // --- Selection primitives (after Vimium's Movement class) ---
530
531 function isBackward(selection) {
532 if (selection.anchorNode === selection.focusNode && selection.anchorOffset === selection.focusOffset) return false;
533 const range = document.createRange();
534 range.setStart(selection.anchorNode, selection.anchorOffset);
535 range.setEnd(selection.focusNode, selection.focusOffset);
536 return range.collapsed;
537 }
538
539 function swapEnds(selection) {
540 selection.setBaseAndExtent(selection.focusNode, selection.focusOffset, selection.anchorNode, selection.anchorOffset);
541 }
542
543 function collapseToFocus(selection) {
544 selection.collapse(selection.focusNode, selection.focusOffset);
545 }
546
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.
554 function extendByOne(selection, direction) {
555 const node = selection.focusNode;
556 const offset = selection.focusOffset;
557 selection.modify("extend", direction, "character");
558 return focusMoved(selection, node, offset) ? 1 : 0;
559 }
560
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];
590 }
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;
600 }
601
602 // previousCharacter returns the character before the focus without moving it.
603 function previousCharacter(selection) {
604 const character = characterAtFocus(selection, false);
605 return character === null ? probeCharacter(selection, "backward") : character;
606 }
607
608 function isWordCharacter(character) {
609 return character !== undefined && WORD_CHARACTER.test(character);
610 }
611
612 // Word motions run character by character: the native word granularity
613 // differs between Linux and other platforms (Vimium #1441).
614 function wordForward(selection, wordFirst) {
615 const skipWord = () => {
616 for (let i = 0; i < MAX_MOTION_STEPS && isWordCharacter(nextCharacter(selection)); i++) {
617 if (extendByOne(selection, "forward") === 0) return false;
618 }
619 return true;
620 };
621 const skipOther = () => {
622 for (let i = 0; i < MAX_MOTION_STEPS; i++) {
623 const character = nextCharacter(selection);
624 if (character === undefined || isWordCharacter(character)) return true;
625 if (extendByOne(selection, "forward") === 0) return false;
626 }
627 return true;
628 };
629 // w: finish the current word, then skip to the next one. e: skip to a
630 // word, then finish it.
631 if (wordFirst) {
632 if (skipWord()) skipOther();
633 } else if (skipOther()) {
634 skipWord();
635 }
636 }
637
638 function wordBackward(selection) {
639 for (let i = 0; i < MAX_MOTION_STEPS; i++) {
640 const character = previousCharacter(selection);
641 if (character === undefined || isWordCharacter(character)) break;
642 if (extendByOne(selection, "backward") === 0) return;
643 }
644 for (let i = 0; i < MAX_MOTION_STEPS && isWordCharacter(previousCharacter(selection)); i++) {
645 if (extendByOne(selection, "backward") === 0) return;
646 }
647 }
648
649 function firstNonBlank(selection, alter) {
650 selection.modify(alter, "backward", "lineboundary");
651 for (let i = 0; i < MAX_MOTION_STEPS; i++) {
652 const character = nextCharacter(selection);
653 if (character === undefined || !/[ \t\u00a0]/.test(character)) break;
654 if (extendByOne(selection, "forward") === 0) break;
655 }
656 // A caret measures with extend, then settles where the measuring ended.
657 if (alter === "move") collapseToFocus(selection);
658 }
659
660 // extendToLines grows the selection so both ends sit on line boundaries.
661 function extendToLines(selection) {
662 const forward = !isBackward(selection);
663 for (const direction of forward ? ["forward", "backward"] : ["backward", "forward"]) {
664 selection.modify("extend", direction, "lineboundary");
665 swapEnds(selection);
666 }
667 }
668
669 // moveOnce applies one motion. In caret phase the selection stays collapsed:
670 // native granularities use "move", and the character-by-character motions
671 // extend and then collapse to the focus.
672 function moveOnce(selection, key) {
673 const caret = state.phase === "caret";
674 const alter = caret ? "move" : "extend";
675 if (CARET_MOTIONS[key]) {
676 selection.modify(alter, CARET_MOTIONS[key][0], CARET_MOTIONS[key][1]);
677 return true;
678 }
679 switch (key) {
680 case "w": wordForward(selection, true); break;
681 case "e": wordForward(selection, false); break;
682 case "b": wordBackward(selection); break;
683 case "^": return firstNonBlank(selection, alter), true;
684 case "gg": selection.modify(alter, "backward", "documentboundary"); return true;
685 default: return false;
686 }
687 if (caret) collapseToFocus(selection);
688 return true;
689 }
690
691 function runMotion(selection, key, count) {
692 for (let i = 0; i < count; i++) {
693 if (state.phase === "line") {
694 const saved = [selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset];
695 if (!moveOnce(selection, key)) return;
696 if (selection.isCollapsed) selection.setBaseAndExtent(...saved);
697 extendToLines(selection);
698 } else if (!moveOnce(selection, key)) {
699 return;
700 }
701 }
702 }
703
704 // --- Phase transitions ---
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.
709 function toCaret(selection) {
710 if (!selection.isCollapsed && !isBackward(selection)) extendByOne(selection, "backward");
711 collapseToFocus(selection);
712 setPhase("caret");
713 revealFocus(selection);
714 }
715
716 function toVisual(selection) {
717 // Include the character under the caret, or the one before at the end.
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 }
723 setPhase("visual");
724 revealFocus(selection);
725 }
726
727 function toLine(selection) {
728 extendToLines(selection);
729 setPhase("line");
730 revealFocus(selection);
731 }
732
733 // yankSelection copies the selection and returns to the caret at its focus,
734 // so further copies can be chained without restarting.
735 function yankSelection(selection) {
736 const text = selection.toString();
737 if (text) post(state.token, { type: "copy", text });
738 toCaret(selection);
739 }
740
741 function caretKey(key) {
742 const selection = window.getSelection();
743 if (!selection || selection.rangeCount === 0) return finish({ type: "end" });
744 const phase = state.phase;
745 if (key === "<Escape>") {
746 state.count = "";
747 state.pendingG = false;
748 if (phase === "caret") {
749 selection.removeAllRanges();
750 return finish({ type: "end" });
751 }
752 return toCaret(selection);
753 }
754 if (key === "v") {
755 state.count = "";
756 state.pendingG = false;
757 if (phase === "caret") return toVisual(selection);
758 if (phase === "visual") return toCaret(selection);
759 setPhase("visual");
760 return updateCursor(selection);
761 }
762 if (key === "V") {
763 state.count = "";
764 state.pendingG = false;
765 return phase === "line" ? toCaret(selection) : toLine(selection);
766 }
767 if ((key === "y" || key === "<Return>")) {
768 state.count = "";
769 state.pendingG = false;
770 return phase === "caret" ? undefined : yankSelection(selection);
771 }
772 if (key === "o") {
773 state.count = "";
774 state.pendingG = false;
775 if (phase === "caret") return;
776 swapEnds(selection);
777 return revealFocus(selection);
778 }
779 if (/^[1-9]$/.test(key) || (key === "0" && state.count !== "")) {
780 state.count += key;
781 return;
782 }
783 let motion = key;
784 if (key === "g") {
785 if (!state.pendingG) {
786 state.pendingG = true;
787 return;
788 }
789 motion = "gg";
790 }
791 state.pendingG = false;
792 const count = Math.min(parseInt(state.count || "1", 10), 500);
793 state.count = "";
794 runMotion(selection, motion, count);
795 revealFocus(selection);
796 }
797
798 // --- Text objects ---------------------------------------------------------
799
800 function selectionText(range) {
801 const selection = window.getSelection();
802 if (!selection) return range.toString();
803 const saved = [];
804 for (let i = 0; i < selection.rangeCount; i++) saved.push(selection.getRangeAt(i));
805 selection.removeAllRanges();
806 selection.addRange(range);
807 const text = selection.toString();
808 selection.removeAllRanges();
809 saved.forEach((savedRange) => selection.addRange(savedRange));
810 return text;
811 }
812
813 function sectionText(color) {
814 const headings = deepQueryAll("h1,h2,h3,h4,h5,h6")
815 .filter((heading) => heading.getClientRects().length > 0);
816 if (headings.length === 0) return "";
817 const threshold = window.innerHeight * 0.25;
818 let index = -1;
819 headings.forEach((heading, i) => {
820 if (heading.getBoundingClientRect().top <= threshold) index = i;
821 });
822 if (index < 0) index = 0;
823 const heading = headings[index];
824 const level = Number(heading.tagName[1]);
825 const boundary = headings.slice(index + 1).find((next) => Number(next.tagName[1]) <= level);
826 const range = document.createRange();
827 range.setStartBefore(heading);
828 if (boundary) range.setEndBefore(boundary);
829 else range.setEndAfter(document.body ? document.body.lastChild || heading : heading);
830 flash(heading, color);
831 return selectionText(range);
832 }
833
834 function objectText(object, color) {
835 if (object === "section") return sectionText(color);
836 for (const selector of OBJECT_SELECTORS[object] || []) {
837 const candidates = deepQueryAll(selector);
838 const element = candidates.find((candidate) =>
839 visibleRect(candidate) && !candidates.some((other) => other !== candidate && other.contains(candidate)));
840 if (element) {
841 flash(element, color);
842 return element.innerText;
843 }
844 }
845 return "";
846 }
847
848 // --- Lifecycle ------------------------------------------------------------
849
850 function cleanup() {
851 if (state) {
852 for (const node of [state.root, state.style, state.cursor]) if (node) node.remove();
853 if (state.frame) window.cancelAnimationFrame(state.frame);
854 if (state.onViewportChange) {
855 window.removeEventListener("scroll", state.onViewportChange, { capture: true });
856 window.removeEventListener("resize", state.onViewportChange);
857 }
858 }
859 state = null;
860 }
861
862 // finish tears down the interaction and reports its single terminal message.
863 function finish(message) {
864 const token = state ? state.token : "";
865 cleanup();
866 if (token) post(token, message);
867 }
868
869 // A document leaving the page (navigation, bfcache) takes the overlay along.
870 window.addEventListener("pagehide", () => { if (state) cleanup(); });
871
872 Object.defineProperty(window, "__dumberVimPage", {
873 configurable: false,
874 enumerable: false,
875 value: Object.freeze({
876 start(token, request) {
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" });
892 }
893 },
894 key(token, key) {
895 if (!state || state.token !== token) {
896 // Unknown interaction (for example the runtime was reinstalled):
897 // release that token's key capture without touching live state.
898 post(token, { type: "end" });
899 return;
900 }
901 try {
902 if (state.phase === "hints" || state.kind !== "visual") return hintKey(key);
903 return caretKey(key);
904 } catch (_) {
905 // A page quirk must never leave the user's keys captured.
906 if (state) finish({ type: "end", reason: "error" });
907 }
908 },
909 cancel(token) {
910 if (!state || state.token !== token) return;
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.
918 }
919 cleanup();
920 },
921 }),
922 });
923})();