mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
0eabcb2333
commit
26b3c686d2
24 files changed
+620
-788
No files matched your search
@@ -128,10 +128,11 @@ it; in the document pane it is that document. In the browse pane:
|
||||
| `Ctrl-D` / `Ctrl-U` | half a page down / up the open document |
|
||||
| `zl` / `zh`, `zL` / `zH` | scroll the table, code block or formula at the top of the view sideways, a little or by half its width |
|
||||
| `]]` / `[[` | the next / previous heading of the open document, in the editor too |
|
||||
| `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) |
|
||||
| `gi` | the info sheet: the open document's headings, earlier versions, backlinks and share link — `Tab` goes from one part to the next (`:info`); clicking the line under the title opens it too |
|
||||
| `gO` | the info sheet at the headings — `j`/`k`, `Enter` jumps there (`:outline`) |
|
||||
| `gx` | open the file under the cursor in a tab of its own |
|
||||
| `gh` | the open document's earlier versions, as diffs — `j`/`k`, `Enter` brings one back (`:history`) |
|
||||
| `gb` | the documents that link to the open one — `j`/`k`, `Enter` opens one (`:backlinks`) |
|
||||
| `gh` | the info sheet at the earlier versions, as diffs — `j`/`k`, `Enter` brings one back (`:history`) |
|
||||
| `gb` | the info sheet at the documents that link to the open one — `j`/`k`, `Enter` opens one (`:backlinks`) |
|
||||
| `F` | a letter over every link and button in view; typing it follows it — links out of the workspace open in a tab of their own |
|
||||
| `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password |
|
||||
| `o` | a new document here, straight into insert mode |
|
||||
|
||||
@@ -29,6 +29,7 @@ arguments and `↑` / `↓` recall earlier lines.
|
||||
| `Ctrl-D / Ctrl-U` | half a page down / up the open document |
|
||||
| `zl / zh · zL / zH` | the wide table or code block at the top of the view, sideways — a little, or half its width |
|
||||
| `]] / \[\[` | next / previous heading of the document — in the editor too |
|
||||
| `gi` | the info sheet: the open document's headings, earlier versions, backlinks and sharing — Tab between them |
|
||||
| `gO` | the headings as a list: j/k, Enter jumps there |
|
||||
| `F` | hints over the links and buttons in view — type one to follow it |
|
||||
| `gf / gx` | in the editor: follow the link under the cursor / open it in a tab |
|
||||
@@ -188,6 +189,7 @@ do: `:w!`, `:space! <name>`, `:import!`.
|
||||
| `:backlinks, :bl` | the documents that link to the open one — j/k, Enter opens one (gb) |
|
||||
| `:source` | show an open markdown file as written, or rendered again |
|
||||
| `:outline, :toc` | the open document's headings to pick from — gO, in the editor too |
|
||||
| `:info [outline\|history\|backlinks\|share]` | what is known about the open document: its headings, earlier versions, backlinks and sharing — Tab between them (gi) |
|
||||
| `:search, :se [text]` | full-text search in this space (empty clears) |
|
||||
| `:search!` | full-text search across every space — Ctrl-P |
|
||||
| `:clear, :noh` | clear the search filter |
|
||||
|
||||
@@ -8,6 +8,7 @@ reader needs no account.
|
||||
| `s`, `:share` | share the document and copy its link; on a shared one, `s` asks before taking it back |
|
||||
| `y`, `:link` | make a new link for the same document; the old one stops working |
|
||||
| `:unshare` | stop sharing |
|
||||
| `:info share` | the share part of the info sheet: whether the document is public, the shape of its link, and the same `s` and `y` |
|
||||
|
||||
The link looks like `https://your-host/s/<token>#<key>`. The page renders the
|
||||
document read-only and serves only the pictures and videos it embeds right now:
|
||||
|
||||
@@ -66,12 +66,17 @@ linked to from anywhere behind the login.
|
||||

