mirror of
https://github.com/dergigi/boris.git
synced 2026-01-09 09:54:34 +01:00
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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
134
src/utils/highlightMatching.tsx
Normal file
134
src/utils/highlightMatching.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user