You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
788 lines
28 KiB
788 lines
28 KiB
<script lang="ts"> |
|
import { getNdkContext, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; |
|
import { pubkeyToHue } from "$lib/utils/nostrUtils"; |
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk"; |
|
import { communityRelays } from "$lib/consts"; |
|
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; |
|
import { generateMockHighlightsForSections } from "$lib/utils/mockHighlightData"; |
|
import { |
|
groupHighlightsByAuthor, |
|
truncateHighlight, |
|
encodeHighlightNaddr, |
|
getRelaysFromHighlight, |
|
getAuthorDisplayName, |
|
sortHighlightsByTime |
|
} from "$lib/utils/highlightUtils"; |
|
import { unifiedProfileCache } from "$lib/utils/npubCache"; |
|
import { nip19 } from "nostr-tools"; |
|
import { highlightByOffset, getPlainText } from "$lib/utils/highlightPositioning"; |
|
|
|
let { |
|
eventId, |
|
eventAddress, |
|
eventIds = [], |
|
eventAddresses = [], |
|
visible = $bindable(false), |
|
useMockHighlights = false, |
|
}: { |
|
eventId?: string; |
|
eventAddress?: string; |
|
eventIds?: string[]; |
|
eventAddresses?: string[]; |
|
visible?: boolean; |
|
useMockHighlights?: boolean; |
|
} = $props(); |
|
|
|
const ndk = getNdkContext(); |
|
|
|
// State management |
|
let highlights: NDKEvent[] = $state([]); |
|
let loading = $state(false); |
|
let containerRef: HTMLElement | null = $state(null); |
|
let expandedAuthors = $state(new Set<string>()); |
|
let authorProfiles = $state(new Map<string, any>()); |
|
let copyFeedback = $state<string | null>(null); |
|
|
|
// Derived state for color mapping |
|
let colorMap = $derived.by(() => { |
|
const map = new Map<string, string>(); |
|
highlights.forEach(highlight => { |
|
if (!map.has(highlight.pubkey)) { |
|
const hue = pubkeyToHue(highlight.pubkey); |
|
map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`); |
|
} |
|
}); |
|
return map; |
|
}); |
|
|
|
// Derived state for grouped highlights |
|
let groupedHighlights = $derived.by(() => { |
|
return groupHighlightsByAuthor(highlights); |
|
}); |
|
|
|
/** |
|
* Fetch highlight events (kind 9802) for the current publication using NDK |
|
* Or generate mock highlights if useMockHighlights is enabled |
|
*/ |
|
async function fetchHighlights() { |
|
// Prevent concurrent fetches |
|
if (loading) { |
|
console.log("[HighlightLayer] Already loading, skipping fetch"); |
|
return; |
|
} |
|
|
|
// Collect all event IDs and addresses |
|
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(Boolean); |
|
const allAddresses = [...(eventAddress ? [eventAddress] : []), ...eventAddresses].filter(Boolean); |
|
|
|
if (allEventIds.length === 0 && allAddresses.length === 0) { |
|
console.warn("[HighlightLayer] No event IDs or addresses provided"); |
|
return; |
|
} |
|
|
|
loading = true; |
|
highlights = []; |
|
|
|
// AI-NOTE: Mock mode allows testing highlight UI without publishing to relays |
|
// This is useful for development and demonstrating the highlight system |
|
if (useMockHighlights) { |
|
console.log(`[HighlightLayer] MOCK MODE - Generating mock highlights for ${allAddresses.length} sections`); |
|
|
|
try { |
|
// Generate mock highlight data |
|
const mockHighlights = generateMockHighlightsForSections(allAddresses); |
|
|
|
// Convert to NDKEvent instances (same as real events) |
|
highlights = mockHighlights.map(rawEvent => new NDKEventClass(ndk, rawEvent)); |
|
|
|
console.log(`[HighlightLayer] Generated ${highlights.length} mock highlights`); |
|
loading = false; |
|
return; |
|
} catch (err) { |
|
console.error(`[HighlightLayer] Error generating mock highlights:`, err); |
|
loading = false; |
|
return; |
|
} |
|
} |
|
|
|
console.log(`[HighlightLayer] Fetching highlights for:`, { |
|
eventIds: allEventIds, |
|
addresses: allAddresses |
|
}); |
|
|
|
try { |
|
// Build filter for kind 9802 highlight events |
|
// IMPORTANT: Use only #a tags because filters are AND, not OR |
|
// If we include both #e and #a, relays will only return highlights that have BOTH |
|
const filter: any = { |
|
kinds: [9802], |
|
limit: 500, |
|
}; |
|
|
|
// Prefer #a (addressable events) since they're more specific and persistent |
|
if (allAddresses.length > 0) { |
|
filter["#a"] = allAddresses; |
|
} else if (allEventIds.length > 0) { |
|
// Fallback to #e if no addresses available |
|
filter["#e"] = allEventIds; |
|
} |
|
|
|
console.log(`[HighlightLayer] Fetching with filter:`, JSON.stringify(filter, null, 2)); |
|
|
|
// Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton) |
|
const relays = [ |
|
...communityRelays, |
|
...$activeOutboxRelays, |
|
...$activeInboxRelays, |
|
]; |
|
const uniqueRelays = Array.from(new Set(relays)); |
|
console.log(`[HighlightLayer] Fetching from ${uniqueRelays.length} relays:`, uniqueRelays); |
|
|
|
/** |
|
* Use WebSocketPool with nostr-tools protocol instead of NDK |
|
* |
|
* Reasons for not using NDK: |
|
* 1. NDK subscriptions mysteriously returned 0 events even when websocat confirmed events existed |
|
* 2. Consistency - CommentButton and HighlightSelectionHandler both use WebSocketPool pattern |
|
* 3. Better debugging - direct access to WebSocket messages for troubleshooting |
|
* 4. Proven reliability - battle-tested in the codebase for similar use cases |
|
* 5. Performance control - explicit 5s timeout per relay, tunable as needed |
|
* |
|
* This matches the pattern in: |
|
* - src/lib/components/publications/CommentButton.svelte:156-220 |
|
* - src/lib/components/publications/HighlightSelectionHandler.svelte:217-280 |
|
*/ |
|
const subscriptionId = `highlights-${Date.now()}`; |
|
const receivedEventIds = new Set<string>(); |
|
let eoseCount = 0; |
|
|
|
const fetchPromises = uniqueRelays.map(async (relayUrl) => { |
|
try { |
|
console.log(`[HighlightLayer] Connecting to ${relayUrl}`); |
|
const ws = await WebSocketPool.instance.acquire(relayUrl); |
|
|
|
return new Promise<void>((resolve) => { |
|
const messageHandler = (event: MessageEvent) => { |
|
try { |
|
const message = JSON.parse(event.data); |
|
|
|
// Log ALL messages from relay.nostr.band for debugging |
|
if (relayUrl.includes('relay.nostr.band')) { |
|
console.log(`[HighlightLayer] RAW message from ${relayUrl}:`, message); |
|
} |
|
|
|
if (message[0] === "EVENT" && message[1] === subscriptionId) { |
|
const rawEvent = message[2]; |
|
console.log(`[HighlightLayer] EVENT from ${relayUrl}:`, { |
|
id: rawEvent.id, |
|
kind: rawEvent.kind, |
|
content: rawEvent.content.substring(0, 50), |
|
tags: rawEvent.tags |
|
}); |
|
|
|
// Avoid duplicates |
|
if (!receivedEventIds.has(rawEvent.id)) { |
|
receivedEventIds.add(rawEvent.id); |
|
|
|
// Convert to NDKEvent |
|
const ndkEvent = new NDKEventClass(ndk, rawEvent); |
|
highlights = [...highlights, ndkEvent]; |
|
console.log(`[HighlightLayer] Added highlight, total now: ${highlights.length}`); |
|
} |
|
} else if (message[0] === "EOSE" && message[1] === subscriptionId) { |
|
eoseCount++; |
|
console.log(`[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`); |
|
|
|
// Close subscription |
|
ws.send(JSON.stringify(["CLOSE", subscriptionId])); |
|
ws.removeEventListener("message", messageHandler); |
|
WebSocketPool.instance.release(ws); |
|
resolve(); |
|
} else if (message[0] === "NOTICE") { |
|
console.warn(`[HighlightLayer] NOTICE from ${relayUrl}:`, message[1]); |
|
} |
|
} catch (err) { |
|
console.error(`[HighlightLayer] Error processing message from ${relayUrl}:`, err); |
|
} |
|
}; |
|
|
|
ws.addEventListener("message", messageHandler); |
|
|
|
// Send REQ |
|
const req = ["REQ", subscriptionId, filter]; |
|
if (relayUrl.includes('relay.nostr.band')) { |
|
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}:`, JSON.stringify(req)); |
|
} else { |
|
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}`); |
|
} |
|
ws.send(JSON.stringify(req)); |
|
|
|
// Timeout per relay (5 seconds) |
|
setTimeout(() => { |
|
if (ws.readyState === WebSocket.OPEN) { |
|
ws.send(JSON.stringify(["CLOSE", subscriptionId])); |
|
ws.removeEventListener("message", messageHandler); |
|
WebSocketPool.instance.release(ws); |
|
} |
|
resolve(); |
|
}, 5000); |
|
}); |
|
} catch (err) { |
|
console.error(`[HighlightLayer] Error connecting to ${relayUrl}:`, err); |
|
} |
|
}); |
|
|
|
// Wait for all relays to respond or timeout |
|
await Promise.all(fetchPromises); |
|
|
|
console.log(`[HighlightLayer] Fetched ${highlights.length} highlights`); |
|
|
|
if (highlights.length > 0) { |
|
console.log(`[HighlightLayer] Highlights summary:`, highlights.map(h => ({ |
|
content: h.content.substring(0, 30) + "...", |
|
address: h.tags.find(t => t[0] === "a")?.[1], |
|
author: h.pubkey.substring(0, 8) |
|
}))); |
|
} |
|
|
|
loading = false; |
|
|
|
// Rendering is handled by the visibility/highlights effect |
|
|
|
} catch (err) { |
|
console.error(`[HighlightLayer] Error fetching highlights:`, err); |
|
loading = false; |
|
} |
|
} |
|
|
|
/** |
|
* Apply highlight using position offsets |
|
* @param offsetStart - Start character position |
|
* @param offsetEnd - End character position |
|
* @param color - The color to use for highlighting |
|
* @param targetAddress - Optional address to limit search to specific section |
|
*/ |
|
function highlightByPosition( |
|
offsetStart: number, |
|
offsetEnd: number, |
|
color: string, |
|
targetAddress?: string |
|
): boolean { |
|
if (!containerRef) { |
|
console.log(`[HighlightLayer] Cannot highlight by position - no containerRef`); |
|
return false; |
|
} |
|
|
|
// If we have a target address, search only in that section |
|
let searchRoot: HTMLElement = containerRef; |
|
if (targetAddress) { |
|
const sectionElement = document.getElementById(targetAddress); |
|
if (sectionElement) { |
|
searchRoot = sectionElement; |
|
console.log(`[HighlightLayer] Highlighting in specific section: ${targetAddress}`); |
|
} else { |
|
console.log(`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`); |
|
} |
|
} |
|
|
|
console.log(`[HighlightLayer] Applying position-based highlight ${offsetStart}-${offsetEnd}`); |
|
const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color); |
|
|
|
if (result) { |
|
console.log(`[HighlightLayer] Successfully applied position-based highlight`); |
|
} else { |
|
console.log(`[HighlightLayer] Failed to apply position-based highlight`); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
/** |
|
* Find text in the DOM and highlight it (fallback method) |
|
* @param text - The text to highlight |
|
* @param color - The color to use for highlighting |
|
* @param targetAddress - Optional address to limit search to specific section |
|
*/ |
|
function findAndHighlightText(text: string, color: string, targetAddress?: string): void { |
|
if (!containerRef || !text || text.trim().length === 0) { |
|
console.log(`[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`); |
|
return; |
|
} |
|
|
|
// If we have a target address, search only in that section |
|
let searchRoot: HTMLElement | Document = containerRef; |
|
if (targetAddress) { |
|
const sectionElement = document.getElementById(targetAddress); |
|
if (sectionElement) { |
|
searchRoot = sectionElement; |
|
console.log(`[HighlightLayer] Searching in specific section: ${targetAddress}`); |
|
} else { |
|
console.log(`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`); |
|
} |
|
} |
|
|
|
console.log(`[HighlightLayer] Searching for text: "${text}" in`, searchRoot); |
|
|
|
// Use TreeWalker to find all text nodes |
|
const walker = document.createTreeWalker( |
|
searchRoot, |
|
NodeFilter.SHOW_TEXT, |
|
null |
|
); |
|
|
|
const textNodes: Node[] = []; |
|
let node: Node | null; |
|
while ((node = walker.nextNode())) { |
|
textNodes.push(node); |
|
} |
|
|
|
// Search for the highlight text in text nodes |
|
console.log(`[HighlightLayer] Searching through ${textNodes.length} text nodes`); |
|
|
|
for (const textNode of textNodes) { |
|
const nodeText = textNode.textContent || ""; |
|
const index = nodeText.toLowerCase().indexOf(text.toLowerCase()); |
|
|
|
if (index !== -1) { |
|
console.log(`[HighlightLayer] Found match in text node:`, nodeText.substring(Math.max(0, index - 20), Math.min(nodeText.length, index + text.length + 20))); |
|
const parent = textNode.parentNode; |
|
if (!parent) continue; |
|
|
|
// Skip if already highlighted |
|
if (parent.nodeName === "MARK" || (parent instanceof Element && parent.classList?.contains("highlight"))) { |
|
continue; |
|
} |
|
|
|
const before = nodeText.substring(0, index); |
|
const match = nodeText.substring(index, index + text.length); |
|
const after = nodeText.substring(index + text.length); |
|
|
|
// Create highlight span |
|
const highlightSpan = document.createElement("mark"); |
|
highlightSpan.className = "highlight"; |
|
highlightSpan.style.backgroundColor = color; |
|
highlightSpan.style.borderRadius = "2px"; |
|
highlightSpan.style.padding = "2px 0"; |
|
highlightSpan.textContent = match; |
|
|
|
// Replace the text node with the highlighted version |
|
const fragment = document.createDocumentFragment(); |
|
if (before) fragment.appendChild(document.createTextNode(before)); |
|
fragment.appendChild(highlightSpan); |
|
if (after) fragment.appendChild(document.createTextNode(after)); |
|
|
|
parent.replaceChild(fragment, textNode); |
|
|
|
console.log(`[HighlightLayer] Highlighted text:`, match); |
|
return; // Only highlight first occurrence to avoid multiple highlights |
|
} |
|
} |
|
|
|
console.log(`[HighlightLayer] No match found for text: "${text}"`); |
|
} |
|
|
|
/** |
|
* Render all highlights on the page |
|
*/ |
|
function renderHighlights() { |
|
console.log(`[HighlightLayer] renderHighlights called - visible: ${visible}, containerRef: ${!!containerRef}, highlights: ${highlights.length}`); |
|
|
|
if (!visible || !containerRef) { |
|
console.log(`[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`); |
|
return; |
|
} |
|
|
|
if (highlights.length === 0) { |
|
console.log(`[HighlightLayer] No highlights to render`); |
|
return; |
|
} |
|
|
|
// Clear existing highlights |
|
clearHighlights(); |
|
|
|
console.log(`[HighlightLayer] Rendering ${highlights.length} highlights`); |
|
console.log(`[HighlightLayer] Container element:`, containerRef); |
|
console.log(`[HighlightLayer] Container has children:`, containerRef.children.length); |
|
|
|
// Apply each highlight |
|
for (const highlight of highlights) { |
|
const content = highlight.content; |
|
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.3)"; |
|
|
|
// Extract the target address from the highlight's "a" tag |
|
const aTag = highlight.tags.find(tag => tag[0] === "a"); |
|
const targetAddress = aTag ? aTag[1] : undefined; |
|
|
|
// Check for offset tags (position-based highlighting) |
|
const offsetTag = highlight.tags.find(tag => tag[0] === "offset"); |
|
const hasOffset = offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined; |
|
|
|
console.log(`[HighlightLayer] Rendering highlight:`, { |
|
hasOffset, |
|
offsetTag, |
|
content: content.substring(0, 50), |
|
contentLength: content.length, |
|
targetAddress, |
|
color, |
|
allTags: highlight.tags |
|
}); |
|
|
|
if (hasOffset) { |
|
// Use position-based highlighting |
|
const offsetStart = parseInt(offsetTag[1], 10); |
|
const offsetEnd = parseInt(offsetTag[2], 10); |
|
|
|
if (!isNaN(offsetStart) && !isNaN(offsetEnd)) { |
|
console.log(`[HighlightLayer] Using position-based highlighting: ${offsetStart}-${offsetEnd}`); |
|
highlightByPosition(offsetStart, offsetEnd, color, targetAddress); |
|
} else { |
|
console.log(`[HighlightLayer] Invalid offset values, falling back to text search`); |
|
if (content && content.trim().length > 0) { |
|
findAndHighlightText(content, color, targetAddress); |
|
} |
|
} |
|
} else { |
|
// Fall back to text-based highlighting |
|
console.log(`[HighlightLayer] Using text-based highlighting`); |
|
if (content && content.trim().length > 0) { |
|
findAndHighlightText(content, color, targetAddress); |
|
} else { |
|
console.log(`[HighlightLayer] Skipping highlight - empty content`); |
|
} |
|
} |
|
} |
|
|
|
// Check if any highlights were actually rendered |
|
const renderedHighlights = containerRef.querySelectorAll("mark.highlight"); |
|
console.log(`[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`); |
|
} |
|
|
|
/** |
|
* Clear all highlights from the page |
|
*/ |
|
function clearHighlights() { |
|
if (!containerRef) return; |
|
|
|
const highlightElements = containerRef.querySelectorAll("mark.highlight"); |
|
highlightElements.forEach(el => { |
|
const parent = el.parentNode; |
|
if (parent) { |
|
// Replace highlight with plain text |
|
const textNode = document.createTextNode(el.textContent || ""); |
|
parent.replaceChild(textNode, el); |
|
|
|
// Normalize the parent to merge adjacent text nodes |
|
parent.normalize(); |
|
} |
|
}); |
|
|
|
console.log(`[HighlightLayer] Cleared ${highlightElements.length} highlights`); |
|
} |
|
|
|
// Track the last fetched event count to know when to refetch |
|
let lastFetchedCount = $state(0); |
|
let fetchTimeout: ReturnType<typeof setTimeout> | null = null; |
|
|
|
// Watch for changes to event data - debounce and fetch when data stabilizes |
|
$effect(() => { |
|
const currentCount = eventIds.length + eventAddresses.length; |
|
const hasEventData = currentCount > 0; |
|
|
|
console.log(`[HighlightLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`); |
|
|
|
// Only fetch if: |
|
// 1. We have event data |
|
// 2. The count has changed since last fetch |
|
// 3. We're not already loading |
|
if (hasEventData && currentCount !== lastFetchedCount && !loading) { |
|
// Clear any existing timeout |
|
if (fetchTimeout) { |
|
clearTimeout(fetchTimeout); |
|
} |
|
|
|
// Debounce: wait 500ms for more events to arrive before fetching |
|
fetchTimeout = setTimeout(() => { |
|
console.log(`[HighlightLayer] Event data stabilized at ${currentCount} events, fetching highlights...`); |
|
lastFetchedCount = currentCount; |
|
fetchHighlights(); |
|
}, 500); |
|
} |
|
|
|
// Cleanup timeout on effect cleanup |
|
return () => { |
|
if (fetchTimeout) { |
|
clearTimeout(fetchTimeout); |
|
} |
|
}; |
|
}); |
|
|
|
// Watch for visibility AND highlights changes - render when both are ready |
|
$effect(() => { |
|
// This effect runs when either visible or highlights.length changes |
|
const highlightCount = highlights.length; |
|
console.log(`[HighlightLayer] Visibility/highlights effect - visible: ${visible}, highlights: ${highlightCount}`); |
|
|
|
if (visible && highlightCount > 0) { |
|
console.log(`[HighlightLayer] Both visible and highlights ready, rendering...`); |
|
renderHighlights(); |
|
} else if (!visible) { |
|
clearHighlights(); |
|
} |
|
}); |
|
|
|
// Fetch profiles when highlights change |
|
$effect(() => { |
|
const highlightCount = highlights.length; |
|
if (highlightCount > 0) { |
|
fetchAuthorProfiles(); |
|
} |
|
}); |
|
|
|
/** |
|
* Fetch author profiles for all unique pubkeys in highlights |
|
*/ |
|
async function fetchAuthorProfiles() { |
|
const uniquePubkeys = Array.from(groupedHighlights.keys()); |
|
console.log(`[HighlightLayer] Fetching profiles for ${uniquePubkeys.length} authors`); |
|
|
|
for (const pubkey of uniquePubkeys) { |
|
try { |
|
// Convert hex pubkey to npub for the profile cache |
|
const npub = nip19.npubEncode(pubkey); |
|
const profile = await unifiedProfileCache.getProfile(npub, ndk); |
|
if (profile) { |
|
authorProfiles.set(pubkey, profile); |
|
// Trigger reactivity |
|
authorProfiles = new Map(authorProfiles); |
|
} |
|
} catch (err) { |
|
console.error(`[HighlightLayer] Error fetching profile for ${pubkey}:`, err); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Toggle expansion state for an author's highlights |
|
*/ |
|
function toggleAuthor(pubkey: string) { |
|
if (expandedAuthors.has(pubkey)) { |
|
expandedAuthors.delete(pubkey); |
|
} else { |
|
expandedAuthors.add(pubkey); |
|
} |
|
// Trigger reactivity |
|
expandedAuthors = new Set(expandedAuthors); |
|
} |
|
|
|
/** |
|
* Scroll to a specific highlight in the document |
|
*/ |
|
function scrollToHighlight(highlight: NDKEvent) { |
|
console.log(`[HighlightLayer] scrollToHighlight called for:`, highlight.content.substring(0, 50)); |
|
|
|
if (!containerRef) { |
|
console.warn(`[HighlightLayer] No containerRef available`); |
|
return; |
|
} |
|
|
|
const content = highlight.content; |
|
if (!content || content.trim().length === 0) { |
|
console.warn(`[HighlightLayer] No content in highlight`); |
|
return; |
|
} |
|
|
|
// Find the highlight mark element |
|
const highlightMarks = containerRef.querySelectorAll("mark.highlight"); |
|
console.log(`[HighlightLayer] Found ${highlightMarks.length} highlight marks in DOM`); |
|
|
|
// Try exact match first |
|
for (const mark of highlightMarks) { |
|
const markText = mark.textContent?.toLowerCase() || ""; |
|
const searchText = content.toLowerCase(); |
|
|
|
if (markText === searchText) { |
|
console.log(`[HighlightLayer] Found exact match, scrolling and flashing`); |
|
// Scroll to this element |
|
mark.scrollIntoView({ behavior: "smooth", block: "center" }); |
|
|
|
// Add a temporary flash effect |
|
mark.classList.add("highlight-flash"); |
|
setTimeout(() => { |
|
mark.classList.remove("highlight-flash"); |
|
}, 1500); |
|
return; |
|
} |
|
} |
|
|
|
// Try partial match (for position-based highlights that might be split) |
|
for (const mark of highlightMarks) { |
|
const markText = mark.textContent?.toLowerCase() || ""; |
|
const searchText = content.toLowerCase(); |
|
|
|
if (markText.includes(searchText) || searchText.includes(markText)) { |
|
console.log(`[HighlightLayer] Found partial match, scrolling and flashing`); |
|
mark.scrollIntoView({ behavior: "smooth", block: "center" }); |
|
mark.classList.add("highlight-flash"); |
|
setTimeout(() => { |
|
mark.classList.remove("highlight-flash"); |
|
}, 1500); |
|
return; |
|
} |
|
} |
|
|
|
console.warn(`[HighlightLayer] Could not find highlight mark for:`, content.substring(0, 50)); |
|
} |
|
|
|
/** |
|
* Copy highlight naddr to clipboard |
|
*/ |
|
async function copyHighlightNaddr(highlight: NDKEvent) { |
|
const relays = getRelaysFromHighlight(highlight); |
|
const naddr = encodeHighlightNaddr(highlight, relays); |
|
|
|
try { |
|
await navigator.clipboard.writeText(naddr); |
|
copyFeedback = highlight.id; |
|
console.log(`[HighlightLayer] Copied naddr to clipboard:`, naddr); |
|
|
|
// Clear feedback after 2 seconds |
|
setTimeout(() => { |
|
copyFeedback = null; |
|
}, 2000); |
|
} catch (err) { |
|
console.error(`[HighlightLayer] Error copying to clipboard:`, err); |
|
} |
|
} |
|
|
|
/** |
|
* Bind to parent container element |
|
*/ |
|
export function setContainer(element: HTMLElement | null) { |
|
containerRef = element; |
|
} |
|
|
|
/** |
|
* Public method to refresh highlights (e.g., after creating a new one) |
|
*/ |
|
export function refresh() { |
|
console.log("[HighlightLayer] Manual refresh triggered"); |
|
|
|
// Clear existing highlights |
|
highlights = []; |
|
clearHighlights(); |
|
|
|
// Reset fetch count to force re-fetch |
|
lastFetchedCount = 0; |
|
fetchHighlights(); |
|
} |
|
</script> |
|
|
|
{#if loading && visible} |
|
<div class="fixed top-40 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-3"> |
|
<p class="text-sm text-gray-600 dark:text-gray-300">Loading highlights...</p> |
|
</div> |
|
{/if} |
|
|
|
{#if visible && highlights.length > 0} |
|
<div class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-sm w-80"> |
|
<h4 class="text-sm font-semibold mb-3 text-gray-900 dark:text-gray-100"> |
|
Highlights |
|
</h4> |
|
<div class="space-y-2 max-h-96 overflow-y-auto"> |
|
{#each Array.from(groupedHighlights.entries()) as [pubkey, authorHighlights]} |
|
{@const isExpanded = expandedAuthors.has(pubkey)} |
|
{@const profile = authorProfiles.get(pubkey)} |
|
{@const displayName = getAuthorDisplayName(profile, pubkey)} |
|
{@const color = colorMap.get(pubkey) || "hsla(60, 70%, 60%, 0.3)"} |
|
{@const sortedHighlights = sortHighlightsByTime(authorHighlights)} |
|
|
|
<div class="border-b border-gray-200 dark:border-gray-700 pb-2"> |
|
<!-- Author header --> |
|
<button |
|
class="w-full flex items-center gap-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 p-2 rounded transition-colors" |
|
onclick={() => toggleAuthor(pubkey)} |
|
> |
|
<div |
|
class="w-3 h-3 rounded flex-shrink-0" |
|
style="background-color: {color};" |
|
></div> |
|
<span class="font-medium text-gray-900 dark:text-gray-100 flex-1 text-left truncate"> |
|
{displayName} |
|
</span> |
|
<span class="text-xs text-gray-500 dark:text-gray-400"> |
|
({authorHighlights.length}) |
|
</span> |
|
<svg |
|
class="w-4 h-4 text-gray-500 transition-transform {isExpanded ? 'rotate-90' : ''}" |
|
fill="none" |
|
stroke="currentColor" |
|
viewBox="0 0 24 24" |
|
> |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> |
|
</svg> |
|
</button> |
|
|
|
<!-- Expanded highlight list --> |
|
{#if isExpanded} |
|
<div class="mt-2 ml-5 space-y-2"> |
|
{#each sortedHighlights as highlight} |
|
{@const truncated = useMockHighlights ? "test data" : truncateHighlight(highlight.content)} |
|
{@const showCopied = copyFeedback === highlight.id} |
|
|
|
<div class="flex items-start gap-2 group"> |
|
<button |
|
class="flex-1 text-left text-xs text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors" |
|
onclick={() => scrollToHighlight(highlight)} |
|
title={useMockHighlights ? "Mock highlight" : highlight.content} |
|
> |
|
{truncated} |
|
</button> |
|
<button |
|
class="flex-shrink-0 p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors" |
|
onclick={() => copyHighlightNaddr(highlight)} |
|
title="Copy naddr" |
|
> |
|
{#if showCopied} |
|
<svg class="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20"> |
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /> |
|
</svg> |
|
{:else} |
|
<svg class="w-3 h-3 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> |
|
</svg> |
|
{/if} |
|
</button> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/each} |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<style> |
|
:global(mark.highlight) { |
|
transition: background-color 0.2s ease; |
|
} |
|
|
|
:global(mark.highlight:hover) { |
|
filter: brightness(1.1); |
|
} |
|
|
|
:global(mark.highlight.highlight-flash) { |
|
animation: flash 1.5s ease-in-out; |
|
} |
|
|
|
@keyframes :global(flash) { |
|
0%, 100% { |
|
filter: brightness(1); |
|
} |
|
50% { |
|
filter: brightness(0.4); |
|
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5); |
|
} |
|
} |
|
</style>
|
|
|