diff --git a/web/src/App.svelte b/web/src/App.svelte index e08a1b1..7e7ab84 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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'} - + {:else if workspace.open} - + {:else}
diff --git a/web/src/lib/DocumentPane.svelte b/web/src/lib/DocumentPane.svelte index dd4235f..5a6e230 100644 --- a/web/src/lib/DocumentPane.svelte +++ b/web/src/lib/DocumentPane.svelte @@ -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 @@ - + {workspace.here}/{doc.slug} - {#if workspace.dirty}modified{/if} + {#if tile.dirty}modified{/if}
written
-
{formatStamp(workspace.draft.created_at)} {formatWeekday(workspace.draft.created_at)}
+
{formatStamp(tile.draft.created_at)} {formatWeekday(tile.draft.created_at)}
edited
{relative(doc.updated_at)}
words
@@ -155,7 +160,7 @@
- {#if workspace.editing} + {#if tile.editing} @@ -180,13 +185,13 @@
{/if} - {#if workspace.editing} + {#if tile.editing} 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 @@
loading editor_
{:then module} {@const Editor = module.default} - + {/await} {/key}
diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte index f2c136b..7cc5315 100644 --- a/web/src/lib/Editor.svelte +++ b/web/src/lib/Editor.svelte @@ -1,6 +1,126 @@ diff --git a/web/src/lib/FilePane.svelte b/web/src/lib/FilePane.svelte index 34a072c..41dcf20 100644 --- a/web/src/lib/FilePane.svelte +++ b/web/src/lib/FilePane.svelte @@ -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 diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 6f112aa..3848e06 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -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(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 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 | 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(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([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('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([]) pane = $state('list') - vimMode = $state('normal') flash = $state(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 | 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 { @@ -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 { - 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 { + 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() }