diff --git a/web/src/app.css b/web/src/app.css index d695d0d..a0f4808 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -232,6 +232,4 @@ input::placeholder { color: var(--fg-faint); } outline: 1px solid var(--accent-dim) !important; color: transparent !important; } -.cm-panels { background: var(--bg-alt); color: var(--fg); border-color: var(--line); } -.cm-vim-panel { padding: 2px 8px; background: var(--bg-alt); } -.cm-vim-panel input { background: transparent; border: 0; color: var(--accent); } +/* The panel and vim `:` prompt are themed in Editor.svelte — see the note there. */ diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte index def954e..7c01aa7 100644 --- a/web/src/lib/Editor.svelte +++ b/web/src/lib/Editor.svelte @@ -34,11 +34,35 @@ { tag: tags.processingInstruction, color: 'var(--fg-faint)' }, ]) - const editorTheme = EditorView.theme({ - '&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' }, - '.cm-content': { padding: '12px 16px', maxWidth: '90ch' }, - '.cm-line': { padding: '0' }, - }) + /* + * CodeMirror's base theme paints panels and the caret for a light editor + * (`&light .cm-panels { color: black }`), and those selectors outrank + * anything the app stylesheet can say. Theme rules do win, so the parts that + * would otherwise render black on black live here rather than in app.css. + * `dark: true` also stops the light defaults applying in the first place. + */ + const editorTheme = EditorView.theme( + { + '&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' }, + '.cm-content': { padding: '12px 16px', maxWidth: '90ch' }, + '.cm-line': { padding: '0' }, + '.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--accent)' }, + '.cm-panels': { + backgroundColor: 'var(--bg-alt)', + color: 'var(--fg)', + borderColor: 'var(--line)', + }, + '.cm-panels-bottom': { borderTop: '1px solid var(--line)' }, + // The `:` prompt is a bare text node in the panel; the input follows it. + '.cm-vim-panel': { padding: '2px 8px', color: 'var(--accent)' }, + '.cm-vim-panel input': { + fontFamily: 'inherit', + color: 'var(--accent)', + caretColor: 'var(--accent)', + }, + }, + { dark: true }, + ) /** Route every `:command` typed inside the editor through the shared handler. */ function bridgeExCommands() { diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index cf78d6e..7287f2b 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -1,5 +1,5 @@
:help + everything is a key or a `:` command
-
+

keys

