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'}
+
+ outline {workspace.outline.length} headings
+
+
+
+
+ {#each workspace.outline as heading, index (index)}
+
+
+
+ {/each}
+
+
+
+
+
+
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` : ''