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.
This commit is contained in:
nvrl committed 2026-10-20 13:52:24 +02:00
1 parent 007fb1d3e3
commit 6a8ffdf408
10 files changed
+368 -10

No files matched your search

+49 -3
View File
@@ -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 @@
<!-- 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')}
>
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else}<MediaBrowser />{/if}
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else}<MediaBrowser />{/if}
</div>
{/if}
</div>
+25 -1
View File
@@ -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
+56
View File
@@ -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<HTMLDivElement>()
@@ -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'
}
})
+18 -2
View File
@@ -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<HTMLElement>()
$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 })
}
})
</script>
<section class="pane">
@@ -161,7 +177,7 @@
{:else if text !== null}
{#if kind === 'markdown' && !source}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
<article class="md">{@html html}</article>
<article class="md" bind:this={article}>{@html html}</article>
{:else if kind === 'table'}
<div class="table">
<table>
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
/*
* `gO`: the headings of what is on screen, as a list to walk — the outline
* in the margin, for when there is no margin, or no pointer to click it
* with. The keys live in App.svelte with the other overlays'.
*/
import { workspace } from './store.svelte'
let sheet = $state<HTMLDivElement>()
$effect(() => {
const index = workspace.outlineCursor
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
// The shallowest level in the document is the left edge, so a text that
// starts at `##` is not indented by a level it never uses.
const top = $derived(Math.min(...workspace.outline.map((heading) => heading.level)))
function go(index: number) {
const heading = workspace.outline[index]
workspace.overlay = 'none'
heading?.go()
}
</script>
<div class="sheet" bind:this={sheet}>
<header>
<span class="label rule"><span class="marker"></span><span class="title">outline</span> {workspace.outline.length} headings</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<ol>
{#each workspace.outline as heading, index (index)}
<li style="--depth: {heading.level - top}">
<button
class="row"
class:at={index === workspace.outlineCursor}
data-index={index}
onclick={() => go(index)}
onpointerenter={() => (workspace.outlineCursor = index)}
>{heading.text}</button>
</li>
{/each}
</ol>
<footer class="label">
<span class="kbd">j</span><span class="kbd">k</span> move
<span class="gap"></span>
<span class="kbd">↵</span> jump there
<span class="gap"></span>
<span class="kbd">q</span><span class="kbd">esc</span> close
</footer>
</div>
<style>
.sheet {
display: flex;
flex-direction: column;
gap: 16px;
width: min(100%, 64ch);
max-height: 100%;
overflow-y: auto;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
ol { list-style: none; margin: 0; padding: 0; }
li { padding-left: calc(var(--depth) * 18px); }
.row {
display: block;
width: 100%;
margin: 1px 0;
padding: 6px 10px;
border: 0;
border-radius: var(--radius-s);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-sans);
font-size: 14px;
letter-spacing: 0;
text-transform: none;
color: var(--fg);
}
.row:hover { background: var(--surface-alt); border-color: transparent; }
.row:active { transform: none; }
.row.at { background: var(--light); color: var(--ink); }
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
footer .kbd { margin-right: 2px; }
footer .gap { width: 14px; }
@media (max-width: 767px), (pointer: coarse) {
footer { display: none; }
}
@media (max-width: 767px) {
.sheet { padding: 14px; gap: 14px; }
}
</style>
+2
View File
@@ -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'],
+20 -1
View File
@@ -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'],
+5 -2
View File
@@ -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<Flash>(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<Heading[]>([])
outlineCursor = $state(0)
/*
* What is due or in hand on every board, kept loaded all the time: the bar
+80
View File
@@ -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<HTMLElement>('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` : ''