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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+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