From 9391b10af11dff516e571e2a9a6511433537386a Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 17 Oct 2026 14:25:32 +0200 Subject: [PATCH] hyprland --- src/routes/mod.rs | 1 + src/routes/nodes.rs | 104 +++++++++++++++- web/src/App.svelte | 210 +++++++++++++++++++++++++++++--- web/src/app.css | 1 + web/src/lib/Bar.svelte | 19 ++- web/src/lib/DocumentPane.svelte | 43 ++++++- web/src/lib/Editor.svelte | 12 +- web/src/lib/Help.svelte | 12 +- web/src/lib/Outline.svelte | 109 +++++++++++++++++ web/src/lib/Overview.svelte | 204 +++++++++++++++++++++++++++++++ web/src/lib/Rail.svelte | 2 + web/src/lib/Shared.svelte | 39 ++++-- web/src/lib/Sidebar.svelte | 5 +- web/src/lib/StatusBar.svelte | 2 + web/src/lib/api.ts | 8 ++ web/src/lib/commands.ts | 3 + web/src/lib/store.svelte.ts | 107 +++++++++++++++- web/src/lib/util.ts | 13 ++ 18 files changed, 841 insertions(+), 53 deletions(-) create mode 100644 web/src/lib/Outline.svelte create mode 100644 web/src/lib/Overview.svelte diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 2d36490..1ef2f48 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -23,6 +23,7 @@ pub fn api_router() -> Router { .route("/export", get(export::export)) .route("/backup", get(backup::status).post(backup::run)) .route("/spaces", get(nodes::spaces).post(nodes::create_space)) + .route("/spaces/overview", get(nodes::overview)) .route("/spaces/{id}/board", get(boards::board)) .route("/spaces/{id}/lists", post(boards::create_list)) .route("/lists/{id}", put(boards::rename_list).delete(boards::remove_list)) diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index fb5aaf6..3f214e6 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -277,17 +277,65 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary { /// The spaces, in display order. Their child counts are what the switcher shows. pub async fn spaces(_: Session, State(state): State) -> AppResult>> { + list_spaces(&state.db).await.map(Json) +} + +async fn list_spaces(db: &SqlitePool) -> AppResult> { let rows = sqlx::query(&format!( "SELECT {SUMMARY_COLUMNS} FROM nodes n WHERE n.parent_id IS NULL AND n.kind = 'space' ORDER BY n.position, n.name" )) - .fetch_all(&state.db) + .fetch_all(db) .await?; - Ok(Json( - rows.iter().map(|row| row_to_summary(row, "")).collect(), + Ok(rows.iter().map(|row| row_to_summary(row, "")).collect()) +} + +/// How many documents each space shows in the overview. +const OVERVIEW_RECENT: i64 = 4; + +#[derive(Serialize)] +pub struct SpaceOverview { + pub space: NodeSummary, + pub recent: Vec, +} + +/// Every space with the documents last written in it, from any depth: what +/// the overview shows, the way the desktop's overview shows each workspace +/// with its windows. +pub async fn overview(_: Session, State(state): State) -> AppResult>> { + let mut out = Vec::new(); + for space in list_spaces(&state.db).await? { + let recent = recent_documents(&state.db, space.id, OVERVIEW_RECENT).await?; + out.push(SpaceOverview { space, recent }); + } + Ok(Json(out)) +} + +/// The `limit` most recently edited documents anywhere under `root`. +pub async fn recent_documents(db: &SqlitePool, root: i64, limit: i64) -> AppResult> { + let rows = sqlx::query(&format!( + "WITH RECURSIVE sub (id) AS ( + SELECT id FROM nodes WHERE id = ?1 + UNION ALL + SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id + ) + SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head + FROM nodes n + WHERE n.kind = 'document' AND n.id IN (SELECT id FROM sub) + ORDER BY n.updated_at DESC, n.id DESC + LIMIT ?2" )) + .bind(root) + .bind(limit) + .fetch_all(db) + .await?; + + Ok(rows + .iter() + .map(|row| row_to_summary(row, &row.try_get::("head").unwrap_or_default())) + .collect()) } pub async fn create_space( @@ -718,7 +766,55 @@ pub async fn load(state: &AppState, id: i64) -> AppResult { mod tests { use std::collections::BTreeSet; - use super::{excerpt, fts_query, next_slug, slugify, EXCERPT_CHARS, HEAD_CHARS}; + use super::{ + excerpt, fts_query, next_slug, recent_documents, slugify, EXCERPT_CHARS, HEAD_CHARS, + }; + + /// The overview reaches into folders, newest edit first, and stops at the + /// limit — and never lists a folder as if it were a document. + #[tokio::test] + async fn recent_documents_come_from_any_depth() { + let db = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("an in-memory database"); + sqlx::migrate!("./migrations").run(&db).await.expect("migrations apply"); + + let insert = |parent: i64, kind: &'static str, slug: &'static str, at: i64| { + let db = db.clone(); + async move { + sqlx::query_scalar::<_, i64>( + "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) + VALUES (?1, ?2, ?3, ?3, '', ?4, ?4) RETURNING id", + ) + .bind(parent) + .bind(kind) + .bind(slug) + .bind(at) + .fetch_one(&db) + .await + .expect("insert") + } + }; + let space: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE slug = 'uni'") + .fetch_one(&db) + .await + .expect("the seeded space"); + let folder = insert(space, "folder", "deep", 1).await; + let inner = insert(folder, "folder", "deeper", 2).await; + insert(space, "document", "a", 10).await; + insert(folder, "document", "b", 30).await; + insert(inner, "document", "c", 20).await; + + let slugs: Vec = recent_documents(&db, space, 2) + .await + .expect("query") + .into_iter() + .map(|n| n.slug) + .collect(); + assert_eq!(slugs, ["b", "c"]); + } #[test] fn strips_markdown_furniture() { diff --git a/web/src/App.svelte b/web/src/App.svelte index 602679c..5647dc1 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -7,6 +7,7 @@ import DocumentPane from './lib/DocumentPane.svelte' import Help from './lib/Help.svelte' import Login from './lib/Login.svelte' + import Overview from './lib/Overview.svelte' import MediaBrowser from './lib/MediaBrowser.svelte' import Rail from './lib/Rail.svelte' import Shared from './lib/Shared.svelte' @@ -43,6 +44,7 @@ hooks.pickArchive = () => importInput?.click() hooks.focusList = () => shell?.focus() hooks.openCommandLine = (initial) => (cmdline = initial) + hooks.openOverview = () => void openOverview() }) /* @@ -92,14 +94,87 @@ const offers: [string, string][] = [ ['o', 'write a new document'], ['O', 'make a folder'], - ['R', 'rename what is marked'], - ['b', "this space's board"], ['j / k', 'browse, l opens'], - ['h', 'up one level'], + ['1 … 9', 'switch space, Tab cycles'], + ['w', 'overview of every space'], + ['b', "this space's board"], ['/', 'search this space'], ['?', 'help'], ] + /* + * Spaces are workspaces: digits jump to one, shifted digits send the node + * there, Tab walks along the bar. `event.code` rather than `event.key`, so + * Shift+1 means space 1 whatever symbol the layout puts on that key. + */ + function spaceKey(event: KeyboardEvent): boolean { + const digit = /^Digit(\d)$/.exec(event.code)?.[1] + if (digit !== undefined) { + const index = digit === '0' ? 9 : Number(digit) - 1 + void workspace.guard(() => + event.shiftKey ? workspace.sendToSpace(index) : workspace.switchSpace(index), + ) + return true + } + if (event.key === 'Tab') { + void workspace.guard(() => workspace.cycleSpace(event.shiftKey ? -1 : 1)) + return true + } + if (event.key === 'w') { + void openOverview() + return true + } + return false + } + + async function openOverview() { + await workspace.guard(() => workspace.loadOverview()) + workspace.overviewCursor = Math.max( + workspace.overview.findIndex((entry) => entry.space.id === workspace.spaceId), + 0, + ) + workspace.overlay = 'overview' + } + + /** The overview's own keys: hjkl across the grid, a digit or Enter to go. */ + function overviewKey(event: KeyboardEvent): boolean { + const count = workspace.overview.length + const columns = hooks.overviewColumns() + const go = (index: number) => { + const entry = workspace.overview[index] + if (!entry) return + workspace.overlay = 'none' + void workspace.guard(() => workspace.enterSpace(entry.space.id)) + } + const digit = /^Digit(\d)$/.exec(event.code)?.[1] + if (digit !== undefined) { + go(digit === '0' ? 9 : Number(digit) - 1) + return true + } + const step: Record = { h: -1, ArrowLeft: -1, l: 1, ArrowRight: 1, k: -columns, ArrowUp: -columns, j: columns, ArrowDown: columns } + if (event.key in step) { + workspace.overviewCursor = Math.min(Math.max(workspace.overviewCursor + step[event.key], 0), count - 1) + return true + } + if (event.key === 'Enter') { + go(workspace.overviewCursor) + return true + } + if (event.key === 'w' || event.key === 'q') { + workspace.overlay = 'none' + return true + } + return false + } + + /** Alt+h / Alt+l: focus moves between the panes, out of the editor too. */ + function focusPane(pane: 'list' | 'document') { + if (pane === 'document' && !workspace.open) return + workspace.pane = pane + if (pane === 'document' && workspace.editing) hooks.focusEditor() + else shell?.focus() + } + /* * The board reads the same keys as the list, one dimension wider: h/l walk * the lists, j/k the cards, and the shifted pair takes the card along. Only @@ -185,6 +260,19 @@ return } + if (event.altKey && !event.ctrlKey && !event.metaKey && (event.key === 'h' || event.key === 'l')) { + event.preventDefault() + focusPane(event.key === 'h' ? 'list' : 'document') + return + } + + if (workspace.overlay === 'overview' && !event.ctrlKey && !event.metaKey && !event.altKey) { + if (overviewKey(event)) { + event.preventDefault() + return + } + } + if (event.key === 'Escape') { if (workspace.overlay !== 'none') { workspace.overlay = 'none' @@ -195,6 +283,10 @@ shell?.focus() return } + if (workspace.fullscreen && !workspace.editing) { + workspace.fullscreen = false + return + } } if (workspace.overlay !== 'none') { @@ -222,6 +314,16 @@ const previous = pending pending = '' + if (spaceKey(event)) { + event.preventDefault() + return + } + if (key === 'f') { + event.preventDefault() + workspace.fullscreen = !workspace.fullscreen + return + } + if (workspace.view === 'board') { if (previous === 'g' && key === 'g') { event.preventDefault() @@ -239,11 +341,11 @@ } /* - * With a document open and not being edited, the reading pane owns j/k: - * the keys scroll what is on screen, and `h` backs out to the list, where - * they walk the rows again. + * The focused pane owns j/k. In the document they scroll what is on + * screen; `h` moves focus to the list with the document still open, where + * they walk the rows again, and `l` goes back. */ - const reading = !!workspace.open && !workspace.editing + const reading = !!workspace.open && !workspace.editing && workspace.pane === 'document' // Two-key sequences: gg and dd. if (previous === 'g' && key === 'g') { @@ -284,7 +386,8 @@ case 'l': case 'ArrowRight': event.preventDefault() - void workspace.guard(() => workspace.openSelected()) + if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document') + else void workspace.guard(() => workspace.openSelected()) break case 'm': // Pick a node up here, walk to a folder, put it down with `p`. @@ -323,10 +426,10 @@ break case 'h': case 'ArrowLeft': - // Out of the document if one is open, otherwise up a level — the same - // key means "leftwards" at both depths. + // Leftwards at every depth: from the document to the list, from the + // list up a level. event.preventDefault() - if (workspace.open) void runCommand('q') + if (workspace.open && workspace.pane === 'document') focusPane('list') else void workspace.guard(() => workspace.up()) break case 'q': @@ -382,13 +485,34 @@
+ + {#key workspace.spaceId} {#if workspace.view === 'board'} -
+
+
+
{:else} -
-
-
+
+ +
(workspace.pane = 'list')} + >
+ +
workspace.open && (workspace.pane = 'document')} + > {#if workspace.open} {:else} @@ -409,6 +533,7 @@
{/if} + {/key}
@@ -421,7 +546,7 @@
e.target === e.currentTarget && (workspace.overlay = 'none')} > - {#if workspace.overlay === 'help'}{:else}{/if} + {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else}{/if}
{/if} @@ -473,15 +598,64 @@ z-index: 5; } + /* + * The panes tile the porthole like windows tile a workspace: a gap round + * and between them, each rounded to sit concentric with the frame. + */ .panes { + --gap: 7px; height: 100%; display: grid; grid-template-columns: minmax(32ch, 24vw) 1fr; + gap: var(--gap); + padding: var(--gap); } .panes.board { grid-template-columns: 1fr; } - .list, .entry { min-width: 0; min-height: 0; overflow: hidden; } + /* + * A window: opaque, a hairline border in overlay. The focused one gets the + * desktop's active border — a light gradient at 135° — and its shadow. + */ + .window { + --pane-bg: color-mix(in oklab, var(--surface) 40%, var(--base)); + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid transparent; + border-radius: calc(var(--frame-radius) - var(--gap)); + background: + linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box, + linear-gradient(var(--overlay), var(--overlay)) border-box; + transition: box-shadow var(--t-base) var(--ease-glide); + } + .window.list { --pane-bg: color-mix(in oklab, var(--surface) 60%, var(--base)); } + .window.focused { + background: + linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box, + linear-gradient(135deg, var(--light), var(--light-dim)) border-box; + box-shadow: 0 6px 24px rgb(0 0 0 / 0.45); + } + /* Only one pane on screen: nothing to tell apart, so no active border. */ + .panes:not(.reading) .window.list, + .panes.board .window { + background: + linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box, + linear-gradient(var(--overlay), var(--overlay)) border-box; + box-shadow: none; + } + .panes:not(.reading) .window.entry { border-color: transparent; background: transparent; } + + /* `f`: the focused pane fills the porthole. */ + .panes.full { grid-template-columns: 1fr; } + .panes.full .window:not(.focused) { display: none; } + .panes.full:not(.reading) .window.entry { display: none; } + + /* hypr: workspaces, slidefadevert 18%, 550ms on the drift curve. */ + .switch { animation: slidefadevert 550ms var(--ease-drift) both; } + @keyframes slidefadevert { + from { opacity: 0; transform: translateY(calc(var(--dir, 1) * 18%)); } + } .placeholder { display: grid; place-items: center; height: 100%; padding: 24px; } .standby { width: min(100%, 44ch); } @@ -515,7 +689,7 @@ .hidden { display: none; } @media (max-width: 760px) { - .panes { grid-template-columns: 1fr; } + .panes { grid-template-columns: 1fr; --gap: 4px; } .panes .entry { display: none; } .panes.reading .list { display: none; } .panes.reading .entry { display: block; } diff --git a/web/src/app.css b/web/src/app.css index 8977747..b75c1fd 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -44,6 +44,7 @@ --ease-snap: cubic-bezier(0.22, 1, 0.36, 1); --ease-glide: cubic-bezier(0.4, 0, 0.2, 1); --ease-in: cubic-bezier(0.55, 0, 0.9, 0.35); + --ease-drift: cubic-bezier(0.35, 0, 0.15, 1); --t-fast: 120ms; --t-base: 220ms; --t-slow: 380ms; diff --git a/web/src/lib/Bar.svelte b/web/src/lib/Bar.svelte index e78584f..fa44823 100644 --- a/web/src/lib/Bar.svelte +++ b/web/src/lib/Bar.svelte @@ -26,13 +26,14 @@ +{/if} + + diff --git a/web/src/lib/Overview.svelte b/web/src/lib/Overview.svelte new file mode 100644 index 0000000..e0d9706 --- /dev/null +++ b/web/src/lib/Overview.svelte @@ -0,0 +1,204 @@ + + +
+
+ overview {workspace.overview.length} spaces + +
+ +
    + {#each workspace.overview as { space, recent }, index (space.id)} +
  1. + +
    enter(space.id)} + onpointerenter={() => (workspace.overviewCursor = index)} + > +
    + {index < 10 ? (index + 1) % 10 : ''} + {space.name} + {#if space.has_board}board{/if} + {space.child_count} +
    + +
      + {#each recent as doc (doc.id)} +
    • + +
    • + {:else} +
    • nothing written yet
    • + {/each} +
    +
    +
  2. + {/each} +
+ +
h j k l move · 1 … 9 or enter go · w closes
+
+ + diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index 2ae2d7b..1e28bbc 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -29,6 +29,8 @@ { key: 'o', caption: 'new', title: 'new document (o)', run: () => runCommand('new') }, { key: 'O', caption: 'dir', title: 'new folder (O)', run: () => hooks.openCommandLine(':mkdir ') }, ...(workspace.space ? [board] : []), + { key: 'w', caption: 'spaces', title: 'overview of every space (w)', run: () => hooks.openOverview() }, + { key: 'f', caption: 'full', title: 'fullscreen the focused pane (f)', run: () => (workspace.fullscreen = !workspace.fullscreen), on: workspace.fullscreen }, { key: '/', caption: 'find', title: 'search (/)', run: () => hooks.openCommandLine('/') }, { key: ':', caption: 'cmd', title: 'command line (:)', run: () => hooks.openCommandLine(':') }, { key: '?', caption: 'help', title: 'help (?)', run: () => runCommand('help') }, diff --git a/web/src/lib/Shared.svelte b/web/src/lib/Shared.svelte index 1bec036..4b835fc 100644 --- a/web/src/lib/Shared.svelte +++ b/web/src/lib/Shared.svelte @@ -1,12 +1,16 @@ -
+
{#if error}

{error}

{:else if doc} -
+
{formatStamp(doc.created_at)} {formatWeekday(doc.created_at)} @@ -35,8 +39,11 @@
{#if doc.name.trim()}

{doc.name}

{/if} - {@html renderMarkdown(doc.body, { token, key: shareKey })} + {@html html}
+
— written in ~/workspace
{:else}

loading_

@@ -44,19 +51,34 @@
diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 128de1b..4ceef4c 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -79,9 +79,6 @@ flex-direction: column; height: 100%; min-width: 0; - --pane: color-mix(in oklab, var(--surface) 55%, var(--base)); - background: var(--pane); - border-right: 1px solid var(--rim); } .head { @@ -120,7 +117,7 @@ z-index: 1; padding: 14px 8px 6px 10px; font-size: 10px; - background: linear-gradient(var(--pane) 70%, transparent); + background: linear-gradient(var(--pane-bg) 70%, transparent); } .day { color: var(--subtle); } diff --git a/web/src/lib/StatusBar.svelte b/web/src/lib/StatusBar.svelte index 37d961d..c1ab528 100644 --- a/web/src/lib/StatusBar.svelte +++ b/web/src/lib/StatusBar.svelte @@ -29,6 +29,7 @@ {workspace.flash?.text ?? ''} + {#if workspace.fullscreen}full{/if} {#if lines !== null}{lines}L{/if} {isTouchDevice ? 'tap : for commands' : '? help · : command · / search'} @@ -75,6 +76,7 @@ .readout { color: var(--fg); font-variant-numeric: tabular-nums; } .unit { color: var(--muted); margin-left: 1px; } .hint { color: var(--muted); letter-spacing: 0.06em; } + .flag { color: var(--light); letter-spacing: 0.18em; text-transform: uppercase; } .tools { display: none; } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 304ef6a..41f7b48 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -31,6 +31,12 @@ export interface WorkspaceNode extends NodeSummary { } /** A public key allowed to mount the workspace over SFTP. */ +/** A space as the overview shows it: itself, and what was last written in it. */ +export interface SpaceOverview { + space: NodeSummary + recent: NodeSummary[] +} + export interface SshKey { id: number name: string @@ -152,6 +158,8 @@ export const api = { listSpaces: () => request('/spaces'), + overview: () => request('/spaces/overview'), + createSpace: (name: string, has_board = true) => request('/spaces', { method: 'POST', body: body({ name, has_board }) }), diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 5313163..e71b34a 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -16,6 +16,9 @@ export const hooks = { /* Filled in while a document is on screen and not being edited: the reading pane owns the scrollback, so the list keys have somewhere to send it. */ scrollReader: (_amount: ScrollAmount): boolean => false, + /** How many space cards the overview fits in a row, for j/k to step by. */ + overviewColumns: (): number => 1, + openOverview: () => {}, openCommandLine: (_initial: string) => {}, } diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 88266b1..bbb6de3 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -7,6 +7,7 @@ import { type WorkspaceNode, type MediaFile, type NodeSummary, + type SpaceOverview, } from './api' export type Pane = 'list' | 'document' @@ -14,6 +15,9 @@ export type Pane = 'list' | 'document' export type View = 'tree' | 'board' export type Flash = { text: string; kind: 'info' | 'error' } | null +/** Where a space was left: the folder, the open document, the row, the pane. */ +type Spot = { parentId: number; openId: number | null; cursor: number; pane: Pane } + /** * The whole application state. It is deliberately one object: the keyboard * layer needs to see everything at once to decide what a key means. @@ -60,8 +64,21 @@ class Workspace { vimMode = $state('normal') flash = $state(null) - overlay = $state<'none' | 'help' | 'media'>('none') + overlay = $state<'none' | 'help' | 'media' | 'overview'>('none') media = $state([]) + overview = $state([]) + overviewCursor = $state(0) + + /** `f`: the focused pane takes the whole porthole, like a fullscreen window. */ + fullscreen = $state(false) + /** +1 when the last space switch went right along the bar, -1 when left. */ + switchDirection = $state(1) + + /* + * Spaces behave like the desktop's workspaces: each keeps the folder and the + * document it was left on, and coming back finds them where they were. + */ + #spots: Record = JSON.parse(localStorage.getItem('diary:spots') ?? '{}') vimEnabled = $state(localStorage.getItem('diary:vim') !== 'off') @@ -246,11 +263,50 @@ class Workspace { if (start) await this.enterSpace(start.id) } + #rememberSpot() { + if (this.spaceId === null || this.parentId === null) return + this.#spots[this.spaceId] = { + parentId: this.parentId, + openId: this.open?.id ?? null, + cursor: this.cursor, + pane: this.pane, + } + localStorage.setItem('diary:spots', JSON.stringify(this.#spots)) + } + async enterSpace(id: number) { + const leaving = this.spaceId !== null && this.spaceId !== id + if (leaving) { + await this.flush() + this.#rememberSpot() + const from = this.spaces.findIndex((s) => s.id === this.spaceId) + const to = this.spaces.findIndex((s) => s.id === id) + this.switchDirection = to >= from ? 1 : -1 + this.open = null + this.editing = false + this.pane = 'list' + } this.spaceId = id localStorage.setItem('diary:space', String(id)) this.query = '' - await this.goTo(id) + + // A remembered folder may have been deleted or moved to another space + // since; then the space's root is where it opens. + const spot = this.#spots[id] + try { + await this.goTo(spot?.parentId ?? id) + if (this.spaceId !== id) await this.goTo(id) + } catch { + await this.goTo(id) + } + if (spot && this.parentId === spot.parentId) { + if (spot.openId !== null && this.nodes.some((n) => n.id === spot.openId)) { + await this.openNode(spot.openId) + this.pane = spot.pane + } else { + this.cursor = Math.min(spot.cursor, Math.max(this.nodes.length - 1, 0)) + } + } // The board belongs to the space, so switching space while looking at one // shows the new space's board — or drops back to the tree for a space that // has none, rather than leaving the previous board on screen. @@ -265,9 +321,11 @@ class Workspace { /** Show the children of `id`, which must be a space or a folder. */ async goTo(id: number, keep?: number) { await this.flush() + const folder = await api.getNode(id) + // Walking into another space by a link leaves this one where it was. + if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot() this.parentId = id this.query = '' - const folder = await api.getNode(id) this.path = folder.path this.spaceId = folder.path[0]?.id ?? id this.nodes = await api.listNodes({ parent: id }) @@ -479,6 +537,49 @@ class Workspace { this.say(`moved #${id}`) } + /** `1`..`9`, `0`: the space at that place on the bar. */ + async switchSpace(index: number) { + const space = this.spaces[index] + if (!space) return this.say(`no space ${index + 1}`, 'error') + if (space.id !== this.spaceId) await this.enterSpace(space.id) + } + + /** Tab / Shift-Tab: the next or previous space, wrapping round. */ + async cycleSpace(delta: number) { + const count = this.spaces.length + if (count < 2) return + const at = this.spaces.findIndex((s) => s.id === this.spaceId) + await this.enterSpace(this.spaces[(at + delta + count) % count].id) + } + + /** + * Shift+digit: send the marked node, or else the one under the cursor, to + * the root of the space at that place — the window moved to workspace N. + */ + async sendToSpace(index: number) { + const space = this.spaces[index] + const node = this.marked ?? this.selected + if (!space) return this.say(`no space ${index + 1}`, 'error') + if (!node) return this.say('nothing selected', 'error') + if (node.parent_id === space.id) return this.say(`already in ${space.slug}`) + + if (this.open?.id === node.id) { + await this.flush() + this.open = null + this.editing = false + this.pane = 'list' + } + await api.moveNode(node.id, space.id) + this.marked = null + await this.refresh() + this.spaces = await api.listSpaces() + this.say(`moved ${node.name.trim() || node.slug} → ${index + 1} ${space.slug}`) + } + + async loadOverview() { + this.overview = await api.overview() + } + /** `m`: pick the node under the cursor up, or put it back down unmoved. */ mark() { const node = this.selected diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index 177b433..6cd8cff 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -61,3 +61,16 @@ export const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0 && window.matchMedia('(pointer: coarse)').matches + +/** A heading as an anchor id: lowercase, dashes, the same idea as the server's slugs. */ +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/ß/g, 'ss') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) +}