Compare commits

..

7 Commits

Author SHA1 Message Date
Gigi
104332fd94 chore: bump version to 0.10.33 2025-11-05 23:08:14 +01:00
Gigi
e736c9f5b9 Merge pull request #36 from dergigi/mobile-highlighting
Fix mobile text selection detection for highlighting
2025-11-05 23:07:33 +01:00
Gigi
103e104cb2 chore: remove unused React import from VideoEmbedProcessor 2025-11-05 23:05:14 +01:00
Gigi
5389397e9b fix(mobile): use selectionchange event for immediate text selection detection
Replace mouseup/touchend handlers with selectionchange event listener
for more reliable mobile text selection detection. This fixes the issue
where the highlight button required an extra tap to become active on
mobile devices.

- Extract selection checking logic into shared checkSelection function
- Use selectionchange event with requestAnimationFrame for immediate detection
- Remove onMouseUp and onTouchEnd props from VideoEmbedProcessor
- Simplify code by eliminating separate mouse/touch event handlers
2025-11-05 23:04:38 +01:00
Gigi
54cba2beed Merge pull request #35 from dergigi/fuzzy2
perf(highlights): optimize highlight application performance
2025-11-03 01:38:07 +01:00
Gigi
da76cb247c perf(highlights): optimize highlight application with multiple improvements
- perf: collect text nodes once instead of per highlight (O(n×m) -> O(n+m))
- fix: correct normalized index mapping algorithm for whitespace handling
- feat: allow nested mark elements for overlapping highlights
- perf: add caching for highlighted HTML results with TTL and size limits
2025-11-03 01:34:02 +01:00
Gigi
9b4a7b6263 docs: update CHANGELOG for v0.10.32 2025-11-02 23:54:07 +01:00
7 changed files with 178 additions and 54 deletions

View File

@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.10.32] - 2025-11-02
### Added
- Loading states with shimmer effect for profile lookups in articles
- localStorage caching for profile resolution with LRU eviction
- Progressive profile resolution that updates from fallback to resolved names
### Changed
- Standardized applesauce helpers for npub/nprofile detection and display
- Standardized profile display name fallbacks across codebase
- Removed 'npub1' prefix from shortened npub displays
- Improved @ prefix handling for profile mentions
- Profile fetching is now reactive (removed timeouts)
- Profile label updates are batched to prevent UI flickering
### Fixed
- Profile label updates now work correctly and preserve pending updates
- Race condition in profile label updates resolved
- React hooks exhaustive-deps warnings resolved
- Rules of Hooks violation in profile mapping
- Syntax error in RichContent try-catch structure
- Profile fetching re-checks eventStore for async profile arrivals
- LRU cache eviction handles QuotaExceededError
- Reduced markdown reprocessing to prevent flicker
- TypeScript errors in nostrUriResolver resolved
- Profile labels initialize synchronously from cache for instant display
### Performance
- Added timing metrics for profile resolution performance
- Increased remote relay timeout for profile fetches
- Batch profile label updates to prevent UI flickering
- Ensure purplepag.es relay is used for profile lookups
### Refactored
- Replaced custom NIP-19 parsing with applesauce helpers
- Standardized profile name extraction and code quality
- Standardized npub/nprofile display implementation
- Use pubkey (hex) as Map key instead of encoded nprofile/npub strings
- Standardized profile display name fallbacks
## [0.10.31] - 2025-11-02
### Changed

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.10.32",
"version": "0.10.33",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

View File

@@ -133,7 +133,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
}, [selectedUrl, title, markdown, html])
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
const { contentRef } = useHighlightInteractions({
onHighlightClick,
selectedHighlightId,
onTextSelection,
@@ -815,8 +815,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
html={finalHtml}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-markdown"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
@@ -830,8 +828,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
html={finalHtml || html || ''}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-html"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
)}

View File

@@ -1,4 +1,4 @@
import React, { useMemo, forwardRef } from 'react'
import { useMemo, forwardRef } from 'react'
import ReactPlayer from 'react-player'
import { classifyUrl } from '../utils/helpers'
@@ -6,8 +6,6 @@ interface VideoEmbedProcessorProps {
html: string
renderVideoLinksAsEmbeds: boolean
className?: string
onMouseUp?: (e: React.MouseEvent) => void
onTouchEnd?: (e: React.TouchEvent) => void
}
/**
@@ -17,9 +15,7 @@ interface VideoEmbedProcessorProps {
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
html,
renderVideoLinksAsEmbeds,
className,
onMouseUp,
onTouchEnd
className
}, ref) => {
// Process HTML and extract video URLs in a single pass to keep them in sync
const { processedHtml, videoUrls } = useMemo(() => {
@@ -109,8 +105,6 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
ref={ref}
className={className}
dangerouslySetInnerHTML={{ __html: processedHtml }}
onMouseUp={onMouseUp}
onTouchEnd={onTouchEnd}
/>
)
}
@@ -119,7 +113,7 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
return (
<div ref={ref} className={className} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd}>
<div ref={ref} className={className}>
{parts.map((part, index) => {
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
if (videoMatch) {

View File

@@ -93,26 +93,37 @@ export const useHighlightInteractions = ({
return () => clearTimeout(timeoutId)
}, [selectedHighlightId, contentVersion])
// Handle text selection (works for both mouse and touch)
const handleSelectionEnd = useCallback(() => {
setTimeout(() => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
onClearSelection?.()
return
}
// Shared function to check and handle text selection
const checkSelection = useCallback(() => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
onClearSelection?.()
return
}
const range = selection.getRangeAt(0)
const text = selection.toString().trim()
const range = selection.getRangeAt(0)
const text = selection.toString().trim()
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
onTextSelection?.(text)
} else {
onClearSelection?.()
}
}, 10)
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
onTextSelection?.(text)
} else {
onClearSelection?.()
}
}, [onTextSelection, onClearSelection])
return { contentRef, handleSelectionEnd }
// Listen to selectionchange events for immediate detection (works reliably on mobile)
useEffect(() => {
const handleSelectionChange = () => {
// Use requestAnimationFrame to ensure selection is checked after browser updates
requestAnimationFrame(checkSelection)
}
document.addEventListener('selectionchange', handleSelectionChange)
return () => {
document.removeEventListener('selectionchange', handleSelectionChange)
}
}, [checkSelection])
return { contentRef }
}

View File

@@ -64,10 +64,36 @@ export function tryMarkInTextNodes(
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
// Build normalized text while tracking original positions
let normalizedPos = 0
let prevWasWs = false
for (let i = 0; i < text.length; i++) {
const ch = text[i]
const isWs = /\s/.test(ch)
if (isWs) {
// Whitespace: count only at start of whitespace sequence
if (!prevWasWs) {
if (normalizedPos === index) {
actualIndex = i
break
}
normalizedPos++
}
prevWasWs = true
} else {
// Non-whitespace: count each character
if (normalizedPos === index) {
actualIndex = i
break
}
normalizedPos++
prevWasWs = false
}
}
// If we didn't find exact match, use last position
if (normalizedPos < index) {
actualIndex = text.length
}
}

View File

@@ -1,6 +1,54 @@
import { Highlight } from '../../types/highlights'
import { tryMarkInTextNodes } from './domUtils'
interface CacheEntry {
html: string
timestamp: number
}
// Simple in-memory cache for highlighted HTML results
const highlightCache = new Map<string, CacheEntry>()
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
const MAX_CACHE_SIZE = 50 // FIFO eviction after this many entries
/**
* Generate cache key from content and highlights
*/
function getCacheKey(html: string, highlights: Highlight[], highlightStyle: string): string {
// Create a stable key from content hash (first 200 chars) and highlight IDs
const contentHash = html.slice(0, 200).replace(/\s+/g, ' ').trim()
const highlightIds = highlights
.map(h => h.id)
.sort()
.join(',')
return `${contentHash.length}:${highlightIds}:${highlightStyle}`
}
/**
* Clean up old cache entries and enforce size limit
*/
function cleanupCache(): void {
const now = Date.now()
const entries = Array.from(highlightCache.entries())
// Remove expired entries
for (const [key, entry] of entries) {
if (now - entry.timestamp > CACHE_TTL) {
highlightCache.delete(key)
}
}
// Enforce size limit with FIFO eviction (oldest first)
if (highlightCache.size > MAX_CACHE_SIZE) {
const sortedEntries = Array.from(highlightCache.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp)
const toRemove = sortedEntries.slice(0, highlightCache.size - MAX_CACHE_SIZE)
for (const [key] of toRemove) {
highlightCache.delete(key)
}
}
}
/**
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
*/
@@ -13,19 +61,24 @@ export function applyHighlightsToHTML(
return html
}
// Check cache
const cacheKey = getCacheKey(html, highlights, highlightStyle)
const cached = highlightCache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.html
}
// Clean up cache periodically
cleanupCache()
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
// CRITICAL: Remove any existing highlight marks to start with clean HTML
// This prevents old broken highlights from corrupting the new rendering
const existingMarks = tempDiv.querySelectorAll('mark[data-highlight-id]')
existingMarks.forEach(mark => {
// Replace the mark with its text content
const textNode = document.createTextNode(mark.textContent || '')
mark.parentNode?.replaceChild(textNode, mark)
})
// Collect all text nodes once before processing highlights (performance optimization)
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)
for (const highlight of highlights) {
const searchText = highlight.content.trim()
@@ -34,14 +87,6 @@ export function applyHighlightsToHTML(
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
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
@@ -51,7 +96,14 @@ export function applyHighlightsToHTML(
}
}
const result = tempDiv.innerHTML
return tempDiv.innerHTML
// Store in cache
highlightCache.set(cacheKey, {
html: result,
timestamp: Date.now()
})
return result
}