v0.33.1 9577fee

vim_page_runtime.js RawHistoryFolder

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})();

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close