keys: links are followed without the pointer

`F` puts a letter or two over every link and button in view, vimium-style,
and typing one does what a click on it would. `f` was already fullscreen, so
the hints live on the other key. The labels start from the home row and are
all one length, so a label is done the moment it is typed. Esc, a key that
matches no label, or anything that scrolls the page puts them away. In the
file pane they reach the unlock and keystore buttons too.

A workspace link (`/n/…`) opens in place, as a click on it already did. Any
other link gets a tab of its own instead of taking the workspace with it.

In the editor, `gf` follows the link under the cursor, or the next one on
the line. A workspace link saves the text and opens that document. `gx`
opens the link in a new tab, as vim hands it to the browser. Both read
markdown links, embeds, `<…>` autolinks and bare addresses.
This commit is contained in:
nvrl committed 2026-10-20 13:55:48 +02:00
1 parent 6a8ffdf408
commit 54bc333be2
9 files changed
+226 -6

No files matched your search

+15
View File
@@ -9,6 +9,7 @@
import FilePane from './lib/FilePane.svelte'
import Headings from './lib/Headings.svelte'
import Help from './lib/Help.svelte'
import Hints from './lib/Hints.svelte'
import Verify from './lib/Verify.svelte'
import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte'
@@ -44,6 +45,8 @@
let shell = $state<HTMLDivElement>()
let overlayEl = $state<HTMLDivElement>()
let pending = $state('')
/** `F`: the pane whose links and buttons are wearing hints right now. */
let hintRoot = $state<HTMLElement | null>(null)
if (!shareToken) void workspace.boot()
@@ -625,6 +628,14 @@
if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document')
else void workspace.guard(() => workspace.openSelected())
break
case 'F': {
// `f` already fills the screen, so the hints vimium puts on `f` live
// on its other key.
event.preventDefault()
const root = reading ? hooks.hintRoot() : null
if (root) hintRoot = root
break
}
case 'm':
// Pick nodes up here, walk to a folder, put them down with `p`.
event.preventDefault()
@@ -809,6 +820,10 @@
<Prompt />
<SecretPrompt />
{#if hintRoot}
<Hints root={hintRoot} close={() => (hintRoot = null)} />
{/if}
{#if workspace.overlay !== 'none'}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
+6 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { hooks, runCommand, type ScrollAmount } from './commands'
import { followLink, hooks, runCommand, type ScrollAmount } from './commands'
import { renderMarkdown } from './markdown'
import Outline from './Outline.svelte'
import { workspace } from './store.svelte'
@@ -74,11 +74,15 @@
if (!scroller || !root) return
const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction)
const list = () => readerHeadings(scroller, root)
// The outline in the margin is links too, so `F` reaches it as well.
const hints = () => scroller
hooks.jumpHeading = jump
hooks.headings = list
hooks.hintRoot = hints
return () => {
if (hooks.jumpHeading === jump) hooks.jumpHeading = () => false
if (hooks.headings === list) hooks.headings = () => ({ headings: [], current: 0 })
if (hooks.hintRoot === hints) hooks.hintRoot = () => null
}
})
@@ -124,8 +128,7 @@
const href = anchor?.getAttribute('href') ?? ''
if (!href.startsWith('/n/')) return
event.preventDefault()
const path = href.slice(3).split('#')[0]
void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
followLink(href)
}
</script>
+23 -2
View File
@@ -19,9 +19,9 @@
import { tags } from '@lezer/highlight'
import { Vim, getCM, vim } from '@replit/codemirror-vim'
import { onDestroy, onMount } from 'svelte'
import { commands, hooks, runCommand } from './commands'
import { commands, followLink, hooks, runCommand } from './commands'
import { embedSnippet } from './markdown'
import { sourceHeadings } from './util'
import { linkAt, sourceHeadings } from './util'
import { workspace } from './store.svelte'
let host = $state<HTMLDivElement>()
@@ -179,6 +179,26 @@
Vim.mapCommand('gO', 'action', 'workspaceOutline', {}, { context: 'normal' })
}
/*
* `gf` follows the link under the cursor — a workspace link opens that
* document, saving this one first, anything else gets a tab — and `gx`
* opens it in a tab whatever it is, as vim hands it to the browser.
*/
function bindLinkKeys() {
const follow = (tab: boolean) => {
if (!view) return
const head = view.state.selection.main.head
const line = view.state.doc.lineAt(head)
const href = linkAt(line.text, head - line.from)
if (!href) return workspace.say('no link under the cursor', 'error')
followLink(href, tab)
}
Vim.defineAction('workspaceFollowLink', () => follow(false))
Vim.defineAction('workspaceOpenLink', () => follow(true))
Vim.mapCommand('gf', 'action', 'workspaceFollowLink', {}, { context: 'normal' })
Vim.mapCommand('gx', 'action', 'workspaceOpenLink', {}, { context: 'normal' })
}
/**
* Media goes on its own line after the one the cursor is on, so attaching a
* file never cuts a sentence in half.
@@ -209,6 +229,7 @@
bridgeExCommands()
bindHelpKey()
bindHeadingKeys()
bindLinkKeys()
mirrorUnnamedRegisterToClipboard()
const extensions = [
+3
View File
@@ -85,11 +85,14 @@
if (locked) return void runCommand('unlock'), true
return cert?.act() ?? false
}
const hints = () => reader ?? null
hooks.scrollReader = scroll
hooks.fileAction = act
hooks.hintRoot = hints
return () => {
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
if (hooks.fileAction === act) hooks.fileAction = () => false
if (hooks.hintRoot === hints) hooks.hintRoot = () => null
}
})
+2
View File
@@ -14,6 +14,8 @@
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
[']] / [[', 'next / previous heading of the document — in the editor too'],
['gO', 'the headings as a list: j/k, Enter jumps there'],
['F', 'hints over the links and buttons in view — type one to follow it'],
['gf / gx', 'in the editor: follow the link under the cursor / open it in a tab'],
['Enter · l', 'open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password'],
['h', 'from the document to the list, from the list up a level'],
['o', 'new document here, straight into insert mode'],
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
/*
* `F`: a letter or two over every link and button in view, the way vimium
* draws them — typing one does what a click on it would. The labels are
* read off the page once and pinned where the targets were; anything that
* moves the page under them puts them away rather than leaving them wrong.
*/
import { followLink } from './commands'
import { workspace } from './store.svelte'
interface Props {
root: HTMLElement
close: () => void
}
const { root, close }: Props = $props()
/* The home row first, then the rows above and below: the labels a hand on
the keys reaches without looking. */
const LETTERS = 'asdfghjklqweruiopzxcvnm'
type Hint = { label: string; element: HTMLElement; x: number; y: number }
/** Every target that is on screen inside the root, top to bottom. */
function targets(): HTMLElement[] {
const view = root.getBoundingClientRect()
return Array.from(root.querySelectorAll<HTMLElement>('a[href], button, summary')).filter((element) => {
const box = element.getBoundingClientRect()
return (
box.width > 0 &&
box.height > 0 &&
box.bottom > Math.max(view.top, 0) &&
box.top < Math.min(view.bottom, innerHeight) &&
box.right > view.left &&
box.left < view.right
)
})
}
/* Labels all of one length, so none is the start of another and a label is
done the moment it is typed. */
function labels(count: number): string[] {
let length = 1
while (LETTERS.length ** length < count) length++
return Array.from({ length: count }, (_, n) => {
let label = ''
for (let i = 0; i < length; i++) {
label = LETTERS[n % LETTERS.length] + label
n = Math.floor(n / LETTERS.length)
}
return label
})
}
const found = targets()
const names = labels(found.length)
const hints: Hint[] = found.map((element, i) => {
const box = element.getBoundingClientRect()
return { label: names[i], element, x: box.left, y: box.top }
})
let typed = $state('')
if (hints.length === 0) {
workspace.say('nothing to follow on screen')
queueMicrotask(() => close())
}
/** What a click would do — except that a link out of the workspace gets a tab of its own. */
function activate(element: HTMLElement) {
if (element instanceof HTMLAnchorElement) {
const href = element.getAttribute('href') ?? ''
if (href.startsWith('#') || element.target || element.hasAttribute('download')) element.click()
else followLink(href)
return
}
element.click()
}
$effect(() => {
// Ahead of everything else, the editor and the window handler included,
// as the `[y/N]` prompt takes its key.
const onkey = (event: KeyboardEvent) => {
if (['Shift', 'Control', 'Alt', 'Meta', 'CapsLock'].includes(event.key)) return
event.preventDefault()
event.stopPropagation()
if (event.key === 'Backspace') {
typed = typed.slice(0, -1)
return
}
const next = typed + event.key.toLowerCase()
const matching = hints.filter((hint) => hint.label.startsWith(next))
if (event.key.length !== 1 || matching.length === 0) return close()
if (matching.length === 1 && matching[0].label === next) {
close()
activate(matching[0].element)
return
}
typed = next
}
window.addEventListener('keydown', onkey, true)
window.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
window.removeEventListener('keydown', onkey, true)
window.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
})
</script>
<div class="hints" aria-hidden="true">
{#each hints as hint (hint.label)}
{#if hint.label.startsWith(typed)}
<span class="hint" style="left: {hint.x}px; top: {hint.y}px"
><span class="typed">{typed}</span>{hint.label.slice(typed.length)}</span
>
{/if}
{/each}
</div>
<style>
.hints { position: fixed; inset: 0; z-index: 35; pointer-events: none; }
.hint {
position: absolute;
transform: translate(-4px, -6px);
padding: 1px 4px;
border-radius: 4px;
background: var(--light);
color: var(--ink);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
line-height: 1.3;
text-transform: lowercase;
box-shadow: 0 1px 6px color-mix(in oklab, var(--ink) 60%, transparent);
}
.typed { opacity: 0.4; }
</style>
+16
View File
@@ -20,6 +20,8 @@ export const hooks = {
/* Filled in while a document is on screen and not being edited: the reading
pane owns the scrollback, so the list keys have somewhere to send it. */
scrollReader: (_amount: ScrollAmount): boolean => false,
/* `F`: what holds the links and buttons on screen, null when nothing does. */
hintRoot: (): HTMLElement | null => null,
/* Filled in by the file pane: what Enter does there, false when nothing. */
fileAction: (): boolean => false,
/* `]]` / `[[` in whatever is being read; false when it has no headings. */
@@ -58,6 +60,20 @@ const requireOpen = () => {
return workspace.open
}
/**
* Where a link in a document goes. `/n/` links are the workspace's own — the
* importer writes them between documents — and are opened in place; anything
* else would take the workspace's tab with it, so it gets a tab of its own,
* as does a workspace link asked for with `gx`.
*/
export function followLink(href: string, tab = false) {
if (href.startsWith('/n/') && !tab) {
const path = href.slice(3).split('#')[0]
return void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
}
window.open(new URL(href, location.href).href, '_blank', 'noopener,noreferrer')
}
/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
const jumpTo = async (index: number) => {
if (workspace.nodes.length === 0) return workspace.say('nothing here', 'error')
+19
View File
@@ -211,6 +211,25 @@ export function sourceHeadings(source: string): { text: string; level: number; l
return found
}
/**
* The link the cursor stands on in a line of markdown — `[text](href)`, an
* embed's `![alt](src)`, `<https://…>` or a bare address — or else the first
* one after it on the line, as vim's `gf` looks ahead to the next name.
*/
export function linkAt(line: string, column: number): string | null {
const pattern =
/!?\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)|<((?:https?:\/\/|\/n\/)[^>\s]+)>|((?:https?:\/\/|\/n\/)[^\s<>()]+)/g
let ahead: string | null = null
for (const match of line.matchAll(pattern)) {
// A sentence that ends on an address ends with a full stop, not the address.
const href = match[1] ?? match[2] ?? match[3].replace(/[.,;:!?]+$/, '')
const start = match.index
if (column >= start && column < start + match[0].length) return href
if (start >= column) ahead ??= href
}
return ahead
}
/** What a share link's reader will miss: a share never serves a sealed file. */
export const sealedNote = (sealed: number) =>
sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : ''