A second notebook whose pages are encrypted with a key made from a passphrase plus the server key. It is unlocked into memory only (15 minutes idle, Lock, or sign-out), is not in search, lists or downloads of the ordinary notebook, and limits wrong guesses. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
516 lines
27 KiB
JavaScript
516 lines
27 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, link: true },
|
|
{ name: 'Link to today', hint: () => isoDay(new Date()), keys: 'today date journal day', text: () => '[[' + isoDay(new Date()) + ']]', link: true },
|
|
{ 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;
|
|
}
|
|
|
|
// With plain set (the vault) there are no page links, so no [[ picker and no link commands.
|
|
function enhance(ta, wrap, plain) {
|
|
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 (!plain && (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 => !(plain && c.link) && (!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, plain) {
|
|
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, plain);
|
|
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, plain);
|
|
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.
|
|
// With vault set the page lives in the vault: its own API, and no links into the ordinary notebook.
|
|
function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved, vault = false }) {
|
|
const base = vault ? '/api/vault' : '/api';
|
|
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, vault);
|
|
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, vault);
|
|
|
|
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', base + '/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', base + '/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 if (e.status === 423) {
|
|
say('Not saved');
|
|
flag([h('span', { class: 'grow' }, 'The vault locked itself, so your latest changes are not saved yet. Your text is still here.'),
|
|
h('a', { class: 'btn-link', href: '#/vault', target: '_blank', rel: 'noopener' }, 'Unlock the vault'),
|
|
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, vault) : 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); },
|
|
});
|
|
}
|