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 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-31 22:59:14 -06:00
1 parent 98f7730913
commit cec2702260
4 files changed
+179 -23

No files matched your search

+6 -3
View File
@@ -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
+7 -7
View File
@@ -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.'))),
};
+155 -13
View File
@@ -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()) + ' ');
+11
View File
@@ -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; }