mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
keys: links are followed without the pointer
`F` puts a letter or two over every link and button in view, vimium-style, and typing one does what a click on it would. `f` was already fullscreen, so the hints live on the other key. The labels start from the home row and are all one length, so a label is done the moment it is typed. Esc, a key that matches no label, or anything that scrolls the page puts them away. In the file pane they reach the unlock and keystore buttons too. A workspace link (`/n/…`) opens in place, as a click on it already did. Any other link gets a tab of its own instead of taking the workspace with it. In the editor, `gf` follows the link under the cursor, or the next one on the line. A workspace link saves the text and opens that document. `gx` opens the link in a new tab, as vim hands it to the browser. Both read markdown links, embeds, `<…>` autolinks and bare addresses.
This commit is contained in:
1 parent
6a8ffdf408
commit
54bc333be2
9 files changed
+226
-6
No files matched your search
@@ -9,6 +9,7 @@
|
||||
import FilePane from './lib/FilePane.svelte'
|
||||
import Headings from './lib/Headings.svelte'
|
||||
import Help from './lib/Help.svelte'
|
||||
import Hints from './lib/Hints.svelte'
|
||||
import Verify from './lib/Verify.svelte'
|
||||
import Login from './lib/Login.svelte'
|
||||
import Overview from './lib/Overview.svelte'
|
||||
@@ -44,6 +45,8 @@
|
||||
let shell = $state<HTMLDivElement>()
|
||||
let overlayEl = $state<HTMLDivElement>()
|
||||
let pending = $state('')
|
||||
/** `F`: the pane whose links and buttons are wearing hints right now. */
|
||||
let hintRoot = $state<HTMLElement | null>(null)
|
||||
|
||||
if (!shareToken) void workspace.boot()
|
||||
|
||||
@@ -625,6 +628,14 @@
|
||||
if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document')
|
||||
else void workspace.guard(() => workspace.openSelected())
|
||||
break
|
||||
case 'F': {
|
||||
// `f` already fills the screen, so the hints vimium puts on `f` live
|
||||
// on its other key.
|
||||
event.preventDefault()
|
||||
const root = reading ? hooks.hintRoot() : null
|
||||
if (root) hintRoot = root
|
||||
break
|
||||
}
|
||||
case 'm':
|
||||
// Pick nodes up here, walk to a folder, put them down with `p`.
|
||||
event.preventDefault()
|
||||
@@ -809,6 +820,10 @@
|
||||
<Prompt />
|
||||
<SecretPrompt />
|
||||
|
||||
{#if hintRoot}
|
||||
<Hints root={hintRoot} close={() => (hintRoot = null)} />
|
||||
{/if}
|
||||
|
||||
{#if workspace.overlay !== 'none'}
|
||||
<!-- 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')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { hooks, runCommand, type ScrollAmount } from './commands'
|
||||
import { followLink, hooks, runCommand, type ScrollAmount } from './commands'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import Outline from './Outline.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
@@ -74,11 +74,15 @@
|
||||
if (!scroller || !root) return
|
||||
const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction)
|
||||
const list = () => readerHeadings(scroller, root)
|
||||
// The outline in the margin is links too, so `F` reaches it as well.
|
||||
const hints = () => scroller
|
||||
hooks.jumpHeading = jump
|
||||
hooks.headings = list
|
||||
hooks.hintRoot = hints
|
||||
return () => {
|
||||
if (hooks.jumpHeading === jump) hooks.jumpHeading = () => false
|
||||
if (hooks.headings === list) hooks.headings = () => ({ headings: [], current: 0 })
|
||||
if (hooks.hintRoot === hints) hooks.hintRoot = () => null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -124,8 +128,7 @@
|
||||
const href = anchor?.getAttribute('href') ?? ''
|
||||
if (!href.startsWith('/n/')) return
|
||||
event.preventDefault()
|
||||
const path = href.slice(3).split('#')[0]
|
||||
void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
|
||||
followLink(href)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { Vim, getCM, vim } from '@replit/codemirror-vim'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { commands, hooks, runCommand } from './commands'
|
||||
import { commands, followLink, hooks, runCommand } from './commands'
|
||||
import { embedSnippet } from './markdown'
|
||||
import { sourceHeadings } from './util'
|
||||
import { linkAt, sourceHeadings } from './util'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
let host = $state<HTMLDivElement>()
|
||||
@@ -179,6 +179,26 @@
|
||||
Vim.mapCommand('gO', 'action', 'workspaceOutline', {}, { context: 'normal' })
|
||||
}
|
||||
|
||||
/*
|
||||
* `gf` follows the link under the cursor — a workspace link opens that
|
||||
* document, saving this one first, anything else gets a tab — and `gx`
|
||||
* opens it in a tab whatever it is, as vim hands it to the browser.
|
||||
*/
|
||||
function bindLinkKeys() {
|
||||
const follow = (tab: boolean) => {
|
||||
if (!view) return
|
||||
const head = view.state.selection.main.head
|
||||
const line = view.state.doc.lineAt(head)
|
||||
const href = linkAt(line.text, head - line.from)
|
||||
if (!href) return workspace.say('no link under the cursor', 'error')
|
||||
followLink(href, tab)
|
||||
}
|
||||
Vim.defineAction('workspaceFollowLink', () => follow(false))
|
||||
Vim.defineAction('workspaceOpenLink', () => follow(true))
|
||||
Vim.mapCommand('gf', 'action', 'workspaceFollowLink', {}, { context: 'normal' })
|
||||
Vim.mapCommand('gx', 'action', 'workspaceOpenLink', {}, { context: 'normal' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Media goes on its own line after the one the cursor is on, so attaching a
|
||||
* file never cuts a sentence in half.
|
||||
@@ -209,6 +229,7 @@
|
||||
bridgeExCommands()
|
||||
bindHelpKey()
|
||||
bindHeadingKeys()
|
||||
bindLinkKeys()
|
||||
mirrorUnnamedRegisterToClipboard()
|
||||
|
||||
const extensions = [
|
||||
|
||||
@@ -85,11 +85,14 @@
|
||||
if (locked) return void runCommand('unlock'), true
|
||||
return cert?.act() ?? false
|
||||
}
|
||||
const hints = () => reader ?? null
|
||||
hooks.scrollReader = scroll
|
||||
hooks.fileAction = act
|
||||
hooks.hintRoot = hints
|
||||
return () => {
|
||||
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
|
||||
if (hooks.fileAction === act) hooks.fileAction = () => false
|
||||
if (hooks.hintRoot === hints) hooks.hintRoot = () => null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
|
||||
[']] / [[', 'next / previous heading of the document — in the editor too'],
|
||||
['gO', 'the headings as a list: j/k, Enter jumps there'],
|
||||
['F', 'hints over the links and buttons in view — type one to follow it'],
|
||||
['gf / gx', 'in the editor: follow the link under the cursor / open it in a tab'],
|
||||
['Enter · l', 'open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password'],
|
||||
['h', 'from the document to the list, from the list up a level'],
|
||||
['o', 'new document here, straight into insert mode'],
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* `F`: a letter or two over every link and button in view, the way vimium
|
||||
* draws them — typing one does what a click on it would. The labels are
|
||||
* read off the page once and pinned where the targets were; anything that
|
||||
* moves the page under them puts them away rather than leaving them wrong.
|
||||
*/
|
||||
import { followLink } from './commands'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
interface Props {
|
||||
root: HTMLElement
|
||||
close: () => void
|
||||
}
|
||||
|
||||
const { root, close }: Props = $props()
|
||||
|
||||
/* The home row first, then the rows above and below: the labels a hand on
|
||||
the keys reaches without looking. */
|
||||
const LETTERS = 'asdfghjklqweruiopzxcvnm'
|
||||
|
||||
type Hint = { label: string; element: HTMLElement; x: number; y: number }
|
||||
|
||||
/** Every target that is on screen inside the root, top to bottom. */
|
||||
function targets(): HTMLElement[] {
|
||||
const view = root.getBoundingClientRect()
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('a[href], button, summary')).filter((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
return (
|
||||
box.width > 0 &&
|
||||
box.height > 0 &&
|
||||
box.bottom > Math.max(view.top, 0) &&
|
||||
box.top < Math.min(view.bottom, innerHeight) &&
|
||||
box.right > view.left &&
|
||||
box.left < view.right
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/* Labels all of one length, so none is the start of another and a label is
|
||||
done the moment it is typed. */
|
||||
function labels(count: number): string[] {
|
||||
let length = 1
|
||||
while (LETTERS.length ** length < count) length++
|
||||
return Array.from({ length: count }, (_, n) => {
|
||||
let label = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
label = LETTERS[n % LETTERS.length] + label
|
||||
n = Math.floor(n / LETTERS.length)
|
||||
}
|
||||
return label
|
||||
})
|
||||
}
|
||||
|
||||
const found = targets()
|
||||
const names = labels(found.length)
|
||||
const hints: Hint[] = found.map((element, i) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
return { label: names[i], element, x: box.left, y: box.top }
|
||||
})
|
||||
|
||||
let typed = $state('')
|
||||
|
||||
if (hints.length === 0) {
|
||||
workspace.say('nothing to follow on screen')
|
||||
queueMicrotask(() => close())
|
||||
}
|
||||
|
||||
/** What a click would do — except that a link out of the workspace gets a tab of its own. */
|
||||
function activate(element: HTMLElement) {
|
||||
if (element instanceof HTMLAnchorElement) {
|
||||
const href = element.getAttribute('href') ?? ''
|
||||
if (href.startsWith('#') || element.target || element.hasAttribute('download')) element.click()
|
||||
else followLink(href)
|
||||
return
|
||||
}
|
||||
element.click()
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Ahead of everything else, the editor and the window handler included,
|
||||
// as the `[y/N]` prompt takes its key.
|
||||
const onkey = (event: KeyboardEvent) => {
|
||||
if (['Shift', 'Control', 'Alt', 'Meta', 'CapsLock'].includes(event.key)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Backspace') {
|
||||
typed = typed.slice(0, -1)
|
||||
return
|
||||
}
|
||||
const next = typed + event.key.toLowerCase()
|
||||
const matching = hints.filter((hint) => hint.label.startsWith(next))
|
||||
if (event.key.length !== 1 || matching.length === 0) return close()
|
||||
if (matching.length === 1 && matching[0].label === next) {
|
||||
close()
|
||||
activate(matching[0].element)
|
||||
return
|
||||
}
|
||||
typed = next
|
||||
}
|
||||
window.addEventListener('keydown', onkey, true)
|
||||
window.addEventListener('scroll', close, true)
|
||||
window.addEventListener('resize', close)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onkey, true)
|
||||
window.removeEventListener('scroll', close, true)
|
||||
window.removeEventListener('resize', close)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="hints" aria-hidden="true">
|
||||
{#each hints as hint (hint.label)}
|
||||
{#if hint.label.startsWith(typed)}
|
||||
<span class="hint" style="left: {hint.x}px; top: {hint.y}px"
|
||||
><span class="typed">{typed}</span>{hint.label.slice(typed.length)}</span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hints { position: fixed; inset: 0; z-index: 35; pointer-events: none; }
|
||||
.hint {
|
||||
position: absolute;
|
||||
transform: translate(-4px, -6px);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--light);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-transform: lowercase;
|
||||
box-shadow: 0 1px 6px color-mix(in oklab, var(--ink) 60%, transparent);
|
||||
}
|
||||
.typed { opacity: 0.4; }
|
||||
</style>
|
||||
@@ -20,6 +20,8 @@ export const hooks = {
|
||||
/* Filled in while a document is on screen and not being edited: the reading
|
||||
pane owns the scrollback, so the list keys have somewhere to send it. */
|
||||
scrollReader: (_amount: ScrollAmount): boolean => false,
|
||||
/* `F`: what holds the links and buttons on screen, null when nothing does. */
|
||||
hintRoot: (): HTMLElement | null => null,
|
||||
/* Filled in by the file pane: what Enter does there, false when nothing. */
|
||||
fileAction: (): boolean => false,
|
||||
/* `]]` / `[[` in whatever is being read; false when it has no headings. */
|
||||
@@ -58,6 +60,20 @@ const requireOpen = () => {
|
||||
return workspace.open
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a link in a document goes. `/n/` links are the workspace's own — the
|
||||
* importer writes them between documents — and are opened in place; anything
|
||||
* else would take the workspace's tab with it, so it gets a tab of its own,
|
||||
* as does a workspace link asked for with `gx`.
|
||||
*/
|
||||
export function followLink(href: string, tab = false) {
|
||||
if (href.startsWith('/n/') && !tab) {
|
||||
const path = href.slice(3).split('#')[0]
|
||||
return void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
|
||||
}
|
||||
window.open(new URL(href, location.href).href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/** 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')
|
||||
|
||||
@@ -211,6 +211,25 @@ export function sourceHeadings(source: string): { text: string; level: number; l
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* The link the cursor stands on in a line of markdown — `[text](href)`, an
|
||||
* embed's ``, `<https://…>` or a bare address — or else the first
|
||||
* one after it on the line, as vim's `gf` looks ahead to the next name.
|
||||
*/
|
||||
export function linkAt(line: string, column: number): string | null {
|
||||
const pattern =
|
||||
/!?\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)|<((?:https?:\/\/|\/n\/)[^>\s]+)>|((?:https?:\/\/|\/n\/)[^\s<>()]+)/g
|
||||
let ahead: string | null = null
|
||||
for (const match of line.matchAll(pattern)) {
|
||||
// A sentence that ends on an address ends with a full stop, not the address.
|
||||
const href = match[1] ?? match[2] ?? match[3].replace(/[.,;:!?]+$/, '')
|
||||
const start = match.index
|
||||
if (column >= start && column < start + match[0].length) return href
|
||||
if (start >= column) ahead ??= href
|
||||
}
|
||||
return ahead
|
||||
}
|
||||
|
||||
/** 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` : ''
|
||||
Reference in new issue
Block a user