From 6a8ffdf40886375da6c9c70a5fe0bcdcb6dd6a98 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 13:52:24 +0200 Subject: [PATCH] keys: headings are a key away, read or written `]]` and `[[` jump to the next and previous heading of the open document, in the reader and in the editor, where vim's section motions mean nothing useful in markdown. Past the last heading they go on to the end, as vim's do. A heading a jump has just parked at the top counts as the one being read, so pressing again moves on. `gO` (or `:outline`, `:toc`) lists the headings in an overlay that opens on the section being read or the one the cursor is in: `j`/`k` walk it, `Enter` jumps there. It is the margin's outline for when the pane is too narrow to show one, and for hands that stay on the keys. In the editor the list comes from the `#` lines of the text, code fences left out, and a pick moves the cursor; in the reader it comes from the rendered headings, markdown files included. --- README.md | 5 +- web/src/App.svelte | 52 ++++++++++++++- web/src/lib/DocumentPane.svelte | 26 +++++++- web/src/lib/Editor.svelte | 56 ++++++++++++++++ web/src/lib/FilePane.svelte | 20 +++++- web/src/lib/Headings.svelte | 109 ++++++++++++++++++++++++++++++++ web/src/lib/Help.svelte | 2 + web/src/lib/commands.ts | 21 +++++- web/src/lib/store.svelte.ts | 7 +- web/src/lib/util.ts | 80 +++++++++++++++++++++++ 10 files changed, 368 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/Headings.svelte diff --git a/README.md b/README.md index 6ab7f31..d1288fe 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ it; in the document pane it is that document. In the browse pane: | `j` / `k` | move down / up, or scroll the document when it has focus | | `gg` / `G` | first / last entry, or top / bottom of the 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 | +| `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) | | `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 | @@ -103,7 +105,8 @@ mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are `:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`, `: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. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`) +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`) 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 b81ecc0..179fef4 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -7,6 +7,7 @@ import CommandLine from './lib/CommandLine.svelte' import DocumentPane from './lib/DocumentPane.svelte' import FilePane from './lib/FilePane.svelte' + import Headings from './lib/Headings.svelte' import Help from './lib/Help.svelte' import Verify from './lib/Verify.svelte' import Login from './lib/Login.svelte' @@ -298,6 +299,33 @@ } } + /** `gO`'s list: j/k down the headings, Enter to the one picked. */ + function outlineKey(event: KeyboardEvent): boolean { + const last = Math.max(workspace.outline.length - 1, 0) + switch (event.key) { + case 'j': + case 'ArrowDown': + workspace.outlineCursor = Math.min(workspace.outlineCursor + 1, last) + return true + case 'k': + case 'ArrowUp': + workspace.outlineCursor = Math.max(workspace.outlineCursor - 1, 0) + return true + case 'G': + workspace.outlineCursor = last + return true + case 'Enter': + case 'l': { + const heading = workspace.outline[workspace.outlineCursor] + workspace.overlay = 'none' + heading?.go() + return true + } + default: + return false + } + } + /** Alt+h / Alt+l: focus moves between the panes, out of the editor too. */ function focusPane(pane: 'list' | 'document') { if (pane === 'document' && !workspace.open) return @@ -417,6 +445,12 @@ return } } + if (workspace.overlay === 'outline' && !event.ctrlKey && !event.metaKey && !event.altKey) { + if (outlineKey(event)) { + event.preventDefault() + return + } + } if (event.key === 'Escape') { if (workspace.overlay !== 'none') { @@ -536,19 +570,31 @@ */ const reading = !!workspace.open && !workspace.editing && workspace.pane === 'document' - // Two-key sequences: gg and dd. + // Two-key sequences: gg and dd; gO, ]] and [[ for the headings of what + // is being read, as in vim. if (previous === 'g' && key === 'g') { event.preventDefault() if (reading) hooks.scrollReader('top') else workspace.cursor = 0 return } + if (previous === 'g' && key === 'O') { + event.preventDefault() + void runCommand('outline') + return + } if (previous === 'd' && key === 'd') { event.preventDefault() void runCommand('d') return } - if (key === 'g' || key === 'd') { + if ((previous === ']' && key === ']') || (previous === '[' && key === '[')) { + event.preventDefault() + // Past the last heading there is still the end, as in vim. + if (reading && !hooks.jumpHeading(key === ']' ? 1 : -1)) hooks.scrollReader(key === ']' ? 'bottom' : 'top') + return + } + if (key === 'g' || key === 'd' || key === ']' || key === '[') { pending = key return } @@ -767,7 +813,7 @@
e.target === e.currentTarget && (workspace.overlay = 'none')} > - {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else}{/if} + {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else if workspace.overlay === 'outline'}{:else}{/if}
{/if} diff --git a/web/src/lib/DocumentPane.svelte b/web/src/lib/DocumentPane.svelte index d9ea6b5..7cebe5c 100644 --- a/web/src/lib/DocumentPane.svelte +++ b/web/src/lib/DocumentPane.svelte @@ -4,7 +4,16 @@ import Outline from './Outline.svelte' import { workspace } from './store.svelte' import Icon from './Icon.svelte' - import { formatStamp, formatWeekday, isTouchDevice, relative, scrollPage } from './util' + import { + formatStamp, + formatWeekday, + headingsIn, + isTouchDevice, + jumpHeading, + readerHeadings, + relative, + scrollPage, + } from './util' const doc = $derived(workspace.open!) const html = $derived(renderMarkdown(workspace.editing ? workspace.draft.body : doc.body)) @@ -58,6 +67,21 @@ } }) + // `]]`, `[[` and `gO` while reading; the editor answers them while writing. + $effect(() => { + const scroller = reader + const root = article + if (!scroller || !root) return + const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction) + const list = () => readerHeadings(scroller, root) + hooks.jumpHeading = jump + hooks.headings = list + return () => { + if (hooks.jumpHeading === jump) hooks.jumpHeading = () => false + if (hooks.headings === list) hooks.headings = () => ({ headings: [], current: 0 }) + } + }) + /* * A sealed file embedded here will not load while the vault is shut: the * server answers 423 and the browser shows a broken image. Errors do not diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte index 98b09f4..6f7db83 100644 --- a/web/src/lib/Editor.svelte +++ b/web/src/lib/Editor.svelte @@ -21,6 +21,7 @@ import { onDestroy, onMount } from 'svelte' import { commands, hooks, runCommand } from './commands' import { embedSnippet } from './markdown' + import { sourceHeadings } from './util' import { workspace } from './store.svelte' let host = $state() @@ -126,6 +127,58 @@ } } + /** The `#` lines being written, as `gO` lists them, and the one the cursor is under. */ + function editorHeadings() { + if (!view) return { headings: [], current: 0 } + const editor = view + const at = editor.state.doc.lineAt(editor.state.selection.main.head).number + const found = sourceHeadings(editor.state.doc.toString()) + let current = 0 + found.forEach((heading, i) => { + if (heading.line <= at) current = i + }) + const headings = found.map(({ text, level, line }) => ({ text, level, go: () => gotoLine(line) })) + return { headings, current } + } + + function gotoLine(number: number) { + if (!view) return + const line = view.state.doc.line(number) + view.dispatch({ + selection: { anchor: line.from }, + effects: EditorView.scrollIntoView(line.from, { y: 'start', yMargin: 24 }), + }) + view.focus() + } + + /* + * vim's `]]` and `[[` walk sections, which in a markdown file are the + * headings — the same jump the reader makes, where the text is. `gO` lists + * them, as it does in vim's own help files. + */ + function bindHeadingKeys() { + const step = (direction: 1 | -1) => { + if (!view) return + const at = view.state.doc.lineAt(view.state.selection.main.head).number + const lines = sourceHeadings(view.state.doc.toString()).map((heading) => heading.line) + let target: number | undefined + for (const line of lines) { + if (direction > 0 && line > at) { + target = line + break + } + if (direction < 0 && line < at) target = line + } + gotoLine(target ?? (direction > 0 ? view.state.doc.lines : 1)) + } + Vim.defineAction('workspaceNextHeading', () => step(1)) + Vim.defineAction('workspacePrevHeading', () => step(-1)) + Vim.defineAction('workspaceOutline', () => void runCommand('outline')) + Vim.mapCommand(']]', 'action', 'workspaceNextHeading', {}, { context: 'normal' }) + Vim.mapCommand('[[', 'action', 'workspacePrevHeading', {}, { context: 'normal' }) + Vim.mapCommand('gO', 'action', 'workspaceOutline', {}, { 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. @@ -155,6 +208,7 @@ onMount(() => { bridgeExCommands() bindHelpKey() + bindHeadingKeys() mirrorUnnamedRegisterToClipboard() const extensions = [ @@ -211,6 +265,7 @@ hooks.focusEditor = focus hooks.insertText = insertAtCursor + hooks.headings = editorHeadings view.focus() const cm = workspace.vimEnabled ? getCM(view) : null @@ -239,6 +294,7 @@ if (hooks.focusEditor === focus) { hooks.focusEditor = () => {} hooks.insertText = () => {} + if (hooks.headings === editorHeadings) hooks.headings = () => ({ headings: [], current: 0 }) workspace.vimMode = 'normal' } }) diff --git a/web/src/lib/FilePane.svelte b/web/src/lib/FilePane.svelte index deb644d..11e00f8 100644 --- a/web/src/lib/FilePane.svelte +++ b/web/src/lib/FilePane.svelte @@ -5,7 +5,7 @@ import { renderMarkdown } from './markdown' import { looksLikeText, parseTable, previewKind, separatorOf } from './preview' import { workspace } from './store.svelte' - import { formatBytes, formatStamp, relative, scrollPage } from './util' + import { formatBytes, formatStamp, headingsIn, jumpHeading, readerHeadings, relative, scrollPage } from './util' const node = $derived(workspace.open!) const file = $derived(node.file!) @@ -92,6 +92,22 @@ if (hooks.fileAction === act) hooks.fileAction = () => false } }) + + // Markdown read rendered has headings to jump between, as a document does. + let article = $state() + $effect(() => { + const scroller = reader + const root = article + if (!scroller || !root) return + const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction) + const list = () => readerHeadings(scroller, root) + hooks.jumpHeading = jump + hooks.headings = list + return () => { + if (hooks.jumpHeading === jump) hooks.jumpHeading = () => false + if (hooks.headings === list) hooks.headings = () => ({ headings: [], current: 0 }) + } + })
@@ -161,7 +177,7 @@ {:else if text !== null} {#if kind === 'markdown' && !source} -
{@html html}
+
{@html html}
{:else if kind === 'table'}
diff --git a/web/src/lib/Headings.svelte b/web/src/lib/Headings.svelte new file mode 100644 index 0000000..dbf3e46 --- /dev/null +++ b/web/src/lib/Headings.svelte @@ -0,0 +1,109 @@ + + +
+
+ outline {workspace.outline.length} headings + +
+ +
    + {#each workspace.outline as heading, index (index)} +
  1. + +
  2. + {/each} +
+ +
+ jk move + + ↵ jump there + + qesc close +
+
+ + diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index aa09395..d45c7e1 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -12,6 +12,8 @@ ['j / k · ↓ ↑', 'move down / up the list — scroll the document when it has focus'], ['gg / G', 'first / last row, or top / bottom of the document'], ['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'], ['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/commands.ts b/web/src/lib/commands.ts index 3186146..e1ace7e 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -1,6 +1,6 @@ import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api' import { workspace } from './store.svelte' -import { formatDay, parseDay, parseDue, relative, sealedNote, type ScrollAmount } from './util' +import { formatDay, parseDay, parseDue, relative, sealedNote, type Heading, type ScrollAmount } from './util' export type { ScrollAmount } @@ -22,6 +22,11 @@ export const hooks = { scrollReader: (_amount: ScrollAmount): boolean => false, /* 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. */ + jumpHeading: (_direction: 1 | -1): boolean => false, + /* `gO`: the headings of what is on screen, read or being written, and the + one the reader or the cursor is in. */ + headings: (): { headings: Heading[]; current: number } => ({ headings: [], current: 0 }), /** How many space cards the overview fits in a row, for j/k to step by. */ overviewColumns: (): number => 1, openOverview: () => {}, @@ -762,6 +767,20 @@ export const commands: CommandSpec[] = [ workspace.overlay = 'media' }), }, + { + name: 'outline', + aliases: ['toc'], + group: 'documents', + help: "the open document's headings to pick from — gO, in the editor too", + run: () => { + if (!requireOpen()) return + const { headings, current } = hooks.headings() + if (headings.length === 0) return workspace.say('no headings in this document') + workspace.outline = headings + workspace.outlineCursor = current + workspace.overlay = 'outline' + }, + }, { name: 'search', aliases: ['se'], diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index ffdcfa3..6954a07 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -14,7 +14,7 @@ import { type SpaceOverview, type VaultStatus, } from './api' -import { formatBytes, isTouchDevice, sealedNote } from './util' +import { formatBytes, isTouchDevice, sealedNote, type Heading } from './util' export type Pane = 'list' | 'document' /** The tree of documents, or the board of the space being browsed. */ @@ -101,7 +101,10 @@ class Workspace { vimMode = $state('normal') flash = $state(null) - overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify'>('none') + overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline'>('none') + /** `gO`: the headings of what is on screen, to jump between from the keyboard. */ + outline = $state.raw([]) + outlineCursor = $state(0) /* * What is due or in hand on every board, kept loaded all the time: the bar diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index 476f4eb..f59348d 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -131,6 +131,86 @@ export function scrollPage(el: HTMLElement, amount: ScrollAmount) { } } +/** A heading to jump to: the reader's, or a line of the text being written. */ +export interface Heading { + text: string + level: number + go: () => void +} + +/** Every heading of a rendered article, the document's own title left out. */ +export const headingsIn = (root: HTMLElement) => + Array.from(root.querySelectorAll('h1, h2, h3, h4, h5, h6')).filter( + (element) => !element.classList.contains('entrytitle'), + ) + +/* How far below the top of the view a heading is left by a jump: enough room + that it does not look cut off, little enough that it reads as the top. */ +const HEADING_GAP = 12 + +/** + * `]]` / `[[`: the next heading further down than the top of the view, or the + * last one above it. A heading a jump has just parked at the top counts as + * the one being read, so pressing again moves on rather than staying put. + */ +export function jumpHeading(scroller: HTMLElement, headings: HTMLElement[], direction: 1 | -1): boolean { + const top = scroller.getBoundingClientRect().top + const offset = (element: HTMLElement) => element.getBoundingClientRect().top - top + let target: HTMLElement | undefined + for (const element of headings) { + const at = offset(element) + if (direction > 0 && at > HEADING_GAP + 4) { + target = element + break + } + if (direction < 0 && at < HEADING_GAP - 4) target = element + } + if (!target) return false + scroller.scrollBy({ top: offset(target) - HEADING_GAP }) + return true +} + +/** + * `gO` in the reader: its headings, and which of them is being read — the + * last one that has passed the upper third of the view, as the outline lights. + */ +export function readerHeadings(scroller: HTMLElement, root: HTMLElement): { headings: Heading[]; current: number } { + const elements = headingsIn(root) + const top = scroller.getBoundingClientRect().top + const line = top + scroller.clientHeight / 3 + let current = 0 + elements.forEach((element, i) => { + if (element.getBoundingClientRect().top <= line) current = i + }) + const headings = elements.map((element) => ({ + text: element.textContent?.trim() ?? '', + level: Number(element.tagName[1]), + go: () => scroller.scrollBy({ top: element.getBoundingClientRect().top - top - HEADING_GAP }), + })) + return { headings, current } +} + +/** + * The `#` lines of markdown source, with the line each stands on. Lines inside + * a fenced block are code, whatever they start with. + */ +export function sourceHeadings(source: string): { text: string; level: number; line: number }[] { + const found: { text: string; level: number; line: number }[] = [] + let fence: string | null = null + source.split('\n').forEach((text, i) => { + const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(text)?.[1] + if (marker) { + if (fence === null) fence = marker[0] + else if (marker[0] === fence) fence = null + return + } + if (fence !== null) return + const heading = /^\s{0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/.exec(text) + if (heading && heading[2]) found.push({ text: heading[2], level: heading[1].length, line: i + 1 }) + }) + return found +} + /** 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` : ''