Files
Mako/internal/web/static/editor.js
T
VeillaxandClaude Sonnet 5.5 e938367929 Fix the / and [[ menus in live preview
The page's hidden text box also heard the live view's edits and showed its own menu in the wrong place, and the live line's menu was dropped when the view redrew. Only the box in use suggests now, the menu re-attaches when shown, and the caret lands inside a new code block.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:10:54 -07:00

507 lines
26 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();
if (el.parentNode !== wrap) wrap.append(el); // a redraw of the live view can drop it
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() {
// the page's own text box also hears the live view's edits (it holds the text); only the box in use may suggest
if (document.activeElement !== ta || 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);
// in the live view a command that adds lines redraws it, and the box being typed in is a new one
const cur = ta.isConnected ? ta : wrap.querySelector('textarea.live-edit') || ta;
if (c.caret) cur.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 });
// act: the block starting at line s is being edited, caret at col. With e set, lines s to e are edited
// together as raw text (a selection across lines), and sel is [start, end, direction] inside them.
let lines = [], act = null;
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.dataset.s = b.s; box.dataset.e = b.e;
box.addEventListener('click', e => {
if (e.target.closest('a,input,button')) return;
if (e.shiftKey && reach(b)) return;
if (!inSelection()) activate(b.s, Infinity, true);
});
return box;
}
// span edits lines s to e as one piece of raw text, so selecting, deleting and Ctrl+Backspace work across lines
function span(s, e, sel) {
lines = ta.value.split('\n');
act = { s, e, sel, focus: true };
draw();
}
// reach extends the selection being edited to a line that was shift-clicked
function reach(b) {
const t = el.querySelector('.live-edit');
if (!act || !t) return false;
const to = act.e ?? blockAt(act.s).e, back = t.selectionDirection === 'backward';
const a = back ? t.selectionEnd : t.selectionStart;
if (b.s > to) span(act.s, b.e, [a, lines.slice(act.s, b.e + 1).join('\n').length, 'forward']);
else if (b.e < act.s) span(b.s, to, [0, a + lines.slice(b.s, act.s).join('\n').length + 1, 'backward']);
else return false;
return true;
}
// grab turns lines selected with the mouse into one raw editor holding exactly those lines
function grab() {
const sel = getSelection();
if (el.hidden || !sel || sel.isCollapsed || !sel.rangeCount) return;
const hit = [...el.querySelectorAll('.live-line')].filter(x => sel.containsNode(x, true));
if (hit.length < 2) return;
const s = Math.min(...hit.map(x => +x.dataset.s)), e = Math.max(...hit.map(x => +x.dataset.e));
lines = ta.value.split('\n');
span(s, e, [0, lines.slice(s, e + 1).join('\n').length, 'forward']);
}
const onUp = () => {
if (!el.isConnected) { document.removeEventListener('mouseup', onUp); return; }
setTimeout(grab, 0);
};
document.addEventListener('mouseup', onUp);
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);
const multi = b.fence || b.span; // a code block or a selection over lines: Enter just adds a line
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;
if (b.span && act) act.e = b.e;
commit();
if (!multi && 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) return;
const cmd = e.ctrlKey || e.metaKey;
if (extend(e, t, b, cmd)) return;
const word = cmd && !e.shiftKey && !e.altKey && (e.key === 'Backspace' || e.key === 'Delete'); // joins lines like Backspace does
if (e.shiftKey || (cmd && !word) || 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 = multi ? 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 (multi || 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 (multi || 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;
}
// extend turns Ctrl+A and Shift+Up or Down past the edge of a line into a selection over several lines
function extend(e, t, b, cmd) {
const v = t.value;
if (cmd && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'a') {
if (b.s === 0 && b.e === lines.length - 1) return false; // already everything: the browser selects it
e.preventDefault(); span(0, lines.length - 1, [0, lines.join('\n').length, 'forward']);
return true;
}
if (!e.shiftKey || cmd || e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return false;
const back = t.selectionDirection === 'backward';
const a = back ? t.selectionEnd : t.selectionStart, f = back ? t.selectionStart : t.selectionEnd;
const here = caretXY(t, f), col = f - (v.lastIndexOf('\n', f - 1) + 1);
if (e.key === 'ArrowUp' && here.y < here.lh * 0.5 && b.s > 0) {
const nb = blockAt(b.s - 1), add = lines.slice(nb.s, b.s).join('\n').length + 1;
e.preventDefault();
span(nb.s, b.e, [nb.fence ? 0 : Math.min(col, lines[nb.s].length), a + add, 'backward']);
return true;
}
if (e.key === 'ArrowDown' && caretXY(t, v.length).y - here.y < here.lh * 0.5 && b.e < lines.length - 1) {
const nb = blockAt(b.e + 1), at = v.length + 1;
e.preventDefault();
span(b.s, nb.e, [a, nb.fence ? at + lines.slice(nb.s, nb.e + 1).join('\n').length : at + Math.min(col, lines[nb.s].length), 'forward']);
return true;
}
return false;
}
function draw() {
lines = ta.value.split('\n');
const kids = [];
let placed = false;
for (const b of blocks()) {
if (act && act.e != null && b.e >= act.s && b.s <= act.e) { // lines edited together
if (!placed) kids.push(editor({ s: act.s, e: Math.min(act.e, lines.length - 1), fence: false, span: true }));
placed = true;
} else if (act && act.e == null && act.s === b.s) kids.push(editor(b));
else kids.push(rendered(b));
}
fill(el, kids, 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) {
t.focus();
if (act.sel) t.setSelectionRange(...act.sel);
else { const c = Math.min(act.col, t.value.length); 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); },
});
}