diff --git a/README.md b/README.md index b1758d9..882648c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,9 @@ works once the frontend has been built at least once. ## Keys -The desktop UI is modal. In the browse pane: +The desktop UI is modal, and a key acts on whatever has focus: in the list +that is the row under the cursor, even while another document is open beside +it; in the document pane it is that document. In the browse pane: | key | action | | --- | --- | @@ -65,7 +67,7 @@ The desktop UI is modal. In the browse pane: | `h` | up one level in the tree | | `/` | full-text search (`n` clears it) | | `s` / `y` | toggle sharing / mint a new share link | -| `x`, `dd` | delete the entry | +| `x`, `dd` | delete the entry under the cursor (asks `y`/`N`) | | `:` | command line | | `Ctrl-S` | save from anywhere | | `?` | help | diff --git a/web/src/App.svelte b/web/src/App.svelte index 0c5be2f..7a54edb 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -9,6 +9,7 @@ import Login from './lib/Login.svelte' import Overview from './lib/Overview.svelte' import MediaBrowser from './lib/MediaBrowser.svelte' + import Prompt from './lib/Prompt.svelte' import Rail from './lib/Rail.svelte' import Shared from './lib/Shared.svelte' import Sidebar from './lib/Sidebar.svelte' @@ -58,6 +59,23 @@ } }) + /* + * A pending `[y/N]` takes the next key before anything else sees it — the + * editor included, where `y` would otherwise start a yank. Only `y` agrees; + * every other key, Escape among them, declines. + */ + $effect(() => { + const answer = (event: KeyboardEvent) => { + const prompt = workspace.prompt + if (!prompt || ['Shift', 'Control', 'Alt', 'Meta', 'CapsLock'].includes(event.key)) return + event.preventDefault() + event.stopPropagation() + prompt.resolve(event.key === 'y' || event.key === 'Y') + } + window.addEventListener('keydown', answer, true) + return () => window.removeEventListener('keydown', answer, true) + }) + // Guard against losing a half-written entry to a stray refresh. $effect(() => { if (!workspace.dirty) return @@ -178,9 +196,9 @@ /* * The board reads the same keys as the list, one dimension wider: h/l walk - * the lists, j/k the cards, and the shifted pair takes the card along. Only - * `:` and `?` fall through to the keys below, because everything else would - * act on a tree that is not on screen. + * the lists, j/k the cards, and the shifted pair takes the card along. + * Nothing falls through to the tree keys, because they would act on a tree + * that is not on screen. */ function boardKey(key: string): boolean { switch (key) { @@ -331,7 +349,12 @@ workspace.cardCursor = 0 return } - if (key === 'g') { + if (previous === 'd' && key === 'd') { + event.preventDefault() + void runCommand('delete') + return + } + if (key === 'g' || key === 'd') { pending = key return } @@ -339,6 +362,12 @@ event.preventDefault() return } + if (key === ':' || key === '?') { + event.preventDefault() + if (key === ':') cmdline = ':' + else void runCommand('help') + } + return } /* @@ -444,7 +473,7 @@ break case 's': event.preventDefault() - void runCommand(workspace.selected?.shared ? 'unshare' : 'share') + void runCommand(workspace.target?.shared ? 'unshare' : 'share') break case 'y': event.preventDefault() @@ -543,6 +572,8 @@ (cmdline = null)} /> {/if} + + {#if workspace.overlay !== 'none'}
e.target === e.currentTarget && (workspace.overlay = 'none')} diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 8f7f5fa..91f1bd7 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -17,7 +17,7 @@ ['o', 'new document here, straight into insert mode'], ['O', 'new folder here'], ['i · a', 'edit the open document'], - ['R', 'rename what is marked — document, folder or card'], + ['R', 'rename what is under the cursor — document, folder or card'], ['m / p', 'pick a node up, then drop it into the folder you walk to'], ['b', "open this space's board"], ['Esc · q', 'close the document'], @@ -25,7 +25,7 @@ ['n', 'clear the search'], ['s', 'toggle sharing for the document'], ['y', 'mint a new share link and copy it'], - ['x · dd', 'delete what is selected (asks first)'], + ['x · dd', 'delete what has focus — the row in the list, the document in its pane (asks y/N)'], [':', 'command line'], ['Ctrl-S', 'save, from anywhere'], ['?', 'this screen — in the editor too'], diff --git a/web/src/lib/Prompt.svelte b/web/src/lib/Prompt.svelte new file mode 100644 index 0000000..8a25dda --- /dev/null +++ b/web/src/lib/Prompt.svelte @@ -0,0 +1,45 @@ + + +{#if workspace.prompt} +
+ {workspace.prompt.text} + + + + +
+{/if} + + diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index d2d78ba..4c9af27 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -234,7 +234,9 @@ export const commands: CommandSpec[] = [ bang: { help: 'add a card linked to the open document — Enter on it opens the document', run: async (arg) => { - const node = workspace.open ?? workspace.selected + // On the board the tree is not on screen, so only the open document + // can be meant; elsewhere it is whatever has focus. + const node = workspace.view === 'board' ? workspace.open : workspace.target if (!node || node.kind !== 'document') { return workspace.say('no document to link — open one first', 'error') } @@ -287,7 +289,8 @@ export const commands: CommandSpec[] = [ if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error') const list = workspace.list if (!list) return workspace.say('no list selected', 'error') - if (!confirm(`delete the list "${list.name}" and its ${list.cards.length} cards?`)) return + const cards = list.cards.length + if (!(await workspace.ask(`delete the list "${list.name}" and its ${cards} card${cards === 1 ? '' : 's'}?`))) return await workspace.guard(() => workspace.deleteList()) }, }, @@ -299,7 +302,7 @@ export const commands: CommandSpec[] = [ group: 'tree', help: 'move the selected node into a folder here, or up one level', run: async (arg) => { - const node = workspace.open ?? workspace.selected + const node = workspace.target if (!node) return workspace.say('nothing selected', 'error') const target = arg.trim() if (!target) return workspace.say('usage: :mv ', 'error') @@ -364,7 +367,11 @@ export const commands: CommandSpec[] = [ group: 'editing', help: 'edit the open document, or open a node by id', run: async (arg) => { - const id = arg ? Number(arg) : workspace.open?.id ?? workspace.selected?.id + const node = arg ? undefined : workspace.target + if (node && node.kind !== 'document') { + return workspace.say(`${node.slug} is a ${node.kind} — l opens it`, 'error') + } + const id = arg ? Number(arg) : node?.id if (!id || Number.isNaN(id)) return workspace.say('usage: :e ', 'error') await workspace.guard(() => workspace.openNode(id, true)) hooks.focusEditor() @@ -424,17 +431,20 @@ export const commands: CommandSpec[] = [ if (workspace.view === 'board') { const card = workspace.card if (!card) return workspace.say('no card selected', 'error') - if (!confirm(`delete the card "${card.title}"?`)) return + if (!(await workspace.ask(`delete the card "${card.title}"?`))) return return workspace.guard(() => workspace.deleteCard()) } - const id = arg ? Number(arg) : workspace.open?.id ?? workspace.selected?.id + // Whatever has focus: the row under the cursor in the list, the open + // document only while the document pane is the one being looked at. + const id = arg ? Number(arg) : workspace.target?.id if (!id || Number.isNaN(id)) return workspace.say('nothing to delete', 'error') const node = workspace.open?.id === id ? workspace.open : workspace.nodes.find((n) => n.id === id) + const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}` const what = node && node.kind !== 'document' - ? `delete ${node.kind} "${node.name}" and everything in it?` - : `delete #${id}?` - if (!confirm(`${what} this cannot be undone.`)) return + ? `delete the ${node.kind} ${name} and everything in it?` + : `delete ${name}?` + if (!(await workspace.ask(`${what} this cannot be undone.`))) return await workspace.guard(() => workspace.deleteNode(id)) }, }, @@ -453,13 +463,13 @@ export const commands: CommandSpec[] = [ // The open document is renamed through the draft, so an unsaved body is // written with the new name instead of being left behind by it. - if (workspace.open) { + const node = workspace.target + if (node && node.id === workspace.open?.id) { workspace.draft.name = arg workspace.dirty = true return workspace.guard(() => workspace.save()) } - const node = workspace.selected if (!node) return workspace.say('nothing selected — :name! renames the space', 'error') await workspace.guard(() => workspace.renameNode(node.id, arg)) }, @@ -485,7 +495,7 @@ export const commands: CommandSpec[] = [ ? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) : workspace.space if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error') - if (!confirm(`delete the space "${space.name}" and everything in it? this cannot be undone.`)) { + if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? this cannot be undone.`))) { return } await workspace.guard(() => workspace.deleteSpace(space.id)) @@ -510,9 +520,9 @@ export const commands: CommandSpec[] = [ group: 'sharing', help: 'publish the document behind an unguessable link and copy it', run: async () => { - const id = workspace.open?.id ?? workspace.selected?.id + const node = workspace.target + const id = node?.id if (!id) return workspace.say('nothing selected', 'error') - const node = workspace.open?.id === id ? workspace.open : workspace.selected if (node?.shared) return workspace.say('already shared — :link for a new link, :unshare to revoke') await workspace.guard(() => workspace.toggleShare(id)) }, @@ -522,9 +532,9 @@ export const commands: CommandSpec[] = [ group: 'sharing', help: 'revoke the share link', run: async () => { - const id = workspace.open?.id ?? workspace.selected?.id + const node = workspace.target + const id = node?.id if (!id) return workspace.say('nothing selected', 'error') - const node = workspace.open?.id === id ? workspace.open : workspace.selected if (!node?.shared) return workspace.say('not shared') await workspace.guard(() => workspace.toggleShare(id)) }, @@ -534,9 +544,9 @@ export const commands: CommandSpec[] = [ group: 'sharing', help: 'mint a new share link for the document and copy it', run: async () => { - const id = workspace.open?.id ?? workspace.selected?.id + const node = workspace.target + const id = node?.id if (!id) return workspace.say('nothing selected', 'error') - const node = workspace.open?.id === id ? workspace.open : workspace.selected if (!node?.shared) return workspace.say('not shared — :share first', 'error') await workspace.guard(async () => { const url = await workspace.mintShareLink(id) diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index bbb6de3..ae7c009 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -65,6 +65,13 @@ class Workspace { flash = $state(null) overlay = $state<'none' | 'help' | 'media' | 'overview'>('none') + + /* + * A `[y/N]` question in the command line's place, instead of the browser's + * confirm(): it names the thing about to go, and it is answered from the + * keyboard without a modal the rest of the page cannot see past. + */ + prompt = $state<{ text: string; resolve: (yes: boolean) => void } | null>(null) media = $state([]) overview = $state([]) overviewCursor = $state(0) @@ -92,6 +99,15 @@ class Workspace { return this.nodes[this.cursor] } + /** + * What a verb acts on: the open document while the document pane has focus, + * the row under the cursor while the list does. Walking the list with a + * document still open must not make `dd` delete the document. + */ + get target(): NodeSummary | undefined { + return this.open && this.pane === 'document' ? this.open : this.selected + } + get space(): NodeSummary | undefined { return this.spaces.find((s) => s.id === this.spaceId) } @@ -109,6 +125,19 @@ class Workspace { return this.list?.cards[this.cardCursor] } + ask(text: string): Promise { + this.prompt?.resolve(false) + return new Promise((resolve) => { + this.prompt = { + text, + resolve: (yes) => { + this.prompt = null + resolve(yes) + }, + } + }) + } + say(text: string, kind: 'info' | 'error' = 'info') { this.flash = { text, kind } if (kind === 'info') { @@ -471,14 +500,18 @@ class Workspace { this.#autosave = null } this.dropStash(id) + const node = this.open?.id === id ? this.open : this.nodes.find((n) => n.id === id) const { removed } = await api.deleteNode(id) if (this.open?.id === id) { this.open = null this.editing = false + this.dirty = false this.pane = 'list' } + if (this.marked?.id === id) this.marked = null await this.refresh() - this.say(removed > 1 ? `deleted ${removed} nodes` : `deleted #${id}`) + const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}` + this.say(removed > 1 ? `deleted ${name} and ${removed - 1} inside it` : `deleted ${name}`) } /**