mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
feat: j/k scroll the open document, and import loose files
With a document open and not being edited, j/k scroll the reading pane, gg/G jump to its top and bottom, and Ctrl-D/Ctrl-U page it. h still backs out to the list, where the same keys walk the rows again. The import pickers offered a folder or a zip and nothing else, so a single file could not be imported at all. The second picker now takes any files; the server already handled them.
This commit is contained in:
1 parent
d8ebf4b850
commit
714a84089a
4 files changed
+77
-13
No files matched your search
+32
-8
@@ -24,7 +24,7 @@
|
||||
let cmdline = $state<string | null>(null)
|
||||
let fileInput = $state<HTMLInputElement>()
|
||||
let folderInput = $state<HTMLInputElement>()
|
||||
let archiveInput = $state<HTMLInputElement>()
|
||||
let importInput = $state<HTMLInputElement>()
|
||||
let shell = $state<HTMLDivElement>()
|
||||
let overlayEl = $state<HTMLDivElement>()
|
||||
let pending = $state('')
|
||||
@@ -42,7 +42,7 @@
|
||||
$effect(() => {
|
||||
hooks.pickFiles = () => fileInput?.click()
|
||||
hooks.pickFolder = () => folderInput?.click()
|
||||
hooks.pickArchive = () => archiveInput?.click()
|
||||
hooks.pickArchive = () => importInput?.click()
|
||||
hooks.focusList = () => shell?.focus()
|
||||
hooks.openCommandLine = (initial) => (cmdline = initial)
|
||||
})
|
||||
@@ -196,6 +196,16 @@
|
||||
}
|
||||
|
||||
if (cmdline !== null || isTypingTarget(event.target)) return
|
||||
|
||||
// Ctrl-D / Ctrl-U page the open document, as they do in a pager.
|
||||
if (event.ctrlKey && !event.metaKey && !event.altKey && (event.key === 'd' || event.key === 'u')) {
|
||||
if (workspace.open && !workspace.editing) {
|
||||
event.preventDefault()
|
||||
hooks.scrollReader(event.key === 'd' ? 'halfdown' : 'halfup')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return
|
||||
|
||||
const key = event.key
|
||||
@@ -218,10 +228,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* With a document open and not being edited, the reading pane owns j/k:
|
||||
* the keys scroll what is on screen, and `h` backs out to the list, where
|
||||
* they walk the rows again.
|
||||
*/
|
||||
const reading = !!workspace.open && !workspace.editing
|
||||
|
||||
// Two-key sequences: gg and dd.
|
||||
if (previous === 'g' && key === 'g') {
|
||||
event.preventDefault()
|
||||
workspace.cursor = 0
|
||||
if (reading) hooks.scrollReader('top')
|
||||
else workspace.cursor = 0
|
||||
return
|
||||
}
|
||||
if (previous === 'd' && key === 'd') {
|
||||
@@ -238,16 +256,19 @@
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
workspace.move(1)
|
||||
if (reading) hooks.scrollReader(1)
|
||||
else workspace.move(1)
|
||||
break
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
workspace.move(-1)
|
||||
if (reading) hooks.scrollReader(-1)
|
||||
else workspace.move(-1)
|
||||
break
|
||||
case 'G':
|
||||
event.preventDefault()
|
||||
workspace.cursor = Math.max(workspace.nodes.length - 1, 0)
|
||||
if (reading) hooks.scrollReader('bottom')
|
||||
else workspace.cursor = Math.max(workspace.nodes.length - 1, 0)
|
||||
break
|
||||
case 'Enter':
|
||||
case 'l':
|
||||
@@ -355,7 +376,7 @@
|
||||
│ O make a folder │
|
||||
│ R rename what is marked │
|
||||
│ b this space's board │
|
||||
│ j/k browse, Enter opens │
|
||||
│ j/k browse, l opens │
|
||||
│ h up one level │
|
||||
│ / search this space │
|
||||
│ ? help │
|
||||
@@ -386,7 +407,10 @@
|
||||
<!-- `webkitdirectory` is set from script: it is the one attribute browsers
|
||||
agree on for picking a folder, and no typed attribute list carries it. -->
|
||||
<input class="hidden" type="file" multiple bind:this={folderInput} onchange={importFolder} />
|
||||
<input class="hidden" type="file" accept=".zip" bind:this={archiveInput} onchange={importFolder} />
|
||||
<!-- The same import, one file at a time: a .zip is expanded, anything else
|
||||
lands as the document or media it is. Picking a folder is the only thing
|
||||
the folder input above can do, so single files need an input of their own. -->
|
||||
<input class="hidden" type="file" multiple bind:this={importInput} onchange={importFolder} />
|
||||
|
||||
<style>
|
||||
.boot { display: grid; place-items: center; height: 100%; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { runCommand } from './commands'
|
||||
import { hooks, runCommand, type ScrollAmount } from './commands'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatStamp, formatWeekday, isTouchDevice, relative } from './util'
|
||||
@@ -16,6 +16,39 @@
|
||||
*/
|
||||
const sharePath = $derived(doc.share_token ? `${location.origin}/s/${doc.share_token}#…` : null)
|
||||
|
||||
let reader = $state<HTMLElement>()
|
||||
|
||||
/*
|
||||
* j/k reach this pane through the window handler in App.svelte, which has no
|
||||
* way to know how tall a line is here — so the pane does the arithmetic and
|
||||
* says whether it scrolled anything at all.
|
||||
*/
|
||||
$effect(() => {
|
||||
hooks.scrollReader = (amount: ScrollAmount) => {
|
||||
if (!reader) return false
|
||||
const line = parseFloat(getComputedStyle(reader).lineHeight) || 24
|
||||
const half = reader.clientHeight / 2
|
||||
switch (amount) {
|
||||
case 'top':
|
||||
reader.scrollTo({ top: 0 })
|
||||
break
|
||||
case 'bottom':
|
||||
reader.scrollTo({ top: reader.scrollHeight })
|
||||
break
|
||||
case 'halfdown':
|
||||
reader.scrollBy({ top: half })
|
||||
break
|
||||
case 'halfup':
|
||||
reader.scrollBy({ top: -half })
|
||||
break
|
||||
default:
|
||||
reader.scrollBy({ top: amount * line * 3 })
|
||||
}
|
||||
return true
|
||||
}
|
||||
return () => (hooks.scrollReader = () => false)
|
||||
})
|
||||
|
||||
/**
|
||||
* `/n/<space>/<slug>` links — the ones the importer writes between imported
|
||||
* documents — are followed in place rather than reloading the application.
|
||||
@@ -101,7 +134,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="body reading">
|
||||
<div class="body reading" bind:this={reader}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<article class="md" onclick={follow}>
|
||||
{#if doc.name.trim()}<h1 class="entrytitle">{doc.name}</h1>{/if}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
const keys: [string, string][] = [
|
||||
['j / k · ↓ ↑', 'move down / up the list'],
|
||||
['gg / G', 'first / last row'],
|
||||
['j / k · ↓ ↑', 'move down / up the list — scroll the open document'],
|
||||
['gg / G', 'first / last row, or top / bottom of the document'],
|
||||
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
|
||||
['Enter · l', 'open a document, or enter a folder'],
|
||||
['h', 'up one level, or back out of a document'],
|
||||
['o', 'new document here, straight into insert mode'],
|
||||
|
||||
@@ -2,6 +2,9 @@ import { api, type BackupStatus } from './api'
|
||||
import { workspace, type Theme } from './store.svelte'
|
||||
import { formatDay, parseDay } from './util'
|
||||
|
||||
/** How far the reading pane is asked to scroll: lines, a half page, or an end. */
|
||||
export type ScrollAmount = number | 'top' | 'bottom' | 'halfdown' | 'halfup'
|
||||
|
||||
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
|
||||
export const hooks = {
|
||||
pickFiles: () => {},
|
||||
@@ -10,6 +13,9 @@ export const hooks = {
|
||||
focusEditor: () => {},
|
||||
focusList: () => {},
|
||||
insertText: (_text: string) => {},
|
||||
/* Filled in while a document is on screen and not being edited: the reading
|
||||
pane owns the scrollback, so the list keys have somewhere to send it. */
|
||||
scrollReader: (_amount: ScrollAmount): boolean => false,
|
||||
openCommandLine: (_initial: string) => {},
|
||||
}
|
||||
|
||||
@@ -210,7 +216,7 @@ export const commands: CommandSpec[] = [
|
||||
help: 'upload a folder into this one — markdown becomes documents, links follow',
|
||||
run: () => hooks.pickFolder(),
|
||||
bang: {
|
||||
help: 'import a .zip of a folder instead of the folder itself',
|
||||
help: 'import loose files, or a .zip of a folder, instead of a folder',
|
||||
run: () => hooks.pickArchive(),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user