Files
Mako/internal/web/static/editor.js
T
VeillaxandClaude Sonnet 5.5 cec2702260 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>
2026-10-31 22:59:14 -06:00

419 lines
22 KiB
JavaScript

'use strict';
// ---------- text helpers: edits go through the browser so Undo keeps working ----------
function replaceRange(ta, from, to, text) {
ta.focus(); ta.setSelectionRange(from, to);
const ok = text === '' ? document.execCommand('delete') : document.execCommand('insertText', false, text);
if (!ok) { ta.setRangeText(text, from, to, 'end'); ta.dispatchEvent(new Event('input', { bubbles: true })); }
}
const lineStart = (v, pos) => v.lastIndexOf('\n', pos - 1) + 1;
const lineEnd = (v, pos) => { const k = v.indexOf('\n', pos); return k < 0 ? v.length : k; };
// Where the caret is, in pixels inside the text box, found by laying the same text out in a hidden copy.
function caretXY(ta, pos) {
const cs = getComputedStyle(ta), mirror = h('div', { class: 'mirror' }), mark = h('span', {}, '.');
for (const p of ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'paddingTop', 'paddingLeft', 'paddingRight', 'tabSize'])
mirror.style[p] = cs[p];
mirror.style.width = ta.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) + 'px';
mirror.append(ta.value.slice(0, pos), mark);
document.body.append(mirror);
const at = { x: mark.offsetLeft, y: mark.offsetTop, lh: parseFloat(cs.lineHeight) || 24 };
mirror.remove();
return at;
}
// ---------- the "/" commands and the "[[" page picker ----------
const SLASH = [
{ name: 'Heading 1', hint: '#', keys: 'h1 heading title', text: '# ' },
{ name: 'Heading 2', hint: '##', keys: 'h2 heading', text: '## ' },
{ name: 'Heading 3', hint: '###', keys: 'h3 heading', text: '### ' },
{ name: 'To-do list', hint: '- [ ]', keys: 'todo task checkbox check', text: '- [ ] ' },
{ name: 'Bulleted list', hint: '-', keys: 'bullet list ul', text: '- ' },
{ name: 'Numbered list', hint: '1.', keys: 'number list ol', text: '1. ' },
{ name: 'Quote', hint: '>', keys: 'quote callout', text: '> ' },
{ name: 'Code block', hint: '```', keys: 'code pre', text: '```\n\n```', caret: 4 },
{ name: 'Divider', hint: '---', keys: 'divider line hr rule', text: '---\n' },
{ name: 'Link to a page', hint: '[[', keys: 'link page wiki mention', text: '[[', again: true },
{ name: 'Link to today', hint: () => isoDay(new Date()), keys: 'today date journal day', text: () => '[[' + isoDay(new Date()) + ']]' },
{ name: 'Current time', hint: () => hhmm(new Date()), keys: 'time clock now', text: () => hhmm(new Date()) + ' ' },
];
function makePopup(ta, wrap) {
const el = h('div', { class: 'popup', role: 'listbox', 'aria-label': 'Suggestions', hidden: true });
wrap.append(el);
let items = [], sel = 0, onPick = null;
function draw() {
fill(el, items.map((it, i) => h('div', {
class: 'popup-row' + (i === sel ? ' on' : ''), role: 'option', 'aria-selected': String(i === sel),
onmousedown: e => { e.preventDefault(); sel = i; pop.pick(); },
}, h('span', { class: 'popup-name' }, it.name), it.hint ? h('span', { class: 'popup-hint' }, typeof it.hint === 'function' ? it.hint() : it.hint) : null)));
el.querySelector('.on')?.scrollIntoView({ block: 'nearest' });
}
const pop = {
get open() { return !el.hidden; },
show(list, pick, pos) {
if (!list.length) return pop.hide();
items = list; onPick = pick; sel = 0; draw(); el.hidden = false;
const c = caretXY(ta, pos);
el.style.left = Math.max(0, Math.min(ta.offsetLeft + c.x, wrap.clientWidth - 270)) + 'px';
el.style.top = (ta.offsetTop + c.y + c.lh) + 'px';
},
hide() { el.hidden = true; items = []; },
move(d) { sel = (sel + d + items.length) % items.length; draw(); },
pick() { const it = items[sel]; pop.hide(); if (it) onPick(it); },
};
return pop;
}
function pageChoices(q) {
const lq = q.trim().toLowerCase();
const rank = p => { const t = p.title.toLowerCase(); return t === lq ? 0 : t.startsWith(lq) ? 1 : 2; };
const list = S.pages.filter(p => p.title && (!lq || p.title.toLowerCase().includes(lq)))
.sort((a, b) => rank(a) - rank(b) || a.title.localeCompare(b.title)).slice(0, 7)
.map(p => ({ name: p.title, hint: p.daily ? 'Journal' : S.byId.get(p.parent)?.name || '' }));
if (lq && !S.byTitle.has(lq)) list.push({ name: 'Create "' + q.trim() + '"', hint: 'New page', create: q.trim() });
return list;
}
function enhance(ta, wrap) {
const pop = makePopup(ta, wrap);
function check() {
if (ta.selectionStart !== ta.selectionEnd) return pop.hide();
const pos = ta.selectionStart, ls = lineStart(ta.value, pos), before = ta.value.slice(ls, pos);
let m;
if ((m = /\[\[([^\[\]|\n]*)$/.exec(before))) {
const from = pos - m[1].length;
pop.show(pageChoices(m[1]), it => {
const closed = ta.value.startsWith(']]', pos);
replaceRange(ta, from, closed ? pos + 2 : pos, (it.create || it.name) + ']]');
}, pos);
} else if ((m = /^(\s*)\/([a-z0-9]{0,16})$/i.exec(before))) {
const q = m[2].toLowerCase(), from = ls + m[1].length;
pop.show(SLASH.filter(c => !q || c.name.toLowerCase().includes(q) || c.keys.includes(q)), c => {
const text = typeof c.text === 'function' ? c.text() : c.text;
replaceRange(ta, from, pos, text);
if (c.caret) ta.setSelectionRange(from + c.caret, from + c.caret);
if (c.again) check();
}, pos);
} else pop.hide();
}
function continueList(e) {
if (ta.selectionStart !== ta.selectionEnd) return false;
const pos = ta.selectionStart, v = ta.value, ls = lineStart(v, pos), le = lineEnd(v, pos), line = v.slice(ls, le);
const m = /^(\s*)([-*+]|(\d+)([.)]))\s+(\[[ xX]\]\s+)?/.exec(line);
if (!m || pos < ls + m[0].length) return false;
e.preventDefault();
if (line.trim() === m[0].trim()) { replaceRange(ta, ls, le, ''); return true; } // an empty item ends the list
const marker = m[3] ? (Number(m[3]) + 1) + m[4] : m[2];
replaceRange(ta, pos, pos, '\n' + m[1] + marker + ' ' + (m[5] ? '[ ] ' : ''));
return true;
}
function indentList(e) {
if (ta.selectionStart !== ta.selectionEnd) return false;
const pos = ta.selectionStart, v = ta.value, ls = lineStart(v, pos), line = v.slice(ls, lineEnd(v, pos));
if (!/^\s*([-*+]|\d+[.)])\s/.test(line)) return false; // anywhere else, Tab keeps moving focus
e.preventDefault();
if (!e.shiftKey) { replaceRange(ta, ls, ls, ' '); ta.setSelectionRange(pos + 2, pos + 2); }
else {
const n = line.startsWith(' ') ? 2 : /^[ \t]/.test(line) ? 1 : 0;
if (n) { replaceRange(ta, ls, ls + n, ''); ta.setSelectionRange(pos - n, pos - n); }
}
return true;
}
function wrapSelection(mark) {
const s = ta.selectionStart, e = ta.selectionEnd, sel = ta.value.slice(s, e);
replaceRange(ta, s, e, mark + sel + mark);
ta.setSelectionRange(s + mark.length, s + mark.length + sel.length);
}
ta.addEventListener('keydown', e => {
if (pop.open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); pop.move(e.key === 'ArrowDown' ? 1 : -1); return; }
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pop.pick(); return; }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); pop.hide(); return; }
}
if (e.isComposing) return;
const plain = !e.ctrlKey && !e.metaKey && !e.altKey;
if (e.key === 'Enter' && plain && !e.shiftKey) continueList(e);
else if (e.key === 'Tab' && plain) indentList(e);
else if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'b' || e.key === 'i')) { e.preventDefault(); wrapSelection(e.key === 'b' ? '**' : '*'); }
});
ta.addEventListener('input', check);
ta.addEventListener('click', check);
ta.addEventListener('keyup', e => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) check(); });
ta.addEventListener('blur', () => setTimeout(pop.hide, 150));
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 = '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 ?? '';
if (fixedTitle != null) { titleEl.readOnly = true; titleEl.tabIndex = -1; }
const titleErr = h('p', { class: 'err small', role: 'alert', hidden: true });
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 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; };
const fit = () => { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 2 + 'px'; };
enhance(ta, wrap);
const dirty = () => titleDirty || bodyDirty;
const ctl = { save, dirty };
S.editors.add(ctl);
function flag(msg) { notice.hidden = !msg; if (msg) fill(notice, icon('alert', 16), msg); }
async function save() {
clearTimeout(timer); timer = null; clearTimeout(retry); retry = null;
if (blocked) return;
const t = titleEl.value, b = ta.value;
const sendTitle = titleDirty && t !== badTitle, sendBody = bodyDirty;
if (!sendTitle && !sendBody) return;
if (saving) { queued = true; return; }
if (!cur && !t.trim() && !b.trim()) { titleDirty = bodyDirty = false; return; } // nothing worth keeping
saving = true; say('Saving');
try {
let n;
if (!cur) n = await api('POST', '/api/notes', { title: sendTitle ? t : '', body: b, parent }, { keepalive: b.length < 15000 });
else {
const payload = { base: cur.updated };
if (sendTitle) payload.title = t;
if (sendBody) payload.body = b;
n = await api('PUT', '/api/notes/' + cur.id, payload, { keepalive: b.length < 15000 });
}
const created = !cur;
cur = n;
if (sendTitle) { if (titleEl.value === t) titleDirty = false; badTitle = null; titleErr.hidden = true; }
if (sendBody && ta.value === b) bodyDirty = false;
flag(null);
say(dirty() ? 'Unsaved' : 'Saved');
if (created) onCreated?.(cur);
onSaved?.(cur, { renamed: sendTitle });
} catch (e) {
if (e.status === 409) conflict(e.data.note);
else if (e.status === 422 && e.data?.code === 'title_taken') {
badTitle = t; titleErr.textContent = 'Another page already has this title. Pick a different one.'; titleErr.hidden = false;
say('Unsaved'); queued = bodyDirty || !cur && !!b.trim();
} else if (e.status === 401) {
say('Not saved');
flag([h('span', { class: 'grow' }, 'Your session ended, so your latest changes are not saved yet. Your text is still here.'),
h('a', { class: 'btn-link', href: '/auth/login?next=' + encodeURIComponent(location.hash), target: '_blank', rel: 'noopener' }, 'Sign in again'),
h('span', { class: 'sub' }, 'Opens in a new tab. Come back afterwards and this page saves itself.')]);
retry = setTimeout(save, 4000);
} else {
say('Not saved');
if (e.offline || e.status >= 500) { flag(e.message + ' Trying again.'); retry = setTimeout(save, 5000); }
else toast(e.message, true);
}
} finally {
saving = false;
if (queued) { queued = false; save(); }
}
}
function conflict(theirs) {
blocked = true; say('Not saved');
flag([h('span', { class: 'grow' }, 'This page changed somewhere else, probably on another device.'),
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'); refresh(); onSaved?.(cur, { renamed: true });
} }, 'Use the other version')]);
}
const later = () => { clearTimeout(timer); timer = setTimeout(save, 700); };
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(); if (mode === 'read') setMode(lastEdit); focusBody(false); }
});
ta.addEventListener('input', () => { bodyDirty = true; say('Unsaved'); fit(); later(); });
// Write shows the text box; Read shows the page as it will look, with tasks you can tick and links you can follow.
function render() { view.replaceChildren(ta.value.trim() ? md(ta.value, ticked, true) : h('p', { class: 'dim' }, 'Nothing here yet. Click to start writing.')); }
function ticked(line, checked) {
const lines = ta.value.split('\n');
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) {
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(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 }, lastEdit: { get: () => lastEdit }, note: { get: () => cur }, body: { get: () => ta.value } });
return Object.assign(ctl, {
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. In Live it goes at the end of the page.
stamp() {
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()) + ' ');
},
dispose() { S.editors.delete(ctl); window.removeEventListener('resize', fit); clearTimeout(timer); clearTimeout(retry); },
});
}