|
||||
|
||||
The line under the title says when it was written and last edited, how many
|
||||
words it has, and how many documents link to it. In the reader:
|
||||
words it has, how many documents link to it, and whether it is public. Click it,
|
||||
or press `gi`, for the info sheet: the document's headings, its earlier
|
||||
versions, the documents that link to it and its share link, one part at a time
|
||||
— `Tab` goes to the next. `gO`, `gh` and `gb` open it at their own part. In the
|
||||
reader:
|
||||
|
||||
| key | what it does |
|
||||
| --- | --- |
|
||||
| `j` / `k`, `Ctrl-D` / `Ctrl-U` | scroll a line, or half a page |
|
||||
| `]]` / `[[` | the next / previous heading |
|
||||
| `gi` | the info sheet |
|
||||
| `gO` | the headings as a list to jump to |
|
||||
| `zl` / `zh` | scroll a wide table or code block sideways |
|
||||
| `F` | a letter over every link and button in view; type it to follow |
|
||||
|
||||
+18
-7
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte'
|
||||
import { openOverview } from './lib/actions'
|
||||
import { hooks } from './lib/commands'
|
||||
import { keysOf } from './lib/keymap'
|
||||
@@ -11,18 +12,16 @@
|
||||
import CommandLine from './lib/CommandLine.svelte'
|
||||
import DocumentPane from './lib/DocumentPane.svelte'
|
||||
import FilePane from './lib/FilePane.svelte'
|
||||
import Headings from './lib/Headings.svelte'
|
||||
import Help from './lib/Help.svelte'
|
||||
import Info from './lib/Info.svelte'
|
||||
import Hints from './lib/Hints.svelte'
|
||||
import Verify from './lib/Verify.svelte'
|
||||
import Login from './lib/Login.svelte'
|
||||
import Overview from './lib/Overview.svelte'
|
||||
import MediaBrowser from './lib/MediaBrowser.svelte'
|
||||
import Trash from './lib/Trash.svelte'
|
||||
import Backlinks from './lib/Backlinks.svelte'
|
||||
import CardSheet from './lib/CardSheet.svelte'
|
||||
import Scratch from './lib/Scratch.svelte'
|
||||
import History from './lib/History.svelte'
|
||||
import Actions from './lib/Actions.svelte'
|
||||
import Prompt from './lib/Prompt.svelte'
|
||||
import SecretPrompt from './lib/SecretPrompt.svelte'
|
||||
@@ -30,7 +29,7 @@
|
||||
import Shared from './lib/Shared.svelte'
|
||||
import Sidebar from './lib/Sidebar.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'
|
||||
|
||||
/*
|
||||
@@ -54,6 +53,18 @@
|
||||
let importRoot = false
|
||||
let shell = $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>()
|
||||
/** The first half of `gg`, `dd`, `]]` …, shown until the second comes. */
|
||||
let pending = $state('')
|
||||
@@ -369,10 +380,10 @@
|
||||
{/if}
|
||||
|
||||
{#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 -->
|
||||
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay === 'card' ? workspace.closeCardSheet() : (workspace.overlay = 'none'))}
|
||||
>
|
||||
{#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 class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && workspace.closeSheet()}>
|
||||
<Shown />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* drift apart.
|
||||
*/
|
||||
import { isContainer } from './api'
|
||||
import Sheet from './Sheet.svelte'
|
||||
import { hooks, runCommand } from './commands'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
@@ -52,42 +53,23 @@
|
||||
})
|
||||
|
||||
function act(action: Action) {
|
||||
workspace.overlay = 'none'
|
||||
workspace.closeSheet()
|
||||
// The list is what the commands act on while it has the focus.
|
||||
workspace.pane = 'list'
|
||||
void action.run()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<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>
|
||||
<Sheet title={node ? node.name.trim() || node.slug : 'nothing'} width={48}>
|
||||
<ul class="items">
|
||||
{#each actions as action (action.label)}
|
||||
<li><button class:danger={action.danger} onclick={() => act(action)}>{action.label}</button></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<style>
|
||||
.sheet {
|
||||
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; }
|
||||
.items { gap: 4px; }
|
||||
li button {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
|
||||
@@ -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>
|
||||
@@ -2,17 +2,15 @@
|
||||
/*
|
||||
* Enter on a card turns it over: a window floated over the board, as the
|
||||
* desktop floats one, with what the card holds on its back — labels, the
|
||||
* description, the checklist. The keys live in App.svelte with the others;
|
||||
* description, the checklist. The keys live in actions.ts with the others;
|
||||
* this draws and lets a pointer do the same.
|
||||
*/
|
||||
import Keys from './Keys.svelte'
|
||||
import Sheet from './Sheet.svelte'
|
||||
import { followLink } from './commands'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatDay, labelHue, urgency } from './util'
|
||||
|
||||
let steps = $state<HTMLUListElement>()
|
||||
|
||||
const card = $derived(workspace.card)
|
||||
const list = $derived(workspace.list)
|
||||
const lists = $derived(workspace.board?.lists.length ?? 0)
|
||||
@@ -22,13 +20,9 @@
|
||||
|
||||
// A card deleted or moved out of sight under the sheet takes the sheet with it.
|
||||
$effect(() => {
|
||||
if (!card) workspace.overlay = 'none'
|
||||
if (!card) workspace.closeSheet()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const at = workspace.taskCursor
|
||||
steps?.querySelector<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. */
|
||||
function follow(event: MouseEvent) {
|
||||
@@ -51,14 +45,7 @@
|
||||
</script>
|
||||
|
||||
{#if card}
|
||||
<div class="sheet">
|
||||
<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>
|
||||
<Sheet title="card" detail="{workspace.boardSpace?.slug ?? ''} / {list?.name ?? ''}{role ? ` · ${role}` : ''}" width={76} cursor={workspace.taskCursor} keys={['card']}>
|
||||
|
||||
<div class="scroll">
|
||||
<h2 class:done={card.done_at !== null}>{card.title}</h2>
|
||||
@@ -113,7 +100,7 @@
|
||||
{#if card.checklist.length}
|
||||
<div class="progress"><span style="width: {(ticked / card.checklist.length) * 100}%"></span></div>
|
||||
{/if}
|
||||
<ul bind:this={steps}>
|
||||
<ul>
|
||||
{#each card.checklist as task, index (index)}
|
||||
<!-- 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)}>
|
||||
@@ -126,32 +113,10 @@
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Keys contexts={['card']} />
|
||||
</div>
|
||||
</Sheet>
|
||||
{/if}
|
||||
|
||||
<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; }
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 Outline from './Outline.svelte'
|
||||
import { workspace, type Tile } from './store.svelte'
|
||||
@@ -26,13 +26,6 @@
|
||||
const words = $derived(
|
||||
(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 article = $state<HTMLElement>()
|
||||
let title = $state<HTMLInputElement>()
|
||||
@@ -154,46 +147,23 @@
|
||||
{#if tile.dirty}<span class="dirty">modified</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<dl class="meta">
|
||||
<dt>written</dt>
|
||||
<dd>{formatStamp(tile.draft.created_at)} {formatWeekday(tile.draft.created_at)}</dd>
|
||||
<dt>edited</dt>
|
||||
<dd>{relative(doc.updated_at)}</dd>
|
||||
<dt>words</dt>
|
||||
<dd>{words}<span class="unit">words</span></dd>
|
||||
<!--
|
||||
The readout is the way to everything else about the document: its
|
||||
headings, its history, what links to it and who can read it — gi.
|
||||
-->
|
||||
<button class="meta" title="info (gi)" onclick={() => openInfo()}>
|
||||
<span class="field"><span class="caption">written</span>{formatStamp(tile.draft.created_at)} {formatWeekday(tile.draft.created_at)}</span>
|
||||
<span class="field"><span class="caption">edited</span>{relative(doc.updated_at)}</span>
|
||||
<span class="field"><span class="caption">words</span>{words}<span class="unit">words</span></span>
|
||||
{#if doc.backlinks > 0}
|
||||
<dt>linked</dt>
|
||||
<dd><button class="linked" title="what links here (gb)" onclick={() => runCommand('backlinks')}>from {doc.backlinks}</button></dd>
|
||||
<span class="field"><span class="caption">linked</span>from {doc.backlinks}</span>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
{#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 doc.shared}
|
||||
<span class="field public"><span class="ring"></span>public</span>
|
||||
{/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>
|
||||
|
||||
{#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}
|
||||
<input
|
||||
class="title"
|
||||
@@ -241,6 +211,13 @@
|
||||
<Outline root={article} scroller={reader} source={html} />
|
||||
</aside>
|
||||
</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}
|
||||
</section>
|
||||
|
||||
@@ -285,55 +262,22 @@
|
||||
}
|
||||
.dirty { flex: none; color: var(--light); }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Read as a line of the header, and a way into the info sheet. */
|
||||
.meta {
|
||||
all: unset;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
align-items: baseline;
|
||||
gap: 4px 18px;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.meta dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.meta dd { margin: 0 10px 0 0; color: var(--fg); }
|
||||
/* Read as the rest of the line, and still a way to the backlinks. */
|
||||
.meta .linked { all: unset; cursor: pointer; text-decoration: underline; text-decoration-color: var(--overlay); text-underline-offset: 4px; }
|
||||
.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); }
|
||||
.meta:hover .caption, .meta:focus-visible .caption { color: var(--light); }
|
||||
.caption { margin-right: 8px; color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.public { display: flex; align-items: center; gap: 6px; color: var(--light); }
|
||||
.ring { width: 7px; height: 7px; flex: none; border: 1.5px solid var(--light); border-radius: 999px; }
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
@@ -400,9 +344,8 @@
|
||||
.unit { display: none; }
|
||||
|
||||
/*
|
||||
* A phone: a back button where the bar's crumbs would be, the readout on one
|
||||
* line without its captions, and the actions as one row of equal buttons
|
||||
* big enough for a thumb. Closing is the back button's job.
|
||||
* A phone: a back button where the bar's crumbs would be, and the readout
|
||||
* on one line without its captions. Closing is the back button's job.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
header { padding: 10px 14px 10px 8px; gap: 8px; }
|
||||
@@ -417,16 +360,10 @@
|
||||
border: 0;
|
||||
color: var(--light);
|
||||
}
|
||||
.row { flex-direction: column; align-items: stretch; gap: 10px; padding-left: 6px; }
|
||||
.meta { flex-wrap: nowrap; gap: 0; overflow: hidden; white-space: nowrap; }
|
||||
.meta dt { display: none; }
|
||||
.meta dd { margin: 0; }
|
||||
.meta dd + dt + dd::before { content: '·'; margin: 0 8px; color: var(--muted); }
|
||||
.meta { flex-wrap: nowrap; gap: 0; padding-left: 6px; overflow: hidden; white-space: nowrap; }
|
||||
.caption { display: none; }
|
||||
.field + .field::before { content: '·'; margin: 0 8px; color: var(--muted); }
|
||||
.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; }
|
||||
.reading { padding: 14px 16px 30vh; grid-template-columns: minmax(0, 1fr); }
|
||||
.reading > article { grid-column: 1; }
|
||||
|
||||
@@ -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>
|
||||
+9
-36
@@ -14,10 +14,9 @@
|
||||
export function scrollHelp(by: 1 | -1 | 'pagedown' | 'pageup' | 'start' | 'end') {
|
||||
if (!pages) return
|
||||
// Columns that run off the right edge scroll sideways; on a phone they
|
||||
// stack, and the sheet around them scrolls down instead.
|
||||
// stack, and scroll down instead.
|
||||
const sideways = pages.scrollWidth > pages.clientWidth
|
||||
const scroller = sideways ? pages : pages.parentElement
|
||||
if (!scroller) return
|
||||
const scroller = pages
|
||||
const axis = sideways ? 'left' : 'top'
|
||||
if (by === 'start' || by === 'end') {
|
||||
scroller.scrollTo({ [axis]: by === 'start' ? 0 : 1e9, behavior: 'smooth' })
|
||||
@@ -40,9 +39,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
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 { workspace } from './store.svelte'
|
||||
|
||||
const titles: Record<Group, string> = {
|
||||
tree: 'spaces & folders',
|
||||
@@ -107,9 +105,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<span class="label rule"><span class="marker"></span><span class="title">help</span> {count} keys and commands</span>
|
||||
<Sheet title="help" detail="{count} keys and commands" width={0} fill keys={['help', 'sheet']}>
|
||||
{#snippet actions()}
|
||||
<label class="filter" class:on={helpFilter.text !== ''}>
|
||||
<span class="slash">/</span>
|
||||
<input
|
||||
@@ -121,8 +118,7 @@
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||
</header>
|
||||
{/snippet}
|
||||
|
||||
<div class="cols" bind:this={pages}>
|
||||
{#each shown as [title, rows] (title)}
|
||||
@@ -142,31 +138,9 @@
|
||||
<p class="none">nothing matches "{helpFilter.text}"</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Keys contexts={['help', 'sheet']} />
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -241,10 +215,9 @@
|
||||
|
||||
.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) {
|
||||
.sheet { overflow-y: auto; }
|
||||
.cols { flex: none; columns: auto; overflow: visible; }
|
||||
.cols { columns: auto; overflow-y: auto; }
|
||||
.filter { width: 14ch; }
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -11,7 +11,7 @@
|
||||
export function insertMedia(file: MediaFile) {
|
||||
if (!workspace.editing) return workspace.say('insert works while writing — i on a document first', 'error')
|
||||
hooks.insertText(embedSnippet(file))
|
||||
workspace.overlay = 'none'
|
||||
workspace.closeSheet()
|
||||
}
|
||||
|
||||
export async function copyMedia(file: MediaFile) {
|
||||
@@ -33,16 +33,9 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Keys from './Keys.svelte'
|
||||
import Sheet from './Sheet.svelte'
|
||||
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 embeds = nodeIds.map((id) => `#${id}`).join(' ')
|
||||
if (fileNode !== null) return embeds ? `in the tree · ${embeds}` : 'in the tree'
|
||||
@@ -50,16 +43,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sheet" bind:this={sheet}>
|
||||
<header>
|
||||
<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>
|
||||
<Sheet title="media" detail="{workspace.media.length} files" cursor={workspace.mediaCursor} keys={['media', 'sheet']} hide={workspace.editing ? [] : ['insert']}>
|
||||
<ul class="items">
|
||||
{#each workspace.media as file, index (file.id)}
|
||||
<!-- 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">
|
||||
{#if file.encrypted}
|
||||
<span class="label">sealed</span>
|
||||
@@ -92,43 +80,10 @@
|
||||
<li class="muted">nothing uploaded yet — attach a file from the editor.</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<Keys contexts={['media', 'sheet']} hide={workspace.editing ? [] : ['insert']} />
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
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); }
|
||||
|
||||
.items > .item { flex-direction: row; align-items: center; gap: 14px; cursor: default; }
|
||||
|
||||
.thumb {
|
||||
width: 52px;
|
||||
@@ -145,9 +100,6 @@
|
||||
.thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.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; }
|
||||
.danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
|
||||
</style>
|
||||
@@ -2,10 +2,10 @@
|
||||
/*
|
||||
* `w`: every space at once, the way the desktop's overview shows every
|
||||
* workspace with its windows — here, with what was last written in it. The
|
||||
* keys live in App.svelte with the others; this only draws and tells them
|
||||
* keys live in actions.ts with the others; this only draws and tells them
|
||||
* how many cards fit in a row.
|
||||
*/
|
||||
import Keys from './Keys.svelte'
|
||||
import Sheet from './Sheet.svelte'
|
||||
import { api, type NodeSummary } from './api'
|
||||
import { hooks } from './commands'
|
||||
import { workspace } from './store.svelte'
|
||||
@@ -19,19 +19,14 @@
|
||||
return () => (hooks.overviewColumns = () => 1)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const index = workspace.overviewCursor
|
||||
grid?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
function enter(spaceId: number) {
|
||||
workspace.overlay = 'none'
|
||||
workspace.closeSheet()
|
||||
void workspace.guard(() => workspace.enterSpace(spaceId))
|
||||
}
|
||||
|
||||
/** A recent document opens in place, in its own space. */
|
||||
function open(doc: NodeSummary) {
|
||||
workspace.overlay = 'none'
|
||||
workspace.closeSheet()
|
||||
void workspace.guard(async () => {
|
||||
const node = await api.getNode(doc.id)
|
||||
await workspace.openPath(node.path.map((crumb) => crumb.slug).join('/'))
|
||||
@@ -39,12 +34,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<span class="label rule"><span class="marker"></span><span class="title">overview</span> {workspace.overview.length} spaces</span>
|
||||
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||
</header>
|
||||
|
||||
<Sheet title="overview" detail="{workspace.overview.length} spaces" width={140} cursor={workspace.overviewCursor} keys={['overview']}>
|
||||
<ol class="grid" bind:this={grid}>
|
||||
{#each workspace.overview as { space, recent }, index (space.id)}
|
||||
<li>
|
||||
@@ -86,28 +76,9 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<Keys contexts={['overview']} />
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<style>
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
width: min(100%, 140ch);
|
||||
max-height: 100%;
|
||||
background: color-mix(in oklab, var(--hull) 97%, transparent);
|
||||
border: 1px solid var(--surface-alt);
|
||||
border-radius: var(--frame-radius);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
header { display: flex; align-items: center; gap: 12px; }
|
||||
header .label { flex: 1; }
|
||||
header .marker { background: var(--light); }
|
||||
header .title { color: var(--light); }
|
||||
|
||||
.grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -200,10 +171,4 @@
|
||||
}
|
||||
.when { flex: none; font-size: 10px; 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>
|
||||
@@ -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>
|
||||
@@ -53,7 +53,7 @@
|
||||
/* A tap opens a row, so on a phone the other verbs need a door of their own. */
|
||||
function actions(index: number) {
|
||||
workspace.cursor = index
|
||||
workspace.overlay = 'actions'
|
||||
workspace.openSheet('actions')
|
||||
}
|
||||
|
||||
/* Inside a folder, a phone has no h key and no crumbs on the bar: this is the way up. */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
/*
|
||||
* What a row's buttons do, out here so the keys in App.svelte do the same:
|
||||
* What a row's buttons do, out here so the keys in actions.ts do the same:
|
||||
* Enter or r restores, x deletes for good.
|
||||
*/
|
||||
export function restoreTrashed(item: TrashItem) {
|
||||
@@ -23,32 +23,23 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Keys from './Keys.svelte'
|
||||
import Sheet from './Sheet.svelte'
|
||||
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)
|
||||
</script>
|
||||
|
||||
<div class="sheet" bind:this={sheet}>
|
||||
<header>
|
||||
<span class="label rule"><span class="marker"></span><span class="title">trash</span> {workspace.trash.length} deleted</span>
|
||||
<Sheet title="trash" detail="{workspace.trash.length} deleted" cursor={workspace.trashCursor} keys={['trash', 'sheet']}>
|
||||
{#snippet actions()}
|
||||
{#if workspace.trash.length > 0}
|
||||
<button class="danger" onclick={emptyTrash}>empty</button>
|
||||
{/if}
|
||||
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||
</header>
|
||||
{/snippet}
|
||||
|
||||
<ul>
|
||||
<ul class="items">
|
||||
{#each workspace.trash as item, index (item.id)}
|
||||
<!-- 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>
|
||||
|
||||
<div class="info">
|
||||
@@ -68,51 +59,14 @@
|
||||
<li class="muted">the trash is empty.</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<Keys contexts={['trash', 'sheet']} />
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<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;
|
||||
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); }
|
||||
|
||||
.items > .item { flex-direction: row; align-items: center; gap: 14px; cursor: default; }
|
||||
|
||||
.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; }
|
||||
.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; }
|
||||
.danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@
|
||||
* or a click beside it — abandons the login.
|
||||
*/
|
||||
import { onDestroy } from 'svelte'
|
||||
import Sheet from './Sheet.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
const ORIGIN = 'https://verify.proton.me'
|
||||
@@ -25,11 +26,11 @@
|
||||
const payload = data?.payload
|
||||
if (data?.type === 'HUMAN_VERIFICATION_SUCCESS' && typeof payload?.token === 'string') {
|
||||
asked?.resolve(payload.token)
|
||||
workspace.overlay = 'none'
|
||||
workspace.closeSheet()
|
||||
} else if (data?.type === 'RESIZE' && typeof payload?.height === 'number') {
|
||||
height = Math.min(Math.max(payload.height, 320), 900)
|
||||
} else if (data?.type === 'CLOSE') {
|
||||
workspace.overlay = 'none'
|
||||
workspace.closeSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +39,9 @@
|
||||
|
||||
<svelte:window {onmessage} />
|
||||
|
||||
<div class="sheet">
|
||||
<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>
|
||||
<Sheet title="proton" detail="prove you are human to log in" width={64}>
|
||||
{#if asked}
|
||||
<div class="frame">
|
||||
<iframe
|
||||
title="Proton human verification"
|
||||
src={asked.url}
|
||||
@@ -51,23 +49,11 @@
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<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; }
|
||||
iframe { width: 100%; border: 0; border-radius: 6px; background: #fff; }
|
||||
.frame { min-height: 0; overflow-y: auto; }
|
||||
iframe { display: block; width: 100%; border: 0; border-radius: 6px; background: #fff; }
|
||||
</style>
|
||||
+27
-31
@@ -8,13 +8,12 @@
|
||||
* share an action and differ in direction. Returning `false` lets the key
|
||||
* on to the next context down, as if it had not been bound here.
|
||||
*/
|
||||
import { cycleTile, focusTile, followLink, hooks, runCommand } from './commands'
|
||||
import { cycleTile, focusTile, followLink, hooks, openInfo, runCommand } from './commands'
|
||||
import { bindings, type Context } from './keymap'
|
||||
import { focusHelpFilter, scrollHelp } from './Help.svelte'
|
||||
import { copyMedia, insertMedia, removeMedia } from './MediaBrowser.svelte'
|
||||
import { purgeTrashed, restoreTrashed } from './Trash.svelte'
|
||||
import { openBacklink } from './Backlinks.svelte'
|
||||
import { moveHistory, restoreShown } from './History.svelte'
|
||||
import { goHeading, moveHistory, openBacklink, renewLink, restoreShown, toggleSharing, turnInfo } from './Info.svelte'
|
||||
import { workspace, type SpecialTile } from './store.svelte'
|
||||
import { dwindle, formatDay, neighbour, type Direction } from './util'
|
||||
|
||||
@@ -45,7 +44,7 @@ export async function openOverview() {
|
||||
workspace.overview.findIndex((entry) => entry.space.id === workspace.spaceId),
|
||||
0,
|
||||
)
|
||||
workspace.overlay = 'overview'
|
||||
workspace.openSheet('overview')
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -136,7 +135,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
// stays up under it.
|
||||
close: () => {
|
||||
if (workspace.describing || workspace.scratchEditing) return
|
||||
workspace.overlay = 'none'
|
||||
workspace.closeSheet()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -164,19 +163,21 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
},
|
||||
go: (key) => goToSpace(spaceIndex(key)),
|
||||
enter: () => goToSpace(workspace.overviewCursor),
|
||||
close: () => void (workspace.overlay = 'none'),
|
||||
close: () => workspace.closeSheet(),
|
||||
},
|
||||
|
||||
/** `gO`'s list: j/k down the headings, Enter to the one picked. */
|
||||
/** The info sheet: Tab from one of its parts to the next. */
|
||||
info: {
|
||||
next: () => turnInfo(1),
|
||||
previous: () => turnInfo(-1),
|
||||
},
|
||||
|
||||
/** Its headings: j/k down them, Enter to the one picked. */
|
||||
outline: {
|
||||
down: () => step('outlineCursor', workspace.outline.length, 1),
|
||||
up: () => step('outlineCursor', workspace.outline.length, -1),
|
||||
bottom: () => step('outlineCursor', workspace.outline.length, Infinity),
|
||||
go: () => {
|
||||
const heading = workspace.outline[workspace.outlineCursor]
|
||||
workspace.overlay = 'none'
|
||||
heading?.go()
|
||||
},
|
||||
go: () => goHeading(),
|
||||
},
|
||||
|
||||
/** The media list: j/k down the files, i inserts, y copies, x deletes. */
|
||||
@@ -213,7 +214,7 @@ export const actions: { [C in Context]: Record<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: {
|
||||
down: () => step('backlinksCursor', workspace.backlinks.length, 1),
|
||||
up: () => step('backlinksCursor', workspace.backlinks.length, -1),
|
||||
@@ -224,7 +225,7 @@ export const actions: { [C in Context]: Record<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: {
|
||||
down: () => moveHistory(1),
|
||||
up: () => moveHistory(-1),
|
||||
@@ -232,6 +233,12 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
restore: () => void restoreShown(),
|
||||
},
|
||||
|
||||
/** Its sharing: the same s and y as on the document itself. */
|
||||
share: {
|
||||
toggle: () => void toggleSharing(workspace.open),
|
||||
link: () => void renewLink(workspace.open),
|
||||
},
|
||||
|
||||
/*
|
||||
* The card sheet reads the list keys over its checklist, and the board's
|
||||
* card keys for the card itself — `D`, `H`/`L`, `R` act on it as they do
|
||||
@@ -259,7 +266,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
sendLeft: () => guard(() => workspace.sendCard(-1)),
|
||||
sendRight: () => guard(() => workspace.sendCard(1)),
|
||||
notes: () => {
|
||||
workspace.overlay = 'none'
|
||||
workspace.closeSheet()
|
||||
guard(() => workspace.openCard())
|
||||
},
|
||||
line: () => line(':'),
|
||||
@@ -353,6 +360,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
previousHeading: () => {
|
||||
if (reading() && !hooks.jumpHeading(-1)) hooks.scrollReader('top')
|
||||
},
|
||||
info: () => void openInfo(),
|
||||
outline: () => void runCommand('outline'),
|
||||
history: () => void runCommand('history'),
|
||||
backlinks: () => void runCommand('backlinks'),
|
||||
@@ -393,21 +401,9 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
delete: () => void runCommand('d'),
|
||||
// A delete goes to the trash, so it can be undone the way vim undoes.
|
||||
undo: () => guard(() => workspace.undoDelete()),
|
||||
// Sharing is a key away; taking a link back from its readers asks first.
|
||||
share: () =>
|
||||
void (async () => {
|
||||
const target = workspace.target
|
||||
if (target?.shared && !(await workspace.ask(`stop sharing "${target.name.trim() || target.slug}"? the link stops working`))) return
|
||||
void runCommand(target?.shared ? 'unshare' : 'share')
|
||||
})(),
|
||||
// A vim hand presses y to yank; here it retires the link readers have,
|
||||
// so it asks first.
|
||||
link: () =>
|
||||
void (async () => {
|
||||
const target = workspace.target
|
||||
if (!target?.shared) return void runCommand('link')
|
||||
if (await workspace.ask(`new share link for "${target.name.trim() || target.slug}"? the current one stops working`)) void runCommand('link')
|
||||
})(),
|
||||
share: () => void toggleSharing(workspace.target),
|
||||
// A vim hand presses y to yank; here it retires the link readers have.
|
||||
link: () => void renewLink(workspace.target),
|
||||
// Through the results, as vim's `n` goes through the matches; the
|
||||
// search itself stays until `:noh`.
|
||||
next: () => guard(() => workspace.nextResult(1)),
|
||||
@@ -547,7 +543,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
function goToSpace(index: number) {
|
||||
const entry = workspace.overview[index]
|
||||
if (!entry) return
|
||||
workspace.overlay = 'none'
|
||||
workspace.closeSheet()
|
||||
guard(() => workspace.enterSpace(entry.space.id))
|
||||
}
|
||||
|
||||
|
||||
+42
-26
@@ -1,8 +1,9 @@
|
||||
import { tick } from 'svelte'
|
||||
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api'
|
||||
import { workspace } from './store.svelte'
|
||||
import { infoSections } from './Info.svelte'
|
||||
import { workspace, type InfoSection } from './store.svelte'
|
||||
import { emptyTrash } from './Trash.svelte'
|
||||
import { formatDay, parseDay, parseDue, relative, sealedNote, titleHref, type Heading, type ScrollAmount, type SidewaysAmount } from './util'
|
||||
import { formatDay, parseDay, parseDue, relative, titleHref, type Heading, type ScrollAmount, type SidewaysAmount } from './util'
|
||||
|
||||
export type { ScrollAmount, SidewaysAmount }
|
||||
|
||||
@@ -74,6 +75,26 @@ const closeFocused = () => {
|
||||
return workspace.focused
|
||||
}
|
||||
|
||||
/**
|
||||
* The info sheet over the open document, at `section` or where it was last
|
||||
* left. Every part is read now, so each one's count is in its tab: the
|
||||
* headings from the pane, the versions and backlinks from the server.
|
||||
*/
|
||||
export async function openInfo(section?: InfoSection) {
|
||||
if (!requireOpen()) return
|
||||
const { headings, current } = hooks.headings()
|
||||
workspace.outline = headings
|
||||
workspace.outlineCursor = current
|
||||
await workspace.guard(async () => {
|
||||
// One after the other: each writes what is typed first, and two saves
|
||||
// of the same text at once would race.
|
||||
await workspace.loadHistory()
|
||||
await workspace.loadBacklinks()
|
||||
if (section) workspace.infoSection = section
|
||||
workspace.openSheet('info')
|
||||
})
|
||||
}
|
||||
|
||||
const requireOpen = () => {
|
||||
if (!workspace.open) {
|
||||
workspace.say('no document open', 'error')
|
||||
@@ -914,11 +935,7 @@ export const commands: CommandSpec[] = [
|
||||
group: 'documents',
|
||||
help: "the open document's earlier versions as diffs — j/k, Enter brings one back (gh)",
|
||||
run: async () => {
|
||||
if (!requireOpen()) return
|
||||
await workspace.guard(async () => {
|
||||
await workspace.loadHistory()
|
||||
workspace.overlay = 'history'
|
||||
})
|
||||
await openInfo('history')
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -927,11 +944,7 @@ export const commands: CommandSpec[] = [
|
||||
group: 'documents',
|
||||
help: 'the documents that link to the open one — j/k, Enter opens one (gb)',
|
||||
run: async () => {
|
||||
if (!requireOpen()) return
|
||||
await workspace.guard(async () => {
|
||||
await workspace.loadBacklinks()
|
||||
workspace.overlay = 'backlinks'
|
||||
})
|
||||
await openInfo('backlinks')
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -946,7 +959,7 @@ export const commands: CommandSpec[] = [
|
||||
await workspace.guard(async () => {
|
||||
await workspace.loadTrash()
|
||||
workspace.trashCursor = 0
|
||||
workspace.overlay = 'trash'
|
||||
workspace.openSheet('trash')
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -997,10 +1010,7 @@ export const commands: CommandSpec[] = [
|
||||
const id = node?.id
|
||||
if (!id) return workspace.say('nothing selected', 'error')
|
||||
if (!node?.shared) return workspace.say('not shared — :share first', 'error')
|
||||
await workspace.guard(async () => {
|
||||
const { url, sealed } = await workspace.mintShareLink(id)
|
||||
workspace.say(`new link → ${url} (copied) — the previous one is dead${sealedNote(sealed)}`)
|
||||
})
|
||||
await workspace.guard(() => workspace.renewShareLink(id))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1098,7 +1108,7 @@ export const commands: CommandSpec[] = [
|
||||
run: () => workspace.guard(async () => {
|
||||
await workspace.loadMedia()
|
||||
workspace.mediaCursor = 0
|
||||
workspace.overlay = 'media'
|
||||
workspace.openSheet('media')
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -1106,13 +1116,18 @@ export const commands: CommandSpec[] = [
|
||||
aliases: ['toc'],
|
||||
group: 'documents',
|
||||
help: "the open document's headings to pick from — gO, in the editor too",
|
||||
run: () => {
|
||||
if (!requireOpen()) return
|
||||
const { headings, current } = hooks.headings()
|
||||
if (headings.length === 0) return workspace.say('no headings in this document')
|
||||
workspace.outline = headings
|
||||
workspace.outlineCursor = current
|
||||
workspace.overlay = 'outline'
|
||||
run: () => openInfo('outline'),
|
||||
},
|
||||
{
|
||||
name: 'info',
|
||||
args: '[outline|history|backlinks|share]',
|
||||
group: 'documents',
|
||||
help: 'what is known about the open document: its headings, earlier versions, backlinks and sharing — Tab between them (gi)',
|
||||
complete: () => infoSections,
|
||||
run: (arg) => {
|
||||
const section = arg.trim()
|
||||
if (section && !infoSections.includes(section as InfoSection)) return workspace.say('usage: :info [outline|history|backlinks|share]', 'error')
|
||||
return openInfo((section || undefined) as InfoSection | undefined)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1431,7 +1446,8 @@ export const commands: CommandSpec[] = [
|
||||
group: 'view',
|
||||
help: 'show the key and command reference',
|
||||
run: () => {
|
||||
workspace.overlay = workspace.overlay === 'help' ? 'none' : 'help'
|
||||
if (workspace.overlay === 'help') workspace.closeSheet()
|
||||
else workspace.openSheet('help')
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,11 +18,13 @@ export type Context =
|
||||
| 'sheet'
|
||||
| 'help'
|
||||
| 'overview'
|
||||
| 'info'
|
||||
| 'outline'
|
||||
| 'media'
|
||||
| 'trash'
|
||||
| 'backlinks'
|
||||
| 'history'
|
||||
| 'share'
|
||||
| 'card'
|
||||
| 'drawer'
|
||||
| 'workspace'
|
||||
@@ -87,6 +89,7 @@ export const bindings = [
|
||||
{ context: 'tree', keys: ['zh', 'zl', 'zH', 'zL'], action: 'sideways', label: 'zl / zh · zL / zH', help: 'the wide table or code block at the top of the view, sideways — a little, or half its width' },
|
||||
{ context: 'tree', keys: [']]'], action: 'nextHeading', label: ']] / [[', help: 'next / previous heading of the document — in the editor too' },
|
||||
{ context: 'tree', keys: ['[['], action: 'previousHeading' },
|
||||
{ context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks and sharing — Tab between them" },
|
||||
{ context: 'tree', keys: ['gO'], action: 'outline', help: 'the headings as a list: j/k, Enter jumps there' },
|
||||
{ context: 'tree', keys: ['F'], action: 'hints', help: 'hints over the links and buttons in view — type one to follow it' },
|
||||
{ context: 'tree', label: 'gf / gx', help: 'in the editor: follow the link under the cursor / open it in a tab' },
|
||||
@@ -236,6 +239,9 @@ export const bindings = [
|
||||
{ context: 'overview', keys: ['Enter'], action: 'enter' },
|
||||
{ context: 'overview', keys: ['w', 'q'], action: 'close', caps: ['w', 'esc'], hint: 'close' },
|
||||
|
||||
{ context: 'info', keys: ['Tab'], action: 'next', caps: ['tab'], hint: 'next part' },
|
||||
{ context: 'info', keys: ['Shift-Tab'], action: 'previous' },
|
||||
|
||||
{ context: 'outline', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' },
|
||||
{ context: 'outline', keys: ['k', 'ArrowUp'], action: 'up' },
|
||||
{ context: 'outline', keys: ['G'], action: 'bottom' },
|
||||
@@ -264,6 +270,9 @@ export const bindings = [
|
||||
{ context: 'history', keys: ['G'], action: 'bottom' },
|
||||
{ context: 'history', keys: ['Enter'], action: 'restore', hint: 'restore' },
|
||||
|
||||
{ context: 'share', keys: ['s', 'Enter'], action: 'toggle', caps: ['s'], hint: 'share · stop' },
|
||||
{ context: 'share', keys: ['y'], action: 'link', hint: 'new link' },
|
||||
|
||||
// --- the editor's own keymap, described only ---------------------------------------
|
||||
{ context: 'editor', label: 'vim', help: 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes' },
|
||||
{ context: 'editor', label: 'drop · paste', help: 'an image, video or audio file attaches it' },
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* a context takes it.
|
||||
*
|
||||
* always Ctrl-S, Alt-h/j/k/l — heard in the editor too
|
||||
* a sheet help, trash, the card's back …; nothing under one hears a key
|
||||
* a sheet help, info, trash, the card's back …; nothing under one hears a key
|
||||
* drawer the scratchpad, while it is down
|
||||
* workspace Esc out of fullscreen, Q
|
||||
* writing a document being written takes every plain key back
|
||||
@@ -19,16 +19,18 @@ import { actions, type Run } from './actions'
|
||||
import { bindings, type Binding, type Context } from './keymap'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
/** The contexts that are sheets: one is up when the overlay of that name is. */
|
||||
const sheets = new Set<string>(['help', 'overview', 'outline', 'media', 'trash', 'backlinks', 'history', 'card'])
|
||||
/** The sheets with keys of their own, under the same name as the sheet. */
|
||||
const sheets = new Set<string>(['help', 'overview', 'media', 'trash', 'card'])
|
||||
|
||||
/** The stack, the topmost first. */
|
||||
export function stack(): Context[] {
|
||||
const modes: Context[] = ['always']
|
||||
const overlay = workspace.overlay
|
||||
if (overlay !== 'none') {
|
||||
// The info sheet hears the keys of the part on show, then its own Tab.
|
||||
if (overlay === 'info') modes.push(workspace.infoSection, 'info')
|
||||
// While the description is written, the card's back has no keys of its own.
|
||||
if (sheets.has(overlay) && !(overlay === 'card' && workspace.describing)) modes.push(overlay as Context)
|
||||
else if (sheets.has(overlay) && !(overlay === 'card' && workspace.describing)) modes.push(overlay as Context)
|
||||
return [...modes, 'sheet']
|
||||
}
|
||||
if (workspace.scratchOpen && !workspace.scratchEditing) modes.push('drawer')
|
||||
|
||||
@@ -33,6 +33,10 @@ export type Pane = 'list' | 'document'
|
||||
export type View = 'tree' | 'special'
|
||||
/** The tile of the special workspace that has focus. */
|
||||
export type SpecialTile = 'board' | 'agenda' | 'calendar'
|
||||
/** What floats over the tiles; one at a time. */
|
||||
export type Sheet = 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'info' | 'trash' | 'card'
|
||||
/** The info sheet's parts: what the open document holds, and what is around it. */
|
||||
export type InfoSection = 'outline' | 'history' | 'backlinks' | 'share'
|
||||
export type Flash = { text: string; kind: 'info' | 'error' } | null
|
||||
|
||||
/**
|
||||
@@ -321,7 +325,10 @@ class Workspace {
|
||||
pane = $state<Pane>('list')
|
||||
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. */
|
||||
specialFocus = $state<SpecialTile>('board')
|
||||
/**
|
||||
@@ -469,7 +476,7 @@ class Workspace {
|
||||
resolve(token)
|
||||
},
|
||||
}
|
||||
this.overlay = 'verify'
|
||||
this.openSheet('verify')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1152,7 +1159,7 @@ class Workspace {
|
||||
tile.draft = { ...tile.draft, name: revision.name, body: revision.body }
|
||||
tile.dirty = true
|
||||
await this.save({ quiet: true, restore: true, tile })
|
||||
this.overlay = 'none'
|
||||
this.closeSheet()
|
||||
this.say(`restored the version from ${formatStamp(revision.saved_at)} — :history has the one it replaced`)
|
||||
}
|
||||
|
||||
@@ -1730,7 +1737,7 @@ class Workspace {
|
||||
if (!this.card) return this.say('no card selected', 'error')
|
||||
this.taskCursor = 0
|
||||
this.describing = false
|
||||
this.overlay = 'card'
|
||||
this.openSheet('card')
|
||||
}
|
||||
|
||||
get scratchNote(): ScratchNote | undefined {
|
||||
@@ -1869,6 +1876,16 @@ class Workspace {
|
||||
this.overlay = 'none'
|
||||
}
|
||||
|
||||
openSheet(sheet: Sheet) {
|
||||
this.overlay = sheet
|
||||
}
|
||||
|
||||
/** Esc, q, a click beside it: the sheet away — the card's asks first. */
|
||||
closeSheet() {
|
||||
if (this.overlay === 'card') return this.closeCardSheet()
|
||||
this.overlay = 'none'
|
||||
}
|
||||
|
||||
/** `e`: write the description, from what it says now. */
|
||||
describe() {
|
||||
const card = this.card
|
||||
@@ -2117,6 +2134,13 @@ class Workspace {
|
||||
return { url, sealed }
|
||||
}
|
||||
|
||||
/** `:link`: a new link for a shared document, copied; the old one stops working. */
|
||||
async renewShareLink(id: number) {
|
||||
const { url, sealed } = await this.mintShareLink(id)
|
||||
this.say(`new link → ${url} (copied) — the previous one is dead${sealedNote(sealed)}`, sealed > 0 ? 'error' : undefined)
|
||||
for (const tile of this.tilesWith(id)) tile.open = await api.getNode(id)
|
||||
}
|
||||
|
||||
async copy(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
|
||||
Reference in new issue
Block a user