coincide resultados con marks de detalle
This commit is contained in:
parent
6668255304
commit
f4357e01d2
|
|
@ -75,3 +75,20 @@ mark.search-match.is-current {
|
||||||
70% { box-shadow: 0 0 0 14px rgba(249, 115, 22, 0), 0 0 0 2px #8cff32 inset; }
|
70% { box-shadow: 0 0 0 14px rgba(249, 115, 22, 0), 0 0 0 2px #8cff32 inset; }
|
||||||
100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0), 0 0 0 2px #8cff32 inset; }
|
100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0), 0 0 0 2px #8cff32 inset; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Span de coincidencia en la vista de detalle de estudios Typesense.
|
||||||
|
Marca el párrafo que el usuario clickeó desde los resultados de búsqueda. */
|
||||||
|
#bible-study-search-match {
|
||||||
|
background-color: #fdff32;
|
||||||
|
color: #000;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 0 0 1px #e9ff32 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark #bible-study-search-match {
|
||||||
|
background-color: #fdff32;
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0 0 0 1px #e9ff32 inset;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ const props = defineProps<{
|
||||||
paragraphsLoading: boolean
|
paragraphsLoading: boolean
|
||||||
collection: string
|
collection: string
|
||||||
query?: string
|
query?: string
|
||||||
|
selectedHit?: TypesenseParagraphHit | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emits = defineEmits(['close'])
|
const emits = defineEmits(['close'])
|
||||||
|
|
@ -154,21 +155,8 @@ function safeLocation(): string {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Snippet resaltado por Typesense --------------------------------------
|
// ---- Utilerías de highlight ------------------------------------------------
|
||||||
|
|
||||||
function highlightedFor(hit: TypesenseParagraphHit, field: string): string | null {
|
|
||||||
const fromArr = hit.highlights?.find(h => h.field === field)
|
|
||||||
if (fromArr?.snippet) return fromArr.snippet
|
|
||||||
if (fromArr?.value) return fromArr.value
|
|
||||||
const fromObj = hit.highlight?.[field]
|
|
||||||
if (fromObj?.snippet) return fromObj.snippet
|
|
||||||
if (fromObj?.value) return fromObj.value
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Para análisis de fragmentos preferimos `value` (campo completo con marks)
|
|
||||||
// en lugar del snippet truncado, para no perder grupos de marks adyacentes
|
|
||||||
// que el snippet podría partir.
|
|
||||||
function fullHighlightedFor(hit: TypesenseParagraphHit, field: string): string | null {
|
function fullHighlightedFor(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?.value) return fromArr.value
|
||||||
|
|
@ -179,9 +167,6 @@ function fullHighlightedFor(hit: TypesenseParagraphHit, field: string): string |
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decodifica entidades HTML usando el parser nativo del navegador, sin
|
|
||||||
// depender de mantener un mapa propio. Evita falsos negativos cuando el
|
|
||||||
// fragmento extraído lleva entidades pero el body ya está renderizado.
|
|
||||||
function decodeEntities(s: string): string {
|
function decodeEntities(s: string): string {
|
||||||
if (!s || s.indexOf('&') === -1) return s
|
if (!s || s.indexOf('&') === -1) return s
|
||||||
if (typeof document === 'undefined') return s
|
if (typeof document === 'undefined') return s
|
||||||
|
|
@ -190,12 +175,6 @@ function decodeEntities(s: string): string {
|
||||||
return tmp.textContent || s
|
return tmp.textContent || s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recorre los <mark class="search-match"> del HTML resaltado por Typesense
|
|
||||||
// y devuelve los fragmentos contiguos (marks separadas solo por whitespace
|
|
||||||
// se unen como una frase; cualquier texto no-blanco entre marks rompe el
|
|
||||||
// grupo). Esto reproduce la noción de "phrase hit" del usuario:
|
|
||||||
// "<mark>programa</mark> <mark>divino</mark>" → ["programa divino"]
|
|
||||||
// "<mark>programa</mark> de Dios <mark>divino</mark>" → ["programa", "divino"]
|
|
||||||
function extractMarkFragments(html: string): string[] {
|
function extractMarkFragments(html: string): string[] {
|
||||||
if (!html) return []
|
if (!html) return []
|
||||||
const markRe = /<mark[^>]*>([\s\S]*?)<\/mark>/gi
|
const markRe = /<mark[^>]*>([\s\S]*?)<\/mark>/gi
|
||||||
|
|
@ -221,55 +200,113 @@ function extractMarkFragments(html: string): string[] {
|
||||||
return groups.map(g => g.join(' ').trim()).filter(Boolean)
|
return groups.map(g => g.join(' ').trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construye la query inicial del find-in-document a partir de los párrafos
|
function normalize(s: string): string {
|
||||||
// que devolvió Typesense, para que el resaltado del body coincida con lo
|
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||||
// que el motor realmente matcheó:
|
|
||||||
// - 1 fragmento único en todos los hits → frase completa (ej. "programa divino").
|
|
||||||
// - varios fragmentos distintos → cada uno como frase entre comillas, OR'd.
|
|
||||||
// Si no hay párrafos (p. ej. usuario sin query), cae al stripOuterQuotes(props.query).
|
|
||||||
function computeInitialQuery(paragraphs: TypesenseParagraphHit[] | undefined): string {
|
|
||||||
const fallback = stripOuterQuotes(props.query || '')
|
|
||||||
if (!paragraphs?.length) return fallback
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const fragments: string[] = []
|
|
||||||
for (const hit of paragraphs) {
|
|
||||||
const html = fullHighlightedFor(hit, 'text')
|
|
||||||
if (!html) continue
|
|
||||||
for (const f of extractMarkFragments(html)) {
|
|
||||||
const key = normalize(f)
|
|
||||||
if (!key || seen.has(key)) continue
|
|
||||||
seen.add(key)
|
|
||||||
fragments.push(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!fragments.length) return fallback
|
|
||||||
if (fragments.length === 1) return fragments[0]
|
|
||||||
// Frases más largas primero — al construir el regex en findMatchesInText,
|
|
||||||
// la alternancia se evalúa de izquierda a derecha y queremos que
|
|
||||||
// "programa divino" gane sobre "programa" cuando ambos están presentes.
|
|
||||||
fragments.sort((a, b) => b.length - a.length)
|
|
||||||
return fragments.map(f => `"${f}"`).join(' ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Find-in-document (igual que InboxActivity) ---------------------------
|
// ---- Renderizado de párrafos y scroll a coincidencia -----------------------
|
||||||
|
|
||||||
const bodyContainer = ref<HTMLElement | null>(null)
|
const paragraphsContainer = ref<HTMLElement | null>(null)
|
||||||
const scrollContainer = ref<HTMLElement | null>(null)
|
const scrollContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
function stripOuterQuotes(s: string): string {
|
function getHighlightText(): string | null {
|
||||||
return s.trim().replace(/^"+|"+$/g, '').trim()
|
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]
|
||||||
}
|
}
|
||||||
|
|
||||||
const localQuery = ref(stripOuterQuotes(props.query || ''))
|
async function highlightAndScroll() {
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const normFlat = normalize(flatText)
|
||||||
|
const normMatch = normalize(matchText)
|
||||||
|
const startIdx = normFlat.indexOf(normMatch)
|
||||||
|
if (startIdx === -1) return
|
||||||
|
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 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) {
|
||||||
|
await nextTick()
|
||||||
|
const matchEl = document.getElementById('bible-study-search-match')
|
||||||
|
if (matchEl) {
|
||||||
|
matchEl.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Find-in-document ----------------------------------------------------
|
||||||
|
|
||||||
|
const localQuery = ref((props.query || '').replace(/^"+|"+$/g, '').trim())
|
||||||
const debouncedLocalQuery = useDebounce(localQuery, 200)
|
const debouncedLocalQuery = useDebounce(localQuery, 200)
|
||||||
|
|
||||||
const currentIdx = ref(0)
|
const currentIdx = ref(0)
|
||||||
const totalMatches = ref(0)
|
const totalMatches = ref(0)
|
||||||
|
|
||||||
// Reset de contadores al cambiar de documento. localQuery se gestiona en el
|
|
||||||
// watcher de párrafos de abajo — necesitamos que el reset y el refinamiento
|
|
||||||
// vivan en un solo sitio para no pisarnos según el orden en que resuelvan
|
|
||||||
// las dos llamadas (fetchFullDocument vs fetchDocumentParagraphs).
|
|
||||||
watch(
|
watch(
|
||||||
() => props.document?.id,
|
() => props.document?.id,
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -278,30 +315,6 @@ watch(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cuando llegan los párrafos resaltados por Typesense (o cambian al re-buscar),
|
|
||||||
// recalcular localQuery a partir de los fragmentos contiguos de marks. Así el
|
|
||||||
// find-in-document busca exactamente lo que Typesense matcheó: un solo
|
|
||||||
// fragmento adyacente → frase completa; varios fragmentos → cada uno como
|
|
||||||
// frase OR'd. { immediate: true } cubre el primer render del componente.
|
|
||||||
watch(
|
|
||||||
() => props.paragraphs,
|
|
||||||
(paragraphs) => {
|
|
||||||
const next = computeInitialQuery(paragraphs)
|
|
||||||
if (next !== localQuery.value) localQuery.value = next
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Sincroniza localQuery cuando el padre cambia su query mientras el detalle
|
|
||||||
// sigue abierto y aún NO ha llegado un nuevo lote de párrafos para refinar.
|
|
||||||
// Si los párrafos llegan después, el watcher de arriba lo afina.
|
|
||||||
watch(() => props.query, (q) => {
|
|
||||||
const stripped = stripOuterQuotes(q || '')
|
|
||||||
if (stripped !== localQuery.value && !localQuery.value.includes('"')) {
|
|
||||||
localQuery.value = stripped
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function escapeRegex(s: string) {
|
function escapeRegex(s: string) {
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
}
|
}
|
||||||
|
|
@ -319,10 +332,6 @@ function normalizeWithMap(text: string): { normalized: string, map: number[] } {
|
||||||
return { normalized, map }
|
return { normalized, map }
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize(s: string): string {
|
|
||||||
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function termToRegexSource(term: string): string {
|
function termToRegexSource(term: string): string {
|
||||||
const parts = term.split(/\s+/).filter(Boolean)
|
const parts = term.split(/\s+/).filter(Boolean)
|
||||||
if (parts.length === 0) return ''
|
if (parts.length === 0) return ''
|
||||||
|
|
@ -332,9 +341,6 @@ function termToRegexSource(term: string): string {
|
||||||
|
|
||||||
function findMatchesInText(text: string, terms: string[]): Array<{ start: number, end: number }> {
|
function findMatchesInText(text: string, terms: string[]): Array<{ start: number, end: number }> {
|
||||||
if (!text || !terms.length) return []
|
if (!text || !terms.length) return []
|
||||||
// Ordenar por longitud desc para que en alternancia regex `(a|ab)`, la
|
|
||||||
// frase más larga gane sobre el prefijo. Por ej., `["programa", "programa divino"]`
|
|
||||||
// debe matchear "programa divino" como un solo hit, no como "programa" + sobra.
|
|
||||||
const sources = terms
|
const sources = terms
|
||||||
.map(t => ({ term: t, src: termToRegexSource(normalize(t)) }))
|
.map(t => ({ term: t, src: termToRegexSource(normalize(t)) }))
|
||||||
.filter(x => x.src.length > 0)
|
.filter(x => x.src.length > 0)
|
||||||
|
|
@ -369,16 +375,8 @@ function parseTerms(query: string): string[] {
|
||||||
|
|
||||||
function termsFor(query: string): string[] {
|
function termsFor(query: string): string[] {
|
||||||
if (!query) return []
|
if (!query) return []
|
||||||
// Si la query trae comillas explícitas (caso típico cuando la inicializamos
|
|
||||||
// desde los fragmentos de Typesense, ej. `"programa" "divino"` o
|
|
||||||
// `"programa divino"`), parseTerms las separa correctamente en frases.
|
|
||||||
if (query.includes('"')) return parseTerms(query)
|
if (query.includes('"')) return parseTerms(query)
|
||||||
// Si la query coincide con la del padre (sin comillas), tokenizamos como
|
const stripped = query.replace(/^"+|"+$/g, '').trim()
|
||||||
// hasta ahora — cada palabra suelta cuenta como término independiente.
|
|
||||||
if (query === (props.query || '')) return parseTerms(query)
|
|
||||||
// Query escrita por el usuario en el input local (distinta del padre y
|
|
||||||
// sin comillas) → tratarla como FRASE EXACTA, igual que antes.
|
|
||||||
const stripped = stripOuterQuotes(query)
|
|
||||||
return stripped ? [stripped] : []
|
return stripped ? [stripped] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -432,14 +430,14 @@ function highlightTextNodes(root: HTMLElement, terms: string[]): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMarks(): HTMLElement[] {
|
function getMarks(): HTMLElement[] {
|
||||||
return bodyContainer.value
|
return paragraphsContainer.value
|
||||||
? Array.from(bodyContainer.value.querySelectorAll('mark.search-match')) as HTMLElement[]
|
? Array.from(paragraphsContainer.value.querySelectorAll('mark.search-match')) as HTMLElement[]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMatchStarts(): HTMLElement[] {
|
function getMatchStarts(): HTMLElement[] {
|
||||||
return bodyContainer.value
|
return paragraphsContainer.value
|
||||||
? Array.from(bodyContainer.value.querySelectorAll('mark.search-match.match-start')) as HTMLElement[]
|
? Array.from(paragraphsContainer.value.querySelectorAll('mark.search-match.match-start')) as HTMLElement[]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -451,7 +449,6 @@ function applyCurrent(scroll = true) {
|
||||||
const target = starts[idx]
|
const target = starts[idx]
|
||||||
if (!target) return
|
if (!target) return
|
||||||
target.classList.remove('is-current')
|
target.classList.remove('is-current')
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
||||||
void target.offsetWidth
|
void target.offsetWidth
|
||||||
target.classList.add('is-current')
|
target.classList.add('is-current')
|
||||||
if (scroll) target.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
if (scroll) target.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
|
@ -472,7 +469,7 @@ function prevMatch() {
|
||||||
function clearLocalQuery() { localQuery.value = '' }
|
function clearLocalQuery() { localQuery.value = '' }
|
||||||
|
|
||||||
function applyHighlights({ query, scroll }: { query: string, scroll: boolean }) {
|
function applyHighlights({ query, scroll }: { query: string, scroll: boolean }) {
|
||||||
const el = bodyContainer.value
|
const el = paragraphsContainer.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
el.querySelectorAll('mark.search-match').forEach((m) => {
|
el.querySelectorAll('mark.search-match').forEach((m) => {
|
||||||
m.parentNode?.replaceChild(document.createTextNode(m.textContent || ''), m)
|
m.parentNode?.replaceChild(document.createTextNode(m.textContent || ''), m)
|
||||||
|
|
@ -485,18 +482,7 @@ function applyHighlights({ query, scroll }: { query: string, scroll: boolean })
|
||||||
if (count > 0) nextTick(() => applyCurrent(scroll))
|
if (count > 0) nextTick(() => applyCurrent(scroll))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderBody() {
|
|
||||||
await nextTick()
|
|
||||||
const el = bodyContainer.value
|
|
||||||
if (!el) return
|
|
||||||
el.innerHTML = props.document?.body ? ((fixLink(props.document.body) as string) || '') : ''
|
|
||||||
if (scrollContainer.value) scrollContainer.value.scrollTop = 0
|
|
||||||
applyHighlights({ query: localQuery.value, scroll: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.document?.id, renderBody, { immediate: true })
|
|
||||||
watch(debouncedLocalQuery, (q) => applyHighlights({ query: q, scroll: false }))
|
watch(debouncedLocalQuery, (q) => applyHighlights({ query: q, scroll: false }))
|
||||||
onMounted(renderBody)
|
|
||||||
|
|
||||||
function onInputKey(e: KeyboardEvent) {
|
function onInputKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
|
|
@ -507,6 +493,22 @@ function onInputKey(e: KeyboardEvent) {
|
||||||
clearLocalQuery()
|
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>
|
||||||
|
|
@ -583,7 +585,7 @@ function onInputKey(e: KeyboardEvent) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="document?.body" 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"
|
||||||
|
|
@ -622,111 +624,71 @@ function onInputKey(e: KeyboardEvent) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="scrollContainer" class="flex-1 overflow-y-auto relative bg-gray-100">
|
<div ref="scrollContainer" class="flex-1 overflow-y-auto relative bg-gray-100">
|
||||||
<!-- Loading del documento -->
|
<!-- Cargando párrafos -->
|
||||||
<div
|
<div
|
||||||
v-if="documentLoading"
|
v-if="paragraphsLoading"
|
||||||
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"
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-loader-circle" class="size-5 animate-spin" />
|
<UIcon name="i-lucide-loader-circle" class="size-5 animate-spin" />
|
||||||
Cargando documento...
|
Cargando párrafos...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Documento no encontrado -->
|
<!-- Todos los párrafos -->
|
||||||
<div
|
<div
|
||||||
v-else-if="!document"
|
v-else-if="paragraphs.length"
|
||||||
|
ref="paragraphsContainer"
|
||||||
|
class="p-1 sm:p-4 max-w-4xl m-4 sm:mx-auto sm:my-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(hit, idx) in paragraphs"
|
||||||
|
:key="idx"
|
||||||
|
:data-paragraph-number="hit.document.number"
|
||||||
|
class="bg-white rounded-lg shadow-md p-4 mb-4 last:mb-0"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<UBadge
|
||||||
|
v-if="hit.document.number"
|
||||||
|
:label="`#${hit.document.number}`"
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="info"
|
||||||
|
/>
|
||||||
|
<UBadge
|
||||||
|
v-if="hit.document.type"
|
||||||
|
:label="hit.document.type"
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="neutral"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="paragraph-html text-sm leading-relaxed text-gray-800 dark:text-gray-200"
|
||||||
|
v-html="hit.document.text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sin contenido -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
|
class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-file-x" class="size-10" />
|
<UIcon name="i-lucide-file-x" class="size-10" />
|
||||||
<p>Documento no encontrado</p>
|
<p>No hay contenido disponible para este documento.</p>
|
||||||
</div>
|
<p v-if="matchUrl">
|
||||||
|
Puedes
|
||||||
<!-- Contenido del documento -->
|
<ULink :to="matchUrl" target="_blank" class="text-primary">verlo en el sitio</ULink>.
|
||||||
<div v-else class="p-1 sm:p-4 max-w-4xl m-4 sm:mx-auto sm:my-6 flex flex-col gap-4">
|
</p>
|
||||||
|
|
||||||
<!-- Sección: párrafos coincidentes -->
|
|
||||||
<div
|
|
||||||
v-if="paragraphsLoading || paragraphs.length"
|
|
||||||
class="bg-white rounded-lg shadow-md p-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<UIcon name="i-lucide-quote" class="size-4 text-yellow-600 shrink-0" />
|
|
||||||
<span class="text-xs font-semibold text-yellow-700 dark:text-yellow-400 uppercase tracking-wide">
|
|
||||||
Párrafos coincidentes
|
|
||||||
</span>
|
|
||||||
<UBadge
|
|
||||||
v-if="paragraphs.length"
|
|
||||||
:label="paragraphs.length"
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="warning"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="paragraphsLoading"
|
|
||||||
class="flex items-center gap-2 py-4 text-sm text-muted justify-center"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
|
||||||
Cargando coincidencias...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-col divide-y divide-default">
|
|
||||||
<div
|
|
||||||
v-for="hit in paragraphs"
|
|
||||||
:key="hit.document.id"
|
|
||||||
class="py-3 first:pt-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<UBadge
|
|
||||||
v-if="hit.document.number"
|
|
||||||
:label="`#${hit.document.number}`"
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="info"
|
|
||||||
/>
|
|
||||||
<UBadge
|
|
||||||
v-if="hit.document.type"
|
|
||||||
:label="hit.document.type"
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="neutral"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="snippet-html text-sm leading-relaxed text-gray-800 dark:text-gray-200"
|
|
||||||
v-html="highlightedFor(hit, 'text') || hit.document.text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cuerpo del documento -->
|
|
||||||
<div class="bg-white rounded-lg shadow-md">
|
|
||||||
<article
|
|
||||||
v-if="document.body"
|
|
||||||
ref="bodyContainer"
|
|
||||||
class="prose prose-sm max-w-none dark:prose-invert p-4 sm:p-6 pb-24 prose-p:mb-2 text-base/7"
|
|
||||||
/>
|
|
||||||
<p v-else class="p-4 sm:p-6 text-sm text-muted">
|
|
||||||
No hay contenido disponible para este documento.
|
|
||||||
<span v-if="matchUrl">
|
|
||||||
Puedes
|
|
||||||
<ULink :to="matchUrl" target="_blank" class="text-primary">verlo en el sitio</ULink>.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.snippet-html :deep(p) {
|
.paragraph-html :deep(p) {
|
||||||
display: inline;
|
margin-bottom: 0.5rem;
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
.snippet-html :deep(br) {
|
.paragraph-html :deep(p:last-child) {
|
||||||
display: none;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,9 @@ const documentLoading = ref(false)
|
||||||
const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
|
const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
|
||||||
const paragraphsLoading = ref(false)
|
const paragraphsLoading = ref(false)
|
||||||
|
|
||||||
|
// Hit original del grupo seleccionado (contiene highlights de Typesense)
|
||||||
|
const selectedHit = ref<TypesenseParagraphHit | null>(null)
|
||||||
|
|
||||||
async function fetchFullDocument(docId: string) {
|
async function fetchFullDocument(docId: string) {
|
||||||
documentLoading.value = true
|
documentLoading.value = true
|
||||||
selectedDocument.value = null
|
selectedDocument.value = null
|
||||||
|
|
@ -264,29 +267,36 @@ async function fetchFullDocument(docId: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDocumentParagraphs(docId: string, q: string) {
|
async function fetchDocumentParagraphs(docId: string) {
|
||||||
paragraphsLoading.value = true
|
paragraphsLoading.value = true
|
||||||
selectedParagraphs.value = []
|
selectedParagraphs.value = []
|
||||||
|
const PER_PAGE = 250
|
||||||
|
let page = 1
|
||||||
|
let total = 0
|
||||||
|
const all: Array<{ document: ParagraphDoc }> = []
|
||||||
try {
|
try {
|
||||||
const res = await documentsApi.multiSearch({
|
do {
|
||||||
multiSearchParameters: {},
|
const res = await documentsApi.multiSearch({
|
||||||
multiSearchSearchesParameter: {
|
multiSearchParameters: {},
|
||||||
searches: [{
|
multiSearchSearchesParameter: {
|
||||||
collection: PARAGRAPHS_COLLECTION,
|
searches: [{
|
||||||
q: q || '*',
|
collection: PARAGRAPHS_COLLECTION,
|
||||||
queryBy: QUERY_BY,
|
q: '*',
|
||||||
filterBy: `document_id:=${docId} && ${filterBy.value}`,
|
queryBy: '',
|
||||||
perPage: 250,
|
filterBy: `document_id:=${docId} && ${filterBy.value}`,
|
||||||
page: 1,
|
perPage: PER_PAGE,
|
||||||
sortBy: 'number:asc',
|
page,
|
||||||
highlightFullFields: QUERY_BY,
|
sortBy: 'number:asc'
|
||||||
highlightFields: QUERY_BY,
|
}]
|
||||||
highlightStartTag: '<mark class="search-match">',
|
}
|
||||||
highlightEndTag: '</mark>'
|
})
|
||||||
}]
|
const result = (res?.results?.[0] as { found?: number, hits?: Array<{ document: ParagraphDoc }> } | undefined)
|
||||||
}
|
if (!result) break
|
||||||
})
|
if (page === 1) total = result.found ?? 0
|
||||||
selectedParagraphs.value = (res?.results?.[0] as { hits?: TypesenseParagraphHit[] })?.hits ?? []
|
all.push(...(result.hits ?? []))
|
||||||
|
page++
|
||||||
|
} while (all.length < total)
|
||||||
|
selectedParagraphs.value = all.map(h => ({ document: h.document }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching paragraphs', err)
|
console.error('Error fetching paragraphs', err)
|
||||||
selectedParagraphs.value = []
|
selectedParagraphs.value = []
|
||||||
|
|
@ -295,15 +305,11 @@ async function fetchDocumentParagraphs(docId: string, q: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectGroup(docId: string) {
|
async function selectGroup(group: { docId: string, firstHit: TypesenseParagraphHit }) {
|
||||||
selectedDocId.value = docId
|
selectedDocId.value = group.docId
|
||||||
fetchFullDocument(docId)
|
selectedHit.value = group.firstHit
|
||||||
if (query.value.trim()) {
|
fetchFullDocument(group.docId)
|
||||||
fetchDocumentParagraphs(docId, query.value)
|
fetchDocumentParagraphs(group.docId)
|
||||||
} else {
|
|
||||||
selectedParagraphs.value = []
|
|
||||||
paragraphsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPanelOpen = computed({
|
const isPanelOpen = computed({
|
||||||
|
|
@ -313,6 +319,7 @@ const isPanelOpen = computed({
|
||||||
selectedDocId.value = null
|
selectedDocId.value = null
|
||||||
selectedDocument.value = null
|
selectedDocument.value = null
|
||||||
selectedParagraphs.value = []
|
selectedParagraphs.value = []
|
||||||
|
selectedHit.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -324,6 +331,7 @@ watch(groupedHits, () => {
|
||||||
selectedDocId.value = null
|
selectedDocId.value = null
|
||||||
selectedDocument.value = null
|
selectedDocument.value = null
|
||||||
selectedParagraphs.value = []
|
selectedParagraphs.value = []
|
||||||
|
selectedHit.value = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -435,7 +443,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
? 'border-primary bg-primary/10'
|
? 'border-primary bg-primary/10'
|
||||||
: 'border-transparent hover:border-primary hover:bg-primary/5'
|
: 'border-transparent hover:border-primary hover:bg-primary/5'
|
||||||
]"
|
]"
|
||||||
@click="selectGroup(group.docId)"
|
@click="selectGroup(group)"
|
||||||
>
|
>
|
||||||
<!-- Título -->
|
<!-- Título -->
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
|
|
@ -502,6 +510,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
:paragraphs-loading="paragraphsLoading"
|
:paragraphs-loading="paragraphsLoading"
|
||||||
:collection="FAVORITES_COLLECTION"
|
:collection="FAVORITES_COLLECTION"
|
||||||
:query="debouncedQuery"
|
:query="debouncedQuery"
|
||||||
|
:selected-hit="selectedHit"
|
||||||
@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">
|
||||||
|
|
@ -523,6 +532,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
:paragraphs-loading="paragraphsLoading"
|
:paragraphs-loading="paragraphsLoading"
|
||||||
:collection="FAVORITES_COLLECTION"
|
:collection="FAVORITES_COLLECTION"
|
||||||
:query="debouncedQuery"
|
:query="debouncedQuery"
|
||||||
|
:selected-hit="selectedHit"
|
||||||
@close="isPanelOpen = false"
|
@close="isPanelOpen = false"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue