/* annotations.js — localStorage-based personal highlights and annotations. Persists across sessions via localStorage. Re-anchors on page load via exact text match using a TreeWalker text-stream search. Public API (window.Annotations): .add(text, color, note) → ann object .remove(id) */ (function () { 'use strict'; var STORAGE_KEY = 'site-annotations'; var tooltip = null; var tooltipTimer = null; /* ------------------------------------------------------------------ Storage ------------------------------------------------------------------ */ function loadAll() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; } catch (e) { return []; } } function saveAll(list) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); } catch (e) {} } function forPage() { var path = location.pathname; return loadAll().filter(function (a) { return a.url === path; }); } function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); } /* ------------------------------------------------------------------ CRUD ------------------------------------------------------------------ */ function addRaw(ann) { var list = loadAll(); list.push(ann); saveAll(list); } function removeById(id) { saveAll(loadAll().filter(function (a) { return a.id !== id; })); document.querySelectorAll('mark[data-ann-id="' + id + '"]').forEach(function (mark) { var parent = mark.parentNode; if (!parent) return; while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); parent.removeChild(mark); parent.normalize(); }); hideTooltip(true); } /* ------------------------------------------------------------------ Text-stream search — finds the first occurrence of searchText in the visible text of root, skipping existing annotation marks. ------------------------------------------------------------------ */ function findTextRange(searchText, root) { var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); var nodes = []; var node; while ((node = walker.nextNode())) { /* Skip text already inside an annotation mark */ if (node.parentElement && node.parentElement.closest('mark.user-annotation')) continue; nodes.push(node); } /* Build one long string, tracking each node's span */ var full = ''; var spans = []; nodes.forEach(function (n) { spans.push({ node: n, start: full.length, end: full.length + n.nodeValue.length }); full += n.nodeValue; }); var idx = full.indexOf(searchText); if (idx === -1) return null; var end = idx + searchText.length; var startNode, startOff, endNode, endOff; for (var i = 0; i < spans.length; i++) { var s = spans[i]; if (startNode === undefined && idx >= s.start && idx < s.end) { startNode = s.node; startOff = idx - s.start; } if (endNode === undefined && end > s.start && end <= s.end) { endNode = s.node; endOff = end - s.start; } if (startNode && endNode) break; } if (!startNode || !endNode) return null; var range = document.createRange(); range.setStart(startNode, startOff); range.setEnd(endNode, endOff); return range; } /* ------------------------------------------------------------------ Apply a single annotation to the DOM ------------------------------------------------------------------ */ function applyAnnotation(ann) { var root = document.getElementById('markdownBody') || document.body; var range = findTextRange(ann.text, root); if (!range) return false; var mark = document.createElement('mark'); mark.className = 'user-annotation user-annotation--' + ann.color; mark.setAttribute('data-ann-id', ann.id); if (ann.note) mark.setAttribute('data-note', ann.note); mark.setAttribute('data-created', ann.created || ''); try { range.surroundContents(mark); } catch (e) { /* Range crosses element boundaries — extract and re-insert */ var frag = range.extractContents(); mark.appendChild(frag); range.insertNode(mark); } bindMarkEvents(mark, ann); return true; } function applyAll() { forPage().forEach(function (ann) { applyAnnotation(ann); }); } /* ------------------------------------------------------------------ Tooltip ------------------------------------------------------------------ */ function initTooltip() { tooltip = document.createElement('div'); tooltip.className = 'ann-tooltip'; tooltip.setAttribute('role', 'tooltip'); document.body.appendChild(tooltip); tooltip.addEventListener('mouseenter', function () { clearTimeout(tooltipTimer); }); tooltip.addEventListener('mouseleave', function () { hideTooltip(false); }); } function escHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function showTooltip(mark, ann) { clearTimeout(tooltipTimer); var note = ann.note || ''; var created = ann.created ? new Date(ann.created).toLocaleDateString() : ''; tooltip.innerHTML = (note ? '