// The article's TEXT, as the notes see it, and the s that show them. // // A block (`[data-block]`: title, subtitle, summary, method, or a section) has // one plain text: its text nodes in document order, MINUS anything inside // `[data-anchor-skip]` -- a citation's superscript number, a "+ note" button -- // so a quote never carries a "3" from a cite marker and re-anchors the same // way `umtool notes` reads the section from report.json. lib/annotations/ // anchor.mjs locates a quote in that text; these turn offsets into DOM and back. // // The marks are DOM the app adds AFTER React has rendered the (memoised, // never re-rendered) article body, and removes before adding them again. export const SKIP = "[data-anchor-skip]"; export type TextModel = { text: string; nodes: { node: Text; start: number }[] }; export function blockText(root: Element): TextModel { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: (n) => { const skip = n.parentElement?.closest(SKIP); return skip && root.contains(skip) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; }, }); const nodes: TextModel["nodes"] = []; let text = ""; for (let n = walker.nextNode(); n; n = walker.nextNode()) { const t = n as Text; nodes.push({ node: t, start: text.length }); text += t.data; } return { text, nodes }; } /** The text offset of a DOM point inside `root`. */ export function offsetOf(root: Element, container: Node, offset: number): number { const { nodes, text } = blockText(root); const point = document.createRange(); point.setStart(container, offset); for (const { node, start } of nodes) { if (node === container) return start + Math.min(offset, node.data.length); const r = document.createRange(); r.selectNodeContents(node); if (r.compareBoundaryPoints(Range.START_TO_START, point) >= 0) return start; } return text.length; } export function unwrapMarks(root: Element) { const parents = new Set(); for (const m of Array.from(root.querySelectorAll("mark[data-note]"))) { const p = m.parentNode; if (!p) continue; while (m.firstChild) p.insertBefore(m.firstChild, m); p.removeChild(m); parents.add(p); } for (const p of parents) p.normalize(); } /** Wrap [start, end) of a block's text in marks carrying `attrs`. Returns the marks. */ export function wrapRange(root: Element, start: number, end: number, attrs: Record, className: string): HTMLElement[] { const { nodes } = blockText(root); const out: HTMLElement[] = []; for (const { node, start: ns } of nodes) { const ne = ns + node.data.length; if (ne <= start || ns >= end) continue; const a = Math.max(start, ns) - ns; const b = Math.min(end, ne) - ns; if (b <= a) continue; let t: Text = node; if (a > 0) t = t.splitText(a); if (b - a < t.data.length) t.splitText(b - a); // Whitespace between two block elements is not worth a mark. if (!t.data.trim()) continue; const mark = document.createElement("mark"); for (const [k, v] of Object.entries(attrs)) mark.setAttribute(k, v); mark.className = className; t.parentNode!.insertBefore(mark, t); mark.appendChild(t); out.push(mark); } return out; } /** * The block a selection lies in, and its offsets, or null (collapsed, or in no * block of `container`). * * A selection that runs ACROSS blocks -- the end of one section into the next, * which is what a drag past a paragraph does -- is noted in ONE of them: a text * anchor names one section (lib/annotations/anchor.mjs), and that is where * `umtool notes` finds it again. The block it starts in, from the start to the * block's end, when that part has any text; else the block it ends in, from its * beginning. It used to get no Note button at all. */ export function selectionIn(container: Element): { block: Element; start: number; end: number; rect: DOMRect } | null { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null; const range = sel.getRangeAt(0); const el = (n: Node) => (n.nodeType === Node.ELEMENT_NODE ? (n as Element) : n.parentElement); const within = (e: Element | null | undefined) => (e && container.contains(e) ? e : null); const a = within(el(range.startContainer)?.closest("[data-block]")); const b = within(el(range.endContainer)?.closest("[data-block]")); const rect = range.getBoundingClientRect(); if (a && a === b) { const start = offsetOf(a, range.startContainer, range.startOffset); const end = offsetOf(a, range.endContainer, range.endOffset); return end > start ? { block: a, start, end, rect } : null; } if (a) { const start = offsetOf(a, range.startContainer, range.startOffset); const text = blockText(a).text; if (text.slice(start).trim()) return { block: a, start, end: text.length, rect }; } if (b) { const end = offsetOf(b, range.endContainer, range.endOffset); if (blockText(b).text.slice(0, end).trim()) return { block: b, start: 0, end, rect }; } return null; }