From 1416f2e48323a7a31de7022c1645937d918f85f5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 9 Oct 2026 22:54:59 +0200 Subject: [PATCH] feat: Add Math and Mermaid blocks to slash menu --- src/lib/editor.js | 1904 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1904 insertions(+) create mode 100644 src/lib/editor.js 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 `
${esc(b.alt || '')}${ic('image')}Image not found: ${esc(b.src)}
`; + case 'table': return tableHtml(b); + case 'footnote': return `${c}`; + case 'embed': return `

Loading…

`; + case 'toggle': return `
${c}
${b.body || ''}
`; + 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 || ''}
`; + }).join('')}`).join(''); + return `
${rows}
` + + `` + + `
`; + } + 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 = ''; + } + }; +}