From 54bc333be229136866d6cfccd3638c03077120b5 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 13:55:48 +0200 Subject: [PATCH] keys: links are followed without the pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- README.md | 4 +- web/src/App.svelte | 15 ++++ web/src/lib/DocumentPane.svelte | 9 ++- web/src/lib/Editor.svelte | 25 +++++- web/src/lib/FilePane.svelte | 3 + web/src/lib/Help.svelte | 2 + web/src/lib/Hints.svelte | 139 ++++++++++++++++++++++++++++++++ web/src/lib/commands.ts | 16 ++++ web/src/lib/util.ts | 19 +++++ 9 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/Hints.svelte diff --git a/README.md b/README.md index d1288fe..418f69d 100644 --- a/README.md +++ b/README.md @@ -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 | | `]]` / `[[` | 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`) | +| `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 | | `o` | a new document here, straight into insert mode | | `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 `: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 -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 list, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard, `:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words diff --git a/web/src/App.svelte b/web/src/App.svelte index 179fef4..801088a 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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() let overlayEl = $state() let pending = $state('') + /** `F`: the pane whose links and buttons are wearing hints right now. */ + let hintRoot = $state(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 @@ + {#if hintRoot} + (hintRoot = null)} /> + {/if} + {#if workspace.overlay !== 'none'}
e.target === e.currentTarget && (workspace.overlay = 'none')} diff --git a/web/src/lib/DocumentPane.svelte b/web/src/lib/DocumentPane.svelte index 7cebe5c..dd4235f 100644 --- a/web/src/lib/DocumentPane.svelte +++ b/web/src/lib/DocumentPane.svelte @@ -1,5 +1,5 @@ diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte index 6f7db83..f2c136b 100644 --- a/web/src/lib/Editor.svelte +++ b/web/src/lib/Editor.svelte @@ -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() @@ -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 = [ diff --git a/web/src/lib/FilePane.svelte b/web/src/lib/FilePane.svelte index 11e00f8..60e8e0c 100644 --- a/web/src/lib/FilePane.svelte +++ b/web/src/lib/FilePane.svelte @@ -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 } }) diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index d45c7e1..28e4eba 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -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'], diff --git a/web/src/lib/Hints.svelte b/web/src/lib/Hints.svelte new file mode 100644 index 0000000..716d50f --- /dev/null +++ b/web/src/lib/Hints.svelte @@ -0,0 +1,139 @@ + + + + + diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index e1ace7e..f5ae835 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -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') diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index f59348d..a7208d3 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -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)`, `` 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*\)|<((?: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` : ''