diff --git a/docs/wiki/Boards-and-Agenda.md b/docs/wiki/Boards-and-Agenda.md index fba3a43..f169dc5 100644 --- a/docs/wiki/Boards-and-Agenda.md +++ b/docs/wiki/Boards-and-Agenda.md @@ -93,9 +93,10 @@ as they do on the board. | `q`, `Esc` | close the back, back to the board | The description is markdown, written in the same editor as a document, vim -and all: `:w` writes it to the card, `:q` or `Esc` in normal mode leaves, and -`:q!` throws away what was not written. Leaving the tile while writing keeps -what is written. A fenced block in a language is +and all. It saves itself as the typing stops, as a document does; `:q` or `Esc` +in normal mode leaves, and `:q!` lets go of what is not saved yet. Leaving the +tile while writing keeps what is written. `[[` links a document by its title, +and the card then shows among that document's cards. A fenced block in a language is coloured the way nvim colours it, written and read. Labels are lower case, and each name keeps its colour on every card and every diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index 31d7acb..a6c3217 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -99,7 +99,7 @@ The first list is the inbox, the last list is done. | `r` | reword the step | | `J / K` | move the step down / up | | `dd` | drop the step | -| `e · i · Enter` | write the description, in the editor — :w writes it, :q or Esc in normal mode leaves | +| `e · i · Enter` | write the description, in the editor — it saves itself, :q or Esc in normal mode leaves | | `a` | put on a label, or take it off — :label on the line, Tab offers the board's | | `R · D` | rename the card, its due date | | `H / L` | send the card to the list left / right | @@ -209,12 +209,12 @@ do: `:w!`, `:space! `, `:import!`. | command | what it does | | --- | --- | -| `:write, :w` | save the open document — or the card's description being written | +| `:write, :w` | save what is being written — the open document, a card's description, a note — rather than in a moment | | `:write!` | save even over a newer version saved somewhere else since this one was opened | -| `:wq, :x` | save and leave insert/editor mode | +| `:wq, :x` | leave the editor with everything written, as :q does | | `:wq!` | save over a newer version, as :w! does, and leave the editor | -| `:quit, :q` | close the editor, or the document when already reading | -| `:quit!` | discard changes and close the editor | +| `:quit, :q` | leave the editor with what is waiting written first, or close the document when already reading | +| `:quit!` | leave the editor, letting go of what is not saved yet | | `:edit, :e [id]` | edit the open document, or open a node by id | | `:edit!` | throw away the draft and re-read the document from the server | | `:name, :title, :rename ` | rename what is under the cursor — document, folder, or card on a board; bare, while writing, into the title | diff --git a/docs/wiki/Guide.md b/docs/wiki/Guide.md index ee4e695..b2baf17 100644 --- a/docs/wiki/Guide.md +++ b/docs/wiki/Guide.md @@ -64,8 +64,8 @@ its tiles, so switching away and back finds them as they were. More in ### Writing `i` edits the open document. The editor is CodeMirror with real vim keys: -`ciw`, `dd`, visual mode, macros, `.` and the rest. `Esc` leaves insert mode, -and `:q` leaves the editor. +`ciw`, `dd`, visual mode, macros, `.` and the rest. What you write saves itself. +`Esc` leaves insert mode, and `Esc` again or `:q` leaves the editor. ![The editor](../images/editor.png) diff --git a/docs/wiki/Writing.md b/docs/wiki/Writing.md index aa405c1..13cde3a 100644 --- a/docs/wiki/Writing.md +++ b/docs/wiki/Writing.md @@ -4,7 +4,15 @@ `i` or `a` on an open document edits it. The editor is CodeMirror 6 with the vim keymap: motions, operators, text objects, visual mode, registers, macros -and `.` all work, and `:w`, `:wq` and `:q!` do what they do in vim. +and `.` all work. It is the one editor in the app: a card's description and a +note in the scratchpad are written in it too, and behave the same way. + +- What is written saves itself (see [Saving](#saving)); `:w` saves sooner. +- `:q`, or `Esc` in normal mode, leaves the editor with what is still waiting + written first. Where that fails, the text stays on screen. `:wq` is the same. +- `:q!` leaves and lets go of what is not saved yet. +- `[[` offers the titles of documents as you type, in a description or a note + as well as a document. ![The editor](../images/editor.png) @@ -27,7 +35,9 @@ copies its address. ## Saving There is nothing to remember. Each change goes to the browser's storage as it is -typed, and to the server a couple of seconds after typing stops. +typed, and to the server a couple of seconds after typing stops. A card's +description and a note are kept the same way, and the first three cases below +hold for them too. - When the server cannot be reached, the draft stays in the browser and the save is tried again, less often the longer it fails, and at once when the @@ -60,9 +70,9 @@ a body and the time it was last touched, newest on top. | `dd` | drop the note (asks y/N) | | `Q`, `q`, `Esc` | raise the scratchpad | -A note is written in the same editor as a document, and saves itself a moment -after each change: there is nothing to `:w`, though it does no harm. `Esc` in -normal mode, or `:q`, goes back to the notes; a note left empty is not kept. +A note is written in the same editor as a document, and saves itself the same +way. `Esc` in normal mode, or `:q`, goes back to the notes; a note left empty is +not kept. `:scratch ` takes a note from the command line without lowering the drawer. diff --git a/web/src/lib/CardTile.svelte b/web/src/lib/CardTile.svelte index dd00ac0..f75c2c5 100644 --- a/web/src/lib/CardTile.svelte +++ b/web/src/lib/CardTile.svelte @@ -80,21 +80,13 @@
description
- {#if workspace.describing} - {#await import('./TextEditor.svelte')} + {#if workspace.describing && workspace.writing} + {#await import('./Editor.svelte')}
loading editor
- {:then { default: TextEditor }} - { - workspace.description = text - workspace.descriptionDirty = true - }} - onsave={() => workspace.guard(() => workspace.saveDescription())} - onleave={() => workspace.stopDescribing()} - /> + {:then { default: Editor }} + {#key workspace.writing.session} + + {/key} {/await} {:else if html} diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte index b8534b1..63d5f1b 100644 --- a/web/src/lib/Editor.svelte +++ b/web/src/lib/Editor.svelte @@ -133,7 +133,14 @@ -
+ +
+
+ {#if writing} +
+ {mode} + {#if writing.dirty}+{/if} + saves itself · :q leave · :q! drop what is unsaved +
+ {/if} +
diff --git a/web/src/lib/Scratch.svelte b/web/src/lib/Scratch.svelte index 71afd35..9ab1296 100644 --- a/web/src/lib/Scratch.svelte +++ b/web/src/lib/Scratch.svelte @@ -42,7 +42,7 @@
    {#if workspace.scratchEditing && workspace.scratchNew} -
  • {headline(workspace.scratchDraft) || 'new note'}now
  • +
  • {headline(workspace.writing?.text ?? '') || 'new note'}now
  • {/if} {#each workspace.scratchNotes as entry, index (entry.id)} @@ -64,20 +64,14 @@
- {#if workspace.scratchEditing} - {#key workspace.scratchSession} - {#await import('./TextEditor.svelte')} -
loading editor
- {:then { default: TextEditor }} - workspace.scratchChanged(text)} - onsave={() => workspace.guard(() => workspace.saveScratchNote())} - onleave={() => workspace.leaveScratchNote()} - /> - {/await} - {/key} + {#if workspace.scratchEditing && workspace.writing} + {#await import('./Editor.svelte')} +
loading editor
+ {:then { default: Editor }} + {#key workspace.writing.session} + + {/key} + {/await} {:else if html}
workspace.editScratchNote()}> @@ -156,8 +150,6 @@ .none { font-size: 12px; } .page { min-height: 0; overflow-y: auto; border-left: 1px solid var(--rim); padding-left: 16px; } - .page :global(.writing) { height: 100%; } - .page :global(.writing .editor) { flex: 1; max-height: none; } .page .md { font-size: 14px; } .hint { font-size: 12px; margin: 6px 0; } diff --git a/web/src/lib/TextEditor.svelte b/web/src/lib/TextEditor.svelte deleted file mode 100644 index bdaa1bc..0000000 --- a/web/src/lib/TextEditor.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - - -
-
-
- {mode} - {#if dirty}+{/if} - :w write · :q leave · :q! discard -
-
- - diff --git a/web/src/lib/actions.ts b/web/src/lib/actions.ts index 220675f..80fd8d7 100644 --- a/web/src/lib/actions.ts +++ b/web/src/lib/actions.ts @@ -106,8 +106,7 @@ function tileKey(direction: Direction, shift: boolean) { export const actions: { [C in Context]: Record, Run> } = { always: { save: () => { - if (workspace.scratchEditing) guard(() => workspace.saveScratchNote()) - else if (workspace.describing) guard(() => workspace.saveDescription()) + if (workspace.writing) guard(() => workspace.saveWriting()) else if (workspace.open) guard(() => workspace.save()) }, close: () => void runCommand('close'), @@ -308,7 +307,7 @@ export const actions: { [C in Context]: Record, Run> } = { workspace: { /** Esc leaves one thing: the line, or fullscreen. */ escape: () => { - if (workspace.describing || workspace.scratchEditing) return + if (workspace.writing) return if (hooks.closeCommandLine()) return if (workspace.fullscreen && !workspace.editing) return void (workspace.fullscreen = false) return false diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 66258b3..5ea785e 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -62,11 +62,11 @@ export interface CommandSpec { args?: string group: Group help: string - run: (arg: string) => void | Promise + run: (arg: string) => unknown /** What the argument can be, for Tab on the command line. */ complete?: (arg: string) => string[] /** The forcing variant, `:name!` — vim hands the bang over as an argument. */ - bang?: { help: string; run: (arg: string) => void | Promise } + bang?: { help: string; run: (arg: string) => unknown } } /** Close the focused tile; the index of the one that has the focus after. */ @@ -326,20 +326,26 @@ const exportName = (title: string, at: number) => { return `${formatDay(at)}${slug ? `-${slug}` : ''}.md` } +/* + * Out of the editor, one level down: what is waiting is written first, and + * where that fails the text stays on screen to be tried again or let go with + * `:q!`. A document `o` made and nothing was written in is not kept. + */ +async function leaveEditor() { + if (workspace.writing) return void workspace.leaveWriting() + if (await workspace.abandonIfBlank()) return hooks.focusList() + if (workspace.dirty && !(await workspace.guard(() => workspace.save()))) return + workspace.editing = false + hooks.focusList() +} + export const commands: CommandSpec[] = [ { name: 'write', aliases: ['w'], group: 'editing', - help: "save the open document — or the card's description being written", - run: () => - workspace.guard(() => - workspace.scratchEditing - ? workspace.saveScratchNote() - : workspace.describing - ? workspace.saveDescription() - : workspace.save(), - ), + help: "save what is being written — the open document, a card's description, a note — rather than in a moment", + run: () => workspace.guard(() => (workspace.writing ? workspace.saveWriting() : workspace.save())), bang: { help: 'save even over a newer version saved somewhere else since this one was opened', run: () => workspace.guard(() => workspace.save({ force: true })), @@ -349,24 +355,12 @@ export const commands: CommandSpec[] = [ name: 'wq', aliases: ['x'], group: 'editing', - help: 'save and leave insert/editor mode', - run: async () => { - if (workspace.scratchEditing) return workspace.leaveScratchNote() - if (workspace.describing) { - await workspace.guard(() => workspace.saveDescription()) - workspace.stopDescribing() - return - } - await workspace.guard(() => workspace.save()) - workspace.editing = false - hooks.focusList() - }, + help: 'leave the editor with everything written, as :q does', + run: () => leaveEditor(), bang: { help: 'save over a newer version, as :w! does, and leave the editor', run: async () => { - await workspace.guard(() => workspace.save({ force: true })) - workspace.editing = false - hooks.focusList() + if (await workspace.guard(() => workspace.save({ force: true }))) await leaveEditor() }, }, }, @@ -374,25 +368,23 @@ export const commands: CommandSpec[] = [ name: 'quit', aliases: ['q'], group: 'editing', - help: 'close the editor, or the document when already reading', + help: 'leave the editor with what is waiting written first, or close the document when already reading', run: async () => { - if (workspace.scratchEditing) return workspace.leaveScratchNote() - if (workspace.describing) return void workspace.stopDescribing() + if (workspace.writing || workspace.editing) return leaveEditor() // A document `o` made and nothing was written in is not kept. if (await workspace.abandonIfBlank()) return hooks.focusList() if (workspace.dirty) { workspace.say('unsaved changes — :w to write, :q! to discard', 'error') return } - if (workspace.editing) workspace.editing = false - else if (workspace.tiles.length > 1) return focusTile(closeFocused()) - else workspace.closeTile() + if (workspace.tiles.length > 1) return focusTile(closeFocused()) + workspace.closeTile() hooks.focusList() }, bang: { - help: 'discard changes and close the editor', + help: 'leave the editor, letting go of what is not saved yet', run: async () => { - if (workspace.describing) return void workspace.stopDescribing(true) + if (workspace.writing) return void workspace.leaveWriting(true) const open = workspace.open workspace.editing = false workspace.dirty = false diff --git a/web/src/lib/keymap.ts b/web/src/lib/keymap.ts index 7ff7447..19f29c6 100644 --- a/web/src/lib/keymap.ts +++ b/web/src/lib/keymap.ts @@ -173,7 +173,7 @@ export const bindings = [ { context: 'card', keys: ['J'], action: 'nudgeDown', label: 'J / K', caps: ['J', 'K'], hint: 'order', help: 'move the step down / up' }, { context: 'card', keys: ['K'], action: 'nudgeUp' }, { context: 'card', keys: ['dd'], action: 'drop', hint: 'drop', help: 'drop the step' }, - { context: 'card', keys: ['e', 'i', 'Enter'], action: 'describe', caps: ['e'], hint: 'describe', help: 'write the description, in the editor — :w writes it, :q or Esc in normal mode leaves' }, + { context: 'card', keys: ['e', 'i', 'Enter'], action: 'describe', caps: ['e'], hint: 'describe', help: 'write the description, in the editor — it saves itself, :q or Esc in normal mode leaves' }, { context: 'card', keys: ['a'], action: 'label', hint: 'label', help: "put on a label, or take it off — :label on the line, Tab offers the board's" }, { context: 'card', keys: ['R'], action: 'rename', label: 'R · D', help: 'rename the card, its due date' }, { context: 'card', keys: ['D'], action: 'due', hint: 'due' }, @@ -283,7 +283,10 @@ export const bindings = [ { context: 'share', keys: ['y'], action: 'link', hint: 'new link' }, // --- the editor's own keymap, described only --------------------------------------- - { context: 'editor', label: 'vim', help: 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes' }, + { context: 'editor', label: 'vim', help: 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros' }, + { context: 'editor', label: 'autosave', help: 'a document, a description or a note saves itself as the typing stops; :w saves sooner' }, + { context: 'editor', label: ':q · Esc', help: 'leave the editor, what is waiting written first — Esc in normal mode; :q! lets it go' }, + { context: 'editor', label: '[[', help: 'link a document by its title, offered as you type' }, { context: 'editor', label: 'drop · paste', help: 'an image, video or audio file attaches it' }, { context: 'editor', label: '$x^2$ · $$…$$', help: 'set as math' }, { context: 'editor', label: '```rust', help: 'a fenced block in a language is coloured as nvim colours it, written and read' }, diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 51b0e75..9f63d15 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -24,6 +24,7 @@ import { type VaultStatus, } from './api' import { formatBytes, formatDay, formatStamp, formatWeekday, isTouchDevice, parseDay, sealedNote, type Heading } from './util' +import { AUTOSAVE_MS, Writing, type WritingKind } from './writing.svelte' export type Pane = 'list' | 'document' /** @@ -259,7 +260,7 @@ class Workspace { /** `:close`: the focused tile goes, or with only one, what it shows. */ closeTile() { - if (this.tile.kind === 'card' && this.describing && !this.stopDescribing()) return + this.#leaveCard() this.tile.clear() this.prune() if (!this.tile.empty) this.focusTile(this.focused) @@ -324,25 +325,31 @@ class Workspace { /* * The scratchpad: a drawer that comes down from under the bar over whatever * is on screen, as the desktop's scratchpad does, with the notes taken in - * passing. `scratchEditing` is the note under the cursor being written; - * `scratchNew` a note not yet saved, which is only kept once it says - * something. + * passing. */ scratchOpen = $state(false) scratchNotes = $state([]) scratchCursor = $state(0) - scratchEditing = $state(false) - scratchNew = $state(false) - scratchDraft = $state('') - /** Counts the times a note was opened to write, so the editor is made anew only then. */ - scratchSession = $state(0) /** The step of the open card's checklist under the cursor. */ taskCursor = $state(0) - /** The open card's description is being written, and what it says so far. */ - describing = $state(false) - description = $state('') - descriptionDirty = $state(false) + /* + * The short text being written in place, if one is: the description of the + * card turned over, or the note under the drawer's cursor. One at a time, + * as there is one place on screen each is written in. + */ + writing = $state(null) + + get describing() { + return this.writing?.kind === 'description' + } + get scratchEditing() { + return this.writing?.kind === 'note' + } + /** The note being written has not been saved yet, so it is in no row. */ + get scratchNew() { + return this.scratchEditing && this.writing?.id === null + } /** `z` on the board: show done cards older than a week too. */ showOldDone = $state(false) @@ -653,7 +660,6 @@ class Workspace { * server a couple of seconds after typing stops. */ - static readonly AUTOSAVE_MS = 2500 static readonly RECENT_DONE_DAYS = 7 #stashKey(id: number) { @@ -665,7 +671,7 @@ class Workspace { tile.dirty = true this.stash(tile) tile.stopAutosave() - tile.autosave = setTimeout(() => void this.#writeBack(tile), Workspace.AUTOSAVE_MS) + tile.autosave = setTimeout(() => void this.#writeBack(tile), AUTOSAVE_MS) } stash(tile = this.tile) { @@ -722,6 +728,7 @@ class Workspace { tile.stopAutosave() void this.#writeBack(tile) } + if (this.writing?.dirty) void this.guard(() => this.saveWriting()) }) window.addEventListener('offline', () => { this.say('offline — what is typed is kept here, and saved once the server answers', 'error') @@ -802,6 +809,7 @@ class Workspace { for (const tile of this.tiles) { if (tile.open && tile.dirty) await this.guard(() => this.save({ quiet: true, tile })) } + if (this.writing?.dirty) await this.guard(() => this.saveWriting()) return } await this.loadSpaces(Workspace.addressedPath()) @@ -1884,15 +1892,54 @@ class Workspace { } /* - * Leaving a card's back while its description is being written: what is - * written is kept, as leaving a document's tile keeps its draft. + * Writing in place. A card's description and a note are written the way a + * document is: saved by themselves a moment after the typing stops, `:w` + * sooner, and `:q` or Esc leaves with what is still waiting written first. */ + + /** Begin writing `kind`: what it says on the server, unless this browser kept newer. */ + #write(kind: WritingKind, id: number | null, text: string, write: (text: string, writing: Writing) => Promise) { + const writing: Writing = new Writing( + kind, + id, + text, + (text) => write(text, writing), + (error, retry) => + unreachable(error) + ? this.say(`not saved: the server cannot be reached — kept here, trying again in ${retry / 1000}s`, 'error') + : void this.guard(() => Promise.reject(error)), + ) + this.writing = writing + if (writing.recovered) this.say(`recovered unsaved ${kind} — :w keeps it, :q! drops it`, 'error') + } + + /** `:w`, Ctrl-S: what is written goes now rather than in a moment. */ + async saveWriting() { + const writing = this.writing + if (!writing) return + await writing.save() + if (writing.kind === 'description') this.say('description written') + } + + /** + * `:q`, Esc in normal mode: leave, with what is still waiting written + * first; `:q!` lets that go instead. False, and a word why, when writing + * failed, so the text stays on screen. A note left empty is not kept. + */ + async leaveWriting(discard = false): Promise { + const writing = this.writing + if (!writing) return true + if (discard) writing.discard() + else if (!(await this.guard(() => writing.save()))) return false + if (this.writing === writing) this.writing = null + const note = this.scratchNotes.find((n) => n.id === writing.id) + if (writing.kind === 'note' && note && !note.body.trim()) await this.guard(() => this.dropScratchNote(false, note)) + return true + } + + /** Leaving a card's back, or the drawer, while something is written there keeps it, as leaving a document's tile does. */ #leaveCard() { - if (this.tile.kind !== 'card' || !this.describing) return - void this.guard(async () => { - if (this.descriptionDirty) await this.saveDescription() - this.stopDescribing(true) - }) + if (this.tile.kind === 'card' && this.describing) void this.leaveWriting() } get scratchNote(): ScratchNote | undefined { @@ -1904,83 +1951,46 @@ class Workspace { if (this.scratchOpen) return this.closeScratch() this.scratchNotes = await api.scratch() this.scratchCursor = Math.min(this.scratchCursor, Math.max(this.scratchNotes.length - 1, 0)) - this.scratchEditing = false this.scratchOpen = true } async closeScratch() { - if (this.scratchEditing) await this.leaveScratchNote() + if (this.scratchEditing && !(await this.leaveWriting())) return this.scratchOpen = false } /** `o`: a new note at the top, written straight away. */ newScratchNote() { - this.scratchNew = true - this.scratchDraft = '' - this.scratchSession++ - this.scratchEditing = true + this.#writeNote(null, '') } /** `e`, `i`, Enter: write the note under the cursor. */ editScratchNote() { const note = this.scratchNote if (!note) return this.newScratchNote() - this.scratchNew = false - this.scratchDraft = note.body - this.scratchSession++ - this.scratchEditing = true + this.#writeNote(note.id, note.body) } - #scratchTimer: ReturnType | undefined - #scratchSaving: Promise = Promise.resolve() - /* - * A scratch note saves itself: nothing is asked of a note taken in passing, - * not even `:w`. Each change waits a moment for the next before it is - * written, and saves run one after another, so the first save of a new note - * has made it before the second looks for its id. + * A new note is only made once it says something. The note touched last + * goes to the top of the list, and the cursor with it. */ - scratchChanged(text: string) { - this.scratchDraft = text - clearTimeout(this.#scratchTimer) - this.#scratchTimer = setTimeout(() => void this.guard(() => this.saveScratchNote()), 800) - } - - saveScratchNote(): Promise { - clearTimeout(this.#scratchTimer) - const run = this.#scratchSaving.then(async () => { - if (!this.scratchEditing) return - const text = this.scratchDraft - if (this.scratchNew) { + #writeNote(id: number | null, body: string) { + this.#write('note', id, body, async (text, writing) => { + if (writing.id === null) { if (!text.trim()) return const note = await api.createScratch(text) + writing.id = note.id this.scratchNotes = [note, ...this.scratchNotes] - this.scratchCursor = 0 - this.scratchNew = false - return + } else { + const saved = await api.updateScratch(writing.id, text) + this.scratchNotes = [saved, ...this.scratchNotes.filter((n) => n.id !== saved.id)] } - const note = this.scratchNote - if (!note || note.body === text) return - const saved = await api.updateScratch(note.id, text) - // The note touched last goes to the top, and the cursor with it. - this.scratchNotes = [saved, ...this.scratchNotes.filter((n) => n.id !== saved.id)] this.scratchCursor = 0 }) - this.#scratchSaving = run.catch(() => {}) - return run } - /** Esc, `:q`: back to the list, saved — and a note left empty is not kept. */ - async leaveScratchNote() { - await this.guard(() => this.saveScratchNote()) - const note = this.scratchNote - if (!this.scratchNew && note && !this.scratchDraft.trim()) await this.guard(() => this.dropScratchNote(false)) - this.scratchEditing = false - this.scratchNew = false - } - - async dropScratchNote(say = true) { - const note = this.scratchNote + async dropScratchNote(say = true, note = this.scratchNote) { if (!note) return await api.deleteScratch(note.id) this.scratchNotes = this.scratchNotes.filter((n) => n.id !== note.id) @@ -2001,7 +2011,7 @@ class Workspace { * line is the title — a `# heading` or not — and the rest the body. */ async keepScratchNote(title = '') { - if (this.scratchEditing) await this.saveScratchNote() + if (this.scratchEditing && !(await this.leaveWriting())) return const note = this.scratchNote if (!note) return this.say('no scratch note to keep', 'error') const parent = this.parentId ?? this.spaceId ?? this.spaces.find((space) => space.role !== 'diary')?.id @@ -2016,8 +2026,7 @@ class Workspace { } const node = await api.createNode({ parent_id: parent, name, body }) // Made first, dropped after: a failure in between leaves two copies, never none. - await this.dropScratchNote(false) - this.scratchEditing = false + await this.dropScratchNote(false, note) this.scratchOpen = false await this.refresh(node.id) await this.openNode(node.id) @@ -2033,33 +2042,12 @@ class Workspace { this.overlay = 'none' } - /** `e`: write the description, from what it says now. */ + /** `e`: write the description of the card turned over, from what it says now. */ describe() { const card = this.card if (!card) return - this.description = card.body - this.descriptionDirty = false - this.describing = true - } - - async saveDescription() { - const card = this.card - if (!card || !this.describing) return - // Named, as this also runs when the focus has just left the card. - await this.patchCard({ body: this.description }, card) - this.descriptionDirty = false - this.say(`"${card.title}" described`) - } - - /** Stop writing; false, and a word why, while something is unsaved. */ - stopDescribing(discard = false): boolean { - if (this.descriptionDirty && !discard) { - this.say('unsaved description — :w to write, :q! to discard', 'error') - return false - } - this.describing = false - this.descriptionDirty = false - return true + // Named, as the board's cursor may be on another card by the time it saves. + this.#write('description', card.id, card.body, (body) => this.patchCard({ body }, card).then(() => {})) } /* @@ -2356,10 +2344,11 @@ class Workspace { localStorage.setItem('diary:clipboard', enabled ? 'on' : 'off') } - /** Surface a failed API call in the status line instead of the console. */ - async guard(action: () => Promise) { + /** Surface a failed API call in the status line instead of the console. True when it went through. */ + async guard(action: () => Promise): Promise { try { await action() + return true } catch (error) { if (error instanceof ApiError && error.status === 401) { this.user = null @@ -2372,6 +2361,7 @@ class Workspace { } else { this.say(error instanceof Error ? error.message : String(error), 'error') } + return false } } } diff --git a/web/src/lib/writing.svelte.ts b/web/src/lib/writing.svelte.ts new file mode 100644 index 0000000..0a2c38d --- /dev/null +++ b/web/src/lib/writing.svelte.ts @@ -0,0 +1,136 @@ +/* + * A short text written in place — a card's description, a note in the + * drawer — kept the way a document's draft is: in this browser on every + * change, cheap and synchronous, and on the server a moment after the typing + * stops. Nothing has to be asked of it: `:w` only writes sooner, and `:q` + * writes what is still waiting before it leaves. + */ +import { unreachable } from './api' + +/** How long the typing has to stop before a draft goes to the server. */ +export const AUTOSAVE_MS = 2500 + +export type WritingKind = 'description' | 'note' + +let sessions = 0 + +export class Writing { + /** Counts the times something was opened to write, so the editor is made anew only then. */ + readonly session = ++sessions + /** The card or the note written, or null for a note not saved yet. */ + id = $state(null) + text = $state('') + /** Changed since the server last had it. */ + dirty = $state(false) + /** The text was found in this browser, newer than what the server has. */ + readonly recovered: boolean + + #write: (text: string) => Promise + #fail: (error: unknown, retry: number) => void + #timer: ReturnType | undefined + #saving: Promise = Promise.resolve() + #retry = 0 + + /** + * `write` sends the text to the server, and may set `id` on the way, as + * the first save of a new note does. `fail` hears of an autosave that did + * not make it, and in how long one that found no server is tried again. + */ + constructor( + readonly kind: WritingKind, + id: number | null, + text: string, + write: (text: string) => Promise, + fail: (error: unknown, retry: number) => void, + ) { + this.id = id + this.#write = write + this.#fail = fail + const stashed = read(this.key) + this.recovered = stashed !== null && stashed !== text + this.text = this.recovered ? stashed! : text + this.dirty = this.recovered + } + + get key() { + return `${this.kind}:draft:${this.id ?? 'new'}` + } + + /** The editor's every change. */ + change(text: string) { + this.text = text + this.dirty = true + keep(this.key, text) + clearTimeout(this.#timer) + this.#timer = setTimeout(() => void this.#autosave(), AUTOSAVE_MS) + } + + /* + * Saves run one after another, so the first save of a new note has made it + * before the next looks for its id. + */ + save(): Promise { + clearTimeout(this.#timer) + const run = this.#saving.then(async () => { + if (!this.dirty) return + const text = this.text + const key = this.key + await this.#write(text) + forget(key) + if (this.text === text) this.dirty = false + // Typed on while it was on its way, or under a key the note has only now. + else keep(this.key, this.text) + }) + this.#saving = run.catch(() => {}) + return run + } + + async #autosave() { + try { + await this.save() + this.#retry = 0 + } catch (error) { + // Nothing is lost — it is kept here — but it is not on the server either, + // so a server that cannot be reached is tried again, less often the + // longer it stays away. + if (unreachable(error)) { + this.#retry = Math.min(Math.max(this.#retry * 2, 2000), 60_000) + this.#timer = setTimeout(() => void this.#autosave(), this.#retry) + } + this.#fail(error, this.#retry) + } + } + + /** `:q!`: what the server does not have yet is let go. */ + discard() { + clearTimeout(this.#timer) + forget(this.key) + this.dirty = false + } +} + +/* A full or disabled localStorage must not stop the typing. */ + +function read(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function keep(key: string, text: string) { + try { + localStorage.setItem(key, text) + } catch { + /* nothing to do */ + } +} + +function forget(key: string) { + try { + localStorage.removeItem(key) + } catch { + /* nothing to do */ + } +}