This commit is contained in:
nvrl committed 2026-10-17 14:25:32 +02:00
1 parent 8a37ca5dfe
commit 9391b10af1
18 files changed
+837 -49

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ pub fn api_router() -> Router<AppState> {
.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))
+100 -4
View File
@@ -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<AppState>) -> AppResult<Json<Vec<NodeSummary>>> {
list_spaces(&state.db).await.map(Json)
}
async fn list_spaces(db: &SqlitePool) -> AppResult<Vec<NodeSummary>> {
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<NodeSummary>,
}
/// 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<AppState>) -> AppResult<Json<Vec<SpaceOverview>>> {
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<Vec<NodeSummary>> {
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::<String, _>("head").unwrap_or_default()))
.collect())
}
pub async fn create_space(
@@ -718,7 +766,55 @@ pub async fn load(state: &AppState, id: i64) -> AppResult<Node> {
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<String> = 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() {
+192 -18
View File
@@ -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<string, number> = { 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 @@
<!-- The opening in the frame: everything that scrolls is seen through it. -->
<main class="porthole">
<!-- Keyed on the space, so a switch slides the new one in, as the desktop
does between workspaces: from below when moving right, above when left. -->
{#key workspace.spaceId}
{#if workspace.view === 'board'}
<!-- The board takes the whole width: its columns are the two panes. -->
<div class="panes board"><Board /></div>
<div class="panes board switch" style="--dir: {workspace.switchDirection}">
<div class="window focused"><Board /></div>
</div>
{:else}
<div class="panes" class:reading={!!workspace.open}>
<div class="list"><Sidebar /></div>
<div class="entry">
<div
class="panes switch"
class:reading={!!workspace.open}
class:full={workspace.fullscreen}
class:focus-doc={!!workspace.open && workspace.pane === 'document'}
style="--dir: {workspace.switchDirection}"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="window list"
class:focused={!workspace.open || workspace.pane === 'list'}
onpointerdown={() => (workspace.pane = 'list')}
><Sidebar /></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="window entry"
class:focused={!!workspace.open && workspace.pane === 'document'}
onpointerdown={() => workspace.open && (workspace.pane = 'document')}
>
{#if workspace.open}
<DocumentPane />
{:else}
@@ -409,6 +533,7 @@
</div>
</div>
{/if}
{/key}
</main>
<StatusBar />
@@ -421,7 +546,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
>
{#if workspace.overlay === 'help'}<Help />{:else}<MediaBrowser />{/if}
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else}<MediaBrowser />{/if}
</div>
{/if}
</div>
@@ -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; }
+1
View File
@@ -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;
+16 -3
View File
@@ -26,13 +26,14 @@
<span class="divider"></span>
<nav class="spaces" aria-label="spaces">
{#each workspace.spaces as space (space.id)}
{#each workspace.spaces as space, index (space.id)}
<button
class="pill"
aria-current={space.id === workspace.spaceId}
title="{space.name} ({index < 10 ? (index + 1) % 10 : ''})"
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
>
{space.slug}
{#if index < 10}<span class="n">{(index + 1) % 10}</span>{/if}{space.slug}
</button>
{/each}
<button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
@@ -129,10 +130,19 @@
.crumbs { flex: 0 1 auto; }
/* Workspace pills: muted at rest, light on hover, inverted when current. */
/* The focused pill widens, as the desktop's does, over the slow duration. */
.pill {
flex: none;
display: flex;
align-items: center;
gap: 6px;
min-width: 22px;
height: 20px;
padding: 0 6px;
transition:
padding var(--t-slow) var(--ease-snap),
background-color var(--t-base) var(--ease-glide),
color var(--t-base) var(--ease-glide);
border: 0;
border-radius: 4px;
font-size: 11px;
@@ -142,7 +152,10 @@
background: transparent;
}
.pill:hover { background: transparent; color: var(--light); }
.pill[aria-current='true'] { background: var(--light); color: var(--ink); }
.pill[aria-current='true'] { background: var(--light); color: var(--ink); padding: 0 12px; }
.n { color: var(--overlay); font-size: 10px; }
.pill:hover .n { color: var(--muted); }
.pill[aria-current='true'] .n { color: var(--ink); opacity: 0.55; }
.pill:focus-visible { background: var(--light); color: var(--ink); }
.pill.add { color: var(--overlay); }
.pill.add:hover { color: var(--light); }
+38 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { hooks, runCommand, type ScrollAmount } from './commands'
import { renderMarkdown } from './markdown'
import Outline from './Outline.svelte'
import { workspace } from './store.svelte'
import { formatStamp, formatWeekday, isTouchDevice, relative } from './util'
@@ -17,6 +18,7 @@
const sharePath = $derived(doc.share_token ? `${location.origin}/s/${doc.share_token}#…` : null)
let reader = $state<HTMLElement>()
let article = $state<HTMLElement>()
/*
* j/k reach this pane through the window handler in App.svelte, which has no
@@ -24,7 +26,9 @@
* says whether it scrolled anything at all.
*/
$effect(() => {
hooks.scrollReader = (amount: ScrollAmount) => {
// A space switch remounts this pane, and the old one's cleanup can run
// after the new one has set the hook — so it only clears its own.
const scroll = (amount: ScrollAmount) => {
if (!reader) return false
const line = parseFloat(getComputedStyle(reader).lineHeight) || 24
const half = reader.clientHeight / 2
@@ -46,7 +50,10 @@
}
return true
}
return () => (hooks.scrollReader = () => false)
hooks.scrollReader = scroll
return () => {
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
}
})
/**
@@ -144,7 +151,7 @@
{:else}
<div class="body reading" bind:this={reader}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<article class="md resolve" onclick={follow}>
<article class="md resolve" bind:this={article} onclick={follow}>
{#if doc.name.trim()}<h1 class="entrytitle">{doc.name}</h1>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
@@ -152,6 +159,9 @@
<p class="muted">this document is empty — press <span class="key">i</span> to write.</p>
{/if}
</article>
<aside class="side">
<Outline root={article} scroller={reader} source={html} />
</aside>
</div>
{/if}
</section>
@@ -249,7 +259,29 @@
.loading { padding: 16px 28px; }
.body { flex: 1; min-height: 0; overflow: hidden; }
.reading { overflow-y: auto; padding: 18px 28px 30vh; }
/*
* The text is a column in the middle of the pane, as on a page; the margin
* to its right holds the outline while there is room for one.
*/
.reading {
overflow-y: auto;
padding: 18px 28px 30vh;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 46rem) minmax(0, 1fr);
align-items: start;
container-type: inline-size;
}
.reading > article { grid-column: 2; max-width: none; }
.side {
grid-column: 3;
position: sticky;
top: 0;
max-width: 30ch;
padding: 6px 0 0 44px;
}
@container (max-width: 1000px) {
.side { display: none; }
}
.entrytitle { margin-top: 0 !important; font-size: 2.1em !important; }
.key {
@@ -275,6 +307,7 @@
.row { flex-direction: column; align-items: flex-start; }
.sharebar { margin: 0 16px 8px; }
.title { margin: 8px 16px 4px; }
.reading { padding: 14px 16px 30vh; }
.reading { padding: 14px 16px 30vh; grid-template-columns: minmax(0, 1fr); }
.reading > article { grid-column: 1; }
}
</style>
+5 -1
View File
@@ -25,6 +25,7 @@
let host = $state<HTMLDivElement>()
let view: EditorView | undefined
const focus = () => view?.focus()
const markdownHighlight = HighlightStyle.define([
{ tag: tags.heading, color: 'var(--light)', fontWeight: '600' },
@@ -208,7 +209,7 @@
parent: host!,
})
hooks.focusEditor = () => view?.focus()
hooks.focusEditor = focus
hooks.insertText = insertAtCursor
view.focus()
@@ -234,9 +235,12 @@
onDestroy(() => {
view?.destroy()
view = undefined
// Only clear the hooks if no newer editor has taken them already.
if (hooks.focusEditor === focus) {
hooks.focusEditor = () => {}
hooks.insertText = () => {}
workspace.vimMode = 'normal'
}
})
</script>
+9 -3
View File
@@ -3,18 +3,24 @@
import { workspace } from './store.svelte'
const keys: [string, string][] = [
['j / k · ↓ ↑', 'move down / up the list — scroll the open document'],
['1 … 9 · 0', 'switch to that space, like a workspace'],
['Shift 1 … 9', 'send the marked or selected node to that space'],
['Tab · Shift-Tab', 'next / previous space'],
['w', 'overview of every space — hjkl, then Enter'],
['f', 'fullscreen the focused pane; again or Esc to leave'],
['Alt-h / Alt-l', 'focus the list / the document, from the editor too'],
['j / k · ↓ ↑', 'move down / up the list — scroll the document when it has focus'],
['gg / G', 'first / last row, or top / bottom of the document'],
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
['Enter · l', 'open a document, or enter a folder'],
['h', 'up one level, or back out of a document'],
['h', 'from the document to the list, from the list up a level'],
['o', 'new document here, straight into insert mode'],
['O', 'new folder here'],
['i · a', 'edit the open document'],
['R', 'rename what is marked — document, folder or card'],
['m / p', 'pick a node up, then drop it into the folder you walk to'],
['b', "open this space's board"],
['Esc · q', 'back out to the list'],
['Esc · q', 'close the document'],
['/', 'full-text search, inside the current space'],
['n', 'clear the search'],
['s', 'toggle sharing for the document'],
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
/*
* The headings of a rendered document, beside it, with the one being read
* lit — the section tracking narl.io's bar does, turned on its side. It
* reads the article after it renders, so it works on any markdown without
* the renderer knowing about it.
*/
import { slugify } from './util'
interface Props {
/** The rendered article. */
root: HTMLElement | undefined
/** What scrolls: the pane, or the page in the share view. */
scroller: HTMLElement | undefined
/** Anything that changes the rendered headings, to read them again. */
source: string
}
const { root, scroller, source }: Props = $props()
type Heading = { id: string; text: string; level: number }
let headings = $state<Heading[]>([])
let current = $state('')
$effect(() => {
void source
if (!root || !scroller) return
const seen = new Map<string, number>()
const found: Heading[] = []
const elements = Array.from(root.querySelectorAll<HTMLElement>('h2, h3'))
for (const element of elements) {
const text = element.textContent?.trim() ?? ''
const base = slugify(text) || 'section'
const n = (seen.get(base) ?? 0) + 1
seen.set(base, n)
element.id = n === 1 ? base : `${base}-${n}`
found.push({ id: element.id, text, level: element.tagName === 'H2' ? 2 : 3 })
}
headings = found
current = found[0]?.id ?? ''
// The section whose heading last crossed the upper third is the one being
// read; a band rather than a line, so a short section still registers.
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) current = entry.target.id
}
},
{ root: scroller, rootMargin: '-10% 0px -70% 0px' },
)
for (const element of elements) observer.observe(element)
return () => observer.disconnect()
})
function jump(id: string) {
root?.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
current = id
}
</script>
{#if headings.length >= 2}
<nav class="outline" aria-label="outline">
<h2 class="label rule">outline</h2>
<ol>
{#each headings as heading (heading.id)}
<li class:sub={heading.level === 3}>
<button class:on={heading.id === current} onclick={() => jump(heading.id)}>
<span class="marker"></span>
<span class="text">{heading.text}</span>
</button>
</li>
{/each}
</ol>
</nav>
{/if}
<style>
.outline { font-size: 12px; }
h2 { margin: 0 0 10px; font-size: 10px; font-weight: 400; }
ol { list-style: none; margin: 0; padding: 0; }
li.sub { padding-left: 15px; }
button {
display: flex;
align-items: baseline;
gap: 8px;
width: 100%;
padding: 3px 6px;
border: 0;
border-radius: 4px;
text-align: left;
font-size: 12px;
letter-spacing: 0;
text-transform: none;
color: var(--muted);
}
button:hover { background: transparent; color: var(--light); }
button:active { transform: none; }
button.on { color: var(--light); }
button.on .marker { background: var(--light); }
button:focus-visible { background: var(--light); color: var(--ink); }
.marker { width: 5px; height: 5px; transform: translateY(-2px); }
.text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>
+204
View File
@@ -0,0 +1,204 @@
<script lang="ts">
/*
* `w`: every space at once, the way the desktop's overview shows every
* workspace with its windows — here, with what was last written in it. The
* keys live in App.svelte with the others; this only draws and tells them
* how many cards fit in a row.
*/
import { api, type NodeSummary } from './api'
import { hooks } from './commands'
import { workspace } from './store.svelte'
import { relative } from './util'
let grid = $state<HTMLOListElement>()
$effect(() => {
hooks.overviewColumns = () =>
grid ? getComputedStyle(grid).gridTemplateColumns.split(' ').length : 1
return () => (hooks.overviewColumns = () => 1)
})
$effect(() => {
const index = workspace.overviewCursor
grid?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
function enter(spaceId: number) {
workspace.overlay = 'none'
void workspace.guard(() => workspace.enterSpace(spaceId))
}
/** A recent document opens in place, in its own space. */
function open(doc: NodeSummary) {
workspace.overlay = 'none'
void workspace.guard(async () => {
const node = await api.getNode(doc.id)
await workspace.openPath(node.path.map((crumb) => crumb.slug).join('/'))
})
}
</script>
<div class="sheet">
<header>
<span class="label rule"><span class="marker"></span><span class="title">overview</span> {workspace.overview.length} spaces</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<ol class="grid" bind:this={grid}>
{#each workspace.overview as { space, recent }, index (space.id)}
<li>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="card"
class:at={index === workspace.overviewCursor}
class:current={space.id === workspace.spaceId}
data-index={index}
onclick={() => enter(space.id)}
onpointerenter={() => (workspace.overviewCursor = index)}
>
<div class="top">
<span class="index">{index < 10 ? (index + 1) % 10 : ''}</span>
<span class="name">{space.name}</span>
{#if space.has_board}<span class="tag">board</span>{/if}
<span class="count">{space.child_count}</span>
</div>
<ul>
{#each recent as doc (doc.id)}
<li>
<button
class="doc"
onclick={(event) => {
event.stopPropagation()
open(doc)
}}
>
<span class="text">{doc.name.trim() || doc.excerpt.trim() || doc.slug}</span>
<span class="when">{relative(doc.updated_at)}</span>
</button>
</li>
{:else}
<li class="empty">nothing written yet</li>
{/each}
</ul>
</div>
</li>
{/each}
</ol>
<footer class="label">h j k l move · 1 … 9 or enter go · w closes</footer>
</div>
<style>
.sheet {
display: flex;
flex-direction: column;
gap: 18px;
width: min(100%, 140ch);
max-height: 100%;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.grid {
list-style: none;
margin: 0;
padding: 2px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(30ch, 1fr));
gap: 10px;
overflow-y: auto;
}
/*
* A card is a miniature window: overlay border at rest, the active border
* gradient under the cursor. The space being worked in is the inverted one,
* as its pill is on the bar.
*/
.card {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
padding: 12px 12px 10px;
border: 1px solid transparent;
border-radius: var(--radius-m);
background:
linear-gradient(var(--surface), var(--surface)) padding-box,
linear-gradient(var(--overlay), var(--overlay)) border-box;
cursor: pointer;
}
.card.at {
background:
linear-gradient(var(--surface), var(--surface)) padding-box,
linear-gradient(135deg, var(--light), var(--light-dim)) border-box;
}
.top { display: flex; align-items: center; gap: 10px; }
.index {
display: grid;
place-items: center;
min-width: 22px;
height: 20px;
border-radius: 4px;
font-size: 11px;
color: var(--muted);
background: var(--surface-alt);
}
.current .index { background: var(--light); color: var(--ink); }
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-sans);
font-size: 15px;
color: var(--fg);
}
.at .name, .current .name { color: var(--light); }
.tag, .count {
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
}
.count { font-variant-numeric: tabular-nums; letter-spacing: 0; }
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
.doc {
display: flex;
align-items: baseline;
gap: 10px;
width: 100%;
padding: 4px 8px;
border: 0;
border-radius: var(--radius-s);
text-align: left;
font-size: 13px;
letter-spacing: 0;
text-transform: none;
color: var(--subtle);
}
.doc:active { transform: none; }
.text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-sans);
}
.when { flex: none; font-size: 10px; color: var(--muted); }
.empty { padding: 4px 8px; font-size: 12px; color: var(--muted); }
footer { font-size: 10px; }
</style>
+2
View File
@@ -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') },
+30 -9
View File
@@ -1,12 +1,16 @@
<script lang="ts">
import { api, type SharedDocument } from './api'
import { renderMarkdown } from './markdown'
import Outline from './Outline.svelte'
import { formatStamp, formatWeekday } from './util'
const { token, shareKey }: { token: string; shareKey: string } = $props()
let doc = $state<SharedDocument | null>(null)
let error = $state('')
let page = $state<HTMLElement>()
let article = $state<HTMLElement>()
const html = $derived(doc ? renderMarkdown(doc.body, { token, key: shareKey }) : '')
$effect(() => {
if (!shareKey) {
@@ -23,11 +27,11 @@
})
</script>
<main>
<main bind:this={page}>
{#if error}
<p class="alarm">{error}</p>
{:else if doc}
<article class="md resolve">
<article class="md resolve" bind:this={article}>
<div class="meta label rule">
<span class="marker"></span>
<span>{formatStamp(doc.created_at)} {formatWeekday(doc.created_at)}</span>
@@ -35,8 +39,11 @@
</div>
{#if doc.name.trim()}<h1>{doc.name}</h1>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html renderMarkdown(doc.body, { token, key: shareKey })}
{@html html}
</article>
<aside class="side">
<Outline root={article} scroller={page} source={html} />
</aside>
<footer class="label">— written in ~/workspace</footer>
{:else}
<p class="label">loading<span class="cursor">_</span></p>
@@ -44,19 +51,34 @@
</main>
<style>
/* The same page as the reading pane: a centred column, the outline in the margin. */
main {
height: 100%;
overflow-y: auto;
padding: 56px 20px 20vh;
display: flex;
flex-direction: column;
align-items: center;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 46rem) minmax(0, 1fr);
align-content: start;
align-items: start;
container-type: inline-size;
background:
radial-gradient(120% 70% at 30% 0%, #141713 0%, transparent 60%),
var(--base);
}
article { width: min(100%, 72ch); }
article, footer, p { grid-column: 2; }
article { max-width: none; }
.side {
grid-column: 3;
grid-row: 1;
position: sticky;
top: 0;
max-width: 30ch;
padding: 4px 0 0 44px;
}
@container (max-width: 1000px) {
.side { display: none; }
}
.meta { margin-bottom: 28px; font-size: 10px; }
.meta .marker { background: var(--light); }
@@ -65,6 +87,5 @@
h1 { margin-top: 0 !important; font-size: 2.1em !important; }
footer { width: min(100%, 72ch); margin-top: 56px; font-size: 10px; }
p { width: min(100%, 72ch); }
footer { margin-top: 56px; font-size: 10px; }
</style>
+1 -4
View File
@@ -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); }
+2
View File
@@ -29,6 +29,7 @@
{workspace.flash?.text ?? ''}
</span>
{#if workspace.fullscreen}<span class="flag">full</span>{/if}
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
<span class="hint">
{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; }
+8
View File
@@ -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<NodeSummary[]>('/spaces'),
overview: () => request<SpaceOverview[]>('/spaces/overview'),
createSpace: (name: string, has_board = true) =>
request<WorkspaceNode>('/spaces', { method: 'POST', body: body({ name, has_board }) }),
+3
View File
@@ -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) => {},
}
+103 -2
View File
@@ -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<Flash>(null)
overlay = $state<'none' | 'help' | 'media'>('none')
overlay = $state<'none' | 'help' | 'media' | 'overview'>('none')
media = $state<MediaFile[]>([])
overview = $state<SpaceOverview[]>([])
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<number, Spot> = 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 = ''
// 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
+13
View File
@@ -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)
}