mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
3c4c383f72
commit
ac3ad166e5
18 files changed
+782
-24
No files matched your search
+89
-2
@@ -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 />
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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[]]),
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user