{#each keys as [key, description] (key)} @@ -37,28 +53,29 @@
-
-

commands

-
- {#each commands as spec (spec.name)} -
- :{spec.name}{#if spec.aliases?.length} ({spec.aliases.join(' ')}){/if} - {#if spec.args} {spec.args}{/if} -
-
{spec.help}
- {#if spec.bang} -
:{spec.name}!{#if spec.aliases?.length} ({spec.aliases.map((a) => `${a}!`).join(' ')}){/if}
-
{spec.bang.help}
- {/if} - {/each} -
-
+ {#each grouped as [group, specs] (group)} +
+

{titles[group]}

+
+ {#each specs as spec (spec.name)} +
+ {signature(spec)}{#if spec.args}{spec.args}{/if} +
+
{spec.help}
+ {#if spec.bang} +
:{spec.name}!
+
{spec.bang.help}
+ {/if} + {/each} +
+
+ {/each}
@@ -66,25 +83,46 @@ .sheet { display: flex; flex-direction: column; - gap: 12px; - width: min(100%, 90ch); + gap: 14px; + width: min(100%, 110ch); max-height: 100%; background: var(--bg-alt); border: 1px solid var(--accent-dim); border-radius: var(--radius); padding: 14px 16px; overflow-y: auto; + overflow-x: hidden; } - header { display: flex; justify-content: space-between; align-items: center; } + header { display: flex; align-items: baseline; gap: 12px; } + header .faint { flex: 1; font-size: 12px; } + header button { align-self: center; } - .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(30ch, 1fr)); gap: 20px; } + /* Newspaper columns, so the short groups fill the gaps under the long ones. */ + .cols { columns: 38ch; column-gap: 28px; } - h2 { font-size: 12px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.1em; margin: 0 0 6px; } + section { break-inside: avoid; min-width: 0; margin: 0 0 16px; } - dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; font-size: 12px; } + h2 { + font-size: 12px; + color: var(--fg-dim); + text-transform: uppercase; + letter-spacing: 0.1em; + margin: 0 0 6px; + border-bottom: 1px dashed var(--line); + padding-bottom: 4px; + } + + dl { + margin: 0; + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 3px 12px; + font-size: 12px; + } dt { color: var(--accent); white-space: nowrap; } - dd { margin: 0; color: var(--fg-dim); } + .args { margin-left: 0.6ch; } + dd { margin: 0; color: var(--fg-dim); overflow-wrap: anywhere; } footer { font-size: 12px; border-top: 1px solid var(--line); padding-top: 10px; } diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index d9c0993..680f7af 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -1,5 +1,5 @@ import { diary, type Theme } from './store.svelte' -import { parseDay } from './util' +import { formatDay, parseDay } from './util' /** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */ export const hooks = { @@ -10,10 +10,15 @@ export const hooks = { openCommandLine: (_initial: string) => {}, } +/** Help groups the command list under these headings, in this order. */ +export const groups = ['entries', 'editing', 'sharing', 'view'] as const +export type Group = (typeof groups)[number] + export interface CommandSpec { name: string aliases?: string[] args?: string + group: Group help: string run: (arg: string) => void | Promise /** The forcing variant, `:name!` — vim hands the bang over as an argument. */ @@ -28,26 +33,61 @@ const requireOpen = () => { return diary.open } +/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */ +const jumpTo = async (index: number) => { + if (diary.entries.length === 0) return diary.say('no entries', 'error') + diary.cursor = Math.min(Math.max(index, 0), diary.entries.length - 1) + await diary.guard(() => diary.openSelected()) +} + +const themes: Theme[] = ['mocha', 'green', 'amber', 'ice'] + +const setTheme = (name: string) => { + if (!themes.includes(name as Theme)) { + return diary.say(`unknown theme: ${name} — try ${themes.join(', ')}`, 'error') + } + diary.setTheme(name as Theme) + diary.say(`theme ${name}`) +} + +/** A filename that survives a download folder: `2026-09-05-first-light.md`. */ +const exportName = (title: string, at: number) => { + const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + return `${formatDay(at)}${slug ? `-${slug}` : ''}.md` +} + export const commands: CommandSpec[] = [ { name: 'write', aliases: ['w'], + group: 'editing', help: 'save the open entry', run: () => diary.guard(() => diary.save()), + bang: { help: 'same as :w — the bang is accepted out of habit', run: () => diary.guard(() => diary.save()) }, }, { name: 'wq', aliases: ['x'], + group: 'editing', help: 'save and leave insert/editor mode', run: async () => { await diary.guard(() => diary.save()) diary.editing = false hooks.focusList() }, + bang: { + help: 'same as :wq — the bang is accepted out of habit', + run: async () => { + await diary.guard(() => diary.save()) + diary.editing = false + hooks.focusList() + }, + }, }, { name: 'quit', aliases: ['q'], + group: 'editing', help: 'close the editor, or the entry when already reading', run: () => { if (diary.dirty) { @@ -76,6 +116,7 @@ export const commands: CommandSpec[] = [ name: 'new', aliases: ['n', 'o'], args: '[yyyy-mm-dd]', + group: 'entries', help: 'start a new entry, optionally dated', run: async (arg) => { await diary.guard(async () => { @@ -91,10 +132,29 @@ export const commands: CommandSpec[] = [ hooks.focusEditor() }, }, + { + name: 'today', + aliases: ['t'], + group: 'entries', + help: "open today's entry, starting one if there is none yet", + run: async () => { + await diary.guard(async () => { + if (diary.query) await diary.search('') + const midnight = new Date() + midnight.setHours(0, 0, 0, 0) + const from = Math.floor(midnight.getTime() / 1000) + const entry = diary.entries.find((e) => e.created_at >= from && e.created_at < from + 86400) + if (entry) await diary.openEntry(entry.id, true) + else await diary.createEntry() + }) + hooks.focusEditor() + }, + }, { name: 'edit', aliases: ['e'], args: '[id]', + group: 'editing', help: 'edit the open entry, or open an entry by id', run: async (arg) => { const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id @@ -102,11 +162,54 @@ export const commands: CommandSpec[] = [ await diary.guard(() => diary.openEntry(id, true)) hooks.focusEditor() }, + bang: { + help: 'throw away the draft and re-read the entry from the server', + run: async () => { + const open = requireOpen() + if (!open) return + diary.dirty = false + await diary.guard(() => diary.openEntry(open.id, diary.editing)) + diary.say(`entry:${open.id} reloaded`) + }, + }, + }, + { + name: 'next', + aliases: ['bn'], + group: 'entries', + help: 'open the next entry down the list', + run: () => jumpTo(diary.cursor + 1), + }, + { + name: 'prev', + aliases: ['bp'], + group: 'entries', + help: 'open the previous entry', + run: () => jumpTo(diary.cursor - 1), + }, + { + name: 'first', + group: 'entries', + help: 'open the newest entry', + run: () => jumpTo(0), + }, + { + name: 'last', + group: 'entries', + help: 'open the oldest entry', + run: () => jumpTo(diary.entries.length - 1), + }, + { + name: 'random', + group: 'entries', + help: 'open an entry at random — good for re-reading', + run: () => jumpTo(Math.floor(Math.random() * diary.entries.length)), }, { name: 'delete', aliases: ['d', 'rm'], args: '[id]', + group: 'entries', help: 'delete an entry (asks first)', run: async (arg) => { const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id @@ -118,6 +221,7 @@ export const commands: CommandSpec[] = [ { name: 'title', args: '', + group: 'editing', help: 'set the title of the open entry', run: async (arg) => { if (!requireOpen()) return @@ -129,6 +233,7 @@ export const commands: CommandSpec[] = [ { name: 'date', args: '', + group: 'editing', help: 're-date the open entry', run: async (arg) => { if (!requireOpen()) return @@ -141,6 +246,7 @@ export const commands: CommandSpec[] = [ }, { name: 'share', + group: 'sharing', help: 'publish the entry behind an unguessable link and copy it', run: async () => { const id = diary.open?.id ?? diary.selected?.id @@ -152,6 +258,7 @@ export const commands: CommandSpec[] = [ }, { name: 'unshare', + group: 'sharing', help: 'revoke the share link', run: async () => { const id = diary.open?.id ?? diary.selected?.id @@ -163,6 +270,7 @@ export const commands: CommandSpec[] = [ }, { name: 'link', + group: 'sharing', help: 'copy the share link of the current entry', run: async () => { const token = diary.open?.share_token ?? diary.selected?.share_token @@ -171,14 +279,46 @@ export const commands: CommandSpec[] = [ diary.say((await diary.copy(url)) ? `copied ${url}` : url) }, }, + { + name: 'copy', + aliases: ['yank'], + group: 'sharing', + help: 'copy the whole entry to the clipboard as markdown', + run: async () => { + if (!requireOpen()) return + const { title, body } = diary.draft + const text = title.trim() ? `# ${title.trim()}\n\n${body}` : body + diary.say((await diary.copy(text)) ? 'entry copied' : 'the clipboard said no', 'info') + }, + }, + { + name: 'export', + aliases: ['exp'], + group: 'sharing', + help: 'download the open entry as a .md file', + run: () => { + if (!requireOpen()) return + const { title, body, created_at } = diary.draft + const text = title.trim() ? `# ${title.trim()}\n\n${body}` : body + const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown' })) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = exportName(title.trim(), created_at) + anchor.click() + URL.revokeObjectURL(url) + diary.say(`wrote ${anchor.download}`) + }, + }, { name: 'upload', aliases: ['up'], + group: 'editing', help: 'pick files to attach at the cursor', run: () => hooks.pickFiles(), }, { name: 'media', + group: 'editing', help: 'browse everything you have uploaded', run: () => diary.guard(async () => { await diary.loadMedia() @@ -189,25 +329,64 @@ export const commands: CommandSpec[] = [ name: 'search', aliases: ['se'], args: '[text]', + group: 'entries', help: 'full-text search (empty clears)', run: (arg) => diary.guard(() => diary.search(arg)), }, + { + name: 'clear', + aliases: ['noh'], + group: 'entries', + help: 'clear the search filter', + run: () => diary.guard(() => diary.search('')), + }, + { + name: 'reload', + aliases: ['r'], + group: 'entries', + help: 're-read the entry list from the server', + run: () => + diary.guard(async () => { + await diary.refresh() + diary.say(`${diary.entries.length} entries`) + }), + }, + { + name: 'stats', + group: 'view', + help: 'word, line and entry counts', + run: () => { + const body = diary.open ? diary.draft.body : '' + const words = body.split(/\s+/).filter(Boolean).length + const here = diary.open ? `entry:${diary.open.id} ${words}w ${body.split('\n').length}L · ` : '' + diary.say(`${here}${diary.entries.length} entries${diary.query ? ` matching "${diary.query}"` : ''}`) + }, + }, + { + name: 'theme', + args: '', + group: 'view', + help: 'switch the colour scheme', + run: (arg) => setTheme(arg.trim() || diary.theme), + }, { name: 'set', args: '