linguaielts-api / fronted /src /composables /useTextHighlighter.js
AnhviNguyen
update reading + listening
369feea
Raw
History Blame Contribute Delete
3.71 kB
/**
* 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 }
}