mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
54bc333be2
commit
37443cf083
7 files changed
+172
-28
No files matched your search
+55
-2
@@ -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')
|
||||
|
||||
@@ -86,13 +86,20 @@
|
||||
return cert?.act() ?? false
|
||||
}
|
||||
const hints = () => reader ?? null
|
||||
const flip = () => {
|
||||
if (kind !== 'markdown' || text === null) return false
|
||||
source = !source
|
||||
return true
|
||||
}
|
||||
hooks.scrollReader = scroll
|
||||
hooks.fileAction = act
|
||||
hooks.hintRoot = hints
|
||||
hooks.toggleSource = flip
|
||||
return () => {
|
||||
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
|
||||
if (hooks.fileAction === act) hooks.fileAction = () => false
|
||||
if (hooks.hintRoot === hints) hooks.hintRoot = () => null
|
||||
if (hooks.toggleSource === flip) hooks.toggleSource = () => false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
['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'],
|
||||
|
||||
@@ -1,36 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { api } from './api'
|
||||
<script lang="ts" module>
|
||||
import { api, type MediaFile } from './api'
|
||||
import { hooks } from './commands'
|
||||
import { embedSnippet } from './markdown'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
/*
|
||||
* What a row's buttons do, out here so the keys in App.svelte do the same:
|
||||
* i inserts, y copies, x deletes.
|
||||
*/
|
||||
export function insertMedia(file: MediaFile) {
|
||||
if (!workspace.editing) return workspace.say('insert works while writing — i on a document first', 'error')
|
||||
hooks.insertText(embedSnippet(file))
|
||||
workspace.overlay = 'none'
|
||||
}
|
||||
|
||||
export async function copyMedia(file: MediaFile) {
|
||||
const copied = await workspace.copy(location.origin + file.url)
|
||||
workspace.say(copied ? `copied the address of ${file.filename}` : 'the clipboard said no', copied ? 'info' : 'error')
|
||||
}
|
||||
|
||||
export async function removeMedia(file: MediaFile) {
|
||||
if (file.file_node !== null) return workspace.say(`${file.filename} is a file in the tree — delete it there`, 'error')
|
||||
const used = file.node_ids.length === 1 ? '1 document' : `${file.node_ids.length} documents`
|
||||
const warning = file.node_ids.length > 0 ? ` it is embedded in ${used}, which will break` : ''
|
||||
if (!(await workspace.ask(`delete ${file.filename}?${warning}`))) return
|
||||
await workspace.guard(async () => {
|
||||
await api.deleteMedia(file.id)
|
||||
await workspace.loadMedia()
|
||||
workspace.mediaCursor = Math.min(workspace.mediaCursor, Math.max(workspace.media.length - 1, 0))
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { formatBytes, formatStamp } from './util'
|
||||
|
||||
let sheet = $state<HTMLDivElement>()
|
||||
|
||||
$effect(() => {
|
||||
const index = workspace.mediaCursor
|
||||
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
const usage = (nodeIds: number[], fileNode: number | null) => {
|
||||
const embeds = nodeIds.map((id) => `#${id}`).join(' ')
|
||||
if (fileNode !== null) return embeds ? `in the tree · ${embeds}` : 'in the tree'
|
||||
return embeds || 'unattached'
|
||||
}
|
||||
|
||||
async function remove(id: string, filename: string, nodeIds: number[]) {
|
||||
const used = nodeIds.length === 1 ? '1 document' : `${nodeIds.length} documents`
|
||||
const warning = nodeIds.length > 0 ? ` it is embedded in ${used}, which will break.` : ''
|
||||
if (!confirm(`delete ${filename}?${warning}`)) return
|
||||
await workspace.guard(async () => {
|
||||
await api.deleteMedia(id)
|
||||
await workspace.loadMedia()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sheet">
|
||||
<div class="sheet" bind:this={sheet}>
|
||||
<header>
|
||||
<span class="label rule"><span class="marker"></span><span class="title">media</span> {workspace.media.length} files</span>
|
||||
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||
</header>
|
||||
|
||||
<ul>
|
||||
{#each workspace.media as file (file.id)}
|
||||
<li>
|
||||
{#each workspace.media as file, index (file.id)}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<li class:at={index === workspace.mediaCursor} data-index={index} onpointerenter={() => (workspace.mediaCursor = index)}>
|
||||
<div class="thumb">
|
||||
{#if file.encrypted}
|
||||
<span class="label">sealed</span>
|
||||
@@ -51,16 +79,11 @@
|
||||
|
||||
<div class="actions">
|
||||
{#if workspace.editing}
|
||||
<button
|
||||
onclick={() => {
|
||||
hooks.insertText(embedSnippet(file))
|
||||
workspace.overlay = 'none'
|
||||
}}>insert</button
|
||||
>
|
||||
<button onclick={() => insertMedia(file)}>insert</button>
|
||||
{/if}
|
||||
<button onclick={() => workspace.copy(location.origin + file.url)}>copy url</button>
|
||||
<button onclick={() => copyMedia(file)}>copy url</button>
|
||||
{#if file.file_node === null}
|
||||
<button class="danger" onclick={() => remove(file.id, file.filename, file.node_ids)}>rm</button>
|
||||
<button class="danger" onclick={() => removeMedia(file)}>rm</button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
@@ -68,6 +91,20 @@
|
||||
<li class="muted">nothing uploaded yet — attach a file from the editor.</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<footer class="label">
|
||||
<span class="kbd">j</span><span class="kbd">k</span> move
|
||||
<span class="gap"></span>
|
||||
{#if workspace.editing}
|
||||
<span class="kbd">i</span> insert
|
||||
<span class="gap"></span>
|
||||
{/if}
|
||||
<span class="kbd">y</span> copy url
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">x</span> delete
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">q</span><span class="kbd">esc</span> close
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -101,6 +138,14 @@
|
||||
transition: background-color var(--t-base) var(--ease-glide);
|
||||
}
|
||||
li:hover { background: var(--surface); }
|
||||
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
|
||||
|
||||
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||
footer .kbd { margin-right: 2px; }
|
||||
footer .gap { width: 14px; }
|
||||
@media (max-width: 767px), (pointer: coarse) {
|
||||
footer { display: none; }
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 52px;
|
||||
|
||||
+32
-1
@@ -24,6 +24,9 @@ export const hooks = {
|
||||
hintRoot: (): HTMLElement | null => null,
|
||||
/* Filled in by the file pane: what Enter does there, false when nothing. */
|
||||
fileAction: (): boolean => false,
|
||||
/* `:source`: a markdown file between rendered and raw; false when the pane
|
||||
shows no markdown. */
|
||||
toggleSource: (): boolean => false,
|
||||
/* `]]` / `[[` in whatever is being read; false when it has no headings. */
|
||||
jumpHeading: (_direction: 1 | -1): boolean => false,
|
||||
/* `gO`: the headings of what is on screen, read or being written, and the
|
||||
@@ -760,6 +763,33 @@ export const commands: CommandSpec[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'download',
|
||||
aliases: ['dl'],
|
||||
group: 'sharing',
|
||||
help: 'download the file under the cursor, or the open one — the open document goes as :export writes it',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
const node = workspace.target
|
||||
if (!node) return workspace.say('nothing selected', 'error')
|
||||
if (node.kind === 'document' && node.id === workspace.open?.id) return runCommand('export')
|
||||
if (!node.file) return workspace.say(`a ${node.kind} is no file — :export! downloads everything`, 'error')
|
||||
if (node.file.encrypted && !(await workspace.ensureUnlocked())) return
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = node.file.url
|
||||
anchor.download = node.name
|
||||
anchor.click()
|
||||
workspace.say(`downloading ${node.name}`)
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
group: 'documents',
|
||||
help: 'show an open markdown file as written, or rendered again',
|
||||
run: () => {
|
||||
if (!hooks.toggleSource()) workspace.say('only an open markdown file has a source to show', 'error')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'upload',
|
||||
aliases: ['up'],
|
||||
@@ -777,9 +807,10 @@ export const commands: CommandSpec[] = [
|
||||
{
|
||||
name: 'media',
|
||||
group: 'editing',
|
||||
help: 'browse everything you have uploaded',
|
||||
help: 'browse everything you have uploaded — j/k, i inserts it while writing, y copies its address, x deletes',
|
||||
run: () => workspace.guard(async () => {
|
||||
await workspace.loadMedia()
|
||||
workspace.mediaCursor = 0
|
||||
workspace.overlay = 'media'
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -133,6 +133,7 @@ class Workspace {
|
||||
/** The vault, as last heard from the server; null until asked. */
|
||||
vault = $state<VaultStatus | null>(null)
|
||||
media = $state<MediaFile[]>([])
|
||||
mediaCursor = $state(0)
|
||||
overview = $state<SpaceOverview[]>([])
|
||||
overviewCursor = $state(0)
|
||||
|
||||
|
||||
Reference in new issue
Block a user