|
|
|
@ -9,6 +9,30 @@ import PostEditor from '@/components/PostEditor' |
|
|
|
import { HighlightData } from '@/components/PostEditor/HighlightEditor' |
|
|
|
import { HighlightData } from '@/components/PostEditor/HighlightEditor' |
|
|
|
import { cn } from '@/lib/utils' |
|
|
|
import { cn } from '@/lib/utils' |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
|
|
* Convert HTML to plain text by extracting text content and cleaning up whitespace |
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
function htmlToPlainText(html: string): string { |
|
|
|
|
|
|
|
if (!html) return '' |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Create a temporary DOM element to extract text content
|
|
|
|
|
|
|
|
const temp = document.createElement('div') |
|
|
|
|
|
|
|
temp.innerHTML = html |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Get text content and clean up whitespace
|
|
|
|
|
|
|
|
let text = temp.textContent || temp.innerText || '' |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Clean up multiple consecutive newlines and whitespace
|
|
|
|
|
|
|
|
text = text |
|
|
|
|
|
|
|
.replace(/\n{3,}/g, '\n\n') // Replace 3+ newlines with 2
|
|
|
|
|
|
|
|
.replace(/[ \t]+/g, ' ') // Replace multiple spaces/tabs with single space
|
|
|
|
|
|
|
|
.replace(/ \n/g, '\n') // Remove spaces before newlines
|
|
|
|
|
|
|
|
.replace(/\n /g, '\n') // Remove spaces after newlines
|
|
|
|
|
|
|
|
.trim() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return text |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
export default function RssFeedItem({ item, className }: { item: TRssFeedItem; className?: string }) { |
|
|
|
export default function RssFeedItem({ item, className }: { item: TRssFeedItem; className?: string }) { |
|
|
|
const { t } = useTranslation() |
|
|
|
const { t } = useTranslation() |
|
|
|
const { pubkey, checkLogin } = useNostr() |
|
|
|
const { pubkey, checkLogin } = useNostr() |
|
|
|
@ -26,17 +50,75 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c |
|
|
|
useEffect(() => { |
|
|
|
useEffect(() => { |
|
|
|
const handleSelection = () => { |
|
|
|
const handleSelection = () => { |
|
|
|
const selection = window.getSelection() |
|
|
|
const selection = window.getSelection() |
|
|
|
if (!selection || selection.isCollapsed || !contentRef.current) { |
|
|
|
if (!selection || selection.rangeCount === 0) { |
|
|
|
setShowHighlightButton(false) |
|
|
|
setShowHighlightButton(false) |
|
|
|
setSelectedText('') |
|
|
|
setSelectedText('') |
|
|
|
|
|
|
|
setSelectionPosition(null) |
|
|
|
return |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Check if selection is within this item's content
|
|
|
|
|
|
|
|
const range = selection.getRangeAt(0) |
|
|
|
const range = selection.getRangeAt(0) |
|
|
|
if (!contentRef.current.contains(range.commonAncestorContainer)) { |
|
|
|
|
|
|
|
|
|
|
|
// Check if selection is collapsed (no actual selection)
|
|
|
|
|
|
|
|
if (selection.isCollapsed || range.collapsed) { |
|
|
|
|
|
|
|
setShowHighlightButton(false) |
|
|
|
|
|
|
|
setSelectedText('') |
|
|
|
|
|
|
|
setSelectionPosition(null) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check if contentRef exists
|
|
|
|
|
|
|
|
if (!contentRef.current) { |
|
|
|
|
|
|
|
setShowHighlightButton(false) |
|
|
|
|
|
|
|
setSelectedText('') |
|
|
|
|
|
|
|
setSelectionPosition(null) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check if selection is within this item's content
|
|
|
|
|
|
|
|
// Use a more reliable containment check
|
|
|
|
|
|
|
|
const commonAncestor = range.commonAncestorContainer |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check if the common ancestor is within our content element
|
|
|
|
|
|
|
|
// Handle both Element and Text nodes
|
|
|
|
|
|
|
|
let isSelectionInContent = false |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (contentRef.current) { |
|
|
|
|
|
|
|
// For Element nodes, use contains directly
|
|
|
|
|
|
|
|
if (commonAncestor.nodeType === Node.ELEMENT_NODE) { |
|
|
|
|
|
|
|
isSelectionInContent = contentRef.current.contains(commonAncestor as Element) |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
// For Text nodes, check if the parent element is contained
|
|
|
|
|
|
|
|
const parentElement = commonAncestor.parentElement |
|
|
|
|
|
|
|
if (parentElement) { |
|
|
|
|
|
|
|
isSelectionInContent = contentRef.current.contains(parentElement) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Also check if the range intersects with our content
|
|
|
|
|
|
|
|
if (!isSelectionInContent) { |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
const contentRect = contentRef.current.getBoundingClientRect() |
|
|
|
|
|
|
|
const rangeRect = range.getBoundingClientRect() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check if ranges overlap
|
|
|
|
|
|
|
|
isSelectionInContent = !( |
|
|
|
|
|
|
|
rangeRect.bottom < contentRect.top || |
|
|
|
|
|
|
|
rangeRect.top > contentRect.bottom || |
|
|
|
|
|
|
|
rangeRect.right < contentRect.left || |
|
|
|
|
|
|
|
rangeRect.left > contentRect.right |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
} catch { |
|
|
|
|
|
|
|
// If getBoundingClientRect fails, fall back to false
|
|
|
|
|
|
|
|
isSelectionInContent = false |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!isSelectionInContent) { |
|
|
|
setShowHighlightButton(false) |
|
|
|
setShowHighlightButton(false) |
|
|
|
setSelectedText('') |
|
|
|
setSelectedText('') |
|
|
|
|
|
|
|
setSelectionPosition(null) |
|
|
|
return |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@ -54,30 +136,53 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c |
|
|
|
} else { |
|
|
|
} else { |
|
|
|
setShowHighlightButton(false) |
|
|
|
setShowHighlightButton(false) |
|
|
|
setSelectedText('') |
|
|
|
setSelectedText('') |
|
|
|
|
|
|
|
setSelectionPosition(null) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const handleMouseUp = () => { |
|
|
|
const handleMouseUp = (e: MouseEvent) => { |
|
|
|
|
|
|
|
// Don't process if clicking on the highlight button itself
|
|
|
|
|
|
|
|
if ((e.target as HTMLElement).closest('.highlight-button-container')) { |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Delay to allow selection to complete
|
|
|
|
// Delay to allow selection to complete
|
|
|
|
if (selectionTimeoutRef.current) { |
|
|
|
if (selectionTimeoutRef.current) { |
|
|
|
clearTimeout(selectionTimeoutRef.current) |
|
|
|
clearTimeout(selectionTimeoutRef.current) |
|
|
|
} |
|
|
|
} |
|
|
|
selectionTimeoutRef.current = setTimeout(handleSelection, 100) |
|
|
|
selectionTimeoutRef.current = setTimeout(handleSelection, 50) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const handleClick = (e: MouseEvent) => { |
|
|
|
const handleClick = (e: MouseEvent) => { |
|
|
|
// Hide button if clicking outside the selection area
|
|
|
|
// Hide button if clicking outside the selection area and not on the button itself
|
|
|
|
if (showHighlightButton && !(e.target as HTMLElement).closest('.highlight-button-container')) { |
|
|
|
const target = e.target as HTMLElement |
|
|
|
setShowHighlightButton(false) |
|
|
|
if (showHighlightButton && !target.closest('.highlight-button-container')) { |
|
|
|
|
|
|
|
// Check if there's still a valid selection
|
|
|
|
|
|
|
|
const selection = window.getSelection() |
|
|
|
|
|
|
|
if (!selection || selection.isCollapsed || selection.rangeCount === 0) { |
|
|
|
|
|
|
|
setShowHighlightButton(false) |
|
|
|
|
|
|
|
setSelectedText('') |
|
|
|
|
|
|
|
setSelectionPosition(null) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Also listen for selectionchange events which fire more reliably
|
|
|
|
|
|
|
|
const handleSelectionChange = () => { |
|
|
|
|
|
|
|
if (selectionTimeoutRef.current) { |
|
|
|
|
|
|
|
clearTimeout(selectionTimeoutRef.current) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
selectionTimeoutRef.current = setTimeout(handleSelection, 50) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mouseup', handleMouseUp) |
|
|
|
document.addEventListener('mouseup', handleMouseUp) |
|
|
|
document.addEventListener('click', handleClick) |
|
|
|
document.addEventListener('click', handleClick, true) // Use capture phase
|
|
|
|
|
|
|
|
document.addEventListener('selectionchange', handleSelectionChange) |
|
|
|
|
|
|
|
|
|
|
|
return () => { |
|
|
|
return () => { |
|
|
|
document.removeEventListener('mouseup', handleMouseUp) |
|
|
|
document.removeEventListener('mouseup', handleMouseUp) |
|
|
|
document.removeEventListener('click', handleClick) |
|
|
|
document.removeEventListener('click', handleClick, true) |
|
|
|
|
|
|
|
document.removeEventListener('selectionchange', handleSelectionChange) |
|
|
|
if (selectionTimeoutRef.current) { |
|
|
|
if (selectionTimeoutRef.current) { |
|
|
|
clearTimeout(selectionTimeoutRef.current) |
|
|
|
clearTimeout(selectionTimeoutRef.current) |
|
|
|
} |
|
|
|
} |
|
|
|
@ -95,13 +200,16 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c |
|
|
|
// Store the text to highlight
|
|
|
|
// Store the text to highlight
|
|
|
|
setHighlightText(text) |
|
|
|
setHighlightText(text) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Convert HTML description to plain text for context
|
|
|
|
|
|
|
|
const plainTextContext = htmlToPlainText(item.description) |
|
|
|
|
|
|
|
|
|
|
|
if (!pubkey) { |
|
|
|
if (!pubkey) { |
|
|
|
checkLogin(() => { |
|
|
|
checkLogin(() => { |
|
|
|
// After login, create highlight data and open editor
|
|
|
|
// After login, create highlight data and open editor
|
|
|
|
const data: HighlightData = { |
|
|
|
const data: HighlightData = { |
|
|
|
sourceType: 'url', |
|
|
|
sourceType: 'url', |
|
|
|
sourceValue: item.link, |
|
|
|
sourceValue: item.link, |
|
|
|
context: item.description |
|
|
|
context: plainTextContext |
|
|
|
} |
|
|
|
} |
|
|
|
setHighlightData(data) |
|
|
|
setHighlightData(data) |
|
|
|
setIsPostEditorOpen(true) |
|
|
|
setIsPostEditorOpen(true) |
|
|
|
@ -117,7 +225,7 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c |
|
|
|
const data: HighlightData = { |
|
|
|
const data: HighlightData = { |
|
|
|
sourceType: 'url', |
|
|
|
sourceType: 'url', |
|
|
|
sourceValue: item.link, |
|
|
|
sourceValue: item.link, |
|
|
|
context: item.description |
|
|
|
context: plainTextContext |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Open PostEditor in highlight mode
|
|
|
|
// Open PostEditor in highlight mode
|
|
|
|
@ -252,15 +360,12 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c |
|
|
|
MozUserSelect: 'text', |
|
|
|
MozUserSelect: 'text', |
|
|
|
msUserSelect: 'text' |
|
|
|
msUserSelect: 'text' |
|
|
|
}} |
|
|
|
}} |
|
|
|
> |
|
|
|
dangerouslySetInnerHTML={{ __html: descriptionHtml }} |
|
|
|
<div |
|
|
|
onMouseUp={(e) => { |
|
|
|
dangerouslySetInnerHTML={{ __html: descriptionHtml }} |
|
|
|
// Allow text selection
|
|
|
|
onMouseUp={(e) => { |
|
|
|
e.stopPropagation() |
|
|
|
// Allow text selection
|
|
|
|
}} |
|
|
|
e.stopPropagation() |
|
|
|
/> |
|
|
|
}} |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Gradient overlay when collapsed */} |
|
|
|
{/* Gradient overlay when collapsed */} |
|
|
|
{needsCollapse && !isExpanded && ( |
|
|
|
{needsCollapse && !isExpanded && ( |
|
|
|
@ -350,3 +455,4 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|