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.
This commit is contained in:
nvrl committed 2026-10-22 04:07:42 +02:00
1 parent 0eabcb2333
commit 26b3c686d2
24 files changed
+642 -810

No files matched your search

+4 -3
View File
@@ -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 | | `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 | | `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 | | `]]` / `[[` | 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 | | `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`) | | `gh` | the info sheet at the 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`) | | `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 | | `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 | | `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 | | `o` | a new document here, straight into insert mode |
+2
View File
@@ -29,6 +29,7 @@ arguments and `↑` / `↓` recall earlier lines.
| `Ctrl-D / Ctrl-U` | half a page down / up the open document | | `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 | | `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 | | `]] / \[\[` | 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 | | `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 | | `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 | | `gf / gx` | in the editor: follow the link under the cursor / open it in a tab |
@@ -188,6 +189,7 @@ do: `:w!`, `:space! <name>`, `:import!`.
| `:backlinks, :bl` | the documents that link to the open one — j/k, Enter opens one (gb) | | `: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 | | `: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 | | `: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, :se [text]` | full-text search in this space (empty clears) |
| `:search!` | full-text search across every space — Ctrl-P | | `:search!` | full-text search across every space — Ctrl-P |
| `:clear, :noh` | clear the search filter | | `:clear, :noh` | clear the search filter |
+1
View File
@@ -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 | | `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 | | `y`, `:link` | make a new link for the same document; the old one stops working |
| `:unshare` | stop sharing | | `: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/<token>#<key>`. The page renders the The link looks like `https://your-host/s/<token>#<key>`. The page renders the
document read-only and serves only the pictures and videos it embeds right now: document read-only and serves only the pictures and videos it embeds right now:
+6 -1
View File
@@ -66,12 +66,17 @@ linked to from anywhere behind the login.
![A document in the reader](../images/document.png) ![A document in the reader](../images/document.png)
The line under the title says when it was written and last edited, how many 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 | | key | what it does |
| --- | --- | | --- | --- |
| `j` / `k`, `Ctrl-D` / `Ctrl-U` | scroll a line, or half a page | | `j` / `k`, `Ctrl-D` / `Ctrl-U` | scroll a line, or half a page |
| `]]` / `[[` | the next / previous heading | | `]]` / `[[` | the next / previous heading |
| `gi` | the info sheet |
| `gO` | the headings as a list to jump to | | `gO` | the headings as a list to jump to |
| `zl` / `zh` | scroll a wide table or code block sideways | | `zl` / `zh` | scroll a wide table or code block sideways |
| `F` | a letter over every link and button in view; type it to follow | | `F` | a letter over every link and button in view; type it to follow |
+18 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Component } from 'svelte'
import { openOverview } from './lib/actions' import { openOverview } from './lib/actions'
import { hooks } from './lib/commands' import { hooks } from './lib/commands'
import { keysOf } from './lib/keymap' import { keysOf } from './lib/keymap'
@@ -11,18 +12,16 @@
import CommandLine from './lib/CommandLine.svelte' import CommandLine from './lib/CommandLine.svelte'
import DocumentPane from './lib/DocumentPane.svelte' import DocumentPane from './lib/DocumentPane.svelte'
import FilePane from './lib/FilePane.svelte' import FilePane from './lib/FilePane.svelte'
import Headings from './lib/Headings.svelte'
import Help from './lib/Help.svelte' import Help from './lib/Help.svelte'
import Info from './lib/Info.svelte'
import Hints from './lib/Hints.svelte' import Hints from './lib/Hints.svelte'
import Verify from './lib/Verify.svelte' import Verify from './lib/Verify.svelte'
import Login from './lib/Login.svelte' import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte' import Overview from './lib/Overview.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte' import MediaBrowser from './lib/MediaBrowser.svelte'
import Trash from './lib/Trash.svelte' import Trash from './lib/Trash.svelte'
import Backlinks from './lib/Backlinks.svelte'
import CardSheet from './lib/CardSheet.svelte' import CardSheet from './lib/CardSheet.svelte'
import Scratch from './lib/Scratch.svelte' import Scratch from './lib/Scratch.svelte'
import History from './lib/History.svelte'
import Actions from './lib/Actions.svelte' import Actions from './lib/Actions.svelte'
import Prompt from './lib/Prompt.svelte' import Prompt from './lib/Prompt.svelte'
import SecretPrompt from './lib/SecretPrompt.svelte' import SecretPrompt from './lib/SecretPrompt.svelte'
@@ -30,7 +29,7 @@
import Shared from './lib/Shared.svelte' import Shared from './lib/Shared.svelte'
import Sidebar from './lib/Sidebar.svelte' import Sidebar from './lib/Sidebar.svelte'
import StatusBar from './lib/StatusBar.svelte' import StatusBar from './lib/StatusBar.svelte'
import { workspace } from './lib/store.svelte' import { workspace, type Sheet } from './lib/store.svelte'
import { dwindle } from './lib/util' import { dwindle } from './lib/util'
/* /*
@@ -54,6 +53,18 @@
let importRoot = false let importRoot = false
let shell = $state<HTMLDivElement>() let shell = $state<HTMLDivElement>()
let overlayEl = $state<HTMLDivElement>() let overlayEl = $state<HTMLDivElement>()
/** What each sheet is drawn with. */
const sheets: Record<Sheet, Component> = {
help: Help,
overview: Overview,
actions: Actions,
verify: Verify,
info: Info,
trash: Trash,
card: CardSheet,
media: MediaBrowser,
}
let boardEl = $state<HTMLDivElement>() let boardEl = $state<HTMLDivElement>()
/** The first half of `gg`, `dd`, `]]` …, shown until the second comes. */ /** The first half of `gg`, `dd`, `]]` …, shown until the second comes. */
let pending = $state('') let pending = $state('')
@@ -369,10 +380,10 @@
{/if} {/if}
{#if workspace.overlay !== 'none'} {#if workspace.overlay !== 'none'}
{@const Shown = sheets[workspace.overlay]}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex --> <!-- 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 === 'card' ? workspace.closeCardSheet() : (workspace.overlay = 'none'))} <div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && workspace.closeSheet()}>
> <Shown />
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else if workspace.overlay === 'card'}<CardSheet />{:else}<MediaBrowser />{/if}
</div> </div>
{/if} {/if}
</div> </div>
+6 -24
View File
@@ -6,6 +6,7 @@
* drift apart. * drift apart.
*/ */
import { isContainer } from './api' import { isContainer } from './api'
import Sheet from './Sheet.svelte'
import { hooks, runCommand } from './commands' import { hooks, runCommand } from './commands'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
@@ -52,42 +53,23 @@
}) })
function act(action: Action) { function act(action: Action) {
workspace.overlay = 'none' workspace.closeSheet()
// The list is what the commands act on while it has the focus. // The list is what the commands act on while it has the focus.
workspace.pane = 'list' workspace.pane = 'list'
void action.run() void action.run()
} }
</script> </script>
<div class="sheet"> <Sheet title={node ? node.name.trim() || node.slug : 'nothing'} width={48}>
<header> <ul class="items">
<span class="label rule"><span class="marker"></span><span class="title">{node ? node.name.trim() || node.slug : 'nothing'}</span></span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<ul>
{#each actions as action (action.label)} {#each actions as action (action.label)}
<li><button class:danger={action.danger} onclick={() => act(action)}>{action.label}</button></li> <li><button class:danger={action.danger} onclick={() => act(action)}>{action.label}</button></li>
{/each} {/each}
</ul> </ul>
</div> </Sheet>
<style> <style>
.sheet { .items { gap: 4px; }
display: flex;
flex-direction: column;
gap: 14px;
width: min(100%, 48ch);
max-height: 100%;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 18px 20px;
animation: resolve var(--t-slow) var(--ease-snap) both;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; min-width: 0; }
.title { color: var(--light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; }
li button { li button {
width: 100%; width: 100%;
padding: 12px 14px; padding: 12px 14px;
-90
View File
@@ -1,90 +0,0 @@
<script lang="ts" module>
import type { Backlink } from './api'
import { workspace } from './store.svelte'
/** What Enter and a click do: the linking document is opened. */
export function openBacklink(link: Backlink) {
workspace.overlay = 'none'
return workspace.guard(() => workspace.openPath(link.path))
}
</script>
<script lang="ts">
import Keys from './Keys.svelte'
let sheet = $state<HTMLDivElement>()
$effect(() => {
const index = workspace.backlinksCursor
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
</script>
<div class="sheet" bind:this={sheet}>
<header>
<span class="label rule">
<span class="marker"></span><span class="title">backlinks</span>
{workspace.open?.name.trim() || workspace.open?.slug} · linked from {workspace.backlinks.length}
</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<ul>
{#each workspace.backlinks as link, index (link.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class:at={index === workspace.backlinksCursor}
data-index={index}
onpointerenter={() => (workspace.backlinksCursor = index)}
onclick={() => openBacklink(link)}
>
<span class="name">{link.name.trim() || link.slug}</span>
<span class="muted">{link.path}</span>
{#if link.context}<span class="context">{link.context}</span>{/if}
</li>
{:else}
<li class="muted">nothing links here yet — a link is [text](/n/…) or [[title]].</li>
{/each}
</ul>
<Keys contexts={['backlinks', 'sheet']} />
</div>
<style>
/* A free-standing panel, as the desktop draws one: hull, one rim, the frame's radius. */
.sheet {
display: flex;
flex-direction: column;
gap: 18px;
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;
animation: resolve var(--t-slow) var(--ease-snap) both;
width: min(100%, 92ch);
overflow: hidden;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
li {
display: flex;
flex-direction: column;
padding: 6px 8px;
border-radius: var(--radius-m);
cursor: pointer;
transition: background-color var(--t-base) var(--ease-glide);
}
li:hover { background: var(--surface); }
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); font-family: var(--font-sans); font-size: 14px; }
.muted { font-size: 11px; }
.context { font-size: 12px; color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>
+6 -41
View File
@@ -2,17 +2,15 @@
/* /*
* Enter on a card turns it over: a window floated over the board, as the * 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 * 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. * this draws and lets a pointer do the same.
*/ */
import Keys from './Keys.svelte' import Sheet from './Sheet.svelte'
import { followLink } from './commands' import { followLink } from './commands'
import { renderMarkdown } from './markdown' import { renderMarkdown } from './markdown'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
import { formatDay, labelHue, urgency } from './util' import { formatDay, labelHue, urgency } from './util'
let steps = $state<HTMLUListElement>()
const card = $derived(workspace.card) const card = $derived(workspace.card)
const list = $derived(workspace.list) const list = $derived(workspace.list)
const lists = $derived(workspace.board?.lists.length ?? 0) 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. // A card deleted or moved out of sight under the sheet takes the sheet with it.
$effect(() => { $effect(() => {
if (!card) workspace.overlay = 'none' if (!card) workspace.closeSheet()
}) })
$effect(() => {
const at = workspace.taskCursor
steps?.querySelector<HTMLElement>(`[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. */ /** A link in the description leaves the card for what it leads to, as one in a document does. */
function follow(event: MouseEvent) { function follow(event: MouseEvent) {
@@ -51,14 +45,7 @@
</script> </script>
{#if card} {#if card}
<div class="sheet"> <Sheet title="card" detail="{workspace.boardSpace?.slug ?? ''} / {list?.name ?? ''}{role ? ` · ${role}` : ''}" width={76} cursor={workspace.taskCursor} keys={['card']}>
<header>
<span class="label rule">
<span class="marker"></span><span class="title">card</span>
{workspace.boardSpace?.slug ?? ''} / {list?.name ?? ''}{#if role}<span class="role">{role}</span>{/if}
</span>
<button onclick={() => workspace.closeCardSheet()}>esc</button>
</header>
<div class="scroll"> <div class="scroll">
<h2 class:done={card.done_at !== null}>{card.title}</h2> <h2 class:done={card.done_at !== null}>{card.title}</h2>
@@ -113,7 +100,7 @@
{#if card.checklist.length} {#if card.checklist.length}
<div class="progress"><span style="width: {(ticked / card.checklist.length) * 100}%"></span></div> <div class="progress"><span style="width: {(ticked / card.checklist.length) * 100}%"></span></div>
{/if} {/if}
<ul bind:this={steps}> <ul>
{#each card.checklist as task, index (index)} {#each card.checklist as task, index (index)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li class:at={index === workspace.taskCursor && !workspace.describing} class:ticked={task.done} data-index={index} onclick={() => pick(index)}> <li class:at={index === workspace.taskCursor && !workspace.describing} class:ticked={task.done} data-index={index} onclick={() => pick(index)}>
@@ -126,32 +113,10 @@
</ul> </ul>
</section> </section>
</div> </div>
</Sheet>
<Keys contexts={['card']} />
</div>
{/if} {/if}
<style> <style>
/* A float, as Hyprland draws one over the tiles: hull, one rim, the frame's radius. */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 100%;
width: min(100%, 76ch);
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
animation: resolve var(--t-slow) var(--ease-snap) both;
overflow: hidden;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.role { margin-left: 8px; color: var(--muted); font-size: 10px; letter-spacing: 0.18em; }
.scroll { display: flex; flex-direction: column; gap: 18px; min-height: 0; overflow-y: auto; } .scroll { display: flex; flex-direction: column; gap: 18px; min-height: 0; overflow-y: auto; }
+39 -102
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { followLink, hooks, runCommand, type ScrollAmount, type SidewaysAmount } from './commands' import { followLink, hooks, openInfo, runCommand, type ScrollAmount, type SidewaysAmount } from './commands'
import { renderMarkdown } from './markdown' import { renderMarkdown } from './markdown'
import Outline from './Outline.svelte' import Outline from './Outline.svelte'
import { workspace, type Tile } from './store.svelte' import { workspace, type Tile } from './store.svelte'
@@ -26,13 +26,6 @@
const words = $derived( const words = $derived(
(tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length, (tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
) )
/*
* Only the token half of a share link is recoverable — the key lives in the
* fragment, and the server kept nothing but its hash — so the bar shows the
* link's shape and offers a fresh one rather than a copy of the old.
*/
const sharePath = $derived(doc.share_token ? `${location.origin}/s/${doc.share_token}#…` : null)
let reader = $state<HTMLElement>() let reader = $state<HTMLElement>()
let article = $state<HTMLElement>() let article = $state<HTMLElement>()
let title = $state<HTMLInputElement>() let title = $state<HTMLInputElement>()
@@ -154,46 +147,23 @@
{#if tile.dirty}<span class="dirty">modified</span>{/if} {#if tile.dirty}<span class="dirty">modified</span>{/if}
</div> </div>
<div class="row"> <!--
<dl class="meta"> The readout is the way to everything else about the document: its
<dt>written</dt> headings, its history, what links to it and who can read it — gi.
<dd>{formatStamp(tile.draft.created_at)} {formatWeekday(tile.draft.created_at)}</dd> -->
<dt>edited</dt> <button class="meta" title="info (gi)" onclick={() => openInfo()}>
<dd>{relative(doc.updated_at)}</dd> <span class="field"><span class="caption">written</span>{formatStamp(tile.draft.created_at)} {formatWeekday(tile.draft.created_at)}</span>
<dt>words</dt> <span class="field"><span class="caption">edited</span>{relative(doc.updated_at)}</span>
<dd>{words}<span class="unit">words</span></dd> <span class="field"><span class="caption">words</span>{words}<span class="unit">words</span></span>
{#if doc.backlinks > 0} {#if doc.backlinks > 0}
<dt>linked</dt> <span class="field"><span class="caption">linked</span>from {doc.backlinks}</span>
<dd><button class="linked" title="what links here (gb)" onclick={() => runCommand('backlinks')}>from {doc.backlinks}</button></dd> {/if}
{/if} {#if doc.shared}
</dl> <span class="field public"><span class="ring"></span>public</span>
{/if}
<div class="actions"> </button>
{#if tile.editing}
<button onclick={() => runCommand('w')}>:w</button>
<button onclick={() => runCommand('upload')}>attach</button>
<button onclick={() => runCommand('wq')}>done</button>
{:else}
<button onclick={() => runCommand('e')}>edit</button>
{/if}
<button class:on={doc.shared} onclick={() => runCommand(doc.shared ? 'unshare' : 'share')}>
{doc.shared ? 'unshare' : 'share'}
</button>
<button class="danger" onclick={() => runCommand('d')}>delete</button>
<button class="close" onclick={() => runCommand('q')}>close</button>
</div>
</div>
</header> </header>
{#if sharePath}
<div class="sharebar">
<span class="ring"></span>
<span class="label public">public</span>
<span class="link">{sharePath}</span>
<button onclick={() => runCommand('link')}>new link</button>
</div>
{/if}
{#if tile.editing} {#if tile.editing}
<input <input
class="title" class="title"
@@ -241,6 +211,13 @@
<Outline root={article} scroller={reader} source={html} /> <Outline root={article} scroller={reader} source={html} />
</aside> </aside>
</div> </div>
<!-- A thumb has no i and no gi. -->
{#if isTouchDevice}
<div class="mobilebar">
<button onclick={() => runCommand('e')}>edit</button>
<button onclick={() => openInfo()}>info</button>
</div>
{/if}
{/if} {/if}
</section> </section>
@@ -285,55 +262,22 @@
} }
.dirty { flex: none; color: var(--light); } .dirty { flex: none; color: var(--light); }
.row { /* Read as a line of the header, and a way into the info sheet. */
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.meta { .meta {
all: unset;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px 8px; align-items: baseline;
margin: 0; gap: 4px 18px;
font-size: 11px;
min-width: 0; min-width: 0;
font-size: 11px;
color: var(--fg);
cursor: pointer;
} }
.meta dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; } .meta:hover .caption, .meta:focus-visible .caption { color: var(--light); }
.meta dd { margin: 0 10px 0 0; color: var(--fg); } .caption { margin-right: 8px; color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
/* Read as the rest of the line, and still a way to the backlinks. */ .public { display: flex; align-items: center; gap: 6px; color: var(--light); }
.meta .linked { all: unset; cursor: pointer; text-decoration: underline; text-decoration-color: var(--overlay); text-underline-offset: 4px; } .ring { width: 7px; height: 7px; flex: none; border: 1.5px solid var(--light); border-radius: 999px; }
.meta .linked:hover, .meta .linked:focus-visible { color: var(--light); }
.actions { display: flex; gap: 4px; flex-wrap: wrap; flex: none; }
.actions .on { color: var(--light); border-color: var(--light-dim); }
.actions .danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
.sharebar {
display: flex;
gap: 10px;
align-items: center;
margin: 0 24px 8px 28px;
padding: 6px 6px 6px 12px;
border: 1px solid var(--rim);
border-radius: var(--radius-m);
background: var(--surface);
font-size: 12px;
overflow-x: auto;
white-space: nowrap;
flex: none;
}
.ring {
width: 8px;
height: 8px;
flex: none;
border: 1.5px solid var(--light);
border-radius: 999px;
}
.public { color: var(--light); }
.sharebar .link { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
.title { .title {
flex: none; flex: none;
@@ -400,9 +344,8 @@
.unit { display: none; } .unit { display: none; }
/* /*
* A phone: a back button where the bar's crumbs would be, the readout on one * A phone: a back button where the bar's crumbs would be, and the readout
* line without its captions, and the actions as one row of equal buttons * on one line without its captions. Closing is the back button's job.
* big enough for a thumb. Closing is the back button's job.
*/ */
@media (max-width: 767px) { @media (max-width: 767px) {
header { padding: 10px 14px 10px 8px; gap: 8px; } header { padding: 10px 14px 10px 8px; gap: 8px; }
@@ -417,16 +360,10 @@
border: 0; border: 0;
color: var(--light); color: var(--light);
} }
.row { flex-direction: column; align-items: stretch; gap: 10px; padding-left: 6px; } .meta { flex-wrap: nowrap; gap: 0; padding-left: 6px; overflow: hidden; white-space: nowrap; }
.meta { flex-wrap: nowrap; gap: 0; overflow: hidden; white-space: nowrap; } .caption { display: none; }
.meta dt { display: none; } .field + .field::before { content: '·'; margin: 0 8px; color: var(--muted); }
.meta dd { margin: 0; }
.meta dd + dt + dd::before { content: '·'; margin: 0 8px; color: var(--muted); }
.unit { display: inline; margin-left: 0.5ch; } .unit { display: inline; margin-left: 0.5ch; }
.actions { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 6px; }
.actions button { height: 36px; }
.actions .close { display: none; }
.sharebar { margin: 0 16px 8px; }
.title { margin: 8px 16px 4px; } .title { margin: 8px 16px 4px; }
.reading { padding: 14px 16px 30vh; grid-template-columns: minmax(0, 1fr); } .reading { padding: 14px 16px 30vh; grid-template-columns: minmax(0, 1fr); }
.reading > article { grid-column: 1; } .reading > article { grid-column: 1; }
-98
View File
@@ -1,98 +0,0 @@
<script lang="ts">
/*
* `gO`: the headings of what is on screen, as a list to walk — the outline
* in the margin, for when there is no margin, or no pointer to click it
* with. The keys live in App.svelte with the other overlays'.
*/
import Keys from './Keys.svelte'
import { workspace } from './store.svelte'
let sheet = $state<HTMLDivElement>()
$effect(() => {
const index = workspace.outlineCursor
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
// The shallowest level in the document is the left edge, so a text that
// starts at `##` is not indented by a level it never uses.
const top = $derived(Math.min(...workspace.outline.map((heading) => heading.level)))
function go(index: number) {
const heading = workspace.outline[index]
workspace.overlay = 'none'
heading?.go()
}
</script>
<div class="sheet" bind:this={sheet}>
<header>
<span class="label rule"><span class="marker"></span><span class="title">outline</span> {workspace.outline.length} headings</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<ol>
{#each workspace.outline as heading, index (index)}
<li style="--depth: {heading.level - top}">
<button
class="row"
class:at={index === workspace.outlineCursor}
data-index={index}
onclick={() => go(index)}
onpointerenter={() => (workspace.outlineCursor = index)}
>{heading.text}</button>
</li>
{/each}
</ol>
<Keys contexts={['outline', 'sheet']} />
</div>
<style>
.sheet {
display: flex;
flex-direction: column;
gap: 16px;
width: min(100%, 64ch);
max-height: 100%;
overflow-y: auto;
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); }
ol { list-style: none; margin: 0; padding: 0; }
li { padding-left: calc(var(--depth) * 18px); }
.row {
display: block;
width: 100%;
margin: 1px 0;
padding: 6px 10px;
border: 0;
border-radius: var(--radius-s);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-sans);
font-size: 14px;
letter-spacing: 0;
text-transform: none;
color: var(--fg);
}
.row:hover { background: var(--surface-alt); border-color: transparent; }
.row:active { transform: none; }
.row.at { background: var(--light); color: var(--ink); }
@media (max-width: 767px) {
.sheet { padding: 14px; gap: 14px; }
}
</style>
+20 -47
View File
@@ -14,10 +14,9 @@
export function scrollHelp(by: 1 | -1 | 'pagedown' | 'pageup' | 'start' | 'end') { export function scrollHelp(by: 1 | -1 | 'pagedown' | 'pageup' | 'start' | 'end') {
if (!pages) return if (!pages) return
// Columns that run off the right edge scroll sideways; on a phone they // 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 sideways = pages.scrollWidth > pages.clientWidth
const scroller = sideways ? pages : pages.parentElement const scroller = pages
if (!scroller) return
const axis = sideways ? 'left' : 'top' const axis = sideways ? 'left' : 'top'
if (by === 'start' || by === 'end') { if (by === 'start' || by === 'end') {
scroller.scrollTo({ [axis]: by === 'start' ? 0 : 1e9, behavior: 'smooth' }) scroller.scrollTo({ [axis]: by === 'start' ? 0 : 1e9, behavior: 'smooth' })
@@ -40,9 +39,8 @@
<script lang="ts"> <script lang="ts">
import { commands, groups, type CommandSpec, type Group } from './commands' import { commands, groups, type CommandSpec, type Group } from './commands'
import Keys from './Keys.svelte' import Sheet from './Sheet.svelte'
import { rows, sections as keySections } from './keymap' import { rows, sections as keySections } from './keymap'
import { workspace } from './store.svelte'
const titles: Record<Group, string> = { const titles: Record<Group, string> = {
tree: 'spaces & folders', tree: 'spaces & folders',
@@ -107,22 +105,20 @@
} }
</script> </script>
<div class="sheet"> <Sheet title="help" detail="{count} keys and commands" width={0} fill keys={['help', 'sheet']}>
<header> {#snippet actions()}
<span class="label rule"><span class="marker"></span><span class="title">help</span> {count} keys and commands</span> <label class="filter" class:on={helpFilter.text !== ''}>
<label class="filter" class:on={helpFilter.text !== ''}> <span class="slash">/</span>
<span class="slash">/</span> <input
<input bind:this={field}
bind:this={field} bind:value={helpFilter.text}
bind:value={helpFilter.text} onkeydown={filterKey}
onkeydown={filterKey} placeholder="filter"
placeholder="filter" spellcheck="false"
spellcheck="false" autocomplete="off"
autocomplete="off" />
/> </label>
</label> {/snippet}
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<div class="cols" bind:this={pages}> <div class="cols" bind:this={pages}>
{#each shown as [title, rows] (title)} {#each shown as [title, rows] (title)}
@@ -142,31 +138,9 @@
<p class="none">nothing matches "{helpFilter.text}"</p> <p class="none">nothing matches "{helpFilter.text}"</p>
{/each} {/each}
</div> </div>
</Sheet>
<Keys contexts={['help', 'sheet']} />
</div>
<style> <style>
/* A free-standing panel, as the desktop draws one: hull, one rim, the
frame's radius — as big as the porthole, so nothing hides below it. */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
height: 100%;
min-height: 0;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 18px 22px 14px;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.filter { .filter {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -241,10 +215,9 @@
.none { margin: 0; color: var(--subtle); font-size: 12px; } .none { margin: 0; color: var(--subtle); font-size: 12px; }
/* A phone stacks the columns and scrolls the sheet down. */ /* A phone stacks the columns and scrolls them down. */
@media (max-width: 767px) { @media (max-width: 767px) {
.sheet { overflow-y: auto; } .cols { columns: auto; overflow-y: auto; }
.cols { flex: none; columns: auto; overflow: visible; }
.filter { width: 14ch; } .filter { width: 14ch; }
} }
</style> </style>
-150
View File
@@ -1,150 +0,0 @@
<script lang="ts" module>
import type { RevisionSummary } from './api'
import { workspace } from './store.svelte'
/** What Enter and the button do: the shown revision becomes the document again. */
export async function restoreShown() {
const revision = workspace.revision
if (!revision) return
if (!(await workspace.ask(`bring back the version from ${formatStamp(revision.saved_at)}? the text now is kept in :history`))) return
await workspace.guard(() => workspace.restoreRevision(revision))
}
export function moveHistory(by: number) {
const last = Math.max(workspace.history.length - 1, 0)
const index = Math.min(Math.max(workspace.historyCursor + by, 0), last)
if (index !== workspace.historyCursor) void workspace.guard(() => workspace.showRevision(index))
}
const REASONS: Record<string, string> = {
edit: 'saved over',
force: 'written over by :w!',
sftp: 'saved over the mount',
restore: 'replaced by a restore',
}
export const reason = (summary: RevisionSummary) => REASONS[summary.reason] ?? summary.reason
</script>
<script lang="ts">
import Keys from './Keys.svelte'
import { diffLines, folded } from './diff'
import { formatStamp, relative } from './util'
let list = $state<HTMLUListElement>()
$effect(() => {
const index = workspace.historyCursor
list?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
// Against the text as it is now: what restoring would take out and put back.
const rows = $derived(workspace.revision ? folded(diffLines(workspace.draft.body, workspace.revision.body)) : [])
const changed = $derived(rows.some((row) => row.kind === 'gone' || row.kind === 'added'))
</script>
<div class="sheet">
<header>
<span class="label rule">
<span class="marker"></span><span class="title">history</span>
{workspace.open?.name.trim() || workspace.open?.slug} · {workspace.history.length} earlier
</span>
{#if workspace.revision}
<button onclick={restoreShown}>restore</button>
{/if}
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<div class="split">
<ul bind:this={list}>
{#each workspace.history as summary, index (summary.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li class:at={index === workspace.historyCursor} data-index={index} onclick={() => workspace.guard(() => workspace.showRevision(index))}>
<span class="when">{formatStamp(summary.saved_at)}</span>
<span class="muted">{relative(summary.saved_at)} · {reason(summary)} · {summary.size} chars</span>
</li>
{:else}
<li class="muted">no earlier versions yet — one is kept when a save replaces the text.</li>
{/each}
</ul>
<div class="diff">
{#if workspace.revision}
{#if workspace.revision.name !== workspace.draft.name}
<div class="row gone">- # {workspace.draft.name}</div>
<div class="row added">+ # {workspace.revision.name}</div>
{/if}
{#each rows as row, index (index)}
{#if row.kind === 'fold'}
<div class="row fold">··· {row.count} unchanged</div>
{:else}
<div class="row {row.kind}">{row.kind === 'gone' ? '-' : row.kind === 'added' ? '+' : ' '} {row.text}</div>
{/if}
{/each}
{#if !changed}
<div class="row fold">the text is the same as now</div>
{/if}
{/if}
</div>
</div>
<Keys contexts={['history', 'sheet']}><span class="muted">- goes, + comes back</span></Keys>
</div>
<style>
/* A free-standing panel, as the desktop draws one: hull, one rim, the frame's radius. */
.sheet {
display: flex;
flex-direction: column;
gap: 18px;
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;
animation: resolve var(--t-slow) var(--ease-snap) both;
width: min(100%, 140ch);
overflow: hidden;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.split { display: flex; gap: 18px; min-height: 0; flex: 1; }
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; width: 34ch; flex: none; }
li {
display: flex;
flex-direction: column;
padding: 6px 8px;
border-radius: var(--radius-m);
cursor: pointer;
transition: background-color var(--t-base) var(--ease-glide);
}
li:hover { background: var(--surface); }
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
.when { color: var(--fg); font-size: 13px; }
.muted { font-size: 11px; }
.diff {
flex: 1;
min-width: 0;
overflow: auto;
border: 1px solid var(--rim);
border-radius: var(--radius-s);
padding: 8px 0;
font-size: 12px;
}
.row { white-space: pre-wrap; overflow-wrap: anywhere; padding: 0 10px; }
.row.gone { background: color-mix(in oklab, var(--alarm) 16%, transparent); }
.row.added { background: color-mix(in oklab, var(--light) 14%, transparent); }
.row.fold { color: var(--muted); }
@media (max-width: 767px) {
.split { flex-direction: column; }
ul { width: auto; max-height: 30%; }
}
</style>
+263
View File
@@ -0,0 +1,263 @@
<script lang="ts" module>
import type { Backlink, NodeSummary, RevisionSummary } from './api'
import { workspace, type InfoSection } from './store.svelte'
import { formatStamp } from './util'
/*
* What the keys in actions.ts do here too, so a key and a click are the
* same thing. The info sheet holds what is known about the open document
* besides its text: its headings, its earlier versions, what links to it,
* and whether it is shared.
*/
export const infoSections: InfoSection[] = ['outline', 'history', 'backlinks', 'share']
/** Tab and Shift-Tab: the next part along, round the ends. */
export function turnInfo(by: number) {
const at = infoSections.indexOf(workspace.infoSection)
workspace.infoSection = infoSections[(at + by + infoSections.length) % infoSections.length]
}
/** Enter on a heading: the sheet away, and the text scrolled there. */
export function goHeading(index = workspace.outlineCursor) {
const heading = workspace.outline[index]
workspace.closeSheet()
heading?.go()
}
/** What Enter and the button do: the shown revision becomes the document again. */
export async function restoreShown() {
const revision = workspace.revision
if (!revision) return
if (!(await workspace.ask(`bring back the version from ${formatStamp(revision.saved_at)}? the text now is kept in the history`))) return
await workspace.guard(() => workspace.restoreRevision(revision))
}
export function moveHistory(by: number) {
const last = Math.max(workspace.history.length - 1, 0)
const index = Math.min(Math.max(workspace.historyCursor + by, 0), last)
if (index !== workspace.historyCursor) void workspace.guard(() => workspace.showRevision(index))
}
/** Enter on a backlink: the linking document is opened. */
export function openBacklink(link: Backlink) {
workspace.closeSheet()
return workspace.guard(() => workspace.openPath(link.path))
}
// Sharing is a key away; taking a link back from its readers asks first.
export async function toggleSharing(node: NodeSummary | null | undefined) {
if (!node) return workspace.say('nothing selected', 'error')
if (node.shared && !(await workspace.ask(`stop sharing "${node.name.trim() || node.slug}"? the link stops working`))) return
await workspace.guard(() => workspace.toggleShare(node.id))
}
// A new link retires the one readers have, so it asks first.
export async function renewLink(node: NodeSummary | null | undefined) {
if (!node) return workspace.say('nothing selected', 'error')
if (!node.shared) return workspace.say('not shared — s shares it', 'error')
if (!(await workspace.ask(`new share link for "${node.name.trim() || node.slug}"? the current one stops working`))) return
await workspace.guard(() => workspace.renewShareLink(node.id))
}
const REASONS: Record<string, string> = {
edit: 'saved over',
force: 'written over by :w!',
sftp: 'saved over the mount',
restore: 'replaced by a restore',
}
const reason = (summary: RevisionSummary) => REASONS[summary.reason] ?? summary.reason
</script>
<script lang="ts">
import Sheet from './Sheet.svelte'
import { diffLines, folded } from './diff'
import { relative } from './util'
const doc = $derived(workspace.open)
const section = $derived(workspace.infoSection)
// The shallowest level in the document is the left edge, so a text that
// starts at `##` is not indented by a level it never uses.
const top = $derived(Math.min(...workspace.outline.map((heading) => heading.level)))
// Against the text as it is now: what restoring would take out and put back.
const rows = $derived(section === 'history' && workspace.revision ? folded(diffLines(workspace.draft.body, workspace.revision.body)) : [])
const changed = $derived(rows.some((row) => row.kind === 'gone' || row.kind === 'added'))
/*
* Only the token half of a share link is recoverable — the key lives in the
* fragment, and the server kept nothing but its hash — so the sheet shows
* the link's shape and offers a fresh one rather than a copy of the old.
*/
const sharePath = $derived(doc?.share_token ? `${location.origin}/s/${doc.share_token}#…` : null)
const tabs = $derived<[InfoSection, string][]>([
['outline', `outline ${workspace.outline.length}`],
['history', `history ${workspace.history.length}`],
['backlinks', `linked from ${workspace.backlinks.length}`],
['share', doc?.shared ? 'shared' : 'private'],
])
const cursor = $derived(
{ outline: workspace.outlineCursor, history: workspace.historyCursor, backlinks: workspace.backlinksCursor, share: undefined }[section],
)
</script>
<Sheet title="info" detail={doc?.name.trim() || doc?.slug || ''} width={120} fill={section === 'history'} {cursor} keys={[section, 'info', 'sheet']}>
{#snippet actions()}
{#if section === 'history' && workspace.revision}
<button onclick={restoreShown}>restore</button>
{/if}
{/snippet}
<nav class="tabs">
{#each tabs as [name, text] (name)}
<button class:on={name === section} onclick={() => (workspace.infoSection = name)}>{text}</button>
{/each}
</nav>
{#if section === 'outline'}
<ol class="items">
{#each workspace.outline as heading, index (index)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class="item"
class:at={index === workspace.outlineCursor}
style:padding-left="{8 + (heading.level - top) * 18}px"
data-index={index}
onclick={() => goHeading(index)}
onpointerenter={() => (workspace.outlineCursor = index)}
>
<span class="name">{heading.text}</span>
</li>
{:else}
<li class="muted">no headings in this document — a line that starts with # is one.</li>
{/each}
</ol>
{:else if section === 'history'}
<div class="split">
<ul class="items versions">
{#each workspace.history as summary, index (summary.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li class="item" class:at={index === workspace.historyCursor} data-index={index} onclick={() => workspace.guard(() => workspace.showRevision(index))}>
<span class="when">{formatStamp(summary.saved_at)}</span>
<span class="muted">{relative(summary.saved_at)} · {reason(summary)} · {summary.size} chars</span>
</li>
{:else}
<li class="muted">no earlier versions yet — one is kept when a save replaces the text.</li>
{/each}
</ul>
<div class="diff">
{#if workspace.revision}
{#if workspace.revision.name !== workspace.draft.name}
<div class="row gone">- # {workspace.draft.name}</div>
<div class="row added">+ # {workspace.revision.name}</div>
{/if}
{#each rows as row, index (index)}
{#if row.kind === 'fold'}
<div class="row fold">··· {row.count} unchanged</div>
{:else}
<div class="row {row.kind}">{row.kind === 'gone' ? '-' : row.kind === 'added' ? '+' : ' '} {row.text}</div>
{/if}
{/each}
{#if !changed}
<div class="row fold">the text is the same as now</div>
{/if}
{/if}
</div>
</div>
{:else if section === 'backlinks'}
<ul class="items">
{#each workspace.backlinks as link, index (link.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class="item"
class:at={index === workspace.backlinksCursor}
data-index={index}
onpointerenter={() => (workspace.backlinksCursor = index)}
onclick={() => openBacklink(link)}
>
<span class="name">{link.name.trim() || link.slug}</span>
<span class="muted">{link.path}</span>
{#if link.context}<span class="context">{link.context}</span>{/if}
</li>
{:else}
<li class="muted">nothing links here yet — a link is [text](/n/…) or [[title]].</li>
{/each}
</ul>
{:else}
<div class="share">
{#if sharePath}
<div class="address">
<span class="ring"></span>
<span class="label public">public</span>
<span class="link">{sharePath}</span>
</div>
<p class="muted">anyone with the link can read it. the link exists in full only when it is made — a new one is copied, and the old one stops working.</p>
<div class="buttons">
<button onclick={() => renewLink(doc)}>new link</button>
<button class="danger" onclick={() => toggleSharing(doc)}>stop sharing</button>
</div>
{:else}
<p class="muted">private — only you can read it. sharing makes a link that cannot be guessed, and copies it.</p>
<div class="buttons">
<button onclick={() => toggleSharing(doc)}>share</button>
</div>
{/if}
</div>
{/if}
{#snippet footer()}
{#if section === 'history'}<span class="muted">- goes, + comes back</span>{/if}
{/snippet}
</Sheet>
<style>
.tabs { display: flex; flex-wrap: wrap; gap: 4px; flex: none; }
.tabs button { text-transform: none; letter-spacing: 0; }
.tabs .on { background: var(--light); border-color: var(--light); color: var(--ink); }
.split { display: flex; gap: 18px; min-height: 0; flex: 1; }
.versions { width: 34ch; flex: none; }
.when { color: var(--fg); font-size: 13px; }
.diff {
flex: 1;
min-width: 0;
overflow: auto;
border: 1px solid var(--rim);
border-radius: var(--radius-s);
padding: 8px 0;
font-size: 12px;
}
.row { white-space: pre-wrap; overflow-wrap: anywhere; padding: 0 10px; }
.row.gone { background: color-mix(in oklab, var(--alarm) 16%, transparent); }
.row.added { background: color-mix(in oklab, var(--light) 14%, transparent); }
.row.fold { color: var(--muted); }
.context { font-size: 12px; color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.share { display: flex; flex-direction: column; gap: 12px; font-size: 12px; }
.share p { margin: 0; }
.address {
display: flex;
gap: 10px;
align-items: center;
padding: 6px 12px;
border: 1px solid var(--rim);
border-radius: var(--radius-m);
background: var(--surface);
overflow-x: auto;
white-space: nowrap;
}
.ring { width: 8px; height: 8px; flex: none; border: 1.5px solid var(--light); border-radius: 999px; }
.public { color: var(--light); }
.address .link { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
.buttons { display: flex; gap: 6px; }
@media (max-width: 767px) {
.split { flex-direction: column; }
.versions { width: auto; max-height: 30%; }
}
</style>
+7 -55
View File
@@ -11,7 +11,7 @@
export function insertMedia(file: MediaFile) { export function insertMedia(file: MediaFile) {
if (!workspace.editing) return workspace.say('insert works while writing — i on a document first', 'error') if (!workspace.editing) return workspace.say('insert works while writing — i on a document first', 'error')
hooks.insertText(embedSnippet(file)) hooks.insertText(embedSnippet(file))
workspace.overlay = 'none' workspace.closeSheet()
} }
export async function copyMedia(file: MediaFile) { export async function copyMedia(file: MediaFile) {
@@ -33,16 +33,9 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import Keys from './Keys.svelte' import Sheet from './Sheet.svelte'
import { formatBytes, formatStamp } from './util' import { formatBytes, formatStamp } from './util'
let sheet = $state<HTMLDivElement>()
$effect(() => {
const index = workspace.mediaCursor
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
const usage = (nodeIds: number[], fileNode: number | null) => { const usage = (nodeIds: number[], fileNode: number | null) => {
const embeds = nodeIds.map((id) => `#${id}`).join(' ') const embeds = nodeIds.map((id) => `#${id}`).join(' ')
if (fileNode !== null) return embeds ? `in the tree · ${embeds}` : 'in the tree' if (fileNode !== null) return embeds ? `in the tree · ${embeds}` : 'in the tree'
@@ -50,16 +43,11 @@
} }
</script> </script>
<div class="sheet" bind:this={sheet}> <Sheet title="media" detail="{workspace.media.length} files" cursor={workspace.mediaCursor} keys={['media', 'sheet']} hide={workspace.editing ? [] : ['insert']}>
<header> <ul class="items">
<span class="label rule"><span class="marker"></span><span class="title">media</span> {workspace.media.length} files</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<ul>
{#each workspace.media as file, index (file.id)} {#each workspace.media as file, index (file.id)}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li class:at={index === workspace.mediaCursor} data-index={index} onpointerenter={() => (workspace.mediaCursor = index)}> <li class="item" class:at={index === workspace.mediaCursor} data-index={index} onpointerenter={() => (workspace.mediaCursor = index)}>
<div class="thumb"> <div class="thumb">
{#if file.encrypted} {#if file.encrypted}
<span class="label">sealed</span> <span class="label">sealed</span>
@@ -92,43 +80,10 @@
<li class="muted">nothing uploaded yet — attach a file from the editor.</li> <li class="muted">nothing uploaded yet — attach a file from the editor.</li>
{/each} {/each}
</ul> </ul>
</Sheet>
<Keys contexts={['media', 'sheet']} hide={workspace.editing ? [] : ['insert']} />
</div>
<style> <style>
/* A free-standing panel, as the desktop draws one: hull, one rim, the frame's radius. */ .items > .item { flex-direction: row; align-items: center; gap: 14px; cursor: default; }
.sheet {
display: flex;
flex-direction: column;
gap: 18px;
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;
animation: resolve var(--t-slow) var(--ease-snap) both;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.sheet { width: min(100%, 92ch); overflow: hidden; }
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
li {
display: flex;
align-items: center;
gap: 14px;
padding: 6px 8px;
border-radius: var(--radius-m);
transition: background-color var(--t-base) var(--ease-glide);
}
li:hover { background: var(--surface); }
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
.thumb { .thumb {
width: 52px; width: 52px;
@@ -145,9 +100,6 @@
.thumb img { width: 100%; height: 100%; object-fit: cover; } .thumb img { width: 100%; height: 100%; object-fit: cover; }
.info { display: flex; flex-direction: column; min-width: 0; flex: 1; font-size: 12px; } .info { display: flex; flex-direction: column; min-width: 0; flex: 1; font-size: 12px; }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); font-family: var(--font-sans); font-size: 14px; }
.muted { font-size: 11px; }
.actions { display: flex; gap: 4px; flex: none; } .actions { display: flex; gap: 4px; flex: none; }
.danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
</style> </style>
+6 -41
View File
@@ -2,10 +2,10 @@
/* /*
* `w`: every space at once, the way the desktop's overview shows every * `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 * 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. * how many cards fit in a row.
*/ */
import Keys from './Keys.svelte' import Sheet from './Sheet.svelte'
import { api, type NodeSummary } from './api' import { api, type NodeSummary } from './api'
import { hooks } from './commands' import { hooks } from './commands'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
@@ -19,19 +19,14 @@
return () => (hooks.overviewColumns = () => 1) return () => (hooks.overviewColumns = () => 1)
}) })
$effect(() => {
const index = workspace.overviewCursor
grid?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
function enter(spaceId: number) { function enter(spaceId: number) {
workspace.overlay = 'none' workspace.closeSheet()
void workspace.guard(() => workspace.enterSpace(spaceId)) void workspace.guard(() => workspace.enterSpace(spaceId))
} }
/** A recent document opens in place, in its own space. */ /** A recent document opens in place, in its own space. */
function open(doc: NodeSummary) { function open(doc: NodeSummary) {
workspace.overlay = 'none' workspace.closeSheet()
void workspace.guard(async () => { void workspace.guard(async () => {
const node = await api.getNode(doc.id) const node = await api.getNode(doc.id)
await workspace.openPath(node.path.map((crumb) => crumb.slug).join('/')) await workspace.openPath(node.path.map((crumb) => crumb.slug).join('/'))
@@ -39,12 +34,7 @@
} }
</script> </script>
<div class="sheet"> <Sheet title="overview" detail="{workspace.overview.length} spaces" width={140} cursor={workspace.overviewCursor} keys={['overview']}>
<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}> <ol class="grid" bind:this={grid}>
{#each workspace.overview as { space, recent }, index (space.id)} {#each workspace.overview as { space, recent }, index (space.id)}
<li> <li>
@@ -86,28 +76,9 @@
</li> </li>
{/each} {/each}
</ol> </ol>
</Sheet>
<Keys contexts={['overview']} />
</div>
<style> <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 { .grid {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -200,10 +171,4 @@
} }
.when { flex: none; font-size: 10px; color: var(--muted); } .when { flex: none; font-size: 10px; color: var(--muted); }
.empty { padding: 4px 8px; font-size: 12px; color: var(--muted); } .empty { padding: 4px 8px; font-size: 12px; color: var(--muted); }
/* Keys mean nothing to a thumb. */
@media (max-width: 767px) {
.sheet { padding: 14px; gap: 14px; }
}
</style> </style>
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
/*
* A sheet: a panel floated over the tiles, as the desktop floats one —
* hull, one rim, the frame's radius. Every overlay is drawn in one: its
* title and what it is about, the buttons beside them, a body, and the
* footer of keys its contexts read.
*
* A sheet with a list keeps the row under its cursor on screen: the rows
* carry `data-index`, and the sheet scrolls to the one at `cursor`. Rows
* drawn as `.item` look the same on every sheet; `.at` is the one under
* the cursor.
*/
import type { Snippet } from 'svelte'
import Keys from './Keys.svelte'
import type { Context } from './keymap'
import { workspace } from './store.svelte'
const {
title,
detail = '',
width = 92,
fill = false,
cursor,
keys = [],
hide,
actions,
footer,
children,
}: {
title: string
detail?: string
/** How wide it may grow, in characters; 0 is as wide as the screen. */
width?: number
/** As tall as the screen, for a body that scrolls on its own. */
fill?: boolean
/** The row to keep on screen. */
cursor?: number
keys?: Context[]
hide?: string[]
/** Buttons in the header, before the close one. */
actions?: Snippet
/** Added to the footer after the keys. */
footer?: Snippet
children: Snippet
} = $props()
let root = $state<HTMLDivElement>()
$effect(() => {
const index = cursor
if (index === undefined) return
root?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
</script>
<div class="sheet" class:fill style:--width={width ? `${width}ch` : '100%'} bind:this={root}>
<header>
<span class="label rule"><span class="marker"></span><span class="title">{title}</span> {detail}</span>
{@render actions?.()}
<button onclick={() => workspace.closeSheet()}>esc</button>
</header>
{@render children()}
{#if keys.length || footer}
<Keys contexts={keys} {hide} children={footer} />
{/if}
</div>
<style>
.sheet {
display: flex;
flex-direction: column;
gap: 18px;
width: min(100%, var(--width));
max-height: 100%;
min-height: 0;
overflow: hidden;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
animation: resolve var(--t-slow) var(--ease-snap) both;
}
.sheet.fill { height: 100%; }
header { display: flex; align-items: center; gap: 12px; min-width: 0; }
header .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
/* The list a sheet walks, and its rows. */
.sheet :global(.items) {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.sheet :global(.item) {
display: flex;
flex-direction: column;
padding: 6px 8px;
border-radius: var(--radius-m);
cursor: pointer;
transition: background-color var(--t-base) var(--ease-glide);
}
.sheet :global(.item:hover) { background: var(--surface); }
.sheet :global(.item.at) { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
.sheet :global(.item .name) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg);
font-family: var(--font-sans);
font-size: 14px;
}
.sheet :global(.item .muted) { font-size: 11px; }
.sheet :global(.danger:hover) { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
@media (max-width: 767px) {
.sheet { padding: 14px; gap: 14px; }
}
</style>
+1 -1
View File
@@ -53,7 +53,7 @@
/* A tap opens a row, so on a phone the other verbs need a door of their own. */ /* A tap opens a row, so on a phone the other verbs need a door of their own. */
function actions(index: number) { function actions(index: number) {
workspace.cursor = index 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. */ /* Inside a folder, a phone has no h key and no crumbs on the bar: this is the way up. */
+9 -55
View File
@@ -3,7 +3,7 @@
import { workspace } from './store.svelte' 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. * Enter or r restores, x deletes for good.
*/ */
export function restoreTrashed(item: TrashItem) { export function restoreTrashed(item: TrashItem) {
@@ -23,32 +23,23 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import Keys from './Keys.svelte' import Sheet from './Sheet.svelte'
import { relative } from './util' import { relative } from './util'
let sheet = $state<HTMLDivElement>()
$effect(() => {
const index = workspace.trashCursor
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
const days = (at: number) => Math.max(Math.ceil((at - Date.now() / 1000) / 86400), 0) const days = (at: number) => Math.max(Math.ceil((at - Date.now() / 1000) / 86400), 0)
</script> </script>
<div class="sheet" bind:this={sheet}> <Sheet title="trash" detail="{workspace.trash.length} deleted" cursor={workspace.trashCursor} keys={['trash', 'sheet']}>
<header> {#snippet actions()}
<span class="label rule"><span class="marker"></span><span class="title">trash</span> {workspace.trash.length} deleted</span>
{#if workspace.trash.length > 0} {#if workspace.trash.length > 0}
<button class="danger" onclick={emptyTrash}>empty</button> <button class="danger" onclick={emptyTrash}>empty</button>
{/if} {/if}
<button onclick={() => (workspace.overlay = 'none')}>esc</button> {/snippet}
</header>
<ul> <ul class="items">
{#each workspace.trash as item, index (item.id)} {#each workspace.trash as item, index (item.id)}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li class:at={index === workspace.trashCursor} data-index={index} onpointerenter={() => (workspace.trashCursor = index)}> <li class="item" class:at={index === workspace.trashCursor} data-index={index} onpointerenter={() => (workspace.trashCursor = index)}>
<span class="kind label">{item.kind}</span> <span class="kind label">{item.kind}</span>
<div class="info"> <div class="info">
@@ -68,51 +59,14 @@
<li class="muted">the trash is empty.</li> <li class="muted">the trash is empty.</li>
{/each} {/each}
</ul> </ul>
</Sheet>
<Keys contexts={['trash', 'sheet']} />
</div>
<style> <style>
/* A free-standing panel, as the desktop draws one: hull, one rim, the frame's radius. */ .items > .item { flex-direction: row; align-items: center; gap: 14px; cursor: default; }
.sheet {
display: flex;
flex-direction: column;
gap: 18px;
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;
animation: resolve var(--t-slow) var(--ease-snap) both;
width: min(100%, 92ch);
overflow: hidden;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
li {
display: flex;
align-items: center;
gap: 14px;
padding: 6px 8px;
border-radius: var(--radius-m);
transition: background-color var(--t-base) var(--ease-glide);
}
li:hover { background: var(--surface); }
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
.kind { width: 64px; flex: none; font-size: 9px; letter-spacing: 0.14em; } .kind { width: 64px; flex: none; font-size: 9px; letter-spacing: 0.14em; }
.info { display: flex; flex-direction: column; min-width: 0; flex: 1; font-size: 12px; } .info { display: flex; flex-direction: column; min-width: 0; flex: 1; font-size: 12px; }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); font-family: var(--font-sans); font-size: 14px; }
.muted { font-size: 11px; }
.actions { display: flex; gap: 4px; flex: none; } .actions { display: flex; gap: 4px; flex: none; }
.danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
</style> </style>
+16 -30
View File
@@ -6,6 +6,7 @@
* or a click beside it — abandons the login. * or a click beside it — abandons the login.
*/ */
import { onDestroy } from 'svelte' import { onDestroy } from 'svelte'
import Sheet from './Sheet.svelte'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
const ORIGIN = 'https://verify.proton.me' const ORIGIN = 'https://verify.proton.me'
@@ -25,11 +26,11 @@
const payload = data?.payload const payload = data?.payload
if (data?.type === 'HUMAN_VERIFICATION_SUCCESS' && typeof payload?.token === 'string') { if (data?.type === 'HUMAN_VERIFICATION_SUCCESS' && typeof payload?.token === 'string') {
asked?.resolve(payload.token) asked?.resolve(payload.token)
workspace.overlay = 'none' workspace.closeSheet()
} else if (data?.type === 'RESIZE' && typeof payload?.height === 'number') { } else if (data?.type === 'RESIZE' && typeof payload?.height === 'number') {
height = Math.min(Math.max(payload.height, 320), 900) height = Math.min(Math.max(payload.height, 320), 900)
} else if (data?.type === 'CLOSE') { } else if (data?.type === 'CLOSE') {
workspace.overlay = 'none' workspace.closeSheet()
} }
} }
@@ -38,36 +39,21 @@
<svelte:window {onmessage} /> <svelte:window {onmessage} />
<div class="sheet"> <Sheet title="proton" detail="prove you are human to log in" width={64}>
<header>
<span class="label rule"><span class="marker"></span><span class="title">proton</span> prove you are human to log in</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
{#if asked} {#if asked}
<iframe <div class="frame">
title="Proton human verification" <iframe
src={asked.url} title="Proton human verification"
style:height="{height}px" src={asked.url}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups" style:height="{height}px"
referrerpolicy="no-referrer" sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
></iframe> referrerpolicy="no-referrer"
></iframe>
</div>
{/if} {/if}
</div> </Sheet>
<style> <style>
.sheet { .frame { min-height: 0; overflow-y: auto; }
display: flex; iframe { display: block; width: 100%; border: 0; border-radius: 6px; background: #fff; }
flex-direction: column;
gap: 16px;
width: min(100%, 64ch);
max-height: 100%;
overflow-y: auto;
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; }
iframe { width: 100%; border: 0; border-radius: 6px; background: #fff; }
</style> </style>
+27 -31
View File
@@ -8,13 +8,12 @@
* share an action and differ in direction. Returning `false` lets the key * 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. * 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 { bindings, type Context } from './keymap'
import { focusHelpFilter, scrollHelp } from './Help.svelte' import { focusHelpFilter, scrollHelp } from './Help.svelte'
import { copyMedia, insertMedia, removeMedia } from './MediaBrowser.svelte' import { copyMedia, insertMedia, removeMedia } from './MediaBrowser.svelte'
import { purgeTrashed, restoreTrashed } from './Trash.svelte' import { purgeTrashed, restoreTrashed } from './Trash.svelte'
import { openBacklink } from './Backlinks.svelte' import { goHeading, moveHistory, openBacklink, renewLink, restoreShown, toggleSharing, turnInfo } from './Info.svelte'
import { moveHistory, restoreShown } from './History.svelte'
import { workspace, type SpecialTile } from './store.svelte' import { workspace, type SpecialTile } from './store.svelte'
import { dwindle, formatDay, neighbour, type Direction } from './util' 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), workspace.overview.findIndex((entry) => entry.space.id === workspace.spaceId),
0, 0,
) )
workspace.overlay = 'overview' workspace.openSheet('overview')
} }
/* /*
@@ -136,7 +135,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
// stays up under it. // stays up under it.
close: () => { close: () => {
if (workspace.describing || workspace.scratchEditing) return if (workspace.describing || workspace.scratchEditing) return
workspace.overlay = 'none' workspace.closeSheet()
}, },
}, },
@@ -164,19 +163,21 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
}, },
go: (key) => goToSpace(spaceIndex(key)), go: (key) => goToSpace(spaceIndex(key)),
enter: () => goToSpace(workspace.overviewCursor), 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: { outline: {
down: () => step('outlineCursor', workspace.outline.length, 1), down: () => step('outlineCursor', workspace.outline.length, 1),
up: () => step('outlineCursor', workspace.outline.length, -1), up: () => step('outlineCursor', workspace.outline.length, -1),
bottom: () => step('outlineCursor', workspace.outline.length, Infinity), bottom: () => step('outlineCursor', workspace.outline.length, Infinity),
go: () => { go: () => goHeading(),
const heading = workspace.outline[workspace.outlineCursor]
workspace.overlay = 'none'
heading?.go()
},
}, },
/** The media list: j/k down the files, i inserts, y copies, x deletes. */ /** 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<ActionOf<C>, 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: { backlinks: {
down: () => step('backlinksCursor', workspace.backlinks.length, 1), down: () => step('backlinksCursor', workspace.backlinks.length, 1),
up: () => step('backlinksCursor', workspace.backlinks.length, -1), up: () => step('backlinksCursor', workspace.backlinks.length, -1),
@@ -224,7 +225,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, 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: { history: {
down: () => moveHistory(1), down: () => moveHistory(1),
up: () => moveHistory(-1), up: () => moveHistory(-1),
@@ -232,6 +233,12 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
restore: () => void restoreShown(), 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 * 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 * 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<ActionOf<C>, Run> } = {
sendLeft: () => guard(() => workspace.sendCard(-1)), sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)), sendRight: () => guard(() => workspace.sendCard(1)),
notes: () => { notes: () => {
workspace.overlay = 'none' workspace.closeSheet()
guard(() => workspace.openCard()) guard(() => workspace.openCard())
}, },
line: () => line(':'), line: () => line(':'),
@@ -353,6 +360,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
previousHeading: () => { previousHeading: () => {
if (reading() && !hooks.jumpHeading(-1)) hooks.scrollReader('top') if (reading() && !hooks.jumpHeading(-1)) hooks.scrollReader('top')
}, },
info: () => void openInfo(),
outline: () => void runCommand('outline'), outline: () => void runCommand('outline'),
history: () => void runCommand('history'), history: () => void runCommand('history'),
backlinks: () => void runCommand('backlinks'), backlinks: () => void runCommand('backlinks'),
@@ -393,21 +401,9 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
delete: () => void runCommand('d'), delete: () => void runCommand('d'),
// A delete goes to the trash, so it can be undone the way vim undoes. // A delete goes to the trash, so it can be undone the way vim undoes.
undo: () => guard(() => workspace.undoDelete()), undo: () => guard(() => workspace.undoDelete()),
// Sharing is a key away; taking a link back from its readers asks first. share: () => void toggleSharing(workspace.target),
share: () => // A vim hand presses y to yank; here it retires the link readers have.
void (async () => { link: () => void renewLink(workspace.target),
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')
})(),
// Through the results, as vim's `n` goes through the matches; the // Through the results, as vim's `n` goes through the matches; the
// search itself stays until `:noh`. // search itself stays until `:noh`.
next: () => guard(() => workspace.nextResult(1)), next: () => guard(() => workspace.nextResult(1)),
@@ -547,7 +543,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
function goToSpace(index: number) { function goToSpace(index: number) {
const entry = workspace.overview[index] const entry = workspace.overview[index]
if (!entry) return if (!entry) return
workspace.overlay = 'none' workspace.closeSheet()
guard(() => workspace.enterSpace(entry.space.id)) guard(() => workspace.enterSpace(entry.space.id))
} }
+42 -26
View File
@@ -1,8 +1,9 @@
import { tick } from 'svelte' import { tick } from 'svelte'
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api' 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 { 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 } export type { ScrollAmount, SidewaysAmount }
@@ -74,6 +75,26 @@ const closeFocused = () => {
return workspace.focused 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 = () => { const requireOpen = () => {
if (!workspace.open) { if (!workspace.open) {
workspace.say('no document open', 'error') workspace.say('no document open', 'error')
@@ -914,11 +935,7 @@ export const commands: CommandSpec[] = [
group: 'documents', group: 'documents',
help: "the open document's earlier versions as diffs — j/k, Enter brings one back (gh)", help: "the open document's earlier versions as diffs — j/k, Enter brings one back (gh)",
run: async () => { run: async () => {
if (!requireOpen()) return await openInfo('history')
await workspace.guard(async () => {
await workspace.loadHistory()
workspace.overlay = 'history'
})
}, },
}, },
{ {
@@ -927,11 +944,7 @@ export const commands: CommandSpec[] = [
group: 'documents', group: 'documents',
help: 'the documents that link to the open one — j/k, Enter opens one (gb)', help: 'the documents that link to the open one — j/k, Enter opens one (gb)',
run: async () => { run: async () => {
if (!requireOpen()) return await openInfo('backlinks')
await workspace.guard(async () => {
await workspace.loadBacklinks()
workspace.overlay = 'backlinks'
})
}, },
}, },
{ {
@@ -946,7 +959,7 @@ export const commands: CommandSpec[] = [
await workspace.guard(async () => { await workspace.guard(async () => {
await workspace.loadTrash() await workspace.loadTrash()
workspace.trashCursor = 0 workspace.trashCursor = 0
workspace.overlay = 'trash' workspace.openSheet('trash')
}) })
}, },
}, },
@@ -997,10 +1010,7 @@ export const commands: CommandSpec[] = [
const id = node?.id const id = node?.id
if (!id) return workspace.say('nothing selected', 'error') if (!id) return workspace.say('nothing selected', 'error')
if (!node?.shared) return workspace.say('not shared — :share first', 'error') if (!node?.shared) return workspace.say('not shared — :share first', 'error')
await workspace.guard(async () => { await workspace.guard(() => workspace.renewShareLink(id))
const { url, sealed } = await workspace.mintShareLink(id)
workspace.say(`new link → ${url} (copied) — the previous one is dead${sealedNote(sealed)}`)
})
}, },
}, },
{ {
@@ -1098,7 +1108,7 @@ export const commands: CommandSpec[] = [
run: () => workspace.guard(async () => { run: () => workspace.guard(async () => {
await workspace.loadMedia() await workspace.loadMedia()
workspace.mediaCursor = 0 workspace.mediaCursor = 0
workspace.overlay = 'media' workspace.openSheet('media')
}), }),
}, },
{ {
@@ -1106,13 +1116,18 @@ export const commands: CommandSpec[] = [
aliases: ['toc'], aliases: ['toc'],
group: 'documents', group: 'documents',
help: "the open document's headings to pick from — gO, in the editor too", help: "the open document's headings to pick from — gO, in the editor too",
run: () => { run: () => openInfo('outline'),
if (!requireOpen()) return },
const { headings, current } = hooks.headings() {
if (headings.length === 0) return workspace.say('no headings in this document') name: 'info',
workspace.outline = headings args: '[outline|history|backlinks|share]',
workspace.outlineCursor = current group: 'documents',
workspace.overlay = 'outline' 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', group: 'view',
help: 'show the key and command reference', help: 'show the key and command reference',
run: () => { run: () => {
workspace.overlay = workspace.overlay === 'help' ? 'none' : 'help' if (workspace.overlay === 'help') workspace.closeSheet()
else workspace.openSheet('help')
}, },
}, },
{ {
+9
View File
@@ -18,11 +18,13 @@ export type Context =
| 'sheet' | 'sheet'
| 'help' | 'help'
| 'overview' | 'overview'
| 'info'
| 'outline' | 'outline'
| 'media' | 'media'
| 'trash' | 'trash'
| 'backlinks' | 'backlinks'
| 'history' | 'history'
| 'share'
| 'card' | 'card'
| 'drawer' | 'drawer'
| 'workspace' | '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: ['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: 'nextHeading', label: ']] / [[', help: 'next / previous heading of the document — in the editor too' },
{ context: 'tree', keys: ['[['], action: 'previousHeading' }, { 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: ['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', 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' }, { 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: ['Enter'], action: 'enter' },
{ context: 'overview', keys: ['w', 'q'], action: 'close', caps: ['w', 'esc'], hint: 'close' }, { 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: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' },
{ context: 'outline', keys: ['k', 'ArrowUp'], action: 'up' }, { context: 'outline', keys: ['k', 'ArrowUp'], action: 'up' },
{ context: 'outline', keys: ['G'], action: 'bottom' }, { context: 'outline', keys: ['G'], action: 'bottom' },
@@ -264,6 +270,9 @@ export const bindings = [
{ context: 'history', keys: ['G'], action: 'bottom' }, { context: 'history', keys: ['G'], action: 'bottom' },
{ context: 'history', keys: ['Enter'], action: 'restore', hint: 'restore' }, { 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 --------------------------------------- // --- 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: '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' }, { context: 'editor', label: 'drop · paste', help: 'an image, video or audio file attaches it' },
+6 -4
View File
@@ -4,7 +4,7 @@
* a context takes it. * a context takes it.
* *
* always Ctrl-S, Alt-h/j/k/l — heard in the editor too * 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 * drawer the scratchpad, while it is down
* workspace Esc out of fullscreen, Q * workspace Esc out of fullscreen, Q
* writing a document being written takes every plain key back * 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 { bindings, type Binding, type Context } from './keymap'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
/** The contexts that are sheets: one is up when the overlay of that name is. */ /** The sheets with keys of their own, under the same name as the sheet. */
const sheets = new Set<string>(['help', 'overview', 'outline', 'media', 'trash', 'backlinks', 'history', 'card']) const sheets = new Set<string>(['help', 'overview', 'media', 'trash', 'card'])
/** The stack, the topmost first. */ /** The stack, the topmost first. */
export function stack(): Context[] { export function stack(): Context[] {
const modes: Context[] = ['always'] const modes: Context[] = ['always']
const overlay = workspace.overlay const overlay = workspace.overlay
if (overlay !== 'none') { 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. // 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'] return [...modes, 'sheet']
} }
if (workspace.scratchOpen && !workspace.scratchEditing) modes.push('drawer') if (workspace.scratchOpen && !workspace.scratchEditing) modes.push('drawer')
+28 -4
View File
@@ -33,6 +33,10 @@ export type Pane = 'list' | 'document'
export type View = 'tree' | 'special' export type View = 'tree' | 'special'
/** The tile of the special workspace that has focus. */ /** The tile of the special workspace that has focus. */
export type SpecialTile = 'board' | 'agenda' | 'calendar' 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 export type Flash = { text: string; kind: 'info' | 'error' } | null
/** /**
@@ -321,7 +325,10 @@ class Workspace {
pane = $state<Pane>('list') pane = $state<Pane>('list')
flash = $state<Flash>(null) flash = $state<Flash>(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<InfoSection>('outline')
/** Which of the special workspace's tiles the keys go to. */ /** Which of the special workspace's tiles the keys go to. */
specialFocus = $state<SpecialTile>('board') specialFocus = $state<SpecialTile>('board')
/** /**
@@ -469,7 +476,7 @@ class Workspace {
resolve(token) 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.draft = { ...tile.draft, name: revision.name, body: revision.body }
tile.dirty = true tile.dirty = true
await this.save({ quiet: true, restore: true, tile }) 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`) 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') if (!this.card) return this.say('no card selected', 'error')
this.taskCursor = 0 this.taskCursor = 0
this.describing = false this.describing = false
this.overlay = 'card' this.openSheet('card')
} }
get scratchNote(): ScratchNote | undefined { get scratchNote(): ScratchNote | undefined {
@@ -1869,6 +1876,16 @@ class Workspace {
this.overlay = 'none' 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. */ /** `e`: write the description, from what it says now. */
describe() { describe() {
const card = this.card const card = this.card
@@ -2117,6 +2134,13 @@ class Workspace {
return { url, sealed } 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) { async copy(text: string) {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)