mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
b8b3641e6e
commit
ccc91028e7
13 files changed
+408
-342
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
+15
-5
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
+109
-27
@@ -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,24 +276,21 @@
|
||||
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)
|
||||
}
|
||||
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 ?? [])
|
||||
@@ -272,31 +308,36 @@
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
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>
|
||||
@@ -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')}
|
||||
{#if workspace.scratchEditing && workspace.writing}
|
||||
{#await import('./Editor.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}
|
||||
{: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; }
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
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.
|
||||
} else {
|
||||
const saved = await api.updateScratch(writing.id, text)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user