'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 }); // 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); }, }); }