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
@@ -79,6 +79,7 @@ it; in the document pane it is that document. In the browse pane:
|
|||||||
| `Ctrl-D` / `Ctrl-U` | half a page down / up the open document |
|
| `Ctrl-D` / `Ctrl-U` | half a page down / up the open document |
|
||||||
| `]]` / `[[` | the next / previous heading of the open document, in the editor too |
|
| `]]` / `[[` | the next / previous heading of the open document, in the editor too |
|
||||||
| `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) |
|
| `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) |
|
||||||
|
| `F` | a letter over every link and button in view; typing it follows it — links out of the workspace open in a tab of their own |
|
||||||
| `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password |
|
| `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password |
|
||||||
| `o` | a new document here, straight into insert mode |
|
| `o` | a new document here, straight into insert mode |
|
||||||
| `O` | a new folder here |
|
| `O` | a new folder here |
|
||||||
@@ -106,7 +107,8 @@ mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are
|
|||||||
`:upload`, `:media`, `:search`, `:backup` and
|
`:upload`, `:media`, `:search`, `:backup` and
|
||||||
`:set novim`. `:name` on its own moves up into the title; `Enter`, `Esc` or `↓`
|
`:set novim`. `:name` on its own moves up into the title; `Enter`, `Esc` or `↓`
|
||||||
there goes back down into the text. In the text, `]]`, `[[` and `gO` walk the
|
there goes back down into the text. In the text, `]]`, `[[` and `gO` walk the
|
||||||
headings as they do in the reader. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`)
|
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
|
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, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard,
|
||||||
`:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words
|
`:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import FilePane from './lib/FilePane.svelte'
|
import FilePane from './lib/FilePane.svelte'
|
||||||
import Headings from './lib/Headings.svelte'
|
import Headings from './lib/Headings.svelte'
|
||||||
import Help from './lib/Help.svelte'
|
import Help from './lib/Help.svelte'
|
||||||
|
import Hints from './lib/Hints.svelte'
|
||||||
import Verify from './lib/Verify.svelte'
|
import Verify from './lib/Verify.svelte'
|
||||||
import Login from './lib/Login.svelte'
|
import Login from './lib/Login.svelte'
|
||||||
import Overview from './lib/Overview.svelte'
|
import Overview from './lib/Overview.svelte'
|
||||||
@@ -44,6 +45,8 @@
|
|||||||
let shell = $state<HTMLDivElement>()
|
let shell = $state<HTMLDivElement>()
|
||||||
let overlayEl = $state<HTMLDivElement>()
|
let overlayEl = $state<HTMLDivElement>()
|
||||||
let pending = $state('')
|
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()
|
if (!shareToken) void workspace.boot()
|
||||||
|
|
||||||
@@ -625,6 +628,14 @@
|
|||||||
if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document')
|
if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document')
|
||||||
else void workspace.guard(() => workspace.openSelected())
|
else void workspace.guard(() => workspace.openSelected())
|
||||||
break
|
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':
|
case 'm':
|
||||||
// Pick nodes up here, walk to a folder, put them down with `p`.
|
// Pick nodes up here, walk to a folder, put them down with `p`.
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -809,6 +820,10 @@
|
|||||||
<Prompt />
|
<Prompt />
|
||||||
<SecretPrompt />
|
<SecretPrompt />
|
||||||
|
|
||||||
|
{#if hintRoot}
|
||||||
|
<Hints root={hintRoot} close={() => (hintRoot = null)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if workspace.overlay !== 'none'}
|
{#if workspace.overlay !== 'none'}
|
||||||
<!-- 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')}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { hooks, runCommand, type ScrollAmount } from './commands'
|
import { followLink, hooks, runCommand, type ScrollAmount } from './commands'
|
||||||
import { renderMarkdown } from './markdown'
|
import { renderMarkdown } from './markdown'
|
||||||
import Outline from './Outline.svelte'
|
import Outline from './Outline.svelte'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
@@ -74,11 +74,15 @@
|
|||||||
if (!scroller || !root) return
|
if (!scroller || !root) return
|
||||||
const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction)
|
const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction)
|
||||||
const list = () => readerHeadings(scroller, root)
|
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.jumpHeading = jump
|
||||||
hooks.headings = list
|
hooks.headings = list
|
||||||
|
hooks.hintRoot = hints
|
||||||
return () => {
|
return () => {
|
||||||
if (hooks.jumpHeading === jump) hooks.jumpHeading = () => false
|
if (hooks.jumpHeading === jump) hooks.jumpHeading = () => false
|
||||||
if (hooks.headings === list) hooks.headings = () => ({ headings: [], current: 0 })
|
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') ?? ''
|
const href = anchor?.getAttribute('href') ?? ''
|
||||||
if (!href.startsWith('/n/')) return
|
if (!href.startsWith('/n/')) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const path = href.slice(3).split('#')[0]
|
followLink(href)
|
||||||
void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
import { tags } from '@lezer/highlight'
|
import { tags } from '@lezer/highlight'
|
||||||
import { Vim, getCM, vim } from '@replit/codemirror-vim'
|
import { Vim, getCM, vim } from '@replit/codemirror-vim'
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
import { commands, hooks, runCommand } from './commands'
|
import { commands, followLink, hooks, runCommand } from './commands'
|
||||||
import { embedSnippet } from './markdown'
|
import { embedSnippet } from './markdown'
|
||||||
import { sourceHeadings } from './util'
|
import { linkAt, sourceHeadings } from './util'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
|
|
||||||
let host = $state<HTMLDivElement>()
|
let host = $state<HTMLDivElement>()
|
||||||
@@ -179,6 +179,26 @@
|
|||||||
Vim.mapCommand('gO', 'action', 'workspaceOutline', {}, { context: 'normal' })
|
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
|
* Media goes on its own line after the one the cursor is on, so attaching a
|
||||||
* file never cuts a sentence in half.
|
* file never cuts a sentence in half.
|
||||||
@@ -209,6 +229,7 @@
|
|||||||
bridgeExCommands()
|
bridgeExCommands()
|
||||||
bindHelpKey()
|
bindHelpKey()
|
||||||
bindHeadingKeys()
|
bindHeadingKeys()
|
||||||
|
bindLinkKeys()
|
||||||
mirrorUnnamedRegisterToClipboard()
|
mirrorUnnamedRegisterToClipboard()
|
||||||
|
|
||||||
const extensions = [
|
const extensions = [
|
||||||
|
|||||||
@@ -85,11 +85,14 @@
|
|||||||
if (locked) return void runCommand('unlock'), true
|
if (locked) return void runCommand('unlock'), true
|
||||||
return cert?.act() ?? false
|
return cert?.act() ?? false
|
||||||
}
|
}
|
||||||
|
const hints = () => reader ?? null
|
||||||
hooks.scrollReader = scroll
|
hooks.scrollReader = scroll
|
||||||
hooks.fileAction = act
|
hooks.fileAction = act
|
||||||
|
hooks.hintRoot = hints
|
||||||
return () => {
|
return () => {
|
||||||
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
|
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
|
||||||
if (hooks.fileAction === act) hooks.fileAction = () => 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'],
|
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
|
||||||
[']] / [[', 'next / previous heading of the document — in the editor too'],
|
[']] / [[', 'next / previous heading of the document — in the editor too'],
|
||||||
['gO', 'the headings as a list: j/k, Enter jumps there'],
|
['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'],
|
['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'],
|
['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'],
|
||||||
|
|||||||
@@ -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
|
/* 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,
|
||||||
|
/* `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. */
|
/* Filled in by the file pane: what Enter does there, false when nothing. */
|
||||||
fileAction: (): boolean => false,
|
fileAction: (): boolean => false,
|
||||||
/* `]]` / `[[` in whatever is being read; false when it has no headings. */
|
/* `]]` / `[[` in whatever is being read; false when it has no headings. */
|
||||||
@@ -58,6 +60,20 @@ const requireOpen = () => {
|
|||||||
return workspace.open
|
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`. */
|
/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
|
||||||
const jumpTo = async (index: number) => {
|
const jumpTo = async (index: number) => {
|
||||||
if (workspace.nodes.length === 0) return workspace.say('nothing here', 'error')
|
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
|
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. */
|
/** What a share link's reader will miss: a share never serves a sealed file. */
|
||||||
export const sealedNote = (sealed: number) =>
|
export const sealedNote = (sealed: number) =>
|
||||||
sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : ''
|
sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : ''
|
||||||
Reference in new issue
Block a user