mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
The help was a box narrower than the screen that had to be scrolled with the mouse. It is now as big as the porthole, in newspaper columns that run on to the right: h/l and j/k move a column, Ctrl-D/U and Space a screen, g/G to either end. A row is never split between columns, and says what it says before its first dash; / filters the rows by key or words, and shows the ones left whole. The prose footer became an 'in the editor' group.
362 lines
15 KiB
Svelte
362 lines
15 KiB
Svelte
<script lang="ts" module>
|
|
/*
|
|
* The help is a sheet the size of the porthole, laid out in newspaper
|
|
* columns that run on to the right, so all of it is in view at once on a
|
|
* wide screen and a key away on a narrow one. Its keys live in App.svelte
|
|
* with the others; these are what they move.
|
|
*/
|
|
let pages: HTMLElement | undefined = $state()
|
|
let field: HTMLInputElement | undefined = $state()
|
|
/** `/` in the help: only the rows whose key or words hold this. */
|
|
export const helpFilter = $state({ text: '' })
|
|
|
|
/** A column on or back, a screen on or back, or to either end. */
|
|
export function scrollHelp(by: 1 | -1 | 'pagedown' | 'pageup' | 'start' | 'end') {
|
|
if (!pages) return
|
|
// Columns that run off the right edge scroll sideways; on a phone they
|
|
// stack, and the sheet around them scrolls down instead.
|
|
const sideways = pages.scrollWidth > pages.clientWidth
|
|
const scroller = sideways ? pages : pages.parentElement
|
|
if (!scroller) return
|
|
const axis = sideways ? 'left' : 'top'
|
|
if (by === 'start' || by === 'end') {
|
|
scroller.scrollTo({ [axis]: by === 'start' ? 0 : 1e9, behavior: 'smooth' })
|
|
return
|
|
}
|
|
const page = sideways ? scroller.clientWidth : scroller.clientHeight
|
|
// A row is never broken over columns, so it is as wide as one of them.
|
|
const column = sideways ? (pages.querySelector<HTMLElement>('.row')?.offsetWidth ?? 0) + COLUMN_GAP : page / 2
|
|
const distance = typeof by === 'number' ? by * column : (by === 'pagedown' ? 1 : -1) * page
|
|
scroller.scrollBy({ [axis]: distance, behavior: 'smooth' })
|
|
}
|
|
|
|
export function focusHelpFilter() {
|
|
field?.focus()
|
|
field?.select()
|
|
}
|
|
|
|
const COLUMN_GAP = 28
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { commands, groups, type CommandSpec, type Group } from './commands'
|
|
import { workspace } from './store.svelte'
|
|
import { isTouchDevice } from './util'
|
|
|
|
const keys: [string, string][] = [
|
|
['1 … 9 · 0', 'switch to that space, like a workspace'],
|
|
['Shift 1 … 9', 'send the marked nodes, or the selected one, to that space'],
|
|
['Tab · Shift-Tab', 'next / previous space'],
|
|
['w', 'overview of every space — hjkl, then Enter'],
|
|
['f', 'fullscreen the focused pane or tile; again or Esc to leave'],
|
|
['Alt-h/j/k/l', 'focus the tile that way, from the editor too — left of the leftmost is the list'],
|
|
['Alt-Shift-h/j/k/l', 'move the focused tile that way'],
|
|
['L · Shift-Enter', 'open the row in a new tile beside the others'],
|
|
['gt / gT', 'next / previous tile — in the editor too'],
|
|
['Alt-q', 'close the focused tile — :close; :only closes the others'],
|
|
['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 — a first g, d, ] or [ waits a second in the status line'],
|
|
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
|
|
['zl / zh · zL / zH', 'the wide table or code block at the top of the view, sideways — a little, or half its width'],
|
|
[']] / [[', 'next / previous heading of the document — in the editor too'],
|
|
['gO', 'the headings as a list: j/k, Enter jumps there'],
|
|
['F', 'hints over the links and buttons in view — type one to follow it'],
|
|
['gf / gx', 'in the editor: follow the link under the cursor / open it in a tab'],
|
|
['gx', 'open the file under the cursor in a tab of its own'],
|
|
['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'],
|
|
['O', 'new folder here'],
|
|
['i · a', 'edit the open document'],
|
|
['R', 'rename what is under the cursor — document, folder or card — starting from its name'],
|
|
['m / p', 'pick nodes up, then drop them all into the folder you walk to'],
|
|
['M', 'pick up the whole folder — again puts it all back'],
|
|
['Ctrl / Shift-click', 'pick a row up · every row up to it — drag them onto a folder'],
|
|
['t', 'agenda — late, due this week, in hand, on every board'],
|
|
['T', "today's diary entry, started if there is none — from templates/day when the diary has one"],
|
|
['C', 'the diary by month: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day'],
|
|
['[d / ]d', 'the diary entry before / after the one open, by date'],
|
|
['Ctrl-P', 'find anything, in every space — Enter goes to the result lit'],
|
|
['b', "open this space's board"],
|
|
['c', "capture a card into this space's inbox, without leaving here"],
|
|
['Esc · q', 'close the document, and its tile when there are several'],
|
|
['/', 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one'],
|
|
['n / N', 'next / previous search result, opened beside the results — :noh clears the search'],
|
|
['s', 'toggle sharing for the document — asks before taking it back'],
|
|
['y', 'mint a new share link and copy it — asks, the old one stops working'],
|
|
['x · dd', 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)'],
|
|
['u', 'undo the last delete: it comes back out of the trash — :trash has the rest'],
|
|
['gh', "the open document's earlier versions, as diffs — Enter brings one back"],
|
|
|
|
['gb', 'the documents that link to the open one — [[title]] links by title, [[ completes one while writing'],
|
|
[':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'],
|
|
['Ctrl-S', 'save, from anywhere'],
|
|
['?', 'this screen, from the editor too — h/l a column, / filters it down'],
|
|
]
|
|
|
|
/* The board rebinds most keys, so it gets its own column rather than
|
|
footnotes on the list keys. */
|
|
const boardKeys: [string, string][] = [
|
|
['rule', 'the first list is the inbox, the last list is done'],
|
|
['h / l', 'to the list left / right'],
|
|
['j / k', 'down / up the cards'],
|
|
['gg / G', 'first / last card of the list'],
|
|
['J / K', 'move the card down / up its list'],
|
|
['H / L', 'move the card to the list left / right'],
|
|
['Space', 'into the done list, or back out to the inbox'],
|
|
['Enter', "the card's notes — a document, made on first use; q comes back"],
|
|
['o · c', 'new card on this list'],
|
|
['D', 'due date: fri, tm, +3d, 10-31 …'],
|
|
['R', 'rename the card, or the list when it is empty'],
|
|
['z', 'show done cards older than a week'],
|
|
['F', 'a letter over every card in view — type it to put the cursor there'],
|
|
['r', 'read the board again, for changes made elsewhere'],
|
|
['x · dd', 'delete the card (asks y/N)'],
|
|
['q · Esc', 'back to the tree'],
|
|
]
|
|
|
|
const agendaKeys: [string, string][] = [
|
|
['j / k', 'down / up the cards, then what was written on this day in earlier years'],
|
|
['Enter', 'to the card, on its own board — or to the entry'],
|
|
['Space', 'done, where it stands'],
|
|
['D', 'due date: fri, tm, +3d … — :due on the line'],
|
|
[':', 'the line, with the agenda kept up — :repeat weekly acts on the card'],
|
|
['H / L', 'to the list left / right on its board'],
|
|
['T', "today's diary entry"],
|
|
['t · q · Esc', 'close the agenda'],
|
|
]
|
|
|
|
const titles: Record<Group, string> = {
|
|
tree: 'spaces & folders',
|
|
board: 'boards',
|
|
documents: 'documents',
|
|
tiles: 'tiles',
|
|
editing: 'writing',
|
|
sharing: 'sharing & export',
|
|
vault: 'vault',
|
|
view: 'session',
|
|
}
|
|
|
|
type Row = { key: string; args?: string; description: string }
|
|
|
|
const fromPairs = (pairs: [string, string][]): Row[] =>
|
|
pairs.map(([key, description]) => ({ key, description }))
|
|
|
|
/** `:write(w)` — the name first, then whatever else reaches it. */
|
|
const signature = (spec: CommandSpec) =>
|
|
`:${spec.name}${spec.aliases?.length ? `(${spec.aliases.join(' ')})` : ''}`
|
|
|
|
const fromSpecs = (specs: CommandSpec[]): Row[] =>
|
|
specs.flatMap((spec) => [
|
|
{ key: signature(spec), args: spec.args, description: spec.help },
|
|
...(spec.bang ? [{ key: `:${spec.name}!`, description: spec.bang.help }] : []),
|
|
])
|
|
|
|
const editorKeys: [string, string][] = [
|
|
['vim', 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes'],
|
|
['drop · paste', 'an image, video or audio file attaches it'],
|
|
['$x^2$ · $$…$$', 'set as math'],
|
|
[':12', 'jumps straight to entry 12'],
|
|
['y · d', 'copied to the system clipboard — :set noclipboard keeps them in the registers'],
|
|
['files', 'open in place — pdf, text and code, markdown, csv, certificates and keystores'],
|
|
]
|
|
|
|
const sections: [string, Row[]][] = [
|
|
['keys', fromPairs(keys)],
|
|
['board keys', fromPairs(boardKeys)],
|
|
['agenda keys', fromPairs(agendaKeys)],
|
|
['in the editor', fromPairs(editorKeys)],
|
|
...groups.map((group: Group) => [titles[group], fromSpecs(commands.filter((spec) => spec.group === group))] as [string, Row[]]),
|
|
]
|
|
|
|
const shown = $derived.by(() => {
|
|
const words = helpFilter.text.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
|
if (words.length === 0) return sections
|
|
const hit = (row: Row) => {
|
|
const text = `${row.key} ${row.args ?? ''} ${row.description}`.toLowerCase()
|
|
return words.every((word) => text.includes(word))
|
|
}
|
|
return sections.map(([title, rows]) => [title, rows.filter(hit)] as [string, Row[]]).filter(([, rows]) => rows.length > 0)
|
|
})
|
|
const count = $derived(shown.reduce((sum, [, rows]) => sum + rows.length, 0))
|
|
const filtering = $derived(helpFilter.text.trim() !== '')
|
|
|
|
/* What a row says before its first dash is enough to find it by; the rest
|
|
comes back once a filter has narrowed the sheet down to a few. */
|
|
const brief = (description: string) => description.split(' — ')[0]
|
|
/** The key column of a section, as wide as its longest key, within reason. */
|
|
const keyWidth = (rows: Row[]) => Math.min(Math.max(...rows.map((row) => Math.max(row.key.length, row.args?.length ?? 0))), 14)
|
|
|
|
// A filter left over from last time would hide rows without saying so.
|
|
helpFilter.text = ''
|
|
|
|
/* The field takes its own Enter and Esc, so they settle the filter
|
|
instead of closing the help underneath it. */
|
|
function filterKey(event: KeyboardEvent) {
|
|
if (event.key !== 'Escape' && event.key !== 'Enter') return
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
if (event.key === 'Escape') helpFilter.text = ''
|
|
field?.blur()
|
|
field?.closest<HTMLElement>('.overlay')?.focus()
|
|
}
|
|
</script>
|
|
|
|
<div class="sheet">
|
|
<header>
|
|
<span class="label rule"><span class="marker"></span><span class="title">help</span> {count} keys and commands</span>
|
|
<label class="filter" class:on={helpFilter.text !== ''}>
|
|
<span class="slash">/</span>
|
|
<input
|
|
bind:this={field}
|
|
bind:value={helpFilter.text}
|
|
onkeydown={filterKey}
|
|
placeholder="filter"
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
/>
|
|
</label>
|
|
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
|
</header>
|
|
|
|
<div class="cols" bind:this={pages}>
|
|
{#each shown as [title, rows] (title)}
|
|
<section style="--key: {keyWidth(rows)}ch">
|
|
<h2>{title}</h2>
|
|
<dl>
|
|
{#each rows as row (row.key)}
|
|
{@const short = filtering ? row.description : brief(row.description)}
|
|
<div class="row">
|
|
<dt>{row.key}{#if row.args}<span class="args">{row.args}</span>{/if}</dt>
|
|
<dd>{short}{#if short !== row.description && !short.endsWith('…')}<span class="more" title={row.description}> …</span>{/if}</dd>
|
|
</div>
|
|
{/each}
|
|
</dl>
|
|
</section>
|
|
{:else}
|
|
<p class="none">nothing matches "{helpFilter.text}"</p>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if !isTouchDevice}
|
|
<footer class="label">
|
|
<span class="kbd">h</span><span class="kbd">l</span> column
|
|
<span class="gap"></span>
|
|
<span class="kbd">⌃d</span><span class="kbd">⌃u</span> page
|
|
<span class="gap"></span>
|
|
<span class="kbd">g</span><span class="kbd">G</span> start · end
|
|
<span class="gap"></span>
|
|
<span class="kbd">/</span> filter, and read the lines whole
|
|
<span class="gap"></span>
|
|
<span class="kbd">?</span><span class="kbd">esc</span> close
|
|
</footer>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
/* A free-standing panel, as the desktop draws one: hull, one rim, the
|
|
frame's radius — as big as the porthole, so nothing hides below it. */
|
|
.sheet {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 0;
|
|
background: color-mix(in oklab, var(--hull) 97%, transparent);
|
|
border: 1px solid var(--surface-alt);
|
|
border-radius: var(--frame-radius);
|
|
padding: 18px 22px 14px;
|
|
}
|
|
|
|
header { display: flex; align-items: center; gap: 12px; }
|
|
header .label { flex: 1; }
|
|
header .marker { background: var(--light); }
|
|
header .title { color: var(--light); }
|
|
|
|
.filter {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
width: 24ch;
|
|
padding: 0 8px;
|
|
border: 1px solid var(--rim);
|
|
border-radius: var(--radius-s);
|
|
color: var(--muted);
|
|
}
|
|
.filter:focus-within, .filter.on { border-color: var(--outline); color: var(--light); }
|
|
.filter input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: 4px 0;
|
|
border: 0;
|
|
background: none;
|
|
color: var(--light);
|
|
font: inherit;
|
|
font-size: 12px;
|
|
outline: none;
|
|
}
|
|
|
|
/*
|
|
* Newspaper columns of a fixed height, so they fill across before they
|
|
* run off the right edge, where h/l reach them. A group may break between
|
|
* columns, but never straight after its heading.
|
|
*/
|
|
.cols {
|
|
flex: 1;
|
|
min-height: 0;
|
|
columns: 40ch;
|
|
column-gap: 28px;
|
|
column-fill: auto;
|
|
column-rule: 1px solid var(--surface-alt);
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
section { margin: 0 0 12px; }
|
|
|
|
h2 {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin: 0 0 6px;
|
|
font-size: 10px;
|
|
font-weight: 400;
|
|
letter-spacing: 0.22em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
break-after: avoid;
|
|
}
|
|
h2::after { content: ''; height: 1px; flex: 1; background: var(--overlay); }
|
|
|
|
/* One row is never split between columns. The key column is as wide as
|
|
the section's longest key up to a cap, so one long signature cannot
|
|
squeeze every description down; arguments wrap under the name. */
|
|
dl { margin: 0; font-size: 12px; line-height: 1.35; }
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: var(--key) minmax(0, 1fr);
|
|
gap: 12px;
|
|
padding: 1px 0;
|
|
break-inside: avoid;
|
|
}
|
|
dt { color: var(--light); overflow-wrap: break-word; }
|
|
.args { display: block; font-size: 10px; color: var(--muted); }
|
|
dd { margin: 0; color: var(--subtle); overflow-wrap: break-word; }
|
|
.more { color: var(--outline); }
|
|
|
|
.none { margin: 0; color: var(--subtle); font-size: 12px; }
|
|
|
|
footer { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 10px; }
|
|
footer .kbd { margin-right: 2px; }
|
|
footer .gap { width: 14px; }
|
|
|
|
/* A phone stacks the columns and scrolls the sheet down. */
|
|
@media (max-width: 767px) {
|
|
.sheet { overflow-y: auto; }
|
|
.cols { flex: none; columns: auto; overflow: visible; }
|
|
.filter { width: 14ch; }
|
|
}
|
|
</style>
|