diff --git a/app/components/estudiosTypensense/EstudiosTypensenseDetail.vue b/app/components/estudiosTypensense/EstudiosTypensenseDetail.vue index 04d7884..07a84ac 100644 --- a/app/components/estudiosTypensense/EstudiosTypensenseDetail.vue +++ b/app/components/estudiosTypensense/EstudiosTypensenseDetail.vue @@ -59,6 +59,7 @@ const props = defineProps<{ collection: string query?: string selectedHit?: TypesenseParagraphHit | null + selectedMatchingHits?: TypesenseParagraphHit[] | null }>() const emits = defineEmits(['close']) @@ -155,15 +156,15 @@ function safeLocation(): string { }) } -// ---- Utilerías de highlight ------------------------------------------------ +// ---- Utilerías de highlight -------------------------------------------------- -function fullHighlightedFor(hit: TypesenseParagraphHit, field: string): string | null { +function highlightedFor(hit: TypesenseParagraphHit, field: string): string | null { const fromArr = hit.highlights?.find(h => h.field === field) - if (fromArr?.value) return fromArr.value if (fromArr?.snippet) return fromArr.snippet + if (fromArr?.value) return fromArr.value const fromObj = hit.highlight?.[field] - if (fromObj?.value) return fromObj.value if (fromObj?.snippet) return fromObj.snippet + if (fromObj?.value) return fromObj.value return null } @@ -175,146 +176,457 @@ function decodeEntities(s: string): string { return tmp.textContent || s } -function extractMarkFragments(html: string): string[] { - if (!html) return [] +// ---- Snippet parsing & precise mark application ---------------------------- + +interface SnippetSegment { + text: string + isMarked: boolean +} + +// Descompone el HTML del snippet en segmentos marcados y no marcados. +function parseSnippetHtml(html: string): SnippetSegment[] { + const segments: SnippetSegment[] = [] + let lastIdx = 0 const markRe = /]*>([\s\S]*?)<\/mark>/gi - const marks: { text: string, start: number, end: number }[] = [] let m: RegExpExecArray | null while ((m = markRe.exec(html)) !== null) { - marks.push({ - text: decodeEntities(m[1]), - start: m.index, - end: m.index + m[0].length - }) - } - if (!marks.length) return [] - const groups: string[][] = [[marks[0].text]] - for (let i = 1; i < marks.length; i++) { - const between = html.slice(marks[i - 1].end, marks[i].start) - if (/^\s*$/.test(between)) { - groups[groups.length - 1].push(marks[i].text) - } else { - groups.push([marks[i].text]) + if (m.index > lastIdx) { + const raw = html.slice(lastIdx, m.index).replace(/<[^>]*>/g, '') + if (raw) segments.push({ text: decodeEntities(raw), isMarked: false }) } + segments.push({ text: decodeEntities(m[1]), isMarked: true }) + lastIdx = m.index + m[0].length } - return groups.map(g => g.join(' ').trim()).filter(Boolean) + if (lastIdx < html.length) { + const raw = html.slice(lastIdx).replace(/<[^>]*>/g, '') + if (raw) segments.push({ text: decodeEntities(raw), isMarked: false }) + } + return segments +} + +// Recolecta nodos de texto de un párrafo, excluyendo los que ya están dentro de marks. +function collectParaTextNodes(paraEl: HTMLElement): { flat: string, nmap: { node: Text, start: number, end: number }[] } { + const walker = document.createTreeWalker(paraEl, NodeFilter.SHOW_TEXT, { + acceptNode(node: Node) { + let p = node.parentNode + while (p && p !== paraEl) { + if (p instanceof HTMLElement && p.matches('mark.search-match')) return NodeFilter.FILTER_REJECT + p = p.parentNode + } + return NodeFilter.FILTER_ACCEPT + } + }) + let flat = '' + const nmap: { node: Text, start: number, end: number }[] = [] + let n: Node | null + while ((n = walker.nextNode())) { + const node = n as Text + const t = node.nodeValue || '' + nmap.push({ node, start: flat.length, end: flat.length + t.length }) + flat += t + } + return { flat, nmap } +} + +// Ubica el snippet completo (con contexto) en el párrafo y aplica un por cada +// segmento marcado del snippet. Retorna los marks creados en orden de documento. +// +// Algoritmo: aplica los marks de DERECHA A IZQUIERDA para que las posiciones +// computadas desde el flat text original sigan siendo válidas en cada iteración, +// ya que las mutaciones DOM a la derecha no desplazan los nodos a la izquierda. +function findAndApplySnippetMarks(paraEl: HTMLElement, snippetHtml: string): HTMLElement[] { + const segments = parseSnippetHtml(snippetHtml) + if (!segments.some(s => s.isMarked)) return [] + + const fullPlain = segments.map(s => s.text).join('') + + // Eliminar SOLO marcadores de truncación de Typesense (… o ...), NO puntos de oración. + // Ejemplo correcto: "…texto aquí…" → "texto aquí" + // Ejemplo incorrecto anterior: "el Programa Divino." → "el Programa Divino" (perdía el punto) + const stripped = fullPlain + .replace(/^(?:…|\.\.\.)\s*/, '') + .replace(/\s*(?:…|\.\.\.)$/, '') + .trim() + if (!stripped) return [] + + // Ajustar leadingLen para el cálculo de offsets de marks + const leadingLen = fullPlain.length - fullPlain.replace(/^(?:…|\.\.\.)\s*/, '').length + + // Flat text inicial del párrafo para calcular posiciones + const { flat: initFlat } = collectParaTextNodes(paraEl) + if (!initFlat) return [] + + // Mapeo posición-normalizada → posición-original + const { normalized: normFlat, map: normToOrig } = normalizeWithMap(initFlat) + const normStripped = normalize(stripped) + const normIdx = normFlat.indexOf(normStripped) + if (normIdx === -1) return [] + + // Calcular posición original de cada segmento marcado dentro del snippet + const markPositions: { origStart: number, origEnd: number }[] = [] + let plainCursor = leadingLen + + for (const seg of segments) { + const normOffset = normalize(fullPlain.slice(leadingLen, plainCursor)).length + if (seg.isMarked && normOffset <= normStripped.length) { + const normStart = normIdx + normOffset + const normEnd = normStart + normalize(seg.text).length + const origStart = normStart < normToOrig.length ? normToOrig[normStart] : initFlat.length + const origEnd = normEnd < normToOrig.length ? normToOrig[normEnd] : initFlat.length + if (origStart < origEnd) markPositions.push({ origStart, origEnd }) + } + plainCursor += seg.text.length + } + + if (!markPositions.length) return [] + + // Aplicar marks de derecha a izquierda + markPositions.sort((a, b) => b.origStart - a.origStart) + const createdMarks: HTMLElement[] = [] + + for (const { origStart, origEnd } of markPositions) { + // Re-recolectar nodos: los ya marcados quedan excluidos del flat text. + // Las posiciones < origStart del último mark aplicado no se ven afectadas. + const { nmap } = collectParaTextNodes(paraEl) + + for (let i = nmap.length - 1; i >= 0; i--) { + const info = nmap[i] + const segStart = Math.max(origStart, info.start) - info.start + const segEnd = Math.min(origEnd, info.end) - info.start + if (segStart >= segEnd) continue + + const nodeText = info.node.nodeValue || '' + const frag = document.createDocumentFragment() + if (segStart > 0) frag.appendChild(document.createTextNode(nodeText.slice(0, segStart))) + const mark = document.createElement('mark') + mark.className = 'search-match match-start' + mark.textContent = nodeText.slice(segStart, segEnd) + frag.appendChild(mark) + createdMarks.push(mark) + if (segEnd < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(segEnd))) + info.node.parentNode?.replaceChild(frag, info.node) + } + paraEl.normalize() + } + + // Los marks se crearon de derecha a izquierda; revertir para orden de documento + createdMarks.reverse() + return createdMarks } function normalize(s: string): string { - return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() + //   = non-breaking space ( ); treat as regular space so DOM text + // nodes (which keep   as  ) match Typesense snippets (regular spaces). + return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/ /g, ' ') } -// ---- Renderizado de párrafos y scroll a coincidencia ----------------------- +// ---- Refs de estado --------------------------------------------------------- const paragraphsContainer = ref(null) -const scrollContainer = ref(null) -function getHighlightText(): string | null { - if (!props.selectedHit) return null - const html = fullHighlightedFor(props.selectedHit, 'text') - if (!html) return null - const fragments = extractMarkFragments(html) - if (!fragments.length) return null - fragments.sort((a, b) => b.length - a.length) - return fragments[0] +// 'typesense' = Estado 1 (server-driven), 'local' = Estado 2 (client-driven) +type SearchMode = 'typesense' | 'local' +const searchMode = ref('typesense') +const matchElements = ref([]) +const currentMatchIdx = ref(0) + +// ---- Limpieza de marks del DOM ---------------------------------------------- + +function clearMatchMarks() { + const container = paragraphsContainer.value + if (!container) return + container.querySelectorAll('mark.search-match').forEach(m => { + const parent = m.parentNode + if (parent) { + parent.replaceChild(document.createTextNode(m.textContent || ''), m) + parent.normalize() + } + }) + matchElements.value = [] + currentMatchIdx.value = 0 } -async function highlightAndScroll() { +// ---- Envolver coincidencias de texto en un párrafo -------------------------- +// +// Recorre los nodos de texto dentro de paraEl, encuentra la primera aparición +// de matchText (ignorando diacríticos y mayúsculas) y la envuelve en un . +// Repite hasta que no haya más apariciones. Devuelve todos los marks creados. + +function wrapTextMatchesInParagraph(paraEl: HTMLElement, matchText: string): HTMLElement[] { + const normMatch = normalize(matchText) + if (!normMatch) return [] + + const marks: HTMLElement[] = [] + + while (true) { + const walker = document.createTreeWalker( + paraEl, + NodeFilter.SHOW_TEXT, + { + acceptNode(node: Node) { + let p = node.parentNode + while (p && p !== paraEl) { + if (p instanceof HTMLElement && p.matches('mark.search-match')) { + return NodeFilter.FILTER_REJECT + } + p = p.parentNode + } + return NodeFilter.FILTER_ACCEPT + } + } + ) + + const textNodes: Text[] = [] + let n: Node | null + while ((n = walker.nextNode())) textNodes.push(n as Text) + + if (!textNodes.length) break + + let flatText = '' + const nodeMap: { node: Text, start: number, end: number }[] = [] + for (const node of textNodes) { + const t = node.nodeValue || '' + nodeMap.push({ node, start: flatText.length, end: flatText.length + t.length }) + flatText += t + } + + const normFlat = normalize(flatText) + const startIdx = normFlat.indexOf(normMatch) + if (startIdx === -1) break + + const endIdx = startIdx + normMatch.length + let applied = false + + for (let i = nodeMap.length - 1; i >= 0; i--) { + const info = nodeMap[i] + const segStart = Math.max(startIdx, info.start) - info.start + const segEnd = Math.min(endIdx, info.end) - info.start + if (segStart >= segEnd) continue + + const nodeText = info.node.nodeValue || '' + const frag = document.createDocumentFragment() + let cursor = 0 + + if (segStart > cursor) frag.appendChild(document.createTextNode(nodeText.slice(cursor, segStart))) + + const mark = document.createElement('mark') + mark.className = 'search-match match-start' + mark.textContent = nodeText.slice(segStart, segEnd) + frag.appendChild(mark) + marks.push(mark) + cursor = segEnd + + if (cursor < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(cursor))) + + info.node.parentNode?.replaceChild(frag, info.node) + applied = true + } + + if (!applied) break + + paraEl.normalize() + } + + return marks +} + +// ---- Estado 1: aplicar highlights de Typesense y scroll inicial ------------- + +async function applyTypesenseHighlights() { await nextTick() + clearMatchMarks() const container = paragraphsContainer.value if (!container) return - container.querySelectorAll('#bible-study-search-match').forEach(el => { - const parent = el.parentNode - if (parent) { - parent.replaceChild(document.createTextNode(el.textContent || ''), el) - parent.normalize() + searchMode.value = 'typesense' + + const hasMatchingHits = !!(props.selectedMatchingHits?.length) + + // Paso 1: marks del snippet del selectedHit (solo cuando hay hits de Typesense). + let snippetMarks: HTMLElement[] = [] + + if (hasMatchingHits && props.selectedHit) { + const snippet = highlightedFor(props.selectedHit, 'text') + const paraNumber = props.selectedHit.document.number + if (snippet && paraNumber !== undefined) { + const paraEl = container.querySelector(`[data-paragraph-number="${paraNumber}"]`) as HTMLElement | null + if (paraEl) snippetMarks = findAndApplySnippetMarks(paraEl, snippet) } - }) - - const matchText = getHighlightText() - const matchNumber = props.selectedHit?.document?.number - if (!matchText || matchNumber === undefined) return - - const paraEl = container.querySelector(`[data-paragraph-number="${matchNumber}"]`) - if (!paraEl) return - - const walker = document.createTreeWalker(paraEl, NodeFilter.SHOW_TEXT, null) - const textNodes: Text[] = [] - let n: Node | null - while ((n = walker.nextNode())) { - textNodes.push(n as Text) } - let flatText = '' - const nodeMap: { node: Text, start: number, end: number }[] = [] - for (const node of textNodes) { - const t = node.nodeValue || '' - nodeMap.push({ node, start: flatText.length, end: flatText.length + t.length }) - flatText += t + // Paso 2: marks de fondo con la frase EXACTA del query (siempre se aplican). + // Pre-check de texto plano para saltarse párrafos sin la frase → más rápido. + const rawPhrase = (props.query || '').replace(/^"+|"+$/g, '').trim() + + if (rawPhrase) { + const normPhrase = normalize(rawPhrase) + const allParaEls = Array.from( + container.querySelectorAll('[data-paragraph-number]') + ) as HTMLElement[] + for (const paraEl of allParaEls) { + if (!normalize(paraEl.textContent || '').includes(normPhrase)) continue + wrapTextMatchesInParagraph(paraEl, rawPhrase) + } } - const normFlat = normalize(flatText) - const normMatch = normalize(matchText) - const startIdx = normFlat.indexOf(normMatch) - if (startIdx === -1) return - const endIdx = startIdx + normMatch.length + // Paso 3: recolectar todos los marks en orden DOM para navegación secuencial. + const domMarks = Array.from( + container.querySelectorAll('mark.search-match.match-start') + ) as HTMLElement[] + matchElements.value = domMarks + currentMatchIdx.value = 0 - let applied = false - for (let i = nodeMap.length - 1; i >= 0; i--) { - const info = nodeMap[i] - const segStart = Math.max(startIdx, info.start) - info.start - const segEnd = Math.min(endIdx, info.end) - info.start - if (segStart >= segEnd) continue + // Paso 4: scroll — solo cuando hay hits de Typesense que ubican el párrafo correcto. + // Sin hits, los marks se muestran para que el usuario navegue, pero no se hace scroll. + if (!hasMatchingHits) return - const nodeText = info.node.nodeValue || '' - const frag = document.createDocumentFragment() - let cursor = 0 + let targetMark: HTMLElement | null = snippetMarks[0] ?? null - if (segStart > cursor) { - frag.appendChild(document.createTextNode(nodeText.slice(cursor, segStart))) + if (!targetMark && props.selectedHit) { + const paraNumber = props.selectedHit.document.number + if (paraNumber !== undefined) { + const paraEl = container.querySelector(`[data-paragraph-number="${paraNumber}"]`) as HTMLElement | null + if (paraEl) { + const marksInPara = Array.from( + paraEl.querySelectorAll('mark.search-match.match-start') + ) as HTMLElement[] + if (marksInPara.length) { + targetMark = marksInPara[0] + } else { + await nextTick() + domMarks.forEach(m => m.classList.remove('is-current')) + paraEl.scrollIntoView({ block: 'start', behavior: 'smooth' }) + return + } + } } - - const span = document.createElement('span') - span.id = 'bible-study-search-match' - span.textContent = nodeText.slice(segStart, segEnd) - frag.appendChild(span) - cursor = segEnd - - if (cursor < nodeText.length) { - frag.appendChild(document.createTextNode(nodeText.slice(cursor))) - } - - info.node.parentNode?.replaceChild(frag, info.node) - applied = true } - if (applied) { + if (!targetMark) targetMark = domMarks[0] ?? null + + if (targetMark) { + const idx = domMarks.indexOf(targetMark) + currentMatchIdx.value = idx !== -1 ? idx : 0 await nextTick() - const matchEl = document.getElementById('bible-study-search-match') - if (matchEl) { - matchEl.scrollIntoView({ block: 'center', behavior: 'smooth' }) - } + domMarks.forEach(m => m.classList.remove('is-current')) + targetMark.classList.add('is-current') + targetMark.scrollIntoView({ block: 'center', behavior: 'smooth' }) } } -// ---- Find-in-document ---------------------------------------------------- +// ---- Estado 2: búsqueda local ----------------------------------------------- -const localQuery = ref((props.query || '').replace(/^"+|"+$/g, '').trim()) +function applyLocalHighlights(query: string) { + clearMatchMarks() + searchMode.value = 'local' + + const el = paragraphsContainer.value + if (!el || !query) return + + const terms = termsFor(query) + if (!terms.length) return + + highlightTextNodes(el, terms) + + matchElements.value = Array.from(el.querySelectorAll('mark.search-match.match-start')) as HTMLElement[] + currentMatchIdx.value = 0 + + if (matchElements.value.length) { + nextTick(() => { + matchElements.value[0].classList.add('is-current') + }) + } +} + +// ---- Navegación entre coincidencias ----------------------------------------- + +function navigateToCurrent() { + matchElements.value.forEach(m => m.classList.remove('is-current')) + const target = matchElements.value[currentMatchIdx.value] + if (!target) return + target.classList.add('is-current') + target.scrollIntoView({ block: 'center', behavior: 'smooth' }) +} + +function nextMatch() { + if (!matchElements.value.length) return + currentMatchIdx.value = (currentMatchIdx.value + 1) % matchElements.value.length + navigateToCurrent() +} + +function prevMatch() { + if (!matchElements.value.length) return + currentMatchIdx.value = (currentMatchIdx.value - 1 + matchElements.value.length) % matchElements.value.length + navigateToCurrent() +} + +function clearLocalQuery() { + localQuery.value = '' + // Al limpiar el input local, volver a Estado 1 si hay hits de Typesense + if (props.selectedMatchingHits?.length) { + applyTypesenseHighlights() + } else { + clearMatchMarks() + searchMode.value = 'typesense' + } +} + +// ---- Input de búsqueda local ------------------------------------------------ +// Arranca vacío siempre: el Estado 1 (Typesense) es el modo inicial. +// Solo al escribir aquí se activa el Estado 2. + +const localQuery = ref('') const debouncedLocalQuery = useDebounce(localQuery, 200) -const currentIdx = ref(0) -const totalMatches = ref(0) +watch(debouncedLocalQuery, (q) => { + // Cualquier cambio en el input activa el Estado 2 + applyLocalHighlights(q) +}) +function onInputKey(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault() + if (e.shiftKey) prevMatch() + else nextMatch() + } else if (e.key === 'Escape') { + clearLocalQuery() + } +} + +// ---- Inicialización y reactividad ------------------------------------------- + +// Al cambiar de documento, resetear todo el estado watch( () => props.document?.id, () => { - currentIdx.value = 0 - totalMatches.value = 0 + localQuery.value = '' + matchElements.value = [] + currentMatchIdx.value = 0 + searchMode.value = 'typesense' } ) +// Cuando llegan los párrafos, aplicar highlights de Typesense (Estado 1) +watch( + () => props.paragraphs, + async (paragraphs) => { + if (paragraphs.length) { + // Resetear input local para garantizar Estado 1 al cargar nuevo documento + localQuery.value = '' + await applyTypesenseHighlights() + } + } +) + +onMounted(async () => { + if (props.paragraphs.length) { + await applyTypesenseHighlights() + } +}) + +// ---- Utilerías de búsqueda local -------------------------------------------- + function escapeRegex(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } @@ -323,7 +635,9 @@ function normalizeWithMap(text: string): { normalized: string, map: number[] } { let normalized = '' const map: number[] = [] for (let i = 0; i < text.length; i++) { - const norm = text[i].normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() + // Treat non-breaking space ( ) as regular space — same as normalize() + const char = text.charCodeAt(i) === 0xa0 ? ' ' : text[i] + const norm = char.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() for (let j = 0; j < norm.length; j++) { normalized += norm[j] map.push(i) @@ -428,87 +742,6 @@ function highlightTextNodes(root: HTMLElement, terms: string[]): number { } return matches.length } - -function getMarks(): HTMLElement[] { - return paragraphsContainer.value - ? Array.from(paragraphsContainer.value.querySelectorAll('mark.search-match')) as HTMLElement[] - : [] -} - -function getMatchStarts(): HTMLElement[] { - return paragraphsContainer.value - ? Array.from(paragraphsContainer.value.querySelectorAll('mark.search-match.match-start')) as HTMLElement[] - : [] -} - -function applyCurrent(scroll = true) { - getMarks().forEach(m => m.classList.remove('is-current')) - const starts = getMatchStarts() - if (!starts.length) return - const idx = Math.min(Math.max(currentIdx.value, 0), starts.length - 1) - const target = starts[idx] - if (!target) return - target.classList.remove('is-current') - void target.offsetWidth - target.classList.add('is-current') - if (scroll) target.scrollIntoView({ block: 'center', behavior: 'smooth' }) -} - -function nextMatch() { - if (!totalMatches.value) return - currentIdx.value = (currentIdx.value + 1) % totalMatches.value - applyCurrent() -} - -function prevMatch() { - if (!totalMatches.value) return - currentIdx.value = (currentIdx.value - 1 + totalMatches.value) % totalMatches.value - applyCurrent() -} - -function clearLocalQuery() { localQuery.value = '' } - -function applyHighlights({ query, scroll }: { query: string, scroll: boolean }) { - const el = paragraphsContainer.value - if (!el) return - el.querySelectorAll('mark.search-match').forEach((m) => { - m.parentNode?.replaceChild(document.createTextNode(m.textContent || ''), m) - }) - el.normalize() - const terms = termsFor(query) - const count = terms.length ? highlightTextNodes(el, terms) : 0 - totalMatches.value = count - currentIdx.value = 0 - if (count > 0) nextTick(() => applyCurrent(scroll)) -} - -watch(debouncedLocalQuery, (q) => applyHighlights({ query: q, scroll: false })) - -function onInputKey(e: KeyboardEvent) { - if (e.key === 'Enter') { - e.preventDefault() - if (e.shiftKey) prevMatch() - else nextMatch() - } else if (e.key === 'Escape') { - clearLocalQuery() - } -} - -watch( - () => props.paragraphs, - async (paragraphs) => { - console.log('[Typesense paragraphs]', paragraphs) - if (paragraphs.length) { - await highlightAndScroll() - } - } -) - -onMounted(async () => { - if (props.paragraphs.length) { - await highlightAndScroll() - } -}) -