editor: one editor, and everything written saves itself

TextEditor.svelte is folded into Editor.svelte. A card's description and a
note in the scratchpad are written in the same editor as a document, with
the same `[[` completion, and kept the same way: a new Writing (writing.svelte.ts)
stashes each change in localStorage and sends it to the server once the
typing stops, retrying a server that cannot be reached and recovering a
draft a dead tab left behind.

The keys out are the same everywhere: `:w` saves sooner, `:q` or Esc in
normal mode leaves with what is waiting written first, and stays where that
fails; `:q!` lets go of what is not saved. The store's describing and
scratch-editing flags are getters over the one `writing`.
This commit is contained in:
nvrl committed 2026-10-22 10:57:56 +02:00
1 parent b8b3641e6e
commit ccc91028e7
13 files changed
+426 -360

No files matched your search

+4 -3
View File
@@ -93,9 +93,10 @@ as they do on the board.
| `q`, `Esc` | close the back, back to the board | | `q`, `Esc` | close the back, back to the board |
The description is markdown, written in the same editor as a document, vim 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 and all. It saves itself as the typing stops, as a document does; `:q` or `Esc`
`:q!` throws away what was not written. Leaving the tile while writing keeps in normal mode leaves, and `:q!` lets go of what is not saved yet. Leaving the
what is written. A fenced block in a language is 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. coloured the way nvim colours it, written and read.
Labels are lower case, and each name keeps its colour on every card and every Labels are lower case, and each name keeps its colour on every card and every
+5 -5
View File
@@ -99,7 +99,7 @@ The first list is the inbox, the last list is done.
| `r` | reword the step | | `r` | reword the step |
| `J / K` | move the step down / up | | `J / K` | move the step down / up |
| `dd` | drop the step | | `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 | | `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 | | `R · D` | rename the card, its due date |
| `H / L` | send the card to the list left / right | | `H / L` | send the card to the list left / right |
@@ -209,12 +209,12 @@ do: `:w!`, `:space! <name>`, `:import!`.
| command | what it does | | 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 | | `: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 | | `: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, :q` | leave the editor with what is waiting written first, or close the document when already reading |
| `:quit!` | discard changes and close the editor | | `: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, :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 | | `:edit!` | throw away the draft and re-read the document from the server |
| `:name, :title, :rename <text>` | rename what is under the cursor — document, folder, or card on a board; bare, while writing, into the title | | `:name, :title, :rename <text>` | rename what is under the cursor — document, folder, or card on a board; bare, while writing, into the title |
+2 -2
View File
@@ -64,8 +64,8 @@ its tiles, so switching away and back finds them as they were. More in
### Writing ### Writing
`i` edits the open document. The editor is CodeMirror with real vim keys: `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, `ciw`, `dd`, visual mode, macros, `.` and the rest. What you write saves itself.
and `:q` leaves the editor. `Esc` leaves insert mode, and `Esc` again or `:q` leaves the editor.
![The editor](../images/editor.png) ![The editor](../images/editor.png)
+15 -5
View File
@@ -4,7 +4,15 @@
`i` or `a` on an open document edits it. The editor is CodeMirror 6 with the `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 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) ![The editor](../images/editor.png)
@@ -27,7 +35,9 @@ copies its address.
## Saving ## Saving
There is nothing to remember. Each change goes to the browser's storage as it is 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 - 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 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) | | `dd` | drop the note (asks y/N) |
| `Q`, `q`, `Esc` | raise the scratchpad | | `Q`, `q`, `Esc` | raise the scratchpad |
A note is written in the same editor as a document, and saves itself a moment A note is written in the same editor as a document, and saves itself the same
after each change: there is nothing to `:w`, though it does no harm. `Esc` in way. `Esc` in normal mode, or `:q`, goes back to the notes; a note left empty is
normal mode, or `:q`, goes back to the notes; a note left empty is not kept. not kept.
`:scratch <text>` takes a note from the command line without lowering the `:scratch <text>` takes a note from the command line without lowering the
drawer. drawer.
+6 -14
View File
@@ -80,21 +80,13 @@
<section> <section>
<div class="label head"><span class="marker"></span>description</div> <div class="label head"><span class="marker"></span>description</div>
{#if workspace.describing} {#if workspace.describing && workspace.writing}
{#await import('./TextEditor.svelte')} {#await import('./Editor.svelte')}
<div class="empty label">loading editor</div> <div class="empty label">loading editor</div>
{:then { default: TextEditor }} {:then { default: Editor }}
<TextEditor {#key workspace.writing.session}
doc={workspace.description} <Editor writing={workspace.writing} hint="what this card is about, in short — [[ links a document" />
placeholder="what this card is about…" {/key}
dirty={workspace.descriptionDirty}
onchange={(text) => {
workspace.description = text
workspace.descriptionDirty = true
}}
onsave={() => workspace.guard(() => workspace.saveDescription())}
onleave={() => workspace.stopDescribing()}
/>
{/await} {/await}
{:else if html} {:else if html}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_element_interactions -->
+125 -43
View File
@@ -133,7 +133,14 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete' /*
* One editor for everything written: a document's draft in its tile, and
* the short texts written in place — a card's description, a note. Vim,
* markdown, `[[` completion, the colours, the autosave behind it and the
* keys out of it are the same in each: `:w` writes sooner, `:q` or Esc in
* normal mode leaves with what is waiting written, `:q!` lets it go.
*/
import { autocompletion, completionStatus, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { indentOnInput } from '@codemirror/language' import { indentOnInput } from '@codemirror/language'
@@ -151,14 +158,38 @@
import { editorTheme, highlighting, languages } from './editorTheme' import { editorTheme, highlighting, languages } from './editorTheme'
import { embedSnippet } from './markdown' import { embedSnippet } from './markdown'
import { workspace, type Tile } from './store.svelte' import { workspace, type Tile } from './store.svelte'
import type { Writing } from './writing.svelte'
/** The tile this editor writes in, which need not be the one with focus. */ /**
const { tile }: { tile: Tile } = $props() * What is written: the draft of the document in `tile`, which need not be
* the one with focus, or the short text in `writing`, framed with a mode
* line of its own since no status line speaks for it — as tall as it needs,
* or filling what it is put in.
*/
const {
tile,
writing,
hint = 'write…',
fill = false,
}: { tile?: Tile; writing?: Writing; hint?: string; fill?: boolean } = $props()
let host = $state<HTMLDivElement>() let host = $state<HTMLDivElement>()
let view: EditorView | undefined let view: EditorView | undefined
let mode = $state('normal')
const focus = () => view?.focus() const focus = () => view?.focus()
/*
* Esc, before vim sees it: in normal mode, or with no vim, it leaves, as
* `:q` does — unless it is closing the completions.
*/
function onkeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || (workspace.vimEnabled && mode !== 'normal')) return
if (view && completionStatus(view.state) !== null) return
event.preventDefault()
event.stopPropagation()
void runCommand('quit')
}
/* /*
* `[[` offers the titles of documents as the search finds them, each with * `[[` offers the titles of documents as the search finds them, each with
* where it sits; picking one closes the brackets. * where it sits; picking one closes the brackets.
@@ -172,7 +203,7 @@
if (context.aborted) return null if (context.aborted) return null
const options = found const options = found
.filter((node) => node.kind === 'document' || node.kind === 'file') .filter((node) => node.kind === 'document' || node.kind === 'file')
.filter((node) => node.id !== tile.open?.id) .filter((node) => node.id !== tile?.open?.id)
.map((node) => { .map((node) => {
// What a `[[link]]` can hold: a name with no bracket or bar in it, or else the slug. // What a `[[link]]` can hold: a name with no bracket or bar in it, or else the slug.
const name = node.name.trim() const name = node.name.trim()
@@ -222,6 +253,14 @@
}) })
} }
/** What a change, `Ctrl-S` and the starting text mean for what is written. */
const changed = (text: string) => {
if (writing) return writing.change(text)
tile!.draft.body = text
workspace.touch(tile)
}
const save = () => void workspace.guard(() => (writing ? workspace.saveWriting() : workspace.save({ tile })))
onMount(() => { onMount(() => {
bindVim() bindVim()
@@ -237,66 +276,68 @@
autocompletion({ override: [titleCompletions], icons: false }), autocompletion({ override: [titleCompletions], icons: false }),
highlighting, highlighting,
EditorView.lineWrapping, EditorView.lineWrapping,
placeholder('write…'), placeholder(hint),
editorTheme, editorTheme,
...(writing ? [EditorView.theme({ '.cm-content': { padding: '10px 14px', maxWidth: 'none' } })] : []),
keymap.of([ keymap.of([
{ { key: 'Mod-s', preventDefault: true, run: () => (save(), true) },
key: 'Mod-s',
preventDefault: true,
run: () => (void workspace.guard(() => workspace.save({ tile })), true),
},
...defaultKeymap, ...defaultKeymap,
...historyKeymap, ...historyKeymap,
indentWithTab, indentWithTab,
]), ]),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) { if (update.docChanged) changed(update.state.doc.toString())
tile.draft.body = update.state.doc.toString()
workspace.touch(tile)
}
}),
EditorView.domEventHandlers({
paste: (event) => {
const files = Array.from(event.clipboardData?.files ?? [])
if (files.length === 0) return false
event.preventDefault()
void uploadFiles(files)
return true
},
drop: (event) => {
const files = Array.from(event.dataTransfer?.files ?? [])
if (files.length === 0) return false
event.preventDefault()
void uploadFiles(files)
return true
},
}), }),
// A file goes into a document; a card or a note has nowhere to keep one.
...(tile
? [
EditorView.domEventHandlers({
paste: (event) => {
const files = Array.from(event.clipboardData?.files ?? [])
if (files.length === 0) return false
event.preventDefault()
void uploadFiles(files)
return true
},
drop: (event) => {
const files = Array.from(event.dataTransfer?.files ?? [])
if (files.length === 0) return false
event.preventDefault()
void uploadFiles(files)
return true
},
}),
]
: []),
] ]
const text = writing ? writing.text : tile!.draft.body
view = new EditorView({ view = new EditorView({
state: EditorState.create({ doc: tile.draft.body, extensions }), state: EditorState.create({ doc: text, extensions }),
parent: host!, parent: host!,
}) })
if (workspace.tile === tile) view.focus() if (writing || workspace.tile === tile) view.focus()
const cm = workspace.vimEnabled ? getCM(view) : null const cm = workspace.vimEnabled ? getCM(view) : null
if (cm) { if (cm) {
// Keep the status line's mode indicator in step with the editor. // Keep the mode line in step with the editor.
const emitter = cm as unknown as { const emitter = cm as unknown as {
on?: (event: string, handler: (e: { mode: string }) => void) => void on?: (event: string, handler: (e: { mode: string }) => void) => void
} }
emitter.on?.('vim-mode-change', (event) => { emitter.on?.('vim-mode-change', (event) => {
tile.vimMode = event.mode mode = event.mode
if (tile) tile.vimMode = event.mode
}) })
// A fresh entry starts in insert mode; an existing one starts in normal // A fresh entry starts in insert mode, and so does an empty short text,
// mode, the way opening a file in vim does. // which is there to be written; anything else starts in normal mode,
if (tile.enterInsert) { // the way opening a file in vim does.
tile.enterInsert = false if (tile?.enterInsert || (writing && !text.trim())) {
if (tile) tile.enterInsert = false
Vim.handleKey(cm, 'i', 'workspace') Vim.handleKey(cm, 'i', 'workspace')
} }
} } else mode = 'insert'
}) })
/* /*
@@ -304,7 +345,7 @@
* the hooks, and the rest stand by until their tile is focused. * the hooks, and the rest stand by until their tile is focused.
*/ */
$effect(() => { $effect(() => {
if (workspace.tile !== tile) return if (!tile || workspace.tile !== tile) return
hooks.focusEditor = focus hooks.focusEditor = focus
hooks.insertText = insertAtCursor hooks.insertText = insertAtCursor
hooks.headings = editorHeadings hooks.headings = editorHeadings
@@ -319,15 +360,56 @@
onDestroy(() => { onDestroy(() => {
view?.destroy() view?.destroy()
view = undefined view = undefined
tile.vimMode = 'normal' if (tile) tile.vimMode = 'normal'
}) })
</script> </script>
<div class="editor" bind:this={host}></div> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="writing" class:framed={writing} class:fill onkeydowncapture={onkeydown}>
<div class="editor" bind:this={host}></div>
{#if writing}
<div class="modeline label">
<span class="mode" class:insert={mode !== 'normal'}>{mode}</span>
{#if writing.dirty}<span class="dirty">+</span>{/if}
<span class="hint">saves itself · <span class="kbd">:q</span> leave · <span class="kbd">:q!</span> drop what is unsaved</span>
</div>
{/if}
</div>
<style> <style>
.editor { .writing {
height: 100%; height: 100%;
display: flex;
flex-direction: column;
}
.editor {
flex: 1;
min-height: 0;
overflow: hidden; overflow: hidden;
} }
/* A short text, written in place among other things. */
.framed {
height: auto;
border: 1px solid var(--light-dim);
border-radius: var(--radius-m);
background: var(--base);
overflow: hidden;
}
.framed .editor { flex: none; min-height: 10em; max-height: 50vh; overflow: auto; }
.framed.fill { height: 100%; }
.framed.fill .editor { flex: 1; max-height: none; }
.modeline {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 12px;
border-top: 1px solid var(--rim);
background: var(--hull);
font-size: 10px;
}
.mode { padding: 0 8px; border-radius: var(--radius-s); background: var(--light-soft); color: var(--ink); }
.mode.insert { background: var(--light); }
.dirty { color: var(--light); }
.hint { margin-left: auto; color: var(--muted); text-transform: none; letter-spacing: 0.04em; }
</style> </style>
+9 -17
View File
@@ -42,7 +42,7 @@
<div class="body"> <div class="body">
<ul class="notes" bind:this={list}> <ul class="notes" bind:this={list}>
{#if workspace.scratchEditing && workspace.scratchNew} {#if workspace.scratchEditing && workspace.scratchNew}
<li class="at new"><span class="line">{headline(workspace.scratchDraft) || 'new note'}</span><span class="when">now</span></li> <li class="at new"><span class="line">{headline(workspace.writing?.text ?? '') || 'new note'}</span><span class="when">now</span></li>
{/if} {/if}
{#each workspace.scratchNotes as entry, index (entry.id)} {#each workspace.scratchNotes as entry, index (entry.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
@@ -64,20 +64,14 @@
</ul> </ul>
<div class="page"> <div class="page">
{#if workspace.scratchEditing} {#if workspace.scratchEditing && workspace.writing}
{#key workspace.scratchSession} {#await import('./Editor.svelte')}
{#await import('./TextEditor.svelte')} <div class="muted label">loading editor</div>
<div class="muted label">loading editor</div> {:then { default: Editor }}
{:then { default: TextEditor }} {#key workspace.writing.session}
<TextEditor <Editor writing={workspace.writing} fill hint="anything — it saves itself; :keep makes it a document" />
doc={workspace.scratchDraft} {/key}
placeholder="anything — it saves itself; :keep makes it a document" {/await}
onchange={(text) => workspace.scratchChanged(text)}
onsave={() => workspace.guard(() => workspace.saveScratchNote())}
onleave={() => workspace.leaveScratchNote()}
/>
{/await}
{/key}
{:else if html} {:else if html}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<article class="md" ondblclick={() => workspace.editScratchNote()}> <article class="md" ondblclick={() => workspace.editScratchNote()}>
@@ -156,8 +150,6 @@
.none { font-size: 12px; } .none { font-size: 12px; }
.page { min-height: 0; overflow-y: auto; border-left: 1px solid var(--rim); padding-left: 16px; } .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; } .page .md { font-size: 14px; }
.hint { font-size: 12px; margin: 6px 0; } .hint { font-size: 12px; margin: 6px 0; }
-131
View File
@@ -1,131 +0,0 @@
<script lang="ts">
/*
* A short text written in place — a card's description, a scratch note —
* in the same editor as a document: vim, markdown, the colours. The owner
* keeps the text and says what writing and leaving do; Esc in normal mode
* leaves, since there is no tree underneath to go back to.
*/
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { indentOnInput } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import { drawSelection, EditorView, highlightActiveLine, keymap, placeholder } from '@codemirror/view'
import { getCM, Vim, vim } from '@replit/codemirror-vim'
import { onDestroy, onMount } from 'svelte'
import { bindVim } from './Editor.svelte'
import { editorTheme, highlighting, languages } from './editorTheme'
import { workspace } from './store.svelte'
const {
doc,
placeholder: hint,
dirty = false,
onchange,
onsave,
onleave,
}: {
doc: string
placeholder: string
dirty?: boolean
onchange: (text: string) => void
onsave: () => void
onleave: () => void
} = $props()
let host = $state<HTMLDivElement>()
let view: EditorView | undefined
let mode = $state('normal')
/* Esc, before vim sees it: in normal mode it is the way out of the editor. */
function onkeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || (workspace.vimEnabled && mode !== 'normal')) return
event.preventDefault()
event.stopPropagation()
onleave()
}
onMount(() => {
bindVim()
view = new EditorView({
parent: host!,
state: EditorState.create({
doc,
extensions: [
...(workspace.vimEnabled ? [vim({ status: false })] : []),
history(),
drawSelection(),
indentOnInput(),
highlightActiveLine(),
markdown({ base: markdownLanguage, codeLanguages: languages }),
highlighting,
EditorView.lineWrapping,
placeholder(hint),
editorTheme,
EditorView.theme({ '.cm-content': { padding: '10px 14px', maxWidth: 'none' } }),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => (onsave(), true),
},
...defaultKeymap,
...historyKeymap,
indentWithTab,
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) onchange(update.state.doc.toString())
}),
],
}),
})
view.focus()
const cm = workspace.vimEnabled ? getCM(view) : null
if (cm) {
const emitter = cm as unknown as { on?: (event: string, handler: (e: { mode: string }) => void) => void }
emitter.on?.('vim-mode-change', (event) => (mode = event.mode))
// An empty text is there to be written, so it starts in insert mode.
if (!doc.trim()) Vim.handleKey(cm, 'i', 'workspace')
} else mode = 'insert'
})
onDestroy(() => {
view?.destroy()
view = undefined
})
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="writing" onkeydowncapture={onkeydown}>
<div class="editor" bind:this={host}></div>
<div class="modeline label">
<span class="mode" class:insert={mode !== 'normal'}>{mode}</span>
{#if dirty}<span class="dirty">+</span>{/if}
<span class="hint"><span class="kbd">:w</span> write · <span class="kbd">:q</span> leave · <span class="kbd">:q!</span> discard</span>
</div>
</div>
<style>
.writing {
display: flex;
flex-direction: column;
border: 1px solid var(--light-dim);
border-radius: var(--radius-m);
background: var(--base);
overflow: hidden;
}
.editor { min-height: 10em; max-height: 50vh; overflow: auto; }
.modeline {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 12px;
border-top: 1px solid var(--rim);
background: var(--hull);
font-size: 10px;
}
.mode { padding: 0 8px; border-radius: var(--radius-s); background: var(--light-soft); color: var(--ink); }
.mode.insert { background: var(--light); }
.dirty { color: var(--light); }
.hint { margin-left: auto; color: var(--muted); text-transform: none; letter-spacing: 0.04em; }
</style>
+2 -3
View File
@@ -106,8 +106,7 @@ function tileKey(direction: Direction, shift: boolean) {
export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
always: { always: {
save: () => { save: () => {
if (workspace.scratchEditing) guard(() => workspace.saveScratchNote()) if (workspace.writing) guard(() => workspace.saveWriting())
else if (workspace.describing) guard(() => workspace.saveDescription())
else if (workspace.open) guard(() => workspace.save()) else if (workspace.open) guard(() => workspace.save())
}, },
close: () => void runCommand('close'), close: () => void runCommand('close'),
@@ -308,7 +307,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
workspace: { workspace: {
/** Esc leaves one thing: the line, or fullscreen. */ /** Esc leaves one thing: the line, or fullscreen. */
escape: () => { escape: () => {
if (workspace.describing || workspace.scratchEditing) return if (workspace.writing) return
if (hooks.closeCommandLine()) return if (hooks.closeCommandLine()) return
if (workspace.fullscreen && !workspace.editing) return void (workspace.fullscreen = false) if (workspace.fullscreen && !workspace.editing) return void (workspace.fullscreen = false)
return false return false
+26 -34
View File
@@ -62,11 +62,11 @@ export interface CommandSpec {
args?: string args?: string
group: Group group: Group
help: string help: string
run: (arg: string) => void | Promise<void> run: (arg: string) => unknown
/** What the argument can be, for Tab on the command line. */ /** What the argument can be, for Tab on the command line. */
complete?: (arg: string) => string[] complete?: (arg: string) => string[]
/** The forcing variant, `:name!` — vim hands the bang over as an argument. */ /** The forcing variant, `:name!` — vim hands the bang over as an argument. */
bang?: { help: string; run: (arg: string) => void | Promise<void> } bang?: { help: string; run: (arg: string) => unknown }
} }
/** Close the focused tile; the index of the one that has the focus after. */ /** 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` 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[] = [ export const commands: CommandSpec[] = [
{ {
name: 'write', name: 'write',
aliases: ['w'], aliases: ['w'],
group: 'editing', group: 'editing',
help: "save the open document — or the card's description being written", help: "save what is being written — the open document, a card's description, a note — rather than in a moment",
run: () => run: () => workspace.guard(() => (workspace.writing ? workspace.saveWriting() : workspace.save())),
workspace.guard(() =>
workspace.scratchEditing
? workspace.saveScratchNote()
: workspace.describing
? workspace.saveDescription()
: workspace.save(),
),
bang: { bang: {
help: 'save even over a newer version saved somewhere else since this one was opened', help: 'save even over a newer version saved somewhere else since this one was opened',
run: () => workspace.guard(() => workspace.save({ force: true })), run: () => workspace.guard(() => workspace.save({ force: true })),
@@ -349,24 +355,12 @@ export const commands: CommandSpec[] = [
name: 'wq', name: 'wq',
aliases: ['x'], aliases: ['x'],
group: 'editing', group: 'editing',
help: 'save and leave insert/editor mode', help: 'leave the editor with everything written, as :q does',
run: async () => { run: () => leaveEditor(),
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()
},
bang: { bang: {
help: 'save over a newer version, as :w! does, and leave the editor', help: 'save over a newer version, as :w! does, and leave the editor',
run: async () => { run: async () => {
await workspace.guard(() => workspace.save({ force: true })) if (await workspace.guard(() => workspace.save({ force: true }))) await leaveEditor()
workspace.editing = false
hooks.focusList()
}, },
}, },
}, },
@@ -374,25 +368,23 @@ export const commands: CommandSpec[] = [
name: 'quit', name: 'quit',
aliases: ['q'], aliases: ['q'],
group: 'editing', 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 () => { run: async () => {
if (workspace.scratchEditing) return workspace.leaveScratchNote() if (workspace.writing || workspace.editing) return leaveEditor()
if (workspace.describing) return void workspace.stopDescribing()
// A document `o` made and nothing was written in is not kept. // A document `o` made and nothing was written in is not kept.
if (await workspace.abandonIfBlank()) return hooks.focusList() if (await workspace.abandonIfBlank()) return hooks.focusList()
if (workspace.dirty) { if (workspace.dirty) {
workspace.say('unsaved changes — :w to write, :q! to discard', 'error') workspace.say('unsaved changes — :w to write, :q! to discard', 'error')
return return
} }
if (workspace.editing) workspace.editing = false if (workspace.tiles.length > 1) return focusTile(closeFocused())
else if (workspace.tiles.length > 1) return focusTile(closeFocused()) workspace.closeTile()
else workspace.closeTile()
hooks.focusList() hooks.focusList()
}, },
bang: { bang: {
help: 'discard changes and close the editor', help: 'leave the editor, letting go of what is not saved yet',
run: async () => { run: async () => {
if (workspace.describing) return void workspace.stopDescribing(true) if (workspace.writing) return void workspace.leaveWriting(true)
const open = workspace.open const open = workspace.open
workspace.editing = false workspace.editing = false
workspace.dirty = false workspace.dirty = false
+5 -2
View File
@@ -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: ['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: ['K'], action: 'nudgeUp' },
{ context: 'card', keys: ['dd'], action: 'drop', hint: 'drop', help: 'drop the step' }, { 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: ['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: ['R'], action: 'rename', label: 'R · D', help: 'rename the card, its due date' },
{ context: 'card', keys: ['D'], action: 'due', hint: 'due' }, { context: 'card', keys: ['D'], action: 'due', hint: 'due' },
@@ -283,7 +283,10 @@ export const bindings = [
{ context: 'share', keys: ['y'], action: 'link', hint: 'new link' }, { context: 'share', keys: ['y'], action: 'link', hint: 'new link' },
// --- the editor's own keymap, described only --------------------------------------- // --- 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: 'drop · paste', help: 'an image, video or audio file attaches it' },
{ context: 'editor', label: '$x^2$ · $$…$$', help: 'set as math' }, { 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' }, { context: 'editor', label: '```rust', help: 'a fenced block in a language is coloured as nvim colours it, written and read' },
+91 -101
View File
@@ -24,6 +24,7 @@ import {
type VaultStatus, type VaultStatus,
} from './api' } from './api'
import { formatBytes, formatDay, formatStamp, formatWeekday, isTouchDevice, parseDay, sealedNote, type Heading } from './util' 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' export type Pane = 'list' | 'document'
/** /**
@@ -259,7 +260,7 @@ class Workspace {
/** `:close`: the focused tile goes, or with only one, what it shows. */ /** `:close`: the focused tile goes, or with only one, what it shows. */
closeTile() { closeTile() {
if (this.tile.kind === 'card' && this.describing && !this.stopDescribing()) return this.#leaveCard()
this.tile.clear() this.tile.clear()
this.prune() this.prune()
if (!this.tile.empty) this.focusTile(this.focused) 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 * 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 * is on screen, as the desktop's scratchpad does, with the notes taken in
* passing. `scratchEditing` is the note under the cursor being written; * passing.
* `scratchNew` a note not yet saved, which is only kept once it says
* something.
*/ */
scratchOpen = $state(false) scratchOpen = $state(false)
scratchNotes = $state<ScratchNote[]>([]) scratchNotes = $state<ScratchNote[]>([])
scratchCursor = $state(0) 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. */ /** The step of the open card's checklist under the cursor. */
taskCursor = $state(0) taskCursor = $state(0)
/** The open card's description is being written, and what it says so far. */ /*
describing = $state(false) * The short text being written in place, if one is: the description of the
description = $state('') * card turned over, or the note under the drawer's cursor. One at a time,
descriptionDirty = $state(false) * as there is one place on screen each is written in.
*/
writing = $state<Writing | null>(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. */ /** `z` on the board: show done cards older than a week too. */
showOldDone = $state(false) showOldDone = $state(false)
@@ -653,7 +660,6 @@ class Workspace {
* server a couple of seconds after typing stops. * server a couple of seconds after typing stops.
*/ */
static readonly AUTOSAVE_MS = 2500
static readonly RECENT_DONE_DAYS = 7 static readonly RECENT_DONE_DAYS = 7
#stashKey(id: number) { #stashKey(id: number) {
@@ -665,7 +671,7 @@ class Workspace {
tile.dirty = true tile.dirty = true
this.stash(tile) this.stash(tile)
tile.stopAutosave() 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) { stash(tile = this.tile) {
@@ -722,6 +728,7 @@ class Workspace {
tile.stopAutosave() tile.stopAutosave()
void this.#writeBack(tile) void this.#writeBack(tile)
} }
if (this.writing?.dirty) void this.guard(() => this.saveWriting())
}) })
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
this.say('offline — what is typed is kept here, and saved once the server answers', 'error') 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) { for (const tile of this.tiles) {
if (tile.open && tile.dirty) await this.guard(() => this.save({ quiet: true, tile })) if (tile.open && tile.dirty) await this.guard(() => this.save({ quiet: true, tile }))
} }
if (this.writing?.dirty) await this.guard(() => this.saveWriting())
return return
} }
await this.loadSpaces(Workspace.addressedPath()) await this.loadSpaces(Workspace.addressedPath())
@@ -1884,15 +1892,54 @@ class Workspace {
} }
/* /*
* Leaving a card's back while its description is being written: what is * Writing in place. A card's description and a note are written the way a
* written is kept, as leaving a document's tile keeps its draft. * 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<void>) {
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<boolean> {
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() { #leaveCard() {
if (this.tile.kind !== 'card' || !this.describing) return if (this.tile.kind === 'card' && this.describing) void this.leaveWriting()
void this.guard(async () => {
if (this.descriptionDirty) await this.saveDescription()
this.stopDescribing(true)
})
} }
get scratchNote(): ScratchNote | undefined { get scratchNote(): ScratchNote | undefined {
@@ -1904,83 +1951,46 @@ class Workspace {
if (this.scratchOpen) return this.closeScratch() if (this.scratchOpen) return this.closeScratch()
this.scratchNotes = await api.scratch() this.scratchNotes = await api.scratch()
this.scratchCursor = Math.min(this.scratchCursor, Math.max(this.scratchNotes.length - 1, 0)) this.scratchCursor = Math.min(this.scratchCursor, Math.max(this.scratchNotes.length - 1, 0))
this.scratchEditing = false
this.scratchOpen = true this.scratchOpen = true
} }
async closeScratch() { async closeScratch() {
if (this.scratchEditing) await this.leaveScratchNote() if (this.scratchEditing && !(await this.leaveWriting())) return
this.scratchOpen = false this.scratchOpen = false
} }
/** `o`: a new note at the top, written straight away. */ /** `o`: a new note at the top, written straight away. */
newScratchNote() { newScratchNote() {
this.scratchNew = true this.#writeNote(null, '')
this.scratchDraft = ''
this.scratchSession++
this.scratchEditing = true
} }
/** `e`, `i`, Enter: write the note under the cursor. */ /** `e`, `i`, Enter: write the note under the cursor. */
editScratchNote() { editScratchNote() {
const note = this.scratchNote const note = this.scratchNote
if (!note) return this.newScratchNote() if (!note) return this.newScratchNote()
this.scratchNew = false this.#writeNote(note.id, note.body)
this.scratchDraft = note.body
this.scratchSession++
this.scratchEditing = true
} }
#scratchTimer: ReturnType<typeof setTimeout> | undefined
#scratchSaving: Promise<unknown> = Promise.resolve()
/* /*
* A scratch note saves itself: nothing is asked of a note taken in passing, * A new note is only made once it says something. The note touched last
* not even `:w`. Each change waits a moment for the next before it is * goes to the top of the list, and the cursor with it.
* 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.
*/ */
scratchChanged(text: string) { #writeNote(id: number | null, body: string) {
this.scratchDraft = text this.#write('note', id, body, async (text, writing) => {
clearTimeout(this.#scratchTimer) if (writing.id === null) {
this.#scratchTimer = setTimeout(() => void this.guard(() => this.saveScratchNote()), 800)
}
saveScratchNote(): Promise<void> {
clearTimeout(this.#scratchTimer)
const run = this.#scratchSaving.then(async () => {
if (!this.scratchEditing) return
const text = this.scratchDraft
if (this.scratchNew) {
if (!text.trim()) return if (!text.trim()) return
const note = await api.createScratch(text) const note = await api.createScratch(text)
writing.id = note.id
this.scratchNotes = [note, ...this.scratchNotes] this.scratchNotes = [note, ...this.scratchNotes]
this.scratchCursor = 0 } else {
this.scratchNew = false const saved = await api.updateScratch(writing.id, text)
return 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.scratchCursor = 0
}) })
this.#scratchSaving = run.catch(() => {})
return run
} }
/** Esc, `:q`: back to the list, saved — and a note left empty is not kept. */ async dropScratchNote(say = true, note = this.scratchNote) {
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
if (!note) return if (!note) return
await api.deleteScratch(note.id) await api.deleteScratch(note.id)
this.scratchNotes = this.scratchNotes.filter((n) => n.id !== 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. * line is the title — a `# heading` or not — and the rest the body.
*/ */
async keepScratchNote(title = '') { async keepScratchNote(title = '') {
if (this.scratchEditing) await this.saveScratchNote() if (this.scratchEditing && !(await this.leaveWriting())) return
const note = this.scratchNote const note = this.scratchNote
if (!note) return this.say('no scratch note to keep', 'error') 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 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 }) const node = await api.createNode({ parent_id: parent, name, body })
// Made first, dropped after: a failure in between leaves two copies, never none. // Made first, dropped after: a failure in between leaves two copies, never none.
await this.dropScratchNote(false) await this.dropScratchNote(false, note)
this.scratchEditing = false
this.scratchOpen = false this.scratchOpen = false
await this.refresh(node.id) await this.refresh(node.id)
await this.openNode(node.id) await this.openNode(node.id)
@@ -2033,33 +2042,12 @@ class Workspace {
this.overlay = 'none' 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() { describe() {
const card = this.card const card = this.card
if (!card) return if (!card) return
this.description = card.body // Named, as the board's cursor may be on another card by the time it saves.
this.descriptionDirty = false this.#write('description', card.id, card.body, (body) => this.patchCard({ body }, card).then(() => {}))
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
} }
/* /*
@@ -2356,10 +2344,11 @@ class Workspace {
localStorage.setItem('diary:clipboard', enabled ? 'on' : 'off') localStorage.setItem('diary:clipboard', enabled ? 'on' : 'off')
} }
/** Surface a failed API call in the status line instead of the console. */ /** Surface a failed API call in the status line instead of the console. True when it went through. */
async guard(action: () => Promise<unknown>) { async guard(action: () => Promise<unknown>): Promise<boolean> {
try { try {
await action() await action()
return true
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.status === 401) { if (error instanceof ApiError && error.status === 401) {
this.user = null this.user = null
@@ -2372,6 +2361,7 @@ class Workspace {
} else { } else {
this.say(error instanceof Error ? error.message : String(error), 'error') this.say(error instanceof Error ? error.message : String(error), 'error')
} }
return false
} }
} }
} }
+136
View File
@@ -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<number | null>(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<void>
#fail: (error: unknown, retry: number) => void
#timer: ReturnType<typeof setTimeout> | undefined
#saving: Promise<unknown> = 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<void>,
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<void> {
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 */
}
}