feat: add inline highlight annotations in content panel

- Create highlightMatching utility to find and apply highlights to text/HTML
- Update ContentPanel to accept highlights and match them to current URL
- Add visual highlighting with yellow background and blue underline
- Show highlight count indicator when content has highlights
- Add hover effects and tooltips showing highlight date
- Support both HTML and markdown content highlighting

Highlighted text now appears underlined in the main content panel when
viewing URLs that have associated NIP-84 highlights.
This commit is contained in:
Gigi
2025-10-04 19:58:10 +01:00
parent 7390104414
commit 296600bb0d
4 changed files with 260 additions and 8 deletions

View File

@@ -122,6 +122,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
html={readerContent?.html}
markdown={readerContent?.markdown}
selectedUrl={selectedUrl}
highlights={highlights}
/>
</div>
<div className="pane highlights">

View File

@@ -1,8 +1,10 @@
import React from 'react'
import React, { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { applyHighlightsToText, applyHighlightsToHTML } from '../utils/highlightMatching'
interface ContentPanelProps {
loading: boolean
@@ -10,9 +12,49 @@ interface ContentPanelProps {
html?: string
markdown?: string
selectedUrl?: string
highlights?: Highlight[]
}
const ContentPanel: React.FC<ContentPanelProps> = ({ loading, title, html, markdown, selectedUrl }) => {
const ContentPanel: React.FC<ContentPanelProps> = ({
loading,
title,
html,
markdown,
selectedUrl,
highlights = []
}) => {
// Filter highlights relevant to the current URL
const relevantHighlights = useMemo(() => {
if (!selectedUrl || highlights.length === 0) return []
return highlights.filter(h => {
// Match by URL reference
if (h.urlReference && selectedUrl.includes(h.urlReference)) return true
if (h.urlReference && h.urlReference.includes(selectedUrl)) return true
// Normalize URLs for comparison (remove trailing slashes, protocols)
const normalizeUrl = (url: string) =>
url.replace(/^https?:\/\//, '').replace(/\/$/, '').toLowerCase()
const normalizedSelected = normalizeUrl(selectedUrl)
const normalizedRef = h.urlReference ? normalizeUrl(h.urlReference) : ''
return normalizedSelected === normalizedRef
})
}, [selectedUrl, highlights])
// Apply highlights to content
const highlightedHTML = useMemo(() => {
if (!html || relevantHighlights.length === 0) return html
return applyHighlightsToHTML(html, relevantHighlights)
}, [html, relevantHighlights])
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">
@@ -32,17 +74,29 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ loading, title, html, markd
)
}
const hasHighlights = relevantHighlights.length > 0
return (
<div className="reader">
{title && <h2 className="reader-title">{title}</h2>}
{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 className="reader-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
{highlightedMarkdown}
</ReactMarkdown>
</div>
) : html ? (
<div className="reader-html" dangerouslySetInnerHTML={{ __html: html }} />
) : highlightedHTML ? (
<div className="reader-html" dangerouslySetInnerHTML={{ __html: highlightedHTML }} />
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>

View File

@@ -476,8 +476,34 @@ body {
font-size: 1.2rem;
}
.reader-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.reader-title {
margin: 0 0 1rem 0;
margin: 0;
flex: 1;
}
.highlight-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: rgba(100, 108, 255, 0.1);
border: 1px solid rgba(100, 108, 255, 0.3);
border-radius: 6px;
font-size: 0.875rem;
color: #646cff;
}
.highlight-indicator svg {
font-size: 0.875rem;
}
.reader-html {
@@ -1233,3 +1259,40 @@ body {
.highlight-source svg {
font-size: 0.875rem;
}
/* Inline content highlights */
.content-highlight {
background: rgba(255, 235, 59, 0.3);
border-bottom: 2px solid #646cff;
padding: 0.125rem 0;
cursor: help;
transition: all 0.2s ease;
position: relative;
}
.content-highlight:hover {
background: rgba(255, 235, 59, 0.5);
border-bottom-color: #535bf2;
}
.reader-html .content-highlight,
.reader-markdown .content-highlight {
color: inherit;
text-decoration: none;
}
/* Ensure highlights work in both light and dark mode */
@media (prefers-color-scheme: light) {
.content-highlight {
background: rgba(255, 235, 59, 0.4);
}
.content-highlight:hover {
background: rgba(255, 235, 59, 0.6);
}
.highlight-indicator {
background: rgba(100, 108, 255, 0.15);
border-color: rgba(100, 108, 255, 0.4);
}
}

View File

@@ -0,0 +1,134 @@
import React from 'react'
import { Highlight } from '../types/highlights'
export interface HighlightMatch {
highlight: Highlight
startIndex: number
endIndex: number
}
/**
* Find all occurrences of highlight text in the content
*/
export function findHighlightMatches(
content: string,
highlights: Highlight[]
): HighlightMatch[] {
const matches: HighlightMatch[] = []
for (const highlight of highlights) {
if (!highlight.content || highlight.content.trim().length === 0) {
continue
}
const searchText = highlight.content.trim()
let startIndex = 0
// Find all occurrences of this highlight in the content
while (true) {
const index = content.indexOf(searchText, startIndex)
if (index === -1) break
matches.push({
highlight,
startIndex: index,
endIndex: index + searchText.length
})
startIndex = index + searchText.length
}
}
// Sort by start index
return matches.sort((a, b) => a.startIndex - b.startIndex)
}
/**
* Apply highlights to text content by wrapping matched text in span elements
*/
export function applyHighlightsToText(
text: string,
highlights: Highlight[]
): React.ReactNode {
const matches = findHighlightMatches(text, highlights)
if (matches.length === 0) {
return text
}
const result: React.ReactNode[] = []
let lastIndex = 0
for (let i = 0; i < matches.length; i++) {
const match = matches[i]
// Skip overlapping highlights (keep the first one)
if (match.startIndex < lastIndex) {
continue
}
// Add text before the highlight
if (match.startIndex > lastIndex) {
result.push(text.substring(lastIndex, match.startIndex))
}
// Add the highlighted text
const highlightedText = text.substring(match.startIndex, match.endIndex)
result.push(
<mark
key={`highlight-${match.highlight.id}-${match.startIndex}`}
className="content-highlight"
data-highlight-id={match.highlight.id}
title={`Highlighted ${new Date(match.highlight.created_at * 1000).toLocaleDateString()}`}
>
{highlightedText}
</mark>
)
lastIndex = match.endIndex
}
// Add remaining text
if (lastIndex < text.length) {
result.push(text.substring(lastIndex))
}
return <>{result}</>
}
/**
* Apply highlights to HTML content by injecting mark tags
*/
export function applyHighlightsToHTML(
html: string,
highlights: Highlight[]
): string {
// Extract text content from HTML for matching
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
const textContent = tempDiv.textContent || ''
const matches = findHighlightMatches(textContent, highlights)
if (matches.length === 0) {
return html
}
// For HTML, we'll wrap the highlight text with mark tags
let modifiedHTML = html
// Process matches in reverse order to maintain indices
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i]
const searchText = match.highlight.content.trim()
// Simple approach: replace text occurrences with marked version
// This is a basic implementation - a more robust solution would use DOM manipulation
const markTag = `<mark class="content-highlight" data-highlight-id="${match.highlight.id}" title="Highlighted ${new Date(match.highlight.created_at * 1000).toLocaleDateString()}">${searchText}</mark>`
// Only replace the first occurrence to avoid duplicates
modifiedHTML = modifiedHTML.replace(searchText, markTag)
}
return modifiedHTML
}