'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; } // ---------- 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'; 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 wrap = h('div', { class: 'editor-wrap' }, ta, 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'); fit(); if (mode === 'read') render(); 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(); setMode('write'); ta.focus(); ta.setSelectionRange(0, 0); } }); 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(); } function setMode(m) { mode = m; if (m === 'read') { render(); fit(); } ta.hidden = m === 'read'; view.hidden = m === 'write'; if (m === 'write') fit(); ctl.onMode?.(m); } 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); }); 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 } }); return Object.assign(ctl, { titleEl, titleErr, ta, view, wrap, status, notice, setMode, fit, flush: () => save(), // Insert the time where the caret is, on a line of its own. stamp() { setMode('write'); 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); }, }); }