state local state typesense

This commit is contained in:
David Ascanio 2026-05-16 15:40:57 -03:00
parent f4357e01d2
commit 65ec579de5
2 changed files with 472 additions and 205 deletions

View File

@ -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,77 +176,211 @@ 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 = /<mark[^>]*>([\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 (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
}
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
}
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])
return { flat, nmap }
}
// Ubica el snippet completo (con contexto) en el párrafo y aplica un <mark> 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
}
return groups.map(g => g.join(' ').trim()).filter(Boolean)
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 (&nbsp;); treat as regular space so DOM text
// nodes (which keep &nbsp; 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<HTMLElement | null>(null)
const scrollContainer = ref<HTMLElement | null>(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<SearchMode>('typesense')
const matchElements = ref<HTMLElement[]>([])
const currentMatchIdx = ref(0)
async function highlightAndScroll() {
await nextTick()
// ---- Limpieza de marks del DOM ----------------------------------------------
function clearMatchMarks() {
const container = paragraphsContainer.value
if (!container) return
container.querySelectorAll('#bible-study-search-match').forEach(el => {
const parent = el.parentNode
container.querySelectorAll('mark.search-match').forEach(m => {
const parent = m.parentNode
if (parent) {
parent.replaceChild(document.createTextNode(el.textContent || ''), el)
parent.replaceChild(document.createTextNode(m.textContent || ''), m)
parent.normalize()
}
})
matchElements.value = []
currentMatchIdx.value = 0
}
const matchText = getHighlightText()
const matchNumber = props.selectedHit?.document?.number
if (!matchText || matchNumber === undefined) return
// ---- 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 <mark>.
// Repite hasta que no haya más apariciones. Devuelve todos los marks creados.
const paraEl = container.querySelector(`[data-paragraph-number="${matchNumber}"]`)
if (!paraEl) return
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 walker = document.createTreeWalker(paraEl, NodeFilter.SHOW_TEXT, null)
const textNodes: Text[] = []
let n: Node | null
while ((n = walker.nextNode())) {
textNodes.push(n as Text)
}
while ((n = walker.nextNode())) textNodes.push(n as Text)
if (!textNodes.length) break
let flatText = ''
const nodeMap: { node: Text, start: number, end: number }[] = []
@ -256,12 +391,12 @@ async function highlightAndScroll() {
}
const normFlat = normalize(flatText)
const normMatch = normalize(matchText)
const startIdx = normFlat.indexOf(normMatch)
if (startIdx === -1) return
const endIdx = startIdx + normMatch.length
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
@ -272,49 +407,226 @@ async function highlightAndScroll() {
const frag = document.createDocumentFragment()
let cursor = 0
if (segStart > cursor) {
frag.appendChild(document.createTextNode(nodeText.slice(cursor, segStart)))
}
if (segStart > cursor) frag.appendChild(document.createTextNode(nodeText.slice(cursor, segStart)))
const span = document.createElement('span')
span.id = 'bible-study-search-match'
span.textContent = nodeText.slice(segStart, segEnd)
frag.appendChild(span)
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)))
}
if (cursor < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(cursor)))
info.node.parentNode?.replaceChild(frag, info.node)
applied = true
}
if (applied) {
await nextTick()
const matchEl = document.getElementById('bible-study-search-match')
if (matchEl) {
matchEl.scrollIntoView({ block: 'center', behavior: 'smooth' })
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
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)
}
}
// 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)
}
}
// 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
// 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
let targetMark: HTMLElement | null = snippetMarks[0] ?? null
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
}
}
}
}
if (!targetMark) targetMark = domMarks[0] ?? null
if (targetMark) {
const idx = domMarks.indexOf(targetMark)
currentMatchIdx.value = idx !== -1 ? idx : 0
await nextTick()
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()
}
})
</script>
<template>
@ -585,11 +818,12 @@ onMounted(async () => {
/>
</div>
<!-- Buscador interno del documento -->
<div v-if="paragraphs.length" class="flex items-center gap-2">
<UInput
v-model="localQuery"
icon="i-lucide-search"
placeholder="Buscar en este documento..."
:placeholder="props.query || 'Buscar en este documento...'"
class="flex-1 min-w-0"
:ui="{ trailing: 'pe-1' }"
@keydown="onInputKey"
@ -604,19 +838,27 @@ onMounted(async () => {
/>
</template>
</UInput>
<template v-if="localQuery">
<template v-if="matchElements.length || searchMode === 'local'">
<UBadge
v-if="searchMode === 'typesense' && matchElements.length"
label="Typesense"
size="xs"
variant="subtle"
color="warning"
class="shrink-0"
/>
<span
class="text-xs tabular-nums whitespace-nowrap shrink-0 px-2 py-1 rounded-md bg-elevated border border-default"
:class="totalMatches ? 'text-toned font-medium' : 'text-dimmed'"
:class="matchElements.length ? 'text-toned font-medium' : 'text-dimmed'"
>
{{ totalMatches ? `${currentIdx + 1} / ${totalMatches}` : '0 / 0' }}
{{ matchElements.length ? `${currentMatchIdx + 1} / ${matchElements.length}` : '0 / 0' }}
</span>
<div class="flex items-center gap-1 shrink-0">
<UTooltip text="Anterior (Shift+Enter)">
<UButton icon="i-lucide-chevron-up" :disabled="!totalMatches" aria-label="Anterior" color="neutral" variant="ghost" size="sm" @click="prevMatch" />
<UButton icon="i-lucide-chevron-up" :disabled="!matchElements.length" aria-label="Anterior" color="neutral" variant="ghost" size="sm" @click="prevMatch" />
</UTooltip>
<UTooltip text="Siguiente (Enter)">
<UButton icon="i-lucide-chevron-down" :disabled="!totalMatches" aria-label="Siguiente" color="neutral" variant="ghost" size="sm" @click="nextMatch" />
<UButton icon="i-lucide-chevron-down" :disabled="!matchElements.length" aria-label="Siguiente" color="neutral" variant="ghost" size="sm" @click="nextMatch" />
</UTooltip>
</div>
</template>

View File

@ -87,6 +87,11 @@ const total = ref(0)
const currentPage = ref(1)
const hasMore = computed(() => hits.value.length < total.value)
// Progressive display: show 10 groups at a time, reveal more on scroll
const visibleGroupCount = ref(10)
const visibleGroups = computed(() => groupedHits.value.slice(0, visibleGroupCount.value))
const hasMoreVisible = computed(() => visibleGroupCount.value < groupedHits.value.length)
// groupedHits: computed un elemento por document_id único.
// El conteo de coincidencias por documento se omite en la lista para evitar
// confusión con el contador del find-in-document (que cuenta sobre el body
@ -186,13 +191,17 @@ async function runSearch(q: string, append = false) {
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
const newHits = res?.hits ?? []
// Fetch metadata first so results never flash with bare docIds
if (!append) docCache.value = {}
const newDocIds = [...new Set(newHits.map(h => h.document.document_id).filter(Boolean))]
await fetchDocumentMeta(newDocIds)
// Abort if a newer search has started while we waited for metadata
if (seq !== searchSeq) return
hits.value = append ? hits.value.concat(newHits) : newHits
total.value = res?.found ?? hits.value.length
currentPage.value = page
if (!append) docCache.value = {}
const newDocIds = [...new Set(newHits.map(h => h.document.document_id).filter(Boolean))]
fetchDocumentMeta(newDocIds)
} catch (err: unknown) {
if (seq !== searchSeq) return
console.error('Typesense error', err)
@ -215,6 +224,20 @@ function loadMore() {
runSearch(query.value, true)
}
const listContainer = ref<HTMLElement | null>(null)
function onListScroll() {
const el = listContainer.value
if (!el) return
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
if (hasMoreVisible.value) {
visibleGroupCount.value += 10
} else if (hasMore.value && !loadingMore.value && !loading.value) {
loadMore()
}
}
}
function retry() {
runSearch(query.value, false)
}
@ -226,6 +249,7 @@ watch(debouncedQuery, (q) => {
hits.value = []
total.value = 0
currentPage.value = 1
visibleGroupCount.value = 10
runSearch(q, false)
})
@ -240,6 +264,9 @@ const paragraphsLoading = ref(false)
// Hit original del grupo seleccionado (contiene highlights de Typesense)
const selectedHit = ref<TypesenseParagraphHit | null>(null)
// Todos los hits del grupo seleccionado (todos los párrafos con highlights de Typesense)
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([])
async function fetchFullDocument(docId: string) {
documentLoading.value = true
selectedDocument.value = null
@ -308,6 +335,7 @@ async function fetchDocumentParagraphs(docId: string) {
async function selectGroup(group: { docId: string, firstHit: TypesenseParagraphHit }) {
selectedDocId.value = group.docId
selectedHit.value = group.firstHit
selectedMatchingHits.value = hits.value.filter(h => h.document.document_id === group.docId)
fetchFullDocument(group.docId)
fetchDocumentParagraphs(group.docId)
}
@ -320,6 +348,7 @@ const isPanelOpen = computed({
selectedDocument.value = null
selectedParagraphs.value = []
selectedHit.value = null
selectedMatchingHits.value = []
}
}
})
@ -332,6 +361,7 @@ watch(groupedHits, () => {
selectedDocument.value = null
selectedParagraphs.value = []
selectedHit.value = null
selectedMatchingHits.value = []
}
})
@ -417,7 +447,7 @@ function metaLocation(meta: DocMeta | undefined): string {
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
/>
<div class="overflow-y-auto divide-y divide-default flex-1">
<div ref="listContainer" class="overflow-y-auto divide-y divide-default flex-1" @scroll="onListScroll">
<div
v-if="loading && !groupedHits.length"
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
@ -435,7 +465,7 @@ function metaLocation(meta: DocMeta | undefined): string {
</div>
<div
v-for="group in groupedHits"
v-for="group in visibleGroups"
:key="group.docId"
class="bg-white p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
:class="[
@ -478,22 +508,15 @@ function metaLocation(meta: DocMeta | undefined): string {
</div>
<div
v-if="hasMore && !loading"
class="p-4 flex justify-center"
v-if="loadingMore"
class="flex items-center justify-center gap-2 py-4 text-sm text-muted"
>
<UButton
variant="outline"
color="neutral"
size="sm"
:loading="loadingMore"
@click="loadMore"
>
Cargar más
</UButton>
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
Cargando más...
</div>
<div
v-else-if="groupedHits.length && !hasMore && !loading"
v-else-if="groupedHits.length && !hasMoreVisible && !hasMore && !loading"
class="py-3 text-center text-xs text-dimmed"
>
No hay más resultados
@ -511,6 +534,7 @@ function metaLocation(meta: DocMeta | undefined): string {
:collection="FAVORITES_COLLECTION"
:query="debouncedQuery"
:selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits"
@close="isPanelOpen = false"
/>
<div v-else-if="!isMobile" class="hidden lg:flex flex-1 items-center justify-center">
@ -533,6 +557,7 @@ function metaLocation(meta: DocMeta | undefined): string {
:collection="FAVORITES_COLLECTION"
:query="debouncedQuery"
:selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits"
@close="isPanelOpen = false"
/>
</template>