mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
store: what belongs to one document lives in a Tile
The open node, its draft, whether it is being written and whether that is saved, the autosave timer and its back-off, the fresh-document marker, the board to go back to and the editor's vim mode move out of Workspace into a Tile. The workspace holds `tiles` and `focused`, always at least one tile, and the old names are getters on the focused tile, so the call sites read as before. Nothing visible changes yet: there is one tile. `flush()` writes every dirty tile, deleting, moving, renaming, sharing or sealing a node updates every tile that shows it, and the page warns before unloading when any tile is dirty. The panes and the editor are handed their tile, and fill the shared hooks only while that tile has focus; the others stand by. Vim's actions are defined once for the page and act on the editor the key was pressed in, rather than on whichever editor happened to be mounted last.
This commit is contained in:
1 parent
c72eccf86b
commit
e0de88938a
5 files changed
+369
-265
No files matched your search
+3
-3
@@ -145,7 +145,7 @@
|
||||
|
||||
// Guard against losing a half-written entry to a stray refresh.
|
||||
$effect(() => {
|
||||
if (!workspace.dirty) return
|
||||
if (!workspace.tiles.some((tile) => tile.dirty)) return
|
||||
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
|
||||
window.addEventListener('beforeunload', warn)
|
||||
return () => window.removeEventListener('beforeunload', warn)
|
||||
@@ -851,9 +851,9 @@
|
||||
onpointerdown={() => workspace.open && (workspace.pane = 'document')}
|
||||
>
|
||||
{#if workspace.open?.kind === 'file'}
|
||||
<FilePane />
|
||||
<FilePane tile={workspace.tile} />
|
||||
{:else if workspace.open}
|
||||
<DocumentPane />
|
||||
<DocumentPane tile={workspace.tile} />
|
||||
{:else}
|
||||
<div class="placeholder">
|
||||
<div class="standby resolve">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { followLink, hooks, runCommand, type ScrollAmount } from './commands'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import Outline from './Outline.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
import { workspace, type Tile } from './store.svelte'
|
||||
import Icon from './Icon.svelte'
|
||||
import {
|
||||
formatStamp,
|
||||
@@ -15,10 +15,14 @@
|
||||
scrollPage,
|
||||
} from './util'
|
||||
|
||||
const doc = $derived(workspace.open!)
|
||||
const html = $derived(renderMarkdown(workspace.editing ? workspace.draft.body : doc.body))
|
||||
/** The tile this pane draws; the keys only reach it while it has focus. */
|
||||
const { tile }: { tile: Tile } = $props()
|
||||
|
||||
const focused = $derived(workspace.tile === tile)
|
||||
const doc = $derived(tile.open!)
|
||||
const html = $derived(renderMarkdown(tile.editing ? tile.draft.body : doc.body))
|
||||
const words = $derived(
|
||||
(workspace.editing ? workspace.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
|
||||
(tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
|
||||
)
|
||||
/*
|
||||
* Only the token half of a share link is recoverable — the key lives in the
|
||||
@@ -34,7 +38,7 @@
|
||||
// `:name` with nothing after it, while writing: up into the title.
|
||||
$effect(() => {
|
||||
const field = title
|
||||
if (!field) return
|
||||
if (!field || !focused) return
|
||||
// A turn later: vim gives the focus back to the text once its `:` line
|
||||
// has run, and that would land after this.
|
||||
const focus = () =>
|
||||
@@ -56,6 +60,7 @@
|
||||
$effect(() => {
|
||||
// A space switch remounts this pane, and the old one's cleanup can run
|
||||
// after the new one has set the hook — so it only clears its own.
|
||||
if (!focused) return
|
||||
const scroll = (amount: ScrollAmount) => {
|
||||
if (!reader) return false
|
||||
scrollPage(reader, amount)
|
||||
@@ -71,7 +76,7 @@
|
||||
$effect(() => {
|
||||
const scroller = reader
|
||||
const root = article
|
||||
if (!scroller || !root) return
|
||||
if (!scroller || !root || !focused) return
|
||||
const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction)
|
||||
const list = () => readerHeadings(scroller, root)
|
||||
// The outline in the margin is links too, so `F` reaches it as well.
|
||||
@@ -139,15 +144,15 @@
|
||||
<button class="back" aria-label="back to the list" onclick={() => runCommand('q')}>
|
||||
<Icon name="back" size={16} />
|
||||
</button>
|
||||
<span class="marker" class:live={workspace.editing}></span>
|
||||
<span class="marker" class:live={tile.editing}></span>
|
||||
<span class="path">{workspace.here}/{doc.slug}</span>
|
||||
{#if workspace.dirty}<span class="dirty">modified</span>{/if}
|
||||
{#if tile.dirty}<span class="dirty">modified</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<dl class="meta">
|
||||
<dt>written</dt>
|
||||
<dd>{formatStamp(workspace.draft.created_at)} {formatWeekday(workspace.draft.created_at)}</dd>
|
||||
<dd>{formatStamp(tile.draft.created_at)} {formatWeekday(tile.draft.created_at)}</dd>
|
||||
<dt>edited</dt>
|
||||
<dd>{relative(doc.updated_at)}</dd>
|
||||
<dt>words</dt>
|
||||
@@ -155,7 +160,7 @@
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
{#if workspace.editing}
|
||||
{#if tile.editing}
|
||||
<button onclick={() => runCommand('w')}>:w</button>
|
||||
<button onclick={() => runCommand('upload')}>attach</button>
|
||||
<button onclick={() => runCommand('wq')}>done</button>
|
||||
@@ -180,13 +185,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if workspace.editing}
|
||||
{#if tile.editing}
|
||||
<input
|
||||
class="title"
|
||||
bind:this={title}
|
||||
placeholder="Untitled"
|
||||
bind:value={workspace.draft.name}
|
||||
oninput={() => workspace.touch()}
|
||||
bind:value={tile.draft.name}
|
||||
oninput={() => workspace.touch(tile)}
|
||||
onkeydown={(event) => {
|
||||
// The title is the line above the text: any of these goes back down.
|
||||
if (['Enter', 'Escape', 'ArrowDown', 'Tab'].includes(event.key) && !event.shiftKey) {
|
||||
@@ -201,7 +206,7 @@
|
||||
<div class="loading label">loading editor<span class="cursor">_</span></div>
|
||||
{:then module}
|
||||
{@const Editor = module.default}
|
||||
<Editor />
|
||||
<Editor {tile} />
|
||||
{/await}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
+155
-133
@@ -1,6 +1,126 @@
|
||||
<script lang="ts" module>
|
||||
/* The vim register controller is global, so it is wrapped once per page. */
|
||||
let clipboardMirrored = false
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { Vim, getCM, vim } from '@replit/codemirror-vim'
|
||||
import { commands, followLink, runCommand } from './commands'
|
||||
import { linkAt, sourceHeadings } from './util'
|
||||
import { workspace as ws } from './store.svelte'
|
||||
|
||||
/*
|
||||
* Vim's actions, ex commands and registers are global: one definition
|
||||
* serves every editor on the page. Each action is handed the editor the key
|
||||
* was pressed in, so with several documents open it acts on that one.
|
||||
*/
|
||||
let vimBound = false
|
||||
|
||||
/** What vim hands an action: CodeMirror 5's face over the editor. */
|
||||
type Adapter = { cm6: EditorView }
|
||||
|
||||
/** Route every `:command` typed inside the editor through the shared handler. */
|
||||
function bridgeExCommands() {
|
||||
for (const spec of commands) {
|
||||
const names = [spec.name, ...(spec.aliases ?? [])]
|
||||
for (const name of names) {
|
||||
Vim.defineEx(name, name, (_cm: unknown, params: { argString?: string }) => {
|
||||
const arg = (params.argString ?? '').trim()
|
||||
// vim parses `:q!` as command `q` with the bang left in the arguments,
|
||||
// so put it back on the name before handing the line over.
|
||||
const bang = arg.startsWith('!')
|
||||
void runCommand(`${spec.name}${bang ? '!' : ''} ${bang ? arg.slice(1).trim() : arg}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `set clipboard=unnamed`, in effect: every yank or delete that lands in the
|
||||
* unnamed register is copied out to the system clipboard too. An explicit
|
||||
* register (`"ay`) is left alone, and `"+p` still pastes the clipboard back.
|
||||
*/
|
||||
function mirrorUnnamedRegisterToClipboard() {
|
||||
const registers = Vim.getRegisterController?.()
|
||||
if (!registers) return
|
||||
const push = registers.pushText.bind(registers)
|
||||
registers.pushText = (name, operator, text, linewise, blockwise) => {
|
||||
push(name, operator, text, linewise, blockwise)
|
||||
if (!name && text && ws.clipboard) void ws.copy(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `?` is vim's search-backwards, but the workspace advertises it as the help key
|
||||
* everywhere else, so it opens the reference here too. `/` still searches.
|
||||
*/
|
||||
function bindHelpKey() {
|
||||
Vim.defineAction('workspaceHelp', () => void runCommand('help'))
|
||||
for (const context of ['normal', 'visual'] as const) {
|
||||
Vim.mapCommand('?', 'action', 'workspaceHelp', {}, { context })
|
||||
}
|
||||
}
|
||||
|
||||
function gotoLine(view: EditorView, number: number) {
|
||||
const line = view.state.doc.line(number)
|
||||
view.dispatch({
|
||||
selection: { anchor: line.from },
|
||||
effects: EditorView.scrollIntoView(line.from, { y: 'start', yMargin: 24 }),
|
||||
})
|
||||
view.focus()
|
||||
}
|
||||
|
||||
/*
|
||||
* vim's `]]` and `[[` walk sections, which in a markdown file are the
|
||||
* headings — the same jump the reader makes, where the text is. `gO` lists
|
||||
* them, as it does in vim's own help files.
|
||||
*/
|
||||
function bindHeadingKeys() {
|
||||
const step = (view: EditorView, direction: 1 | -1) => {
|
||||
const at = view.state.doc.lineAt(view.state.selection.main.head).number
|
||||
const lines = sourceHeadings(view.state.doc.toString()).map((heading) => heading.line)
|
||||
let target: number | undefined
|
||||
for (const line of lines) {
|
||||
if (direction > 0 && line > at) {
|
||||
target = line
|
||||
break
|
||||
}
|
||||
if (direction < 0 && line < at) target = line
|
||||
}
|
||||
gotoLine(view, target ?? (direction > 0 ? view.state.doc.lines : 1))
|
||||
}
|
||||
Vim.defineAction('workspaceNextHeading', (cm) => step((cm as unknown as Adapter).cm6, 1))
|
||||
Vim.defineAction('workspacePrevHeading', (cm) => step((cm as unknown as Adapter).cm6, -1))
|
||||
Vim.defineAction('workspaceOutline', () => void runCommand('outline'))
|
||||
Vim.mapCommand(']]', 'action', 'workspaceNextHeading', {}, { context: 'normal' })
|
||||
Vim.mapCommand('[[', 'action', 'workspacePrevHeading', {}, { context: 'normal' })
|
||||
Vim.mapCommand('gO', 'action', 'workspaceOutline', {}, { context: 'normal' })
|
||||
}
|
||||
|
||||
/*
|
||||
* `gf` follows the link under the cursor — a workspace link opens that
|
||||
* document, saving this one first, anything else gets a tab — and `gx`
|
||||
* opens it in a tab whatever it is, as vim hands it to the browser.
|
||||
*/
|
||||
function bindLinkKeys() {
|
||||
const follow = (view: EditorView, tab: boolean) => {
|
||||
const head = view.state.selection.main.head
|
||||
const line = view.state.doc.lineAt(head)
|
||||
const href = linkAt(line.text, head - line.from)
|
||||
if (!href) return ws.say('no link under the cursor', 'error')
|
||||
followLink(href, tab)
|
||||
}
|
||||
Vim.defineAction('workspaceFollowLink', (cm) => follow((cm as unknown as Adapter).cm6, false))
|
||||
Vim.defineAction('workspaceOpenLink', (cm) => follow((cm as unknown as Adapter).cm6, true))
|
||||
Vim.mapCommand('gf', 'action', 'workspaceFollowLink', {}, { context: 'normal' })
|
||||
Vim.mapCommand('gx', 'action', 'workspaceOpenLink', {}, { context: 'normal' })
|
||||
}
|
||||
|
||||
function bindVim() {
|
||||
if (vimBound) return
|
||||
vimBound = true
|
||||
bridgeExCommands()
|
||||
bindHelpKey()
|
||||
bindHeadingKeys()
|
||||
bindLinkKeys()
|
||||
mirrorUnnamedRegisterToClipboard()
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -9,7 +129,6 @@
|
||||
import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import {
|
||||
EditorView,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
highlightActiveLine,
|
||||
@@ -17,12 +136,13 @@
|
||||
placeholder,
|
||||
} from '@codemirror/view'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { Vim, getCM, vim } from '@replit/codemirror-vim'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { commands, followLink, hooks, runCommand } from './commands'
|
||||
import { hooks } from './commands'
|
||||
import { embedSnippet } from './markdown'
|
||||
import { linkAt, sourceHeadings } from './util'
|
||||
import { workspace } from './store.svelte'
|
||||
import { workspace, type Tile } from './store.svelte'
|
||||
|
||||
/** The tile this editor writes in, which need not be the one with focus. */
|
||||
const { tile }: { tile: Tile } = $props()
|
||||
|
||||
let host = $state<HTMLDivElement>()
|
||||
let view: EditorView | undefined
|
||||
@@ -83,50 +203,6 @@
|
||||
{ dark: true },
|
||||
)
|
||||
|
||||
/** Route every `:command` typed inside the editor through the shared handler. */
|
||||
function bridgeExCommands() {
|
||||
for (const spec of commands) {
|
||||
const names = [spec.name, ...(spec.aliases ?? [])]
|
||||
for (const name of names) {
|
||||
Vim.defineEx(name, name, (_cm: unknown, params: { argString?: string }) => {
|
||||
const arg = (params.argString ?? '').trim()
|
||||
// vim parses `:q!` as command `q` with the bang left in the arguments,
|
||||
// so put it back on the name before handing the line over.
|
||||
const bang = arg.startsWith('!')
|
||||
void runCommand(`${spec.name}${bang ? '!' : ''} ${bang ? arg.slice(1).trim() : arg}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `set clipboard=unnamed`, in effect: every yank or delete that lands in the
|
||||
* unnamed register is copied out to the system clipboard too. An explicit
|
||||
* register (`"ay`) is left alone, and `"+p` still pastes the clipboard back.
|
||||
*/
|
||||
function mirrorUnnamedRegisterToClipboard() {
|
||||
if (clipboardMirrored) return
|
||||
const registers = Vim.getRegisterController?.()
|
||||
if (!registers) return
|
||||
clipboardMirrored = true
|
||||
const push = registers.pushText.bind(registers)
|
||||
registers.pushText = (name, operator, text, linewise, blockwise) => {
|
||||
push(name, operator, text, linewise, blockwise)
|
||||
if (!name && text && workspace.clipboard) void workspace.copy(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `?` is vim's search-backwards, but the workspace advertises it as the help key
|
||||
* everywhere else, so it opens the reference here too. `/` still searches.
|
||||
*/
|
||||
function bindHelpKey() {
|
||||
Vim.defineAction('workspaceHelp', () => void runCommand('help'))
|
||||
for (const context of ['normal', 'visual'] as const) {
|
||||
Vim.mapCommand('?', 'action', 'workspaceHelp', {}, { context })
|
||||
}
|
||||
}
|
||||
|
||||
/** The `#` lines being written, as `gO` lists them, and the one the cursor is under. */
|
||||
function editorHeadings() {
|
||||
if (!view) return { headings: [], current: 0 }
|
||||
@@ -137,68 +213,10 @@
|
||||
found.forEach((heading, i) => {
|
||||
if (heading.line <= at) current = i
|
||||
})
|
||||
const headings = found.map(({ text, level, line }) => ({ text, level, go: () => gotoLine(line) }))
|
||||
const headings = found.map(({ text, level, line }) => ({ text, level, go: () => gotoLine(editor, line) }))
|
||||
return { headings, current }
|
||||
}
|
||||
|
||||
function gotoLine(number: number) {
|
||||
if (!view) return
|
||||
const line = view.state.doc.line(number)
|
||||
view.dispatch({
|
||||
selection: { anchor: line.from },
|
||||
effects: EditorView.scrollIntoView(line.from, { y: 'start', yMargin: 24 }),
|
||||
})
|
||||
view.focus()
|
||||
}
|
||||
|
||||
/*
|
||||
* vim's `]]` and `[[` walk sections, which in a markdown file are the
|
||||
* headings — the same jump the reader makes, where the text is. `gO` lists
|
||||
* them, as it does in vim's own help files.
|
||||
*/
|
||||
function bindHeadingKeys() {
|
||||
const step = (direction: 1 | -1) => {
|
||||
if (!view) return
|
||||
const at = view.state.doc.lineAt(view.state.selection.main.head).number
|
||||
const lines = sourceHeadings(view.state.doc.toString()).map((heading) => heading.line)
|
||||
let target: number | undefined
|
||||
for (const line of lines) {
|
||||
if (direction > 0 && line > at) {
|
||||
target = line
|
||||
break
|
||||
}
|
||||
if (direction < 0 && line < at) target = line
|
||||
}
|
||||
gotoLine(target ?? (direction > 0 ? view.state.doc.lines : 1))
|
||||
}
|
||||
Vim.defineAction('workspaceNextHeading', () => step(1))
|
||||
Vim.defineAction('workspacePrevHeading', () => step(-1))
|
||||
Vim.defineAction('workspaceOutline', () => void runCommand('outline'))
|
||||
Vim.mapCommand(']]', 'action', 'workspaceNextHeading', {}, { context: 'normal' })
|
||||
Vim.mapCommand('[[', 'action', 'workspacePrevHeading', {}, { context: 'normal' })
|
||||
Vim.mapCommand('gO', 'action', 'workspaceOutline', {}, { context: 'normal' })
|
||||
}
|
||||
|
||||
/*
|
||||
* `gf` follows the link under the cursor — a workspace link opens that
|
||||
* document, saving this one first, anything else gets a tab — and `gx`
|
||||
* opens it in a tab whatever it is, as vim hands it to the browser.
|
||||
*/
|
||||
function bindLinkKeys() {
|
||||
const follow = (tab: boolean) => {
|
||||
if (!view) return
|
||||
const head = view.state.selection.main.head
|
||||
const line = view.state.doc.lineAt(head)
|
||||
const href = linkAt(line.text, head - line.from)
|
||||
if (!href) return workspace.say('no link under the cursor', 'error')
|
||||
followLink(href, tab)
|
||||
}
|
||||
Vim.defineAction('workspaceFollowLink', () => follow(false))
|
||||
Vim.defineAction('workspaceOpenLink', () => follow(true))
|
||||
Vim.mapCommand('gf', 'action', 'workspaceFollowLink', {}, { context: 'normal' })
|
||||
Vim.mapCommand('gx', 'action', 'workspaceOpenLink', {}, { context: 'normal' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Media goes on its own line after the one the cursor is on, so attaching a
|
||||
* file never cuts a sentence in half.
|
||||
@@ -226,11 +244,7 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
bridgeExCommands()
|
||||
bindHelpKey()
|
||||
bindHeadingKeys()
|
||||
bindLinkKeys()
|
||||
mirrorUnnamedRegisterToClipboard()
|
||||
bindVim()
|
||||
|
||||
const extensions = [
|
||||
...(workspace.vimEnabled ? [vim({ status: false })] : []),
|
||||
@@ -249,7 +263,7 @@
|
||||
{
|
||||
key: 'Mod-s',
|
||||
preventDefault: true,
|
||||
run: () => (void workspace.guard(() => workspace.save()), true),
|
||||
run: () => (void workspace.guard(() => workspace.save({ tile })), true),
|
||||
},
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
@@ -257,8 +271,8 @@
|
||||
]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
workspace.draft.body = update.state.doc.toString()
|
||||
workspace.touch()
|
||||
tile.draft.body = update.state.doc.toString()
|
||||
workspace.touch(tile)
|
||||
}
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
@@ -280,14 +294,11 @@
|
||||
]
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc: workspace.draft.body, extensions }),
|
||||
state: EditorState.create({ doc: tile.draft.body, extensions }),
|
||||
parent: host!,
|
||||
})
|
||||
|
||||
hooks.focusEditor = focus
|
||||
hooks.insertText = insertAtCursor
|
||||
hooks.headings = editorHeadings
|
||||
view.focus()
|
||||
if (workspace.tile === tile) view.focus()
|
||||
|
||||
const cm = workspace.vimEnabled ? getCM(view) : null
|
||||
if (cm) {
|
||||
@@ -296,28 +307,39 @@
|
||||
on?: (event: string, handler: (e: { mode: string }) => void) => void
|
||||
}
|
||||
emitter.on?.('vim-mode-change', (event) => {
|
||||
workspace.vimMode = event.mode
|
||||
tile.vimMode = event.mode
|
||||
})
|
||||
|
||||
// A fresh entry starts in insert mode; an existing one starts in normal
|
||||
// mode, the way opening a file in vim does.
|
||||
if (workspace.enterInsert) {
|
||||
workspace.enterInsert = false
|
||||
if (tile.enterInsert) {
|
||||
tile.enterInsert = false
|
||||
Vim.handleKey(cm, 'i', 'workspace')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
* The keys reach whichever editor is in the focused tile: that one fills
|
||||
* the hooks, and the rest stand by until their tile is focused.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (workspace.tile !== tile) return
|
||||
hooks.focusEditor = focus
|
||||
hooks.insertText = insertAtCursor
|
||||
hooks.headings = editorHeadings
|
||||
// Only clear the hooks if no other editor has taken them already.
|
||||
return () => {
|
||||
if (hooks.focusEditor === focus) hooks.focusEditor = () => {}
|
||||
if (hooks.insertText === insertAtCursor) hooks.insertText = () => {}
|
||||
if (hooks.headings === editorHeadings) hooks.headings = () => ({ headings: [], current: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
view?.destroy()
|
||||
view = undefined
|
||||
// Only clear the hooks if no newer editor has taken them already.
|
||||
if (hooks.focusEditor === focus) {
|
||||
hooks.focusEditor = () => {}
|
||||
hooks.insertText = () => {}
|
||||
if (hooks.headings === editorHeadings) hooks.headings = () => ({ headings: [], current: 0 })
|
||||
workspace.vimMode = 'normal'
|
||||
}
|
||||
tile.vimMode = 'normal'
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
import Icon from './Icon.svelte'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { looksLikeText, parseTable, previewKind, separatorOf } from './preview'
|
||||
import { workspace } from './store.svelte'
|
||||
import { workspace, type Tile } from './store.svelte'
|
||||
import { formatBytes, formatStamp, headingsIn, jumpHeading, readerHeadings, relative, scrollPage } from './util'
|
||||
|
||||
const node = $derived(workspace.open!)
|
||||
/** The tile this pane draws; the keys only reach it while it has focus. */
|
||||
const { tile }: { tile: Tile } = $props()
|
||||
|
||||
const focused = $derived(workspace.tile === tile)
|
||||
const node = $derived(tile.open!)
|
||||
const file = $derived(node.file!)
|
||||
/* A sealed file cannot be fetched while the vault is shut. */
|
||||
const locked = $derived(file.encrypted && !workspace.vault?.unlocked)
|
||||
@@ -76,6 +80,7 @@
|
||||
// and Enter does the one thing the file is waiting for — the vault's
|
||||
// password while it is sealed, a keystore's when it has one.
|
||||
$effect(() => {
|
||||
if (!focused) return
|
||||
const scroll = (amount: ScrollAmount) => {
|
||||
if (!reader) return false
|
||||
scrollPage(reader, amount)
|
||||
@@ -108,7 +113,7 @@
|
||||
$effect(() => {
|
||||
const scroller = reader
|
||||
const root = article
|
||||
if (!scroller || !root) return
|
||||
if (!scroller || !root || !focused) return
|
||||
const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction)
|
||||
const list = () => readerHeadings(scroller, root)
|
||||
hooks.jumpHeading = jump
|
||||
|
||||
+184
-112
@@ -24,6 +24,56 @@ export type Flash = { text: string; kind: 'info' | 'error' } | null
|
||||
/** Where a space was left: the folder, the open document, the row, the pane. */
|
||||
type Spot = { parentId: number; openId: number | null; cursor: number; pane: Pane }
|
||||
|
||||
export type Draft = { name: string; body: string; created_at: number }
|
||||
|
||||
let tiles = 0
|
||||
|
||||
/**
|
||||
* One document on screen, and everything that belongs to it rather than to
|
||||
* the workspace: what it shows, the draft being written, whether that is
|
||||
* saved, and the timer that will save it.
|
||||
*/
|
||||
export class Tile {
|
||||
readonly id = ++tiles
|
||||
open = $state<WorkspaceNode | null>(null)
|
||||
draft = $state<Draft>({ name: '', body: '', created_at: 0 })
|
||||
editing = $state(false)
|
||||
/** Set when the editor should open in insert mode rather than normal mode. */
|
||||
enterInsert = $state(false)
|
||||
dirty = $state(false)
|
||||
/** The editor's vim mode, for the status line while this tile has focus. */
|
||||
vimMode = $state('normal')
|
||||
/*
|
||||
* Set when a card's document was opened from the board, so closing the
|
||||
* document goes back to the board — on the same card — rather than leaving
|
||||
* you in a tree you never asked for.
|
||||
*/
|
||||
returnTo = $state<'board' | null>(null)
|
||||
/*
|
||||
* The document `o` just made. If it is left with neither a name nor a word
|
||||
* in it, it is removed on the way out rather than left as an empty row.
|
||||
*/
|
||||
fresh: number | null = null
|
||||
/** Pending debounced write, if the draft has changed since the last save. */
|
||||
autosave: ReturnType<typeof setTimeout> | null = null
|
||||
/** Saves that failed for want of a server wait this long before trying again. */
|
||||
retryDelay = 0
|
||||
|
||||
stopAutosave() {
|
||||
if (this.autosave) clearTimeout(this.autosave)
|
||||
this.autosave = null
|
||||
}
|
||||
|
||||
/** Put the tile back to showing nothing. Where it goes back to stays. */
|
||||
clear() {
|
||||
this.stopAutosave()
|
||||
this.open = null
|
||||
this.editing = false
|
||||
this.dirty = false
|
||||
this.fresh = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole application state. It is deliberately one object: the keyboard
|
||||
* layer needs to see everything at once to decide what a key means.
|
||||
@@ -47,12 +97,65 @@ class Workspace {
|
||||
cursor = $state(0)
|
||||
query = $state('')
|
||||
|
||||
open = $state<WorkspaceNode | null>(null)
|
||||
draft = $state({ name: '', body: '', created_at: 0 })
|
||||
editing = $state(false)
|
||||
/** Set when the editor should open in insert mode rather than normal mode. */
|
||||
enterInsert = $state(false)
|
||||
dirty = $state(false)
|
||||
/*
|
||||
* The documents on screen. There is always at least one tile, empty when
|
||||
* nothing is open; the names below (`open`, `draft`, `editing` …) are the
|
||||
* focused tile's, so most of the app reads one document as it always did.
|
||||
*/
|
||||
tiles = $state<Tile[]>([new Tile()])
|
||||
focused = $state(0)
|
||||
|
||||
get tile(): Tile {
|
||||
return this.tiles[this.focused] ?? this.tiles[0]
|
||||
}
|
||||
|
||||
get open() {
|
||||
return this.tile.open
|
||||
}
|
||||
set open(node: WorkspaceNode | null) {
|
||||
this.tile.open = node
|
||||
}
|
||||
get draft() {
|
||||
return this.tile.draft
|
||||
}
|
||||
set draft(draft: Draft) {
|
||||
this.tile.draft = draft
|
||||
}
|
||||
get editing() {
|
||||
return this.tile.editing
|
||||
}
|
||||
set editing(editing: boolean) {
|
||||
this.tile.editing = editing
|
||||
}
|
||||
get enterInsert() {
|
||||
return this.tile.enterInsert
|
||||
}
|
||||
set enterInsert(insert: boolean) {
|
||||
this.tile.enterInsert = insert
|
||||
}
|
||||
get dirty() {
|
||||
return this.tile.dirty
|
||||
}
|
||||
set dirty(dirty: boolean) {
|
||||
this.tile.dirty = dirty
|
||||
}
|
||||
get returnTo() {
|
||||
return this.tile.returnTo
|
||||
}
|
||||
set returnTo(to: 'board' | null) {
|
||||
this.tile.returnTo = to
|
||||
}
|
||||
get vimMode() {
|
||||
return this.tile.vimMode
|
||||
}
|
||||
set vimMode(mode: string) {
|
||||
this.tile.vimMode = mode
|
||||
}
|
||||
|
||||
/** Every tile showing `id`. */
|
||||
tilesWith(id: number): Tile[] {
|
||||
return this.tiles.filter((tile) => tile.open?.id === id)
|
||||
}
|
||||
|
||||
/** The board of the current space, loaded only while it is being looked at. */
|
||||
view = $state<View>('tree')
|
||||
@@ -81,13 +184,6 @@ class Workspace {
|
||||
cardCursor = $state(0)
|
||||
/** `z` on the board: show done cards older than a week too. */
|
||||
showOldDone = $state(false)
|
||||
/*
|
||||
* Set when a card's document was opened from the board, so closing the
|
||||
* document goes back to the board — on the same card — rather than leaving
|
||||
* you in a tree you never asked for.
|
||||
*/
|
||||
returnTo = $state<'board' | null>(null)
|
||||
|
||||
/*
|
||||
* The nodes picked up by `m`, waiting for the `p` that drops them into
|
||||
* whatever folder the list is showing then. A move is two keystrokes apart
|
||||
@@ -98,7 +194,6 @@ class Workspace {
|
||||
marked = $state.raw<NodeSummary[]>([])
|
||||
|
||||
pane = $state<Pane>('list')
|
||||
vimMode = $state('normal')
|
||||
flash = $state<Flash>(null)
|
||||
|
||||
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline'>('none')
|
||||
@@ -116,12 +211,6 @@ class Workspace {
|
||||
/** A search across every space rather than the one being browsed — Ctrl-P. */
|
||||
globalSearch = $state(false)
|
||||
|
||||
/*
|
||||
* The document `o` just made. If it is left with neither a name nor a word
|
||||
* in it, it is removed on the way out rather than left as an empty row.
|
||||
*/
|
||||
#fresh: number | null = null
|
||||
|
||||
/*
|
||||
* 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
|
||||
@@ -154,15 +243,9 @@ class Workspace {
|
||||
/** What the login screen has to say: why it is showing, if not for a first visit. */
|
||||
notice = $state('')
|
||||
|
||||
/** Saves that failed for want of a server wait this long before trying again. */
|
||||
#retryDelay = 0
|
||||
|
||||
/** Mirror vim yanks and deletes into the system clipboard, like `set clipboard=unnamed`. */
|
||||
clipboard = $state(localStorage.getItem('diary:clipboard') !== 'off')
|
||||
|
||||
/** Pending debounced write, if the draft has changed since the last save. */
|
||||
#autosave: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
get selected(): NodeSummary | undefined {
|
||||
return this.nodes[this.cursor]
|
||||
}
|
||||
@@ -301,7 +384,7 @@ class Workspace {
|
||||
failed > 0 || shared > 0 ? 'error' : undefined,
|
||||
)
|
||||
await this.refresh()
|
||||
if (this.open?.kind === 'file') this.open = await api.getNode(this.open.id)
|
||||
for (const tile of this.tiles) if (tile.open?.kind === 'file') tile.open = await api.getNode(tile.open.id)
|
||||
}
|
||||
|
||||
ask(text: string): Promise<boolean> {
|
||||
@@ -341,17 +424,17 @@ class Workspace {
|
||||
}
|
||||
|
||||
/** Call whenever the draft changes. Replaces a bare `dirty = true`. */
|
||||
touch() {
|
||||
this.dirty = true
|
||||
this.stash()
|
||||
if (this.#autosave) clearTimeout(this.#autosave)
|
||||
this.#autosave = setTimeout(() => void this.#writeBack(), Workspace.AUTOSAVE_MS)
|
||||
touch(tile = this.tile) {
|
||||
tile.dirty = true
|
||||
this.stash(tile)
|
||||
tile.stopAutosave()
|
||||
tile.autosave = setTimeout(() => void this.#writeBack(tile), Workspace.AUTOSAVE_MS)
|
||||
}
|
||||
|
||||
stash() {
|
||||
if (!this.open) return
|
||||
stash(tile = this.tile) {
|
||||
if (!tile.open) return
|
||||
try {
|
||||
localStorage.setItem(this.#stashKey(this.open.id), JSON.stringify(this.draft))
|
||||
localStorage.setItem(this.#stashKey(tile.open.id), JSON.stringify(tile.draft))
|
||||
} catch {
|
||||
/* a full or disabled localStorage must not stop the typing */
|
||||
}
|
||||
@@ -365,7 +448,7 @@ class Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
#readStash(id: number): typeof this.draft | null {
|
||||
#readStash(id: number): Draft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.#stashKey(id))
|
||||
if (!raw) return null
|
||||
@@ -377,29 +460,31 @@ class Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
async #writeBack() {
|
||||
this.#autosave = null
|
||||
if (!this.open || !this.dirty) return
|
||||
async #writeBack(tile: Tile) {
|
||||
tile.autosave = null
|
||||
if (!tile.open || !tile.dirty) return
|
||||
try {
|
||||
await this.save({ quiet: true })
|
||||
if (this.#retryDelay > 0) this.say('saved — the server is back')
|
||||
this.#retryDelay = 0
|
||||
await this.save({ quiet: true, tile })
|
||||
if (tile.retryDelay > 0) this.say('saved — the server is back')
|
||||
tile.retryDelay = 0
|
||||
} catch (error) {
|
||||
if (!unreachable(error)) return this.guard(() => Promise.reject(error))
|
||||
// Nothing is lost — the draft is in localStorage — but it is not on the
|
||||
// server either, so it is tried again, less often the longer it fails.
|
||||
this.#retryDelay = Math.min(Math.max(this.#retryDelay * 2, 2000), 60_000)
|
||||
this.say(`not saved: the server cannot be reached — kept here, trying again in ${this.#retryDelay / 1000}s`, 'error')
|
||||
this.#autosave = setTimeout(() => void this.#writeBack(), this.#retryDelay)
|
||||
tile.retryDelay = Math.min(Math.max(tile.retryDelay * 2, 2000), 60_000)
|
||||
this.say(`not saved: the server cannot be reached — kept here, trying again in ${tile.retryDelay / 1000}s`, 'error')
|
||||
tile.autosave = setTimeout(() => void this.#writeBack(tile), tile.retryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
/** Back online: what is waiting goes now rather than at the next retry. */
|
||||
#watchConnection() {
|
||||
window.addEventListener('online', () => {
|
||||
if (!this.dirty) return
|
||||
if (this.#autosave) clearTimeout(this.#autosave)
|
||||
void this.#writeBack()
|
||||
for (const tile of this.tiles) {
|
||||
if (!tile.dirty) continue
|
||||
tile.stopAutosave()
|
||||
void this.#writeBack(tile)
|
||||
}
|
||||
})
|
||||
window.addEventListener('offline', () => {
|
||||
this.say('offline — what is typed is kept here, and saved once the server answers', 'error')
|
||||
@@ -407,37 +492,32 @@ class Workspace {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write out a pending draft before doing something that would replace it.
|
||||
* Without this, clicking another document in the list discarded unsaved work
|
||||
* without a word.
|
||||
* Write out every pending draft before doing something that would replace
|
||||
* one. Without this, clicking another document in the list discarded
|
||||
* unsaved work without a word.
|
||||
*/
|
||||
async flush() {
|
||||
if (this.#autosave) {
|
||||
clearTimeout(this.#autosave)
|
||||
this.#autosave = null
|
||||
}
|
||||
if (await this.abandonIfBlank()) return
|
||||
if (this.open && this.dirty) await this.guard(() => this.save({ quiet: true }))
|
||||
for (const tile of this.tiles) await this.flushTile(tile)
|
||||
}
|
||||
|
||||
/** The same for one tile only, as it is about to show something else. */
|
||||
async flushTile(tile = this.tile) {
|
||||
tile.stopAutosave()
|
||||
if (await this.abandonIfBlank(tile)) return
|
||||
if (tile.open && tile.dirty) await this.guard(() => this.save({ quiet: true, tile }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the document `o` made if nothing was ever put in it. True when it
|
||||
* went, so the caller knows there is nothing open any more.
|
||||
*/
|
||||
async abandonIfBlank(): Promise<boolean> {
|
||||
const open = this.open
|
||||
if (!open || open.id !== this.#fresh) return false
|
||||
if (this.draft.name.trim() || this.draft.body.trim()) return false
|
||||
this.#fresh = null
|
||||
if (this.#autosave) {
|
||||
clearTimeout(this.#autosave)
|
||||
this.#autosave = null
|
||||
}
|
||||
async abandonIfBlank(tile = this.tile): Promise<boolean> {
|
||||
const open = tile.open
|
||||
if (!open || open.id !== tile.fresh) return false
|
||||
if (tile.draft.name.trim() || tile.draft.body.trim()) return false
|
||||
tile.clear()
|
||||
this.dropStash(open.id)
|
||||
this.open = null
|
||||
this.editing = false
|
||||
this.dirty = false
|
||||
this.pane = 'list'
|
||||
if (tile === this.tile) this.pane = 'list'
|
||||
try {
|
||||
await api.deleteNode(open.id)
|
||||
} catch {
|
||||
@@ -482,7 +562,9 @@ class Workspace {
|
||||
// the draft included, so pick up where it stopped rather than start over.
|
||||
if (this.spaces.length > 0) {
|
||||
await this.refresh()
|
||||
if (this.open && this.dirty) await this.guard(() => this.save({ quiet: true }))
|
||||
for (const tile of this.tiles) {
|
||||
if (tile.open && tile.dirty) await this.guard(() => this.save({ quiet: true, tile }))
|
||||
}
|
||||
return
|
||||
}
|
||||
await this.loadSpaces(Workspace.addressedPath())
|
||||
@@ -497,8 +579,7 @@ class Workspace {
|
||||
this.vault = null
|
||||
this.spaces = []
|
||||
this.nodes = []
|
||||
this.open = null
|
||||
this.editing = false
|
||||
for (const tile of this.tiles) tile.clear()
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -569,9 +650,8 @@ class Workspace {
|
||||
const from = this.spaces.findIndex((s) => s.id === this.spaceId)
|
||||
const to = this.spaces.findIndex((s) => s.id === id)
|
||||
this.switchDirection = to >= from ? 1 : -1
|
||||
this.tile.clear()
|
||||
this.returnTo = null
|
||||
this.open = null
|
||||
this.editing = false
|
||||
this.pane = 'list'
|
||||
}
|
||||
this.spaceId = id
|
||||
@@ -676,7 +756,7 @@ class Workspace {
|
||||
}
|
||||
|
||||
async openNode(id: number, edit = false, underBoard = false) {
|
||||
if (this.open?.id !== id) await this.flush()
|
||||
if (this.open?.id !== id) await this.flushTile()
|
||||
|
||||
const node = await api.getNode(id)
|
||||
// What is asked for from the board — `:12`, `T`, a search — is shown, not
|
||||
@@ -737,7 +817,7 @@ class Workspace {
|
||||
this.globalSearch = false
|
||||
await this.refresh()
|
||||
await this.openNode(node.id, true)
|
||||
this.#fresh = node.id
|
||||
this.tile.fresh = node.id
|
||||
this.enterInsert = true
|
||||
this.say(`new document ${this.here}/${node.slug}`)
|
||||
}
|
||||
@@ -764,22 +844,22 @@ class Workspace {
|
||||
* since it was opened here — another tab, the phone, the SFTP mount —
|
||||
* unless `force`, which is `:w!`.
|
||||
*/
|
||||
async save({ quiet = false, force = false } = {}) {
|
||||
if (!this.open) return
|
||||
async save({ quiet = false, force = false, tile = this.tile } = {}) {
|
||||
if (!tile.open) return
|
||||
// Snapshot what goes over the wire: an autosave races the typing that
|
||||
// triggered it, and anything typed since must stay marked unsaved.
|
||||
const sent = { ...this.draft, name: this.draft.name.trim() }
|
||||
const saved = await api.updateNode(this.open.id, {
|
||||
const sent = { ...tile.draft, name: tile.draft.name.trim() }
|
||||
const saved = await api.updateNode(tile.open.id, {
|
||||
...sent,
|
||||
base_updated_at: force ? undefined : this.open.updated_at,
|
||||
base_updated_at: force ? undefined : tile.open.updated_at,
|
||||
})
|
||||
|
||||
this.open = saved
|
||||
this.dirty =
|
||||
this.draft.body !== sent.body ||
|
||||
this.draft.name.trim() !== sent.name ||
|
||||
this.draft.created_at !== sent.created_at
|
||||
if (this.dirty) this.touch()
|
||||
tile.open = saved
|
||||
tile.dirty =
|
||||
tile.draft.body !== sent.body ||
|
||||
tile.draft.name.trim() !== sent.name ||
|
||||
tile.draft.created_at !== sent.created_at
|
||||
if (tile.dirty) this.touch(tile)
|
||||
else this.dropStash(saved.id)
|
||||
// The response carries the same excerpt the list query builds, so the row
|
||||
// can usually be patched in place rather than re-reading the whole list.
|
||||
@@ -803,19 +883,13 @@ class Workspace {
|
||||
}
|
||||
|
||||
async deleteNode(id: number) {
|
||||
if (this.#autosave) {
|
||||
clearTimeout(this.#autosave)
|
||||
this.#autosave = null
|
||||
}
|
||||
const showing = this.tilesWith(id)
|
||||
for (const tile of showing) tile.stopAutosave()
|
||||
this.dropStash(id)
|
||||
const node = this.open?.id === id ? this.open : this.nodes.find((n) => n.id === id)
|
||||
const node = showing[0]?.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'
|
||||
}
|
||||
for (const tile of showing) tile.clear()
|
||||
if (showing.includes(this.tile)) this.pane = 'list'
|
||||
this.marked = this.marked.filter((n) => n.id !== id)
|
||||
await this.refresh()
|
||||
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
|
||||
@@ -836,9 +910,9 @@ class Workspace {
|
||||
body: current.body,
|
||||
created_at: current.created_at,
|
||||
})
|
||||
if (this.open?.id === id) {
|
||||
this.open = saved
|
||||
this.draft.name = saved.name
|
||||
for (const tile of this.tilesWith(id)) {
|
||||
tile.open = saved
|
||||
tile.draft.name = saved.name
|
||||
}
|
||||
if (this.path.some((crumb) => crumb.id === id)) this.path = saved.path
|
||||
if (saved.kind === 'space') this.spaces = await api.listSpaces()
|
||||
@@ -853,8 +927,7 @@ class Workspace {
|
||||
*/
|
||||
async deleteSpace(id: number) {
|
||||
const { removed } = await api.deleteNode(id)
|
||||
this.open = null
|
||||
this.editing = false
|
||||
for (const tile of this.tiles) tile.clear()
|
||||
this.rawBoard = null
|
||||
this.view = 'tree'
|
||||
this.spaces = await api.listSpaces()
|
||||
@@ -888,12 +961,12 @@ class Workspace {
|
||||
*/
|
||||
async moveNodes(nodes: NodeSummary[], parent: number, where = 'here') {
|
||||
if (nodes.length === 0) return this.say('nothing marked — press m on a row first', 'error')
|
||||
if (this.open && nodes.some((n) => n.id === this.open?.id)) {
|
||||
await this.flush()
|
||||
this.open = null
|
||||
this.editing = false
|
||||
this.pane = 'list'
|
||||
const leaving = this.tiles.filter((tile) => nodes.some((n) => n.id === tile.open?.id))
|
||||
for (const tile of leaving) {
|
||||
await this.flushTile(tile)
|
||||
tile.clear()
|
||||
}
|
||||
if (leaving.includes(this.tile)) this.pane = 'list'
|
||||
const { moved, unchanged } = await api.moveNodes(
|
||||
nodes.map((n) => n.id),
|
||||
parent,
|
||||
@@ -1244,9 +1317,8 @@ class Workspace {
|
||||
|
||||
/** Back to the board a card's document was opened from. */
|
||||
async returnToBoard() {
|
||||
this.tile.clear()
|
||||
this.returnTo = null
|
||||
this.open = null
|
||||
this.editing = false
|
||||
this.pane = 'list'
|
||||
await this.reloadBoard()
|
||||
this.view = 'board'
|
||||
@@ -1276,7 +1348,7 @@ class Workspace {
|
||||
const { url, sealed } = await this.mintShareLink(id)
|
||||
this.say(`shared → ${url} (copied)${sealedNote(sealed)}`, sealed > 0 ? 'error' : undefined)
|
||||
}
|
||||
if (this.open?.id === id) this.open = await api.getNode(id)
|
||||
for (const tile of this.tilesWith(id)) tile.open = await api.getNode(id)
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user