mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
keys: one table drives dispatch, help, footers, the rail and the wiki
keymap.ts lists every key with its context, action and help line. actions.ts holds what each one does, typed against the table so a key without an action, or an action without a key, does not compile. modes.ts stacks the contexts (always > sheet > drawer > workspace > writing > focused tile > special > global) and writes the two-key pending logic once. App.svelte's 480-line onkeydown is now one call. Help, the panel footers (a shared Keys component), the rail's keycaps and Commands.md all read the table; the wiki gains the card and scratchpad key tables. One fix on the way: Ctrl-S in the editor no longer saves twice, which could make the second save fail as "changed somewhere else".
This commit is contained in:
1 parent
7f8a3816d3
commit
0eabcb2333
23 files changed
+1215
-1523
No files matched your search
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Which keys are heard right now, and in what order: a stack of the
|
||||
* contexts in keymap.ts, the topmost first. A key goes down the stack until
|
||||
* a context takes it.
|
||||
*
|
||||
* always Ctrl-S, Alt-h/j/k/l — heard in the editor too
|
||||
* a sheet help, trash, the card's back …; nothing under one hears a key
|
||||
* drawer the scratchpad, while it is down
|
||||
* workspace Esc out of fullscreen, Q
|
||||
* writing a document being written takes every plain key back
|
||||
* tile the focused kind: tree, board, agenda or calendar
|
||||
* special what the three tiles of the special workspace share
|
||||
* global spaces, t, T, C, S, :, ?
|
||||
*
|
||||
* The `[y/N]` prompt sits over all of it, in App.svelte, and the command
|
||||
* line keeps its own keys while it has them.
|
||||
*/
|
||||
import { actions, type Run } from './actions'
|
||||
import { bindings, type Binding, type Context } from './keymap'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
/** The contexts that are sheets: one is up when the overlay of that name is. */
|
||||
const sheets = new Set<string>(['help', 'overview', 'outline', 'media', 'trash', 'backlinks', 'history', 'card'])
|
||||
|
||||
/** The stack, the topmost first. */
|
||||
export function stack(): Context[] {
|
||||
const modes: Context[] = ['always']
|
||||
const overlay = workspace.overlay
|
||||
if (overlay !== 'none') {
|
||||
// While the description is written, the card's back has no keys of its own.
|
||||
if (sheets.has(overlay) && !(overlay === 'card' && workspace.describing)) modes.push(overlay as Context)
|
||||
return [...modes, 'sheet']
|
||||
}
|
||||
if (workspace.scratchOpen && !workspace.scratchEditing) modes.push('drawer')
|
||||
modes.push('workspace')
|
||||
if (workspace.view === 'special') modes.push(workspace.specialFocus, 'special')
|
||||
else {
|
||||
if (workspace.editing && workspace.pane === 'document') modes.push('writing')
|
||||
modes.push('tree')
|
||||
}
|
||||
modes.push('global')
|
||||
return modes
|
||||
}
|
||||
|
||||
const lone = new Set(['Shift', 'Control', 'Alt', 'AltGraph', 'Meta', 'CapsLock', 'OS'])
|
||||
|
||||
/**
|
||||
* A key as the table spells it: `j`, `G`, `Enter`, `Shift-Tab`, `Ctrl-d`,
|
||||
* `Alt-Shift-h`, `1`, `Shift-1`. Digits, and letters under Alt, are read
|
||||
* from `event.code`, so they are the same key on every layout — and on a
|
||||
* Mac, where Alt turns h into ˙. Shift is only spelled out where the key
|
||||
* itself does not already say it. A modifier on its own is no key at all.
|
||||
*/
|
||||
export function keyName(event: KeyboardEvent): string | null {
|
||||
if (lone.has(event.key)) return null
|
||||
const digit = /^Digit(\d)$/.exec(event.code)?.[1]
|
||||
const letter = event.altKey && !event.ctrlKey && !event.metaKey ? /^Key([A-Z])$/.exec(event.code)?.[1].toLowerCase() : undefined
|
||||
const key = digit ?? letter ?? (event.key === ' ' ? 'Space' : event.key)
|
||||
const shift = event.shiftKey && (key.length > 1 || digit !== undefined || letter !== undefined)
|
||||
const mods = [event.ctrlKey && 'Ctrl', event.metaKey && 'Meta', event.altKey && 'Alt', shift && 'Shift']
|
||||
return [...mods.filter(Boolean), key].join('-')
|
||||
}
|
||||
|
||||
const plain = (name: string) => !/^(Ctrl|Meta|Alt)-/.test(name)
|
||||
|
||||
const byContext = new Map<Context, Binding[]>()
|
||||
for (const binding of bindings as readonly Binding[]) {
|
||||
if (!binding.keys) continue
|
||||
byContext.set(binding.context, [...(byContext.get(binding.context) ?? []), binding])
|
||||
}
|
||||
|
||||
function find(context: Context, name: string): Binding | undefined {
|
||||
const own = byContext.get(context) ?? []
|
||||
// A shifted key nothing asked for is the key itself: Shift-Space is Space.
|
||||
return own.find((binding) => binding.keys!.includes(name)) ?? (name.startsWith('Shift-') ? find(context, name.slice(6)) : undefined)
|
||||
}
|
||||
|
||||
function run(binding: Binding, name: string, event: KeyboardEvent): boolean {
|
||||
const action = (actions[binding.context] as Record<string, Run>)[binding.action!]
|
||||
return action(name, event) !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a key to the stack. `pending` is the first half of a two-key `gg`,
|
||||
* `dd`, `]]` … still waiting; what comes back is the half waiting now.
|
||||
*/
|
||||
export function dispatch(event: KeyboardEvent, pending: string, line: boolean, typing: boolean): string {
|
||||
const name = keyName(event)
|
||||
if (name === null) return pending
|
||||
// What the editor took for itself — its own Ctrl-S among them — is done;
|
||||
// running it again here would send a second save racing the first.
|
||||
if (typing && event.defaultPrevented) return pending
|
||||
const listening = !line && !typing
|
||||
const heard = (binding: Binding | undefined) => !!binding && (listening || !!binding.typing)
|
||||
|
||||
// A sheet hears what it hears and lets nothing past it; the drawer and a
|
||||
// document being written take every plain key that reaches them.
|
||||
const reach: Context[] = []
|
||||
for (const context of stack()) {
|
||||
reach.push(context)
|
||||
if (context === 'sheet') break
|
||||
if (plain(name) && listening && byContext.get(context)?.some((binding) => binding.keys!.includes('*'))) break
|
||||
}
|
||||
|
||||
const take = (binding: Binding, key: string) => {
|
||||
if (!run(binding, key, event)) return false
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
// The second half of a pair goes first, wherever in the stack the pair is.
|
||||
if (pending && listening) {
|
||||
for (const context of reach) {
|
||||
const binding = find(context, pending + name)
|
||||
if (heard(binding) && take(binding!, pending + name)) return ''
|
||||
}
|
||||
}
|
||||
for (const context of reach) {
|
||||
const binding = find(context, name)
|
||||
if (heard(binding) && take(binding!, name)) return ''
|
||||
// The first half of a pair this context knows waits for the second.
|
||||
if (listening && !binding && byContext.get(context)?.some((entry) => entry.keys!.some((key) => key.length === 2 && key[0] === name))) {
|
||||
event.preventDefault()
|
||||
return name
|
||||
}
|
||||
const rest = find(context, '*')
|
||||
if (listening && plain(name) && heard(rest) && take(rest!, name)) return ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
Reference in new issue
Block a user