scratch: a drawer of notes taken in passing, kept as documents later

Q lowers the scratchpad from under the bar over whatever is on screen,
as the desktop lowers its own. Its notes have no name or place; each is
written in the vim editor and saves itself. K (:keep [title]) makes the
note under the cursor a document in the folder being browsed, titled by
its first line, and takes it off the pad. :scratch <text> jots one from
the command line. The notes ride along in backups and whole-workspace
exports.

The card description's editor is now TextEditor, shared by both.
This commit is contained in:
nvrl committed 2026-10-22 00:07:23 +02:00
1 parent 3c4c383f72
commit ac3ad166e5
18 files changed
+782 -24

No files matched your search

+89 -2
View File
@@ -18,6 +18,7 @@
import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte'
import Backlinks, { openBacklink } from './lib/Backlinks.svelte'
import CardSheet from './lib/CardSheet.svelte'
import Scratch from './lib/Scratch.svelte'
import History, { moveHistory, restoreShown } from './lib/History.svelte'
import Actions from './lib/Actions.svelte'
import Prompt from './lib/Prompt.svelte'
@@ -793,6 +794,74 @@
}
}
/*
* The scratchpad's keys, while it is down and its note is not being
* written: the list keys over its notes. It sits over whatever was on
* screen, so nothing else hears a key until it is raised again.
*/
function scratchKey(event: KeyboardEvent): boolean {
const key = event.key
const previous = pending
pending = ''
const last = Math.max(workspace.scratchNotes.length - 1, 0)
if (previous === 'g' && key === 'g') return (workspace.scratchCursor = 0), true
if (previous === 'd' && key === 'd') {
const note = workspace.scratchNote
if (note) {
void (async () => {
if (await workspace.ask('drop this scratch note?')) await workspace.guard(() => workspace.dropScratchNote())
})()
}
return true
}
switch (key) {
case 'g':
case 'd':
pending = key
return true
case 'j':
case 'ArrowDown':
workspace.scratchCursor = Math.min(workspace.scratchCursor + 1, last)
return true
case 'k':
case 'ArrowUp':
workspace.scratchCursor = Math.max(workspace.scratchCursor - 1, 0)
return true
case 'G':
workspace.scratchCursor = last
return true
case 'o':
case 'O':
case 'c':
workspace.newScratchNote()
return true
case 'e':
case 'i':
case 'a':
case 'Enter':
workspace.editScratchNote()
return true
case 'K':
void workspace.guard(() => workspace.keepScratchNote())
return true
case ':':
cmdGlobal = false
cmdline = ':'
return true
case '?':
void runCommand('help')
return true
case 'Q':
case 'q':
case 'Escape':
void workspace.closeScratch()
return true
default:
// Nothing under the drawer hears a key while it is down.
return true
}
}
function isTypingTarget(target: EventTarget | null) {
const node = target as HTMLElement | null
return (
@@ -807,7 +876,8 @@
function onkeydown(event: KeyboardEvent) {
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
if (workspace.describing) void workspace.guard(() => workspace.saveDescription())
if (workspace.scratchEditing) void workspace.guard(() => workspace.saveScratchNote())
else if (workspace.describing) void workspace.guard(() => workspace.saveDescription())
else if (workspace.open) void workspace.guard(() => workspace.save())
return
}
@@ -828,6 +898,19 @@
// The command line over an overlay — `D` in the agenda — keeps its keys.
const listening = cmdline === null && !isTypingTarget(event.target)
// `Q`, a quick note: the scratchpad lowered from anywhere a key is not
// being typed — a letter, since a backtick is a dead key on many layouts.
if (event.key === 'Q' && listening && !workspace.scratchOpen && workspace.overlay === 'none' && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault()
void workspace.guard(() => workspace.toggleScratch())
return
}
if (workspace.scratchOpen && !workspace.scratchEditing && workspace.overlay === 'none' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (scratchKey(event)) {
event.preventDefault()
return
}
}
if (workspace.overlay === 'card' && !workspace.describing && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (cardKey(event)) {
event.preventDefault()
@@ -894,7 +977,7 @@
if (event.key === 'Escape') {
// The description's editor has had its Esc; the sheet stays up under it.
if (workspace.describing) return
if (workspace.describing || workspace.scratchEditing) return
if (workspace.overlay !== 'none') {
workspace.overlay = 'none'
return
@@ -1370,6 +1453,10 @@
<CommandLine initial={cmdline} global={cmdGlobal} close={() => ((cmdline = null), (cmdGlobal = false))} />
{/if}
{#if workspace.scratchOpen}
<Scratch />
{/if}
<Prompt />
<SecretPrompt />
+13 -3
View File
@@ -65,10 +65,20 @@
<section>
<div class="label head"><span class="marker"></span>description</div>
{#if workspace.describing}
{#await import('./CardEditor.svelte')}
{#await import('./TextEditor.svelte')}
<div class="empty label">loading editor</div>
{:then module}
<module.default />
{: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()}
/>
{/await}
{:else if html}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+11
View File
@@ -117,6 +117,16 @@
['q · Esc', 'leave the special workspace'],
]
const scratchKeys: [string, string][] = [
['Q', 'lower the scratchpad over whatever is on screen, or raise it again — :scratch <text> takes a note without it'],
['j / k', 'down / up the notes, the one touched last on top'],
['o', 'a new note, written straight away — it saves itself'],
['Enter · e · i', 'write the note — Esc in normal mode, or :q, comes back to the notes'],
['K', 'keep the note: a document in the folder being browsed, titled by its first line — :keep <title> names it'],
['dd', 'drop the note (asks y/N)'],
['q · Esc · Q', 'raise the scratchpad'],
]
const cardKeys: [string, string][] = [
['j / k', 'down / up the checklist'],
['gg / G', 'first / last step'],
@@ -186,6 +196,7 @@
['keys', fromPairs(keys)],
['board keys', fromPairs(boardKeys)],
['card keys', fromPairs(cardKeys)],
['scratchpad keys', fromPairs(scratchKeys)],
['agenda keys', fromPairs(agendaKeys)],
['in the editor', fromPairs(editorKeys)],
...groups.map((group: Group) => [titles[group], fromSpecs(commands.filter((spec) => spec.group === group))] as [string, Row[]]),
+178
View File
@@ -0,0 +1,178 @@
<script lang="ts">
/*
* `Q`: the scratchpad, a drawer lowered from under the bar over whatever
* is on screen, the way the desktop lowers its own. The notes on the left,
* newest first; the one under the cursor on the right, read or written.
* The keys live in App.svelte with the others; this draws and lets a
* pointer do the same.
*/
import { renderMarkdown } from './markdown'
import { workspace } from './store.svelte'
import { relative } from './util'
let list = $state<HTMLUListElement>()
const note = $derived(workspace.scratchNote)
const html = $derived(note && !workspace.scratchEditing ? renderMarkdown(note.body) : '')
$effect(() => {
const at = workspace.scratchCursor
list?.querySelector<HTMLElement>(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' })
})
/** What a note is called in the list: its first line that says something. */
function headline(body: string): string {
return body.split('\n').find((line) => line.trim())?.replace(/^#+\s*/, '').trim() ?? ''
}
function pick(index: number) {
if (workspace.scratchEditing) return
workspace.scratchCursor = index
}
</script>
<div class="drawer">
<header class="label rule">
<span class="marker"></span><span class="title">scratch</span>
{workspace.scratchNotes.length} {workspace.scratchNotes.length === 1 ? 'note' : 'notes'}
<span class="kbd tile-key" title="the key that lowers and raises the scratchpad">Q</span>
</header>
<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>
{/if}
{#each workspace.scratchNotes as entry, index (entry.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class:at={index === workspace.scratchCursor && !workspace.scratchNew}
class:dim={workspace.scratchEditing}
data-index={index}
onclick={() => pick(index)}
ondblclick={() => workspace.editScratchNote()}
>
<span class="line">{headline(entry.body) || '—'}</span>
<span class="when">{relative(entry.updated_at)}</span>
</li>
{:else}
{#if !workspace.scratchNew}
<li class="none muted">nothing here — <span class="kbd">o</span> takes a note</li>
{/if}
{/each}
</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}
{:else if html}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<article class="md" ondblclick={() => workspace.editScratchNote()}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
</article>
{:else}
<p class="muted hint">a note is a few lines, kept here until it is clear where they belong.</p>
{/if}
</div>
</div>
<footer class="label">
{#if workspace.scratchEditing}
<span>writing — it saves itself</span>
<span><span class="kbd">esc</span> back to the notes</span>
<span><span class="kbd">:keep</span> make it a document</span>
{:else}
<span><span class="kbd">j</span><span class="kbd">k</span> note</span>
<span><span class="kbd">o</span> new</span>
<span><span class="kbd">↵</span> write</span>
<span><span class="kbd">K</span> keep as document</span>
<span><span class="kbd">dd</span> drop</span>
<span><span class="kbd">Q</span><span class="kbd">q</span> raise</span>
{/if}
</footer>
</div>
<style>
/*
* Hung from under the bar across the frame's opening, as the desktop's
* scratchpad slides down from under its bar: hull, a rim, the lower corners
* rounded, and the light along its lower edge to say it has the keys.
*/
.drawer {
position: absolute;
top: calc(var(--bar-h) + var(--frame));
left: calc(var(--frame) + 24px);
right: calc(var(--rail-w) + 24px);
z-index: 25;
height: min(62vh, 640px);
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 20px 12px;
background: var(--hull);
border: 1px solid var(--surface-alt);
border-top: 0;
border-bottom-color: var(--light-dim);
border-radius: 0 0 var(--frame-radius) var(--frame-radius);
box-shadow: 0 18px 40px color-mix(in oklab, var(--ink) 70%, transparent);
animation: lower var(--t-slow) var(--ease-snap) both;
}
@keyframes lower {
from { transform: translateY(-104%); }
}
header { display: flex; align-items: center; gap: 10px; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
header .tile-key { order: 1; margin-left: 12px; background: var(--light); border-color: var(--light); color: var(--ink); }
.body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(22ch, 32ch) 1fr; gap: 16px; }
.notes { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
.notes li {
display: flex;
align-items: baseline;
gap: 10px;
padding: 6px 10px;
border-radius: var(--radius-s);
cursor: default;
}
.notes li:hover { background: var(--surface); }
.notes li.at { background: var(--light); color: var(--ink); }
.notes li.at.dim { background: var(--surface-alt); color: var(--light); }
.notes li.new { font-style: italic; }
.line { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-sans); font-size: 14px; }
.when { flex: none; font-size: 10px; color: var(--muted); }
.notes li.at:not(.dim) .when { color: var(--ink); }
.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; }
footer { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 10px; }
footer > span { display: flex; gap: 3px; align-items: center; }
@media (max-width: 767px) {
.drawer { left: var(--frame); right: var(--frame); height: 70vh; }
.body { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
.notes { max-height: 30%; }
.page { border-left: 0; padding-left: 0; }
footer { display: none; }
}
</style>
@@ -1,10 +1,9 @@
<script lang="ts">
/*
* A card's description, written in place on its sheet: the same editor as
* a document's — vim, markdown, the colours — over `workspace.description`
* instead of a tile. `:w` writes it to the card, `:q` leaves, and Esc in
* normal mode leaves too when nothing is unsaved, since there is no tree
* underneath to go back to.
* 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'
@@ -17,6 +16,22 @@
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')
@@ -26,7 +41,7 @@
if (event.key !== 'Escape' || (workspace.vimEnabled && mode !== 'normal')) return
event.preventDefault()
event.stopPropagation()
workspace.stopDescribing()
onleave()
}
onMount(() => {
@@ -34,7 +49,7 @@
view = new EditorView({
parent: host!,
state: EditorState.create({
doc: workspace.description,
doc,
extensions: [
...(workspace.vimEnabled ? [vim({ status: false })] : []),
history(),
@@ -44,23 +59,21 @@
markdown({ base: markdownLanguage, codeLanguages: languages }),
highlighting,
EditorView.lineWrapping,
placeholder('what this card is about…'),
placeholder(hint),
editorTheme,
EditorView.theme({ '.cm-content': { padding: '10px 14px', maxWidth: 'none' } }),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => (void workspace.guard(() => workspace.saveDescription()), true),
run: () => (onsave(), true),
},
...defaultKeymap,
...historyKeymap,
indentWithTab,
]),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
workspace.description = update.state.doc.toString()
workspace.descriptionDirty = true
if (update.docChanged) onchange(update.state.doc.toString())
}),
],
}),
@@ -71,8 +84,8 @@
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 description is there to be written, so it starts in insert mode.
if (!workspace.description.trim()) Vim.handleKey(cm, 'i', 'workspace')
// 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'
})
@@ -87,7 +100,7 @@
<div class="editor" bind:this={host}></div>
<div class="modeline label">
<span class="mode" class:insert={mode !== 'normal'}>{mode}</span>
{#if workspace.descriptionDirty}<span class="dirty">+</span>{/if}
{#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>
+15
View File
@@ -226,6 +226,14 @@ export interface Template {
slug: string
}
/** A note on the scratchpad: a body and nothing else, until it is kept as a document. */
export interface ScratchNote {
id: number
body: string
created_at: number
updated_at: number
}
export interface BoardList {
id: number
name: string
@@ -580,6 +588,13 @@ export const api = {
},
) => request<Moved>(`/cards/${id}`, { method: 'PUT', body: body(patch) }),
/** The scratchpad's notes, the one touched last first. */
scratch: () => request<ScratchNote[]>('/scratch'),
createScratch: (text: string) => request<ScratchNote>('/scratch', { method: 'POST', body: body({ body: text }) }),
updateScratch: (id: number, text: string) =>
request<ScratchNote>(`/scratch/${id}`, { method: 'PUT', body: body({ body: text }) }),
deleteScratch: (id: number) => request<{ ok: true }>(`/scratch/${id}`, { method: 'DELETE' }),
deleteCard: (id: number) => request<{ ok: true }>(`/cards/${id}`, { method: 'DELETE' }),
moveCard: (id: number, list_id: number, position?: number) =>
+31 -1
View File
@@ -299,7 +299,14 @@ export const commands: CommandSpec[] = [
aliases: ['w'],
group: 'editing',
help: "save the open document — or the card's description being written",
run: () => workspace.guard(() => (workspace.describing ? workspace.saveDescription() : workspace.save())),
run: () =>
workspace.guard(() =>
workspace.scratchEditing
? workspace.saveScratchNote()
: workspace.describing
? workspace.saveDescription()
: 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 })),
@@ -311,6 +318,7 @@ export const commands: CommandSpec[] = [
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()
@@ -335,6 +343,7 @@ export const commands: CommandSpec[] = [
group: 'editing',
help: 'close the editor, or the document when already reading',
run: async () => {
if (workspace.scratchEditing) return workspace.leaveScratchNote()
if (workspace.describing) return void workspace.stopDescribing()
// A document `o` made and nothing was written in is not kept.
if (await workspace.abandonIfBlank()) {
@@ -515,6 +524,27 @@ export const commands: CommandSpec[] = [
return workspace.guard(() => workspace.toggleDone())
},
},
{
name: 'scratch',
aliases: ['sc'],
args: '[text]',
group: 'documents',
help: 'the scratchpad, lowered over whatever is on screen — Q does the same; with text, a note on it without leaving',
run: async (arg) => {
if (!arg.trim()) return workspace.guard(() => workspace.toggleScratch())
await workspace.guard(() => workspace.scratch(arg.trim()))
},
},
{
name: 'keep',
args: '[title]',
group: 'documents',
help: 'make the scratch note a document in the folder being browsed — titled by its first line unless given one — and take it off the scratchpad',
run: async (arg) => {
if (!workspace.scratchOpen) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
await workspace.guard(() => workspace.keepScratchNote(arg))
},
},
{
name: 'task',
aliases: ['step'],
+147
View File
@@ -16,6 +16,7 @@ import {
type NodeSummary,
type Revision,
type RevisionSummary,
type ScratchNote,
type SpaceOverview,
type Task,
type TrashItem,
@@ -283,6 +284,22 @@ class Workspace {
})
listCursor = $state(0)
cardCursor = $state(0)
/*
* 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.
*/
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. */
@@ -1702,6 +1719,136 @@ class Workspace {
this.overlay = 'card'
}
get scratchNote(): ScratchNote | undefined {
return this.scratchNotes[this.scratchCursor]
}
/** `Q`: the drawer down, read again, or up and away. */
async toggleScratch() {
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()
this.scratchOpen = false
}
/** `o`: a new note at the top, written straight away. */
newScratchNote() {
this.scratchNew = true
this.scratchDraft = ''
this.scratchSession++
this.scratchEditing = true
}
/** `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
}
#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.
*/
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) {
if (!text.trim()) return
const note = await api.createScratch(text)
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.
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
if (!note) return
await api.deleteScratch(note.id)
this.scratchNotes = this.scratchNotes.filter((n) => n.id !== note.id)
this.scratchCursor = Math.min(this.scratchCursor, Math.max(this.scratchNotes.length - 1, 0))
if (say) this.say('scratch note dropped')
}
/** `:scratch <text>`: a note from the command line, without the drawer. */
async scratch(text: string) {
const note = await api.createScratch(text)
this.scratchNotes = [note, ...this.scratchNotes.filter((n) => n.id !== note.id)]
this.say('scratched — Q shows the scratchpad')
}
/**
* `:keep [title]`: the note under the cursor made a document in the folder
* being browsed, and taken off the scratchpad. Without a title, the first
* line is the title — a `# heading` or not — and the rest the body.
*/
async keepScratchNote(title = '') {
if (this.scratchEditing) await this.saveScratchNote()
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
if (parent === undefined || parent === null) return this.say('no space to keep it in', 'error')
let name = title.trim()
let body = note.body
if (!name) {
const [first, ...rest] = note.body.replace(/^\s+/, '').split('\n')
name = first.replace(/^#+\s*/, '').trim().slice(0, 200)
body = rest.join('\n').replace(/^\n+/, '')
}
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
this.scratchOpen = false
if (this.view === 'special') this.closeSpecial()
await this.refresh(node.id)
await this.openNode(node.id)
this.say(`kept as ${node.path.map((crumb) => crumb.slug).join('/')}`)
}
/** `q`: the sheet away, unless that would lose a description being written. */
closeCardSheet() {
if (this.describing && !this.stopDescribing()) return