From cec2702260d18b9954414fcbefe10127995ad873 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:59:14 -0600 Subject: [PATCH] Basking: live preview editing Pages open in Live mode, where every line is rendered except the one being edited, with Edit (plain markdown) and View (fully rendered) beside it. Arrow keys, Backspace and Delete move between lines, code blocks are edited as a whole, and edits are spliced back into the one text the rest of the editor already saves. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 9 +- internal/web/static/app.js | 14 +-- internal/web/static/editor.js | 168 +++++++++++++++++++++++++++++++--- internal/web/static/style.css | 11 +++ 4 files changed, 179 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index fd915c8..e15ec22 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,10 @@ Named for the basking shark, which swims along with its mouth open and takes in Rename a page and every `[[link]]` to it is updated. - **Type `/` for commands**: headings, to-do lists, quotes, code blocks, dividers, today's date, the time. Lists continue when you press Enter and indent with Tab. Ctrl+B and Ctrl+I work as you expect. -- **Write or Read.** Write shows the markdown; Read shows the page as it will look, with tasks you can - tick and links you can follow. Click a page in Read to start writing (Ctrl+E switches). +- **Live, Edit or View.** Live is the default: every line shows rendered except the one you are on, + which shows its markdown, as in Obsidian's live preview. Edit shows the whole page as plain + markdown, and View shows the whole page rendered, with tasks you can tick and links you can follow. + Click a page in View to start writing. Ctrl+E switches between editing and View. - **Quick find with Ctrl+K** searches titles and text as you type, and makes a new page if there is no match. - **A journal.** Today opens the page for the day, with an Insert time button. Days link to each @@ -169,7 +171,8 @@ lower-case letters, numbers and dashes). Restart Basking to see files added by h - Searching holds every page in memory, which is comfortable for tens of thousands of pages and not built for millions. - Pages are only reloaded from disk when Basking starts. -- Write mode is plain markdown, not a block editor. Read mode shows how it looks. +- Live preview works a line at a time (a code block counts as one), so a table or a quote spread over + several lines shows rendered line by line. View shows the whole page as one. ## Design diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 1717658..bf44a79 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -220,9 +220,9 @@ function pageView({ id, date, parent = '' }) { function drawActions() { const have = !!(note && note.id); - const seg = h('div', { class: 'seg', role: 'group', 'aria-label': 'View' }, - h('button', { class: 'seg-b' + (ed.mode === 'write' ? ' on' : ''), 'aria-pressed': String(ed.mode === 'write'), onclick: () => ed.setMode('write') }, 'Write'), - h('button', { class: 'seg-b' + (ed.mode === 'read' ? ' on' : ''), 'aria-pressed': String(ed.mode === 'read'), onclick: () => ed.setMode('read') }, 'Read')); + const seg = h('div', { class: 'seg', role: 'group', 'aria-label': 'How the page is shown' }, + [['live', 'Live', 'Rendered, except the line you are on'], ['write', 'Edit', 'Plain markdown'], ['read', 'View', 'The finished page']].map(([m, label, tip]) => + h('button', { class: 'seg-b' + (ed.mode === m ? ' on' : ''), 'aria-pressed': String(ed.mode === m), title: tip, onclick: () => ed.setMode(m) }, label))); fill(actions, ed.status, h('span', { class: 'grow' }), seg, date ? [btn('Insert time', 'clock', () => ed.stamp())] : null, btn(note?.pinned ? 'Unpin' : 'Pin', 'pin', act(async () => { @@ -259,10 +259,10 @@ function pageView({ id, date, parent = '' }) { }, }); ed.onMode = () => drawActions(); - view.toggleMode = () => ed.setMode(ed.mode === 'write' ? 'read' : 'write'); + view.toggleMode = () => ed.setMode(ed.mode === 'read' ? ed.lastEdit : 'read'); if (n?.backlinks) backlinks = n.backlinks; const pref = store.get('mode'); - const start = n && n.body.trim() ? pref || 'read' : 'write'; // an empty page is for writing + const start = pref === 'read' && !(n && n.body.trim()) ? 'live' : pref || 'live'; // an empty page is for writing const title = date ? null : h('div', { class: 'title-wrap' }, ed.titleEl, ed.titleErr); fill(body, ed.notice, title, date ? h('h1', { class: 'day-title' }, ed.titleEl.value) : null, ed.wrap, tagsRow, asideBox, meta); ed.setMode(start); @@ -272,7 +272,7 @@ function pageView({ id, date, parent = '' }) { const set = ed.setMode; ed.setMode = m => { store.set('mode', m); set(m); }; if (!n) setTimeout(() => ed.titleEl.focus()); else if (!date && !n.body.trim() && !n.title) setTimeout(() => ed.titleEl.focus()); - else if (date && !n.body.trim() && start === 'write') setTimeout(() => ed.ta.focus()); + else if (date && !n.body.trim() && start !== 'read') setTimeout(() => ed.focusBody(true)); } function trashedView(n) { @@ -553,7 +553,7 @@ function settingsPage() { btn('Download all pages', 'download', () => { location.href = '/api/export'; })), card('Shortcuts', h('ul', { class: 'keys' }, row('Ctrl K', 'Search pages, or make a new one'), row('c', 'New page'), row('n', 'Jot a quick note'), - row('Ctrl E', 'Switch between Write and Read'), row('/', 'In a page: commands. Anywhere else: search'), + row('Ctrl E', 'Switch between editing and View'), row('/', 'In a page: commands. Anywhere else: search'), row('[[', 'In a page: link to another page'), row('Ctrl B, Ctrl I', 'Bold, italic'), row('Tab', 'In a list: indent'))), card('Privacy', h('p', { class: 'dim' }, 'Pages are stored as plain markdown files on the machine running Basking, under a folder only you map to. They are not encrypted on disk, so anyone who can read that machine can read them.'))), }; diff --git a/internal/web/static/editor.js b/internal/web/static/editor.js index f621a2c..4c3d0f1 100644 --- a/internal/web/static/editor.js +++ b/internal/web/static/editor.js @@ -147,11 +147,131 @@ function enhance(ta, wrap) { return pop; } +// ---------- live preview: every line shows rendered except the one being edited ---------- +// The text itself stays in the hidden text box `ta`; this view splices each edit back into it, so saving, +// conflicts and the other modes need to know nothing about it. A fenced code block is edited as a whole. +function makeLive(ta) { + const el = h('div', { class: 'live', hidden: true }); + let lines = [], act = null; // act: the block starting at line s is being edited, caret at col + + function blocks() { + const out = []; + for (let i = 0; i < lines.length;) { + if (/^\s*```/.test(lines[i])) { + let j = i + 1; + while (j < lines.length && !/^\s*```/.test(lines[j])) j++; + out.push({ s: i, e: Math.min(j, lines.length - 1), fence: true }); + i = j + 1; + } else out.push({ s: i, e: i, fence: false }), i++; + } + return out; + } + const blockAt = n => blocks().find(b => n >= b.s && n <= b.e); + const commit = () => { ta.value = lines.join('\n'); ta.dispatchEvent(new Event('input', { bubbles: true })); }; + + function rendered(b) { + const text = lines.slice(b.s, b.e + 1).join('\n'), box = h('div', { class: 'live-line' }); + if (text.trim()) { + const root = md(text, (_, checked) => { lines[b.s] = lines[b.s].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]'); commit(); draw(); }, true); + const m = /^\s*(\d+)[.)]\s/.exec(text); + if (m) root.querySelector('ol')?.setAttribute('start', m[1]); + box.append(root); + } + box.addEventListener('click', e => { if (!e.target.closest('a,input,button') && !inSelection()) activate(b.s, Infinity, true); }); + return box; + } + + function merge(at, col) { + const len = lines[at].length; + lines.splice(at, 2, lines[at] + lines[at + 1]); + commit(); act = { s: at, col: col ?? len, focus: true }; draw(); + } + + function editor(b) { + const empty = lines.length === 1 && !lines[0]; + const t = h('textarea', { class: 'live-edit', rows: 1, spellcheck: 'true', 'aria-label': 'Editing', placeholder: empty ? "Write something, or type '/' for commands, '[[' to link a page" : '' }); + t.value = lines.slice(b.s, b.e + 1).join('\n'); + enhance(t, el); + t.addEventListener('input', () => { + const fresh = t.value.split('\n'); + lines.splice(b.s, b.e - b.s + 1, ...fresh); + b.e = b.s + fresh.length - 1; + commit(); + if (!b.fence && fresh.length > 1) { // Enter: the line above is finished, the new one is being edited + const before = t.value.slice(0, t.selectionStart); + place(b.s + before.split('\n').length - 1, before.length - before.lastIndexOf('\n') - 1, true); + draw(); + } else { t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; } + }); + t.addEventListener('keydown', e => { + if (e.isComposing) return; + if (e.key === 'Escape' && !e.defaultPrevented) { t.blur(); return; } + if (e.defaultPrevented || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey || t.selectionStart !== t.selectionEnd) return; + const v = t.value, p = t.selectionStart, here = caretXY(t, p), lh = here.lh; + const firstRow = here.y < lh * 0.5, lastRow = caretXY(t, v.length).y - here.y < lh * 0.5, col = b.fence ? 0 : p; + if (e.key === 'ArrowUp' && firstRow && b.s > 0) { e.preventDefault(); activate(b.s - 1, col, true); } + else if (e.key === 'ArrowDown' && lastRow && b.e < lines.length - 1) { e.preventDefault(); activate(b.e + 1, col, true); } + else if (e.key === 'ArrowLeft' && p === 0 && b.s > 0) { e.preventDefault(); activate(b.s - 1, Infinity, true); } + else if (e.key === 'ArrowRight' && p === v.length && b.e < lines.length - 1) { e.preventDefault(); activate(b.e + 1, 0, true); } + else if (e.key === 'Backspace' && p === 0 && b.s > 0) { + e.preventDefault(); + if (b.fence || blockAt(b.s - 1).fence) activate(b.s - 1, Infinity, true); else merge(b.s - 1); + } else if (e.key === 'Delete' && p === v.length && b.e < lines.length - 1) { + e.preventDefault(); + if (b.fence || blockAt(b.e + 1).fence) activate(b.e + 1, 0, true); else merge(b.s); + } + }); + t.addEventListener('blur', () => setTimeout(() => { if (act && !el.contains(document.activeElement)) { act = null; draw(); } }, 0)); + return t; + } + + function draw() { + lines = ta.value.split('\n'); + fill(el, blocks().map(b => act && act.s === b.s ? editor(b) : rendered(b)), h('div', { class: 'live-tail', onclick: () => activate(lines.length - 1, Infinity, true) })); + const t = el.querySelector('.live-edit'); + if (!t) return; + t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; + if (act.focus) { + const c = Math.min(act.col, t.value.length); + t.focus(); t.setSelectionRange(c, c); t.scrollIntoView({ block: 'nearest' }); + act.focus = false; + } + } + // place chooses the block that holds line n; inside a code block the caret is counted from its first line + function place(n, col, focus) { + const b = blockAt(n); + if (!b) return false; + const c = b.fence && col !== Infinity ? lines.slice(b.s, n).join('\n').length + (n > b.s ? 1 : 0) + col : col; + act = { s: b.s, col: c, focus }; + return true; + } + function activate(n, col = Infinity, focus = false) { + lines = ta.value.split('\n'); + if (place(n, col, focus)) draw(); + } + return { + el, activate, + // show draws the page; with a place it starts editing there, and an empty page starts on its first line + show(at) { + lines = ta.value.split('\n'); + if (at) activate(at.n, at.col, at.focus); + else { act = ta.value ? null : { s: 0, col: 0, focus: false }; draw(); } + }, + hide() { act = null; }, + refresh() { draw(); }, + caretOffset() { + const t = el.querySelector('.live-edit'); + if (!act || !t) return null; + return lines.slice(0, act.s).join('\n').length + (act.s ? 1 : 0) + t.selectionStart; + }, + }; +} + // ---------- the page editor: autosaves title and text, and notices when a page changed elsewhere ---------- // With no note it starts a new page, which is only created once something is typed. function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) { let cur = note || null, titleDirty = false, bodyDirty = false, saving = false, queued = false, blocked = false; - let timer = null, retry = null, badTitle = null, mode = 'write'; + let timer = null, retry = null, badTitle = null, mode = 'live', lastEdit = 'live'; const titleEl = h('input', { class: 'title-input', type: 'text', placeholder: 'Untitled', 'aria-label': 'Page title', maxlength: 120, autocomplete: 'off', spellcheck: 'true' }); titleEl.value = fixedTitle ?? note?.title ?? ''; @@ -160,7 +280,8 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) { const ta = h('textarea', { class: 'write big', 'aria-label': 'Page text', placeholder: "Write something, or type '/' for commands, '[[' to link a page" }); ta.value = note?.body ?? ''; const view = h('div', { class: 'view', hidden: true }); - const wrap = h('div', { class: 'editor-wrap' }, ta, view); + const live = makeLive(ta); + const wrap = h('div', { class: 'editor-wrap' }, ta, live.el, view); const status = h('span', { class: 'sub', role: 'status' }); const notice = h('div', { class: 'notice notice-warning', hidden: true }); const say = t => { status.textContent = t; }; @@ -227,7 +348,7 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) { h('button', { class: 'ghost', onclick: () => { cur = theirs; blocked = false; titleDirty = titleEl.value !== theirs.title; bodyDirty = ta.value !== theirs.body; flag(null); save(); } }, 'Keep my version'), h('button', { class: 'ghost', onclick: () => { cur = theirs; blocked = false; titleDirty = bodyDirty = false; titleEl.value = theirs.title; ta.value = theirs.body; - flag(null); say('Saved'); fit(); if (mode === 'read') render(); onSaved?.(cur, { renamed: true }); + flag(null); say('Saved'); refresh(); onSaved?.(cur, { renamed: true }); } }, 'Use the other version')]); } @@ -235,7 +356,7 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) { titleEl.addEventListener('input', () => { titleDirty = true; titleErr.hidden = true; if (badTitle !== null && titleEl.value !== badTitle) badTitle = null; say('Unsaved'); later(); }); titleEl.addEventListener('blur', () => save()); titleEl.addEventListener('keydown', e => { - if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); setMode('write'); ta.focus(); ta.setSelectionRange(0, 0); } + if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); if (mode === 'read') setMode(lastEdit); focusBody(false); } }); ta.addEventListener('input', () => { bodyDirty = true; say('Unsaved'); fit(); later(); }); @@ -246,27 +367,48 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) { lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]'); ta.value = lines.join('\n'); bodyDirty = true; say('Unsaved'); render(); later(); } + // Live shows every line rendered except the one you are on; Edit shows the markdown; View shows the page. function setMode(m) { - mode = m; - if (m === 'read') { render(); fit(); } - ta.hidden = m === 'read'; view.hidden = m === 'write'; - if (m === 'write') fit(); + const was = mode, hadFocus = document.activeElement === ta, off = was === 'live' ? live.caretOffset() : null; + mode = m; if (m !== 'read') lastEdit = m; + ta.hidden = m !== 'write'; view.hidden = m !== 'read'; live.el.hidden = m !== 'live'; + if (was === 'live' && m !== 'live') live.hide(); + if (m === 'read') render(); + else if (m === 'write') { fit(); if (off != null) { ta.focus(); ta.setSelectionRange(off, off); } } + else if (hadFocus) { + const before = ta.value.slice(0, ta.selectionStart); + live.show({ n: before.split('\n').length - 1, col: before.length - before.lastIndexOf('\n') - 1, focus: true }); + } else live.show(); ctl.onMode?.(m); } + // refresh redraws whatever is showing after the text was replaced from outside + function refresh() { if (mode === 'read') render(); else if (mode === 'live') live.show(); else fit(); } + function focusBody(end) { + if (mode === 'live') live.show({ n: end ? ta.value.split('\n').length - 1 : 0, col: end ? Infinity : 0, focus: true }); + else { ta.focus(); const p = end ? ta.value.length : 0; ta.setSelectionRange(p, p); } + } view.addEventListener('click', e => { if (e.target.closest('a,input,button') || inSelection()) return; - setMode('write'); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); + setMode(lastEdit); focusBody(true); }); window.addEventListener('resize', fit); // Getters must be defined on the object itself; Object.assign would copy their values once. - Object.defineProperties(ctl, { mode: { get: () => mode }, note: { get: () => cur }, body: { get: () => ta.value } }); + Object.defineProperties(ctl, { mode: { get: () => mode }, lastEdit: { get: () => lastEdit }, note: { get: () => cur }, body: { get: () => ta.value } }); return Object.assign(ctl, { - titleEl, titleErr, ta, view, wrap, status, notice, setMode, + titleEl, titleErr, ta, view, wrap, status, notice, setMode, focusBody, fit, flush: () => save(), - // Insert the time where the caret is, on a line of its own. + // Insert the time where the caret is, on a line of its own. In Live it goes at the end of the page. stamp() { - setMode('write'); ta.focus(); + if (mode === 'read') setMode(lastEdit); + if (mode === 'live') { + const text = ta.value.replace(/\s+$/, ''); + ta.value = (text ? text + '\n\n' : '') + hhmm(new Date()) + ' '; + ta.dispatchEvent(new Event('input', { bubbles: true })); + focusBody(true); + return; + } + ta.focus(); const at = ta.selectionStart, before = ta.value.slice(0, at), line = before.slice(before.lastIndexOf('\n') + 1); const lead = line.trim() ? '\n\n' : before && !before.endsWith('\n\n') && before.endsWith('\n') ? '\n' : ''; replaceRange(ta, at, ta.selectionEnd, lead + hhmm(new Date()) + ' '); diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 073ee1b..063740f 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -325,3 +325,14 @@ .note-title a:hover { text-decoration:underline; } .keys { list-style:none; margin:0; padding:0; display:grid; gap:var(--s2); } @media (max-width:640px) { .page-actions { justify-content:flex-start; } .shade-top { padding-top:var(--s4); } } + + /* live preview: lines render, the line being edited shows its markdown */ + .live { position:relative; line-height:1.7; font-size:var(--text-base); } + .live-line { min-height:1.7em; cursor:text; overflow-wrap:anywhere; } + .live .md p, .live .md ul, .live .md ol, .live .md pre, .live .md blockquote { margin:0; } + .live .md hr { margin:var(--s2) 0; } + .live .md .md-h { margin:var(--s2) 0 0; } + .live .md h3.md-h { font-size:var(--text-xl); } .live .md h4.md-h { font-size:var(--text-lg); } + textarea.live-edit { display:block; width:100%; min-height:1.7em; margin:0 0 0 calc(-1 * (var(--s2) + 2px)); padding:0 0 0 var(--s2); border:0; border-left:2px solid var(--accent); border-radius:0; background:var(--accent-soft); font-family:var(--font); font-size:var(--text-base); line-height:1.7; resize:none; overflow:hidden; } + textarea.live-edit:focus { outline:none; border-color:var(--accent); } + .live-tail { min-height:30vh; cursor:text; }