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
@@ -23,6 +23,7 @@ pub fn api_router() -> Router<AppState> {
|
|||||||
.route("/export", get(export::export))
|
.route("/export", get(export::export))
|
||||||
.route("/backup", get(backup::status).post(backup::run))
|
.route("/backup", get(backup::status).post(backup::run))
|
||||||
.route("/spaces", get(nodes::spaces).post(nodes::create_space))
|
.route("/spaces", get(nodes::spaces).post(nodes::create_space))
|
||||||
|
.route("/spaces/overview", get(nodes::overview))
|
||||||
.route("/spaces/{id}/board", get(boards::board))
|
.route("/spaces/{id}/board", get(boards::board))
|
||||||
.route("/spaces/{id}/lists", post(boards::create_list))
|
.route("/spaces/{id}/lists", post(boards::create_list))
|
||||||
.route("/lists/{id}", put(boards::rename_list).delete(boards::remove_list))
|
.route("/lists/{id}", put(boards::rename_list).delete(boards::remove_list))
|
||||||
|
|||||||
+100
-4
@@ -277,17 +277,65 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
|
|||||||
|
|
||||||
/// The spaces, in display order. Their child counts are what the switcher shows.
|
/// The spaces, in display order. Their child counts are what the switcher shows.
|
||||||
pub async fn spaces(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<NodeSummary>>> {
|
pub async fn spaces(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<NodeSummary>>> {
|
||||||
|
list_spaces(&state.db).await.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_spaces(db: &SqlitePool) -> AppResult<Vec<NodeSummary>> {
|
||||||
let rows = sqlx::query(&format!(
|
let rows = sqlx::query(&format!(
|
||||||
"SELECT {SUMMARY_COLUMNS} FROM nodes n
|
"SELECT {SUMMARY_COLUMNS} FROM nodes n
|
||||||
WHERE n.parent_id IS NULL AND n.kind = 'space'
|
WHERE n.parent_id IS NULL AND n.kind = 'space'
|
||||||
ORDER BY n.position, n.name"
|
ORDER BY n.position, n.name"
|
||||||
))
|
))
|
||||||
.fetch_all(&state.db)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(
|
Ok(rows.iter().map(|row| row_to_summary(row, "")).collect())
|
||||||
rows.iter().map(|row| row_to_summary(row, "")).collect(),
|
}
|
||||||
|
|
||||||
|
/// How many documents each space shows in the overview.
|
||||||
|
const OVERVIEW_RECENT: i64 = 4;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SpaceOverview {
|
||||||
|
pub space: NodeSummary,
|
||||||
|
pub recent: Vec<NodeSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every space with the documents last written in it, from any depth: what
|
||||||
|
/// the overview shows, the way the desktop's overview shows each workspace
|
||||||
|
/// with its windows.
|
||||||
|
pub async fn overview(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<SpaceOverview>>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for space in list_spaces(&state.db).await? {
|
||||||
|
let recent = recent_documents(&state.db, space.id, OVERVIEW_RECENT).await?;
|
||||||
|
out.push(SpaceOverview { space, recent });
|
||||||
|
}
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `limit` most recently edited documents anywhere under `root`.
|
||||||
|
pub async fn recent_documents(db: &SqlitePool, root: i64, limit: i64) -> AppResult<Vec<NodeSummary>> {
|
||||||
|
let rows = sqlx::query(&format!(
|
||||||
|
"WITH RECURSIVE sub (id) AS (
|
||||||
|
SELECT id FROM nodes WHERE id = ?1
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
|
||||||
|
)
|
||||||
|
SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head
|
||||||
|
FROM nodes n
|
||||||
|
WHERE n.kind = 'document' AND n.id IN (SELECT id FROM sub)
|
||||||
|
ORDER BY n.updated_at DESC, n.id DESC
|
||||||
|
LIMIT ?2"
|
||||||
))
|
))
|
||||||
|
.bind(root)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row_to_summary(row, &row.try_get::<String, _>("head").unwrap_or_default()))
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_space(
|
pub async fn create_space(
|
||||||
@@ -718,7 +766,55 @@ pub async fn load(state: &AppState, id: i64) -> AppResult<Node> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use super::{excerpt, fts_query, next_slug, slugify, EXCERPT_CHARS, HEAD_CHARS};
|
use super::{
|
||||||
|
excerpt, fts_query, next_slug, recent_documents, slugify, EXCERPT_CHARS, HEAD_CHARS,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The overview reaches into folders, newest edit first, and stops at the
|
||||||
|
/// limit — and never lists a folder as if it were a document.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recent_documents_come_from_any_depth() {
|
||||||
|
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("an in-memory database");
|
||||||
|
sqlx::migrate!("./migrations").run(&db).await.expect("migrations apply");
|
||||||
|
|
||||||
|
let insert = |parent: i64, kind: &'static str, slug: &'static str, at: i64| {
|
||||||
|
let db = db.clone();
|
||||||
|
async move {
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?3, '', ?4, ?4) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(parent)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(at)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await
|
||||||
|
.expect("insert")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let space: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE slug = 'uni'")
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await
|
||||||
|
.expect("the seeded space");
|
||||||
|
let folder = insert(space, "folder", "deep", 1).await;
|
||||||
|
let inner = insert(folder, "folder", "deeper", 2).await;
|
||||||
|
insert(space, "document", "a", 10).await;
|
||||||
|
insert(folder, "document", "b", 30).await;
|
||||||
|
insert(inner, "document", "c", 20).await;
|
||||||
|
|
||||||
|
let slugs: Vec<String> = recent_documents(&db, space, 2)
|
||||||
|
.await
|
||||||
|
.expect("query")
|
||||||
|
.into_iter()
|
||||||
|
.map(|n| n.slug)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(slugs, ["b", "c"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strips_markdown_furniture() {
|
fn strips_markdown_furniture() {
|
||||||
|
|||||||
+192
-18
@@ -7,6 +7,7 @@
|
|||||||
import DocumentPane from './lib/DocumentPane.svelte'
|
import DocumentPane from './lib/DocumentPane.svelte'
|
||||||
import Help from './lib/Help.svelte'
|
import Help from './lib/Help.svelte'
|
||||||
import Login from './lib/Login.svelte'
|
import Login from './lib/Login.svelte'
|
||||||
|
import Overview from './lib/Overview.svelte'
|
||||||
import MediaBrowser from './lib/MediaBrowser.svelte'
|
import MediaBrowser from './lib/MediaBrowser.svelte'
|
||||||
import Rail from './lib/Rail.svelte'
|
import Rail from './lib/Rail.svelte'
|
||||||
import Shared from './lib/Shared.svelte'
|
import Shared from './lib/Shared.svelte'
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
hooks.pickArchive = () => importInput?.click()
|
hooks.pickArchive = () => importInput?.click()
|
||||||
hooks.focusList = () => shell?.focus()
|
hooks.focusList = () => shell?.focus()
|
||||||
hooks.openCommandLine = (initial) => (cmdline = initial)
|
hooks.openCommandLine = (initial) => (cmdline = initial)
|
||||||
|
hooks.openOverview = () => void openOverview()
|
||||||
})
|
})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -92,14 +94,87 @@
|
|||||||
const offers: [string, string][] = [
|
const offers: [string, string][] = [
|
||||||
['o', 'write a new document'],
|
['o', 'write a new document'],
|
||||||
['O', 'make a folder'],
|
['O', 'make a folder'],
|
||||||
['R', 'rename what is marked'],
|
|
||||||
['b', "this space's board"],
|
|
||||||
['j / k', 'browse, l opens'],
|
['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'],
|
['/', 'search this space'],
|
||||||
['?', 'help'],
|
['?', '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 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
|
* the lists, j/k the cards, and the shifted pair takes the card along. Only
|
||||||
@@ -185,6 +260,19 @@
|
|||||||
return
|
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 (event.key === 'Escape') {
|
||||||
if (workspace.overlay !== 'none') {
|
if (workspace.overlay !== 'none') {
|
||||||
workspace.overlay = 'none'
|
workspace.overlay = 'none'
|
||||||
@@ -195,6 +283,10 @@
|
|||||||
shell?.focus()
|
shell?.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (workspace.fullscreen && !workspace.editing) {
|
||||||
|
workspace.fullscreen = false
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspace.overlay !== 'none') {
|
if (workspace.overlay !== 'none') {
|
||||||
@@ -222,6 +314,16 @@
|
|||||||
const previous = pending
|
const previous = pending
|
||||||
pending = ''
|
pending = ''
|
||||||
|
|
||||||
|
if (spaceKey(event)) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key === 'f') {
|
||||||
|
event.preventDefault()
|
||||||
|
workspace.fullscreen = !workspace.fullscreen
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (workspace.view === 'board') {
|
if (workspace.view === 'board') {
|
||||||
if (previous === 'g' && key === 'g') {
|
if (previous === 'g' && key === 'g') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -239,11 +341,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* With a document open and not being edited, the reading pane owns j/k:
|
* The focused pane owns j/k. In the document they scroll what is on
|
||||||
* the keys scroll what is on screen, and `h` backs out to the list, where
|
* screen; `h` moves focus to the list with the document still open, where
|
||||||
* they walk the rows again.
|
* 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.
|
// Two-key sequences: gg and dd.
|
||||||
if (previous === 'g' && key === 'g') {
|
if (previous === 'g' && key === 'g') {
|
||||||
@@ -284,7 +386,8 @@
|
|||||||
case 'l':
|
case 'l':
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
event.preventDefault()
|
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
|
break
|
||||||
case 'm':
|
case 'm':
|
||||||
// Pick a node up here, walk to a folder, put it down with `p`.
|
// Pick a node up here, walk to a folder, put it down with `p`.
|
||||||
@@ -323,10 +426,10 @@
|
|||||||
break
|
break
|
||||||
case 'h':
|
case 'h':
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
// Out of the document if one is open, otherwise up a level — the same
|
// Leftwards at every depth: from the document to the list, from the
|
||||||
// key means "leftwards" at both depths.
|
// list up a level.
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (workspace.open) void runCommand('q')
|
if (workspace.open && workspace.pane === 'document') focusPane('list')
|
||||||
else void workspace.guard(() => workspace.up())
|
else void workspace.guard(() => workspace.up())
|
||||||
break
|
break
|
||||||
case 'q':
|
case 'q':
|
||||||
@@ -382,13 +485,34 @@
|
|||||||
|
|
||||||
<!-- The opening in the frame: everything that scrolls is seen through it. -->
|
<!-- The opening in the frame: everything that scrolls is seen through it. -->
|
||||||
<main class="porthole">
|
<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'}
|
{#if workspace.view === 'board'}
|
||||||
<!-- The board takes the whole width: its columns are the two panes. -->
|
<!-- 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}
|
{:else}
|
||||||
<div class="panes" class:reading={!!workspace.open}>
|
<div
|
||||||
<div class="list"><Sidebar /></div>
|
class="panes switch"
|
||||||
<div class="entry">
|
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}
|
{#if workspace.open}
|
||||||
<DocumentPane />
|
<DocumentPane />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -409,6 +533,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/key}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
@@ -421,7 +546,7 @@
|
|||||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
||||||
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -473,15 +598,64 @@
|
|||||||
z-index: 5;
|
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 {
|
.panes {
|
||||||
|
--gap: 7px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(32ch, 24vw) 1fr;
|
grid-template-columns: minmax(32ch, 24vw) 1fr;
|
||||||
|
gap: var(--gap);
|
||||||
|
padding: var(--gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panes.board { grid-template-columns: 1fr; }
|
.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; }
|
.placeholder { display: grid; place-items: center; height: 100%; padding: 24px; }
|
||||||
.standby { width: min(100%, 44ch); }
|
.standby { width: min(100%, 44ch); }
|
||||||
@@ -515,7 +689,7 @@
|
|||||||
.hidden { display: none; }
|
.hidden { display: none; }
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.panes { grid-template-columns: 1fr; }
|
.panes { grid-template-columns: 1fr; --gap: 4px; }
|
||||||
.panes .entry { display: none; }
|
.panes .entry { display: none; }
|
||||||
.panes.reading .list { display: none; }
|
.panes.reading .list { display: none; }
|
||||||
.panes.reading .entry { display: block; }
|
.panes.reading .entry { display: block; }
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
--ease-snap: cubic-bezier(0.22, 1, 0.36, 1);
|
--ease-snap: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
--ease-glide: cubic-bezier(0.4, 0, 0.2, 1);
|
--ease-glide: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--ease-in: cubic-bezier(0.55, 0, 0.9, 0.35);
|
--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-fast: 120ms;
|
||||||
--t-base: 220ms;
|
--t-base: 220ms;
|
||||||
--t-slow: 380ms;
|
--t-slow: 380ms;
|
||||||
|
|||||||
+16
-3
@@ -26,13 +26,14 @@
|
|||||||
<span class="divider"></span>
|
<span class="divider"></span>
|
||||||
|
|
||||||
<nav class="spaces" aria-label="spaces">
|
<nav class="spaces" aria-label="spaces">
|
||||||
{#each workspace.spaces as space (space.id)}
|
{#each workspace.spaces as space, index (space.id)}
|
||||||
<button
|
<button
|
||||||
class="pill"
|
class="pill"
|
||||||
aria-current={space.id === workspace.spaceId}
|
aria-current={space.id === workspace.spaceId}
|
||||||
|
title="{space.name} ({index < 10 ? (index + 1) % 10 : ''})"
|
||||||
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
|
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
|
||||||
>
|
>
|
||||||
{space.slug}
|
{#if index < 10}<span class="n">{(index + 1) % 10}</span>{/if}{space.slug}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
|
<button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
|
||||||
@@ -129,10 +130,19 @@
|
|||||||
.crumbs { flex: 0 1 auto; }
|
.crumbs { flex: 0 1 auto; }
|
||||||
|
|
||||||
/* Workspace pills: muted at rest, light on hover, inverted when current. */
|
/* 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 {
|
.pill {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
min-width: 22px;
|
min-width: 22px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 0 6px;
|
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: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -142,7 +152,10 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.pill:hover { background: transparent; color: var(--light); }
|
.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:focus-visible { background: var(--light); color: var(--ink); }
|
||||||
.pill.add { color: var(--overlay); }
|
.pill.add { color: var(--overlay); }
|
||||||
.pill.add:hover { color: var(--light); }
|
.pill.add:hover { color: var(--light); }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { hooks, runCommand, type ScrollAmount } from './commands'
|
import { hooks, runCommand, type ScrollAmount } from './commands'
|
||||||
import { renderMarkdown } from './markdown'
|
import { renderMarkdown } from './markdown'
|
||||||
|
import Outline from './Outline.svelte'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
import { formatStamp, formatWeekday, isTouchDevice, relative } from './util'
|
import { formatStamp, formatWeekday, isTouchDevice, relative } from './util'
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
const sharePath = $derived(doc.share_token ? `${location.origin}/s/${doc.share_token}#…` : null)
|
const sharePath = $derived(doc.share_token ? `${location.origin}/s/${doc.share_token}#…` : null)
|
||||||
|
|
||||||
let reader = $state<HTMLElement>()
|
let reader = $state<HTMLElement>()
|
||||||
|
let article = $state<HTMLElement>()
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* j/k reach this pane through the window handler in App.svelte, which has no
|
* 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.
|
* says whether it scrolled anything at all.
|
||||||
*/
|
*/
|
||||||
$effect(() => {
|
$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
|
if (!reader) return false
|
||||||
const line = parseFloat(getComputedStyle(reader).lineHeight) || 24
|
const line = parseFloat(getComputedStyle(reader).lineHeight) || 24
|
||||||
const half = reader.clientHeight / 2
|
const half = reader.clientHeight / 2
|
||||||
@@ -46,7 +50,10 @@
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return () => (hooks.scrollReader = () => false)
|
hooks.scrollReader = scroll
|
||||||
|
return () => {
|
||||||
|
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,7 +151,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="body reading" bind:this={reader}>
|
<div class="body reading" bind:this={reader}>
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||||
<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}
|
{#if doc.name.trim()}<h1 class="entrytitle">{doc.name}</h1>{/if}
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
||||||
{@html html}
|
{@html html}
|
||||||
@@ -152,6 +159,9 @@
|
|||||||
<p class="muted">this document is empty — press <span class="key">i</span> to write.</p>
|
<p class="muted">this document is empty — press <span class="key">i</span> to write.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
|
<aside class="side">
|
||||||
|
<Outline root={article} scroller={reader} source={html} />
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -249,7 +259,29 @@
|
|||||||
.loading { padding: 16px 28px; }
|
.loading { padding: 16px 28px; }
|
||||||
|
|
||||||
.body { flex: 1; min-height: 0; overflow: hidden; }
|
.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; }
|
.entrytitle { margin-top: 0 !important; font-size: 2.1em !important; }
|
||||||
|
|
||||||
.key {
|
.key {
|
||||||
@@ -275,6 +307,7 @@
|
|||||||
.row { flex-direction: column; align-items: flex-start; }
|
.row { flex-direction: column; align-items: flex-start; }
|
||||||
.sharebar { margin: 0 16px 8px; }
|
.sharebar { margin: 0 16px 8px; }
|
||||||
.title { margin: 8px 16px 4px; }
|
.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>
|
</style>
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
let host = $state<HTMLDivElement>()
|
let host = $state<HTMLDivElement>()
|
||||||
let view: EditorView | undefined
|
let view: EditorView | undefined
|
||||||
|
const focus = () => view?.focus()
|
||||||
|
|
||||||
const markdownHighlight = HighlightStyle.define([
|
const markdownHighlight = HighlightStyle.define([
|
||||||
{ tag: tags.heading, color: 'var(--light)', fontWeight: '600' },
|
{ tag: tags.heading, color: 'var(--light)', fontWeight: '600' },
|
||||||
@@ -208,7 +209,7 @@
|
|||||||
parent: host!,
|
parent: host!,
|
||||||
})
|
})
|
||||||
|
|
||||||
hooks.focusEditor = () => view?.focus()
|
hooks.focusEditor = focus
|
||||||
hooks.insertText = insertAtCursor
|
hooks.insertText = insertAtCursor
|
||||||
view.focus()
|
view.focus()
|
||||||
|
|
||||||
@@ -234,9 +235,12 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
view?.destroy()
|
view?.destroy()
|
||||||
view = undefined
|
view = undefined
|
||||||
hooks.focusEditor = () => {}
|
// Only clear the hooks if no newer editor has taken them already.
|
||||||
hooks.insertText = () => {}
|
if (hooks.focusEditor === focus) {
|
||||||
workspace.vimMode = 'normal'
|
hooks.focusEditor = () => {}
|
||||||
|
hooks.insertText = () => {}
|
||||||
|
workspace.vimMode = 'normal'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,24 @@
|
|||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
|
|
||||||
const keys: [string, string][] = [
|
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'],
|
['gg / G', 'first / last row, or top / bottom of the document'],
|
||||||
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
|
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
|
||||||
['Enter · l', 'open a document, or enter a folder'],
|
['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 document here, straight into insert mode'],
|
||||||
['O', 'new folder here'],
|
['O', 'new folder here'],
|
||||||
['i · a', 'edit the open document'],
|
['i · a', 'edit the open document'],
|
||||||
['R', 'rename what is marked — document, folder or card'],
|
['R', 'rename what is marked — document, folder or card'],
|
||||||
['m / p', 'pick a node up, then drop it into the folder you walk to'],
|
['m / p', 'pick a node up, then drop it into the folder you walk to'],
|
||||||
['b', "open this space's board"],
|
['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'],
|
['/', 'full-text search, inside the current space'],
|
||||||
['n', 'clear the search'],
|
['n', 'clear the search'],
|
||||||
['s', 'toggle sharing for the document'],
|
['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: 'new', title: 'new document (o)', run: () => runCommand('new') },
|
||||||
{ key: 'O', caption: 'dir', title: 'new folder (O)', run: () => hooks.openCommandLine(':mkdir ') },
|
{ key: 'O', caption: 'dir', title: 'new folder (O)', run: () => hooks.openCommandLine(':mkdir ') },
|
||||||
...(workspace.space ? [board] : []),
|
...(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: 'find', title: 'search (/)', run: () => hooks.openCommandLine('/') },
|
||||||
{ key: ':', caption: 'cmd', title: 'command line (:)', run: () => hooks.openCommandLine(':') },
|
{ key: ':', caption: 'cmd', title: 'command line (:)', run: () => hooks.openCommandLine(':') },
|
||||||
{ key: '?', caption: 'help', title: 'help (?)', run: () => runCommand('help') },
|
{ key: '?', caption: 'help', title: 'help (?)', run: () => runCommand('help') },
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api, type SharedDocument } from './api'
|
import { api, type SharedDocument } from './api'
|
||||||
import { renderMarkdown } from './markdown'
|
import { renderMarkdown } from './markdown'
|
||||||
|
import Outline from './Outline.svelte'
|
||||||
import { formatStamp, formatWeekday } from './util'
|
import { formatStamp, formatWeekday } from './util'
|
||||||
|
|
||||||
const { token, shareKey }: { token: string; shareKey: string } = $props()
|
const { token, shareKey }: { token: string; shareKey: string } = $props()
|
||||||
|
|
||||||
let doc = $state<SharedDocument | null>(null)
|
let doc = $state<SharedDocument | null>(null)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
|
let page = $state<HTMLElement>()
|
||||||
|
let article = $state<HTMLElement>()
|
||||||
|
const html = $derived(doc ? renderMarkdown(doc.body, { token, key: shareKey }) : '')
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!shareKey) {
|
if (!shareKey) {
|
||||||
@@ -23,11 +27,11 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main bind:this={page}>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="alarm">{error}</p>
|
<p class="alarm">{error}</p>
|
||||||
{:else if doc}
|
{:else if doc}
|
||||||
<article class="md resolve">
|
<article class="md resolve" bind:this={article}>
|
||||||
<div class="meta label rule">
|
<div class="meta label rule">
|
||||||
<span class="marker"></span>
|
<span class="marker"></span>
|
||||||
<span>{formatStamp(doc.created_at)} {formatWeekday(doc.created_at)}</span>
|
<span>{formatStamp(doc.created_at)} {formatWeekday(doc.created_at)}</span>
|
||||||
@@ -35,8 +39,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if doc.name.trim()}<h1>{doc.name}</h1>{/if}
|
{#if doc.name.trim()}<h1>{doc.name}</h1>{/if}
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
||||||
{@html renderMarkdown(doc.body, { token, key: shareKey })}
|
{@html html}
|
||||||
</article>
|
</article>
|
||||||
|
<aside class="side">
|
||||||
|
<Outline root={article} scroller={page} source={html} />
|
||||||
|
</aside>
|
||||||
<footer class="label">— written in ~/workspace</footer>
|
<footer class="label">— written in ~/workspace</footer>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="label">loading<span class="cursor">_</span></p>
|
<p class="label">loading<span class="cursor">_</span></p>
|
||||||
@@ -44,19 +51,34 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* The same page as the reading pane: a centred column, the outline in the margin. */
|
||||||
main {
|
main {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 56px 20px 20vh;
|
padding: 56px 20px 20vh;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 46rem) minmax(0, 1fr);
|
||||||
align-items: center;
|
align-content: start;
|
||||||
|
align-items: start;
|
||||||
|
container-type: inline-size;
|
||||||
background:
|
background:
|
||||||
radial-gradient(120% 70% at 30% 0%, #141713 0%, transparent 60%),
|
radial-gradient(120% 70% at 30% 0%, #141713 0%, transparent 60%),
|
||||||
var(--base);
|
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 { margin-bottom: 28px; font-size: 10px; }
|
||||||
.meta .marker { background: var(--light); }
|
.meta .marker { background: var(--light); }
|
||||||
@@ -65,6 +87,5 @@
|
|||||||
|
|
||||||
h1 { margin-top: 0 !important; font-size: 2.1em !important; }
|
h1 { margin-top: 0 !important; font-size: 2.1em !important; }
|
||||||
|
|
||||||
footer { width: min(100%, 72ch); margin-top: 56px; font-size: 10px; }
|
footer { margin-top: 56px; font-size: 10px; }
|
||||||
p { width: min(100%, 72ch); }
|
|
||||||
</style>
|
</style>
|
||||||
@@ -79,9 +79,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
--pane: color-mix(in oklab, var(--surface) 55%, var(--base));
|
|
||||||
background: var(--pane);
|
|
||||||
border-right: 1px solid var(--rim);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.head {
|
.head {
|
||||||
@@ -120,7 +117,7 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 14px 8px 6px 10px;
|
padding: 14px 8px 6px 10px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
background: linear-gradient(var(--pane) 70%, transparent);
|
background: linear-gradient(var(--pane-bg) 70%, transparent);
|
||||||
}
|
}
|
||||||
.day { color: var(--subtle); }
|
.day { color: var(--subtle); }
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
{workspace.flash?.text ?? ''}
|
{workspace.flash?.text ?? ''}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{#if workspace.fullscreen}<span class="flag">full</span>{/if}
|
||||||
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
|
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
|
||||||
<span class="hint">
|
<span class="hint">
|
||||||
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
|
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
|
||||||
@@ -75,6 +76,7 @@
|
|||||||
.readout { color: var(--fg); font-variant-numeric: tabular-nums; }
|
.readout { color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||||
.unit { color: var(--muted); margin-left: 1px; }
|
.unit { color: var(--muted); margin-left: 1px; }
|
||||||
.hint { color: var(--muted); letter-spacing: 0.06em; }
|
.hint { color: var(--muted); letter-spacing: 0.06em; }
|
||||||
|
.flag { color: var(--light); letter-spacing: 0.18em; text-transform: uppercase; }
|
||||||
|
|
||||||
.tools { display: none; }
|
.tools { display: none; }
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export interface WorkspaceNode extends NodeSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A public key allowed to mount the workspace over SFTP. */
|
/** 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 {
|
export interface SshKey {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -152,6 +158,8 @@ export const api = {
|
|||||||
|
|
||||||
listSpaces: () => request<NodeSummary[]>('/spaces'),
|
listSpaces: () => request<NodeSummary[]>('/spaces'),
|
||||||
|
|
||||||
|
overview: () => request<SpaceOverview[]>('/spaces/overview'),
|
||||||
|
|
||||||
createSpace: (name: string, has_board = true) =>
|
createSpace: (name: string, has_board = true) =>
|
||||||
request<WorkspaceNode>('/spaces', { method: 'POST', body: body({ name, has_board }) }),
|
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
|
/* 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. */
|
pane owns the scrollback, so the list keys have somewhere to send it. */
|
||||||
scrollReader: (_amount: ScrollAmount): boolean => false,
|
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) => {},
|
openCommandLine: (_initial: string) => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+104
-3
@@ -7,6 +7,7 @@ import {
|
|||||||
type WorkspaceNode,
|
type WorkspaceNode,
|
||||||
type MediaFile,
|
type MediaFile,
|
||||||
type NodeSummary,
|
type NodeSummary,
|
||||||
|
type SpaceOverview,
|
||||||
} from './api'
|
} from './api'
|
||||||
|
|
||||||
export type Pane = 'list' | 'document'
|
export type Pane = 'list' | 'document'
|
||||||
@@ -14,6 +15,9 @@ export type Pane = 'list' | 'document'
|
|||||||
export type View = 'tree' | 'board'
|
export type View = 'tree' | 'board'
|
||||||
export type Flash = { text: string; kind: 'info' | 'error' } | null
|
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
|
* The whole application state. It is deliberately one object: the keyboard
|
||||||
* layer needs to see everything at once to decide what a key means.
|
* layer needs to see everything at once to decide what a key means.
|
||||||
@@ -60,8 +64,21 @@ class Workspace {
|
|||||||
vimMode = $state('normal')
|
vimMode = $state('normal')
|
||||||
flash = $state<Flash>(null)
|
flash = $state<Flash>(null)
|
||||||
|
|
||||||
overlay = $state<'none' | 'help' | 'media'>('none')
|
overlay = $state<'none' | 'help' | 'media' | 'overview'>('none')
|
||||||
media = $state<MediaFile[]>([])
|
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')
|
vimEnabled = $state(localStorage.getItem('diary:vim') !== 'off')
|
||||||
|
|
||||||
@@ -246,11 +263,50 @@ class Workspace {
|
|||||||
if (start) await this.enterSpace(start.id)
|
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) {
|
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
|
this.spaceId = id
|
||||||
localStorage.setItem('diary:space', String(id))
|
localStorage.setItem('diary:space', String(id))
|
||||||
this.query = ''
|
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
|
// 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
|
// 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.
|
// 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. */
|
/** Show the children of `id`, which must be a space or a folder. */
|
||||||
async goTo(id: number, keep?: number) {
|
async goTo(id: number, keep?: number) {
|
||||||
await this.flush()
|
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.parentId = id
|
||||||
this.query = ''
|
this.query = ''
|
||||||
const folder = await api.getNode(id)
|
|
||||||
this.path = folder.path
|
this.path = folder.path
|
||||||
this.spaceId = folder.path[0]?.id ?? id
|
this.spaceId = folder.path[0]?.id ?? id
|
||||||
this.nodes = await api.listNodes({ parent: id })
|
this.nodes = await api.listNodes({ parent: id })
|
||||||
@@ -479,6 +537,49 @@ class Workspace {
|
|||||||
this.say(`moved #${id}`)
|
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. */
|
/** `m`: pick the node under the cursor up, or put it back down unmoved. */
|
||||||
mark() {
|
mark() {
|
||||||
const node = this.selected
|
const node = this.selected
|
||||||
|
|||||||
@@ -61,3 +61,16 @@ export const isTouchDevice =
|
|||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
navigator.maxTouchPoints > 0 &&
|
navigator.maxTouchPoints > 0 &&
|
||||||
window.matchMedia('(pointer: coarse)').matches
|
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