mirror of
https://github.com/dergigi/boris.git
synced 2025-12-31 05:24:36 +01:00
- Clicking a highlight in the main text scrolls to it in the sidebar - Selected highlight is visually highlighted with border and shadow - Add selectedHighlightId state management in Bookmarks component - Add click handlers to mark elements in ContentPanel - Add isSelected prop to HighlightItem with scroll-into-view - Add CSS styles for selected highlight state - Set cursor to pointer on clickable highlights Users can now click on highlighted text to jump to the corresponding highlight in the right sidebar for easy navigation.
223 lines
7.0 KiB
TypeScript
223 lines
7.0 KiB
TypeScript
import React, { useMemo, useEffect, useRef } from 'react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
import { faSpinner, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
|
import { Highlight } from '../types/highlights'
|
|
import { applyHighlightsToHTML } from '../utils/highlightMatching'
|
|
|
|
interface ContentPanelProps {
|
|
loading: boolean
|
|
title?: string
|
|
html?: string
|
|
markdown?: string
|
|
selectedUrl?: string
|
|
highlights?: Highlight[]
|
|
showUnderlines?: boolean
|
|
onHighlightClick?: (highlightId: string) => void
|
|
}
|
|
|
|
const ContentPanel: React.FC<ContentPanelProps> = ({
|
|
loading,
|
|
title,
|
|
html,
|
|
markdown,
|
|
selectedUrl,
|
|
highlights = [],
|
|
showUnderlines = true,
|
|
onHighlightClick
|
|
}) => {
|
|
const contentRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Filter highlights relevant to the current URL
|
|
const relevantHighlights = useMemo(() => {
|
|
if (!selectedUrl || highlights.length === 0) {
|
|
console.log('🔍 No highlights to filter:', { selectedUrl, highlightsCount: highlights.length })
|
|
return []
|
|
}
|
|
|
|
// Normalize URLs for comparison (remove trailing slashes, protocols, www, query params, fragments)
|
|
const normalizeUrl = (url: string) => {
|
|
try {
|
|
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
|
|
// Get just the hostname + pathname, remove trailing slash
|
|
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
|
|
} catch {
|
|
// Fallback for invalid URLs
|
|
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
|
|
}
|
|
}
|
|
|
|
const normalizedSelected = normalizeUrl(selectedUrl)
|
|
console.log('🔍 Normalized selected URL:', normalizedSelected)
|
|
|
|
const filtered = highlights.filter(h => {
|
|
if (!h.urlReference) {
|
|
console.log('⚠️ Highlight has no URL reference:', h.id.slice(0, 8))
|
|
return false
|
|
}
|
|
|
|
const normalizedRef = normalizeUrl(h.urlReference)
|
|
const matches = normalizedSelected === normalizedRef ||
|
|
normalizedSelected.includes(normalizedRef) ||
|
|
normalizedRef.includes(normalizedSelected)
|
|
|
|
console.log('🔍 URL comparison:', {
|
|
highlightId: h.id.slice(0, 8),
|
|
originalRef: h.urlReference,
|
|
normalizedRef,
|
|
normalizedSelected,
|
|
matches
|
|
})
|
|
|
|
return matches
|
|
})
|
|
|
|
console.log('🔍 Filtered highlights:', {
|
|
selectedUrl,
|
|
totalHighlights: highlights.length,
|
|
relevantHighlights: filtered.length,
|
|
highlights: filtered.map(h => ({
|
|
id: h.id.slice(0, 8),
|
|
urlRef: h.urlReference,
|
|
content: h.content.slice(0, 50)
|
|
}))
|
|
})
|
|
|
|
return filtered
|
|
}, [selectedUrl, highlights])
|
|
|
|
// Apply highlights after DOM is rendered
|
|
useEffect(() => {
|
|
// Skip if no content or underlines are hidden
|
|
if ((!html && !markdown) || !showUnderlines) {
|
|
console.log('⚠️ Skipping highlight application:', {
|
|
reason: (!html && !markdown) ? 'no content' : 'underlines hidden',
|
|
hasHtml: !!html,
|
|
hasMarkdown: !!markdown
|
|
})
|
|
|
|
// If underlines are hidden, remove any existing highlights
|
|
if (!showUnderlines && contentRef.current) {
|
|
const marks = contentRef.current.querySelectorAll('mark.content-highlight')
|
|
marks.forEach(mark => {
|
|
const text = mark.textContent || ''
|
|
const textNode = document.createTextNode(text)
|
|
mark.parentNode?.replaceChild(textNode, mark)
|
|
})
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Skip if no relevant highlights
|
|
if (relevantHighlights.length === 0) {
|
|
console.log('⚠️ No relevant highlights to apply')
|
|
return
|
|
}
|
|
|
|
console.log('🔍 Scheduling highlight application:', {
|
|
relevantHighlightsCount: relevantHighlights.length,
|
|
highlights: relevantHighlights.map(h => h.content.slice(0, 50)),
|
|
hasHtml: !!html,
|
|
hasMarkdown: !!markdown
|
|
})
|
|
|
|
// Use requestAnimationFrame to ensure DOM is fully rendered
|
|
const rafId = requestAnimationFrame(() => {
|
|
if (!contentRef.current) {
|
|
console.log('⚠️ contentRef not available after RAF')
|
|
return
|
|
}
|
|
|
|
console.log('🔍 Applying highlights to rendered DOM')
|
|
|
|
const originalHTML = contentRef.current.innerHTML
|
|
const highlightedHTML = applyHighlightsToHTML(originalHTML, relevantHighlights)
|
|
|
|
if (originalHTML !== highlightedHTML) {
|
|
console.log('✅ Applied highlights to DOM')
|
|
contentRef.current.innerHTML = highlightedHTML
|
|
|
|
// Add click handlers to all highlight marks
|
|
if (onHighlightClick) {
|
|
const marks = contentRef.current.querySelectorAll('mark.content-highlight')
|
|
marks.forEach(mark => {
|
|
const highlightId = mark.getAttribute('data-highlight-id')
|
|
if (highlightId) {
|
|
mark.addEventListener('click', () => {
|
|
onHighlightClick(highlightId)
|
|
})
|
|
;(mark as HTMLElement).style.cursor = 'pointer'
|
|
}
|
|
})
|
|
}
|
|
} else {
|
|
console.log('⚠️ No changes made to DOM')
|
|
}
|
|
})
|
|
|
|
return () => cancelAnimationFrame(rafId)
|
|
}, [relevantHighlights, html, markdown, showUnderlines, onHighlightClick])
|
|
|
|
const highlightedMarkdown = useMemo(() => {
|
|
if (!markdown || relevantHighlights.length === 0) return markdown
|
|
// For markdown, we'll apply highlights after rendering
|
|
return markdown
|
|
}, [markdown, relevantHighlights])
|
|
|
|
if (!selectedUrl) {
|
|
return (
|
|
<div className="reader empty">
|
|
<p>Select a bookmark to read its content.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="reader loading">
|
|
<div className="loading-spinner">
|
|
<FontAwesomeIcon icon={faSpinner} spin />
|
|
<span>Loading content…</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const hasHighlights = relevantHighlights.length > 0
|
|
|
|
return (
|
|
<div className="reader">
|
|
{title && (
|
|
<div className="reader-header">
|
|
<h2 className="reader-title">{title}</h2>
|
|
{hasHighlights && (
|
|
<div className="highlight-indicator">
|
|
<FontAwesomeIcon icon={faHighlighter} />
|
|
<span>{relevantHighlights.length} highlight{relevantHighlights.length !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{markdown ? (
|
|
<div ref={contentRef} className="reader-markdown">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
{highlightedMarkdown}
|
|
</ReactMarkdown>
|
|
</div>
|
|
) : html ? (
|
|
<div ref={contentRef} className="reader-html" dangerouslySetInnerHTML={{ __html: html }} />
|
|
) : (
|
|
<div className="reader empty">
|
|
<p>No readable content found for this URL.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ContentPanel
|
|
|
|
|