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

+6 -1
View File
@@ -44,6 +44,8 @@ Everything is also in the [wiki](https://github.com/narrrl/narl-workspace/wiki).
without this app, and an import that takes it back.
- **Seal.** A vault: files sealed with AES-256-GCM under a password that is
never stored.
- **Jot.** A scratchpad that drops down over whatever is on screen, for notes
taken in passing; one worth keeping becomes a document with one key.
- **Capture.** A token per phone or script that puts a card in an inbox or a
line in today's entry, and nothing else.
- **Share.** Any document behind an unguessable, revocable read-only link.
@@ -145,6 +147,7 @@ it; in the document pane it is that document. In the browse pane:
| `C` | the diary by month (`:cal`) — see [The diary](#the-diary) |
| `[d` / `]d` | the diary entry before / after the one open, by date |
| `c` | capture a card into this space's inbox without leaving where you are |
| `Q` | the scratchpad, lowered over whatever is on screen — see [The scratchpad](docs/wiki/Writing.md#the-scratchpad) |
| `Esc`, `q` | close the document, and its tile when there are several |
| `h` | up one level in the tree |
| `/` | full-text search in this space (`:noh` clears it); `Tab` or `Ctrl-N`/`Ctrl-P` walk the results without leaving the line, `Enter` opens the one lit |
@@ -874,8 +877,10 @@ sealed or not.
| `POST` | `/api/spaces/{id}/lists` | add a list |
| `PUT`/`DELETE` | `/api/lists/{id}` | rename / delete a list and its cards |
| `POST` | `/api/lists/{id}/cards` | add a card |
| `PUT`/`DELETE` | `/api/cards/{id}` | update (title, body, due, repeat, done, link) / delete; finishing a repeating card answers its copy as `again` |
| `PUT`/`DELETE` | `/api/cards/{id}` | update (title, body, due, repeat, checklist, labels, done, link) / delete; finishing a repeating card answers its copy as `again` |
| `POST` | `/api/cards/{id}/move` | to another list and place in it; `again` as above |
| `GET`/`POST` | `/api/scratch` | the scratchpad's notes, the one touched last first / a new note `{ body }` |
| `PUT`/`DELETE` | `/api/scratch/{id}` | rewrite a note `{ body }` / drop it |
| `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings |
| `POST` | `/api/nodes/move` | `{ ids, parent_id }`: several into one folder, all or none |
| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node |
+3 -1
View File
@@ -30,8 +30,10 @@ sealed or not.
| `POST` | `/api/spaces/{id}/lists` | add a list |
| `PUT`/`DELETE` | `/api/lists/{id}` | rename / delete a list and its cards |
| `POST` | `/api/lists/{id}/cards` | add a card |
| `PUT`/`DELETE` | `/api/cards/{id}` | update (title, body, due, repeat, done, link) / delete; finishing a repeating card answers its copy as `again` |
| `PUT`/`DELETE` | `/api/cards/{id}` | update (title, body, due, repeat, checklist, labels, done, link) / delete; finishing a repeating card answers its copy as `again` |
| `POST` | `/api/cards/{id}/move` | to another list and place in it; `again` as above |
| `GET`/`POST` | `/api/scratch` | the scratchpad's notes, the one touched last first / a new note `{ body }` |
| `PUT`/`DELETE` | `/api/scratch/{id}` | rewrite a note `{ body }` / drop it |
| `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings |
| `POST` | `/api/nodes/move` | `{ ids, parent_id }`: several into one folder, all or none |
| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node |
+2
View File
@@ -142,6 +142,8 @@ do: `:w!`, `:space! <name>`, `:import!`.
| command | what it does |
| --- | --- |
| `:scratch, :sc [text]` | the scratchpad, lowered over whatever is on screen — Q does the same; with text, a note on it without leaving |
| `:keep [title]` | 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 |
| `:new, :n, :o [template] [yyyy-mm-dd] [name]` | start a new document here — from one of the space's templates, dated, named; in the diary from templates/day |
| `:cal, :calendar` | the diary by month — h/l a day, j/k a week, H/L a month, Enter opens the day or starts it |
| `:today` | open today's diary entry, starting one if there is none yet |
+36
View File
@@ -43,6 +43,37 @@ typed, and to the server a couple of seconds after typing stops.
Each save keeps the text it replaces, so nothing written is lost. See
[Not losing things](Not-Losing-Things.md#history).
## The scratchpad
Not every note knows yet where it belongs. `Q` (or `:scratch`) lowers the
scratchpad from under the bar, over whatever is on screen, the way the
desktop lowers its own; `Q`, `q` or `Esc` raises it again, and what was on
screen is as it was. Its notes have no name, no space and no folder: a note is
a body and the time it was last touched, newest on top.
| key | what it does |
| --- | --- |
| `j` / `k`, `gg` / `G` | down / up the notes |
| `o` | a new note, written straight away |
| `Enter`, `e`, `i` | write the note under the cursor |
| `K` | keep it: a document in the folder being browsed (`:keep [title]`) |
| `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.
`:scratch <text>` takes a note from the command line without lowering the
drawer.
Keeping a note makes it a document in the folder the tree is showing, and
takes it off the scratchpad. Its first line is the title — `# Plan` or plain
`Plan` alike — and the rest the body; `:keep <title>` names it instead and
keeps every line. The document opens, and the drawer goes up.
The notes are in the backup, and an export of the whole workspace carries
them in `.workspace.json`, so `:import! /` puts them back.
## Markdown
Documents are GitHub-flavoured markdown: headings, lists, task lists, tables,
@@ -52,6 +83,11 @@ at a fixed width, as imported notes and files saved over the SFTP mount often
are, is read as the running text it is. A break asked for outright, with two
spaces or a `\` at the end of the line, is always kept.
A fenced block that names its language — ` ```rust `, ` ```python `, ` ```sh ` and
the rest CodeMirror knows — is coloured the way nvim's `yorha` theme colours it:
keywords in sand, strings in moss, numbers in heather, types in dusk, comments
muted and italic. The editor colours it the same as it is written.
Math is set by KaTeX:
- `$x^2$` inline;
+12
View File
@@ -0,0 +1,12 @@
-- The scratchpad: notes written down in passing, before it is clear where they
-- belong. They live outside the tree on purpose — no space, no folder, no
-- name — so taking one costs nothing. Keeping one is making it a document,
-- which takes it out of here.
CREATE TABLE scratch_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_scratch_notes_updated ON scratch_notes (updated_at);
+3
View File
@@ -268,6 +268,8 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
// diary with photographs in it does not fit comfortably in a Vec. The file
// is made in the data directory, which is sized for the workspace, rather
// than in a /tmp that may be a small tmpfs.
let notes = crate::routes::scratch::export(&state.db).await?;
let file = tokio::task::spawn_blocking(move || -> AppResult<std::fs::File> {
let mut zip = ZipWriter::new(tempfile::tempfile_in(&scratch)?);
let text = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
@@ -322,6 +324,7 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
let mut meta = Manifest {
format: manifest::FORMAT,
scratch: notes,
..Manifest::default()
};
let mut note = |path: &str, node: &ExportNode| {
+4 -1
View File
@@ -27,6 +27,7 @@ use crate::{
manifest::{self, Manifest, NodeMeta},
media,
nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER, SPACE},
scratch,
},
state::AppState,
vault,
@@ -976,9 +977,11 @@ async fn build(
links::index(&mut tx, *id, &rewritten).await?;
}
// The boards come back only where spaces do.
// The boards come back only where spaces do, and the scratchpad with
// them: both belong to the workspace as a whole, not to a folder.
let mut cards = 0usize;
if let (Into::Root, Some(manifest)) = (into, &manifest) {
scratch::restore(&mut tx, &manifest.scratch).await?;
for (dir, space) in &manifest.spaces {
let Some(&space_id) = dir_ids.get(dir) else {
continue;
+11
View File
@@ -36,6 +36,17 @@ pub struct Manifest {
/// they were uploaded under.
#[serde(default)]
pub media: BTreeMap<String, String>,
/// The scratchpad's notes, oldest first. Added without a bump of `FORMAT`:
/// an older build leaves them out of an import and misreads nothing.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scratch: Vec<ScratchMeta>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ScratchMeta {
pub body: String,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Serialize, Deserialize, Clone)]
+6
View File
@@ -12,6 +12,7 @@ pub mod media;
pub mod nodes;
pub mod repeat;
pub mod revisions;
pub mod scratch;
pub mod seal;
pub mod session;
pub mod share;
@@ -63,6 +64,11 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
.route("/diary/days", get(diary::days))
.route("/diary/around/{id}", get(diary::around_entry))
.route("/capture", post(capture::capture))
.route("/scratch", get(scratch::list).post(scratch::create))
.route(
"/scratch/{id}",
put(scratch::update).delete(scratch::remove),
)
.route("/spaces/{id}/board", get(boards::board))
.route("/spaces/{id}/lists", post(boards::create_list))
.route(
+187
View File
@@ -0,0 +1,187 @@
//! The scratchpad: notes taken in passing, outside the tree.
//!
//! A note here has a body and nothing else — no space, no folder, no name —
//! because deciding those is the cost a quick note should not pay. The drawer
//! lists them newest first; one worth keeping is made into a document by the
//! client, which then deletes it here, so a note lives in one place at a time.
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::{Row, SqliteConnection};
use crate::{
auth::Session,
error::{AppError, AppResult},
routes::manifest::ScratchMeta,
state::AppState,
};
/// A note is a few lines; this is far past that, and short of what would make
/// the drawer slow to load.
const MAX_BODY: usize = 256 * 1024;
#[derive(Serialize)]
pub struct Note {
pub id: i64,
pub body: String,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Deserialize)]
pub struct NoteInput {
#[serde(default)]
pub body: String,
}
fn checked(body: &str) -> AppResult<()> {
if body.len() > MAX_BODY {
return Err(AppError::BadRequest(
"a scratch note is at most 256 KiB — make it a document".into(),
));
}
Ok(())
}
fn row_to_note(row: &sqlx::sqlite::SqliteRow) -> Note {
Note {
id: row.get("id"),
body: row.get("body"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}
}
/// Every note, the one touched last first.
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Note>>> {
let rows = sqlx::query(
"SELECT id, body, created_at, updated_at FROM scratch_notes
ORDER BY updated_at DESC, id DESC",
)
.fetch_all(&state.db)
.await?;
Ok(Json(rows.iter().map(row_to_note).collect()))
}
pub async fn create(
_: Session,
State(state): State<AppState>,
Json(input): Json<NoteInput>,
) -> AppResult<Json<Note>> {
checked(&input.body)?;
let now = crate::now();
let row = sqlx::query(
"INSERT INTO scratch_notes (body, created_at, updated_at) VALUES (?1, ?2, ?2)
RETURNING id, body, created_at, updated_at",
)
.bind(&input.body)
.bind(now)
.fetch_one(&state.db)
.await?;
state.backup.signal();
Ok(Json(row_to_note(&row)))
}
pub async fn update(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<NoteInput>,
) -> AppResult<Json<Note>> {
checked(&input.body)?;
let row = sqlx::query(
"UPDATE scratch_notes SET body = ?1, updated_at = ?2 WHERE id = ?3
RETURNING id, body, created_at, updated_at",
)
.bind(&input.body)
.bind(crate::now())
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
state.backup.signal();
Ok(Json(row_to_note(&row)))
}
pub async fn remove(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
let affected = sqlx::query("DELETE FROM scratch_notes WHERE id = ?1")
.bind(id)
.execute(&state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound);
}
state.backup.signal();
Ok(Json(serde_json::json!({ "ok": true })))
}
/// The notes as an export carries them.
pub(crate) async fn export(db: &sqlx::SqlitePool) -> AppResult<Vec<ScratchMeta>> {
let rows = sqlx::query(
"SELECT body, created_at, updated_at FROM scratch_notes ORDER BY created_at, id",
)
.fetch_all(db)
.await?;
Ok(rows
.iter()
.map(|row| ScratchMeta {
body: row.get("body"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})
.collect())
}
/// An export's notes put back beside whatever notes there are already.
pub(crate) async fn restore(db: &mut SqliteConnection, notes: &[ScratchMeta]) -> AppResult<()> {
for note in notes {
sqlx::query("INSERT INTO scratch_notes (body, created_at, updated_at) VALUES (?1, ?2, ?3)")
.bind(&note.body)
.bind(note.created_at)
.bind(note.updated_at)
.execute(&mut *db)
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{export, restore};
use crate::routes::manifest::ScratchMeta;
#[tokio::test]
async fn the_notes_go_out_with_an_export_and_come_back() {
let db = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::migrate!("./migrations").run(&db).await.unwrap();
let note = |body: &str, at| ScratchMeta {
body: body.into(),
created_at: at,
updated_at: at + 1,
};
let mut conn = db.acquire().await.unwrap();
restore(
&mut conn,
&[note("call the landlord", 10), note("idea: a drawer", 20)],
)
.await
.unwrap();
drop(conn);
let out = export(&db).await.unwrap();
let bodies: Vec<&str> = out.iter().map(|n| n.body.as_str()).collect();
assert_eq!(bodies, ["call the landlord", "idea: a drawer"]);
assert_eq!(out[1].updated_at, 21);
}
}
+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