1101 lines
37 KiB
Vue
1101 lines
37 KiB
Vue
<script setup lang="ts">
|
||
import dayjs from 'dayjs'
|
||
import { useDebounce } from '@vueuse/core'
|
||
import { useFavoritesStore } from '~/stores/favorites'
|
||
import { useHistoryStore } from '~/stores/history'
|
||
import { storeToRefs } from 'pinia'
|
||
import { useSettingsStore } from '~/stores/settings'
|
||
import type { SearchHit } from '~/types'
|
||
import { pageHeader, select } from '#build/ui'
|
||
|
||
interface TypesenseHighlight {
|
||
field?: string
|
||
snippet?: string
|
||
value?: string
|
||
}
|
||
|
||
interface ParagraphDoc {
|
||
id?: string
|
||
document_id: string
|
||
text: string
|
||
raw?: string
|
||
number: number
|
||
locale: string
|
||
type: string
|
||
}
|
||
|
||
interface TypesenseParagraphHit {
|
||
document: ParagraphDoc
|
||
highlights?: TypesenseHighlight[]
|
||
highlight?: Record<string, { snippet?: string, value?: string }>
|
||
}
|
||
|
||
interface DocumentDoc {
|
||
id?: string
|
||
code: string
|
||
locale: string
|
||
type: string
|
||
title: string
|
||
timestamp: number
|
||
date: string
|
||
place?: string
|
||
city?: string
|
||
state?: string
|
||
country?: string
|
||
thumbnail?: string
|
||
files?: {
|
||
youtube?: string
|
||
video?: string
|
||
audio?: string
|
||
booklet?: string
|
||
simple?: string
|
||
}
|
||
slug?: string
|
||
body?: string
|
||
draft?: boolean
|
||
[key: string]: unknown
|
||
}
|
||
|
||
const props = defineProps<{
|
||
document: DocumentDoc | null
|
||
documentLoading: boolean
|
||
paragraphs: TypesenseParagraphHit[]
|
||
paragraphsLoading: boolean
|
||
collection: string
|
||
query?: string
|
||
selectedHit?: TypesenseParagraphHit | null
|
||
selectedMatchingHits?: TypesenseParagraphHit[] | null
|
||
accentColor?: 'green' | 'blue'
|
||
}>()
|
||
|
||
const emits = defineEmits(['close'])
|
||
|
||
const { locale } = useI18n()
|
||
const favorites = useFavoritesStore()
|
||
|
||
const iconColor = computed(() => props.accentColor === 'blue' ? 'text-carpablue' : 'text-carpagreen')
|
||
const history = useHistoryStore()
|
||
const { showParagraphNumbers } = storeToRefs(useSettingsStore())
|
||
const toast = useToast()
|
||
|
||
function toSearchHit(doc: DocumentDoc): SearchHit {
|
||
return {
|
||
_id: doc.id || doc.code || '',
|
||
id: doc.id,
|
||
title: doc.title || '',
|
||
date: doc.date,
|
||
place: doc.place,
|
||
city: doc.city,
|
||
state: doc.state,
|
||
country: doc.country,
|
||
body: doc.body,
|
||
thumbnail: doc.thumbnail,
|
||
type: doc.type,
|
||
files: doc.files,
|
||
slug: doc.slug,
|
||
...doc
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => [props.collection, props.document?.id] as const,
|
||
([collection, id]) => {
|
||
if (!collection || !id || !props.document) return
|
||
history.visit(collection, toSearchHit(props.document))
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
const isFav = computed(() => {
|
||
if (!props.collection || !props.document?.id) return false
|
||
return favorites.isFavorite(props.collection, props.document.id)
|
||
})
|
||
|
||
function onToggleFavorite() {
|
||
if (!props.collection || !props.document) return
|
||
const wasFav = isFav.value
|
||
favorites.toggle(props.collection, toSearchHit(props.document))
|
||
toast.add({
|
||
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
|
||
description: props.document.title,
|
||
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
|
||
color: wasFav ? 'neutral' : 'primary',
|
||
duration: 1800
|
||
})
|
||
}
|
||
|
||
const matchUrl = computed(() => {
|
||
const doc = props.document
|
||
if (!doc?.type) return null
|
||
const ts = doc.timestamp
|
||
? doc.timestamp * 1000
|
||
: doc.date ? new Date(doc.date).getTime() : null
|
||
if (!ts || !Number.isFinite(ts)) return null
|
||
const d = dayjs(ts)
|
||
const month = (d.month() + 1).toString().padStart(2, '0')
|
||
const slug = doc.slug && doc.slug !== 'undefined' ? doc.slug : doc.id ?? doc.code
|
||
return `https://www.carpa.com/${locale.value}/${doc.type}/${d.year()}/${month}/${slug}`
|
||
})
|
||
|
||
const fileLinks = computed(() => formatFiles(props.document?.files || {}))
|
||
|
||
function safeDate(): string {
|
||
const doc = props.document
|
||
if (!doc) return ''
|
||
const ts = doc.timestamp
|
||
? doc.timestamp
|
||
: doc.date ? Math.floor(new Date(doc.date).getTime() / 1000) : null
|
||
if (!ts) return doc.date || ''
|
||
return formatDate(ts)
|
||
}
|
||
|
||
function safeLocation(): string {
|
||
const doc = props.document
|
||
if (!doc) return ''
|
||
return formatLocation({
|
||
id: doc.id || '',
|
||
date: doc.timestamp ?? 0,
|
||
slug: doc.slug ?? '',
|
||
type: doc.type ?? '',
|
||
place: doc.place ?? '',
|
||
city: doc.city ?? '',
|
||
state: doc.state ?? '',
|
||
country: doc.country ?? '',
|
||
thumbnail: ''
|
||
})
|
||
}
|
||
|
||
// ---- 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
|
||
}
|
||
|
||
function decodeEntities(s: string): string {
|
||
if (!s || s.indexOf('&') === -1) return s
|
||
if (typeof document === 'undefined') return s
|
||
const tmp = document.createElement('div')
|
||
tmp.innerHTML = s
|
||
return tmp.textContent || s
|
||
}
|
||
|
||
// ---- 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
|
||
let m: RegExpExecArray | null
|
||
while ((m = markRe.exec(html)) !== null) {
|
||
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
|
||
}
|
||
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 {
|
||
// = 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, ' ')
|
||
}
|
||
|
||
// ---- Refs de estado ---------------------------------------------------------
|
||
|
||
const paragraphsContainer = ref<HTMLElement | null>(null)
|
||
const scrollContainer = ref<HTMLElement | null>(null)
|
||
|
||
// '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)
|
||
|
||
// ---- Limpieza de marks del DOM ----------------------------------------------
|
||
|
||
function clearMatchMarks() {
|
||
const container = paragraphsContainer.value
|
||
if (!container) return
|
||
container.querySelectorAll('mark.search-match').forEach((m) => {
|
||
const parent = m.parentNode
|
||
if (parent) {
|
||
parent.replaceChild(document.createTextNode(m.textContent || ''), m)
|
||
parent.normalize()
|
||
}
|
||
})
|
||
matchElements.value = []
|
||
currentMatchIdx.value = 0
|
||
}
|
||
|
||
// ---- 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()
|
||
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 — sin hits (browse mode) volver al inicio; con hits, ir al párrafo correcto.
|
||
if (!hasMatchingHits) {
|
||
if (scrollContainer.value) scrollContainer.value.scrollTop = 0
|
||
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' })
|
||
}
|
||
}
|
||
|
||
// ---- Estado 2: búsqueda local -----------------------------------------------
|
||
|
||
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)
|
||
|
||
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,
|
||
() => {
|
||
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()
|
||
}
|
||
document.addEventListener('selectionchange', onSelectionChange)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('selectionchange', onSelectionChange)
|
||
if (selectionTimer) clearTimeout(selectionTimer)
|
||
})
|
||
|
||
// ---- Utilerías de búsqueda local --------------------------------------------
|
||
|
||
function escapeRegex(s: string) {
|
||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
}
|
||
|
||
function normalizeWithMap(text: string): { normalized: string, map: number[] } {
|
||
let normalized = ''
|
||
const map: number[] = []
|
||
for (let i = 0; i < text.length; i++) {
|
||
// 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)
|
||
}
|
||
}
|
||
return { normalized, map }
|
||
}
|
||
|
||
function termToRegexSource(term: string): string {
|
||
const parts = term.split(/\s+/).filter(Boolean)
|
||
if (parts.length === 0) return ''
|
||
if (parts.length === 1) return escapeRegex(parts[0])
|
||
return parts.map(escapeRegex).join('\\s+')
|
||
}
|
||
|
||
function findMatchesInText(text: string, terms: string[]): Array<{ start: number, end: number }> {
|
||
if (!text || !terms.length) return []
|
||
const sources = terms
|
||
.map(t => ({ term: t, src: termToRegexSource(normalize(t)) }))
|
||
.filter(x => x.src.length > 0)
|
||
.sort((a, b) => b.term.length - a.term.length)
|
||
.map(x => x.src)
|
||
if (!sources.length) return []
|
||
const { normalized, map } = normalizeWithMap(text)
|
||
const re = new RegExp(`(${sources.join('|')})`, 'g')
|
||
const out: Array<{ start: number, end: number }> = []
|
||
let m: RegExpExecArray | null
|
||
while ((m = re.exec(normalized)) !== null) {
|
||
if (m[0].length === 0) { re.lastIndex++; continue }
|
||
const start = map[m.index] ?? text.length
|
||
const endNormIdx = m.index + m[0].length
|
||
const end = endNormIdx < map.length ? map[endNormIdx] : text.length
|
||
out.push({ start, end })
|
||
}
|
||
return out
|
||
}
|
||
|
||
function parseTerms(query: string): string[] {
|
||
if (!query) return []
|
||
const tokens: string[] = []
|
||
const tokenRe = /"([^"]+)"|(\S+)/g
|
||
for (const match of query.matchAll(tokenRe)) {
|
||
const [, phrase, word] = match
|
||
if (phrase) tokens.push(phrase.trim())
|
||
else if (word && word.length > 1) tokens.push(word)
|
||
}
|
||
return tokens.filter(Boolean)
|
||
}
|
||
|
||
function termsFor(query: string): string[] {
|
||
if (!query) return []
|
||
if (query.includes('"')) return parseTerms(query)
|
||
const stripped = query.replace(/^"+|"+$/g, '').trim()
|
||
return stripped ? [stripped] : []
|
||
}
|
||
|
||
function highlightTextNodes(root: HTMLElement, terms: string[]): number {
|
||
if (!terms.length) return 0
|
||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null)
|
||
const infos: { node: Text, start: number, end: number }[] = []
|
||
let flat = ''
|
||
let n: Node | null
|
||
while ((n = walker.nextNode())) {
|
||
const node = n as Text
|
||
const text = node.nodeValue || ''
|
||
infos.push({ node, start: flat.length, end: flat.length + text.length })
|
||
flat += text
|
||
}
|
||
if (!infos.length) return 0
|
||
|
||
const matches = findMatchesInText(flat, terms)
|
||
if (!matches.length) return 0
|
||
|
||
const matchStartByPos = new Set(matches.map(m => m.start))
|
||
|
||
for (let i = infos.length - 1; i >= 0; i--) {
|
||
const info = infos[i]
|
||
const nodeText = info.node.nodeValue || ''
|
||
const segments: { start: number, end: number, matchStart: number }[] = []
|
||
for (const match of matches) {
|
||
const segStart = Math.max(match.start, info.start) - info.start
|
||
const segEnd = Math.min(match.end, info.end) - info.start
|
||
if (segStart < segEnd) segments.push({ start: segStart, end: segEnd, matchStart: match.start })
|
||
}
|
||
if (!segments.length) continue
|
||
segments.sort((a, b) => a.start - b.start)
|
||
const frag = document.createDocumentFragment()
|
||
let cursor = 0
|
||
for (const seg of segments) {
|
||
if (seg.start > cursor) frag.appendChild(document.createTextNode(nodeText.slice(cursor, seg.start)))
|
||
const mark = document.createElement('mark')
|
||
mark.className = 'search-match'
|
||
if (matchStartByPos.has(seg.matchStart) && info.start + seg.start === seg.matchStart) {
|
||
mark.classList.add('match-start')
|
||
}
|
||
mark.textContent = nodeText.slice(seg.start, seg.end)
|
||
frag.appendChild(mark)
|
||
cursor = seg.end
|
||
}
|
||
if (cursor < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(cursor)))
|
||
info.node.parentNode?.replaceChild(frag, info.node)
|
||
}
|
||
return matches.length
|
||
}
|
||
const isPopOverOpen = ref(false)
|
||
const selectionTooltip = ref<{ x: number; y: number } | null>(null)
|
||
let selectionTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
function handleSelection(event: MouseEvent) {
|
||
const selection = window.getSelection()
|
||
if (selection && selection.toString().trim().length > 3) {
|
||
if (selectionTimer) clearTimeout(selectionTimer)
|
||
selectionTimer = setTimeout(() => {
|
||
const x = Math.min(Math.max(event.clientX, 96), window.innerWidth - 96)
|
||
selectionTooltip.value = { x, y: event.clientY }
|
||
isPopOverOpen.value = true
|
||
}, 320)
|
||
} else {
|
||
clearSelectionTooltip()
|
||
}
|
||
}
|
||
|
||
function clearSelectionTooltip() {
|
||
if (selectionTimer) { clearTimeout(selectionTimer); selectionTimer = null }
|
||
selectionTooltip.value = null
|
||
isPopOverOpen.value = false
|
||
}
|
||
|
||
function onSelectionChange() {
|
||
const sel = window.getSelection()
|
||
if (!sel || sel.toString().trim().length === 0) clearSelectionTooltip()
|
||
}
|
||
|
||
const links = computed(() => {
|
||
return [
|
||
{
|
||
label: 'Ver en sitio',
|
||
icon: 'ph-arrow-square-out',
|
||
to: matchUrl,
|
||
target: '_blank'
|
||
},
|
||
...formatFiles(props.document?.files)
|
||
]
|
||
})
|
||
|
||
const items = computed(() => {
|
||
return [
|
||
|
||
]
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<UDashboardPanel id="estudios-ts-detail">
|
||
<UDashboardNavbar :toggle="false">
|
||
<template #leading>
|
||
<UButton
|
||
icon="i-lucide-arrow-left"
|
||
color="neutral"
|
||
variant="ghost"
|
||
class="-ms-1.5"
|
||
@click="emits('close')"
|
||
/>
|
||
</template>
|
||
|
||
<template #title>
|
||
<span class="truncate font-semibold text-sm min-w-0 block" :title="document?.title">
|
||
{{ documentLoading ? 'Cargando...' : (document?.title || '—') }}
|
||
</span>
|
||
</template>
|
||
|
||
<template #right>
|
||
<UTooltip :text="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'">
|
||
<UButton
|
||
:icon="isFav ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
|
||
:color="isFav ? 'primary' : 'neutral'"
|
||
variant="ghost"
|
||
:disabled="!document"
|
||
:aria-label="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'"
|
||
@click="onToggleFavorite"
|
||
/>
|
||
</UTooltip>
|
||
</template>
|
||
</UDashboardNavbar>
|
||
|
||
<!-- Metadata -->
|
||
<div class="flex flex-col gap-3 p-4 sm:px-6 border-b border-default shadow-sm z-10">
|
||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
||
<p v-if="document?.draft" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
|
||
<UIcon name="ph:file-dashed" class="size-4 text-carpared" />
|
||
{{ $t('search.draft') }}
|
||
</p>
|
||
<p v-if="safeDate()" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
|
||
<UIcon name="ph:calendar" :class="['size-4', iconColor]" />
|
||
{{ safeDate() }}
|
||
</p>
|
||
<p v-if="document?.activity" class="text-sm flex items-center gap-1.5 text-muted shrink-0">
|
||
<UIcon name="ph:hash" :class="['size-4 shrink-0', iconColor]" />
|
||
{{ $t('search.publication') }} {{ document.activity }}
|
||
</p>
|
||
<p v-if="safeLocation()" class="text-sm flex items-center gap-1.5 text-muted min-w-0">
|
||
<UIcon name="ph:map-pin" :class="['size-4 shrink-0', iconColor]" />
|
||
<span class="truncate max-w-55">{{ safeLocation() }}</span>
|
||
</p>
|
||
</div>
|
||
<UButton
|
||
v-if="matchUrl"
|
||
:to="matchUrl"
|
||
target="_blank"
|
||
icon="i-lucide-external-link"
|
||
label="Ver en sitio"
|
||
color="primary"
|
||
variant="soft"
|
||
size="xs"
|
||
class="shrink-0"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="fileLinks.length" class="flex flex-wrap gap-2">
|
||
<UButton
|
||
v-for="(f, idx) in fileLinks"
|
||
:key="idx"
|
||
:to="f.to"
|
||
:target="f.target"
|
||
:icon="f.icon!"
|
||
:label="f.label"
|
||
color="neutral"
|
||
variant="subtle"
|
||
size="xs"
|
||
external
|
||
/>
|
||
</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="props.query || 'Buscar en este documento...'"
|
||
class="flex-1 min-w-0"
|
||
:ui="{ trailing: 'pe-1' }"
|
||
@keydown="onInputKey"
|
||
>
|
||
<template #trailing>
|
||
<UButton
|
||
v-if="localQuery"
|
||
icon="i-lucide-x"
|
||
variant="link"
|
||
aria-label="Limpiar"
|
||
@click="clearLocalQuery"
|
||
/>
|
||
</template>
|
||
</UInput>
|
||
<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="matchElements.length ? 'text-toned font-medium' : 'text-dimmed'"
|
||
>
|
||
{{ 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="!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="!matchElements.length"
|
||
aria-label="Siguiente"
|
||
color="neutral"
|
||
variant="ghost"
|
||
size="sm"
|
||
@click="nextMatch"
|
||
/>
|
||
</UTooltip>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<div ref="scrollContainer" class="flex-1 overflow-y-auto relative bg-gray-100">
|
||
<UPage class="max-w-6xl mx-auto py-0 my-0 " @mouseup="handleSelection">
|
||
<UPageBody class="bg-white p-8 rounded-xl shadow-lg">
|
||
<UPageHeader
|
||
:title="document?.title"
|
||
:description="formatSignature(document)"
|
||
class="py-0 pb-2"
|
||
>
|
||
<template #headline>
|
||
<div class="flex w-full justify-between" v-if="paragraphs.length>0">
|
||
<UBadge variant="outline" color="neutral" :class="document?.type=='activities' ? 'bg-carpagreen/20' : 'bg-carpablue/20'">{{ document?.locale.toUpperCase() }}-{{ document?.code }}</UBadge>
|
||
<div class="text-carpared font-semibold" v-if="document?.draft"><UIcon name="ph-file-dashed" class="mr-1" />{{ $t('ui.draft') }}</div>
|
||
</div>
|
||
</template>
|
||
</UPageHeader>
|
||
<div v-if="paragraphsLoading" 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" />
|
||
Cargando párrafos...
|
||
</div>
|
||
<div v-else-if="paragraphs.length" ref="paragraphsContainer">
|
||
<div class="">
|
||
<div v-for="(hit, idx) in paragraphs" :key="idx" :data-paragraph-number="hit.document.number">
|
||
<div class="grid grid-cols-1fr items-start gap-2 mb-2" :class="(showParagraphNumbers && 'grid-cols-[20px_1fr]')">
|
||
<div v-if="showParagraphNumbers" class="w-full select-none cursor-pointer flex justify-end">
|
||
<UBadge
|
||
v-if="hit.document.number"
|
||
:label="`${hit.document.number}`"
|
||
size="md"
|
||
variant="link"
|
||
class="text-gray-300 font-bold"
|
||
:class="(hit.document.type=='activities'?'hover:text-carpagreen':'hover:text-carpablue')"
|
||
@click="isPopOverOpen = !isPopOverOpen"
|
||
/>
|
||
</div>
|
||
<div class="">
|
||
<div
|
||
class="paragraph-html text-sm leading-relaxed text-gray-800 dark:text-gray-200"
|
||
v-html="hit.document.html || hit.document.text"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sin contenido -->
|
||
<div v-else 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" />
|
||
<p>No hay contenido disponible para este documento.</p>
|
||
<p v-if="matchUrl">
|
||
Puedes
|
||
<ULink :to="matchUrl" target="_blank" class="text-primary">verlo en el sitio</ULink>.
|
||
</p>
|
||
</div>
|
||
</UPageBody>
|
||
|
||
<template #right>
|
||
<UPageAside>
|
||
<UPageAnchors :links="links" v-if="paragraphs.length" />
|
||
|
||
<UAccordion :items="items">
|
||
<template #body="{ item }">
|
||
<pre>{{ item.content }}</pre>
|
||
</template>
|
||
</UAccordion>
|
||
</UPageAside>
|
||
</template>
|
||
</UPage>
|
||
</div>
|
||
|
||
<!-- Tooltip de selección -->
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="selectionTooltip"
|
||
class="fixed z-9999 pointer-events-none"
|
||
:style="{ left: selectionTooltip.x + 'px', top: (selectionTooltip.y - 12) + 'px' }"
|
||
>
|
||
<Transition name="tooltip-pop">
|
||
<div
|
||
v-if="isPopOverOpen"
|
||
class="p-2 selection-tooltip flex items-center bg-white rounded-xl shadow-2xl overflow-hidden select-none pointer-events-auto"
|
||
@mousedown.prevent
|
||
>
|
||
<!-- <UButton
|
||
variant="soft"
|
||
class="group flex flex-col items-center gap-1 px-4 py-2.5 text-blue-300 hover:bg-white/10 active:bg-white/20 transition-colors"
|
||
aria-label="Crear nota"
|
||
@click="addNote()"
|
||
>
|
||
<UIcon name="ph-note-pencil" class="size-5" />
|
||
<span class="group-hover:text-black text-[9px] font-semibold leading-none">Nota</span>
|
||
</UButton>
|
||
<div class="w-px self-stretch bg-white/10" /> -->
|
||
<!-- <UButton
|
||
variant="soft"
|
||
class="group flex flex-col items-center gap-1 px-4 py-2.5 text-green-400 hover:bg-white/10 active:bg-white/20 transition-colors"
|
||
aria-label="Resaltar"
|
||
@click="highlightParagraph()"
|
||
>
|
||
<UIcon name="ph-highlighter" class="size-5" />
|
||
<span class="group-hover:text-black text-[9px] font-semibold leading-none">Resaltar</span>
|
||
</UButton> -->
|
||
<div class="w-px self-stretch bg-white/10" />
|
||
<UButton
|
||
variant="soft"
|
||
class="cursor-pointer group flex flex-col items-center gap-1 px-4 py-2.5 text-carpared hover:bg-white/10 active:bg-white/20 transition-colors"
|
||
:aria-label="$t('ui.copy')"
|
||
@click="copyToClipboard(props.document)"
|
||
>
|
||
<UIcon name="ph-clipboard-text" class="size-7" />
|
||
<span class="group-hover:text-black text-xs font-semibold leading-none">{{ $t('ui.copy') }}</span>
|
||
</UButton>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
</Teleport>
|
||
</UDashboardPanel>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.paragraph-html :deep(p) {
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.paragraph-html :deep(p:last-child) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
/* Tooltip de selección de texto */
|
||
.selection-tooltip {
|
||
transform: translateX(-50%) translateY(-100%);
|
||
}
|
||
|
||
.tooltip-pop-enter-active {
|
||
transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.12s ease;
|
||
}
|
||
.tooltip-pop-leave-active {
|
||
transition: transform 0.12s ease-in, opacity 0.1s ease;
|
||
}
|
||
.tooltip-pop-enter-from,
|
||
.tooltip-pop-leave-to {
|
||
transform: translateX(-50%) translateY(calc(-100% + 8px));
|
||
opacity: 0;
|
||
}
|
||
</style>
|