mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
hyprland
This commit is contained in:
1 parent
8a37ca5dfe
commit
9391b10af1
18 files changed
+841
-53
No files matched your search
+192
-18
@@ -7,6 +7,7 @@
|
||||
import DocumentPane from './lib/DocumentPane.svelte'
|
||||
import Help from './lib/Help.svelte'
|
||||
import Login from './lib/Login.svelte'
|
||||
import Overview from './lib/Overview.svelte'
|
||||
import MediaBrowser from './lib/MediaBrowser.svelte'
|
||||
import Rail from './lib/Rail.svelte'
|
||||
import Shared from './lib/Shared.svelte'
|
||||
@@ -43,6 +44,7 @@
|
||||
hooks.pickArchive = () => importInput?.click()
|
||||
hooks.focusList = () => shell?.focus()
|
||||
hooks.openCommandLine = (initial) => (cmdline = initial)
|
||||
hooks.openOverview = () => void openOverview()
|
||||
})
|
||||
|
||||
/*
|
||||
@@ -92,14 +94,87 @@
|
||||
const offers: [string, string][] = [
|
||||
['o', 'write a new document'],
|
||||
['O', 'make a folder'],
|
||||
['R', 'rename what is marked'],
|
||||
['b', "this space's board"],
|
||||
['j / k', 'browse, l opens'],
|
||||
['h', 'up one level'],
|
||||
['1 … 9', 'switch space, Tab cycles'],
|
||||
['w', 'overview of every space'],
|
||||
['b', "this space's board"],
|
||||
['/', 'search this space'],
|
||||
['?', 'help'],
|
||||
]
|
||||
|
||||
/*
|
||||
* Spaces are workspaces: digits jump to one, shifted digits send the node
|
||||
* there, Tab walks along the bar. `event.code` rather than `event.key`, so
|
||||
* Shift+1 means space 1 whatever symbol the layout puts on that key.
|
||||
*/
|
||||
function spaceKey(event: KeyboardEvent): boolean {
|
||||
const digit = /^Digit(\d)$/.exec(event.code)?.[1]
|
||||
if (digit !== undefined) {
|
||||
const index = digit === '0' ? 9 : Number(digit) - 1
|
||||
void workspace.guard(() =>
|
||||
event.shiftKey ? workspace.sendToSpace(index) : workspace.switchSpace(index),
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
void workspace.guard(() => workspace.cycleSpace(event.shiftKey ? -1 : 1))
|
||||
return true
|
||||
}
|
||||
if (event.key === 'w') {
|
||||
void openOverview()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function openOverview() {
|
||||
await workspace.guard(() => workspace.loadOverview())
|
||||
workspace.overviewCursor = Math.max(
|
||||
workspace.overview.findIndex((entry) => entry.space.id === workspace.spaceId),
|
||||
0,
|
||||
)
|
||||
workspace.overlay = 'overview'
|
||||
}
|
||||
|
||||
/** The overview's own keys: hjkl across the grid, a digit or Enter to go. */
|
||||
function overviewKey(event: KeyboardEvent): boolean {
|
||||
const count = workspace.overview.length
|
||||
const columns = hooks.overviewColumns()
|
||||
const go = (index: number) => {
|
||||
const entry = workspace.overview[index]
|
||||
if (!entry) return
|
||||
workspace.overlay = 'none'
|
||||
void workspace.guard(() => workspace.enterSpace(entry.space.id))
|
||||
}
|
||||
const digit = /^Digit(\d)$/.exec(event.code)?.[1]
|
||||
if (digit !== undefined) {
|
||||
go(digit === '0' ? 9 : Number(digit) - 1)
|
||||
return true
|
||||
}
|
||||
const step: Record<string, number> = { h: -1, ArrowLeft: -1, l: 1, ArrowRight: 1, k: -columns, ArrowUp: -columns, j: columns, ArrowDown: columns }
|
||||
if (event.key in step) {
|
||||
workspace.overviewCursor = Math.min(Math.max(workspace.overviewCursor + step[event.key], 0), count - 1)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
go(workspace.overviewCursor)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'w' || event.key === 'q') {
|
||||
workspace.overlay = 'none'
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Alt+h / Alt+l: focus moves between the panes, out of the editor too. */
|
||||
function focusPane(pane: 'list' | 'document') {
|
||||
if (pane === 'document' && !workspace.open) return
|
||||
workspace.pane = pane
|
||||
if (pane === 'document' && workspace.editing) hooks.focusEditor()
|
||||
else shell?.focus()
|
||||
}
|
||||
|
||||
/*
|
||||
* The board reads the same keys as the list, one dimension wider: h/l walk
|
||||
* the lists, j/k the cards, and the shifted pair takes the card along. Only
|
||||
@@ -185,6 +260,19 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (event.altKey && !event.ctrlKey && !event.metaKey && (event.key === 'h' || event.key === 'l')) {
|
||||
event.preventDefault()
|
||||
focusPane(event.key === 'h' ? 'list' : 'document')
|
||||
return
|
||||
}
|
||||
|
||||
if (workspace.overlay === 'overview' && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
if (overviewKey(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
if (workspace.overlay !== 'none') {
|
||||
workspace.overlay = 'none'
|
||||
@@ -195,6 +283,10 @@
|
||||
shell?.focus()
|
||||
return
|
||||
}
|
||||
if (workspace.fullscreen && !workspace.editing) {
|
||||
workspace.fullscreen = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (workspace.overlay !== 'none') {
|
||||
@@ -222,6 +314,16 @@
|
||||
const previous = pending
|
||||
pending = ''
|
||||
|
||||
if (spaceKey(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (key === 'f') {
|
||||
event.preventDefault()
|
||||
workspace.fullscreen = !workspace.fullscreen
|
||||
return
|
||||
}
|
||||
|
||||
if (workspace.view === 'board') {
|
||||
if (previous === 'g' && key === 'g') {
|
||||
event.preventDefault()
|
||||
@@ -239,11 +341,11 @@
|
||||
}
|
||||
|
||||
/*
|
||||
* With a document open and not being edited, the reading pane owns j/k:
|
||||
* the keys scroll what is on screen, and `h` backs out to the list, where
|
||||
* they walk the rows again.
|
||||
* The focused pane owns j/k. In the document they scroll what is on
|
||||
* screen; `h` moves focus to the list with the document still open, where
|
||||
* they walk the rows again, and `l` goes back.
|
||||
*/
|
||||
const reading = !!workspace.open && !workspace.editing
|
||||
const reading = !!workspace.open && !workspace.editing && workspace.pane === 'document'
|
||||
|
||||
// Two-key sequences: gg and dd.
|
||||
if (previous === 'g' && key === 'g') {
|
||||
@@ -284,7 +386,8 @@
|
||||
case 'l':
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
void workspace.guard(() => workspace.openSelected())
|
||||
if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document')
|
||||
else void workspace.guard(() => workspace.openSelected())
|
||||
break
|
||||
case 'm':
|
||||
// Pick a node up here, walk to a folder, put it down with `p`.
|
||||
@@ -323,10 +426,10 @@
|
||||
break
|
||||
case 'h':
|
||||
case 'ArrowLeft':
|
||||
// Out of the document if one is open, otherwise up a level — the same
|
||||
// key means "leftwards" at both depths.
|
||||
// Leftwards at every depth: from the document to the list, from the
|
||||
// list up a level.
|
||||
event.preventDefault()
|
||||
if (workspace.open) void runCommand('q')
|
||||
if (workspace.open && workspace.pane === 'document') focusPane('list')
|
||||
else void workspace.guard(() => workspace.up())
|
||||
break
|
||||
case 'q':
|
||||
@@ -382,13 +485,34 @@
|
||||
|
||||
<!-- The opening in the frame: everything that scrolls is seen through it. -->
|
||||
<main class="porthole">
|
||||
<!-- Keyed on the space, so a switch slides the new one in, as the desktop
|
||||
does between workspaces: from below when moving right, above when left. -->
|
||||
{#key workspace.spaceId}
|
||||
{#if workspace.view === 'board'}
|
||||
<!-- The board takes the whole width: its columns are the two panes. -->
|
||||
<div class="panes board"><Board /></div>
|
||||
<div class="panes board switch" style="--dir: {workspace.switchDirection}">
|
||||
<div class="window focused"><Board /></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="panes" class:reading={!!workspace.open}>
|
||||
<div class="list"><Sidebar /></div>
|
||||
<div class="entry">
|
||||
<div
|
||||
class="panes switch"
|
||||
class:reading={!!workspace.open}
|
||||
class:full={workspace.fullscreen}
|
||||
class:focus-doc={!!workspace.open && workspace.pane === 'document'}
|
||||
style="--dir: {workspace.switchDirection}"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="window list"
|
||||
class:focused={!workspace.open || workspace.pane === 'list'}
|
||||
onpointerdown={() => (workspace.pane = 'list')}
|
||||
><Sidebar /></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="window entry"
|
||||
class:focused={!!workspace.open && workspace.pane === 'document'}
|
||||
onpointerdown={() => workspace.open && (workspace.pane = 'document')}
|
||||
>
|
||||
{#if workspace.open}
|
||||
<DocumentPane />
|
||||
{:else}
|
||||
@@ -409,6 +533,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
</main>
|
||||
|
||||
<StatusBar />
|
||||
@@ -421,7 +546,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
||||
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
|
||||
>
|
||||
{#if workspace.overlay === 'help'}<Help />{:else}<MediaBrowser />{/if}
|
||||
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else}<MediaBrowser />{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -473,15 +598,64 @@
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/*
|
||||
* The panes tile the porthole like windows tile a workspace: a gap round
|
||||
* and between them, each rounded to sit concentric with the frame.
|
||||
*/
|
||||
.panes {
|
||||
--gap: 7px;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(32ch, 24vw) 1fr;
|
||||
gap: var(--gap);
|
||||
padding: var(--gap);
|
||||
}
|
||||
|
||||
.panes.board { grid-template-columns: 1fr; }
|
||||
|
||||
.list, .entry { min-width: 0; min-height: 0; overflow: hidden; }
|
||||
/*
|
||||
* A window: opaque, a hairline border in overlay. The focused one gets the
|
||||
* desktop's active border — a light gradient at 135° — and its shadow.
|
||||
*/
|
||||
.window {
|
||||
--pane-bg: color-mix(in oklab, var(--surface) 40%, var(--base));
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--frame-radius) - var(--gap));
|
||||
background:
|
||||
linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box,
|
||||
linear-gradient(var(--overlay), var(--overlay)) border-box;
|
||||
transition: box-shadow var(--t-base) var(--ease-glide);
|
||||
}
|
||||
.window.list { --pane-bg: color-mix(in oklab, var(--surface) 60%, var(--base)); }
|
||||
.window.focused {
|
||||
background:
|
||||
linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box,
|
||||
linear-gradient(135deg, var(--light), var(--light-dim)) border-box;
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 0.45);
|
||||
}
|
||||
/* Only one pane on screen: nothing to tell apart, so no active border. */
|
||||
.panes:not(.reading) .window.list,
|
||||
.panes.board .window {
|
||||
background:
|
||||
linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box,
|
||||
linear-gradient(var(--overlay), var(--overlay)) border-box;
|
||||
box-shadow: none;
|
||||
}
|
||||
.panes:not(.reading) .window.entry { border-color: transparent; background: transparent; }
|
||||
|
||||
/* `f`: the focused pane fills the porthole. */
|
||||
.panes.full { grid-template-columns: 1fr; }
|
||||
.panes.full .window:not(.focused) { display: none; }
|
||||
.panes.full:not(.reading) .window.entry { display: none; }
|
||||
|
||||
/* hypr: workspaces, slidefadevert 18%, 550ms on the drift curve. */
|
||||
.switch { animation: slidefadevert 550ms var(--ease-drift) both; }
|
||||
@keyframes slidefadevert {
|
||||
from { opacity: 0; transform: translateY(calc(var(--dir, 1) * 18%)); }
|
||||
}
|
||||
|
||||
.placeholder { display: grid; place-items: center; height: 100%; padding: 24px; }
|
||||
.standby { width: min(100%, 44ch); }
|
||||
@@ -515,7 +689,7 @@
|
||||
.hidden { display: none; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.panes { grid-template-columns: 1fr; }
|
||||
.panes { grid-template-columns: 1fr; --gap: 4px; }
|
||||
.panes .entry { display: none; }
|
||||
.panes.reading .list { display: none; }
|
||||
.panes.reading .entry { display: block; }
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
--ease-snap: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-glide: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-in: cubic-bezier(0.55, 0, 0.9, 0.35);
|
||||
--ease-drift: cubic-bezier(0.35, 0, 0.15, 1);
|
||||
--t-fast: 120ms;
|
||||
--t-base: 220ms;
|
||||
--t-slow: 380ms;
|
||||
|
||||
+16
-3
@@ -26,13 +26,14 @@
|
||||
<span class="divider"></span>
|
||||
|
||||
<nav class="spaces" aria-label="spaces">
|
||||
{#each workspace.spaces as space (space.id)}
|
||||
{#each workspace.spaces as space, index (space.id)}
|
||||
<button
|
||||
class="pill"
|
||||
aria-current={space.id === workspace.spaceId}
|
||||
title="{space.name} ({index < 10 ? (index + 1) % 10 : ''})"
|
||||
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
|
||||
>
|
||||
{space.slug}
|
||||
{#if index < 10}<span class="n">{(index + 1) % 10}</span>{/if}{space.slug}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
|
||||
@@ -129,10 +130,19 @@
|
||||
.crumbs { flex: 0 1 auto; }
|
||||
|
||||
/* Workspace pills: muted at rest, light on hover, inverted when current. */
|
||||
/* The focused pill widens, as the desktop's does, over the slow duration. */
|
||||
.pill {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 22px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
transition:
|
||||
padding var(--t-slow) var(--ease-snap),
|
||||
background-color var(--t-base) var(--ease-glide),
|
||||
color var(--t-base) var(--ease-glide);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
@@ -142,7 +152,10 @@
|
||||
background: transparent;
|
||||
}
|
||||
.pill:hover { background: transparent; color: var(--light); }
|
||||
.pill[aria-current='true'] { background: var(--light); color: var(--ink); }
|
||||
.pill[aria-current='true'] { background: var(--light); color: var(--ink); padding: 0 12px; }
|
||||
.n { color: var(--overlay); font-size: 10px; }
|
||||
.pill:hover .n { color: var(--muted); }
|
||||
.pill[aria-current='true'] .n { color: var(--ink); opacity: 0.55; }
|
||||
.pill:focus-visible { background: var(--light); color: var(--ink); }
|
||||
.pill.add { color: var(--overlay); }
|
||||
.pill.add:hover { color: var(--light); }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { hooks, runCommand, type ScrollAmount } from './commands'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import Outline from './Outline.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatStamp, formatWeekday, isTouchDevice, relative } from './util'
|
||||
|
||||
@@ -17,6 +18,7 @@
|
||||
const sharePath = $derived(doc.share_token ? `${location.origin}/s/${doc.share_token}#…` : null)
|
||||
|
||||
let reader = $state<HTMLElement>()
|
||||
let article = $state<HTMLElement>()
|
||||
|
||||
/*
|
||||
* j/k reach this pane through the window handler in App.svelte, which has no
|
||||
@@ -24,7 +26,9 @@
|
||||
* says whether it scrolled anything at all.
|
||||
*/
|
||||
$effect(() => {
|
||||
hooks.scrollReader = (amount: ScrollAmount) => {
|
||||
// A space switch remounts this pane, and the old one's cleanup can run
|
||||
// after the new one has set the hook — so it only clears its own.
|
||||
const scroll = (amount: ScrollAmount) => {
|
||||
if (!reader) return false
|
||||
const line = parseFloat(getComputedStyle(reader).lineHeight) || 24
|
||||
const half = reader.clientHeight / 2
|
||||
@@ -46,7 +50,10 @@
|
||||
}
|
||||
return true
|
||||
}
|
||||
return () => (hooks.scrollReader = () => false)
|
||||
hooks.scrollReader = scroll
|
||||
return () => {
|
||||
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -144,7 +151,7 @@
|
||||
{:else}
|
||||
<div class="body reading" bind:this={reader}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<article class="md resolve" onclick={follow}>
|
||||
<article class="md resolve" bind:this={article} onclick={follow}>
|
||||
{#if doc.name.trim()}<h1 class="entrytitle">{doc.name}</h1>{/if}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
||||
{@html html}
|
||||
@@ -152,6 +159,9 @@
|
||||
<p class="muted">this document is empty — press <span class="key">i</span> to write.</p>
|
||||
{/if}
|
||||
</article>
|
||||
<aside class="side">
|
||||
<Outline root={article} scroller={reader} source={html} />
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -249,7 +259,29 @@
|
||||
.loading { padding: 16px 28px; }
|
||||
|
||||
.body { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.reading { overflow-y: auto; padding: 18px 28px 30vh; }
|
||||
/*
|
||||
* The text is a column in the middle of the pane, as on a page; the margin
|
||||
* to its right holds the outline while there is room for one.
|
||||
*/
|
||||
.reading {
|
||||
overflow-y: auto;
|
||||
padding: 18px 28px 30vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 46rem) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.reading > article { grid-column: 2; max-width: none; }
|
||||
.side {
|
||||
grid-column: 3;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
max-width: 30ch;
|
||||
padding: 6px 0 0 44px;
|
||||
}
|
||||
@container (max-width: 1000px) {
|
||||
.side { display: none; }
|
||||
}
|
||||
.entrytitle { margin-top: 0 !important; font-size: 2.1em !important; }
|
||||
|
||||
.key {
|
||||
@@ -275,6 +307,7 @@
|
||||
.row { flex-direction: column; align-items: flex-start; }
|
||||
.sharebar { margin: 0 16px 8px; }
|
||||
.title { margin: 8px 16px 4px; }
|
||||
.reading { padding: 14px 16px 30vh; }
|
||||
.reading { padding: 14px 16px 30vh; grid-template-columns: minmax(0, 1fr); }
|
||||
.reading > article { grid-column: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
let host = $state<HTMLDivElement>()
|
||||
let view: EditorView | undefined
|
||||
const focus = () => view?.focus()
|
||||
|
||||
const markdownHighlight = HighlightStyle.define([
|
||||
{ tag: tags.heading, color: 'var(--light)', fontWeight: '600' },
|
||||
@@ -208,7 +209,7 @@
|
||||
parent: host!,
|
||||
})
|
||||
|
||||
hooks.focusEditor = () => view?.focus()
|
||||
hooks.focusEditor = focus
|
||||
hooks.insertText = insertAtCursor
|
||||
view.focus()
|
||||
|
||||
@@ -234,9 +235,12 @@
|
||||
onDestroy(() => {
|
||||
view?.destroy()
|
||||
view = undefined
|
||||
hooks.focusEditor = () => {}
|
||||
hooks.insertText = () => {}
|
||||
workspace.vimMode = 'normal'
|
||||
// Only clear the hooks if no newer editor has taken them already.
|
||||
if (hooks.focusEditor === focus) {
|
||||
hooks.focusEditor = () => {}
|
||||
hooks.insertText = () => {}
|
||||
workspace.vimMode = 'normal'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,18 +3,24 @@
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
const keys: [string, string][] = [
|
||||
['j / k · ↓ ↑', 'move down / up the list — scroll the open document'],
|
||||
['1 … 9 · 0', 'switch to that space, like a workspace'],
|
||||
['Shift 1 … 9', 'send the marked or selected node to that space'],
|
||||
['Tab · Shift-Tab', 'next / previous space'],
|
||||
['w', 'overview of every space — hjkl, then Enter'],
|
||||
['f', 'fullscreen the focused pane; again or Esc to leave'],
|
||||
['Alt-h / Alt-l', 'focus the list / the document, from the editor too'],
|
||||
['j / k · ↓ ↑', 'move down / up the list — scroll the document when it has focus'],
|
||||
['gg / G', 'first / last row, or top / bottom of the document'],
|
||||
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
|
||||
['Enter · l', 'open a document, or enter a folder'],
|
||||
['h', 'up one level, or back out of a document'],
|
||||
['h', 'from the document to the list, from the list up a level'],
|
||||
['o', 'new document here, straight into insert mode'],
|
||||
['O', 'new folder here'],
|
||||
['i · a', 'edit the open document'],
|
||||
['R', 'rename what is marked — document, folder or card'],
|
||||
['m / p', 'pick a node up, then drop it into the folder you walk to'],
|
||||
['b', "open this space's board"],
|
||||
['Esc · q', 'back out to the list'],
|
||||
['Esc · q', 'close the document'],
|
||||
['/', 'full-text search, inside the current space'],
|
||||
['n', 'clear the search'],
|
||||
['s', 'toggle sharing for the document'],
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* The headings of a rendered document, beside it, with the one being read
|
||||
* lit — the section tracking narl.io's bar does, turned on its side. It
|
||||
* reads the article after it renders, so it works on any markdown without
|
||||
* the renderer knowing about it.
|
||||
*/
|
||||
import { slugify } from './util'
|
||||
|
||||
interface Props {
|
||||
/** The rendered article. */
|
||||
root: HTMLElement | undefined
|
||||
/** What scrolls: the pane, or the page in the share view. */
|
||||
scroller: HTMLElement | undefined
|
||||
/** Anything that changes the rendered headings, to read them again. */
|
||||
source: string
|
||||
}
|
||||
|
||||
const { root, scroller, source }: Props = $props()
|
||||
|
||||
type Heading = { id: string; text: string; level: number }
|
||||
|
||||
let headings = $state<Heading[]>([])
|
||||
let current = $state('')
|
||||
|
||||
$effect(() => {
|
||||
void source
|
||||
if (!root || !scroller) return
|
||||
|
||||
const seen = new Map<string, number>()
|
||||
const found: Heading[] = []
|
||||
const elements = Array.from(root.querySelectorAll<HTMLElement>('h2, h3'))
|
||||
for (const element of elements) {
|
||||
const text = element.textContent?.trim() ?? ''
|
||||
const base = slugify(text) || 'section'
|
||||
const n = (seen.get(base) ?? 0) + 1
|
||||
seen.set(base, n)
|
||||
element.id = n === 1 ? base : `${base}-${n}`
|
||||
found.push({ id: element.id, text, level: element.tagName === 'H2' ? 2 : 3 })
|
||||
}
|
||||
headings = found
|
||||
current = found[0]?.id ?? ''
|
||||
|
||||
// The section whose heading last crossed the upper third is the one being
|
||||
// read; a band rather than a line, so a short section still registers.
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) current = entry.target.id
|
||||
}
|
||||
},
|
||||
{ root: scroller, rootMargin: '-10% 0px -70% 0px' },
|
||||
)
|
||||
for (const element of elements) observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
})
|
||||
|
||||
function jump(id: string) {
|
||||
root?.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
current = id
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if headings.length >= 2}
|
||||
<nav class="outline" aria-label="outline">
|
||||
<h2 class="label rule">outline</h2>
|
||||
<ol>
|
||||
{#each headings as heading (heading.id)}
|
||||
<li class:sub={heading.level === 3}>
|
||||
<button class:on={heading.id === current} onclick={() => jump(heading.id)}>
|
||||
<span class="marker"></span>
|
||||
<span class="text">{heading.text}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.outline { font-size: 12px; }
|
||||
h2 { margin: 0 0 10px; font-size: 10px; font-weight: 400; }
|
||||
|
||||
ol { list-style: none; margin: 0; padding: 0; }
|
||||
li.sub { padding-left: 15px; }
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 3px 6px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
button:hover { background: transparent; color: var(--light); }
|
||||
button:active { transform: none; }
|
||||
button.on { color: var(--light); }
|
||||
button.on .marker { background: var(--light); }
|
||||
button:focus-visible { background: var(--light); color: var(--ink); }
|
||||
|
||||
.marker { width: 5px; height: 5px; transform: translateY(-2px); }
|
||||
.text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* `w`: every space at once, the way the desktop's overview shows every
|
||||
* workspace with its windows — here, with what was last written in it. The
|
||||
* keys live in App.svelte with the others; this only draws and tells them
|
||||
* how many cards fit in a row.
|
||||
*/
|
||||
import { api, type NodeSummary } from './api'
|
||||
import { hooks } from './commands'
|
||||
import { workspace } from './store.svelte'
|
||||
import { relative } from './util'
|
||||
|
||||
let grid = $state<HTMLOListElement>()
|
||||
|
||||
$effect(() => {
|
||||
hooks.overviewColumns = () =>
|
||||
grid ? getComputedStyle(grid).gridTemplateColumns.split(' ').length : 1
|
||||
return () => (hooks.overviewColumns = () => 1)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const index = workspace.overviewCursor
|
||||
grid?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
function enter(spaceId: number) {
|
||||
workspace.overlay = 'none'
|
||||
void workspace.guard(() => workspace.enterSpace(spaceId))
|
||||
}
|
||||
|
||||
/** A recent document opens in place, in its own space. */
|
||||
function open(doc: NodeSummary) {
|
||||
workspace.overlay = 'none'
|
||||
void workspace.guard(async () => {
|
||||
const node = await api.getNode(doc.id)
|
||||
await workspace.openPath(node.path.map((crumb) => crumb.slug).join('/'))
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<span class="label rule"><span class="marker"></span><span class="title">overview</span> {workspace.overview.length} spaces</span>
|
||||
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||
</header>
|
||||
|
||||
<ol class="grid" bind:this={grid}>
|
||||
{#each workspace.overview as { space, recent }, index (space.id)}
|
||||
<li>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="card"
|
||||
class:at={index === workspace.overviewCursor}
|
||||
class:current={space.id === workspace.spaceId}
|
||||
data-index={index}
|
||||
onclick={() => enter(space.id)}
|
||||
onpointerenter={() => (workspace.overviewCursor = index)}
|
||||
>
|
||||
<div class="top">
|
||||
<span class="index">{index < 10 ? (index + 1) % 10 : ''}</span>
|
||||
<span class="name">{space.name}</span>
|
||||
{#if space.has_board}<span class="tag">board</span>{/if}
|
||||
<span class="count">{space.child_count}</span>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{#each recent as doc (doc.id)}
|
||||
<li>
|
||||
<button
|
||||
class="doc"
|
||||
onclick={(event) => {
|
||||
event.stopPropagation()
|
||||
open(doc)
|
||||
}}
|
||||
>
|
||||
<span class="text">{doc.name.trim() || doc.excerpt.trim() || doc.slug}</span>
|
||||
<span class="when">{relative(doc.updated_at)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="empty">nothing written yet</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<footer class="label">h j k l move · 1 … 9 or enter go · w closes</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
width: min(100%, 140ch);
|
||||
max-height: 100%;
|
||||
background: color-mix(in oklab, var(--hull) 97%, transparent);
|
||||
border: 1px solid var(--surface-alt);
|
||||
border-radius: var(--frame-radius);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
header { display: flex; align-items: center; gap: 12px; }
|
||||
header .label { flex: 1; }
|
||||
header .marker { background: var(--light); }
|
||||
header .title { color: var(--light); }
|
||||
|
||||
.grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 2px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(30ch, 1fr));
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* A card is a miniature window: overlay border at rest, the active border
|
||||
* gradient under the cursor. The space being worked in is the inverted one,
|
||||
* as its pill is on the bar.
|
||||
*/
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
padding: 12px 12px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-m);
|
||||
background:
|
||||
linear-gradient(var(--surface), var(--surface)) padding-box,
|
||||
linear-gradient(var(--overlay), var(--overlay)) border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card.at {
|
||||
background:
|
||||
linear-gradient(var(--surface), var(--surface)) padding-box,
|
||||
linear-gradient(135deg, var(--light), var(--light-dim)) border-box;
|
||||
}
|
||||
|
||||
.top { display: flex; align-items: center; gap: 10px; }
|
||||
.index {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 22px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
.current .index { background: var(--light); color: var(--ink); }
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.at .name, .current .name { color: var(--light); }
|
||||
.tag, .count {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.count { font-variant-numeric: tabular-nums; letter-spacing: 0; }
|
||||
|
||||
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.doc {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-s);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--subtle);
|
||||
}
|
||||
.doc:active { transform: none; }
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.when { flex: none; font-size: 10px; color: var(--muted); }
|
||||
.empty { padding: 4px 8px; font-size: 12px; color: var(--muted); }
|
||||
|
||||
footer { font-size: 10px; }
|
||||
</style>
|
||||
@@ -29,6 +29,8 @@
|
||||
{ key: 'o', caption: 'new', title: 'new document (o)', run: () => runCommand('new') },
|
||||
{ key: 'O', caption: 'dir', title: 'new folder (O)', run: () => hooks.openCommandLine(':mkdir ') },
|
||||
...(workspace.space ? [board] : []),
|
||||
{ key: 'w', caption: 'spaces', title: 'overview of every space (w)', run: () => hooks.openOverview() },
|
||||
{ key: 'f', caption: 'full', title: 'fullscreen the focused pane (f)', run: () => (workspace.fullscreen = !workspace.fullscreen), on: workspace.fullscreen },
|
||||
{ key: '/', caption: 'find', title: 'search (/)', run: () => hooks.openCommandLine('/') },
|
||||
{ key: ':', caption: 'cmd', title: 'command line (:)', run: () => hooks.openCommandLine(':') },
|
||||
{ key: '?', caption: 'help', title: 'help (?)', run: () => runCommand('help') },
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { api, type SharedDocument } from './api'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import Outline from './Outline.svelte'
|
||||
import { formatStamp, formatWeekday } from './util'
|
||||
|
||||
const { token, shareKey }: { token: string; shareKey: string } = $props()
|
||||
|
||||
let doc = $state<SharedDocument | null>(null)
|
||||
let error = $state('')
|
||||
let page = $state<HTMLElement>()
|
||||
let article = $state<HTMLElement>()
|
||||
const html = $derived(doc ? renderMarkdown(doc.body, { token, key: shareKey }) : '')
|
||||
|
||||
$effect(() => {
|
||||
if (!shareKey) {
|
||||
@@ -23,11 +27,11 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<main bind:this={page}>
|
||||
{#if error}
|
||||
<p class="alarm">{error}</p>
|
||||
{:else if doc}
|
||||
<article class="md resolve">
|
||||
<article class="md resolve" bind:this={article}>
|
||||
<div class="meta label rule">
|
||||
<span class="marker"></span>
|
||||
<span>{formatStamp(doc.created_at)} {formatWeekday(doc.created_at)}</span>
|
||||
@@ -35,8 +39,11 @@
|
||||
</div>
|
||||
{#if doc.name.trim()}<h1>{doc.name}</h1>{/if}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
||||
{@html renderMarkdown(doc.body, { token, key: shareKey })}
|
||||
{@html html}
|
||||
</article>
|
||||
<aside class="side">
|
||||
<Outline root={article} scroller={page} source={html} />
|
||||
</aside>
|
||||
<footer class="label">— written in ~/workspace</footer>
|
||||
{:else}
|
||||
<p class="label">loading<span class="cursor">_</span></p>
|
||||
@@ -44,19 +51,34 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
/* The same page as the reading pane: a centred column, the outline in the margin. */
|
||||
main {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 56px 20px 20vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 46rem) minmax(0, 1fr);
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
container-type: inline-size;
|
||||
background:
|
||||
radial-gradient(120% 70% at 30% 0%, #141713 0%, transparent 60%),
|
||||
var(--base);
|
||||
}
|
||||
|
||||
article { width: min(100%, 72ch); }
|
||||
article, footer, p { grid-column: 2; }
|
||||
article { max-width: none; }
|
||||
.side {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
max-width: 30ch;
|
||||
padding: 4px 0 0 44px;
|
||||
}
|
||||
@container (max-width: 1000px) {
|
||||
.side { display: none; }
|
||||
}
|
||||
|
||||
.meta { margin-bottom: 28px; font-size: 10px; }
|
||||
.meta .marker { background: var(--light); }
|
||||
@@ -65,6 +87,5 @@
|
||||
|
||||
h1 { margin-top: 0 !important; font-size: 2.1em !important; }
|
||||
|
||||
footer { width: min(100%, 72ch); margin-top: 56px; font-size: 10px; }
|
||||
p { width: min(100%, 72ch); }
|
||||
footer { margin-top: 56px; font-size: 10px; }
|
||||
</style>
|
||||
@@ -79,9 +79,6 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
--pane: color-mix(in oklab, var(--surface) 55%, var(--base));
|
||||
background: var(--pane);
|
||||
border-right: 1px solid var(--rim);
|
||||
}
|
||||
|
||||
.head {
|
||||
@@ -120,7 +117,7 @@
|
||||
z-index: 1;
|
||||
padding: 14px 8px 6px 10px;
|
||||
font-size: 10px;
|
||||
background: linear-gradient(var(--pane) 70%, transparent);
|
||||
background: linear-gradient(var(--pane-bg) 70%, transparent);
|
||||
}
|
||||
.day { color: var(--subtle); }
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
{workspace.flash?.text ?? ''}
|
||||
</span>
|
||||
|
||||
{#if workspace.fullscreen}<span class="flag">full</span>{/if}
|
||||
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
|
||||
<span class="hint">
|
||||
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
|
||||
@@ -75,6 +76,7 @@
|
||||
.readout { color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||
.unit { color: var(--muted); margin-left: 1px; }
|
||||
.hint { color: var(--muted); letter-spacing: 0.06em; }
|
||||
.flag { color: var(--light); letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
|
||||
.tools { display: none; }
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ export interface WorkspaceNode extends NodeSummary {
|
||||
}
|
||||
|
||||
/** A public key allowed to mount the workspace over SFTP. */
|
||||
/** A space as the overview shows it: itself, and what was last written in it. */
|
||||
export interface SpaceOverview {
|
||||
space: NodeSummary
|
||||
recent: NodeSummary[]
|
||||
}
|
||||
|
||||
export interface SshKey {
|
||||
id: number
|
||||
name: string
|
||||
@@ -152,6 +158,8 @@ export const api = {
|
||||
|
||||
listSpaces: () => request<NodeSummary[]>('/spaces'),
|
||||
|
||||
overview: () => request<SpaceOverview[]>('/spaces/overview'),
|
||||
|
||||
createSpace: (name: string, has_board = true) =>
|
||||
request<WorkspaceNode>('/spaces', { method: 'POST', body: body({ name, has_board }) }),
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ export const hooks = {
|
||||
/* Filled in while a document is on screen and not being edited: the reading
|
||||
pane owns the scrollback, so the list keys have somewhere to send it. */
|
||||
scrollReader: (_amount: ScrollAmount): boolean => false,
|
||||
/** How many space cards the overview fits in a row, for j/k to step by. */
|
||||
overviewColumns: (): number => 1,
|
||||
openOverview: () => {},
|
||||
openCommandLine: (_initial: string) => {},
|
||||
}
|
||||
|
||||
|
||||
+104
-3
@@ -7,6 +7,7 @@ import {
|
||||
type WorkspaceNode,
|
||||
type MediaFile,
|
||||
type NodeSummary,
|
||||
type SpaceOverview,
|
||||
} from './api'
|
||||
|
||||
export type Pane = 'list' | 'document'
|
||||
@@ -14,6 +15,9 @@ export type Pane = 'list' | 'document'
|
||||
export type View = 'tree' | 'board'
|
||||
export type Flash = { text: string; kind: 'info' | 'error' } | null
|
||||
|
||||
/** Where a space was left: the folder, the open document, the row, the pane. */
|
||||
type Spot = { parentId: number; openId: number | null; cursor: number; pane: Pane }
|
||||
|
||||
/**
|
||||
* The whole application state. It is deliberately one object: the keyboard
|
||||
* layer needs to see everything at once to decide what a key means.
|
||||
@@ -60,8 +64,21 @@ class Workspace {
|
||||
vimMode = $state('normal')
|
||||
flash = $state<Flash>(null)
|
||||
|
||||
overlay = $state<'none' | 'help' | 'media'>('none')
|
||||
overlay = $state<'none' | 'help' | 'media' | 'overview'>('none')
|
||||
media = $state<MediaFile[]>([])
|
||||
overview = $state<SpaceOverview[]>([])
|
||||
overviewCursor = $state(0)
|
||||
|
||||
/** `f`: the focused pane takes the whole porthole, like a fullscreen window. */
|
||||
fullscreen = $state(false)
|
||||
/** +1 when the last space switch went right along the bar, -1 when left. */
|
||||
switchDirection = $state(1)
|
||||
|
||||
/*
|
||||
* Spaces behave like the desktop's workspaces: each keeps the folder and the
|
||||
* document it was left on, and coming back finds them where they were.
|
||||
*/
|
||||
#spots: Record<number, Spot> = JSON.parse(localStorage.getItem('diary:spots') ?? '{}')
|
||||
|
||||
vimEnabled = $state(localStorage.getItem('diary:vim') !== 'off')
|
||||
|
||||
@@ -246,11 +263,50 @@ class Workspace {
|
||||
if (start) await this.enterSpace(start.id)
|
||||
}
|
||||
|
||||
#rememberSpot() {
|
||||
if (this.spaceId === null || this.parentId === null) return
|
||||
this.#spots[this.spaceId] = {
|
||||
parentId: this.parentId,
|
||||
openId: this.open?.id ?? null,
|
||||
cursor: this.cursor,
|
||||
pane: this.pane,
|
||||
}
|
||||
localStorage.setItem('diary:spots', JSON.stringify(this.#spots))
|
||||
}
|
||||
|
||||
async enterSpace(id: number) {
|
||||
const leaving = this.spaceId !== null && this.spaceId !== id
|
||||
if (leaving) {
|
||||
await this.flush()
|
||||
this.#rememberSpot()
|
||||
const from = this.spaces.findIndex((s) => s.id === this.spaceId)
|
||||
const to = this.spaces.findIndex((s) => s.id === id)
|
||||
this.switchDirection = to >= from ? 1 : -1
|
||||
this.open = null
|
||||
this.editing = false
|
||||
this.pane = 'list'
|
||||
}
|
||||
this.spaceId = id
|
||||
localStorage.setItem('diary:space', String(id))
|
||||
this.query = ''
|
||||
await this.goTo(id)
|
||||
|
||||
// A remembered folder may have been deleted or moved to another space
|
||||
// since; then the space's root is where it opens.
|
||||
const spot = this.#spots[id]
|
||||
try {
|
||||
await this.goTo(spot?.parentId ?? id)
|
||||
if (this.spaceId !== id) await this.goTo(id)
|
||||
} catch {
|
||||
await this.goTo(id)
|
||||
}
|
||||
if (spot && this.parentId === spot.parentId) {
|
||||
if (spot.openId !== null && this.nodes.some((n) => n.id === spot.openId)) {
|
||||
await this.openNode(spot.openId)
|
||||
this.pane = spot.pane
|
||||
} else {
|
||||
this.cursor = Math.min(spot.cursor, Math.max(this.nodes.length - 1, 0))
|
||||
}
|
||||
}
|
||||
// The board belongs to the space, so switching space while looking at one
|
||||
// shows the new space's board — or drops back to the tree for a space that
|
||||
// has none, rather than leaving the previous board on screen.
|
||||
@@ -265,9 +321,11 @@ class Workspace {
|
||||
/** Show the children of `id`, which must be a space or a folder. */
|
||||
async goTo(id: number, keep?: number) {
|
||||
await this.flush()
|
||||
const folder = await api.getNode(id)
|
||||
// Walking into another space by a link leaves this one where it was.
|
||||
if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot()
|
||||
this.parentId = id
|
||||
this.query = ''
|
||||
const folder = await api.getNode(id)
|
||||
this.path = folder.path
|
||||
this.spaceId = folder.path[0]?.id ?? id
|
||||
this.nodes = await api.listNodes({ parent: id })
|
||||
@@ -479,6 +537,49 @@ class Workspace {
|
||||
this.say(`moved #${id}`)
|
||||
}
|
||||
|
||||
/** `1`..`9`, `0`: the space at that place on the bar. */
|
||||
async switchSpace(index: number) {
|
||||
const space = this.spaces[index]
|
||||
if (!space) return this.say(`no space ${index + 1}`, 'error')
|
||||
if (space.id !== this.spaceId) await this.enterSpace(space.id)
|
||||
}
|
||||
|
||||
/** Tab / Shift-Tab: the next or previous space, wrapping round. */
|
||||
async cycleSpace(delta: number) {
|
||||
const count = this.spaces.length
|
||||
if (count < 2) return
|
||||
const at = this.spaces.findIndex((s) => s.id === this.spaceId)
|
||||
await this.enterSpace(this.spaces[(at + delta + count) % count].id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift+digit: send the marked node, or else the one under the cursor, to
|
||||
* the root of the space at that place — the window moved to workspace N.
|
||||
*/
|
||||
async sendToSpace(index: number) {
|
||||
const space = this.spaces[index]
|
||||
const node = this.marked ?? this.selected
|
||||
if (!space) return this.say(`no space ${index + 1}`, 'error')
|
||||
if (!node) return this.say('nothing selected', 'error')
|
||||
if (node.parent_id === space.id) return this.say(`already in ${space.slug}`)
|
||||
|
||||
if (this.open?.id === node.id) {
|
||||
await this.flush()
|
||||
this.open = null
|
||||
this.editing = false
|
||||
this.pane = 'list'
|
||||
}
|
||||
await api.moveNode(node.id, space.id)
|
||||
this.marked = null
|
||||
await this.refresh()
|
||||
this.spaces = await api.listSpaces()
|
||||
this.say(`moved ${node.name.trim() || node.slug} → ${index + 1} ${space.slug}`)
|
||||
}
|
||||
|
||||
async loadOverview() {
|
||||
this.overview = await api.overview()
|
||||
}
|
||||
|
||||
/** `m`: pick the node under the cursor up, or put it back down unmoved. */
|
||||
mark() {
|
||||
const node = this.selected
|
||||
|
||||
@@ -61,3 +61,16 @@ export const isTouchDevice =
|
||||
typeof window !== 'undefined' &&
|
||||
navigator.maxTouchPoints > 0 &&
|
||||
window.matchMedia('(pointer: coarse)').matches
|
||||
|
||||
/** A heading as an anchor id: lowercase, dashes, the same idea as the server's slugs. */
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80)
|
||||
}
|
||||
Reference in new issue
Block a user