keys: files and uploads answer the keyboard

`gx` opens the file under the cursor in a tab of its own, as the browser
shows it. `:download` (`:dl`) downloads it, after asking for the vault when
the file is sealed; on the open document it is `:export`. `:source` turns a
markdown file between rendered and as written, which before had only a
button. A sealed file asks for the vault before `gx` instead of after, so
the browser does not block the late tab as a popup.

The media list gets a cursor: `j`/`k` walk it, `i` or `Enter` embeds the
highlighted upload at the cursor while writing, `y` copies its address and
`x` deletes it. Deleting asks through the `[y/N]` prompt, which names what
would break, in place of the browser's confirm(): that one was the last
modal on the page and needed the pointer to dismiss. A footer lists the
keys, as the agenda's does.
This commit is contained in:
nvrl committed 2026-10-20 13:59:09 +02:00
1 parent 54bc333be2
commit 37443cf083
7 files changed
+172 -28

No files matched your search

+55 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { hooks, runCommand } from './lib/commands'
import { followLink, hooks, runCommand } from './lib/commands'
import { embedSnippet } from './lib/markdown'
import Agenda from './lib/Agenda.svelte'
import Bar from './lib/Bar.svelte'
@@ -13,7 +13,7 @@
import Verify from './lib/Verify.svelte'
import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte'
import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte'
import Actions from './lib/Actions.svelte'
import Prompt from './lib/Prompt.svelte'
import SecretPrompt from './lib/SecretPrompt.svelte'
@@ -329,6 +329,48 @@
}
}
/** The media list's keys: j/k down the files, i inserts, y copies, x deletes. */
function mediaKey(event: KeyboardEvent): boolean {
const files = workspace.media
const last = Math.max(files.length - 1, 0)
const file = files[workspace.mediaCursor]
switch (event.key) {
case 'j':
case 'ArrowDown':
workspace.mediaCursor = Math.min(workspace.mediaCursor + 1, last)
return true
case 'k':
case 'ArrowUp':
workspace.mediaCursor = Math.max(workspace.mediaCursor - 1, 0)
return true
case 'G':
workspace.mediaCursor = last
return true
case 'Enter':
case 'i':
if (file) insertMedia(file)
return true
case 'y':
if (file) void copyMedia(file)
return true
case 'x':
if (file) void removeMedia(file)
return true
default:
return false
}
}
/** `gx`: the file as the browser shows it, in a tab of its own. */
function openInTab() {
const file = workspace.target?.file
if (!file) return workspace.say('gx opens a file — put the cursor on one', 'error')
// A tab opened after the vault's question would be stopped as a popup, so
// a sealed file asks for the vault to be opened first instead.
if (file.encrypted && !workspace.vault?.unlocked) return workspace.say('sealed — :unlock first, then gx', 'error')
followLink(file.url, true)
}
/** 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
@@ -448,6 +490,12 @@
return
}
}
if (workspace.overlay === 'media' && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (mediaKey(event)) {
event.preventDefault()
return
}
}
if (workspace.overlay === 'outline' && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (outlineKey(event)) {
event.preventDefault()
@@ -586,6 +634,11 @@
void runCommand('outline')
return
}
if (previous === 'g' && key === 'x') {
event.preventDefault()
openInTab()
return
}
if (previous === 'd' && key === 'd') {
event.preventDefault()
void runCommand('d')