diff --git a/src/lib/editor.js b/src/lib/editor.js
new file mode 100644
index 0000000..b6ed9bc
--- /dev/null
+++ b/src/lib/editor.js
@@ -0,0 +1,1904 @@
+// Block editor engine. Plain DOM on purpose: contenteditable and a virtual DOM don't mix well,
+// so Svelte mounts this once per page and listens to onChange.
+//
+// keystroke -> .c (contenteditable) -> block object (html/text) -> onChange(blocks) -> store saves .md
+import { ic } from './icons.js';
+import { esc, htmlText } from './text.js';
+import { highlight } from './highlight.js';
+import { block, linkChip, webLink, isWebUrl, toMarkdown, footnoteRef, inlineNote } from './markdown.js';
+import { paintFootnotes } from './footnotes.js';
+import { scrollToBlock } from './scroll.js';
+import { staticHtml } from './embed.js';
+import { linkLabel, IMAGE_FILE, OTHER_FILE } from './links.js';
+import katex from 'katex';
+import mermaid from 'mermaid';
+mermaid.initialize({ startOnLoad: false, theme: 'dark' });
+
+let mermaidId = 0;
+
+
+const reduceMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+const PH = {
+ p: "Type '/' for commands", h1: 'Heading 1', h2: 'Heading 2', h3: 'Heading 3', todo: 'To-do',
+ bullet: 'List', number: 'List', footnote: 'Footnote text', quote: 'Quote', callout: 'Write something worth noticing', toggle: 'Toggle'
+};
+
+const KINDS = [
+ { t: 'p', label: 'Text', desc: 'Plain paragraph', g: 'Aa', k: 'text paragraph plain' },
+ { t: 'h1', label: 'Heading 1', desc: 'Big section heading', g: 'H1', hint: '#', k: 'title h1' },
+ { t: 'h2', label: 'Heading 2', desc: 'Medium section heading', g: 'H2', hint: '##', k: 'h2' },
+ { t: 'h3', label: 'Heading 3', desc: 'Small section heading', g: 'H3', hint: '###', k: 'h3' },
+ { t: 'todo', label: 'To-do list', desc: 'Track tasks with a checkbox', g: ic('check'), hint: '[]', k: 'todo task check' },
+ { t: 'bullet', label: 'Bulleted list', desc: 'A simple list', g: '•', hint: '-', k: 'list bullet ul' },
+ { t: 'number', label: 'Numbered list', desc: 'A list that counts', g: '1.', hint: '1.', k: 'list numbered ordered ol' },
+ { t: 'quote', label: 'Quote', desc: 'Set a passage apart', g: '“', hint: '>', k: 'quote blockquote' },
+ { t: 'callout', label: 'Callout', desc: 'Make a note stand out', g: ic('leaf'), k: 'callout note info' },
+ { t: 'toggle', label: 'Toggle', desc: 'Tuck details away', g: ic('chev'), k: 'toggle collapse details' },
+ { t: 'code', label: 'Code', desc: 'With syntax highlighting', g: '</>', hint: '```', k: 'code snippet' },
+ { t: 'divider', label: 'Divider', desc: 'Separate sections', g: '—', hint: '---', k: 'divider line hr rule' },
+ { t: 'table', label: 'Table', desc: 'Rows and columns', g: ic('table'), k: 'table grid rows columns spreadsheet' },
+ { t: 'math', label: 'Math', desc: 'KaTeX equation block', g: '∑', hint: '$', k: 'math latex formula equation katex' },
+ { t: 'mermaid', label: 'Mermaid', desc: 'Diagrams and charts', g: '⑂', hint: '```mermaid', k: 'mermaid diagram chart graph' },
+
+ { t: 'image', label: 'Image', desc: 'Add a picture from your computer', g: ic('image'), k: 'image picture photo upload' },
+ { t: 'footnote', label: 'Footnote', desc: 'A numbered note at the bottom of the page', g: '¹', hint: '[^1]', k: 'footnote note reference citation source' },
+ { t: 'embed', label: 'Embed page', desc: 'Show another note inline', g: ic('file'), hint: '![[', k: 'embed page note include transclude' },
+ { t: 'link', label: 'Link to page', desc: 'Point to another note', g: ic('file'), hint: '[[', k: 'link page wiki mention' }
+];
+// Blocks without text to put the caret in.
+const SOLID = new Set(['divider', 'image', 'embed']);
+// Enter continues these; Enter on an empty one ends the list.
+const LIST = new Set(['bullet', 'number', 'todo']);
+// Blocks that can't be turned into another type.
+const FIXED = new Set(['divider', 'image', 'table', 'embed', 'footnote']);
+// What "Turn into" offers.
+const TEXT_KINDS = ['p', 'h1', 'h2', 'h3', 'bullet', 'number', 'todo', 'quote', 'callout', 'toggle', 'code'];
+
+const SHORTCUTS = [
+ ['### ', 'h3'], ['## ', 'h2'], ['# ', 'h1'], ['- ', 'bullet'], ['* ', 'bullet'],
+ ['[] ', 'todo'], ['[ ] ', 'todo'], ['> ', 'quote'], ['```', 'code']
+];
+const LANGS = ['js', 'ts', 'py', 'sh', 'md', 'txt'];
+
+/* caret helpers */
+const sel = () => window.getSelection();
+function placeEnd(el) { el.focus(); const r = document.createRange(); r.selectNodeContents(el); r.collapse(false); const s = sel(); s.removeAllRanges(); s.addRange(r); }
+function placeStart(el) { el.focus(); const r = document.createRange(); r.selectNodeContents(el); r.collapse(true); const s = sel(); s.removeAllRanges(); s.addRange(r); }
+function textBefore(el) { const s = sel(); if (!s.rangeCount || !el.contains(s.focusNode)) return ''; const r = document.createRange(); r.selectNodeContents(el); r.setEnd(s.focusNode, s.focusOffset); return r.toString(); }
+const caretOffset = (el) => textBefore(el).length;
+function atStart(el) { const s = sel(); return !!s.rangeCount && s.isCollapsed && textBefore(el).length === 0; }
+function atEnd(el) { const s = sel(); if (!s.rangeCount || !s.isCollapsed || !el.contains(s.focusNode)) return false; const r = document.createRange(); r.selectNodeContents(el); r.setStart(s.focusNode, s.focusOffset); return r.toString().length === 0; }
+function setCaret(el, off) {
+ const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
+ let n, rest = off;
+ while ((n = w.nextNode())) {
+ if (rest <= n.length) { const r = document.createRange(); r.setStart(n, rest); r.collapse(true); const s = sel(); s.removeAllRanges(); s.addRange(r); return; }
+ rest -= n.length;
+ }
+ placeEnd(el);
+}
+function rangeAt(el, a, z) {
+ const r = document.createRange();
+ const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
+ let n, pos = 0, started = false;
+ while ((n = w.nextNode())) {
+ const L = n.length;
+ if (!started && a <= pos + L) { r.setStart(n, a - pos); started = true; }
+ if (started && z <= pos + L) { r.setEnd(n, z - pos); return r; }
+ pos += L;
+ }
+ return null;
+}
+function insertText(t) {
+ const s = sel();
+ if (!s.rangeCount) return;
+ const r = s.getRangeAt(0);
+ r.deleteContents();
+ const node = document.createTextNode(t);
+ r.insertNode(node);
+ r.setStartAfter(node);
+ r.collapse(true);
+ s.removeAllRanges();
+ s.addRange(r);
+}
+
+const NO_LINKS = {
+ pages: () => [], resolve: () => null, refFor: (id) => id, open: () => {}, create: async () => null,
+ current: () => null, headings: async () => [], embed: async () => ({ state: 'error' })
+};
+const NO_IMAGES = { url: (src) => src, save: async () => null, pick: async () => null };
+
+const stamp = (d = new Date()) => {
+ const z = (n) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}-${z(d.getHours())}${z(d.getMinutes())}${z(d.getSeconds())}`;
+};
+const imageExt = (type) => ({ 'image/jpeg': 'jpg', 'image/svg+xml': 'svg' })[type] || (type.split('/')[1] || 'png').replace(/[^a-z0-9]/g, '');
+
+// links: { pages(), resolve(ref), refFor(id), open(ref), create(title) -> id, current() -> id of the open page,
+// headings(id) -> [{ text, level }], embed(ref) -> what an ![[embed]] shows (see store.loadEmbed) }
+// images: { url(src), save(file, name) -> src, pick() -> src | null }
+export function createEditor(host, { blocks: initial, animate = true, onChange = () => {}, onExitTop = () => {}, onCopy = () => {}, links = NO_LINKS, images = NO_IMAGES }) {
+ const reduce = reduceMotion();
+ let blocks = initial && initial.length ? initial : [block('p')];
+ let slash = null; // popup under the caret: mode 'blocks' (after /) or 'link' (after [[)
+ let dragId = null;
+
+ host.classList.add('blocks');
+ host.innerHTML = '
';
+ const drop = host.firstChild;
+ const tail = document.createElement('div');
+ tail.className = 'tail';
+ host.after(tail);
+ const slashEl = document.createElement('div');
+ slashEl.className = 'pop slash';
+ slashEl.setAttribute('role', 'listbox');
+ slashEl.hidden = true;
+ document.body.append(slashEl);
+ const scroller = host.closest('.scroller');
+
+ // Copy an editable field's HTML into its block: .c -> html, .c2 -> toggle body, .tcell -> table cell.
+ function sync(c) {
+ const b = byId(c.closest('.block')?.dataset.id);
+ if (!b) return null;
+ if (c.innerHTML === '
') c.innerHTML = '';
+ if (c.classList.contains('tcell')) { b.rows[+c.dataset.r][+c.dataset.c] = c.innerHTML; delete b.src; }
+ else if (c.classList.contains('c2')) b.body = c.innerHTML;
+ else b.html = c.innerHTML;
+ return b;
+ }
+
+ // kind 'type' = text typed into block `id`; anything else is a structural change.
+ const changed = (kind = 'op', id = null) => { record(kind, id); onChange(blocks); repaintFootnotes(); };
+ const byId = (id) => blocks.find((b) => b.id === id);
+ const elOf = (id) => host.querySelector(`.block[data-id="${id}"]`);
+
+ /* rendering */
+ function inner(b) {
+ const c = `${b.html || ''}
`;
+ switch (b.type) {
+ case 'bullet': return `${c}`;
+ case 'number': return `${c}`;
+ case 'todo': return `${c}`;
+ case 'callout': {
+ const k = b.kind || 'note';
+ const icons = { note: 'leaf', tip: 'bulb', warning: 'bell', caution: 'bolt' };
+ return `${ic(icons[k] || 'leaf')}${c}
`;
+ }
+ case 'code': return `${highlight(b.text || '')} `;
+
+ case 'math': {
+ let preview = '';
+ try { preview = katex.renderToString(b.text || '', { throwOnError: false, displayMode: true }); } catch (e) { preview = '' + esc(e.message) + '
'; }
+ return `${preview}
math
${highlight(b.text || '')} `;
+ }
+ case 'mermaid': {
+ const id = 'mermaid-' + (++mermaidId);
+ // We will render mermaid in a delayed manner
+ setTimeout(async () => {
+ try {
+ if (!b.text?.trim()) return;
+ const el = document.getElementById(id);
+ if (!el) return;
+ const { svg } = await mermaid.render(id + '-svg', b.text);
+ el.innerHTML = svg;
+ } catch (e) {
+ const el = document.getElementById(id);
+ if (el) el.innerHTML = '' + esc(e.message) + '
';
+ }
+ }, 10);
+ return `mermaid
${highlight(b.text || '')} `;
+ }
+
+ case 'divider': return '';
+ case 'image': return `
${ic('image')}Image not found: ${esc(b.src)}`;
+ case 'table': return tableHtml(b);
+ case 'footnote': return `${c}`;
+ case 'embed': return ` `;
+ case 'toggle': return ``;
+ default: return c;
+ }
+ }
+ function tableHtml(b) {
+ const align = (c) => (b.align?.[c] ? ` style="text-align:${b.align[c]}"` : '');
+ const rows = b.rows.map((row, r) => `${row.map((h, c) => {
+ const tag = r ? 'td' : 'th';
+ return `<${tag}${align(c)}>${h || ''}
${tag}>`;
+ }).join('')}
`).join('');
+ return `` +
+ `
` +
+ `
`;
+ }
+ function make(b) {
+ const w = document.createElement('div');
+ w.className = 'block b-' + b.type + (b.type === 'todo' && b.checked ? ' done' : '');
+ w.dataset.id = b.id;
+ if (b.indent) w.style.marginLeft = (b.indent * 24) + 'px';
+ if (b.type === 'footnote') w.dataset.fid = b.fid;
+ w.innerHTML = `` + inner(b);
+ w.addEventListener('animationend', () => w.classList.remove('stagger', 'enter-b', 'morph'));
+ paintLinks(w);
+ if (b.type === 'embed') fillEmbed(w, b);
+ return w;
+ }
+ // Fetch what an embed points at and show it (read-only) under its title.
+ async function fillEmbed(w, b) {
+ const box = w.querySelector('.emb'), body = w.querySelector('.emb-body'), more = w.querySelector('.emb-more');
+ const title = w.querySelector('.emb-title span');
+ let r;
+ try { r = await links.embed(b.ref); } catch { r = { state: 'error' }; }
+ const note = (html) => { body.innerHTML = `${html}
`; more.hidden = true; };
+ if (r.state === 'ok') {
+ title.textContent = r.heading ? `${r.title} › ${r.heading}` : r.title;
+ body.innerHTML = staticHtml(r.blocks, { imageUrl: r.imageUrl });
+ paintLinks(body);
+ paintFootnotes(body);
+ // A long embed is folded, with a button to open it fully.
+ const fold = () => {
+ if (!body.isConnected) return ro.disconnect();
+ const long = body.scrollHeight > 340;
+ body.classList.toggle('long', long);
+ more.hidden = !long;
+ };
+ const ro = new ResizeObserver(fold);
+ ro.observe(body);
+ fold();
+ } else if (r.state === 'missing') { box.classList.add('missing'); note(`There's no note called “${esc(r.name)}”.`); }
+ else if (r.state === 'self') note('A note can’t be embedded in itself.');
+ else if (r.state === 'board') note('This is a board, so there’s no text to show. Open it with the title above.');
+ else if (r.state === 'noheading') note(`“${esc(r.title)}” has no heading called “${esc(r.heading)}”.`);
+ else note('Couldn’t load this note.');
+ }
+ // Dim links whose page doesn't exist (yet).
+ function paintLinks(root) {
+ for (const a of root.querySelectorAll('.wl')) a.classList.toggle('missing', !links.resolve(a.dataset.ref));
+ }
+ blocks.forEach((b, i) => {
+ const el = make(b);
+ if (animate && !reduce) { el.style.setProperty('--i', Math.min(i, 16)); el.classList.add('stagger'); }
+ host.append(el);
+ });
+
+ /* undo / redo
+ Whole-page snapshots. Typing in one block within a short pause is one step; every structural change
+ (new, removed, moved or converted block) is its own step, however many changed() calls it makes. */
+ const past = [], future = [];
+ let saved = snap(); // the state after the last recorded change
+ let last = { kind: null, id: null, at: 0 };
+ let sameTick = false;
+
+ function caretInfo() {
+ const s = sel();
+ if (!s.rangeCount) return null;
+ const n = s.focusNode;
+ const c = n && (n.nodeType === 1 ? n : n.parentElement)?.closest('.c,.c2,.tcell');
+ if (!c || !host.contains(c) || c.closest('.emb')) return null;
+ const cell = c.classList.contains('tcell') ? [+c.dataset.r, +c.dataset.c] : null;
+ return { id: c.closest('.block').dataset.id, c2: c.classList.contains('c2'), cell, off: textBefore(c).length };
+ }
+ function snap() { return { json: JSON.stringify(blocks), caret: caretInfo() }; }
+ function record(kind, id) {
+ const now = Date.now();
+ const merge = sameTick || (kind === 'type' && last.kind === 'type' && last.id === id && now - last.at < 1200);
+ if (!merge) {
+ past.push(saved);
+ if (past.length > 300) past.shift();
+ }
+ future.length = 0;
+ saved = snap();
+ last = { kind, id, at: now };
+ if (kind !== 'type' && !sameTick) { sameTick = true; queueMicrotask(() => (sameTick = false)); }
+ }
+ function restore(s) {
+ closeSlash();
+ closeMenu();
+ endDrag();
+ blocks = JSON.parse(s.json);
+ saved = s;
+ last = { kind: null, id: null, at: 0 };
+ host.querySelectorAll('.block').forEach((x) => x.remove());
+ for (const b of blocks) host.append(make(b));
+ const el = s.caret && elOf(s.caret.id);
+ const c = el && el.querySelector(s.caret.cell ? `.tcell[data-r="${s.caret.cell[0]}"][data-c="${s.caret.cell[1]}"]` : s.caret.c2 ? '.c2' : '.c');
+ if (c) { c.focus(); setCaret(c, s.caret.off); }
+ onChange(blocks);
+ repaintFootnotes();
+ }
+ function undo() {
+ if (!past.length) return;
+ future.push(snap());
+ restore(past.pop());
+ }
+ function redo() {
+ if (!future.length) return;
+ past.push(snap());
+ restore(future.pop());
+ }
+ function onHistoryKey(e) {
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
+ const k = e.key.toLowerCase();
+ if (k !== 'z' && k !== 'y') return;
+ const t = e.target;
+ // Our editor, or nothing focused (e.g. right after dragging a block). Inputs elsewhere keep their own undo.
+ if (!host.contains(t) && t !== document.body && t !== document.documentElement) return;
+ e.preventDefault();
+ k === 'y' || e.shiftKey ? redo() : undo();
+ }
+ function onBeforeInput(e) {
+ if (e.inputType === 'historyUndo') { e.preventDefault(); undo(); }
+ if (e.inputType === 'historyRedo') { e.preventDefault(); redo(); }
+ }
+
+ /* block operations */
+ function focusBlock(id, where = 'end') {
+ const el = elOf(id);
+ const b = byId(id);
+ if (b && SOLID.has(b.type)) return;
+ const cells = el ? el.querySelectorAll('.tcell') : [];
+ const c = el && (el.querySelector('.c') || (where === 'start' ? cells[0] : cells[cells.length - 1]));
+ if (c) (where === 'start' ? placeStart : placeEnd)(c);
+ }
+ function insertAfter(refId, b, focus = 'start') {
+ const i = blocks.findIndex((x) => x.id === refId);
+ blocks.splice(i + 1, 0, b);
+ const el = make(b);
+ el.classList.add('enter-b');
+ const ref = elOf(refId);
+ ref ? ref.after(el) : host.append(el);
+ if (focus) focusBlock(b.id, focus);
+ changed();
+ return el;
+ }
+ function removeBlock(id, anim = true) {
+ blocks = blocks.filter((b) => b.id !== id);
+ const el = elOf(id);
+ if (el) {
+ if (anim && !reduce) { el.style.pointerEvents = 'none'; el.classList.add('leaving'); setTimeout(() => el.remove(), 180); }
+ else el.remove();
+ }
+ if (!blocks.length) { const b = block('p'); blocks.push(b); host.append(make(b)); }
+ changed();
+ }
+ function convert(b, type, where = 'end') {
+ const old = elOf(b.id);
+ if (type === 'code') { b.text = b.text ?? htmlText(b.html); b.lang = b.lang || 'txt'; }
+ else if (b.type === 'code') b.html = esc(b.text || '');
+ if (type === 'todo') b.checked = !!b.checked;
+ if (type === 'toggle') { b.body = b.body || ''; b.open = true; }
+ b.type = type;
+ const el = make(b);
+ el.classList.add('morph');
+ old.replaceWith(el);
+ if (type !== 'divider') focusBlock(b.id, where);
+ changed();
+ }
+ function addDivider(b, empty) {
+ if (empty) { convert(b, 'divider'); insertAfter(b.id, block('p'), 'start'); }
+ else { const d = block('divider'); insertAfter(b.id, d, false); insertAfter(d.id, block('p'), 'start'); }
+ }
+ // Put block `b` at position `index`.
+ function insertAt(index, b) {
+ blocks.splice(index, 0, b);
+ const el = make(b);
+ el.classList.add('enter-b');
+ const next = blocks[index + 1];
+ next ? elOf(next.id).before(el) : host.append(el);
+ changed();
+ return el;
+ }
+ function move(b, dir, where) {
+ let i = blocks.indexOf(b) + dir;
+ while (blocks[i] && SOLID.has(blocks[i].type)) i += dir;
+ if (blocks[i]) focusBlock(blocks[i].id, where);
+ else if (dir < 0) onExitTop();
+ }
+
+ /* slash menu and [[link picker */
+ function openSlash(c, b, q) {
+ const ql = q.toLowerCase();
+ // Best matches first: the label starts with what was typed, then a word of the label does, then a keyword matches.
+ const score = (k) => {
+ const label = k.label.toLowerCase();
+ if (!ql || label.startsWith(ql)) return 0;
+ if (label.split(/\s+/).some((w) => w.startsWith(ql))) return 1;
+ return (label + ' ' + k.k).toLowerCase().includes(ql) ? 2 : -1;
+ };
+ const items = KINDS.map((k, i) => ({ k, i, s: score(k) })).filter((x) => x.s >= 0).sort((a, z) => a.s - z.s || a.i - z.i).map((x) => x.k);
+ if (!items.length) return closeSlash();
+ // Keep the highlighted row only while the query is unchanged; a new query starts at its best match.
+ const idx = slash && slash.mode === 'blocks' && slash.id === b.id && slash.q === q ? Math.min(slash.idx, items.length - 1) : 0;
+ slash = { mode: 'blocks', id: b.id, q, items, idx, c, end: caretOffset(c) };
+ drawSlash();
+ placeSlash(c);
+ }
+ let linkSeq = 0; // a heading list arrives later; only the newest request may show it
+ // `embed`: the picker was opened with ![[ rather than [[. A # in the text switches it to that page's headings.
+ function openLinks(c, b, q, embed = false) {
+ linkSeq++;
+ const hashAt = q.indexOf('#');
+ if (hashAt >= 0) return openHeadings(c, b, q, hashAt, embed);
+ const ql = q.trim().toLowerCase();
+ const pages = links.pages();
+ const score = (p) => {
+ const t = p.title.toLowerCase();
+ return !ql ? 1 : t === ql ? 0 : t.startsWith(ql) ? 1 : t.includes(ql) ? 2 : p.id.toLowerCase().includes(ql) ? 3 : -1;
+ };
+ const items = pages
+ .map((p) => ({ kind: 'page', id: p.id, title: p.title, icon: p.icon, s: score(p) }))
+ .filter((x) => x.s >= 0)
+ .sort((a, b2) => a.s - b2.s || a.title.localeCompare(b2.title))
+ .slice(0, 8);
+ if (ql && !embed && !pages.some((p) => p.title.toLowerCase() === ql)) items.push({ kind: 'new', title: q.trim() });
+ if (!items.length) return closeSlash();
+ const idx = slash && slash.mode === 'link' && slash.id === b.id && slash.q === q ? Math.min(slash.idx, items.length - 1) : 0;
+ slash = { mode: 'link', id: b.id, q, items, idx, c, end: caretOffset(c), embed };
+ drawSlash();
+ placeSlash(c);
+ }
+ // [[Page#… or [[#… : suggest the headings of that page (or of this one).
+ function openHeadings(c, b, q, hashAt, embed) {
+ const pagePart = q.slice(0, hashAt).trim();
+ const want = q.slice(hashAt + 1).trim().toLowerCase();
+ const pageId = pagePart ? links.resolve(pagePart) : links.current();
+ if (!pageId) { linkSeq++; return closeSlash(); }
+ const seq = linkSeq, end = caretOffset(c);
+ links.headings(pageId).then((list) => {
+ if (seq !== linkSeq || !c.isConnected) return;
+ const items = list
+ .filter((h) => !want || h.text.toLowerCase().includes(want))
+ .slice(0, 10)
+ .map((h) => ({ kind: 'heading', pageRef: pagePart ? links.refFor(pageId) : '', title: h.text, level: h.level }));
+ if (!items.length) return closeSlash();
+ const idx = slash && slash.mode === 'link' && slash.id === b.id && slash.q === q ? Math.min(slash.idx, items.length - 1) : 0;
+ slash = { mode: 'link', id: b.id, q, items, idx, c, end, embed };
+ drawSlash();
+ placeSlash(c);
+ });
+ }
+ function drawSlash() {
+ const row = (i, glyph, title, sub, hint = '') =>
+ ``;
+ if (slash.mode === 'link') {
+ const heads = slash.items[0] && slash.items[0].kind === 'heading';
+ slashEl.innerHTML = `${heads ? 'Headings' : slash.embed ? 'Embed page' : 'Link to page'}
` +
+ slash.items.map((it, i) => it.kind === 'new'
+ ? row(i, ic('plus'), `Create “${esc(it.title)}”`, 'New page at the top level')
+ : it.kind === 'heading'
+ ? row(i, ic('hash'), esc(it.title), `H${it.level} · ${esc(it.pageRef || 'this page')}`)
+ : row(i, ic('file'), esc(it.title), esc(it.id.includes('/') ? it.id.slice(0, it.id.lastIndexOf('/')) : 'Top level'))
+ ).join('');
+ } else {
+ slashEl.innerHTML = `${slash.q ? 'Matching blocks' : 'Basic blocks'}
` +
+ slash.items.map((k, i) => row(i, k.g, k.label, k.desc, k.hint ? `${esc(k.hint)}` : '')).join('');
+ }
+ slashEl.hidden = false;
+ const on = slashEl.querySelector('.si.on');
+ if (on) on.scrollIntoView({ block: 'nearest' });
+ }
+ function placeSlash(c) {
+ const s = sel();
+ let r = s.rangeCount ? s.getRangeAt(0).getBoundingClientRect() : null;
+ if (!r || (!r.top && !r.left)) r = c.getBoundingClientRect();
+ const w = slashEl.offsetWidth, h = slashEl.offsetHeight;
+ const x = Math.min(r.left, innerWidth - w - 12);
+ let y = r.bottom + 8;
+ if (y + h > innerHeight - 12) y = Math.max(12, r.top - h - 8);
+ slashEl.style.left = Math.max(12, x) + 'px';
+ slashEl.style.top = y + 'px';
+ }
+ function closeSlash() { linkSeq++; if (!slash) return; slash = null; slashEl.hidden = true; }
+ // A 3×3 table (header + two rows) in place of an empty paragraph, or after `b`.
+ function addTable(b, empty) {
+ const t = block('table', '', { rows: [['', '', ''], ['', '', ''], ['', '', '']], align: [] });
+ if (empty) {
+ const i = blocks.indexOf(b);
+ blocks.splice(i, 1, t);
+ const el = make(t);
+ el.classList.add('morph');
+ elOf(b.id).replaceWith(el);
+ changed();
+ } else insertAt(blocks.indexOf(b) + 1, t);
+ focusCell(t, 0, 0);
+ }
+ function applySlash(i) {
+ if (slash.mode === 'link') return applyLink(i);
+ const k = slash.items[i], c = slash.c, q = slash.q, end = slash.end, b = byId(slash.id);
+ closeSlash();
+ if (!b) return;
+ const from = Math.max(0, end - q.length - 1);
+ const r = rangeAt(c, from, end);
+ if (r) r.deleteContents();
+ if (c.innerHTML === '
') c.innerHTML = '';
+ b.html = c.innerHTML;
+ const empty = !c.textContent.trim();
+ if (k.t === 'link') {
+ setCaret(c, from);
+ insertText('[[');
+ return onInput(c);
+ }
+ if (k.t === 'footnote') return addFootnote(c, from);
+ if (k.t === 'embed') {
+ setCaret(c, from);
+ insertText('![[');
+ return onInput(c);
+ }
+ if (k.t === 'image') {
+ changed('type', b.id);
+ images.pick().then((src) => src && addImage(b, src), () => {});
+ return;
+ }
+ if (k.t === 'divider') return addDivider(b, empty);
+ if (k.t === 'table') return addTable(b, empty);
+ if (empty) { if (k.t === 'code') b.text = ''; convert(b, k.t, 'start'); }
+ else {
+ const extra = k.t === 'code' ? { text: '', lang: 'txt' } : k.t === 'toggle' ? { body: '', open: true } : k.t === 'todo' ? { checked: false } : {};
+ insertAfter(b.id, block(k.t, '', extra), 'start');
+ }
+ }
+
+ /* links */
+ // Replace characters a..z of `c` with a link chip and put the caret just after it
+ // (after a spacer, when typing goes on from there).
+ function putChip(c, a, z, ref, label, spacer = true) { putInline(c, a, z, linkChip(ref, label), spacer); }
+ // Replace characters a..z of `c` with one atomic inline element (link chip, footnote reference).
+ function putInline(c, a, z, html, spacer = true) {
+ const r = rangeAt(c, a, z);
+ if (!r) return;
+ r.deleteContents();
+ const holder = document.createElement('span');
+ holder.innerHTML = html;
+ const chip = holder.firstChild;
+ const space = spacer ? document.createTextNode('\u00a0') : null;
+ if (space) r.insertNode(space);
+ r.insertNode(chip);
+ paintLinks(c);
+ c.focus();
+ const after = document.createRange();
+ after.setStartAfter(space || chip);
+ after.collapse(true);
+ const s = sel();
+ s.removeAllRanges();
+ s.addRange(after);
+ sync(c);
+ changed();
+ }
+ async function applyLink(i) {
+ const it = slash.items[i], c = slash.c, q = slash.q, end = slash.end, embed = !!slash.embed;
+ closeSlash();
+ const from = Math.max(0, end - q.length - (embed ? 3 : 2)); // back over "![[" or "[["
+ let ref;
+ if (it.kind === 'heading') ref = it.pageRef + '#' + it.title;
+ else {
+ const id = it.kind === 'new' ? await links.create(it.title) : it.id;
+ if (!id) return;
+ ref = links.refFor(id);
+ }
+ if (!c.isConnected) return;
+ embed ? placeEmbed(c, from, end, ref) : putChip(c, from, end, ref);
+ }
+
+ // After a link chip or footnote reference, putInline leaves a no-break space so the caret has somewhere to go.
+ // A space typed right after it stands in for that spacer, so the file doesn't get two spaces.
+ function skipDoubleSpace(e) {
+ if (!e || e.inputType !== 'insertText' || e.data !== ' ') return;
+ const s = sel();
+ const n = s.focusNode, o = s.focusOffset;
+ if (!n || n.nodeType !== 3 || o !== 2 || n.data.slice(0, 2) !== '\u00a0 ') return;
+ let prev = n.previousSibling;
+ while (prev && prev.nodeType === 3 && !prev.data) prev = prev.previousSibling; // empty text left by the replace
+ if (!prev?.matches?.('.wl, sup.fn')) return;
+ n.deleteData(0, 1);
+ const r = document.createRange();
+ r.setStart(n, 1);
+ r.collapse(true);
+ s.removeAllRanges();
+ s.addRange(r);
+ }
+
+ /* footnotes: [^id] references in the text, "[^id]: text" blocks (usually at the end), ^[inline notes] */
+ const notInEmbed = (el) => !el.closest('.emb');
+ let fnFrame = 0;
+ function repaintFootnotes() {
+ if (!fnFrame) fnFrame = requestAnimationFrame(() => { fnFrame = 0; paintFootnotes(host, notInEmbed); });
+ }
+ const footnoteBlock = (id) => blocks.find((x) => x.type === 'footnote' && x.fid === id);
+
+ // /footnote: a reference at the caret (next free number) and an empty footnote text after the others, then go there.
+ function addFootnote(c, at) {
+ const used = new Set(blocks.filter((x) => x.type === 'footnote').map((x) => x.fid));
+ for (const el of host.querySelectorAll('sup.fn[data-fn]')) used.add(el.dataset.fn);
+ let n = 1;
+ while (used.has(String(n))) n++;
+ const id = String(n);
+ // A reference hugs the word before it: "word[^1]", not "word [^1]".
+ if (at > 0 && /\S[ \u00a0]$/.test(textBefore(c) && c.textContent.slice(0, at))) {
+ const sp = rangeAt(c, at - 1, at);
+ if (sp) { sp.deleteContents(); at--; }
+ }
+ setCaret(c, at);
+ const r = sel().getRangeAt(0);
+ const holder = document.createElement('span');
+ holder.innerHTML = footnoteRef(id);
+ r.insertNode(holder.firstChild);
+ sync(c);
+ newFootnote(id);
+ }
+ function newFootnote(id) {
+ let at = blocks.length;
+ for (let i = blocks.length - 1; i >= 0; i--) if (blocks[i].type === 'footnote') { at = i + 1; break; }
+ const fb = block('footnote', '', { fid: id });
+ insertAt(at, fb);
+ const el = elOf(fb.id);
+ if (scroller && el) scrollToBlock(scroller, el);
+ focusBlock(fb.id, 'start');
+ }
+ // Done writing a footnote: put the caret back in the text, right after (the first) reference to it.
+ function backToReference(b) {
+ const sup = [...host.querySelectorAll('sup.fn[data-fn]')].find((x) => x.dataset.fn === b.fid && notInEmbed(x));
+ const c = sup && sup.closest('.c,.c2,.tcell');
+ if (!c) return false;
+ c.focus();
+ const r = document.createRange();
+ const next = sup.nextSibling;
+ if (next && next.nodeType === 3) r.setStart(next, 0);
+ else r.setStartAfter(sup);
+ r.collapse(true);
+ const s = sel();
+ s.removeAllRanges();
+ s.addRange(r);
+ const block = sup.closest('.block');
+ if (scroller && block) scrollToBlock(scroller, block);
+ return true;
+ }
+
+ // Click a reference: scroll to its text (or start one, if there's none yet).
+ function goToFootnote(sup) {
+ hideTip();
+ if ('inline' in sup.dataset) return showTip(sup);
+ const b = footnoteBlock(sup.dataset.fn);
+ if (!b) return newFootnote(sup.dataset.fn);
+ const el = elOf(b.id);
+ if (el && scroller) scrollToBlock(scroller, el);
+ }
+
+ // Hovering a reference shows its text.
+ const tip = document.createElement('div');
+ tip.className = 'pop fn-tip';
+ tip.hidden = true;
+ document.body.append(tip);
+ function showTip(sup) {
+ let html;
+ if ('inline' in sup.dataset) html = esc(sup.dataset.inline);
+ else {
+ const b = footnoteBlock(sup.dataset.fn);
+ html = !b ? 'No footnote text yet. Click to write it.' : htmlText(b.html).trim() ? b.html : 'This footnote is empty.';
+ }
+ tip.innerHTML = `${esc(sup.textContent)}${html}`;
+ tip.hidden = false;
+ const r = sup.getBoundingClientRect();
+ const w = tip.offsetWidth, h = tip.offsetHeight;
+ let y = r.top - h - 8;
+ if (y < 8) y = r.bottom + 8;
+ tip.style.left = Math.max(8, Math.min(r.left + r.width / 2 - w / 2, innerWidth - w - 8)) + 'px';
+ tip.style.top = y + 'px';
+ }
+ function hideTip() { tip.hidden = true; }
+ const onOver = (e) => { const s = e.target.closest?.('sup.fn'); if (s && notInEmbed(s)) showTip(s); };
+ const onOut = (e) => { const s = e.target.closest?.('sup.fn'); if (s && !s.contains(e.relatedTarget)) hideTip(); };
+
+ /* embeds: ![[Page]] shows a note inline as a block of its own */
+ // Replace the typed "![[…" in `c` with an embed block.
+ function placeEmbed(c, from, end, ref) {
+ const b = byId(c.closest('.block')?.dataset.id);
+ if (!b) return;
+ const r = rangeAt(c, from, end);
+ if (r) r.deleteContents();
+ if (c.innerHTML === '
') c.innerHTML = '';
+ b.html = c.innerHTML;
+ addEmbed(b, c, ref);
+ }
+ function addEmbed(b, c, ref, alias = '') {
+ let nb = b;
+ if (b.type === 'p' && !c.textContent.trim()) {
+ b.type = 'embed'; b.ref = ref; b.alias = alias; b.html = '';
+ const el = make(b);
+ el.classList.add('morph');
+ elOf(b.id).replaceWith(el);
+ changed();
+ } else {
+ nb = block('embed', '', { ref, alias });
+ insertAt(blocks.indexOf(b) + 1, nb);
+ }
+ // Keep a line to carry on typing on.
+ const next = blocks[blocks.indexOf(nb) + 1];
+ if (!next) insertAfter(nb.id, block('p'), 'start');
+ else if (!SOLID.has(next.type)) focusBlock(next.id, 'start');
+ }
+
+ /* inline formatting: toolbar over a selection, and its shortcuts */
+ const MOD = typeof navigator !== 'undefined' && /Mac/.test(navigator.platform) ? '⌘' : 'Ctrl+';
+ const FMT = [
+ ['bold', 'B', 'Bold', MOD + 'B'],
+ ['italic', 'I', 'Italic', MOD + 'I'],
+ ['strike', 'S', 'Strikethrough', MOD + 'Shift+S'],
+ ['mark', 'H', 'Highlight', MOD + 'Shift+H'],
+ ['code', ic('code'), 'Code', MOD + 'E'],
+ ['weblink', ic('link'), 'Link', MOD + 'K'],
+ ['link', ic('file'), 'Link to page', '[[']
+ ];
+ const bar = document.createElement('div');
+ bar.className = 'pop fmt';
+ bar.setAttribute('role', 'toolbar');
+ bar.setAttribute('aria-label', 'Text formatting');
+ bar.hidden = true;
+ const barButtons = FMT.map(([f, g, label, keys]) => ``).join('');
+ bar.innerHTML = barButtons;
+ document.body.append(bar);
+ let barFrame = 0;
+
+ // The text block holding the whole (non-empty) selection, if formatting applies there.
+ function selBlock() {
+ const s = sel();
+ if (!s.rangeCount || s.isCollapsed) return null;
+ const r = s.getRangeAt(0);
+ const n = r.commonAncestorContainer;
+ const c = (n.nodeType === 1 ? n : n.parentElement)?.closest('.c,.c2,.tcell');
+ if (!c || !host.contains(c) || c.classList.contains('code-c') || c.closest('.emb')) return null;
+ return c;
+ }
+ const inside = (node, tag, c) => {
+ for (let n = node; n && n !== c; n = n.parentNode) if (n.nodeName === tag) return n;
+ return null;
+ };
+ // Which formats the selection in `c` already has (lights the toolbar and right-click buttons).
+ function formatsAt(c) {
+ const r = sel().getRangeAt(0);
+ return {
+ bold: document.queryCommandState('bold'),
+ italic: document.queryCommandState('italic'),
+ strike: document.queryCommandState('strikeThrough'),
+ code: !!inside(r.commonAncestorContainer, 'CODE', c),
+ mark: !!(inside(r.commonAncestorContainer, 'MARK', c) || inside(r.startContainer, 'MARK', c)),
+ link: !!(r.commonAncestorContainer.nodeType === 1 && r.commonAncestorContainer.querySelector?.('.wl')),
+ weblink: !!(linkAt(r.startContainer) || r.cloneContents().querySelector('a.xl'))
+ };
+ }
+ function showBar() {
+ barFrame = 0;
+ if (linkBox) return;
+ // A caret inside a web link gets the link bubble instead.
+ const s = sel();
+ const a = !drag && !menu && s.rangeCount && s.isCollapsed && linkAt(s.focusNode);
+ if (a) showBubble(a); else hideBubble();
+ // The right-click menu has its own format buttons; don't show both.
+ const c = !drag && !menu && !slash && !document.querySelector('.tmenu') && selBlock();
+ if (!c) { bar.hidden = true; return; }
+ const r = sel().getRangeAt(0);
+ const state = formatsAt(c);
+ for (const btn of bar.children) btn.classList.toggle('on', !!state[btn.dataset.f]);
+ bar.hidden = false;
+ const rect = r.getBoundingClientRect();
+ const w = bar.offsetWidth, h = bar.offsetHeight;
+ let y = rect.top - h - 8;
+ if (y < 8) y = rect.bottom + 8;
+ bar.style.left = Math.max(8, Math.min(rect.left + rect.width / 2 - w / 2, innerWidth - w - 8)) + 'px';
+ bar.style.top = y + 'px';
+ }
+ const onSelection = () => { if (!barFrame) barFrame = requestAnimationFrame(showBar); };
+
+ // Run a formatting change as its own undo step, apart from the typing around it.
+ function asStep(c, fn) {
+ last = { kind: null, id: null, at: 0 };
+ fn();
+ onInput(c);
+ last = { kind: null, id: null, at: 0 };
+ onSelection();
+ }
+ function format(f) {
+ const c = selBlock();
+ if (!c) return false;
+ if (f === 'link') return linkSelection(c);
+ if (f === 'weblink') { openLinkBox(c, sel().getRangeAt(0), linkAt(sel().getRangeAt(0).startContainer)); return true; }
+ asStep(c, () => {
+ if (f === 'bold') document.execCommand('bold');
+ else if (f === 'italic') document.execCommand('italic');
+ else if (f === 'strike') document.execCommand('strikeThrough');
+ else if (f === 'code') toggleCode(c);
+ else if (f === 'mark') toggleMark(c);
+ });
+ return true;
+ }
+ function selectNode(n) {
+ const r = document.createRange();
+ r.selectNodeContents(n);
+ const s = sel();
+ s.removeAllRanges();
+ s.addRange(r);
+ }
+ // ==highlight==: wrap the selection in , or take the highlight off if it's already in one.
+ function toggleMark(c) {
+ const r = sel().getRangeAt(0);
+ const m = inside(r.commonAncestorContainer, 'MARK', c) || inside(r.startContainer, 'MARK', c);
+ if (m) {
+ const first = m.firstChild, lastNode = m.lastChild;
+ unwrap(m);
+ if (first) {
+ const again = document.createRange();
+ again.setStartBefore(first);
+ again.setEndAfter(lastNode);
+ sel().removeAllRanges();
+ sel().addRange(again);
+ }
+ return;
+ }
+ const frag = r.extractContents();
+ frag.querySelectorAll('mark').forEach(unwrap);
+ const el = document.createElement('mark');
+ el.append(frag);
+ r.insertNode(el);
+ selectNode(el);
+ }
+ function toggleCode(c) {
+ const r = sel().getRangeAt(0);
+ const code = inside(r.commonAncestorContainer, 'CODE', c);
+ if (code) {
+ const t = document.createTextNode(code.textContent);
+ code.replaceWith(t);
+ selectNode(t);
+ return;
+ }
+ const text = r.toString();
+ if (!text) return;
+ r.deleteContents();
+ const el = document.createElement('code');
+ el.textContent = text;
+ r.insertNode(el);
+ selectNode(el);
+ }
+ // The selected words become a [[link]] to the page with that title (dimmed until it exists).
+ function linkSelection(c) {
+ const r = sel().getRangeAt(0);
+ const text = r.toString().replace(/\s+/g, ' ').trim();
+ if (!text || text.length > 120 || r.cloneContents().querySelector('.wl')) return false;
+ const start = document.createRange();
+ start.selectNodeContents(c);
+ start.setEnd(r.startContainer, r.startOffset);
+ const a = start.toString().length;
+ const z = a + r.toString().length;
+ const id = links.resolve(text);
+ last = { kind: null, id: null, at: 0 };
+ putChip(c, a, z, id ? links.refFor(id) : text, id && links.refFor(id) === text ? '' : text, false);
+ last = { kind: null, id: null, at: 0 };
+ bar.hidden = true;
+ return true;
+ }
+ const barDown = (e) => e.preventDefault(); // keep the selection
+ const barDownSafe = (e) => { if (!e.target.closest('input')) e.preventDefault(); };
+ const barClick = (e) => {
+ const btn = e.target.closest('button');
+ if (!btn) return;
+ if (btn.dataset.f === 'url-ok') return applyLinkBox(bar.querySelector('input')?.value || '');
+ format(btn.dataset.f);
+ };
+
+ /* block menu: click the ⋮⋮ grip */
+ const menuEl = document.createElement('div');
+ menuEl.className = 'pop menu bmenu';
+ menuEl.setAttribute('role', 'menu');
+ menuEl.hidden = true;
+ document.body.append(menuEl);
+ let menu = null; // { id }
+
+ function openMenu(b, anchor) {
+ closeSlash();
+ bar.hidden = true;
+ const kinds = FIXED.has(b.type) ? [] : TEXT_KINDS.map((t) => KINDS.find((k) => k.t === t));
+ menuEl.innerHTML =
+ (kinds.length
+ ? `Turn into
` +
+ kinds.map((k) => ``).join('') +
+ ''
+ : '') +
+ `` +
+ ``;
+ menu = { id: b.id };
+ elOf(b.id)?.classList.add('menu-open');
+ menuEl.hidden = false;
+ const w = menuEl.offsetWidth, h = menuEl.offsetHeight;
+ let y = anchor.bottom + 6;
+ if (y + h > innerHeight - 8) y = Math.max(8, anchor.top - h - 6);
+ menuEl.style.left = Math.max(8, Math.min(anchor.left, innerWidth - w - 8)) + 'px';
+ menuEl.style.top = y + 'px';
+ (menuEl.querySelector('.mi.cur') || menuEl.querySelector('.mi')).focus();
+ }
+ function closeMenu() {
+ if (!menu) return;
+ elOf(menu.id)?.classList.remove('menu-open');
+ menu = null;
+ menuEl.hidden = true;
+ }
+ function duplicate(b) {
+ const copy = { ...JSON.parse(JSON.stringify(b)), id: block('p').id };
+ insertAt(blocks.indexOf(b) + 1, copy);
+ if (!SOLID.has(copy.type)) focusBlock(copy.id, 'end');
+ }
+ function deleteBlock(b) {
+ const i = blocks.indexOf(b);
+ removeBlock(b.id);
+ const near = blocks.slice(0, i).reverse().find((x) => !SOLID.has(x.type)) || blocks.find((x) => !SOLID.has(x.type));
+ if (near) focusBlock(near.id, 'end');
+ }
+ function onMenuClick(e) {
+ const btn = e.target.closest('.mi');
+ const b = btn && menu && byId(menu.id);
+ if (!b) return;
+ const act = btn.dataset.act, t = btn.dataset.t, cell = menu.cell;
+ closeMenu();
+ if (cell) return tableAct(b, act, ...cell);
+ if (act === 'turn' && t !== b.type) {
+ if (t === 'code') b.text = b.text ?? htmlText(b.html);
+ convert(b, t);
+ } else if (act === 'turn') focusBlock(b.id, 'end');
+ if (act === 'dup') duplicate(b);
+ if (act === 'del') deleteBlock(b);
+ }
+ function onMenuKey(e) {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ const m = menu;
+ closeMenu();
+ if (m?.cell) focusCell(byId(m.id), ...m.cell);
+ else if (m) focusBlock(m.id, 'end');
+ return;
+ }
+ if (menu?.cell) return;
+ if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); const b = menu && byId(menu.id); closeMenu(); if (b) deleteBlock(b); return; }
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
+ e.preventDefault();
+ const items = [...menuEl.querySelectorAll('.mi')];
+ const i = items.indexOf(document.activeElement);
+ items[(i + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length].focus();
+ }
+ const onDocDown = (e) => {
+ if (menu && !menuEl.contains(e.target)) closeMenu();
+ if (linkBox && !bar.contains(e.target)) closeLinkBox(false);
+ };
+
+ /* tables
+ Each cell is its own small contenteditable (.tcell, data-r/data-c). Row 0 is the header.
+ Structural changes re-render the whole table and put the caret back in a cell. */
+ const cellEl = (b, r, c) => elOf(b.id)?.querySelector(`.tcell[data-r="${r}"][data-c="${c}"]`);
+ function focusCell(b, r, c, where = 'end') {
+ const el = cellEl(b, r, c);
+ if (el) (where === 'start' ? placeStart : placeEnd)(el);
+ }
+ function redrawTable(b, focus) {
+ delete b.src;
+ const old = elOf(b.id);
+ if (old) old.replaceWith(make(b));
+ changed();
+ if (focus) focusCell(b, ...focus);
+ }
+ function addRow(b, at, focusCol = 0) {
+ b.rows.splice(at, 0, b.rows[0].map(() => ''));
+ redrawTable(b, [at, focusCol, 'start']);
+ }
+ function addCol(b, at, focusRow = 0) {
+ for (const row of b.rows) row.splice(at, 0, '');
+ b.align = b.align || [];
+ b.align.splice(at, 0, null);
+ redrawTable(b, [focusRow, at, 'start']);
+ }
+ function delRow(b, r) {
+ if (b.rows.length <= 1) return deleteBlock(b);
+ b.rows.splice(r, 1);
+ redrawTable(b, [Math.min(r, b.rows.length - 1), 0]);
+ }
+ function delCol(b, c) {
+ if (b.rows[0].length <= 1) return deleteBlock(b);
+ for (const row of b.rows) row.splice(c, 1);
+ b.align?.splice(c, 1);
+ redrawTable(b, [0, Math.min(c, b.rows[0].length - 1)]);
+ }
+ function setAlign(b, c, a) {
+ b.align = b.align || [];
+ b.align[c] = a === 'left' && !b.align[c] ? null : a;
+ redrawTable(b, [0, c]);
+ }
+ // Tab / Enter / arrows move between cells; Tab past the last cell or Enter on the last row adds a row.
+ function onCellKey(e, cell, b) {
+ const r = +cell.dataset.r, c = +cell.dataset.c;
+ const rows = b.rows.length, cols = b.rows[0].length;
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ if (e.shiftKey) {
+ if (c > 0) focusCell(b, r, c - 1);
+ else if (r > 0) focusCell(b, r - 1, cols - 1);
+ } else if (c < cols - 1) focusCell(b, r, c + 1);
+ else if (r < rows - 1) focusCell(b, r + 1, 0);
+ else addRow(b, rows);
+ return;
+ }
+ if (e.key === 'Enter' && !e.isComposing) {
+ e.preventDefault(); // a Markdown cell holds one line
+ if (e.shiftKey) return;
+ if (r < rows - 1) focusCell(b, r + 1, c);
+ else addRow(b, rows, c);
+ return;
+ }
+ if (e.key === 'ArrowUp' && atStart(cell)) { e.preventDefault(); return r > 0 ? focusCell(b, r - 1, c) : move(b, -1, 'end'); }
+ if (e.key === 'ArrowDown' && atEnd(cell)) { e.preventDefault(); return r < rows - 1 ? focusCell(b, r + 1, c, 'start') : move(b, 1, 'start'); }
+ if (e.key === 'ArrowLeft' && atStart(cell) && (c > 0 || r > 0)) { e.preventDefault(); return c > 0 ? focusCell(b, r, c - 1) : focusCell(b, r - 1, cols - 1); }
+ if (e.key === 'ArrowRight' && atEnd(cell) && (c < cols - 1 || r < rows - 1)) { e.preventDefault(); return c < cols - 1 ? focusCell(b, r, c + 1, 'start') : focusCell(b, r + 1, 0, 'start'); }
+ if (e.key === 'Escape') cell.blur();
+ }
+ function openTableMenu(b, r, c, x, y) {
+ closeSlash();
+ bar.hidden = true;
+ const a = (b.align && b.align[c]) || 'left';
+ const item = (act, icon, label, extra = '') => ``;
+ const tick = (v) => (a === v ? ic('tick', 'mi-on') : '');
+ menuEl.innerHTML =
+ item('row-above', ic('plus'), 'Insert row above') + item('row-below', ic('plus'), 'Insert row below') +
+ item('col-left', ic('plus'), 'Insert column left') + item('col-right', ic('plus'), 'Insert column right') +
+ 'Align column
' +
+ item('align-left', ic('alignLeft'), 'Left', tick('left')) + item('align-center', ic('alignCenter'), 'Center', tick('center')) +
+ item('align-right', ic('alignRight'), 'Right', tick('right')) +
+ '' +
+ item('del-row', ic('minus'), r === 0 ? 'Delete header row' : 'Delete row') + item('del-col', ic('minus'), 'Delete column') +
+ ``;
+ menu = { id: b.id, cell: [r, c] };
+ menuEl.hidden = false;
+ const w = menuEl.offsetWidth, h = menuEl.offsetHeight;
+ menuEl.style.left = Math.max(8, Math.min(x, innerWidth - w - 8)) + 'px';
+ menuEl.style.top = (y + h > innerHeight - 8 ? Math.max(8, y - h) : y) + 'px';
+ menuEl.querySelector('.mi').focus();
+ }
+ function tableAct(b, act, r, c) {
+ if (act === 'row-above') addRow(b, Math.max(1, r), c); // the header stays the first row
+ if (act === 'row-below') addRow(b, r + 1, c);
+ if (act === 'col-left') addCol(b, c, r);
+ if (act === 'col-right') addCol(b, c + 1, r);
+ if (act.startsWith('align-')) setAlign(b, c, act.slice(6));
+ if (act === 'del-row') delRow(b, r);
+ if (act === 'del-col') delCol(b, c);
+ if (act === 'del-table') deleteBlock(b);
+ }
+ function onContextMenu(e) {
+ // The app's right-click menu is about to open: get the toolbar, link bubble and footnote preview out of its way.
+ bar.hidden = true;
+ hideBubble();
+ hideTip();
+ const cell = e.target.closest('.tcell');
+ const b = cell && byId(cell.closest('.block').dataset.id);
+ if (!b || e.target.closest('a.xl, .wl')) return;
+ e.preventDefault();
+ openTableMenu(b, +cell.dataset.r, +cell.dataset.c, e.clientX, e.clientY);
+ }
+
+ /* web links: [text](url) and bare URLs, as */
+ const openUrl = (url) => { if (isWebUrl(url)) window.open(url, '_blank', 'noopener'); };
+ // What was typed into the link box, as a URL we can open, or null.
+ function normalizeUrl(t) {
+ const s = String(t || '').trim();
+ if (!s || /\s/.test(s)) return null;
+ if (isWebUrl(s)) return s;
+ if (/^[\w.+-]+@[\w-]+\.[\w.-]+$/.test(s)) return 'mailto:' + s;
+ if (/^(www\.)?[\w-]+(\.[\w-]+)+(:\d+)?([/?#].*)?$/.test(s)) return 'https://' + s;
+ return null;
+ }
+ const linkAt = (node) => {
+ const el = node && (node.nodeType === 1 ? node : node.parentElement);
+ const a = el && el.closest('a.xl');
+ return a && host.contains(a) && !a.closest('.emb') ? a : null;
+ };
+ function unwrap(a) {
+ const parent = a.parentNode;
+ while (a.firstChild) parent.insertBefore(a.firstChild, a);
+ a.remove();
+ return parent;
+ }
+ // Make the selection in field `c` a link to `url` (any links already inside it are replaced).
+ function wrapLink(c, range, url) {
+ const frag = range.extractContents();
+ frag.querySelectorAll?.('a.xl').forEach(unwrap);
+ const holder = document.createElement('span');
+ holder.innerHTML = webLink(url, '');
+ const a = holder.firstChild;
+ a.append(frag);
+ if (a.textContent === url) a.dataset.auto = '1'; // a bare URL stays bare in the file
+ range.insertNode(a);
+ const after = document.createRange();
+ after.setStartAfter(a);
+ after.collapse(true);
+ const s = sel();
+ s.removeAllRanges();
+ s.addRange(after);
+ return a;
+ }
+
+ // The link box: the toolbar turns into a URL field for the selection (or for the link under the caret).
+ let linkBox = null; // { c, range, a }
+ function openLinkBox(c, range, a = null) {
+ linkBox = { c, range: range.cloneRange(), a };
+ bubble.hidden = true;
+ bar.classList.add('url');
+ bar.innerHTML = `${ic('link')}`;
+ bar.hidden = false;
+ const input = bar.querySelector('input');
+ input.focus();
+ input.select();
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') { e.preventDefault(); applyLinkBox(input.value); }
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeLinkBox(true); }
+ });
+ input.addEventListener('input', () => input.classList.remove('bad'));
+ }
+ function closeLinkBox(refocus) {
+ if (!linkBox) return;
+ const { c, range } = linkBox;
+ linkBox = null;
+ bar.classList.remove('url');
+ bar.innerHTML = barButtons;
+ bar.hidden = true;
+ if (refocus && c.isConnected) {
+ c.focus();
+ const s = sel();
+ s.removeAllRanges();
+ s.addRange(range);
+ }
+ }
+ function applyLinkBox(value) {
+ const { c, range, a } = linkBox;
+ const url = normalizeUrl(value);
+ if (value.trim() && !url) { bar.querySelector('input')?.classList.add('bad'); return; }
+ closeLinkBox(true);
+ if (!c.isConnected) return;
+ last = { kind: null, id: null, at: 0 };
+ if (a && a.isConnected) {
+ if (!url) unwrap(a);
+ else {
+ const wasBare = a.dataset.auto && a.textContent === a.getAttribute('href');
+ a.setAttribute('href', url);
+ if (wasBare) a.textContent = url;
+ }
+ } else if (url) {
+ if (range.collapsed) {
+ const holder = document.createElement('span');
+ holder.innerHTML = webLink(url, esc(url));
+ range.insertNode(holder.firstChild);
+ } else wrapLink(c, range, url);
+ }
+ sync(c);
+ changed();
+ last = { kind: null, id: null, at: 0 };
+ }
+
+ // The bubble under a link while the caret is in it: address, Open, Edit, Copy, Remove.
+ const bubble = document.createElement('div');
+ bubble.className = 'pop lbub';
+ bubble.hidden = true;
+ document.body.append(bubble);
+ let bubbleFor = null;
+ function showBubble(a) {
+ if (bubbleFor !== a) {
+ const href = a.getAttribute('href');
+ const shown = href.replace(/^mailto:/, '').replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '');
+ bubble.innerHTML = `${ic(href.startsWith('mailto:') ? 'mail' : 'globe')}${esc(shown)}` +
+ `` +
+ ``;
+ bubbleFor = a;
+ }
+ bubble.hidden = false;
+ const r = a.getBoundingClientRect();
+ const w = bubble.offsetWidth, h = bubble.offsetHeight;
+ let y = r.bottom + 6;
+ if (y + h > innerHeight - 8) y = r.top - h - 6;
+ bubble.style.left = Math.max(8, Math.min(r.left, innerWidth - w - 8)) + 'px';
+ bubble.style.top = y + 'px';
+ }
+ function hideBubble() { bubble.hidden = true; bubbleFor = null; }
+ function onBubbleClick(e) {
+ const btn = e.target.closest('button');
+ const a = bubbleFor;
+ if (!btn || !a || !a.isConnected) return;
+ const href = a.getAttribute('href');
+ const c = a.closest('.c,.c2,.tcell');
+ if (btn.dataset.b === 'open') openUrl(href);
+ if (btn.dataset.b === 'copy') {
+ navigator.clipboard.writeText(href).then(() => { btn.innerHTML = ic('tick'); setTimeout(() => bubbleFor === a && (btn.innerHTML = ic('copy')), 1200); }, () => {});
+ }
+ if (btn.dataset.b === 'edit') {
+ const r = document.createRange();
+ r.selectNodeContents(a);
+ openLinkBox(c, r, a);
+ }
+ if (btn.dataset.b === 'unlink') {
+ last = { kind: null, id: null, at: 0 };
+ unwrap(a);
+ hideBubble();
+ sync(c);
+ changed();
+ }
+ }
+
+ // A URL typed and followed by a space becomes a link.
+ function autoLink(c, e) {
+ if (!e || e.inputType !== 'insertText' || !/^[\s ]$/.test(e.data || '')) return false;
+ const before = textBefore(c);
+ const m = before.match(/(?:^|[\s (])((?:https?:\/\/|www\.)[^\s ]+?)([.,;:!?)]*)[\s ]$/);
+ if (!m) return false;
+ const url = normalizeUrl(m[1]);
+ if (!url) return false;
+ const end = before.length - 1 - m[2].length;
+ const r = rangeAt(c, end - m[1].length, end);
+ if (!r || linkAt(r.startContainer)) return false;
+ const caret = before.length;
+ wrapLink(c, r, url);
+ setCaret(c, caret);
+ return true;
+ }
+
+ /* images */
+ // Show image `src` after block `b`, or in its place when `b` is an empty paragraph.
+ function addImage(b, src) {
+ if (!blocks.includes(b)) return;
+ if (b.type === 'p' && !htmlText(b.html).trim()) {
+ b.type = 'image'; b.src = src; b.alt = ''; b.html = '';
+ const el = make(b);
+ el.classList.add('morph');
+ elOf(b.id).replaceWith(el);
+ changed();
+ return b;
+ }
+ const nb = block('image', '', { src, alt: '' });
+ insertAt(blocks.indexOf(b) + 1, nb);
+ return nb;
+ }
+ // Save pasted or dropped image files and show them: { into: block } fills/follows that block, { at: index } inserts there.
+ async function addFiles(files, where) {
+ let prev = where.into || null;
+ let at = where.at;
+ for (const f of files) {
+ const name = f.name && f.name !== 'image.png' ? f.name : `pasted-${stamp()}.${imageExt(f.type)}`;
+ let src;
+ try { src = await images.save(f, name); } catch { src = null; }
+ if (!src) continue;
+ if (prev && blocks.includes(prev)) prev = addImage(prev, src);
+ else {
+ prev = block('image', '', { src, alt: '' });
+ insertAt(Math.max(0, Math.min(at ?? blocks.length, blocks.length)), prev);
+ }
+ }
+ }
+ const imageFiles = (dt) => [...((dt && dt.files) || [])].filter((f) => f.type.startsWith('image/'));
+
+ /* input */
+ function onInput(c, e) {
+ const el = c.closest('.block');
+ const b = el && byId(el.dataset.id);
+ if (!b) return;
+ skipDoubleSpace(e);
+ if (c.classList.contains('tcell')) {
+ autoLink(c, e);
+ sync(c);
+ return linkTyping(c, b) || changed('type', b.id);
+ }
+ if (c.classList.contains('code-c')) {
+ if (e && e.isComposing) return;
+ b.text = c.textContent;
+ const o = caretOffset(c);
+ c.innerHTML = highlight(b.text);
+ setCaret(c, o);
+ return changed('type', b.id);
+ }
+ if (c.innerHTML === '
') c.innerHTML = '';
+ if (!c.classList.contains('code-c')) autoLink(c, e);
+ if (c.classList.contains('c2')) { b.body = c.innerHTML; return changed('type', b.id); }
+ b.html = c.innerHTML;
+ if (b.type === 'p') {
+ // Shortcuts only look at what was typed at the very start of the line, up to the first chip: a footnote
+ // reference's number or a link's label must never read as "1. " or "# ".
+ const t = c.textContent.replace(/\u00a0/g, ' ');
+ let lead = '';
+ for (const n of c.childNodes) { if (n.nodeType !== 3) break; lead += n.data; }
+ lead = lead.replace(/\u00a0/g, ' ');
+ // Remove the shortcut's characters but keep the rest of the line as it is (links, formatting).
+ const dropLead = (len) => { const r = rangeAt(c, 0, len); if (r) r.deleteContents(); if (c.innerHTML === '
') c.innerHTML = ''; return c.innerHTML; };
+ if (t === '---' && lead === t) { b.html = ''; return addDivider(b, true); }
+ const fd = lead.match(/^\[\^([^\]\s^]+)\]: /);
+ if (fd) { b.fid = fd[1]; b.html = dropLead(fd[0].length); return convert(b, 'footnote', 'start'); }
+ const num = lead.match(/^\d{1,9}[.)] /);
+ if (num) { b.html = dropLead(num[0].length); return convert(b, 'number', 'start'); }
+ for (const [k, ty] of SHORTCUTS) {
+ if (lead.startsWith(k)) {
+ const rest = t.slice(k.length);
+ b.html = dropLead(k.length);
+ if (ty === 'code') b.text = rest;
+ if (ty === 'todo') b.checked = false;
+ return convert(b, ty, 'start');
+ }
+ }
+ }
+ if (linkTyping(c, b)) return;
+ const m = textBefore(c).match(/(?:^|\s)\/([\w-]{0,14})$/);
+ m ? openSlash(c, b, m[1]) : closeSlash();
+ changed('type', b.id);
+ }
+ // [[ opens the page picker; a whole [[link]] typed by hand becomes a chip. True when it handled the input.
+ function linkTyping(c, b) {
+ const before = textBefore(c);
+ // [^id] becomes a footnote reference, except opening a line, where it may be starting a footnote text ("[^1]: ")
+ const fr = before.match(/\[\^([^\]\s^]+)\]$/);
+ if (fr && !(b.type === 'p' && c.classList.contains('c') && before === fr[0])) {
+ closeSlash();
+ changed('type', b.id);
+ putInline(c, before.length - fr[0].length, before.length, footnoteRef(fr[1]));
+ return true;
+ }
+ const fi = before.match(/\^\[([^\]\n]+)\]$/);
+ if (fi) {
+ closeSlash();
+ changed('type', b.id);
+ putInline(c, before.length - fi[0].length, before.length, inlineNote(fi[1]));
+ return true;
+ }
+ const typed = before.match(/(? x.dataset.fn === b.fid && !x.closest('.emb'));
+ const target = ref && ref.closest('.block');
+ if (target && scroller) scrollToBlock(scroller, target);
+ } else if (t.classList.contains('emb-title')) {
+ links.open(b.ref);
+ } else if (t.classList.contains('emb-more')) {
+ const box = t.closest('.emb');
+ box.classList.toggle('open');
+ t.textContent = box.classList.contains('open') ? 'Show less' : 'Show more';
+ } else if (t.classList.contains('tbl-add-row')) {
+ addRow(b, b.rows.length);
+ } else if (t.classList.contains('tbl-add-col')) {
+ addCol(b, b.rows[0].length);
+ } else if (t.classList.contains('img-x')) {
+ removeBlock(b.id);
+ } else if (t.classList.contains('g-add')) {
+ const nb = block('p');
+ insertAfter(b.id, nb, 'start');
+ const c = elOf(nb.id).querySelector('.c');
+ insertText('/');
+ onInput(c);
+ } else if (t.classList.contains('check')) {
+ b.checked = !b.checked;
+ t.classList.toggle('on', b.checked);
+ t.setAttribute('aria-checked', b.checked);
+ el.classList.toggle('done', b.checked);
+ t.classList.remove('bump'); void t.offsetWidth; t.classList.add('bump');
+ changed();
+ } else if (t.classList.contains('tg-chev')) {
+ b.open = !b.open;
+ el.querySelector('.tg').classList.toggle('open', b.open);
+ } else if (t.classList.contains('lang')) {
+ b.lang = LANGS[(LANGS.indexOf(b.lang) + 1) % LANGS.length];
+ t.textContent = b.lang;
+ changed();
+ } else if (t.classList.contains('copy')) {
+ onCopy(b.text || '');
+ }
+ }
+
+ function onFocusOut() {
+ setTimeout(() => { if (slash && !slash.c.contains(document.activeElement)) closeSlash(); }, 0);
+ }
+
+ function onTail() {
+ // Footnote texts stay at the bottom: a new line goes above them.
+ let at = blocks.length;
+ while (at > 0 && blocks[at - 1].type === 'footnote') at--;
+ if (at < blocks.length) {
+ const before = blocks[at - 1];
+ if (before && before.type === 'p' && !htmlText(before.html).trim()) return focusBlock(before.id, 'start');
+ const nb = block('p');
+ insertAt(at, nb);
+ return focusBlock(nb.id, 'start');
+ }
+ const last = blocks[blocks.length - 1];
+ if (last && last.type === 'p' && !htmlText(last.html).trim()) return focusBlock(last.id, 'start');
+ const nb = block('p');
+ blocks.push(nb);
+ const el = make(nb);
+ el.classList.add('enter-b');
+ host.append(el);
+ focusBlock(nb.id, 'start');
+ changed();
+ }
+
+ /* slash menu mouse */
+ const slashDown = (e) => e.preventDefault();
+ const slashClick = (e) => { const s = e.target.closest('.si'); if (s && slash) applySlash(+s.dataset.i); };
+ // Hovering picks a row, but only when the mouse really moved: when the menu opens or redraws under a still
+ // pointer, the browser can report a "move" at the same spot, which would silently change what Enter picks.
+ let pointer = '', prevPointer = '';
+ const trackPointer = (e) => { prevPointer = pointer; pointer = e.clientX + ',' + e.clientY; };
+ const slashMove = (e) => {
+ if (pointer === prevPointer) return;
+ const s = e.target.closest('.si');
+ if (s && slash && +s.dataset.i !== slash.idx) {
+ slash.idx = +s.dataset.i;
+ slashEl.querySelectorAll('.si').forEach((x, i) => x.classList.toggle('on', i === slash.idx));
+ }
+ };
+
+ /* drag to reorder
+ Pointer events rather than native HTML drag and drop: native drops only land when released over the
+ editor itself, which the grip column and heading margins are not, and Linux/Wayland drags are flaky. */
+ let drag = null; // { id, el, ghost, startY, offsetY, y, raf }
+ function target(y) {
+ const els = [...host.querySelectorAll('.block:not(.leaving)')].filter((x) => x.dataset.id !== dragId);
+ const base = host.getBoundingClientRect().top;
+ for (const x of els) {
+ const r = x.getBoundingClientRect();
+ if (y < r.top + r.height / 2) return { before: x.dataset.id, top: r.top - base - 2 };
+ }
+ const l = els[els.length - 1];
+ return { before: null, top: l ? l.getBoundingClientRect().bottom - base : 0 };
+ }
+ const onPointerDown = (e) => {
+ const g = e.button === 0 && e.target.closest('.g-drag');
+ if (!g || drag) return;
+ e.preventDefault(); // no focus change or text selection
+ const el = g.closest('.block');
+ drag = { id: el.dataset.id, el, ghost: null, startY: e.clientY, offsetY: e.clientY - el.getBoundingClientRect().top, y: e.clientY, raf: 0 };
+ document.addEventListener('pointermove', onDragMove);
+ document.addEventListener('pointerup', onDragUp);
+ document.addEventListener('pointercancel', onDragCancel);
+ document.addEventListener('keydown', onDragKey, true);
+ };
+ function startDrag() {
+ const { el } = drag;
+ const r = el.getBoundingClientRect();
+ const ghost = el.cloneNode(true);
+ ghost.classList.remove('stagger', 'enter-b', 'morph');
+ ghost.classList.add('drag-ghost');
+ ghost.removeAttribute('data-id');
+ ghost.querySelectorAll('[contenteditable]').forEach((x) => x.removeAttribute('contenteditable'));
+ ghost.style.cssText = `left:${r.left}px;top:${r.top}px;width:${r.width}px`;
+ document.body.append(ghost);
+ drag.ghost = ghost;
+ dragId = drag.id;
+ el.classList.add('dragging');
+ document.body.classList.add('block-dragging');
+ window.getSelection()?.removeAllRanges();
+ closeSlash();
+ }
+ function dragFrame() {
+ drag.raf = 0;
+ const { y } = drag;
+ drag.ghost.style.top = y - drag.offsetY + 'px';
+ drop.style.top = target(y).top + 'px';
+ drop.classList.add('show');
+ // Scroll the page when the pointer nears its top or bottom edge.
+ if (scroller) {
+ const r = scroller.getBoundingClientRect();
+ const edge = 56;
+ const v = y < r.top + edge ? -(r.top + edge - y) : y > r.bottom - edge ? y - (r.bottom - edge) : 0;
+ if (v) {
+ scroller.scrollTop += Math.max(-24, Math.min(24, v / 2));
+ drag.raf = requestAnimationFrame(dragFrame);
+ }
+ }
+ }
+ function onDragMove(e) {
+ drag.y = e.clientY;
+ if (!dragId) {
+ if (Math.abs(e.clientY - drag.startY) < 4) return;
+ startDrag();
+ }
+ if (!drag.raf) drag.raf = requestAnimationFrame(dragFrame);
+ }
+ function onDragUp(e) {
+ const moved = !!dragId;
+ const id = drag.id, grip = drag.el.querySelector('.g-drag');
+ if (moved) moveTo(target(e.clientY));
+ endDrag();
+ // A click without dragging opens the block menu.
+ if (!moved && byId(id) && grip) openMenu(byId(id), grip.getBoundingClientRect());
+ }
+ const onDragCancel = () => endDrag();
+ const onDragKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); endDrag(); } };
+ function endDrag() {
+ if (!drag) return;
+ cancelAnimationFrame(drag.raf);
+ drag.ghost?.remove();
+ drag.el.classList.remove('dragging');
+ document.body.classList.remove('block-dragging');
+ document.removeEventListener('pointermove', onDragMove);
+ document.removeEventListener('pointerup', onDragUp);
+ document.removeEventListener('pointercancel', onDragCancel);
+ document.removeEventListener('keydown', onDragKey, true);
+ drop.classList.remove('show');
+ dragId = null;
+ drag = null;
+ }
+ function moveTo(t) {
+ if (t.before === dragId) return;
+ const els = [...host.querySelectorAll('.block')];
+ const first = new Map(els.map((x) => [x, x.getBoundingClientRect().top]));
+ const i = blocks.findIndex((b) => b.id === dragId);
+ const [b] = blocks.splice(i, 1);
+ const j = t.before ? blocks.findIndex((x) => x.id === t.before) : blocks.length;
+ blocks.splice(j, 0, b);
+ if (j === i) return; // dropped where it already was
+ const el = elOf(dragId);
+ t.before ? elOf(t.before).before(el) : host.append(el);
+ if (!reduce) {
+ for (const x of els) {
+ const dy = first.get(x) - x.getBoundingClientRect().top;
+ if (dy) x.animate([{ transform: `translateY(${dy}px)` }, { transform: 'none' }], { duration: 320, easing: 'cubic-bezier(.2,.8,.2,1)' });
+ }
+ }
+ changed();
+ }
+
+ /* image files dropped in from the file manager (native drag and drop is fine for this) */
+ const hasFiles = (e) => !dragId && [...(e.dataTransfer?.types || [])].includes('Files');
+ const onFileOver = (e) => {
+ if (!hasFiles(e)) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+ drop.style.top = target(e.clientY).top + 'px';
+ drop.classList.add('show');
+ };
+ const onFileLeave = (e) => { if (!host.contains(e.relatedTarget)) drop.classList.remove('show'); };
+ const onFileDrop = (e) => {
+ if (!hasFiles(e)) return;
+ e.preventDefault();
+ drop.classList.remove('show');
+ const files = imageFiles(e.dataTransfer);
+ if (!files.length) return;
+ const t = target(e.clientY);
+ addFiles(files, { at: t.before ? blocks.findIndex((x) => x.id === t.before) : blocks.length });
+ };
+
+ host.addEventListener('input', (e) => { const c = e.target.closest('.c,.c2,.tcell'); if (c) onInput(c, e); });
+ host.addEventListener('beforeinput', onBeforeInput);
+ host.addEventListener('dragover', onFileOver);
+ host.addEventListener('dragleave', onFileLeave);
+ host.addEventListener('drop', onFileDrop);
+ host.addEventListener('error', (e) => { if (e.target.tagName === 'IMG') e.target.closest('.img')?.classList.add('broken'); }, true);
+ document.addEventListener('keydown', onHistoryKey);
+ host.addEventListener('keydown', onKeydown);
+host.addEventListener('copy', (e) => {
+ const s = window.getSelection();
+ if (s.isCollapsed) return;
+
+ // If the selection spans multiple blocks, let's assemble markdown
+ // If it's within a single block, let the browser handle it, or we could also intercept.
+ // For now, if it spans multiple blocks:
+ const range = s.getRangeAt(0);
+ const startBlock = range.startContainer.closest ? range.startContainer.closest('.block') : range.startContainer.parentElement?.closest('.block');
+ const endBlock = range.endContainer.closest ? range.endContainer.closest('.block') : range.endContainer.parentElement?.closest('.block');
+
+ if (startBlock && endBlock && startBlock !== endBlock) {
+ // Find all blocks in selection
+ let selecting = false;
+ const selectedBlocks = [];
+ for (const b of blocks) {
+ if (b.id === startBlock.dataset.id) selecting = true;
+ if (selecting) selectedBlocks.push(b);
+ if (b.id === endBlock.dataset.id) break;
+ }
+
+ // Rough toMarkdown for the selected blocks.
+
+ const md = toMarkdown(selectedBlocks);
+ e.clipboardData.setData('text/plain', md);
+
+ e.preventDefault();
+ }
+ });
+ host.addEventListener('paste', onPaste);
+ host.addEventListener('click', onClick);
+ host.addEventListener('focusout', onFocusOut);
+ host.addEventListener('pointerdown', onPointerDown);
+ tail.addEventListener('click', onTail);
+ slashEl.addEventListener('mousedown', slashDown);
+ slashEl.addEventListener('click', slashClick);
+ slashEl.addEventListener('mousemove', slashMove);
+ document.addEventListener('mousemove', trackPointer, true);
+ scroller?.addEventListener('scroll', closeSlash, { passive: true });
+ scroller?.addEventListener('scroll', closeMenu, { passive: true });
+ scroller?.addEventListener('scroll', onSelection, { passive: true });
+ document.addEventListener('selectionchange', onSelection);
+ document.addEventListener('pointerdown', onDocDown, true);
+ bar.addEventListener('mousedown', barDownSafe);
+ bubble.addEventListener('mousedown', barDown);
+ bubble.addEventListener('click', onBubbleClick);
+ host.addEventListener('contextmenu', onContextMenu);
+ host.addEventListener('mouseover', onOver);
+ host.addEventListener('mouseout', onOut);
+ host.addEventListener('keydown', hideTip, true);
+ scroller?.addEventListener('scroll', hideTip, { passive: true });
+ repaintFootnotes();
+ scroller?.addEventListener('scroll', hideBubble, { passive: true });
+ bar.addEventListener('click', barClick);
+ menuEl.addEventListener('click', onMenuClick);
+ menuEl.addEventListener('keydown', onMenuKey);
+ window.addEventListener('resize', closeSlash);
+
+ return {
+ get blocks() { return blocks; },
+ focusFirst() {
+ let b = blocks.find((x) => !SOLID.has(x.type));
+ if (!b) { b = block('p'); blocks.unshift(b); host.insertBefore(make(b), drop.nextSibling); }
+ focusBlock(b.id, 'start');
+ },
+ // Page list changed: re-check which links point nowhere.
+ refreshLinks() { paintLinks(host); },
+ // For the right-click menu: formats of the current selection (null when formatting doesn't apply), and actions.
+ formatState() { const c = selBlock(); return c ? formatsAt(c) : null; },
+ format(f) { return format(f); },
+ linkAction(a, kind) {
+ if (!a || !host.contains(a)) return;
+ const c = a.closest('.c,.c2,.tcell');
+ if (!c) return;
+ if (kind === 'edit') {
+ const r = document.createRange();
+ r.selectNodeContents(a);
+ openLinkBox(c, r, a);
+ } else if (kind === 'unlink') {
+ last = { kind: null, id: null, at: 0 };
+ unwrap(a);
+ hideBubble();
+ sync(c);
+ changed();
+ }
+ },
+ undo,
+ redo,
+ destroy() {
+ closeSlash();
+ slashEl.remove();
+ document.removeEventListener('mousemove', trackPointer, true);
+ closeMenu();
+ menuEl.remove();
+ bar.remove();
+ tip.remove();
+ scroller?.removeEventListener('scroll', hideTip);
+ cancelAnimationFrame(fnFrame);
+ bubble.remove();
+ scroller?.removeEventListener('scroll', hideBubble);
+ cancelAnimationFrame(barFrame);
+ document.removeEventListener('selectionchange', onSelection);
+ document.removeEventListener('pointerdown', onDocDown, true);
+ scroller?.removeEventListener('scroll', closeMenu);
+ scroller?.removeEventListener('scroll', onSelection);
+ tail.remove();
+ endDrag();
+ document.removeEventListener('keydown', onHistoryKey);
+ scroller?.removeEventListener('scroll', closeSlash);
+ window.removeEventListener('resize', closeSlash);
+ host.innerHTML = '';
+ }
+ };
+}