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

+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()) + ' ');