search/app/components/PublicationDetail.vue

1103 lines
36 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 (&nbsp;); treat as regular space so DOM text
// nodes (which keep &nbsp; as  ) match Typesense snippets (regular spaces).
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/ /g, ' ')
}
// ---- 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>
<span class="text-carpared font-semibold">
<UIcon name="ph-file-dashed" class="mr-1" />{{ document?.draft ? $t('ui.draft') : '' }}
</span>
</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
class="py-0 my-0"
>
<UPageAnchors :links="links" />
<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>