mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
tiles: several documents open at once
`L` or Shift-Enter on a row opens it in a tile of its own; Enter and `l` still open it in the focused tile. Right of the list the tiles share the space as hyprland's dwindle does: two side by side, a third splits the newer one, and so on. Each keeps its own scroll position and its own editor, and the keys act on the focused one. A document already open in a tile is focused there rather than opened twice, so two drafts of it can never overwrite each other. Alt-h/j/k/l moves the focus to the tile that way, out of the editor too, and Alt-h past the leftmost tile focuses the list as before. With Shift the tile itself moves, swapping places with its neighbour. `gt` / `gT` and `:bn` / `:bp` cycle the tiles, `:ls` lists them, `:b <n or name>` focuses one, `:close` (Alt-q) closes one, asking first when its draft could not be written, and `:only` closes the rest. `:b` was the board's alias; `:board` is spelled out now. `:next` / `:prev` still walk the list. `q` on a document closes its tile when there are several. `f` fullscreens the focused tile. Each space remembers its tiles, as it remembered its one document. A card's notes open in a tile beside whatever was being read, and `q` there takes the tile away again on the way back to the board. Under 768px only the focused tile is shown. The status line says which tile has focus.
This commit is contained in:
1 parent
e0de88938a
commit
cb76aaf43a
9 files changed
+457
-64
No files matched your search
@@ -72,8 +72,12 @@ it; in the document pane it is that document. In the browse pane:
|
||||
| `Shift-1` … `9` | send the marked nodes, or the selected one, to that space |
|
||||
| `Tab`, `Shift-Tab` | next / previous space |
|
||||
| `w` | overview of every space; `hjkl` then `Enter` |
|
||||
| `f` | fullscreen the focused pane |
|
||||
| `Alt-h` / `Alt-l` | focus the list / the document, from the editor too |
|
||||
| `f` | fullscreen the focused pane, or the focused tile |
|
||||
| `Alt-h/j/k/l` | focus the tile in that direction, from the editor too; `Alt-h` past the leftmost tile focuses the list |
|
||||
| `Alt-Shift-h/j/k/l` | move the focused tile that way |
|
||||
| `L`, `Shift-Enter` | open the highlighted row in a new tile instead of the focused one |
|
||||
| `gt` / `gT` | next / previous tile, in the editor too |
|
||||
| `Alt-q` | close the focused tile (`:close`); asks first if its draft could not be written |
|
||||
| `j` / `k` | move down / up, or scroll the document when it has focus |
|
||||
| `gg` / `G` | first / last entry, or top / bottom of the document; a first `g`, `d`, `]` or `[` shows in the status line for a second while it waits for the second key |
|
||||
| `Ctrl-D` / `Ctrl-U` | half a page down / up the open document |
|
||||
@@ -92,7 +96,7 @@ it; in the document pane it is that document. In the browse pane:
|
||||
| `t` | the agenda: what is late, due this week, or in hand, on every board |
|
||||
| `T` | today's diary entry, started if there is none |
|
||||
| `c` | capture a card into this space's inbox without leaving where you are |
|
||||
| `Esc`, `q` | close the document |
|
||||
| `Esc`, `q` | close the document, and its tile when there are several |
|
||||
| `h` | up one level in the tree |
|
||||
| `/` | full-text search in this space (`:noh` clears it); `Tab` or `Ctrl-N`/`Ctrl-P` walk the results without leaving the line, `Enter` opens the one lit |
|
||||
| `Ctrl-P` | full-text search across every space; `Enter` goes to the match |
|
||||
@@ -112,7 +116,7 @@ there goes back down into the text. In the text, `]]`, `[[` and `gO` walk the
|
||||
headings as they do in the reader. `gf` follows the link under the cursor, saving
|
||||
first, and `gx` opens it in a new tab. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`)
|
||||
opens a node by id, `:next`, `:prev`, `:first`, `:last` and `:random` walk the
|
||||
list, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard,
|
||||
list (`:bn` and `:bp` walk the tiles), `:delete [id]` deletes, `:copy` puts the whole document on the clipboard,
|
||||
`:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words
|
||||
and lines, `:search!` searches every space, `:set clipboard` / `:set
|
||||
noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the
|
||||
@@ -136,6 +140,24 @@ has today's entry, an upload into the folder, a search across every space,
|
||||
and on the board the card moves, renames and deletes; the agenda has a done
|
||||
button per card.
|
||||
|
||||
## Tiles
|
||||
|
||||
Several documents can be open at once. `L` (or `Shift-Enter`) on a row opens
|
||||
it in a tile of its own; `Enter` and `l` still open it in the focused tile.
|
||||
Right of the list the tiles share the space the way hyprland's dwindle layout
|
||||
does: two side by side, a third splits the newer one, and so on. Each keeps
|
||||
its own scroll position and its own editor, and the keys always act on the
|
||||
focused one. Opening a document that is already in a tile focuses that tile
|
||||
rather than opening it twice.
|
||||
|
||||
`Alt-h/j/k/l` moves the focus between tiles and `Alt-Shift-h/j/k/l` moves the
|
||||
tile itself; `gt` / `gT` and `:bn` / `:bp` cycle through them, `:ls` lists
|
||||
them, `:b <n or name>` focuses one, `:close` (`Alt-q`) closes one and `:only`
|
||||
every other. `:next` and `:prev` still walk the list. Each space remembers its
|
||||
tiles, so switching away and back finds them as they were. A card's notes
|
||||
open in a tile beside what was being read, and `q` there goes back to the
|
||||
board. Under 768px wide only the focused tile is shown.
|
||||
|
||||
## The SFTP mount
|
||||
|
||||
The workspace can be mounted as a filesystem over SFTP, so an editor or a file
|
||||
|
||||
+123
-36
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { followLink, hooks, runCommand } from './lib/commands'
|
||||
import { cycleTile, focusTile, followLink, hooks, runCommand } from './lib/commands'
|
||||
import { embedSnippet } from './lib/markdown'
|
||||
import Agenda from './lib/Agenda.svelte'
|
||||
import Bar from './lib/Bar.svelte'
|
||||
@@ -22,6 +22,7 @@
|
||||
import Sidebar from './lib/Sidebar.svelte'
|
||||
import StatusBar from './lib/StatusBar.svelte'
|
||||
import { workspace } from './lib/store.svelte'
|
||||
import { dwindle, neighbour, type Direction } from './lib/util'
|
||||
|
||||
/*
|
||||
* A share link is /s/<token>#<key>. The fragment never leaves the browser, so
|
||||
@@ -51,6 +52,15 @@
|
||||
|
||||
if (!shareToken) void workspace.boot()
|
||||
|
||||
/* Where each tile sits, in the order of `workspace.tiles`. */
|
||||
const layout = $derived(dwindle(workspace.tiles.length))
|
||||
/*
|
||||
* The tiles are drawn in the order they were made, whatever order they are
|
||||
* in now, so moving one never moves its element — which would cost it its
|
||||
* scroll position, and a PDF its page.
|
||||
*/
|
||||
const drawn = $derived([...workspace.tiles].sort((a, b) => a.id - b.id))
|
||||
|
||||
$effect(() => {
|
||||
folderInput?.setAttribute('webkitdirectory', '')
|
||||
})
|
||||
@@ -202,7 +212,7 @@
|
||||
const offers: [string[], string][] = [
|
||||
[['o'], 'write a new document'],
|
||||
[['⇧ O'], 'make a folder'],
|
||||
[['j', 'k'], 'browse, l opens'],
|
||||
[['j', 'k'], 'browse, l opens, L opens beside'],
|
||||
[['1…9'], 'switch space, Tab cycles'],
|
||||
[['w'], 'overview of every space'],
|
||||
[['b'], "this space's board"],
|
||||
@@ -379,6 +389,28 @@
|
||||
followLink(file.url, true)
|
||||
}
|
||||
|
||||
/*
|
||||
* Alt + h/j/k/l: the focus goes to the tile that way, out of the editor
|
||||
* too; left of the leftmost tile is the list. With Shift the focused tile
|
||||
* goes that way instead, swapping places with the one it lands on.
|
||||
*/
|
||||
function tileKey(direction: Direction, shift: boolean) {
|
||||
if (workspace.pane === 'list' || !workspace.open) {
|
||||
if (direction === 'l' && !shift) focusPane('document')
|
||||
return
|
||||
}
|
||||
const next = neighbour(layout, workspace.focused, direction)
|
||||
if (next < 0) {
|
||||
if (direction === 'h' && !shift) focusPane('list')
|
||||
return
|
||||
}
|
||||
if (!shift) return void focusTile(next)
|
||||
const tiles = [...workspace.tiles]
|
||||
;[tiles[workspace.focused], tiles[next]] = [tiles[next], tiles[workspace.focused]]
|
||||
workspace.tiles = tiles
|
||||
workspace.focused = next
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -480,9 +512,17 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (event.altKey && !event.ctrlKey && !event.metaKey && (event.key === 'h' || event.key === 'l')) {
|
||||
// `event.code`, so Alt-Shift-h is the same key on every layout, and on a
|
||||
// Mac, where Alt turns h into ˙.
|
||||
const alt = event.altKey && !event.ctrlKey && !event.metaKey ? /^Key([HJKLQ])$/.exec(event.code)?.[1].toLowerCase() : undefined
|
||||
if (alt === 'q' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
focusPane(event.key === 'h' ? 'list' : 'document')
|
||||
void runCommand('close')
|
||||
return
|
||||
}
|
||||
if (alt && alt !== 'q') {
|
||||
event.preventDefault()
|
||||
tileKey(alt as Direction, event.shiftKey)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -574,6 +614,13 @@
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
// `gt` / `gT` walk the tiles, as they walk vim's tab pages — before `t`
|
||||
// and `T` on their own are taken for the agenda and today's entry.
|
||||
if (previous === 'g' && (key === 't' || key === 'T') && workspace.view !== 'board') {
|
||||
event.preventDefault()
|
||||
void cycleTile(key === 't' ? 1 : -1)
|
||||
return
|
||||
}
|
||||
if (key === 'f') {
|
||||
event.preventDefault()
|
||||
workspace.fullscreen = !workspace.fullscreen
|
||||
@@ -647,6 +694,7 @@
|
||||
openInTab()
|
||||
return
|
||||
}
|
||||
|
||||
if (previous === 'd' && key === 'd') {
|
||||
event.preventDefault()
|
||||
void runCommand('d')
|
||||
@@ -683,12 +731,23 @@
|
||||
break
|
||||
case 'Enter':
|
||||
case 'l':
|
||||
case 'ArrowRight':
|
||||
case 'ArrowRight': {
|
||||
event.preventDefault()
|
||||
if (key === 'Enter' && reading && hooks.fileAction()) break
|
||||
if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document')
|
||||
// Shift-Enter, like `L`: beside what is open, in a tile of its own.
|
||||
if (key === 'Enter' && event.shiftKey && !reading) {
|
||||
void workspace.guard(() => workspace.openBeside())
|
||||
break
|
||||
}
|
||||
const shown = workspace.tiles.findIndex((tile) => tile.open && tile.open.id === workspace.selected?.id)
|
||||
if (shown >= 0) void focusTile(shown)
|
||||
else void workspace.guard(() => workspace.openSelected())
|
||||
break
|
||||
}
|
||||
case 'L':
|
||||
event.preventDefault()
|
||||
if (!reading) void workspace.guard(() => workspace.openBeside())
|
||||
break
|
||||
case 'F': {
|
||||
// `f` already fills the screen, so the hints vimium puts on `f` live
|
||||
// on its other key.
|
||||
@@ -833,7 +892,7 @@
|
||||
{:else}
|
||||
<div
|
||||
class="panes switch"
|
||||
class:reading={!!workspace.open}
|
||||
class:reading={workspace.tiles.some((tile) => tile.open)}
|
||||
class:full={workspace.fullscreen}
|
||||
class:focus-doc={!!workspace.open && workspace.pane === 'document'}
|
||||
style="--dir: {workspace.switchDirection}"
|
||||
@@ -844,31 +903,40 @@
|
||||
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?.kind === 'file'}
|
||||
<FilePane tile={workspace.tile} />
|
||||
{:else if workspace.open}
|
||||
<DocumentPane tile={workspace.tile} />
|
||||
{:else}
|
||||
<div class="placeholder">
|
||||
<div class="standby resolve">
|
||||
<h2 class="label rule">nothing open</h2>
|
||||
<dl>
|
||||
{#each offers as [keys, what] (what)}
|
||||
<dt>{#each keys as key (key)}<span class="kbd">{key}</span>{/each}</dt>
|
||||
<dd>{what}</dd>
|
||||
{/each}
|
||||
<dt>status</dt>
|
||||
<dd class="light">standing by<span class="cursor">_</span></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="tiles" class:only={workspace.tiles.length === 1}>
|
||||
{#each drawn as tile (tile.id)}
|
||||
{@const index = workspace.tiles.indexOf(tile)}
|
||||
{@const rect = layout[index]}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="window entry"
|
||||
class:current={index === workspace.focused}
|
||||
class:focused={!!tile.open && index === workspace.focused && workspace.pane === 'document'}
|
||||
style="--x: {rect.x}; --y: {rect.y}; --w: {rect.w}; --h: {rect.h}"
|
||||
onpointerdown={() => tile.open && workspace.focusTile(index)}
|
||||
onfocusin={() => tile.open && index !== workspace.focused && workspace.focusTile(index)}
|
||||
>
|
||||
{#if tile.open?.kind === 'file'}
|
||||
<FilePane {tile} />
|
||||
{:else if tile.open}
|
||||
<DocumentPane {tile} />
|
||||
{:else}
|
||||
<div class="placeholder">
|
||||
<div class="standby resolve">
|
||||
<h2 class="label rule">nothing open</h2>
|
||||
<dl>
|
||||
{#each offers as [keys, what] (what)}
|
||||
<dt>{#each keys as key (key)}<span class="kbd">{key}</span>{/each}</dt>
|
||||
<dd>{what}</dd>
|
||||
{/each}
|
||||
<dt>status</dt>
|
||||
<dd class="light">standing by<span class="cursor">_</span></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -924,10 +992,26 @@
|
||||
}
|
||||
.panes:not(.reading) .window.entry { border-color: transparent; background: transparent; }
|
||||
|
||||
/* `f`: the focused pane fills the porthole. */
|
||||
/*
|
||||
* The tiles share the column right of the list, each placed by the dwindle
|
||||
* layout. Half a gap is taken off each side of a tile, and the column
|
||||
* reaches half a gap out to make up for it at the edges.
|
||||
*/
|
||||
.tiles { position: relative; min-width: 0; min-height: 0; margin: calc(var(--gap) / -2); }
|
||||
.tiles > .window {
|
||||
position: absolute;
|
||||
left: calc(var(--x) * 100% + var(--gap) / 2);
|
||||
top: calc(var(--y) * 100% + var(--gap) / 2);
|
||||
width: calc(var(--w) * 100% - var(--gap));
|
||||
height: calc(var(--h) * 100% - var(--gap));
|
||||
}
|
||||
|
||||
/* `f`: the focused pane fills the porthole — the list, or one tile. */
|
||||
.panes.full { grid-template-columns: 1fr; }
|
||||
.panes.full .window:not(.focused) { display: none; }
|
||||
.panes.full:not(.reading) .window.entry { display: none; }
|
||||
.panes.full.focus-doc .window.list,
|
||||
.panes.full:not(.focus-doc) .tiles,
|
||||
.panes.full .tiles > .window:not(.current) { display: none; }
|
||||
.panes.full .tiles > .window.current { inset: calc(var(--gap) / 2); width: auto; height: auto; }
|
||||
|
||||
.placeholder { display: grid; place-items: center; height: 100%; padding: 24px; }
|
||||
.standby { width: min(100%, 44ch); }
|
||||
@@ -960,11 +1044,14 @@
|
||||
|
||||
.hidden { display: none; }
|
||||
|
||||
/* A phone shows one window: the list, or the focused tile. */
|
||||
@media (max-width: 767px) {
|
||||
.panes { grid-template-columns: 1fr; }
|
||||
.panes .entry { display: none; }
|
||||
.panes .tiles { display: none; }
|
||||
.panes.reading .list { display: none; }
|
||||
.panes.reading .entry { display: block; }
|
||||
.panes.reading .tiles { display: block; }
|
||||
.tiles > .window:not(.current) { display: none; }
|
||||
.tiles > .window.current { inset: calc(var(--gap) / 2); width: auto; height: auto; }
|
||||
.overlay { padding: 12px; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" module>
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { Vim, getCM, vim } from '@replit/codemirror-vim'
|
||||
import { commands, followLink, runCommand } from './commands'
|
||||
import { commands, cycleTile, followLink, runCommand } from './commands'
|
||||
import { linkAt, sourceHeadings } from './util'
|
||||
import { workspace as ws } from './store.svelte'
|
||||
|
||||
@@ -112,6 +112,14 @@
|
||||
Vim.mapCommand('gx', 'action', 'workspaceOpenLink', {}, { context: 'normal' })
|
||||
}
|
||||
|
||||
/* `gt` / `gT` walk the tiles from inside one, as they walk vim's tab pages. */
|
||||
function bindTileKeys() {
|
||||
Vim.defineAction('workspaceNextTile', () => void cycleTile(1))
|
||||
Vim.defineAction('workspacePrevTile', () => void cycleTile(-1))
|
||||
Vim.mapCommand('gt', 'action', 'workspaceNextTile', {}, { context: 'normal' })
|
||||
Vim.mapCommand('gT', 'action', 'workspacePrevTile', {}, { context: 'normal' })
|
||||
}
|
||||
|
||||
function bindVim() {
|
||||
if (vimBound) return
|
||||
vimBound = true
|
||||
@@ -119,6 +127,7 @@
|
||||
bindHelpKey()
|
||||
bindHeadingKeys()
|
||||
bindLinkKeys()
|
||||
bindTileKeys()
|
||||
mirrorUnnamedRegisterToClipboard()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
['Shift 1 … 9', 'send the marked nodes, or the selected one, 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'],
|
||||
['f', 'fullscreen the focused pane or tile; again or Esc to leave'],
|
||||
['Alt-h/j/k/l', 'focus the tile that way, from the editor too — left of the leftmost is the list'],
|
||||
['Alt-Shift-h/j/k/l', 'move the focused tile that way'],
|
||||
['L · Shift-Enter', 'open the row in a new tile beside the others'],
|
||||
['gt / gT', 'next / previous tile — in the editor too'],
|
||||
['Alt-q', 'close the focused tile — :close; :only closes the others'],
|
||||
['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 — a first g, d, ] or [ waits a second in the status line'],
|
||||
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
|
||||
@@ -31,7 +35,7 @@
|
||||
['Ctrl-P', 'find anything, in every space — Enter goes to the result lit'],
|
||||
['b', "open this space's board"],
|
||||
['c', "capture a card into this space's inbox, without leaving here"],
|
||||
['Esc · q', 'close the document'],
|
||||
['Esc · q', 'close the document, and its tile when there are several'],
|
||||
['/', 'full-text search, inside the current space — Tab or Ctrl-N/P walk the results, Enter opens one'],
|
||||
['n / N', 'next / previous search result, opened beside the results — :noh clears the search'],
|
||||
['s', 'toggle sharing for the document — asks before taking it back'],
|
||||
@@ -74,6 +78,7 @@
|
||||
tree: 'spaces & folders',
|
||||
board: 'boards',
|
||||
documents: 'documents',
|
||||
tiles: 'tiles',
|
||||
editing: 'writing',
|
||||
sharing: 'sharing & export',
|
||||
vault: 'vault',
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
actions(index)
|
||||
}}
|
||||
class:active={index === workspace.cursor}
|
||||
class:open={workspace.open?.id === node.id}
|
||||
class:open={workspace.tiles.some((tile) => tile.open?.id === node.id)}
|
||||
class:marked={workspace.isMarked(node.id)}
|
||||
data-index={index}
|
||||
onclick={(event) => click(event, index, node)}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<span class="msg" class:alarm={workspace.flash?.kind === 'error'}>{workspace.flash?.text ?? ''}</span>
|
||||
|
||||
{#if pending}<span class="pending" aria-live="polite">{pending}</span>{/if}
|
||||
{#if workspace.tiles.length > 1}<span class="flag" title=":ls lists the tiles">tile {workspace.focused + 1}/{workspace.tiles.length}</span>{/if}
|
||||
{#if workspace.fullscreen}<span class="flag">full</span>{/if}
|
||||
{#if workspace.vault?.unlocked}<span class="flag vault" title="the vault is open — :lock closes it">vault open</span>{/if}
|
||||
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
|
||||
|
||||
+99
-8
@@ -1,3 +1,4 @@
|
||||
import { tick } from 'svelte'
|
||||
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatDay, parseDay, parseDue, relative, sealedNote, type Heading, type ScrollAmount } from './util'
|
||||
@@ -41,7 +42,7 @@ export const hooks = {
|
||||
}
|
||||
|
||||
/** Help groups the command list under these headings, in this order. */
|
||||
export const groups = ['tree', 'board', 'documents', 'editing', 'sharing', 'vault', 'view'] as const
|
||||
export const groups = ['tree', 'board', 'documents', 'tiles', 'editing', 'sharing', 'vault', 'view'] as const
|
||||
export type Group = (typeof groups)[number]
|
||||
|
||||
export interface CommandSpec {
|
||||
@@ -57,6 +58,12 @@ export interface CommandSpec {
|
||||
bang?: { help: string; run: (arg: string) => void | Promise<void> }
|
||||
}
|
||||
|
||||
/** Close the focused tile; the index of the one that has the focus after. */
|
||||
const closeFocused = () => {
|
||||
workspace.closeTile()
|
||||
return workspace.focused
|
||||
}
|
||||
|
||||
const requireOpen = () => {
|
||||
if (!workspace.open) {
|
||||
workspace.say('no document open', 'error')
|
||||
@@ -79,6 +86,33 @@ export function followLink(href: string, tab = false) {
|
||||
window.open(new URL(href, location.href).href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus a tile and hand it the keys: its editor while it is being written,
|
||||
* otherwise the shell, whose keys then act on that tile.
|
||||
*/
|
||||
export async function focusTile(index: number) {
|
||||
if (!workspace.tiles[index]) return
|
||||
workspace.focusTile(index)
|
||||
// The tile's editor fills the hooks once it has seen it has the focus.
|
||||
await tick()
|
||||
if (workspace.editing && workspace.pane === 'document') hooks.focusEditor()
|
||||
else hooks.focusList()
|
||||
}
|
||||
|
||||
/** `gt` / `gT`, `:bn` / `:bp`: the next or previous tile, round and round. */
|
||||
export function cycleTile(delta: 1 | -1) {
|
||||
const count = workspace.tiles.length
|
||||
if (count < 2) return workspace.say('only one tile — L on a row opens another', 'error')
|
||||
return focusTile((workspace.focused + delta + count) % count)
|
||||
}
|
||||
|
||||
/** `:ls`'s line for one tile: number, `%` for the focused one, `[+]` unsaved. */
|
||||
const describeTile = (index: number) => {
|
||||
const tile = workspace.tiles[index]
|
||||
const name = tile.open ? tile.open.name.trim() || tile.open.slug : '(empty)'
|
||||
return `${index + 1}${index === workspace.focused ? '%' : ''} ${name}${tile.dirty ? ' [+]' : ''}`
|
||||
}
|
||||
|
||||
/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
|
||||
const jumpTo = async (index: number) => {
|
||||
if (workspace.nodes.length === 0) return workspace.say('nothing here', 'error')
|
||||
@@ -272,10 +306,8 @@ export const commands: CommandSpec[] = [
|
||||
}
|
||||
if (workspace.editing) workspace.editing = false
|
||||
else if (workspace.returnTo === 'board') return void workspace.guard(() => workspace.returnToBoard())
|
||||
else {
|
||||
workspace.open = null
|
||||
workspace.pane = 'list'
|
||||
}
|
||||
else if (workspace.tiles.length > 1) return focusTile(closeFocused())
|
||||
else workspace.closeTile()
|
||||
hooks.focusList()
|
||||
},
|
||||
bang: {
|
||||
@@ -361,7 +393,6 @@ export const commands: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
name: 'board',
|
||||
aliases: ['b'],
|
||||
group: 'board',
|
||||
help: "show this space's board, or go back to the tree",
|
||||
run: () => workspace.guard(() => workspace.toggleBoardView()),
|
||||
@@ -563,18 +594,78 @@ export const commands: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
name: 'next',
|
||||
aliases: ['bn'],
|
||||
group: 'documents',
|
||||
help: 'open the next node down the list',
|
||||
run: () => jumpTo(workspace.cursor + 1),
|
||||
},
|
||||
{
|
||||
name: 'prev',
|
||||
aliases: ['bp'],
|
||||
group: 'documents',
|
||||
help: 'open the previous node',
|
||||
run: () => jumpTo(workspace.cursor - 1),
|
||||
},
|
||||
{
|
||||
name: 'close',
|
||||
aliases: ['clo'],
|
||||
group: 'tiles',
|
||||
help: 'close the focused tile — Alt-q; asks when its draft could not be written',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
await workspace.flushTile()
|
||||
if (workspace.dirty) {
|
||||
const name = workspace.open?.slug ?? 'the draft'
|
||||
if (!(await workspace.ask(`${name} could not be written — close anyway? the draft stays in this browser`))) return
|
||||
}
|
||||
await focusTile(closeFocused())
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'only',
|
||||
aliases: ['on'],
|
||||
group: 'tiles',
|
||||
help: 'close every tile but the focused one',
|
||||
run: () => workspace.guard(() => workspace.onlyTile()),
|
||||
},
|
||||
{
|
||||
name: 'ls',
|
||||
aliases: ['buffers', 'files'],
|
||||
group: 'tiles',
|
||||
help: 'list the open tiles, % on the focused one',
|
||||
run: () => workspace.say(workspace.tiles.map((_, index) => describeTile(index)).join(' · ')),
|
||||
},
|
||||
{
|
||||
name: 'buffer',
|
||||
aliases: ['b'],
|
||||
args: '<n|name>',
|
||||
group: 'tiles',
|
||||
help: 'focus a tile by its number in :ls, or by the start of its name',
|
||||
complete: () => workspace.tiles.flatMap((tile) => (tile.open ? [tile.open.slug] : [])),
|
||||
run: (arg) => {
|
||||
const wanted = arg.trim().toLowerCase()
|
||||
if (!wanted) return workspace.say(describeTile(workspace.focused))
|
||||
const index = /^\d+$/.test(wanted)
|
||||
? Number(wanted) - 1
|
||||
: workspace.tiles.findIndex(
|
||||
(tile) => tile.open && (tile.open.slug.startsWith(wanted) || tile.open.name.toLowerCase().startsWith(wanted)),
|
||||
)
|
||||
if (!workspace.tiles[index]) return workspace.say(`E86: no tile ${arg.trim()} — :ls lists them`, 'error')
|
||||
return focusTile(index)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'bnext',
|
||||
aliases: ['bn'],
|
||||
group: 'tiles',
|
||||
help: 'the next tile — gt',
|
||||
run: () => cycleTile(1),
|
||||
},
|
||||
{
|
||||
name: 'bprevious',
|
||||
aliases: ['bp', 'bN'],
|
||||
group: 'tiles',
|
||||
help: 'the previous tile — gT',
|
||||
run: () => cycleTile(-1),
|
||||
},
|
||||
{
|
||||
name: 'first',
|
||||
group: 'documents',
|
||||
|
||||
+130
-11
@@ -21,8 +21,18 @@ 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 }
|
||||
/**
|
||||
* Where a space was left: the folder, the open documents, the row, the pane.
|
||||
* `openId` is the focused tile's, all a spot held before there were tiles.
|
||||
*/
|
||||
type Spot = {
|
||||
parentId: number
|
||||
openId: number | null
|
||||
cursor: number
|
||||
pane: Pane
|
||||
open?: number[]
|
||||
focused?: number
|
||||
}
|
||||
|
||||
export type Draft = { name: string; body: string; created_at: number }
|
||||
|
||||
@@ -157,6 +167,78 @@ class Workspace {
|
||||
return this.tiles.filter((tile) => tile.open?.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus a tile, and follow it in the list and the address bar, as opening
|
||||
* its document did.
|
||||
*/
|
||||
focusTile(index: number) {
|
||||
const tile = this.tiles[index]
|
||||
if (!tile) return
|
||||
this.focused = index
|
||||
if (!tile.open) return
|
||||
this.pane = 'document'
|
||||
const row = this.nodes.findIndex((n) => n.id === tile.open?.id)
|
||||
if (row >= 0) this.cursor = row
|
||||
this.#syncUrl(tile.open.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiles with nothing in them go, as long as one is left: a closed document
|
||||
* closes its tile, as `:q` closes a window in vim.
|
||||
*/
|
||||
prune() {
|
||||
const current = this.tile
|
||||
if (this.tiles.length > 1) {
|
||||
const kept = this.tiles.filter((tile) => tile.open)
|
||||
this.tiles = kept.length > 0 ? kept : [current]
|
||||
const at = this.tiles.indexOf(current)
|
||||
this.focused = at >= 0 ? at : Math.min(this.focused, this.tiles.length - 1)
|
||||
}
|
||||
if (!this.open) this.pane = 'list'
|
||||
}
|
||||
|
||||
/** `:close`: the focused tile goes, or with only one, its document. */
|
||||
closeTile() {
|
||||
this.tile.clear()
|
||||
this.prune()
|
||||
if (this.open) this.focusTile(this.focused)
|
||||
}
|
||||
|
||||
/**
|
||||
* `L` / Shift-Enter: the row under the cursor in a tile of its own, next to
|
||||
* what is open, rather than in its place.
|
||||
*/
|
||||
async openBeside() {
|
||||
const selected = this.selected
|
||||
if (!selected) return
|
||||
if (isContainer(selected)) return this.say(`${selected.slug} is a ${selected.kind} — l goes into it`, 'error')
|
||||
const shown = this.tiles.findIndex((tile) => tile.open?.id === selected.id)
|
||||
if (shown >= 0) return this.focusTile(shown)
|
||||
if (this.open) {
|
||||
this.tiles.push(new Tile())
|
||||
this.focused = this.tiles.length - 1
|
||||
}
|
||||
try {
|
||||
await this.openSelected()
|
||||
} finally {
|
||||
this.prune()
|
||||
}
|
||||
}
|
||||
|
||||
/** `:only`: every other tile goes, after their drafts are written. */
|
||||
async onlyTile() {
|
||||
const others = this.tiles.filter((tile) => tile !== this.tile)
|
||||
for (const tile of others) await this.flushTile(tile)
|
||||
const unsaved = others.filter((tile) => tile.dirty)
|
||||
if (unsaved.length > 0) {
|
||||
const names = unsaved.map((tile) => tile.open?.slug).join(', ')
|
||||
if (!(await this.ask(`${names} could not be written — close anyway? the drafts stay in this browser`))) return
|
||||
}
|
||||
for (const tile of others) tile.stopAutosave()
|
||||
this.tiles = [this.tile]
|
||||
this.focused = 0
|
||||
}
|
||||
|
||||
/** The board of the current space, loaded only while it is being looked at. */
|
||||
view = $state<View>('tree')
|
||||
/** The board as the server sent it. */
|
||||
@@ -517,7 +599,7 @@ class Workspace {
|
||||
if (tile.draft.name.trim() || tile.draft.body.trim()) return false
|
||||
tile.clear()
|
||||
this.dropStash(open.id)
|
||||
if (tile === this.tile) this.pane = 'list'
|
||||
this.prune()
|
||||
try {
|
||||
await api.deleteNode(open.id)
|
||||
} catch {
|
||||
@@ -580,6 +662,7 @@ class Workspace {
|
||||
this.spaces = []
|
||||
this.nodes = []
|
||||
for (const tile of this.tiles) tile.clear()
|
||||
this.prune()
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -633,11 +716,14 @@ class Workspace {
|
||||
|
||||
#rememberSpot() {
|
||||
if (this.spaceId === null || this.parentId === null) return
|
||||
const open = this.tiles.flatMap((tile) => (tile.open ? [tile.open.id] : []))
|
||||
this.#spots[this.spaceId] = {
|
||||
parentId: this.parentId,
|
||||
openId: this.open?.id ?? null,
|
||||
cursor: this.cursor,
|
||||
pane: this.pane,
|
||||
open,
|
||||
focused: Math.max(open.indexOf(this.open?.id ?? -1), 0),
|
||||
}
|
||||
localStorage.setItem('diary:spots', JSON.stringify(this.#spots))
|
||||
}
|
||||
@@ -650,8 +736,10 @@ class Workspace {
|
||||
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.tile.clear()
|
||||
this.returnTo = null
|
||||
// Each space has tiles of its own; these come back with this one.
|
||||
for (const tile of this.tiles) tile.stopAutosave()
|
||||
this.tiles = [new Tile()]
|
||||
this.focused = 0
|
||||
this.pane = 'list'
|
||||
}
|
||||
this.spaceId = id
|
||||
@@ -668,9 +756,24 @@ class Workspace {
|
||||
await this.goTo(id)
|
||||
}
|
||||
if (spot && this.parentId === spot.parentId) {
|
||||
if (spot.openId !== null && this.nodes.some((n) => n.id === spot.openId)) {
|
||||
// Reopened under the board, if that is what is on screen.
|
||||
await this.openNode(spot.openId, false, true)
|
||||
const ids = spot.open ?? (spot.openId !== null && this.nodes.some((n) => n.id === spot.openId) ? [spot.openId] : [])
|
||||
let opened = 0
|
||||
for (const open of ids) {
|
||||
if (opened > 0) {
|
||||
this.tiles.push(new Tile())
|
||||
this.focused = this.tiles.length - 1
|
||||
}
|
||||
try {
|
||||
// Reopened under the board, if that is what is on screen.
|
||||
await this.openNode(open, false, true)
|
||||
opened++
|
||||
} catch {
|
||||
/* deleted or moved away since: one tile fewer */
|
||||
}
|
||||
}
|
||||
this.prune()
|
||||
if (opened > 0) {
|
||||
this.focusTile(Math.min(spot.focused ?? 0, this.tiles.length - 1))
|
||||
this.pane = spot.pane
|
||||
} else {
|
||||
this.cursor = Math.min(spot.cursor, Math.max(this.nodes.length - 1, 0))
|
||||
@@ -772,6 +875,14 @@ class Workspace {
|
||||
await this.goTo(node.id)
|
||||
return
|
||||
}
|
||||
// A document already open in another tile is gone to, not opened twice:
|
||||
// two drafts of one document would only overwrite each other.
|
||||
const shown = this.tiles.findIndex((tile) => tile !== this.tile && tile.open?.id === id)
|
||||
if (shown >= 0) {
|
||||
this.focusTile(shown)
|
||||
if (edit && node.kind === 'document') this.editing = true
|
||||
return
|
||||
}
|
||||
|
||||
this.open = node
|
||||
this.draft = { name: node.name, body: node.body, created_at: node.created_at }
|
||||
@@ -889,7 +1000,7 @@ class Workspace {
|
||||
const node = showing[0]?.open ?? this.nodes.find((n) => n.id === id)
|
||||
const { removed } = await api.deleteNode(id)
|
||||
for (const tile of showing) tile.clear()
|
||||
if (showing.includes(this.tile)) this.pane = 'list'
|
||||
this.prune()
|
||||
this.marked = this.marked.filter((n) => n.id !== id)
|
||||
await this.refresh()
|
||||
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
|
||||
@@ -928,6 +1039,7 @@ class Workspace {
|
||||
async deleteSpace(id: number) {
|
||||
const { removed } = await api.deleteNode(id)
|
||||
for (const tile of this.tiles) tile.clear()
|
||||
this.prune()
|
||||
this.rawBoard = null
|
||||
this.view = 'tree'
|
||||
this.spaces = await api.listSpaces()
|
||||
@@ -966,7 +1078,7 @@ class Workspace {
|
||||
await this.flushTile(tile)
|
||||
tile.clear()
|
||||
}
|
||||
if (leaving.includes(this.tile)) this.pane = 'list'
|
||||
this.prune()
|
||||
const { moved, unchanged } = await api.moveNodes(
|
||||
nodes.map((n) => n.id),
|
||||
parent,
|
||||
@@ -1310,15 +1422,22 @@ class Workspace {
|
||||
this.view = 'tree'
|
||||
if (this.parentId !== space) await this.goTo(space)
|
||||
else await this.refresh()
|
||||
// The notes open beside whatever was being read, and `q` takes their
|
||||
// tile away again on the way back to the board.
|
||||
if (this.open && this.open.id !== nodeId) {
|
||||
this.tiles.push(new Tile())
|
||||
this.focused = this.tiles.length - 1
|
||||
}
|
||||
await this.openNode(nodeId, card.node_id === null)
|
||||
this.prune()
|
||||
if (card.node_id === null) this.enterInsert = true
|
||||
this.returnTo = 'board'
|
||||
}
|
||||
|
||||
/** Back to the board a card's document was opened from. */
|
||||
async returnToBoard() {
|
||||
this.tile.clear()
|
||||
this.returnTo = null
|
||||
this.closeTile()
|
||||
this.pane = 'list'
|
||||
await this.reloadBoard()
|
||||
this.view = 'board'
|
||||
|
||||
@@ -233,3 +233,62 @@ export function linkAt(line: string, column: number): string | null {
|
||||
/** What a share link's reader will miss: a share never serves a sealed file. */
|
||||
export const sealedNote = (sealed: number) =>
|
||||
sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : ''
|
||||
|
||||
/** Where a tile sits, as fractions of the space the tiles share. */
|
||||
export type Rect = { x: number; y: number; w: number; h: number }
|
||||
export type Direction = 'h' | 'j' | 'k' | 'l'
|
||||
|
||||
/**
|
||||
* Hyprland's dwindle: the first tile takes half, and each one after it takes
|
||||
* half of what is left, side by side and then one over the other in turn.
|
||||
*/
|
||||
export function dwindle(count: number): Rect[] {
|
||||
const rects: Rect[] = []
|
||||
let rest: Rect = { x: 0, y: 0, w: 1, h: 1 }
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (i === count - 1) {
|
||||
rects.push(rest)
|
||||
} else if (i % 2 === 0) {
|
||||
rects.push({ ...rest, w: rest.w / 2 })
|
||||
rest = { ...rest, x: rest.x + rest.w / 2, w: rest.w / 2 }
|
||||
} else {
|
||||
rects.push({ ...rest, h: rest.h / 2 })
|
||||
rest = { ...rest, y: rest.y + rest.h / 2, h: rest.h / 2 }
|
||||
}
|
||||
}
|
||||
return rects
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile next to `from` in a direction: one that touches its side and
|
||||
* shares some of its length, the nearest and then the most overlapping.
|
||||
* -1 when it is at the edge.
|
||||
*/
|
||||
export function neighbour(rects: Rect[], from: number, direction: Direction): number {
|
||||
const a = rects[from]
|
||||
if (!a) return -1
|
||||
const near = 1e-6
|
||||
let best = -1
|
||||
let bestGap = Infinity
|
||||
let bestOverlap = 0
|
||||
rects.forEach((b, index) => {
|
||||
if (index === from) return
|
||||
const across = direction === 'h' || direction === 'l'
|
||||
const overlap = across
|
||||
? Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
|
||||
: Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
|
||||
if (overlap <= near) return
|
||||
const gap =
|
||||
direction === 'l' ? b.x - (a.x + a.w)
|
||||
: direction === 'h' ? a.x - (b.x + b.w)
|
||||
: direction === 'j' ? b.y - (a.y + a.h)
|
||||
: a.y - (b.y + b.h)
|
||||
if (gap < -near) return
|
||||
if (gap < bestGap - near || (Math.abs(gap - bestGap) <= near && overlap > bestOverlap)) {
|
||||
best = index
|
||||
bestGap = gap
|
||||
bestOverlap = overlap
|
||||
}
|
||||
})
|
||||
return best
|
||||
}
|
||||
Reference in new issue
Block a user