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)