/** * useTextHighlighter – handles text selection inside a DOM container and * persists highlights as {id, text, color, rects} objects. * * SRP: Only responsible for selection detection and highlight data management. */ import { ref } from 'vue' const COLOR_BG = { yellow: '#fef08a', green: '#bbf7d0', rose: '#fecdd3', blue: '#bfdbfe', } export function useTextHighlighter(containerRef) { const highlights = ref([]) // [{id, text, color, range}] /** Call on mouseup when highlight tool is active. */ function applyHighlight(color = 'yellow') { const sel = window.getSelection() if (!sel || sel.isCollapsed || !containerRef.value) return const text = sel.toString().trim() if (!text) return // Make sure selection is inside our container const range = sel.getRangeAt(0) if (!containerRef.value.contains(range.commonAncestorContainer)) { sel.removeAllRanges() return } const id = `hl_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` highlights.value.push({ id, text, color }) _renderHighlight(range, id, color) sel.removeAllRanges() } /** Rehydrate existing highlights into DOM (after content re-render) */ function rehydrateHighlights() { if (!containerRef.value) return // Re-apply all saved highlights by searching text in DOM highlights.value.forEach((hl) => _applyHighlightByText(containerRef.value, hl)) } /** Remove a specific highlight by id */ function removeHighlight(id) { highlights.value = highlights.value.filter((h) => h.id !== id) if (!containerRef.value) return containerRef.value.querySelectorAll(`[data-hl="${id}"]`).forEach((el) => { const parent = el.parentNode while (el.firstChild) parent.insertBefore(el.firstChild, el) parent.removeChild(el) parent.normalize() }) } /** Clear all highlights */ function clearHighlights() { const ids = highlights.value.map((h) => h.id) ids.forEach(removeHighlight) highlights.value = [] } // ── Private helpers ───────────────────────────────────────────────────────── function _renderHighlight(range, id, color) { try { const mark = document.createElement('mark') mark.dataset.hl = id mark.style.cssText = `background:${COLOR_BG[color] || COLOR_BG.yellow};border-radius:2px;padding:0 1px;cursor:pointer;` mark.addEventListener('click', () => removeHighlight(id)) range.surroundContents(mark) } catch { // surroundContents fails if selection crosses element boundaries – use extractContents try { const frag = range.extractContents() const mark = document.createElement('mark') mark.dataset.hl = id mark.style.cssText = `background:${COLOR_BG[color] || COLOR_BG.yellow};border-radius:2px;padding:0 1px;cursor:pointer;` mark.addEventListener('click', () => removeHighlight(id)) mark.appendChild(frag) range.insertNode(mark) } catch (_) { /* silent */ } } } function _applyHighlightByText(container, hl) { const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT) let node while ((node = walker.nextNode())) { const idx = node.textContent.indexOf(hl.text) if (idx === -1) continue const range = document.createRange() range.setStart(node, idx) range.setEnd(node, idx + hl.text.length) _renderHighlight(range, hl.id, hl.color) break } } return { highlights, applyHighlight, rehydrateHighlights, removeHighlight, clearHighlights, COLOR_BG } }