From 26b3c686d2f72e18dbbdc8d34434521d16795214 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 22 Oct 2026 04:07:42 +0200 Subject: [PATCH] sheets: one Sheet component, and the info sheet over a document Every overlay is drawn in Sheet.svelte: the header, the close button, the footer of keys and the list cursor kept on screen, written once instead of in ten copies. Overlays open and close through openSheet and closeSheet. The outline, history and backlinks sheets and the share bar are one info sheet with a part for each, Tab between them. gi opens it, gO, gh and gb open it at their part, and so does a click on the line under the title. The buttons in the document header are gone; the meta line says when the document is public, and a phone gets edit and info under the text. --- README.md | 7 +- docs/wiki/Commands.md | 2 + docs/wiki/Sharing.md | 1 + docs/wiki/Spaces-and-Documents.md | 7 +- web/src/App.svelte | 25 ++- web/src/lib/Actions.svelte | 30 +--- web/src/lib/Backlinks.svelte | 90 ---------- web/src/lib/CardSheet.svelte | 47 +----- web/src/lib/DocumentPane.svelte | 141 +++++----------- web/src/lib/Headings.svelte | 98 ----------- web/src/lib/Help.svelte | 67 +++----- web/src/lib/History.svelte | 150 ----------------- web/src/lib/Info.svelte | 263 ++++++++++++++++++++++++++++++ web/src/lib/MediaBrowser.svelte | 62 +------ web/src/lib/Overview.svelte | 47 +----- web/src/lib/Sheet.svelte | 126 ++++++++++++++ web/src/lib/Sidebar.svelte | 2 +- web/src/lib/Trash.svelte | 64 +------- web/src/lib/Verify.svelte | 46 ++---- web/src/lib/actions.ts | 58 +++---- web/src/lib/commands.ts | 68 +++++--- web/src/lib/keymap.ts | 9 + web/src/lib/modes.ts | 10 +- web/src/lib/store.svelte.ts | 32 +++- 24 files changed, 642 insertions(+), 810 deletions(-) delete mode 100644 web/src/lib/Backlinks.svelte delete mode 100644 web/src/lib/Headings.svelte delete mode 100644 web/src/lib/History.svelte create mode 100644 web/src/lib/Info.svelte create mode 100644 web/src/lib/Sheet.svelte diff --git a/README.md b/README.md index a7519e0..94657af 100644 --- a/README.md +++ b/README.md @@ -128,10 +128,11 @@ it; in the document pane it is that document. In the browse pane: | `Ctrl-D` / `Ctrl-U` | half a page down / up the open document | | `zl` / `zh`, `zL` / `zH` | scroll the table, code block or formula at the top of the view sideways, a little or by half its width | | `]]` / `[[` | the next / previous heading of the open document, in the editor too | -| `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) | +| `gi` | the info sheet: the open document's headings, earlier versions, backlinks and share link — `Tab` goes from one part to the next (`:info`); clicking the line under the title opens it too | +| `gO` | the info sheet at the headings — `j`/`k`, `Enter` jumps there (`:outline`) | | `gx` | open the file under the cursor in a tab of its own | -| `gh` | the open document's earlier versions, as diffs — `j`/`k`, `Enter` brings one back (`:history`) | -| `gb` | the documents that link to the open one — `j`/`k`, `Enter` opens one (`:backlinks`) | +| `gh` | the info sheet at the earlier versions, as diffs — `j`/`k`, `Enter` brings one back (`:history`) | +| `gb` | the info sheet at the documents that link to the open one — `j`/`k`, `Enter` opens one (`:backlinks`) | | `F` | a letter over every link and button in view; typing it follows it — links out of the workspace open in a tab of their own | | `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password | | `o` | a new document here, straight into insert mode | diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index c9b0658..9f3cb9e 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -29,6 +29,7 @@ arguments and `↑` / `↓` recall earlier lines. | `Ctrl-D / Ctrl-U` | half a page down / up the open document | | `zl / zh · zL / zH` | the wide table or code block at the top of the view, sideways — a little, or half its width | | `]] / \[\[` | next / previous heading of the document — in the editor too | +| `gi` | the info sheet: the open document's headings, earlier versions, backlinks and sharing — Tab between them | | `gO` | the headings as a list: j/k, Enter jumps there | | `F` | hints over the links and buttons in view — type one to follow it | | `gf / gx` | in the editor: follow the link under the cursor / open it in a tab | @@ -188,6 +189,7 @@ do: `:w!`, `:space! `, `:import!`. | `:backlinks, :bl` | the documents that link to the open one — j/k, Enter opens one (gb) | | `:source` | show an open markdown file as written, or rendered again | | `:outline, :toc` | the open document's headings to pick from — gO, in the editor too | +| `:info [outline\|history\|backlinks\|share]` | what is known about the open document: its headings, earlier versions, backlinks and sharing — Tab between them (gi) | | `:search, :se [text]` | full-text search in this space (empty clears) | | `:search!` | full-text search across every space — Ctrl-P | | `:clear, :noh` | clear the search filter | diff --git a/docs/wiki/Sharing.md b/docs/wiki/Sharing.md index 052ae00..da60434 100644 --- a/docs/wiki/Sharing.md +++ b/docs/wiki/Sharing.md @@ -8,6 +8,7 @@ reader needs no account. | `s`, `:share` | share the document and copy its link; on a shared one, `s` asks before taking it back | | `y`, `:link` | make a new link for the same document; the old one stops working | | `:unshare` | stop sharing | +| `:info share` | the share part of the info sheet: whether the document is public, the shape of its link, and the same `s` and `y` | The link looks like `https://your-host/s/#`. The page renders the document read-only and serves only the pictures and videos it embeds right now: diff --git a/docs/wiki/Spaces-and-Documents.md b/docs/wiki/Spaces-and-Documents.md index b6bbadf..4d90fbd 100644 --- a/docs/wiki/Spaces-and-Documents.md +++ b/docs/wiki/Spaces-and-Documents.md @@ -66,12 +66,17 @@ linked to from anywhere behind the login. ![A document in the reader](../images/document.png) The line under the title says when it was written and last edited, how many -words it has, and how many documents link to it. In the reader: +words it has, how many documents link to it, and whether it is public. Click it, +or press `gi`, for the info sheet: the document's headings, its earlier +versions, the documents that link to it and its share link, one part at a time +— `Tab` goes to the next. `gO`, `gh` and `gb` open it at their own part. In the +reader: | key | what it does | | --- | --- | | `j` / `k`, `Ctrl-D` / `Ctrl-U` | scroll a line, or half a page | | `]]` / `[[` | the next / previous heading | +| `gi` | the info sheet | | `gO` | the headings as a list to jump to | | `zl` / `zh` | scroll a wide table or code block sideways | | `F` | a letter over every link and button in view; type it to follow | diff --git a/web/src/App.svelte b/web/src/App.svelte index 1940baf..c423882 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,4 +1,5 @@ -
-
- {node ? node.name.trim() || node.slug : 'nothing'} - -
-
    + +
      {#each actions as action (action.label)}
    • {/each}
    -
+ diff --git a/web/src/lib/CardSheet.svelte b/web/src/lib/CardSheet.svelte index 2b50bd3..f6a9936 100644 --- a/web/src/lib/CardSheet.svelte +++ b/web/src/lib/CardSheet.svelte @@ -2,17 +2,15 @@ /* * Enter on a card turns it over: a window floated over the board, as the * desktop floats one, with what the card holds on its back — labels, the - * description, the checklist. The keys live in App.svelte with the others; + * description, the checklist. The keys live in actions.ts with the others; * this draws and lets a pointer do the same. */ - import Keys from './Keys.svelte' + import Sheet from './Sheet.svelte' import { followLink } from './commands' import { renderMarkdown } from './markdown' import { workspace } from './store.svelte' import { formatDay, labelHue, urgency } from './util' - let steps = $state() - const card = $derived(workspace.card) const list = $derived(workspace.list) const lists = $derived(workspace.board?.lists.length ?? 0) @@ -22,13 +20,9 @@ // A card deleted or moved out of sight under the sheet takes the sheet with it. $effect(() => { - if (!card) workspace.overlay = 'none' + if (!card) workspace.closeSheet() }) - $effect(() => { - const at = workspace.taskCursor - steps?.querySelector(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' }) - }) /** A link in the description leaves the card for what it leads to, as one in a document does. */ function follow(event: MouseEvent) { @@ -51,14 +45,7 @@ {#if card} -
-
- - card - {workspace.boardSpace?.slug ?? ''} / {list?.name ?? ''}{#if role}{role}{/if} - - -
+

{card.title}

@@ -113,7 +100,7 @@ {#if card.checklist.length}
{/if} -
    +
      {#each card.checklist as task, index (index)}
    • pick(index)}> @@ -126,32 +113,10 @@
- - -
+ {/if} diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index cb1fac0..44e6a12 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -14,10 +14,9 @@ export function scrollHelp(by: 1 | -1 | 'pagedown' | 'pageup' | 'start' | 'end') { if (!pages) return // Columns that run off the right edge scroll sideways; on a phone they - // stack, and the sheet around them scrolls down instead. + // stack, and scroll down instead. const sideways = pages.scrollWidth > pages.clientWidth - const scroller = sideways ? pages : pages.parentElement - if (!scroller) return + const scroller = pages const axis = sideways ? 'left' : 'top' if (by === 'start' || by === 'end') { scroller.scrollTo({ [axis]: by === 'start' ? 0 : 1e9, behavior: 'smooth' }) @@ -40,9 +39,8 @@ -
-
- help {count} keys and commands - - -
+ + {#snippet actions()} + + {/snippet}
{#each shown as [title, rows] (title)} @@ -142,31 +138,9 @@

nothing matches "{helpFilter.text}"

{/each}
- - -
+ diff --git a/web/src/lib/History.svelte b/web/src/lib/History.svelte deleted file mode 100644 index 476c235..0000000 --- a/web/src/lib/History.svelte +++ /dev/null @@ -1,150 +0,0 @@ - - - - -
-
- - history - {workspace.open?.name.trim() || workspace.open?.slug} · {workspace.history.length} earlier - - {#if workspace.revision} - - {/if} - -
- -
-
    - {#each workspace.history as summary, index (summary.id)} - -
  • workspace.guard(() => workspace.showRevision(index))}> - {formatStamp(summary.saved_at)} - {relative(summary.saved_at)} · {reason(summary)} · {summary.size} chars -
  • - {:else} -
  • no earlier versions yet — one is kept when a save replaces the text.
  • - {/each} -
- -
- {#if workspace.revision} - {#if workspace.revision.name !== workspace.draft.name} -
- # {workspace.draft.name}
-
+ # {workspace.revision.name}
- {/if} - {#each rows as row, index (index)} - {#if row.kind === 'fold'} -
··· {row.count} unchanged
- {:else} -
{row.kind === 'gone' ? '-' : row.kind === 'added' ? '+' : ' '} {row.text}
- {/if} - {/each} - {#if !changed} -
the text is the same as now
- {/if} - {/if} -
-
- - - goes, + comes back -
- - diff --git a/web/src/lib/Info.svelte b/web/src/lib/Info.svelte new file mode 100644 index 0000000..f1e8022 --- /dev/null +++ b/web/src/lib/Info.svelte @@ -0,0 +1,263 @@ + + + + + + {#snippet actions()} + {#if section === 'history' && workspace.revision} + + {/if} + {/snippet} + + + + {#if section === 'outline'} +
    + {#each workspace.outline as heading, index (index)} + +
  1. goHeading(index)} + onpointerenter={() => (workspace.outlineCursor = index)} + > + {heading.text} +
  2. + {:else} +
  3. no headings in this document — a line that starts with # is one.
  4. + {/each} +
+ {:else if section === 'history'} +
+
    + {#each workspace.history as summary, index (summary.id)} + +
  • workspace.guard(() => workspace.showRevision(index))}> + {formatStamp(summary.saved_at)} + {relative(summary.saved_at)} · {reason(summary)} · {summary.size} chars +
  • + {:else} +
  • no earlier versions yet — one is kept when a save replaces the text.
  • + {/each} +
+ +
+ {#if workspace.revision} + {#if workspace.revision.name !== workspace.draft.name} +
- # {workspace.draft.name}
+
+ # {workspace.revision.name}
+ {/if} + {#each rows as row, index (index)} + {#if row.kind === 'fold'} +
··· {row.count} unchanged
+ {:else} +
{row.kind === 'gone' ? '-' : row.kind === 'added' ? '+' : ' '} {row.text}
+ {/if} + {/each} + {#if !changed} +
the text is the same as now
+ {/if} + {/if} +
+
+ {:else if section === 'backlinks'} +
    + {#each workspace.backlinks as link, index (link.id)} + +
  • (workspace.backlinksCursor = index)} + onclick={() => openBacklink(link)} + > + {link.name.trim() || link.slug} + {link.path} + {#if link.context}{link.context}{/if} +
  • + {:else} +
  • nothing links here yet — a link is [text](/n/…) or [[title]].
  • + {/each} +
+ {:else} + + {/if} + + {#snippet footer()} + {#if section === 'history'}- goes, + comes back{/if} + {/snippet} +
+ + diff --git a/web/src/lib/MediaBrowser.svelte b/web/src/lib/MediaBrowser.svelte index 86ddc8f..206cd09 100644 --- a/web/src/lib/MediaBrowser.svelte +++ b/web/src/lib/MediaBrowser.svelte @@ -11,7 +11,7 @@ export function insertMedia(file: MediaFile) { if (!workspace.editing) return workspace.say('insert works while writing — i on a document first', 'error') hooks.insertText(embedSnippet(file)) - workspace.overlay = 'none' + workspace.closeSheet() } export async function copyMedia(file: MediaFile) { @@ -33,16 +33,9 @@ -
-
- media {workspace.media.length} files - -
- -
    + +
      {#each workspace.media as file, index (file.id)} -
    • (workspace.mediaCursor = index)}> +
    • (workspace.mediaCursor = index)}>
      {#if file.encrypted} sealed @@ -92,43 +80,10 @@
    • nothing uploaded yet — attach a file from the editor.
    • {/each}
    - - -
+ diff --git a/web/src/lib/Overview.svelte b/web/src/lib/Overview.svelte index d1dddee..23385e1 100644 --- a/web/src/lib/Overview.svelte +++ b/web/src/lib/Overview.svelte @@ -2,10 +2,10 @@ /* * `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 + * keys live in actions.ts with the others; this only draws and tells them * how many cards fit in a row. */ - import Keys from './Keys.svelte' + import Sheet from './Sheet.svelte' import { api, type NodeSummary } from './api' import { hooks } from './commands' import { workspace } from './store.svelte' @@ -19,19 +19,14 @@ return () => (hooks.overviewColumns = () => 1) }) - $effect(() => { - const index = workspace.overviewCursor - grid?.querySelector(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' }) - }) - function enter(spaceId: number) { - workspace.overlay = 'none' + workspace.closeSheet() void workspace.guard(() => workspace.enterSpace(spaceId)) } /** A recent document opens in place, in its own space. */ function open(doc: NodeSummary) { - workspace.overlay = 'none' + workspace.closeSheet() void workspace.guard(async () => { const node = await api.getNode(doc.id) await workspace.openPath(node.path.map((crumb) => crumb.slug).join('/')) @@ -39,12 +34,7 @@ } -
-
- overview {workspace.overview.length} spaces - -
- +
    {#each workspace.overview as { space, recent }, index (space.id)}
  1. @@ -86,28 +76,9 @@
  2. {/each}
- - -
+ diff --git a/web/src/lib/Sheet.svelte b/web/src/lib/Sheet.svelte new file mode 100644 index 0000000..28a2665 --- /dev/null +++ b/web/src/lib/Sheet.svelte @@ -0,0 +1,126 @@ + + +
+
+ {title} {detail} + {@render actions?.()} + +
+ + {@render children()} + + {#if keys.length || footer} + + {/if} +
+ + diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 0ccd810..ab0cd5d 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -53,7 +53,7 @@ /* A tap opens a row, so on a phone the other verbs need a door of their own. */ function actions(index: number) { workspace.cursor = index - workspace.overlay = 'actions' + workspace.openSheet('actions') } /* Inside a folder, a phone has no h key and no crumbs on the bar: this is the way up. */ diff --git a/web/src/lib/Trash.svelte b/web/src/lib/Trash.svelte index 1cffe2c..a8f17f3 100644 --- a/web/src/lib/Trash.svelte +++ b/web/src/lib/Trash.svelte @@ -3,7 +3,7 @@ import { workspace } from './store.svelte' /* - * What a row's buttons do, out here so the keys in App.svelte do the same: + * What a row's buttons do, out here so the keys in actions.ts do the same: * Enter or r restores, x deletes for good. */ export function restoreTrashed(item: TrashItem) { @@ -23,32 +23,23 @@ -
-
- trash {workspace.trash.length} deleted + + {#snippet actions()} {#if workspace.trash.length > 0} {/if} - -
+ {/snippet} -
    +
      {#each workspace.trash as item, index (item.id)} -
    • (workspace.trashCursor = index)}> +
    • (workspace.trashCursor = index)}> {item.kind}
      @@ -68,51 +59,14 @@
    • the trash is empty.
    • {/each}
    - - -
+ diff --git a/web/src/lib/Verify.svelte b/web/src/lib/Verify.svelte index 2d5d88d..f56d4f0 100644 --- a/web/src/lib/Verify.svelte +++ b/web/src/lib/Verify.svelte @@ -6,6 +6,7 @@ * or a click beside it — abandons the login. */ import { onDestroy } from 'svelte' + import Sheet from './Sheet.svelte' import { workspace } from './store.svelte' const ORIGIN = 'https://verify.proton.me' @@ -25,11 +26,11 @@ const payload = data?.payload if (data?.type === 'HUMAN_VERIFICATION_SUCCESS' && typeof payload?.token === 'string') { asked?.resolve(payload.token) - workspace.overlay = 'none' + workspace.closeSheet() } else if (data?.type === 'RESIZE' && typeof payload?.height === 'number') { height = Math.min(Math.max(payload.height, 320), 900) } else if (data?.type === 'CLOSE') { - workspace.overlay = 'none' + workspace.closeSheet() } } @@ -38,36 +39,21 @@ -
-
- proton prove you are human to log in - -
+ {#if asked} - +
+ +
{/if} -
+ diff --git a/web/src/lib/actions.ts b/web/src/lib/actions.ts index 4588140..c024d2e 100644 --- a/web/src/lib/actions.ts +++ b/web/src/lib/actions.ts @@ -8,13 +8,12 @@ * share an action and differ in direction. Returning `false` lets the key * on to the next context down, as if it had not been bound here. */ -import { cycleTile, focusTile, followLink, hooks, runCommand } from './commands' +import { cycleTile, focusTile, followLink, hooks, openInfo, runCommand } from './commands' import { bindings, type Context } from './keymap' import { focusHelpFilter, scrollHelp } from './Help.svelte' import { copyMedia, insertMedia, removeMedia } from './MediaBrowser.svelte' import { purgeTrashed, restoreTrashed } from './Trash.svelte' -import { openBacklink } from './Backlinks.svelte' -import { moveHistory, restoreShown } from './History.svelte' +import { goHeading, moveHistory, openBacklink, renewLink, restoreShown, toggleSharing, turnInfo } from './Info.svelte' import { workspace, type SpecialTile } from './store.svelte' import { dwindle, formatDay, neighbour, type Direction } from './util' @@ -45,7 +44,7 @@ export async function openOverview() { workspace.overview.findIndex((entry) => entry.space.id === workspace.spaceId), 0, ) - workspace.overlay = 'overview' + workspace.openSheet('overview') } /* @@ -136,7 +135,7 @@ export const actions: { [C in Context]: Record, Run> } = { // stays up under it. close: () => { if (workspace.describing || workspace.scratchEditing) return - workspace.overlay = 'none' + workspace.closeSheet() }, }, @@ -164,19 +163,21 @@ export const actions: { [C in Context]: Record, Run> } = { }, go: (key) => goToSpace(spaceIndex(key)), enter: () => goToSpace(workspace.overviewCursor), - close: () => void (workspace.overlay = 'none'), + close: () => workspace.closeSheet(), }, - /** `gO`'s list: j/k down the headings, Enter to the one picked. */ + /** The info sheet: Tab from one of its parts to the next. */ + info: { + next: () => turnInfo(1), + previous: () => turnInfo(-1), + }, + + /** Its headings: j/k down them, Enter to the one picked. */ outline: { down: () => step('outlineCursor', workspace.outline.length, 1), up: () => step('outlineCursor', workspace.outline.length, -1), bottom: () => step('outlineCursor', workspace.outline.length, Infinity), - go: () => { - const heading = workspace.outline[workspace.outlineCursor] - workspace.overlay = 'none' - heading?.go() - }, + go: () => goHeading(), }, /** The media list: j/k down the files, i inserts, y copies, x deletes. */ @@ -213,7 +214,7 @@ export const actions: { [C in Context]: Record, Run> } = { }, }, - /** The backlinks: j/k through the documents that link here, Enter opens one. */ + /** Its backlinks: j/k through the documents that link here, Enter opens one. */ backlinks: { down: () => step('backlinksCursor', workspace.backlinks.length, 1), up: () => step('backlinksCursor', workspace.backlinks.length, -1), @@ -224,7 +225,7 @@ export const actions: { [C in Context]: Record, Run> } = { }, }, - /** The history: j/k through the versions, Enter brings the one shown back. */ + /** Its history: j/k through the versions, Enter brings the one shown back. */ history: { down: () => moveHistory(1), up: () => moveHistory(-1), @@ -232,6 +233,12 @@ export const actions: { [C in Context]: Record, Run> } = { restore: () => void restoreShown(), }, + /** Its sharing: the same s and y as on the document itself. */ + share: { + toggle: () => void toggleSharing(workspace.open), + link: () => void renewLink(workspace.open), + }, + /* * The card sheet reads the list keys over its checklist, and the board's * card keys for the card itself — `D`, `H`/`L`, `R` act on it as they do @@ -259,7 +266,7 @@ export const actions: { [C in Context]: Record, Run> } = { sendLeft: () => guard(() => workspace.sendCard(-1)), sendRight: () => guard(() => workspace.sendCard(1)), notes: () => { - workspace.overlay = 'none' + workspace.closeSheet() guard(() => workspace.openCard()) }, line: () => line(':'), @@ -353,6 +360,7 @@ export const actions: { [C in Context]: Record, Run> } = { previousHeading: () => { if (reading() && !hooks.jumpHeading(-1)) hooks.scrollReader('top') }, + info: () => void openInfo(), outline: () => void runCommand('outline'), history: () => void runCommand('history'), backlinks: () => void runCommand('backlinks'), @@ -393,21 +401,9 @@ export const actions: { [C in Context]: Record, Run> } = { delete: () => void runCommand('d'), // A delete goes to the trash, so it can be undone the way vim undoes. undo: () => guard(() => workspace.undoDelete()), - // Sharing is a key away; taking a link back from its readers asks first. - share: () => - void (async () => { - const target = workspace.target - if (target?.shared && !(await workspace.ask(`stop sharing "${target.name.trim() || target.slug}"? the link stops working`))) return - void runCommand(target?.shared ? 'unshare' : 'share') - })(), - // A vim hand presses y to yank; here it retires the link readers have, - // so it asks first. - link: () => - void (async () => { - const target = workspace.target - if (!target?.shared) return void runCommand('link') - if (await workspace.ask(`new share link for "${target.name.trim() || target.slug}"? the current one stops working`)) void runCommand('link') - })(), + share: () => void toggleSharing(workspace.target), + // A vim hand presses y to yank; here it retires the link readers have. + link: () => void renewLink(workspace.target), // Through the results, as vim's `n` goes through the matches; the // search itself stays until `:noh`. next: () => guard(() => workspace.nextResult(1)), @@ -547,7 +543,7 @@ export const actions: { [C in Context]: Record, Run> } = { function goToSpace(index: number) { const entry = workspace.overview[index] if (!entry) return - workspace.overlay = 'none' + workspace.closeSheet() guard(() => workspace.enterSpace(entry.space.id)) } diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 2f5cde3..974ce9c 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -1,8 +1,9 @@ import { tick } from 'svelte' import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api' -import { workspace } from './store.svelte' +import { infoSections } from './Info.svelte' +import { workspace, type InfoSection } from './store.svelte' import { emptyTrash } from './Trash.svelte' -import { formatDay, parseDay, parseDue, relative, sealedNote, titleHref, type Heading, type ScrollAmount, type SidewaysAmount } from './util' +import { formatDay, parseDay, parseDue, relative, titleHref, type Heading, type ScrollAmount, type SidewaysAmount } from './util' export type { ScrollAmount, SidewaysAmount } @@ -74,6 +75,26 @@ const closeFocused = () => { return workspace.focused } +/** + * The info sheet over the open document, at `section` or where it was last + * left. Every part is read now, so each one's count is in its tab: the + * headings from the pane, the versions and backlinks from the server. + */ +export async function openInfo(section?: InfoSection) { + if (!requireOpen()) return + const { headings, current } = hooks.headings() + workspace.outline = headings + workspace.outlineCursor = current + await workspace.guard(async () => { + // One after the other: each writes what is typed first, and two saves + // of the same text at once would race. + await workspace.loadHistory() + await workspace.loadBacklinks() + if (section) workspace.infoSection = section + workspace.openSheet('info') + }) +} + const requireOpen = () => { if (!workspace.open) { workspace.say('no document open', 'error') @@ -914,11 +935,7 @@ export const commands: CommandSpec[] = [ group: 'documents', help: "the open document's earlier versions as diffs — j/k, Enter brings one back (gh)", run: async () => { - if (!requireOpen()) return - await workspace.guard(async () => { - await workspace.loadHistory() - workspace.overlay = 'history' - }) + await openInfo('history') }, }, { @@ -927,11 +944,7 @@ export const commands: CommandSpec[] = [ group: 'documents', help: 'the documents that link to the open one — j/k, Enter opens one (gb)', run: async () => { - if (!requireOpen()) return - await workspace.guard(async () => { - await workspace.loadBacklinks() - workspace.overlay = 'backlinks' - }) + await openInfo('backlinks') }, }, { @@ -946,7 +959,7 @@ export const commands: CommandSpec[] = [ await workspace.guard(async () => { await workspace.loadTrash() workspace.trashCursor = 0 - workspace.overlay = 'trash' + workspace.openSheet('trash') }) }, }, @@ -997,10 +1010,7 @@ export const commands: CommandSpec[] = [ const id = node?.id if (!id) return workspace.say('nothing selected', 'error') if (!node?.shared) return workspace.say('not shared — :share first', 'error') - await workspace.guard(async () => { - const { url, sealed } = await workspace.mintShareLink(id) - workspace.say(`new link → ${url} (copied) — the previous one is dead${sealedNote(sealed)}`) - }) + await workspace.guard(() => workspace.renewShareLink(id)) }, }, { @@ -1098,7 +1108,7 @@ export const commands: CommandSpec[] = [ run: () => workspace.guard(async () => { await workspace.loadMedia() workspace.mediaCursor = 0 - workspace.overlay = 'media' + workspace.openSheet('media') }), }, { @@ -1106,13 +1116,18 @@ export const commands: CommandSpec[] = [ aliases: ['toc'], group: 'documents', help: "the open document's headings to pick from — gO, in the editor too", - run: () => { - if (!requireOpen()) return - const { headings, current } = hooks.headings() - if (headings.length === 0) return workspace.say('no headings in this document') - workspace.outline = headings - workspace.outlineCursor = current - workspace.overlay = 'outline' + run: () => openInfo('outline'), + }, + { + name: 'info', + args: '[outline|history|backlinks|share]', + group: 'documents', + help: 'what is known about the open document: its headings, earlier versions, backlinks and sharing — Tab between them (gi)', + complete: () => infoSections, + run: (arg) => { + const section = arg.trim() + if (section && !infoSections.includes(section as InfoSection)) return workspace.say('usage: :info [outline|history|backlinks|share]', 'error') + return openInfo((section || undefined) as InfoSection | undefined) }, }, { @@ -1431,7 +1446,8 @@ export const commands: CommandSpec[] = [ group: 'view', help: 'show the key and command reference', run: () => { - workspace.overlay = workspace.overlay === 'help' ? 'none' : 'help' + if (workspace.overlay === 'help') workspace.closeSheet() + else workspace.openSheet('help') }, }, { diff --git a/web/src/lib/keymap.ts b/web/src/lib/keymap.ts index c710e91..94e0a2a 100644 --- a/web/src/lib/keymap.ts +++ b/web/src/lib/keymap.ts @@ -18,11 +18,13 @@ export type Context = | 'sheet' | 'help' | 'overview' + | 'info' | 'outline' | 'media' | 'trash' | 'backlinks' | 'history' + | 'share' | 'card' | 'drawer' | 'workspace' @@ -87,6 +89,7 @@ export const bindings = [ { context: 'tree', keys: ['zh', 'zl', 'zH', 'zL'], action: 'sideways', label: 'zl / zh · zL / zH', help: 'the wide table or code block at the top of the view, sideways — a little, or half its width' }, { context: 'tree', keys: [']]'], action: 'nextHeading', label: ']] / [[', help: 'next / previous heading of the document — in the editor too' }, { context: 'tree', keys: ['[['], action: 'previousHeading' }, + { context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks and sharing — Tab between them" }, { context: 'tree', keys: ['gO'], action: 'outline', help: 'the headings as a list: j/k, Enter jumps there' }, { context: 'tree', keys: ['F'], action: 'hints', help: 'hints over the links and buttons in view — type one to follow it' }, { context: 'tree', label: 'gf / gx', help: 'in the editor: follow the link under the cursor / open it in a tab' }, @@ -236,6 +239,9 @@ export const bindings = [ { context: 'overview', keys: ['Enter'], action: 'enter' }, { context: 'overview', keys: ['w', 'q'], action: 'close', caps: ['w', 'esc'], hint: 'close' }, + { context: 'info', keys: ['Tab'], action: 'next', caps: ['tab'], hint: 'next part' }, + { context: 'info', keys: ['Shift-Tab'], action: 'previous' }, + { context: 'outline', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' }, { context: 'outline', keys: ['k', 'ArrowUp'], action: 'up' }, { context: 'outline', keys: ['G'], action: 'bottom' }, @@ -264,6 +270,9 @@ export const bindings = [ { context: 'history', keys: ['G'], action: 'bottom' }, { context: 'history', keys: ['Enter'], action: 'restore', hint: 'restore' }, + { context: 'share', keys: ['s', 'Enter'], action: 'toggle', caps: ['s'], hint: 'share · stop' }, + { context: 'share', keys: ['y'], action: 'link', hint: 'new link' }, + // --- the editor's own keymap, described only --------------------------------------- { context: 'editor', label: 'vim', help: 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes' }, { context: 'editor', label: 'drop · paste', help: 'an image, video or audio file attaches it' }, diff --git a/web/src/lib/modes.ts b/web/src/lib/modes.ts index 09fb191..cd8d3bf 100644 --- a/web/src/lib/modes.ts +++ b/web/src/lib/modes.ts @@ -4,7 +4,7 @@ * a context takes it. * * always Ctrl-S, Alt-h/j/k/l — heard in the editor too - * a sheet help, trash, the card's back …; nothing under one hears a key + * a sheet help, info, trash, the card's back …; nothing under one hears a key * drawer the scratchpad, while it is down * workspace Esc out of fullscreen, Q * writing a document being written takes every plain key back @@ -19,16 +19,18 @@ import { actions, type Run } from './actions' import { bindings, type Binding, type Context } from './keymap' import { workspace } from './store.svelte' -/** The contexts that are sheets: one is up when the overlay of that name is. */ -const sheets = new Set(['help', 'overview', 'outline', 'media', 'trash', 'backlinks', 'history', 'card']) +/** The sheets with keys of their own, under the same name as the sheet. */ +const sheets = new Set(['help', 'overview', 'media', 'trash', 'card']) /** The stack, the topmost first. */ export function stack(): Context[] { const modes: Context[] = ['always'] const overlay = workspace.overlay if (overlay !== 'none') { + // The info sheet hears the keys of the part on show, then its own Tab. + if (overlay === 'info') modes.push(workspace.infoSection, 'info') // While the description is written, the card's back has no keys of its own. - if (sheets.has(overlay) && !(overlay === 'card' && workspace.describing)) modes.push(overlay as Context) + else if (sheets.has(overlay) && !(overlay === 'card' && workspace.describing)) modes.push(overlay as Context) return [...modes, 'sheet'] } if (workspace.scratchOpen && !workspace.scratchEditing) modes.push('drawer') diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index a98d264..fcd8a69 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -33,6 +33,10 @@ export type Pane = 'list' | 'document' export type View = 'tree' | 'special' /** The tile of the special workspace that has focus. */ export type SpecialTile = 'board' | 'agenda' | 'calendar' +/** What floats over the tiles; one at a time. */ +export type Sheet = 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'info' | 'trash' | 'card' +/** The info sheet's parts: what the open document holds, and what is around it. */ +export type InfoSection = 'outline' | 'history' | 'backlinks' | 'share' export type Flash = { text: string; kind: 'info' | 'error' } | null /** @@ -321,7 +325,10 @@ class Workspace { pane = $state('list') flash = $state(null) - overlay = $state<'none' | 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks' | 'card'>('none') + /** The sheet over everything, if any; `openSheet` and `closeSheet` move it. */ + overlay = $state<'none' | Sheet>('none') + /** The part of the info sheet on show; it stays for the next time it opens. */ + infoSection = $state('outline') /** Which of the special workspace's tiles the keys go to. */ specialFocus = $state('board') /** @@ -469,7 +476,7 @@ class Workspace { resolve(token) }, } - this.overlay = 'verify' + this.openSheet('verify') }) } @@ -1152,7 +1159,7 @@ class Workspace { tile.draft = { ...tile.draft, name: revision.name, body: revision.body } tile.dirty = true await this.save({ quiet: true, restore: true, tile }) - this.overlay = 'none' + this.closeSheet() this.say(`restored the version from ${formatStamp(revision.saved_at)} — :history has the one it replaced`) } @@ -1730,7 +1737,7 @@ class Workspace { if (!this.card) return this.say('no card selected', 'error') this.taskCursor = 0 this.describing = false - this.overlay = 'card' + this.openSheet('card') } get scratchNote(): ScratchNote | undefined { @@ -1869,6 +1876,16 @@ class Workspace { this.overlay = 'none' } + openSheet(sheet: Sheet) { + this.overlay = sheet + } + + /** Esc, q, a click beside it: the sheet away — the card's asks first. */ + closeSheet() { + if (this.overlay === 'card') return this.closeCardSheet() + this.overlay = 'none' + } + /** `e`: write the description, from what it says now. */ describe() { const card = this.card @@ -2117,6 +2134,13 @@ class Workspace { return { url, sealed } } + /** `:link`: a new link for a shared document, copied; the old one stops working. */ + async renewShareLink(id: number) { + const { url, sealed } = await this.mintShareLink(id) + this.say(`new link → ${url} (copied) — the previous one is dead${sealedNote(sealed)}`, sealed > 0 ? 'error' : undefined) + for (const tile of this.tilesWith(id)) tile.open = await api.getNode(id) + } + async copy(text: string) { try { await navigator.clipboard.writeText(text)