state local state typesense
This commit is contained in:
parent
f4357e01d2
commit
65ec579de5
|
|
@ -59,6 +59,7 @@ const props = defineProps<{
|
||||||
collection: string
|
collection: string
|
||||||
query?: string
|
query?: string
|
||||||
selectedHit?: TypesenseParagraphHit | null
|
selectedHit?: TypesenseParagraphHit | null
|
||||||
|
selectedMatchingHits?: TypesenseParagraphHit[] | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emits = defineEmits(['close'])
|
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)
|
const fromArr = hit.highlights?.find(h => h.field === field)
|
||||||
if (fromArr?.value) return fromArr.value
|
|
||||||
if (fromArr?.snippet) return fromArr.snippet
|
if (fromArr?.snippet) return fromArr.snippet
|
||||||
|
if (fromArr?.value) return fromArr.value
|
||||||
const fromObj = hit.highlight?.[field]
|
const fromObj = hit.highlight?.[field]
|
||||||
if (fromObj?.value) return fromObj.value
|
|
||||||
if (fromObj?.snippet) return fromObj.snippet
|
if (fromObj?.snippet) return fromObj.snippet
|
||||||
|
if (fromObj?.value) return fromObj.value
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,146 +176,457 @@ function decodeEntities(s: string): string {
|
||||||
return tmp.textContent || s
|
return tmp.textContent || s
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMarkFragments(html: string): string[] {
|
// ---- Snippet parsing & precise mark application ----------------------------
|
||||||
if (!html) return []
|
|
||||||
|
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 markRe = /<mark[^>]*>([\s\S]*?)<\/mark>/gi
|
||||||
const marks: { text: string, start: number, end: number }[] = []
|
|
||||||
let m: RegExpExecArray | null
|
let m: RegExpExecArray | null
|
||||||
while ((m = markRe.exec(html)) !== null) {
|
while ((m = markRe.exec(html)) !== null) {
|
||||||
marks.push({
|
if (m.index > lastIdx) {
|
||||||
text: decodeEntities(m[1]),
|
const raw = html.slice(lastIdx, m.index).replace(/<[^>]*>/g, '')
|
||||||
start: m.index,
|
if (raw) segments.push({ text: decodeEntities(raw), isMarked: false })
|
||||||
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])
|
|
||||||
}
|
}
|
||||||
|
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 <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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
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<HTMLElement | null>(null)
|
const paragraphsContainer = ref<HTMLElement | null>(null)
|
||||||
const scrollContainer = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
function getHighlightText(): string | null {
|
// 'typesense' = Estado 1 (server-driven), 'local' = Estado 2 (client-driven)
|
||||||
if (!props.selectedHit) return null
|
type SearchMode = 'typesense' | 'local'
|
||||||
const html = fullHighlightedFor(props.selectedHit, 'text')
|
const searchMode = ref<SearchMode>('typesense')
|
||||||
if (!html) return null
|
const matchElements = ref<HTMLElement[]>([])
|
||||||
const fragments = extractMarkFragments(html)
|
const currentMatchIdx = ref(0)
|
||||||
if (!fragments.length) return null
|
|
||||||
fragments.sort((a, b) => b.length - a.length)
|
// ---- Limpieza de marks del DOM ----------------------------------------------
|
||||||
return fragments[0]
|
|
||||||
|
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 <mark>.
|
||||||
|
// 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()
|
await nextTick()
|
||||||
|
clearMatchMarks()
|
||||||
|
|
||||||
const container = paragraphsContainer.value
|
const container = paragraphsContainer.value
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
container.querySelectorAll('#bible-study-search-match').forEach(el => {
|
searchMode.value = 'typesense'
|
||||||
const parent = el.parentNode
|
|
||||||
if (parent) {
|
const hasMatchingHits = !!(props.selectedMatchingHits?.length)
|
||||||
parent.replaceChild(document.createTextNode(el.textContent || ''), el)
|
|
||||||
parent.normalize()
|
// 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 = ''
|
// Paso 2: marks de fondo con la frase EXACTA del query (siempre se aplican).
|
||||||
const nodeMap: { node: Text, start: number, end: number }[] = []
|
// Pre-check de texto plano para saltarse párrafos sin la frase → más rápido.
|
||||||
for (const node of textNodes) {
|
const rawPhrase = (props.query || '').replace(/^"+|"+$/g, '').trim()
|
||||||
const t = node.nodeValue || ''
|
|
||||||
nodeMap.push({ node, start: flatText.length, end: flatText.length + t.length })
|
if (rawPhrase) {
|
||||||
flatText += t
|
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)
|
// Paso 3: recolectar todos los marks en orden DOM para navegación secuencial.
|
||||||
const normMatch = normalize(matchText)
|
const domMarks = Array.from(
|
||||||
const startIdx = normFlat.indexOf(normMatch)
|
container.querySelectorAll('mark.search-match.match-start')
|
||||||
if (startIdx === -1) return
|
) as HTMLElement[]
|
||||||
const endIdx = startIdx + normMatch.length
|
matchElements.value = domMarks
|
||||||
|
currentMatchIdx.value = 0
|
||||||
|
|
||||||
let applied = false
|
// Paso 4: scroll — solo cuando hay hits de Typesense que ubican el párrafo correcto.
|
||||||
for (let i = nodeMap.length - 1; i >= 0; i--) {
|
// Sin hits, los marks se muestran para que el usuario navegue, pero no se hace scroll.
|
||||||
const info = nodeMap[i]
|
if (!hasMatchingHits) return
|
||||||
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 || ''
|
let targetMark: HTMLElement | null = snippetMarks[0] ?? null
|
||||||
const frag = document.createDocumentFragment()
|
|
||||||
let cursor = 0
|
|
||||||
|
|
||||||
if (segStart > cursor) {
|
if (!targetMark && props.selectedHit) {
|
||||||
frag.appendChild(document.createTextNode(nodeText.slice(cursor, segStart)))
|
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()
|
await nextTick()
|
||||||
const matchEl = document.getElementById('bible-study-search-match')
|
domMarks.forEach(m => m.classList.remove('is-current'))
|
||||||
if (matchEl) {
|
targetMark.classList.add('is-current')
|
||||||
matchEl.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
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 debouncedLocalQuery = useDebounce(localQuery, 200)
|
||||||
|
|
||||||
const currentIdx = ref(0)
|
watch(debouncedLocalQuery, (q) => {
|
||||||
const totalMatches = ref(0)
|
// 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(
|
watch(
|
||||||
() => props.document?.id,
|
() => props.document?.id,
|
||||||
() => {
|
() => {
|
||||||
currentIdx.value = 0
|
localQuery.value = ''
|
||||||
totalMatches.value = 0
|
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) {
|
function escapeRegex(s: string) {
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
}
|
}
|
||||||
|
|
@ -323,7 +635,9 @@ function normalizeWithMap(text: string): { normalized: string, map: number[] } {
|
||||||
let normalized = ''
|
let normalized = ''
|
||||||
const map: number[] = []
|
const map: number[] = []
|
||||||
for (let i = 0; i < text.length; i++) {
|
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++) {
|
for (let j = 0; j < norm.length; j++) {
|
||||||
normalized += norm[j]
|
normalized += norm[j]
|
||||||
map.push(i)
|
map.push(i)
|
||||||
|
|
@ -428,87 +742,6 @@ function highlightTextNodes(root: HTMLElement, terms: string[]): number {
|
||||||
}
|
}
|
||||||
return matches.length
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -585,11 +818,12 @@ onMounted(async () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Buscador interno del documento -->
|
||||||
<div v-if="paragraphs.length" class="flex items-center gap-2">
|
<div v-if="paragraphs.length" class="flex items-center gap-2">
|
||||||
<UInput
|
<UInput
|
||||||
v-model="localQuery"
|
v-model="localQuery"
|
||||||
icon="i-lucide-search"
|
icon="i-lucide-search"
|
||||||
placeholder="Buscar en este documento..."
|
:placeholder="props.query || 'Buscar en este documento...'"
|
||||||
class="flex-1 min-w-0"
|
class="flex-1 min-w-0"
|
||||||
:ui="{ trailing: 'pe-1' }"
|
:ui="{ trailing: 'pe-1' }"
|
||||||
@keydown="onInputKey"
|
@keydown="onInputKey"
|
||||||
|
|
@ -604,19 +838,27 @@ onMounted(async () => {
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</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
|
<span
|
||||||
class="text-xs tabular-nums whitespace-nowrap shrink-0 px-2 py-1 rounded-md bg-elevated border border-default"
|
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>
|
</span>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<UTooltip text="Anterior (Shift+Enter)">
|
<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>
|
||||||
<UTooltip text="Siguiente (Enter)">
|
<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>
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,11 @@ const total = ref(0)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const hasMore = computed(() => hits.value.length < total.value)
|
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.
|
// groupedHits: computed — un elemento por document_id único.
|
||||||
// El conteo de coincidencias por documento se omite en la lista para evitar
|
// 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
|
// 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 res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
||||||
const newHits = res?.hits ?? []
|
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
|
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||||
total.value = res?.found ?? hits.value.length
|
total.value = res?.found ?? hits.value.length
|
||||||
currentPage.value = page
|
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) {
|
} catch (err: unknown) {
|
||||||
if (seq !== searchSeq) return
|
if (seq !== searchSeq) return
|
||||||
console.error('Typesense error', err)
|
console.error('Typesense error', err)
|
||||||
|
|
@ -215,6 +224,20 @@ function loadMore() {
|
||||||
runSearch(query.value, true)
|
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() {
|
function retry() {
|
||||||
runSearch(query.value, false)
|
runSearch(query.value, false)
|
||||||
}
|
}
|
||||||
|
|
@ -226,6 +249,7 @@ watch(debouncedQuery, (q) => {
|
||||||
hits.value = []
|
hits.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
visibleGroupCount.value = 10
|
||||||
runSearch(q, false)
|
runSearch(q, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -240,6 +264,9 @@ const paragraphsLoading = ref(false)
|
||||||
// Hit original del grupo seleccionado (contiene highlights de Typesense)
|
// Hit original del grupo seleccionado (contiene highlights de Typesense)
|
||||||
const selectedHit = ref<TypesenseParagraphHit | null>(null)
|
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) {
|
async function fetchFullDocument(docId: string) {
|
||||||
documentLoading.value = true
|
documentLoading.value = true
|
||||||
selectedDocument.value = null
|
selectedDocument.value = null
|
||||||
|
|
@ -308,6 +335,7 @@ async function fetchDocumentParagraphs(docId: string) {
|
||||||
async function selectGroup(group: { docId: string, firstHit: TypesenseParagraphHit }) {
|
async function selectGroup(group: { docId: string, firstHit: TypesenseParagraphHit }) {
|
||||||
selectedDocId.value = group.docId
|
selectedDocId.value = group.docId
|
||||||
selectedHit.value = group.firstHit
|
selectedHit.value = group.firstHit
|
||||||
|
selectedMatchingHits.value = hits.value.filter(h => h.document.document_id === group.docId)
|
||||||
fetchFullDocument(group.docId)
|
fetchFullDocument(group.docId)
|
||||||
fetchDocumentParagraphs(group.docId)
|
fetchDocumentParagraphs(group.docId)
|
||||||
}
|
}
|
||||||
|
|
@ -320,6 +348,7 @@ const isPanelOpen = computed({
|
||||||
selectedDocument.value = null
|
selectedDocument.value = null
|
||||||
selectedParagraphs.value = []
|
selectedParagraphs.value = []
|
||||||
selectedHit.value = null
|
selectedHit.value = null
|
||||||
|
selectedMatchingHits.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -332,6 +361,7 @@ watch(groupedHits, () => {
|
||||||
selectedDocument.value = null
|
selectedDocument.value = null
|
||||||
selectedParagraphs.value = []
|
selectedParagraphs.value = []
|
||||||
selectedHit.value = null
|
selectedHit.value = null
|
||||||
|
selectedMatchingHits.value = []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -417,7 +447,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
|
: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
|
<div
|
||||||
v-if="loading && !groupedHits.length"
|
v-if="loading && !groupedHits.length"
|
||||||
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
|
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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="group in groupedHits"
|
v-for="group in visibleGroups"
|
||||||
:key="group.docId"
|
:key="group.docId"
|
||||||
class="bg-white p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
|
class="bg-white p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
|
||||||
:class="[
|
:class="[
|
||||||
|
|
@ -478,22 +508,15 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="hasMore && !loading"
|
v-if="loadingMore"
|
||||||
class="p-4 flex justify-center"
|
class="flex items-center justify-center gap-2 py-4 text-sm text-muted"
|
||||||
>
|
>
|
||||||
<UButton
|
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
||||||
variant="outline"
|
Cargando más...
|
||||||
color="neutral"
|
|
||||||
size="sm"
|
|
||||||
:loading="loadingMore"
|
|
||||||
@click="loadMore"
|
|
||||||
>
|
|
||||||
Cargar más
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
class="py-3 text-center text-xs text-dimmed"
|
||||||
>
|
>
|
||||||
No hay más resultados
|
No hay más resultados
|
||||||
|
|
@ -511,6 +534,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
:collection="FAVORITES_COLLECTION"
|
:collection="FAVORITES_COLLECTION"
|
||||||
:query="debouncedQuery"
|
:query="debouncedQuery"
|
||||||
:selected-hit="selectedHit"
|
:selected-hit="selectedHit"
|
||||||
|
:selected-matching-hits="selectedMatchingHits"
|
||||||
@close="isPanelOpen = false"
|
@close="isPanelOpen = false"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="!isMobile" class="hidden lg:flex flex-1 items-center justify-center">
|
<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"
|
:collection="FAVORITES_COLLECTION"
|
||||||
:query="debouncedQuery"
|
:query="debouncedQuery"
|
||||||
:selected-hit="selectedHit"
|
:selected-hit="selectedHit"
|
||||||
|
:selected-matching-hits="selectedMatchingHits"
|
||||||
@close="isPanelOpen = false"
|
@close="isPanelOpen = false"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue