mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b240b01ec | ||
|
|
945894e3db | ||
|
|
667397e528 | ||
|
|
e4b0d6d1cd | ||
|
|
3cdda2dcb7 | ||
|
|
876ecc808d | ||
|
|
34671bd067 | ||
|
|
a6285f6a1d | ||
|
|
36508d600a | ||
|
|
a304bb7c26 | ||
|
|
04bab96a07 | ||
|
|
22ebbff755 | ||
|
|
b43f40597f | ||
|
|
fe3af25c5f |
91
CHANGELOG.md
91
CHANGELOG.md
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.2] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Pull-to-refresh gesture on mobile for all scrollable views
|
||||
- HighlightsPanel (right sidebar) - refresh highlights for current article
|
||||
- Explore page - refresh blog posts from friends
|
||||
- Me pages (all tabs) - refresh user data
|
||||
- BookmarkList (left sidebar) - refresh bookmarks
|
||||
- Touch-only activation using coarse pointer detection
|
||||
- Visual indicator with rotating arrow and contextual messages
|
||||
- Three-dot menu for external URLs in reader (`/r/` path)
|
||||
- Open in browser, Copy URL, Share URL actions
|
||||
- Consistent with article menu functionality
|
||||
|
||||
### Changed
|
||||
|
||||
- Bookmark refresh button moved to footer alongside view mode controls
|
||||
- Last update time now shown in button tooltip
|
||||
- Cleaner UI with all controls consolidated in footer
|
||||
- Unified button styles across left and right sidebars
|
||||
- All sidebar buttons now use IconButton component for consistency
|
||||
- Removed 73 lines of redundant CSS for old button classes
|
||||
- Highlights panel buttons match bookmark sidebar styling
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reader content alignment on desktop
|
||||
- Title, summary, metadata, and body text now properly aligned
|
||||
- All reader elements now have consistent 2rem horizontal padding
|
||||
- Mobile layout retains compact padding
|
||||
- Highlight text matching with multiple improvements
|
||||
- Precise normalized-to-original character position mapping
|
||||
- Remove existing highlight marks before applying new ones
|
||||
- Robust validation and error handling for multi-node highlights
|
||||
- Prevent character spacing issues in highlighted text
|
||||
- Add text validation before applying highlights
|
||||
- Eliminate intra-word spaces in highlighted text
|
||||
|
||||
## [0.6.1] - 2025-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- Writings tab on `/me` page to display user's published articles
|
||||
- Comprehensive headline styling (h1-h6) with Tailwind typography
|
||||
- List styling for ordered and unordered lists in articles
|
||||
- Blockquote styling with indentation and italics
|
||||
- Vertical padding to blockquotes for better readability
|
||||
- Horizontal padding for reader text content on desktop
|
||||
- Drop-shadows to sidebars for visual depth
|
||||
- MutationObserver for tracking highlight DOM changes
|
||||
|
||||
### Changed
|
||||
|
||||
- Article titles now larger and more prominent
|
||||
- Article summaries now display properly in reader header
|
||||
- Zap splits settings UI with preset buttons and full-width sliders
|
||||
- Sidebars now extend to 100vh height
|
||||
- Blockquote styling simplified to minimal indent and italic
|
||||
- Improved zap splits settings visual design
|
||||
|
||||
### Fixed
|
||||
|
||||
- Horizontal overflow from code blocks and wide content on mobile
|
||||
- Settings view now mobile-friendly with proper width constraints
|
||||
- Long relay URLs no longer cause horizontal overflow on mobile
|
||||
- Sidebar/highlights toggle buttons hidden on settings/explore/me pages
|
||||
- Video titles now show filename instead of 'Error Loading Content'
|
||||
- AddBookmarkModal z-index issue fixed using React Portal
|
||||
- Highlight matching for text spanning multiple DOM nodes/inline elements
|
||||
- Highlights now appear as single continuous element across DOM nodes
|
||||
- Highlights display immediately after creation with synchronous render
|
||||
- Scroll-to-highlight functionality restored after DOM updates
|
||||
- Padding gaps around sidebars removed
|
||||
- TypeScript errors in video-meta.ts resolved
|
||||
|
||||
### Refactored
|
||||
|
||||
- Migrated entire color system to Tailwind v4 color palette
|
||||
- Migrated all CSS files (sidebar, highlights, cards, forms, reader, etc.) to Tailwind colors
|
||||
- Updated default highlight colors to yellow-400 for markers and yellow-300 for other contexts
|
||||
- Added comprehensive color system documentation (COLOR_SYSTEM.md)
|
||||
- Cleaned up legacy.css removing unused debugging styles
|
||||
|
||||
## [0.6.0] - 2025-10-13
|
||||
|
||||
### Added
|
||||
@@ -1012,7 +1096,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.5.5...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.2...HEAD
|
||||
[0.6.2]: https://github.com/dergigi/boris/compare/v0.6.1...v0.6.2
|
||||
[0.6.1]: https://github.com/dergigi/boris/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/dergigi/boris/compare/v0.5.7...v0.6.0
|
||||
[0.5.7]: https://github.com/dergigi/boris/compare/v0.5.6...v0.5.7
|
||||
[0.5.6]: https://github.com/dergigi/boris/compare/v0.5.5...v0.5.6
|
||||
[0.5.5]: https://github.com/dergigi/boris/compare/v0.5.4...v0.5.5
|
||||
[0.5.2]: https://github.com/dergigi/boris/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://github.com/dergigi/boris/compare/v0.5.0...v0.5.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
@@ -10,6 +10,8 @@ import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -48,6 +50,19 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
settings,
|
||||
isMobile = false
|
||||
}) => {
|
||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Pull-to-refresh for bookmarks
|
||||
const pullToRefreshState = usePullToRefresh(bookmarksListRef, {
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: isRefreshing || false,
|
||||
disabled: !onRefresh
|
||||
})
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
// Check if has content (text)
|
||||
@@ -124,7 +139,16 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
<div
|
||||
ref={bookmarksListRef}
|
||||
className={`bookmarks-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={isRefreshing || false}
|
||||
/>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem
|
||||
@@ -137,37 +161,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<div className="refresh-section" style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '1rem',
|
||||
marginTop: '1rem',
|
||||
borderTop: '1px solid var(--border-color)',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh bookmarks"
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
{lastFetchTime && (
|
||||
<span>
|
||||
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
|
||||
@@ -98,8 +98,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
const externalMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||
|
||||
@@ -145,15 +147,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showArticleMenu || showVideoMenu) {
|
||||
if (showArticleMenu || showVideoMenu || showExternalMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu])
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
|
||||
const readingStats = useMemo(() => {
|
||||
const content = markdown || html || ''
|
||||
@@ -280,6 +285,38 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
// External article actions
|
||||
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
||||
|
||||
const handleOpenExternalUrl = () => {
|
||||
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyExternalUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareExternalUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
|
||||
} else if (selectedUrl) {
|
||||
await navigator.clipboard.writeText(selectedUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if article is already marked as read when URL/article changes
|
||||
useEffect(() => {
|
||||
@@ -533,6 +570,47 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Article menu for external URLs */}
|
||||
{!isNostrArticle && !isExternalVideo && selectedUrl && (
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleExternalMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
|
||||
{showExternalMenu && (
|
||||
<div className="article-menu">
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleCopyExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleShareExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Article menu for nostr-native articles */}
|
||||
{isNostrArticle && currentArticle && articleLinks && (
|
||||
<div className="article-menu-container">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
@@ -8,6 +8,8 @@ import { fetchContacts } from '../services/contactService'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
@@ -18,6 +20,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const exploreContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const loadBlogPosts = async () => {
|
||||
@@ -116,7 +120,15 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
}
|
||||
|
||||
loadBlogPosts()
|
||||
}, [relayPool, activeAccount, blogPosts.length])
|
||||
}, [relayPool, activeAccount, blogPosts.length, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(exploreContainerRef, {
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
})
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
// Get the d-tag identifier
|
||||
@@ -144,7 +156,16 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div
|
||||
ref={exploreContainerRef}
|
||||
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading && pullToRefreshState.canRefresh}
|
||||
/>
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
|
||||
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
@@ -57,12 +59,24 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
}) => {
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||
const highlightsListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleToggleHighlights = () => {
|
||||
const newValue = !showHighlights
|
||||
setShowHighlights(newValue)
|
||||
onToggleHighlights?.(newValue)
|
||||
}
|
||||
|
||||
// Pull-to-refresh for highlights
|
||||
const pullToRefreshState = usePullToRefresh(highlightsListRef, {
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: loading,
|
||||
disabled: !onRefresh
|
||||
})
|
||||
|
||||
// Keep track of highlight updates
|
||||
React.useEffect(() => {
|
||||
@@ -127,7 +141,16 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list">
|
||||
<div
|
||||
ref={highlightsListRef}
|
||||
className={`highlights-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading}
|
||||
/>
|
||||
{filteredHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { HighlightVisibility } from '../HighlightsPanel'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface HighlightsPanelHeaderProps {
|
||||
loading: boolean
|
||||
@@ -32,76 +32,72 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
<div className="highlights-actions-left">
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
nostrverse: !highlightVisibility.nostrverse
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
||||
title="Toggle nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights"
|
||||
ariaLabel="Toggle nostrverse highlights"
|
||||
variant={highlightVisibility.nostrverse ? 'primary' : 'ghost'}
|
||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||
aria-label="Toggle friends highlights"
|
||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
ariaLabel="Toggle friends highlights"
|
||||
variant={highlightVisibility.friends ? 'primary' : 'ghost'}
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||
aria-label="Toggle my highlights"
|
||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
ariaLabel="Toggle my highlights"
|
||||
variant={highlightVisibility.mine ? 'primary' : 'ghost'}
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
className="refresh-highlights-btn"
|
||||
title="Refresh highlights"
|
||||
aria-label="Refresh highlights"
|
||||
ariaLabel="Refresh highlights"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
||||
</button>
|
||||
spin={loading}
|
||||
/>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={showHighlights ? faEye : faEyeSlash}
|
||||
onClick={onToggleHighlights}
|
||||
className="toggle-highlight-display-btn"
|
||||
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
>
|
||||
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
|
||||
</button>
|
||||
ariaLabel={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<IconButton
|
||||
icon={faChevronRight}
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-highlights-btn"
|
||||
title="Collapse highlights panel"
|
||||
aria-label="Collapse highlights panel"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
|
||||
</button>
|
||||
ariaLabel="Collapse highlights panel"
|
||||
variant="ghost"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ interface IconButtonProps {
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IconButtonProps> = ({
|
||||
@@ -23,7 +24,8 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
size = 33,
|
||||
disabled = false,
|
||||
spin = false,
|
||||
className = ''
|
||||
className = '',
|
||||
style
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
@@ -31,7 +33,7 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
style={{ width: size, height: size }}
|
||||
style={{ width: size, height: size, ...style }}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} spin={spin} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
@@ -21,6 +21,8 @@ import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
@@ -40,6 +42,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
const meContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
@@ -102,7 +106,15 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [relayPool, activeAccount])
|
||||
}, [relayPool, activeAccount, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(meContainerRef, {
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
})
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
setHighlights(prev => {
|
||||
@@ -301,7 +313,16 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div
|
||||
ref={meContainerRef}
|
||||
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading && pullToRefreshState.canRefresh}
|
||||
/>
|
||||
<div className="explore-header">
|
||||
{activeAccount && <AuthorCard authorPubkey={activeAccount.pubkey} />}
|
||||
|
||||
|
||||
61
src/components/PullToRefreshIndicator.tsx
Normal file
61
src/components/PullToRefreshIndicator.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faArrowDown, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface PullToRefreshIndicatorProps {
|
||||
isPulling: boolean
|
||||
pullDistance: number
|
||||
canRefresh: boolean
|
||||
isRefreshing: boolean
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
const PullToRefreshIndicator: React.FC<PullToRefreshIndicatorProps> = ({
|
||||
isPulling,
|
||||
pullDistance,
|
||||
canRefresh,
|
||||
isRefreshing,
|
||||
threshold = 80
|
||||
}) => {
|
||||
// Don't show if not pulling and not refreshing
|
||||
if (!isPulling && !isRefreshing) return null
|
||||
|
||||
const opacity = Math.min(pullDistance / threshold, 1)
|
||||
const rotation = (pullDistance / threshold) * 180
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pull-to-refresh-indicator"
|
||||
style={{
|
||||
opacity: isRefreshing ? 1 : opacity,
|
||||
transform: `translateY(${isRefreshing ? 0 : -20 + pullDistance / 2}px)`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pull-to-refresh-icon"
|
||||
style={{
|
||||
transform: isRefreshing ? 'none' : `rotate(${rotation}deg)`
|
||||
}}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowDown}
|
||||
style={{ color: canRefresh ? 'var(--accent-color, #3b82f6)' : 'var(--text-secondary)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="pull-to-refresh-text">
|
||||
{isRefreshing
|
||||
? 'Refreshing...'
|
||||
: canRefresh
|
||||
? 'Release to refresh'
|
||||
: 'Pull to refresh'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PullToRefreshIndicator
|
||||
|
||||
153
src/hooks/usePullToRefresh.ts
Normal file
153
src/hooks/usePullToRefresh.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState, RefObject } from 'react'
|
||||
import { useIsCoarsePointer } from './useMediaQuery'
|
||||
|
||||
interface UsePullToRefreshOptions {
|
||||
onRefresh: () => void | Promise<void>
|
||||
isRefreshing?: boolean
|
||||
disabled?: boolean
|
||||
threshold?: number // Distance in pixels to trigger refresh
|
||||
resistance?: number // Resistance factor (higher = harder to pull)
|
||||
}
|
||||
|
||||
interface PullToRefreshState {
|
||||
isPulling: boolean
|
||||
pullDistance: number
|
||||
canRefresh: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to enable pull-to-refresh gesture on touch devices
|
||||
* @param containerRef - Ref to the scrollable container element
|
||||
* @param options - Configuration options
|
||||
* @returns State of the pull gesture
|
||||
*/
|
||||
export function usePullToRefresh(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
options: UsePullToRefreshOptions
|
||||
): PullToRefreshState {
|
||||
const {
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
disabled = false,
|
||||
threshold = 80,
|
||||
resistance = 2.5
|
||||
} = options
|
||||
|
||||
const isTouch = useIsCoarsePointer()
|
||||
const [pullState, setPullState] = useState<PullToRefreshState>({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
|
||||
const touchStartY = useRef<number>(0)
|
||||
const startScrollTop = useRef<number>(0)
|
||||
const isDragging = useRef<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || !isTouch || disabled || isRefreshing) return
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// Only start if scrolled to top
|
||||
const scrollTop = container.scrollTop
|
||||
if (scrollTop <= 0) {
|
||||
touchStartY.current = e.touches[0].clientY
|
||||
startScrollTop.current = scrollTop
|
||||
isDragging.current = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!isDragging.current) return
|
||||
|
||||
const currentY = e.touches[0].clientY
|
||||
const deltaY = currentY - touchStartY.current
|
||||
const scrollTop = container.scrollTop
|
||||
|
||||
// Only pull down when at top and pulling down
|
||||
if (scrollTop <= 0 && deltaY > 0) {
|
||||
// Prevent default scroll behavior
|
||||
e.preventDefault()
|
||||
|
||||
// Apply resistance to make pulling feel natural
|
||||
const distance = Math.min(deltaY / resistance, threshold * 1.5)
|
||||
const canRefresh = distance >= threshold
|
||||
|
||||
setPullState({
|
||||
isPulling: true,
|
||||
pullDistance: distance,
|
||||
canRefresh
|
||||
})
|
||||
} else {
|
||||
// Reset if scrolled or pulling up
|
||||
isDragging.current = false
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = async () => {
|
||||
if (!isDragging.current) return
|
||||
|
||||
isDragging.current = false
|
||||
|
||||
if (pullState.canRefresh && !isRefreshing) {
|
||||
// Keep the indicator visible while refreshing
|
||||
setPullState(prev => ({
|
||||
...prev,
|
||||
isPulling: false
|
||||
}))
|
||||
|
||||
// Trigger refresh
|
||||
await onRefresh()
|
||||
}
|
||||
|
||||
// Reset state
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
isDragging.current = false
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
|
||||
// Add event listeners with passive: false to allow preventDefault
|
||||
container.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
container.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
container.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
container.addEventListener('touchcancel', handleTouchCancel, { passive: true })
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('touchstart', handleTouchStart)
|
||||
container.removeEventListener('touchmove', handleTouchMove)
|
||||
container.removeEventListener('touchend', handleTouchEnd)
|
||||
container.removeEventListener('touchcancel', handleTouchCancel)
|
||||
}
|
||||
}, [containerRef, isTouch, disabled, isRefreshing, threshold, resistance, onRefresh, pullState.canRefresh])
|
||||
|
||||
// Reset pull state when refresh completes
|
||||
useEffect(() => {
|
||||
if (!isRefreshing && pullState.isPulling) {
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
}, [isRefreshing, pullState.isPulling])
|
||||
|
||||
return pullState
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
@import './styles/components/reader.css';
|
||||
@import './styles/components/settings.css';
|
||||
@import './styles/components/me.css';
|
||||
@import './styles/components/pull-to-refresh.css';
|
||||
@import './styles/utils/animations.css';
|
||||
@import './styles/utils/utilities.css';
|
||||
@import './styles/utils/legacy.css';
|
||||
|
||||
53
src/styles/components/pull-to-refresh.css
Normal file
53
src/styles/components/pull-to-refresh.css
Normal file
@@ -0,0 +1,53 @@
|
||||
/* Pull-to-refresh indicator styles */
|
||||
.pull-to-refresh-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.pull-to-refresh-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pull-to-refresh-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
background: var(--background-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Container needs relative positioning for absolute indicator */
|
||||
.pull-to-refresh-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ensure smooth transitions during pull */
|
||||
.pull-to-refresh-container.is-pulling {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,12 @@
|
||||
|
||||
/* Desktop: increase horizontal padding for text content */
|
||||
@media (min-width: 769px) {
|
||||
.reader-html, .reader-markdown {
|
||||
.reader-header,
|
||||
.reader-summary-below-image,
|
||||
.reader-html,
|
||||
.reader-markdown,
|
||||
.article-menu-container,
|
||||
.mark-as-read-container {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
@@ -74,38 +74,6 @@
|
||||
|
||||
/* Three-level highlight toggles */
|
||||
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
|
||||
.highlight-level-toggles .level-toggle-btn { background: none; border: none; color: rgb(161 161 170); /* zinc-400 */ cursor: pointer; padding: 0.375rem 0.5rem; border-radius: 3px; transition: all 0.2s; font-size: 0.9rem; }
|
||||
.highlight-level-toggles .level-toggle-btn:hover { background: rgba(255, 255, 255, 0.1); }
|
||||
.highlight-level-toggles .level-toggle-btn.active { background: rgba(255, 255, 255, 0.1); opacity: 1; }
|
||||
.highlight-level-toggles .level-toggle-btn:not(.active) { opacity: 0.4; }
|
||||
.highlight-level-toggles .level-toggle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.highlight-level-toggles .level-toggle-btn:disabled:hover { background: none; }
|
||||
|
||||
.refresh-highlights-btn,
|
||||
.toggle-highlight-display-btn,
|
||||
.toggle-highlights-btn {
|
||||
background: transparent;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
border: 1px solid rgb(82 82 91); /* zinc-600 */
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.refresh-highlights-btn:hover,
|
||||
.toggle-highlight-display-btn:hover,
|
||||
.toggle-highlights-btn:hover { background: rgb(39 39 42); /* zinc-800 */ color: rgb(255 255 255); /* white */ }
|
||||
.refresh-highlights-btn:active,
|
||||
.toggle-highlight-display-btn:active,
|
||||
.toggle-highlights-btn:active { transform: translateY(1px); }
|
||||
.refresh-highlights-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.refresh-highlights-btn:disabled:hover { background: transparent; color: rgb(228 228 231); /* zinc-200 */ }
|
||||
|
||||
.highlights-loading,
|
||||
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: rgb(161 161 170); /* zinc-400 */ text-align: center; gap: 0.5rem; }
|
||||
|
||||
@@ -74,6 +74,13 @@ export function tryMarkInTextNodes(
|
||||
const before = text.substring(0, actualIndex)
|
||||
const match = text.substring(actualIndex, actualIndex + searchText.length)
|
||||
const after = text.substring(actualIndex + searchText.length)
|
||||
|
||||
// Validate the match makes sense (not just whitespace or empty)
|
||||
if (!match || match.trim().length === 0) {
|
||||
console.warn('Invalid match (empty or whitespace only)')
|
||||
continue
|
||||
}
|
||||
|
||||
const mark = createMarkElement(highlight, match, highlightStyle)
|
||||
|
||||
replaceTextWithMark(textNode, before, after, mark)
|
||||
@@ -117,13 +124,81 @@ function tryMultiNodeMatch(
|
||||
|
||||
// Map normalized index back to original if needed
|
||||
let startIndex = matchIndex
|
||||
let endIndex = matchIndex + searchText.length
|
||||
let endIndex = matchIndex + searchFor.length
|
||||
|
||||
if (useNormalized) {
|
||||
// This is a simplified mapping - for normalized matches we approximate
|
||||
const ratio = combinedText.length / searchIn.length
|
||||
startIndex = Math.floor(matchIndex * ratio)
|
||||
endIndex = Math.min(combinedText.length, startIndex + searchText.length)
|
||||
// Build precise mapping by walking original text and advancing a normalized counter
|
||||
const endPos = matchIndex + searchFor.length // end position in normalized text (exclusive)
|
||||
let normPos = 0
|
||||
let foundStart = false
|
||||
let foundEnd = false
|
||||
let startNode: Text | null = null
|
||||
let startOffset = 0
|
||||
let endNode: Text | null = null
|
||||
let endOffset = 0
|
||||
|
||||
for (const nodeInfo of nodeMap) {
|
||||
const text = nodeInfo.originalText
|
||||
let prevWasWs = false
|
||||
for (let i = 0; i < text.length && (!foundStart || !foundEnd); i++) {
|
||||
const ch = text[i]
|
||||
const isWs = /\s/.test(ch)
|
||||
|
||||
if (isWs) {
|
||||
if (!prevWasWs) {
|
||||
// This whitespace sequence counts as one in normalized text
|
||||
if (!foundStart && normPos === matchIndex) {
|
||||
startNode = nodeInfo.node
|
||||
startOffset = i
|
||||
foundStart = true
|
||||
}
|
||||
if (!foundEnd && normPos === endPos) {
|
||||
endNode = nodeInfo.node
|
||||
endOffset = i
|
||||
foundEnd = true
|
||||
}
|
||||
normPos++
|
||||
}
|
||||
prevWasWs = true
|
||||
} else {
|
||||
if (!foundStart && normPos === matchIndex) {
|
||||
startNode = nodeInfo.node
|
||||
startOffset = i
|
||||
foundStart = true
|
||||
}
|
||||
normPos++
|
||||
if (!foundEnd && normPos === endPos) {
|
||||
endNode = nodeInfo.node
|
||||
endOffset = i + 1 // end after this character
|
||||
foundEnd = true
|
||||
}
|
||||
prevWasWs = false
|
||||
}
|
||||
}
|
||||
if (foundStart && foundEnd) break
|
||||
}
|
||||
|
||||
if (!foundStart || !foundEnd || !startNode || !endNode) {
|
||||
console.warn('Failed to map normalized positions to nodes', { matchIndex, endPos, normPos })
|
||||
return false
|
||||
}
|
||||
|
||||
// Set indices relative to combinedText by reconstructing start/end using nodeMap
|
||||
const startNodeInfo = nodeMap.find(n => n.node === startNode)!
|
||||
const endNodeInfo = nodeMap.find(n => n.node === endNode)!
|
||||
startIndex = startNodeInfo.start + startOffset
|
||||
endIndex = endNodeInfo.start + endOffset
|
||||
|
||||
if (startIndex < 0 || endIndex <= startIndex || endIndex > combinedText.length) {
|
||||
console.warn('Mapped indices invalid', { startIndex, endIndex, combinedTextLength: combinedText.length })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate indices
|
||||
if (startIndex < 0 || endIndex > combinedText.length || startIndex >= endIndex) {
|
||||
console.warn('Invalid highlight range:', { startIndex, endIndex, combinedTextLength: combinedText.length })
|
||||
return false
|
||||
}
|
||||
|
||||
// Find which nodes contain the match
|
||||
@@ -133,37 +208,82 @@ function tryMultiNodeMatch(
|
||||
if (startIndex < nodeInfo.end && endIndex > nodeInfo.start) {
|
||||
const nodeStart = Math.max(0, startIndex - nodeInfo.start)
|
||||
const nodeEnd = Math.min(nodeInfo.originalText.length, endIndex - nodeInfo.start)
|
||||
|
||||
// Validate node offsets
|
||||
if (nodeStart < 0 || nodeEnd > nodeInfo.originalText.length || nodeStart > nodeEnd) {
|
||||
console.warn('Invalid node offsets:', { nodeStart, nodeEnd, nodeLength: nodeInfo.originalText.length })
|
||||
continue
|
||||
}
|
||||
|
||||
affectedNodes.push({ node: nodeInfo.node, startOffset: nodeStart, endOffset: nodeEnd })
|
||||
}
|
||||
}
|
||||
|
||||
if (affectedNodes.length === 0) return false
|
||||
if (affectedNodes.length === 0) {
|
||||
console.warn('No affected nodes found for highlight')
|
||||
return false
|
||||
}
|
||||
|
||||
// Create a Range to wrap the entire selection in a single mark element
|
||||
const range = document.createRange()
|
||||
const firstNode = affectedNodes[0]
|
||||
const lastNode = affectedNodes[affectedNodes.length - 1]
|
||||
|
||||
range.setStart(firstNode.node, firstNode.startOffset)
|
||||
range.setEnd(lastNode.node, lastNode.endOffset)
|
||||
|
||||
// Extract the content from the range
|
||||
const extractedContent = range.extractContents()
|
||||
|
||||
// Create a single mark element
|
||||
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()}`)
|
||||
|
||||
// Append the extracted content to the mark
|
||||
mark.appendChild(extractedContent)
|
||||
|
||||
// Insert the mark at the range position
|
||||
range.insertNode(mark)
|
||||
|
||||
return true
|
||||
try {
|
||||
// Create a Range to wrap the entire selection in a single mark element
|
||||
const range = document.createRange()
|
||||
const firstNode = affectedNodes[0]
|
||||
const lastNode = affectedNodes[affectedNodes.length - 1]
|
||||
|
||||
range.setStart(firstNode.node, firstNode.startOffset)
|
||||
range.setEnd(lastNode.node, lastNode.endOffset)
|
||||
|
||||
// Verify the range isn't collapsed or invalid
|
||||
if (range.collapsed) {
|
||||
console.warn('Range is collapsed, skipping highlight')
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the text content before extraction to verify it matches
|
||||
const rangeText = range.toString()
|
||||
const normalizedRangeText = normalizeWhitespace(rangeText)
|
||||
const normalizedSearchText = normalizeWhitespace(searchText)
|
||||
|
||||
// Validate that the extracted text matches what we're searching for
|
||||
if (!rangeText.includes(searchText) &&
|
||||
!normalizedRangeText.includes(normalizedSearchText) &&
|
||||
normalizedRangeText !== normalizedSearchText) {
|
||||
console.warn('Range text does not match search text:', {
|
||||
rangeText: rangeText.substring(0, 100),
|
||||
searchText: searchText.substring(0, 100),
|
||||
rangeLength: rangeText.length,
|
||||
searchLength: searchText.length
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract the content from the range
|
||||
const extractedContent = range.extractContents()
|
||||
|
||||
// Verify we actually extracted something
|
||||
if (!extractedContent || extractedContent.childNodes.length === 0) {
|
||||
console.warn('No content extracted from range')
|
||||
return false
|
||||
}
|
||||
|
||||
// Create a single mark element
|
||||
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()}`)
|
||||
|
||||
// Append the extracted content to the mark
|
||||
mark.appendChild(extractedContent)
|
||||
|
||||
// Insert the mark at the range position
|
||||
range.insertNode(mark)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error applying multi-node highlight:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@ export function applyHighlightsToHTML(
|
||||
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)
|
||||
})
|
||||
|
||||
console.log('🧹 Removed', existingMarks.length, 'existing highlight marks')
|
||||
|
||||
let appliedCount = 0
|
||||
|
||||
for (const highlight of highlights) {
|
||||
|
||||
Reference in New Issue
Block a user