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` : ''