clone of repo on github
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.
 
 
 
 

1364 lines
54 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,
currentViewAddress,
rootAddress,
publicationType,
}: {
eventId?: string;
eventAddress?: string;
eventIds?: string[];
eventAddresses?: string[];
visible?: boolean;
useMockHighlights?: boolean;
currentViewAddress?: string;
rootAddress?: string;
publicationType?: string;
} = $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
// AI-NOTE: Increased opacity from 0.3 to 0.5 for better visibility
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.5)`);
}
});
return map;
});
// Filter highlights to only show those for the current view
// AI-NOTE:
// - If viewing a blog index (30040), don't show any highlights - highlights scoped to the blog
// contain text from individual articles (30041) which aren't loaded on the index
// - If viewing a blog entry (30041), show highlights scoped to that entry OR to the parent blog (30040)
// - If viewing a publication index, show all highlights scoped to the publication
let filteredHighlights = $derived.by(() => {
if (!currentViewAddress) {
// No current view specified - viewing publication/blog index
// For blogs, don't show highlights on the index - the text is in the articles, not the index
if (publicationType === "blog") {
return [];
}
// For regular publications, show all highlights
return highlights;
}
// Check if currentViewAddress is a blog entry (30041) or section
const isBlogEntry = currentViewAddress.startsWith('30041:');
// Filter highlights to only those scoped to the current view
// OR to the root publication if viewing a blog entry
return highlights.filter((highlight) => {
const aTag = highlight.tags.find((tag) => tag[0] === "a");
if (!aTag) return false;
const highlightAddress = aTag[1];
// Match if highlight is scoped to current view
if (highlightAddress === currentViewAddress) {
return true;
}
// If viewing a blog entry (30041), also show highlights scoped to the parent publication (30040)
if (isBlogEntry && rootAddress && highlightAddress === rootAddress) {
return true;
}
return false;
});
});
// Derived state for grouped highlights (using filtered highlights)
let groupedHighlights = $derived.by(() => {
return groupHighlightsByAuthor(filteredHighlights);
});
/**
* 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) {
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) {
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),
);
loading = false;
return;
} catch (err) {
console.error(
`[HighlightLayer] Error generating mock highlights:`,
err,
);
loading = false;
return;
}
}
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;
}
// Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton)
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
const uniqueRelays = Array.from(new Set(relays));
/**
* 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 {
const ws = await WebSocketPool.instance.acquire(relayUrl);
return new Promise<void>((resolve) => {
let released = false;
let resolved = false;
const releaseConnection = () => {
if (released) {
return;
}
released = true;
try {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
}
ws.removeEventListener("message", messageHandler);
WebSocketPool.instance.release(ws);
} catch (err) {
console.error(`[HighlightLayer] Error releasing connection to ${relayUrl}:`, err);
}
};
const safeResolve = () => {
if (!resolved) {
resolved = true;
resolve();
}
};
const messageHandler = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
if (message[0] === "EVENT" && message[1] === subscriptionId) {
const rawEvent = message[2];
// Avoid duplicates
if (!receivedEventIds.has(rawEvent.id)) {
receivedEventIds.add(rawEvent.id);
// Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent);
highlights = [...highlights, ndkEvent];
}
} else if (
message[0] === "EOSE" &&
message[1] === subscriptionId
) {
eoseCount++;
// Close subscription and release connection
releaseConnection();
safeResolve();
}
} catch (err) {
console.error(
`[HighlightLayer] Error processing message from ${relayUrl}:`,
err,
);
}
};
ws.addEventListener("message", messageHandler);
// Send REQ
const req = ["REQ", subscriptionId, filter];
ws.send(JSON.stringify(req));
// Timeout per relay (5 seconds)
setTimeout(() => {
releaseConnection();
safeResolve();
}, 5000);
});
} catch (err) {
console.error(
`[HighlightLayer] Error connecting to ${relayUrl}:`,
err,
);
}
});
// Wait for all relays to respond or timeout
await Promise.all(fetchPromises);
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) {
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.debug(`[HighlightLayer] Found section element for ${targetAddress}, using it as search root`);
} else {
console.warn(`[HighlightLayer] Section element not found for ${targetAddress}, falling back to containerRef`);
}
}
const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color);
if (!result) {
console.warn(`[HighlightLayer] highlightByOffset returned false for offsets ${offsetStart}-${offsetEnd} in ${targetAddress || 'container'}`);
}
return result;
}
// Track retry attempts for text highlighting
const textHighlightRetries = new Map<string, number>();
const MAX_TEXT_HIGHLIGHT_RETRIES = 10;
const TEXT_HIGHLIGHT_RETRY_DELAYS = [100, 200, 300, 500, 750, 1000, 1500, 2000, 3000, 5000]; // ms
/**
* 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
* @param retryCount - Internal parameter for retry attempts
*/
function findAndHighlightText(
text: string,
color: string,
targetAddress?: string,
retryCount: number = 0,
): void {
console.log(`[HighlightLayer] findAndHighlightText called: text="${text}", color="${color}", targetAddress="${targetAddress}", retryCount=${retryCount}`);
if (!text || text.trim().length === 0) {
console.warn(`[HighlightLayer] Empty text provided, returning`);
return;
}
// If we have a target address, search only in that section
// AI-NOTE: When viewing a section directly (leaf), the section element might be outside containerRef
// So we search the entire document for the section element
let searchRoot: HTMLElement | Document | null = null;
let sectionElement: HTMLElement | null = null;
if (targetAddress) {
// Check if this is a publication address (30040) or section address (30041)
const isPublicationAddress = targetAddress.startsWith('30040:');
if (isPublicationAddress) {
// For publication-scoped highlights, search in all sections of that publication
// Find all section elements that belong to this publication
const allSections = document.querySelectorAll('section[id^="30041:"], section[id^="30040:"]');
const matchingSections: HTMLElement[] = [];
// Extract publication identifier from target address (pubkey:d-tag)
const pubParts = targetAddress.split(':');
if (pubParts.length >= 3) {
const pubKey = pubParts[1];
const pubDtag = pubParts[2];
// Find sections that belong to this publication
for (const section of allSections) {
const sectionId = section.id;
const sectionParts = sectionId.split(':');
if (sectionParts.length >= 3 && sectionParts[1] === pubKey) {
// This section belongs to the same publication
matchingSections.push(section as HTMLElement);
}
}
}
if (matchingSections.length > 0) {
// Create a container to search across all matching sections
// We'll search each section individually
console.debug(`[HighlightLayer] Found ${matchingSections.length} sections for publication ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
// Search in each matching section
for (const section of matchingSections) {
const contentSection = section.querySelector('section.publication-leather');
const searchTarget = (contentSection || section.querySelector('.section-content') || section) as HTMLElement;
if (searchTarget) {
// Use TreeWalker to find text in this section
const walker = document.createTreeWalker(
searchTarget,
NodeFilter.SHOW_TEXT,
null,
);
const textNodes: Node[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
// Search for the text in this section's text nodes
const normalizedSearchText = text.trim().replace(/\s+/g, ' ').toLowerCase();
for (const textNode of textNodes) {
const nodeText = textNode.textContent || "";
if (!nodeText || nodeText.trim().length === 0) continue;
const normalizedNodeText = nodeText.replace(/\s+/g, ' ').toLowerCase();
if (normalizedNodeText.includes(normalizedSearchText)) {
// Found match - highlight it
let index = normalizedNodeText.indexOf(normalizedSearchText);
if (index !== -1) {
const searchPattern = text.trim();
let actualIndex = nodeText.toLowerCase().indexOf(searchPattern.toLowerCase());
if (actualIndex === -1) {
// Try flexible whitespace matching
const escapedText = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flexiblePattern = escapedText.split(/\s+/).join('\\s+');
const regex = new RegExp(flexiblePattern, 'i');
const regexMatch = nodeText.match(regex);
if (regexMatch && regexMatch.index !== undefined) {
actualIndex = regexMatch.index;
const actualMatchText = regexMatch[0];
const parent = textNode.parentNode;
if (!parent) continue;
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
continue;
}
const before = nodeText.substring(0, actualIndex);
const after = nodeText.substring(actualIndex + actualMatchText.length);
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit";
highlightSpan.style.fontWeight = "inherit";
highlightSpan.textContent = actualMatchText;
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.debug(`[HighlightLayer] Successfully highlighted text in publication-scoped highlight`);
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Found and highlighted, done
}
} else {
// Found exact match
const parent = textNode.parentNode;
if (!parent) continue;
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
continue;
}
const matchLength = text.length;
const before = nodeText.substring(0, actualIndex);
const match = nodeText.substring(actualIndex, actualIndex + matchLength);
const after = nodeText.substring(actualIndex + matchLength);
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit";
highlightSpan.style.fontWeight = "inherit";
highlightSpan.textContent = match;
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.debug(`[HighlightLayer] Successfully highlighted text in publication-scoped highlight`);
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Found and highlighted, done
}
}
}
}
}
}
// If we get here, we searched all sections but didn't find the text
// This might mean the content isn't loaded yet, so we'll retry
const retryKey = `${targetAddress}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Text not found in publication sections yet, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
} else {
// Only warn if we truly couldn't find it after all retries
// Check if highlight was actually rendered before warning
const queryRoot = containerRef || document;
const existingHighlights = queryRoot.querySelectorAll("mark.highlight");
const highlightFound = Array.from(existingHighlights).some(mark =>
mark.textContent?.toLowerCase().includes(text.toLowerCase())
);
if (!highlightFound) {
console.debug(`[HighlightLayer] Text "${text}" not found in publication sections after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (content may not be loaded yet)`);
}
return;
}
} else {
// No sections found for this publication - might not be loaded yet
const retryKey = `${targetAddress}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] No sections found for publication ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
} else {
// Only warn if we truly couldn't find sections after all retries
console.debug(`[HighlightLayer] No sections found for publication ${targetAddress} after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (sections may not be loaded yet)`);
return;
}
}
} else {
// Section-scoped highlight - search in specific section element
sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
// AI-NOTE: The actual content is in a nested <section class="whitespace-normal publication-leather">
// created by contentParagraph snippet. Look for that nested section first.
const contentSection = sectionElement.querySelector('section.publication-leather');
if (contentSection) {
searchRoot = contentSection as HTMLElement;
console.debug(`[HighlightLayer] Found nested content section for ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
} else {
// Fallback to .section-content div if nested section not found
const contentDiv = sectionElement.querySelector(".section-content");
if (contentDiv) {
searchRoot = contentDiv as HTMLElement;
console.debug(`[HighlightLayer] Found section-content div for ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
} else {
// Last resort: search entire section
searchRoot = sectionElement;
console.debug(`[HighlightLayer] Found section element for ${targetAddress} (no nested content found), searching for text: "${text.substring(0, 50)}"`);
}
}
} else {
// Section not found - might not be loaded yet, retry if we haven't exceeded max retries
const retryKey = `${targetAddress}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Section element not found for ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
} else {
// Only warn if we truly couldn't find the section after all retries
console.debug(`[HighlightLayer] Section element not found for ${targetAddress} after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (section may not be loaded yet)`);
return;
}
}
}
}
// If no target address, use containerRef if available, otherwise document
if (!targetAddress) {
if (containerRef) {
searchRoot = containerRef;
console.debug(`[HighlightLayer] No target address, searching in containerRef for text: "${text.substring(0, 50)}"`);
} else {
searchRoot = document;
console.debug(`[HighlightLayer] No target address and no containerRef, searching in document for text: "${text.substring(0, 50)}"`);
}
}
if (!searchRoot) {
return;
}
// Use TreeWalker to find all text nodes
const walker = document.createTreeWalker(
searchRoot,
NodeFilter.SHOW_TEXT,
null,
);
// AI-NOTE: First, check if the text exists in the full content
// This helps us know if we should continue searching
// Normalize whitespace for matching - highlights may have different whitespace than DOM
const fullText = searchRoot instanceof HTMLElement
? searchRoot.textContent || searchRoot.innerText || ""
: "";
const normalizedSearchText = text.trim().replace(/\s+/g, ' ').toLowerCase();
const normalizedFullText = fullText.replace(/\s+/g, ' ').toLowerCase();
// AI-NOTE: If content is empty or very short, the section might still be loading
// Check if we have meaningful content (more than just whitespace/formatting)
const hasContent = fullText.trim().length > 5; // At least 5 characters of actual content
if (!hasContent && sectionElement && retryCount < MAX_TEXT_HIGHLIGHT_RETRIES) {
// Content not loaded yet, retry
const retryKey = `${targetAddress || 'no-address'}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Section content not loaded yet for ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
}
}
if (!normalizedFullText.includes(normalizedSearchText)) {
// Text not found - retry if section exists and we haven't exceeded max retries
if (sectionElement && retryCount < MAX_TEXT_HIGHLIGHT_RETRIES) {
const retryKey = `${targetAddress || 'no-address'}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Text "${text}" not found in content yet, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES}). Full text (first 200 chars): "${fullText.substring(0, 200)}"`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
}
}
// Only warn if we truly couldn't find the text after checking
// This might be a false negative if content is still loading
console.debug(
`[HighlightLayer] Text "${text}" not found in full content. Full text (first 200 chars): "${fullText.substring(0, 200)}"`,
);
return;
}
console.debug(
`[HighlightLayer] Text "${text}" found in full content. Searching text nodes...`,
);
// Collect all text nodes
const textNodes: Node[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
console.debug(`[HighlightLayer] Found ${textNodes.length} text nodes to search`);
// Log first few text nodes for debugging
const sampleNodes = textNodes.slice(0, 20).filter(n => n.textContent && n.textContent.trim().length > 0);
console.log(`[HighlightLayer] Sample text nodes (first 20 non-empty):`, sampleNodes.map(n => `"${n.textContent?.substring(0, 50)}${(n.textContent?.length || 0) > 50 ? '...' : ''}"`));
// Search for the highlight text in text nodes
// normalizedSearchText is already defined above with whitespace normalization
for (let i = 0; i < textNodes.length; i++) {
const textNode = textNodes[i];
const nodeText = textNode.textContent || "";
if (!nodeText || nodeText.trim().length === 0) {
continue; // Skip empty text nodes
}
// Normalize node text for comparison (collapse whitespace)
const normalizedNodeText = nodeText.replace(/\s+/g, ' ').toLowerCase();
// Log every text node that contains the search text (for debugging)
if (normalizedNodeText.includes(normalizedSearchText)) {
console.log(`[HighlightLayer] Text node ${i} contains search text: "${nodeText.substring(0, 100)}${nodeText.length > 100 ? '...' : ''}"`);
}
// Try normalized match first (case-insensitive, whitespace-normalized)
let index = normalizedNodeText.indexOf(normalizedSearchText);
// If normalized match found, find the actual position in the original text
// by searching for the normalized pattern in the original text
if (index !== -1) {
// Find the actual start position in the original text
// We need to account for whitespace differences
const searchPattern = text.trim();
let actualIndex = nodeText.toLowerCase().indexOf(searchPattern.toLowerCase());
// If that doesn't work, try finding by character position accounting for whitespace
if (actualIndex === -1) {
// Build a regex that matches the text with flexible whitespace
const escapedText = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flexiblePattern = escapedText.split(/\s+/).join('\\s+');
const regex = new RegExp(flexiblePattern, 'i');
const regexMatch = nodeText.match(regex);
if (regexMatch && regexMatch.index !== undefined) {
actualIndex = regexMatch.index;
// Update the search text to the actual matched text for highlighting
const actualMatchText = regexMatch[0];
// Use the actual matched text length for highlighting
const before = nodeText.substring(0, actualIndex);
const matchedText = actualMatchText;
const after = nodeText.substring(actualIndex + actualMatchText.length);
const parent = textNode.parentNode;
if (!parent) {
console.warn(`[HighlightLayer] Text node has no parent, skipping`);
continue;
}
// Skip if already highlighted
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
console.debug(`[HighlightLayer] Text node already highlighted, skipping`);
continue;
}
console.debug(`[HighlightLayer] Found match at index ${actualIndex}: "${matchedText}" in node: "${nodeText.substring(0, 100)}${nodeText.length > 100 ? '...' : ''}"`);
// Create highlight span with visible styling
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit";
highlightSpan.style.fontWeight = "inherit";
highlightSpan.textContent = matchedText;
// 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.debug(
`[HighlightLayer] Successfully highlighted text "${matchedText}" at index ${actualIndex} in node with text: "${nodeText.substring(0, 50)}${nodeText.length > 50 ? "..." : ""}"`,
);
// Clear retry count on success
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Only highlight first occurrence to avoid multiple highlights
}
} else {
// Found exact match (case-insensitive) - use it directly
index = actualIndex;
}
}
// Fallback: try exact match (case-sensitive) if normalized match failed
if (index === -1) {
index = nodeText.indexOf(text);
}
// Fallback: try case-insensitive exact match
if (index === -1) {
const lowerNodeText = nodeText.toLowerCase();
const lowerSearchText = text.toLowerCase();
index = lowerNodeText.indexOf(lowerSearchText);
}
if (index !== -1) {
const parent = textNode.parentNode;
if (!parent) {
console.warn(`[HighlightLayer] Text node has no parent, skipping`);
continue;
}
// Skip if already highlighted
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
console.debug(`[HighlightLayer] Text node already highlighted, skipping`);
continue;
}
// Find the actual match - use the original text length
const matchLength = text.length;
const before = nodeText.substring(0, index);
const match = nodeText.substring(index, index + matchLength);
const after = nodeText.substring(index + matchLength);
console.debug(`[HighlightLayer] Found match at index ${index}: "${match}" in node: "${nodeText.substring(0, 100)}${nodeText.length > 100 ? '...' : ''}"`);
// Create highlight span with visible styling
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit"; // Ensure text color is visible
highlightSpan.style.fontWeight = "inherit"; // Preserve font weight
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.debug(
`[HighlightLayer] Successfully highlighted text "${match}" at index ${index} in node with text: "${nodeText.substring(0, 50)}${nodeText.length > 50 ? "..." : ""}"`,
);
// Clear retry count on success
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Only highlight first occurrence to avoid multiple highlights
}
}
// AI-NOTE: If no match found, log for debugging
console.warn(
`[HighlightLayer] Could not find highlight text "${text}" in section. Searched ${textNodes.length} text nodes.`,
);
if (textNodes.length > 0) {
// Log more text nodes and their full content to help debug
const sampleNodes = textNodes.slice(0, 10);
console.debug(
`[HighlightLayer] Sample text nodes (first 10):`,
sampleNodes.map((n, i) => ({
index: i,
text: n.textContent?.substring(0, 100) || "",
fullLength: n.textContent?.length || 0,
parentTag: n.parentElement?.tagName || "unknown",
})),
);
// Also log the full text content of the search root to see what's actually there
if (searchRoot instanceof HTMLElement) {
const fullText = searchRoot.textContent || "";
console.debug(
`[HighlightLayer] Full text content of search root (first 500 chars): "${fullText.substring(0, 500)}"`,
);
console.debug(
`[HighlightLayer] Full text contains "${text}": ${fullText.toLowerCase().includes(text.toLowerCase())}`,
);
}
}
}
/**
* Render all highlights on the page
*/
function renderHighlights() {
if (!visible) {
return;
}
if (filteredHighlights.length === 0) {
return;
}
// AI-NOTE: When viewing a section directly (leaf), containerRef might not be set
// But we can still highlight by searching the document for section elements
// Only clear highlights if containerRef exists, otherwise clear from document
if (containerRef) {
clearHighlights();
} else {
// Clear highlights from entire document
const highlightElements = document.querySelectorAll("mark.highlight");
highlightElements.forEach((el) => {
const parent = el.parentNode;
if (parent) {
const textNode = document.createTextNode(el.textContent || "");
parent.replaceChild(textNode, el);
parent.normalize();
}
});
}
// Apply each highlight (only filtered highlights for current view)
for (const highlight of filteredHighlights) {
const content = highlight.content;
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.5)";
console.log(`[HighlightLayer] Processing highlight: content="${content}", color="${color}"`);
// 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;
console.log(`[HighlightLayer] Highlight targetAddress: "${targetAddress}"`);
// 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;
if (hasOffset) {
// Use position-based highlighting
const offsetStart = parseInt(offsetTag[1], 10);
const offsetEnd = parseInt(offsetTag[2], 10);
if (!isNaN(offsetStart) && !isNaN(offsetEnd)) {
highlightByPosition(offsetStart, offsetEnd, color, targetAddress);
} else if (content && content.trim().length > 0) {
findAndHighlightText(content, color, targetAddress);
}
} else if (content && content.trim().length > 0) {
// Fall back to text-based highlighting
findAndHighlightText(content, color, targetAddress);
}
}
// Check if any highlights were actually rendered
const renderedHighlights = containerRef
? containerRef.querySelectorAll("mark.highlight")
: document.querySelectorAll("mark.highlight");
console.log(
`[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`,
);
// AI-NOTE: Debug logging to help diagnose highlight visibility issues
if (renderedHighlights.length === 0 && filteredHighlights.length > 0) {
console.warn(`[HighlightLayer] No highlights rendered despite ${filteredHighlights.length} filtered highlights available. Container:`, containerRef, "Visible:", visible, "CurrentView:", currentViewAddress);
// Log highlight details for debugging
filteredHighlights.forEach((h, i) => {
const aTag = h.tags.find((tag) => tag[0] === "a");
const offsetTag = h.tags.find((tag) => tag[0] === "offset");
console.debug(`[HighlightLayer] Highlight ${i}: content="${h.content.substring(0, 50)}", targetAddress="${aTag?.[1]}", hasOffset=${!!offsetTag}`);
});
}
}
/**
* Clear all highlights from the page
* AI-NOTE: If containerRef is not set (e.g., blog entries), clear from document
*/
function clearHighlights() {
const queryRoot = containerRef || document;
const highlightElements = queryRoot.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();
}
});
}
// 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;
// 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(() => {
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
// AI-NOTE: Also watch containerRef to ensure it's set before rendering
// AI-NOTE: For blog entries viewed directly, containerRef might not be set, so we render without it
$effect(() => {
// This effect runs when either visible, highlights.length, or containerRef changes
const highlightCount = filteredHighlights.length;
if (visible && highlightCount > 0) {
// AI-NOTE: Retry rendering with increasing delays to handle async content loading
// This is especially important when viewing sections directly
let retryCount = 0;
const maxRetries = 5;
const retryDelays = [100, 300, 500, 1000, 2000];
const tryRender = () => {
renderHighlights();
const renderedCount = containerRef?.querySelectorAll("mark.highlight").length || 0;
if (renderedCount === 0 && retryCount < maxRetries) {
console.debug(`[HighlightLayer] No highlights rendered, retrying (attempt ${retryCount + 1}/${maxRetries})...`);
setTimeout(tryRender, retryDelays[retryCount]);
retryCount++;
} else if (renderedCount > 0) {
console.debug(`[HighlightLayer] Successfully rendered ${renderedCount} highlights after ${retryCount} retries`);
}
};
tryRender();
} 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());
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) {
if (!containerRef) {
return;
}
const content = highlight.content;
if (!content || content.trim().length === 0) {
return;
}
// Find the highlight mark element
const highlightMarks = containerRef.querySelectorAll("mark.highlight");
// Try exact match first
for (const mark of highlightMarks) {
const markText = mark.textContent?.toLowerCase() || "";
const searchText = content.toLowerCase();
if (markText === searchText) {
// 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)) {
mark.scrollIntoView({ behavior: "smooth", block: "center" });
mark.classList.add("highlight-flash");
setTimeout(() => {
mark.classList.remove("highlight-flash");
}, 1500);
return;
}
}
}
/**
* 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;
// 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() {
// 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 && filteredHighlights.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.5)"}
{@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>