diff --git a/src/lib/components/cards/BlogHeader.svelte b/src/lib/components/cards/BlogHeader.svelte index f3b35e9..e48f5b8 100644 --- a/src/lib/components/cards/BlogHeader.svelte +++ b/src/lib/components/cards/BlogHeader.svelte @@ -1,7 +1,7 @@ {#if title != null} @@ -117,6 +134,68 @@ {@render userBadge(authorPubkey, author, ndk)} {publishedAt()} + {#if showActionsMenu} +
+ + {#if actionsMenuOpen} + (actionsMenuOpen = false)} + > +
+
+
    +
  • + +
  • +
  • + +
  • +
+
+
+
+ {/if} +
+ {/if}
{ + 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(filteredHighlights); }); /** @@ -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
- // 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
+ // 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 @@ // 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 @@ 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 @@ 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 @@ 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 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; + } } - // Try exact match first (case-sensitive) - let index = nodeText.indexOf(text); + // Fallback: try exact match (case-sensitive) if normalized match failed + if (index === -1) { + index = nodeText.indexOf(text); + } - // If exact match fails, try case-insensitive + // Fallback: try case-insensitive exact match if (index === -1) { - const normalizedNodeText = nodeText.toLowerCase(); - const normalizedSearchText = text.toLowerCase(); - index = normalizedNodeText.indexOf(normalizedSearchText); + const lowerNodeText = nodeText.toLowerCase(); + const lowerSearchText = text.toLowerCase(); + index = lowerNodeText.indexOf(lowerSearchText); } if (index !== -1) { @@ -569,7 +916,7 @@ return; } - if (highlights.length === 0) { + if (filteredHighlights.length === 0) { return; } @@ -591,8 +938,8 @@ }); } - // Apply each highlight - for (const highlight of highlights) { + // 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)"; @@ -634,10 +981,10 @@ ); // AI-NOTE: Debug logging to help diagnose highlight visibility issues - if (renderedHighlights.length === 0 && highlights.length > 0) { - console.warn(`[HighlightLayer] No highlights rendered despite ${highlights.length} highlights available. Container:`, containerRef, "Visible:", visible); + 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 - highlights.forEach((h, i) => { + 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}`); @@ -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 @@ // 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 = highlights.length; + const highlightCount = filteredHighlights.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 @@
{/if} -{#if visible && highlights.length > 0} +{#if visible && filteredHighlights.length > 0}
diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 5388387..cc03224 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -58,13 +58,21 @@ const ndk = getNdkContext(); + // AI-NOTE: Default visibility logic: + // - Blogs: comments and highlights ON by default + // - Articles/sections: comments and highlights ON by default + // - Publication indexes (kind 30040): comments and highlights OFF by default (for undisturbed reading) + const isPublicationIndex = publicationType === "publication" && indexEvent.kind === 30040; + const defaultCommentsVisible = !isPublicationIndex; + const defaultHighlightsVisible = !isPublicationIndex; + // Highlight layer state - let highlightsVisible = $state(false); + let highlightsVisible = $state(defaultHighlightsVisible); let highlightLayerRef: any = null; let publicationContentRef: HTMLElement | null = $state(null); // Comment layer state - let commentsVisible = $state(false); + let commentsVisible = $state(defaultCommentsVisible); let comments = $state([]); let commentLayerRef: any = null; let showArticleCommentUI = $state(false); @@ -118,6 +126,17 @@ }), ); + // Filter comments for the current blog entry + // AI-NOTE: NIP-22: Uppercase A tag points to root scope (blog entry address) + let blogComments = $derived.by(() => { + if (!currentBlog) return []; + return comments.filter((comment) => { + // NIP-22: Look for uppercase A tag (root scope) + const rootATag = comment.tags.find((t) => t[0] === "A"); + return rootATag && rootATag[1] === currentBlog; + }); + }); + // #region Loading let leaves = $state>([]); let isLoading = $state(false); @@ -651,6 +670,14 @@ let currentBlog: null | string = $state(null); let currentBlogEvent: null | NDKEvent = $state(null); const isLeaf = $derived(indexEvent.kind === 30041); + + // AI-NOTE: Determine current view address for filtering highlights + // - If viewing a blog entry, use the blog address + // - If viewing a section directly (leaf), use the root address + // - Otherwise (publication index), undefined (show all highlights) + const currentViewAddress = $derived( + currentBlog || (isLeaf ? rootAddress : undefined) + ); function isInnerActive() { @@ -1540,22 +1567,44 @@ event={currentBlogEvent} onBlogUpdate={loadBlog} active={true} + showActionsMenu={true} + commentsVisible={commentsVisible} + highlightsVisible={highlightsVisible} + onToggleComments={toggleComments} + onToggleHighlights={toggleHighlights} /> {/if} - - {#if !currentBlog && !isLeaf} + + {#if (!currentBlog && !isLeaf) || (currentBlog && currentBlogEvent)}
- - {#if articleComments.length === 0} -

- No comments yet. Be the first to comment! -

+ {#if currentBlog && currentBlogEvent} + + + {#if blogComments.length === 0} +

+ No comments yet. Be the first to comment! +

+ {/if} + {:else} + + + {#if articleComments.length === 0} +

+ No comments yet. Be the first to comment! +

+ {/if} {/if}
{/if} @@ -1621,12 +1670,16 @@ {/if} + diff --git a/src/lib/components/publications/PublicationSection.svelte b/src/lib/components/publications/PublicationSection.svelte index 50d02a9..1f50fe1 100644 --- a/src/lib/components/publications/PublicationSection.svelte +++ b/src/lib/components/publications/PublicationSection.svelte @@ -303,6 +303,27 @@ ref(sectionRef); }); + + // Initialize ABC notation blocks after content is rendered + $effect(() => { + if (typeof window === "undefined") return; + + // Watch for content changes + leafContent.then(() => { + // Wait for content to be rendered in DOM + const initABC = () => { + if (typeof (window as any).initializeABCBlocks === "function") { + (window as any).initializeABCBlocks(); + } else { + // If function not available yet, wait a bit and try again + setTimeout(initABC, 100); + } + }; + + // Initialize after a short delay to ensure DOM is ready + setTimeout(initABC, 200); + }); + }); diff --git a/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts b/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts index 14b2344..b83fcd4 100644 --- a/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts +++ b/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts @@ -8,6 +8,7 @@ import plantumlEncoder from "plantuml-encoder"; * - PlantUML diagrams * - BPMN diagrams * - TikZ diagrams + * - ABC notation (music) */ export async function postProcessAdvancedAsciidoctorHtml( html: string, @@ -25,6 +26,8 @@ export async function postProcessAdvancedAsciidoctorHtml( processedHtml = processBPMNBlocks(processedHtml); // Process TikZ blocks processedHtml = processTikZBlocks(processedHtml); + // Process ABC notation blocks + processedHtml = processABCBlocks(processedHtml); // After all processing, apply highlight.js if available if ( typeof globalThis !== "undefined" && @@ -366,6 +369,147 @@ function processTikZBlocks(html: string): string { return html; } +/** + * Processes ABC notation blocks in HTML content + * Uses data attributes to mark blocks for rendering, which will be processed by a global function + */ +function processABCBlocks(html: string): string { + // Match code blocks with class 'language-abc' or 'abc' + html = html.replace( + /
\s*
\s*
\s*]*class="[^"]*(?:language-abc|abc)[^"]*"[^>]*>([\s\S]*?)<\/code>\s*<\/pre>\s*<\/div>\s*<\/div>/g,
+    (match, content) => {
+      try {
+        const rawContent = decodeHTMLEntities(content);
+        const blockId = `abc-${Math.random().toString(36).substring(2, 9)}`;
+        // Escape the ABC content for data attribute
+        const escapedContent = escapeHtml(rawContent).replace(/"/g, """);
+        return `
+
+
+ + Show ABC source + +
+              ${escapeHtml(rawContent)}
+            
+
+
`; + } catch (error) { + console.warn("Failed to process ABC block:", error); + return match; + } + }, + ); + + // Fallback: match
 blocks whose content starts with X: (ABC notation header)
+  html = html.replace(
+    /
\s*
\s*
([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
+    (match, content) => {
+      const lines = content.trim().split("\n");
+      // ABC notation typically starts with X: (tune number) or contains ABC-specific patterns
+      if (
+        lines.some((line: string) => 
+          line.trim().startsWith("X:") ||
+          line.trim().startsWith("T:") ||
+          line.trim().startsWith("M:") ||
+          line.trim().startsWith("K:")
+        )
+      ) {
+        try {
+          const rawContent = decodeHTMLEntities(content);
+          const blockId = `abc-${Math.random().toString(36).substring(2, 9)}`;
+          const escapedContent = escapeHtml(rawContent).replace(/"/g, """);
+          return `
+
+
+ + Show ABC source + +
+                ${escapeHtml(rawContent)}
+              
+
+
`; + } catch (error) { + console.warn("Failed to process ABC fallback block:", error); + return match; + } + } + return match; + }, + ); + + return html; +} + +/** + * Initializes ABC notation rendering for all blocks marked with data-abc-content + * This function is called after HTML is inserted into the DOM + */ +function initializeABCBlocks(): void { + if (typeof window === "undefined") return; + + const abcBlocks = document.querySelectorAll('[data-abc-content]'); + if (abcBlocks.length === 0) return; + + // Load abcjs from CDN if not already loaded + if (typeof (window as any).ABCJS === "undefined") { + const script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/abcjs@6.2.0/dist/abcjs-basic.min.js"; + script.onload = () => { + renderAllABCBlocks(); + }; + script.onerror = () => { + console.warn("Failed to load abcjs library"); + }; + document.head.appendChild(script); + } else { + renderAllABCBlocks(); + } + + function renderAllABCBlocks(): void { + const abcjs = (window as any).ABCJS; + if (!abcjs) return; + + abcBlocks.forEach((block) => { + const container = block as HTMLElement; + const abcContent = container.getAttribute("data-abc-content"); + if (!abcContent) return; + + // Decode HTML entities + const textarea = document.createElement("textarea"); + textarea.innerHTML = abcContent; + const decodedContent = textarea.value; + + try { + abcjs.renderAbc(container.id || container, decodedContent, { + responsive: "resize", + staffwidth: 740, + scale: 1.0, + paddingleft: 20, + paddingright: 20, + paddingtop: 20, + paddingbottom: 20, + }); + // Remove data attribute after rendering to avoid re-rendering + container.removeAttribute("data-abc-content"); + } catch (error) { + console.warn("Failed to render ABC notation:", error); + container.innerHTML = '

Error rendering ABC notation. Please check the source.

'; + } + }); + } +} + +// Make initializeABCBlocks available globally so it can be called from Svelte components +if (typeof window !== "undefined") { + (window as any).initializeABCBlocks = initializeABCBlocks; +} + /** * Escapes HTML characters for safe display */ diff --git a/src/lib/utils/markup/asciidoctorExtensions.ts b/src/lib/utils/markup/asciidoctorExtensions.ts index 0a7b646..e4651d1 100644 --- a/src/lib/utils/markup/asciidoctorExtensions.ts +++ b/src/lib/utils/markup/asciidoctorExtensions.ts @@ -76,6 +76,7 @@ export function createAdvancedExtensions(): any { registerDiagramBlock("plantuml"); registerDiagramBlock("tikz"); registerDiagramBlock("bpmn"); + registerDiagramBlock("abc"); // --- END NEW --- return extensions;