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>