diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index 67d5341e..50c5282c 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -122,6 +122,7 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { html={readerContent?.html} markdown={readerContent?.markdown} selectedUrl={selectedUrl} + highlights={highlights} />
diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index e1cc81d3..2380e271 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -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 = ({ loading, title, html, markdown, selectedUrl }) => { +const ContentPanel: React.FC = ({ + 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 (
@@ -32,17 +74,29 @@ const ContentPanel: React.FC = ({ loading, title, html, markd ) } + const hasHighlights = relevantHighlights.length > 0 + return (
- {title &&

{title}

} + {title && ( +
+

{title}

+ {hasHighlights && ( +
+ + {relevantHighlights.length} highlight{relevantHighlights.length !== 1 ? 's' : ''} +
+ )} +
+ )} {markdown ? (
- {markdown} + {highlightedMarkdown}
- ) : html ? ( -
+ ) : highlightedHTML ? ( +
) : (

No readable content found for this URL.

diff --git a/src/index.css b/src/index.css index 433e5792..8a372c5a 100644 --- a/src/index.css +++ b/src/index.css @@ -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); + } +} diff --git a/src/utils/highlightMatching.tsx b/src/utils/highlightMatching.tsx new file mode 100644 index 00000000..3f92359c --- /dev/null +++ b/src/utils/highlightMatching.tsx @@ -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( + + {highlightedText} + + ) + + 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 = `${searchText}` + + // Only replace the first occurrence to avoid duplicates + modifiedHTML = modifiedHTML.replace(searchText, markTag) + } + + return modifiedHTML +}