mirror of
https://github.com/dergigi/boris.git
synced 2025-12-27 19:44:40 +01:00
- Add three highlight levels: nostrverse (all), friends (followed), and mine (user's own) - Create contactService to fetch user's follow list from kind 3 events - Add three configurable colors in settings (purple, orange, yellow defaults) - Replace mode switcher with independent toggle buttons for each level - Update highlight rendering to apply level-specific colors using CSS custom properties - Add CSS styles for three-level highlights in both marker and underline modes - Classify highlights dynamically based on user's context and follow list - All three levels can be shown/hidden independently via toggle buttons
197 lines
6.0 KiB
TypeScript
197 lines
6.0 KiB
TypeScript
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
|
|
let index = content.indexOf(searchText, startIndex)
|
|
while (index !== -1) {
|
|
matches.push({
|
|
highlight,
|
|
startIndex: index,
|
|
endIndex: index + searchText.length
|
|
})
|
|
|
|
startIndex = index + searchText.length
|
|
index = content.indexOf(searchText, startIndex)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
const levelClass = match.highlight.level ? ` level-${match.highlight.level}` : ''
|
|
result.push(
|
|
<mark
|
|
key={`highlight-${match.highlight.id}-${match.startIndex}`}
|
|
className={`content-highlight${levelClass}`}
|
|
data-highlight-id={match.highlight.id}
|
|
data-highlight-level={match.highlight.level || 'nostrverse'}
|
|
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}</>
|
|
}
|
|
|
|
// Helper to normalize whitespace for flexible matching
|
|
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
|
|
|
|
// Helper to create a mark element for a highlight
|
|
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
|
|
const mark = document.createElement('mark')
|
|
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
|
|
mark.className = `content-highlight-${highlightStyle}${levelClass}`
|
|
mark.setAttribute('data-highlight-id', highlight.id)
|
|
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
|
|
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
|
|
mark.textContent = matchText
|
|
return mark
|
|
}
|
|
|
|
// Helper to replace text node with mark element
|
|
function replaceTextWithMark(textNode: Text, before: string, after: string, mark: HTMLElement) {
|
|
const parent = textNode.parentNode
|
|
if (!parent) return
|
|
|
|
if (before) parent.insertBefore(document.createTextNode(before), textNode)
|
|
parent.insertBefore(mark, textNode)
|
|
if (after) {
|
|
textNode.textContent = after
|
|
} else {
|
|
parent.removeChild(textNode)
|
|
}
|
|
}
|
|
|
|
// Helper to find and mark text in nodes
|
|
function tryMarkInTextNodes(
|
|
textNodes: Text[],
|
|
searchText: string,
|
|
highlight: Highlight,
|
|
useNormalized: boolean,
|
|
highlightStyle: 'marker' | 'underline' = 'marker'
|
|
): boolean {
|
|
const normalizedSearch = normalizeWhitespace(searchText)
|
|
|
|
for (const textNode of textNodes) {
|
|
const text = textNode.textContent || ''
|
|
const searchIn = useNormalized ? normalizeWhitespace(text) : text
|
|
const searchFor = useNormalized ? normalizedSearch : searchText
|
|
const index = searchIn.indexOf(searchFor)
|
|
|
|
if (index === -1) continue
|
|
|
|
console.log(`✅ Found ${useNormalized ? 'normalized' : 'exact'} match:`, text.slice(0, 50))
|
|
|
|
let actualIndex = index
|
|
if (useNormalized) {
|
|
// Map normalized index back to original text
|
|
let normalizedIdx = 0
|
|
for (let i = 0; i < text.length && normalizedIdx < index; i++) {
|
|
if (!/\s/.test(text[i]) || (i > 0 && !/\s/.test(text[i-1]))) normalizedIdx++
|
|
actualIndex = i + 1
|
|
}
|
|
}
|
|
|
|
const before = text.substring(0, actualIndex)
|
|
const match = text.substring(actualIndex, actualIndex + searchText.length)
|
|
const after = text.substring(actualIndex + searchText.length)
|
|
const mark = createMarkElement(highlight, match, highlightStyle)
|
|
|
|
replaceTextWithMark(textNode, before, after, mark)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
|
|
*/
|
|
export function applyHighlightsToHTML(html: string, highlights: Highlight[], highlightStyle: 'marker' | 'underline' = 'marker'): string {
|
|
if (!html || highlights.length === 0) return html
|
|
|
|
const tempDiv = document.createElement('div')
|
|
tempDiv.innerHTML = html
|
|
|
|
for (const highlight of highlights) {
|
|
const searchText = highlight.content.trim()
|
|
if (!searchText) continue
|
|
|
|
// Collect all text nodes
|
|
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
|
const textNodes: Text[] = []
|
|
let node: Node | null
|
|
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
|
|
|
// Try exact match first, then normalized match
|
|
tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
|
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
|
}
|
|
|
|
return tempDiv.innerHTML
|
|
}
|