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

+3 -1
View File
@@ -79,6 +79,7 @@ it; in the document pane it is that document. In the browse pane:
| `Ctrl-D` / `Ctrl-U` | half a page down / up the open document | | `Ctrl-D` / `Ctrl-U` | half a page down / up the open document |
| `]]` / `[[` | the next / previous heading of the open document, in the editor too | | `]]` / `[[` | the next / previous heading of the open document, in the editor too |
| `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) | | `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) |
| `F` | a letter over every link and button in view; typing it follows it — links out of the workspace open in a tab of their own |
| `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password | | `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password |
| `o` | a new document here, straight into insert mode | | `o` | a new document here, straight into insert mode |
| `O` | a new folder here | | `O` | a new folder here |
@@ -106,7 +107,8 @@ mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are
`:upload`, `:media`, `:search`, `:backup` and `:upload`, `:media`, `:search`, `:backup` and
`:set novim`. `:name` on its own moves up into the title; `Enter`, `Esc` or `↓` `:set novim`. `:name` on its own moves up into the title; `Enter`, `Esc` or `↓`
there goes back down into the text. In the text, `]]`, `[[` and `gO` walk the there goes back down into the text. In the text, `]]`, `[[` and `gO` walk the
headings as they do in the reader. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`) headings as they do in the reader. `gf` follows the link under the cursor, saving
first, and `gx` opens it in a new tab. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`)
opens a node by id, `:next`, `:prev`, `:first`, `:last` and `:random` walk the opens a node by id, `:next`, `:prev`, `:first`, `:last` and `:random` walk the
list, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard, list, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard,
`:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words `:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words
+15
View File
@@ -9,6 +9,7 @@
import FilePane from './lib/FilePane.svelte' import FilePane from './lib/FilePane.svelte'
import Headings from './lib/Headings.svelte' import Headings from './lib/Headings.svelte'
import Help from './lib/Help.svelte' import Help from './lib/Help.svelte'
import Hints from './lib/Hints.svelte'
import Verify from './lib/Verify.svelte' import Verify from './lib/Verify.svelte'
import Login from './lib/Login.svelte' import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte' import Overview from './lib/Overview.svelte'
@@ -44,6 +45,8 @@
let shell = $state<HTMLDivElement>() let shell = $state<HTMLDivElement>()
let overlayEl = $state<HTMLDivElement>() let overlayEl = $state<HTMLDivElement>()
let pending = $state('') 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() if (!shareToken) void workspace.boot()
@@ -625,6 +628,14 @@
if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document') if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document')
else void workspace.guard(() => workspace.openSelected()) else void workspace.guard(() => workspace.openSelected())
break 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': case 'm':
// Pick nodes up here, walk to a folder, put them down with `p`. // Pick nodes up here, walk to a folder, put them down with `p`.
event.preventDefault() event.preventDefault()
@@ -809,6 +820,10 @@
<Prompt /> <Prompt />
<SecretPrompt /> <SecretPrompt />
{#if hintRoot}
<Hints root={hintRoot} close={() => (hintRoot = null)} />
{/if}
{#if workspace.overlay !== 'none'} {#if workspace.overlay !== 'none'}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex --> <!-- 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')} <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"> <script lang="ts">
import { hooks, runCommand, type ScrollAmount } from './commands' import { followLink, hooks, runCommand, type ScrollAmount } from './commands'
import { renderMarkdown } from './markdown' import { renderMarkdown } from './markdown'
import Outline from './Outline.svelte' import Outline from './Outline.svelte'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
@@ -74,11 +74,15 @@
if (!scroller || !root) return if (!scroller || !root) return
const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction) const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction)
const list = () => readerHeadings(scroller, root) 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.jumpHeading = jump
hooks.headings = list hooks.headings = list
hooks.hintRoot = hints
return () => { return () => {
if (hooks.jumpHeading === jump) hooks.jumpHeading = () => false if (hooks.jumpHeading === jump) hooks.jumpHeading = () => false
if (hooks.headings === list) hooks.headings = () => ({ headings: [], current: 0 }) 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') ?? '' const href = anchor?.getAttribute('href') ?? ''
if (!href.startsWith('/n/')) return if (!href.startsWith('/n/')) return
event.preventDefault() event.preventDefault()
const path = href.slice(3).split('#')[0] followLink(href)
void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
} }
</script> </script>
+23 -2
View File
@@ -19,9 +19,9 @@
import { tags } from '@lezer/highlight' import { tags } from '@lezer/highlight'
import { Vim, getCM, vim } from '@replit/codemirror-vim' import { Vim, getCM, vim } from '@replit/codemirror-vim'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { commands, hooks, runCommand } from './commands' import { commands, followLink, hooks, runCommand } from './commands'
import { embedSnippet } from './markdown' import { embedSnippet } from './markdown'
import { sourceHeadings } from './util' import { linkAt, sourceHeadings } from './util'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
let host = $state<HTMLDivElement>() let host = $state<HTMLDivElement>()
@@ -179,6 +179,26 @@
Vim.mapCommand('gO', 'action', 'workspaceOutline', {}, { context: 'normal' }) 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 * Media goes on its own line after the one the cursor is on, so attaching a
* file never cuts a sentence in half. * file never cuts a sentence in half.
@@ -209,6 +229,7 @@
bridgeExCommands() bridgeExCommands()
bindHelpKey() bindHelpKey()
bindHeadingKeys() bindHeadingKeys()
bindLinkKeys()
mirrorUnnamedRegisterToClipboard() mirrorUnnamedRegisterToClipboard()
const extensions = [ const extensions = [
+3
View File
@@ -85,11 +85,14 @@
if (locked) return void runCommand('unlock'), true if (locked) return void runCommand('unlock'), true
return cert?.act() ?? false return cert?.act() ?? false
} }
const hints = () => reader ?? null
hooks.scrollReader = scroll hooks.scrollReader = scroll
hooks.fileAction = act hooks.fileAction = act
hooks.hintRoot = hints
return () => { return () => {
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
if (hooks.fileAction === act) hooks.fileAction = () => 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'], ['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
[']] / [[', 'next / previous heading of the document — in the editor too'], [']] / [[', 'next / previous heading of the document — in the editor too'],
['gO', 'the headings as a list: j/k, Enter jumps there'], ['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'], ['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'], ['h', 'from the document to the list, from the list up a level'],
['o', 'new document here, straight into insert mode'], ['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 /* 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. */ pane owns the scrollback, so the list keys have somewhere to send it. */
scrollReader: (_amount: ScrollAmount): boolean => false, 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. */ /* Filled in by the file pane: what Enter does there, false when nothing. */
fileAction: (): boolean => false, fileAction: (): boolean => false,
/* `]]` / `[[` in whatever is being read; false when it has no headings. */ /* `]]` / `[[` in whatever is being read; false when it has no headings. */
@@ -58,6 +60,20 @@ const requireOpen = () => {
return workspace.open 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`. */ /** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
const jumpTo = async (index: number) => { const jumpTo = async (index: number) => {
if (workspace.nodes.length === 0) return workspace.say('nothing here', 'error') 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 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. */ /** What a share link's reader will miss: a share never serves a sealed file. */
export const sealedNote = (sealed: number) => export const sealedNote = (sealed: number) =>
sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : '' sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : ''