mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
keys act on the focused pane, deletes ask y/N in place
dd, x, i, R, s, y and :mv used the open document whenever one was open, so walking the list and pressing dd deleted the document beside it. They now act on the row under the cursor while the list has focus. Deletes ask an in-app y/N naming the node instead of confirm(), and the board no longer lets tree keys through to the hidden list.
This commit is contained in:
1 parent
4ded89b0e7
commit
d9b78170d8
6 files changed
+149
-28
No files matched your search
+36
-5
@@ -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 @@
|
||||
<CommandLine initial={cmdline} close={() => (cmdline = null)} />
|
||||
{/if}
|
||||
|
||||
<Prompt />
|
||||
|
||||
{#if workspace.overlay !== 'none'}
|
||||
<!-- 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')}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* The `[y/N]` question `workspace.ask` raises, drawn where the command line
|
||||
* opens. The keys are read in App.svelte, ahead of everything else, so a
|
||||
* stray `y` can never reach the editor or the list underneath.
|
||||
*/
|
||||
import { workspace } from './store.svelte'
|
||||
</script>
|
||||
|
||||
{#if workspace.prompt}
|
||||
<div class="prompt" role="alertdialog" aria-label={workspace.prompt.text}>
|
||||
<span class="text">{workspace.prompt.text}</span>
|
||||
<span class="choice">
|
||||
<button class="yes" onclick={() => workspace.prompt?.resolve(true)}><span class="kbd">y</span> yes</button>
|
||||
<button onclick={() => workspace.prompt?.resolve(false)}>no</button>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.prompt {
|
||||
position: absolute;
|
||||
left: calc(var(--frame) + 12px);
|
||||
right: calc(var(--rail-w) + 12px);
|
||||
bottom: calc(var(--status-h) - 1px);
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in oklab, var(--hull) 97%, transparent);
|
||||
border: 1px solid var(--alarm);
|
||||
border-bottom: 0;
|
||||
border-radius: var(--radius-l) var(--radius-l) 0 0;
|
||||
font-size: 13px;
|
||||
animation: rise var(--t-slow) var(--ease-snap) both;
|
||||
}
|
||||
.text { flex: 1; min-width: 0; color: var(--light); overflow-wrap: anywhere; }
|
||||
.choice { flex: none; display: flex; gap: 6px; align-items: center; }
|
||||
.yes:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.prompt { left: 8px; right: 8px; flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
+28
-18
@@ -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 <folder|..>', '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 <id>', '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)
|
||||
|
||||
@@ -65,6 +65,13 @@ class Workspace {
|
||||
flash = $state<Flash>(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<MediaFile[]>([])
|
||||
overview = $state<SpaceOverview[]>([])
|
||||
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<boolean> {
|
||||
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}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user