@ -32,6 +32,9 @@
@@ -32,6 +32,9 @@
eventAddresses = [],
visible = $bindable(false),
useMockHighlights = false,
currentViewAddress,
rootAddress,
publicationType,
}: {
eventId?: string;
eventAddress?: string;
@ -39,6 +42,9 @@
@@ -39,6 +42,9 @@
eventAddresses?: string[];
visible?: boolean;
useMockHighlights?: boolean;
currentViewAddress?: string;
rootAddress?: string;
publicationType?: string;
} = $props();
const ndk = getNdkContext();
@ -64,9 +70,51 @@
@@ -64,9 +70,51 @@
return map;
});
// Derived state for grouped highlights
// 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(highlights);
return groupHighlightsByAuthor(filteredH ighlights);
});
/**
@ -327,46 +375,256 @@
@@ -327,46 +375,256 @@
let sectionElement: HTMLElement | null = null;
if (targetAddress) {
// Search in entire document, not just containerRef
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 )} "`);
// 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 {
// 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 )} "`);
// 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 {
// 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 )} "`);
// 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 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;
// 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 {
console.warn(`[HighlightLayer] Section element not found for ${ targetAddress } after ${ MAX_TEXT_HIGHLIGHT_RETRIES } retries, giving up.`);
return;
// 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;
}
}
}
} else {
// No target address - use containerRef if available, otherwise document
}
// 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 )} "`);
@ -389,11 +647,12 @@
@@ -389,11 +647,12 @@
// 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().toLowerCase();
const normalizedFullText = fullText.toLowerCase();
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)
@ -429,7 +688,9 @@
@@ -429,7 +688,9 @@
return;
}
}
console.warn(
// 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;
@ -453,7 +714,7 @@
@@ -453,7 +714,7 @@
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
// AI-NOTE: Use simple indexOf for exact matching - the text should match exactly
// normalizedSearchText is already defined above with whitespace normalization
for (let i = 0; i < textNodes.length ; i ++) {
const textNode = textNodes[i];
const nodeText = textNode.textContent || "";
@ -461,19 +722,105 @@
@@ -461,19 +722,105 @@
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 (nodeText.toLowerCase().includes(text.toLowerCase())) {
console.log(`[HighlightLayer] Text node ${ i } contains search text: "${ nodeText } "`);
if (normalizedNodeText.includes(normalizedSearchText )) {
console.log(`[HighlightLayer] Text node ${ i } contains search text: "${ nodeText . substring ( 0 , 100 )} ${ nodeText . length > 100 ? '...' : '' } "`);
}
// Try exact match first (case-sensitive )
let index = nodeText.indexOf(t ext);
// Try normalized match first (case-insensitive, whitespace-normalized )
let index = normalizedNodeText.indexOf(normalizedSearchT ext);
// If exact match fails, try case-insensitive
// 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) {
const normalizedNodeText = nodeText.toLowerCase();
const normalizedSearchText = text.toLowerCase();
index = normalizedNodeText.indexOf(normalizedSearchText);
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) {
@ -569,7 +916,7 @@
@@ -569,7 +916,7 @@
return;
}
if (h ighlights.length === 0) {
if (filteredH ighlights.length === 0) {
return;
}
@ -591,8 +938,8 @@
@@ -591,8 +938,8 @@
});
}
// Apply each highlight
for (const highlight of h ighlights) {
// Apply each highlight (only filtered highlights for current view)
for (const highlight of filteredH ighlights) {
const content = highlight.content;
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.5)";
@ -634,10 +981,10 @@
@@ -634,10 +981,10 @@
);
// AI-NOTE: Debug logging to help diagnose highlight visibility issues
if (renderedHighlights.length === 0 && h ighlights.length > 0) {
console.warn(`[HighlightLayer] No highlights rendered despite ${ h ighlights. length } highlights available. Container:`, containerRef, "Visible:", visible);
if (renderedHighlights.length === 0 && filteredH ighlights.length > 0) {
console.warn(`[HighlightLayer] No highlights rendered despite ${ filteredH ighlights. length } filtered highlights available. Container:`, containerRef, "Visible:", visible, "CurrentView:", currentViewAddress );
// Log highlight details for debugging
h ighlights.forEach((h, i) => {
filteredH ighlights.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 } `);
@ -647,11 +994,11 @@
@@ -647,11 +994,11 @@
/**
* Clear all highlights from the page
* AI-NOTE: If containerRef is not set (e.g., blog entries), clear from document
*/
function clearHighlights() {
if (!containerRef) return;
const highlightElements = containerRef.querySelectorAll("mark.highlight");
const queryRoot = containerRef || document;
const highlightElements = queryRoot.querySelectorAll("mark.highlight");
highlightElements.forEach((el) => {
const parent = el.parentNode;
if (parent) {
@ -701,11 +1048,12 @@
@@ -701,11 +1048,12 @@
// 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 = h ighlights.length;
const highlightCount = filteredH ighlights.length;
if (visible && highlightCount > 0 && containerRef ) {
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;
@ -878,7 +1226,7 @@
@@ -878,7 +1226,7 @@
< / div >
{ /if }
{ #if visible && h ighlights. length > 0 }
{ #if visible && filteredH ighlights. 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"
>