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 |
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
+5 -5
View File
@@ -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! <name>`, `: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 <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
`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)
+15 -5
View File
@@ -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 <text>` takes a note from the command line without lowering the
drawer.
+6 -14
View File
@@ -80,21 +80,13 @@
<section>
<div class="label head"><span class="marker"></span>description</div>
{#if workspace.describing}
{#await import('./TextEditor.svelte')}
{#if workspace.describing && workspace.writing}
{#await import('./Editor.svelte')}
<div class="empty label">loading editor</div>
{:then { default: TextEditor }}
<TextEditor
doc={workspace.description}
placeholder="what this card is about…"
dirty={workspace.descriptionDirty}
onchange={(text) => {
workspace.description = text
workspace.descriptionDirty = true
}}
onsave={() => workspace.guard(() => workspace.saveDescription())}
onleave={() => workspace.stopDescribing()}
/>
{:then { default: Editor }}
{#key workspace.writing.session}
<Editor writing={workspace.writing} hint="what this card is about, in short — [[ links a document" />
{/key}
{/await}
{:else if html}
<!-- 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 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 { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { indentOnInput } from '@codemirror/language'
@@ -151,14 +158,38 @@
import { editorTheme, highlighting, languages } from './editorTheme'
import { embedSnippet } from './markdown'
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 view: EditorView | undefined
let mode = $state('normal')
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
* where it sits; picking one closes the brackets.
@@ -172,7 +203,7 @@
if (context.aborted) return null
const options = found
.filter((node) => node.kind === 'document' || node.kind === 'file')
.filter((node) => node.id !== tile.open?.id)
.filter((node) => node.id !== tile?.open?.id)
.map((node) => {
// What a `[[link]]` can hold: a name with no bracket or bar in it, or else the slug.
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(() => {
bindVim()
@@ -237,66 +276,68 @@
autocompletion({ override: [titleCompletions], icons: false }),
highlighting,
EditorView.lineWrapping,
placeholder('write…'),
placeholder(hint),
editorTheme,
...(writing ? [EditorView.theme({ '.cm-content': { padding: '10px 14px', maxWidth: 'none' } })] : []),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => (void workspace.guard(() => workspace.save({ tile })), true),
},
{ key: 'Mod-s', preventDefault: true, run: () => (save(), true) },
...defaultKeymap,
...historyKeymap,
indentWithTab,
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
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
},
if (update.docChanged) changed(update.state.doc.toString())
}),
// 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({
state: EditorState.create({ doc: tile.draft.body, extensions }),
state: EditorState.create({ doc: text, extensions }),
parent: host!,
})
if (workspace.tile === tile) view.focus()
if (writing || workspace.tile === tile) view.focus()
const cm = workspace.vimEnabled ? getCM(view) : null
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 {
on?: (event: string, handler: (e: { mode: string }) => void) => void
}
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
// mode, the way opening a file in vim does.
if (tile.enterInsert) {
tile.enterInsert = false
// A fresh entry starts in insert mode, and so does an empty short text,
// which is there to be written; anything else starts in normal mode,
// the way opening a file in vim does.
if (tile?.enterInsert || (writing && !text.trim())) {
if (tile) tile.enterInsert = false
Vim.handleKey(cm, 'i', 'workspace')
}
}
} else mode = 'insert'
})
/*
@@ -304,7 +345,7 @@
* the hooks, and the rest stand by until their tile is focused.
*/
$effect(() => {
if (workspace.tile !== tile) return
if (!tile || workspace.tile !== tile) return
hooks.focusEditor = focus
hooks.insertText = insertAtCursor
hooks.headings = editorHeadings
@@ -319,15 +360,56 @@
onDestroy(() => {
view?.destroy()
view = undefined
tile.vimMode = 'normal'
if (tile) tile.vimMode = 'normal'
})
</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>
.editor {
.writing {
height: 100%;
display: flex;
flex-direction: column;
}
.editor {
flex: 1;
min-height: 0;
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>
+9 -17
View File
@@ -42,7 +42,7 @@
<div class="body">
<ul class="notes" bind:this={list}>
{#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}
{#each workspace.scratchNotes as entry, index (entry.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
@@ -64,20 +64,14 @@
</ul>
<div class="page">
{#if workspace.scratchEditing}
{#key workspace.scratchSession}
{#await import('./TextEditor.svelte')}
<div class="muted label">loading editor</div>
{:then { default: TextEditor }}
<TextEditor
doc={workspace.scratchDraft}
placeholder="anything — it saves itself; :keep makes it a document"
onchange={(text) => workspace.scratchChanged(text)}
onsave={() => workspace.guard(() => workspace.saveScratchNote())}
onleave={() => workspace.leaveScratchNote()}
/>
{/await}
{/key}
{#if workspace.scratchEditing && workspace.writing}
{#await import('./Editor.svelte')}
<div class="muted label">loading editor</div>
{:then { default: Editor }}
{#key workspace.writing.session}
<Editor writing={workspace.writing} fill hint="anything — it saves itself; :keep makes it a document" />
{/key}
{/await}
{:else if html}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<article class="md" ondblclick={() => 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; }
-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> } = {
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<ActionOf<C>, 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
+26 -34
View File
@@ -62,11 +62,11 @@ export interface CommandSpec {
args?: string
group: Group
help: string
run: (arg: string) => void | Promise<void>
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<void> }
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
+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: ['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' },
+91 -101
View File
@@ -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<ScratchNote[]>([])
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<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. */
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<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() {
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<typeof setTimeout> | undefined
#scratchSaving: Promise<unknown> = 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<void> {
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<unknown>) {
/** Surface a failed API call in the status line instead of the console. True when it went through. */
async guard(action: () => Promise<unknown>): Promise<boolean> {
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
}
}
}
+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 */
}
}