From ac3ad166e5faf159c98c55053cdfd8ed2157aacf Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 22 Oct 2026 00:07:23 +0200 Subject: [PATCH] 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 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. --- README.md | 7 +- docs/wiki/API.md | 4 +- docs/wiki/Commands.md | 2 + docs/wiki/Writing.md | 36 ++++ migrations/0026_scratch.sql | 12 ++ src/routes/export.rs | 3 + src/routes/import.rs | 5 +- src/routes/manifest.rs | 11 ++ src/routes/mod.rs | 6 + src/routes/scratch.rs | 187 ++++++++++++++++++ web/src/App.svelte | 91 ++++++++- web/src/lib/CardSheet.svelte | 16 +- web/src/lib/Help.svelte | 11 ++ web/src/lib/Scratch.svelte | 178 +++++++++++++++++ .../{CardEditor.svelte => TextEditor.svelte} | 43 ++-- web/src/lib/api.ts | 15 ++ web/src/lib/commands.ts | 32 ++- web/src/lib/store.svelte.ts | 147 ++++++++++++++ 18 files changed, 782 insertions(+), 24 deletions(-) create mode 100644 migrations/0026_scratch.sql create mode 100644 src/routes/scratch.rs create mode 100644 web/src/lib/Scratch.svelte rename web/src/lib/{CardEditor.svelte => TextEditor.svelte} (78%) diff --git a/README.md b/README.md index e54f146..2ac67c3 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/wiki/API.md b/docs/wiki/API.md index 7bd2e0b..46eaa69 100644 --- a/docs/wiki/API.md +++ b/docs/wiki/API.md @@ -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 | diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index 339b046..2fd3499 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -142,6 +142,8 @@ do: `:w!`, `:space! `, `: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 | diff --git a/docs/wiki/Writing.md b/docs/wiki/Writing.md index b34bba2..aa405c1 100644 --- a/docs/wiki/Writing.md +++ b/docs/wiki/Writing.md @@ -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 ` 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 ` 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; diff --git a/migrations/0026_scratch.sql b/migrations/0026_scratch.sql new file mode 100644 index 0000000..768716c --- /dev/null +++ b/migrations/0026_scratch.sql @@ -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); diff --git a/src/routes/export.rs b/src/routes/export.rs index 36c8a3c..1d975f1 100644 --- a/src/routes/export.rs +++ b/src/routes/export.rs @@ -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| { diff --git a/src/routes/import.rs b/src/routes/import.rs index 79b2831..c2082b2 100644 --- a/src/routes/import.rs +++ b/src/routes/import.rs @@ -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; diff --git a/src/routes/manifest.rs b/src/routes/manifest.rs index 9b82267..1f2b9b9 100644 --- a/src/routes/manifest.rs +++ b/src/routes/manifest.rs @@ -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)] diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 7059ff2..2591820 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -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( diff --git a/src/routes/scratch.rs b/src/routes/scratch.rs new file mode 100644 index 0000000..a1ed22e --- /dev/null +++ b/src/routes/scratch.rs @@ -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(¬e.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); + } +} diff --git a/web/src/App.svelte b/web/src/App.svelte index 105cb32..846932a 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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 /> diff --git a/web/src/lib/CardSheet.svelte b/web/src/lib/CardSheet.svelte index 5b3739c..16efb21 100644 --- a/web/src/lib/CardSheet.svelte +++ b/web/src/lib/CardSheet.svelte @@ -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 --> diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 60447d2..5773341 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -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[]]), diff --git a/web/src/lib/Scratch.svelte b/web/src/lib/Scratch.svelte new file mode 100644 index 0000000..02597ae --- /dev/null +++ b/web/src/lib/Scratch.svelte @@ -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> diff --git a/web/src/lib/CardEditor.svelte b/web/src/lib/TextEditor.svelte similarity index 78% rename from web/src/lib/CardEditor.svelte rename to web/src/lib/TextEditor.svelte index ad0cbd9..bdaa1bc 100644 --- a/web/src/lib/CardEditor.svelte +++ b/web/src/lib/TextEditor.svelte @@ -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> diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3f6c1e4..d1ab6e6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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) => diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 9069530..1cc263d 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -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'], diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index bb91dbf..691d9be 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -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