Browse Source

added musical notation and make comment and highlight display default, except on publications

master^2
Silberengel 2 months ago
parent
commit
0f643e08ce
  1. 81
      src/lib/components/cards/BlogHeader.svelte
  2. 464
      src/lib/components/publications/HighlightLayer.svelte
  3. 83
      src/lib/components/publications/Publication.svelte
  4. 21
      src/lib/components/publications/PublicationSection.svelte
  5. 144
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  6. 1
      src/lib/utils/markup/asciidoctorExtensions.ts

81
src/lib/components/cards/BlogHeader.svelte

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { scale } from "svelte/transition";
import { Card } from "flowbite-svelte";
import { Card, Button, Popover } from "flowbite-svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import Interactions from "$components/util/Interactions.svelte";
import { quintOut } from "svelte/easing";
@ -11,17 +11,32 @@ @@ -11,17 +11,32 @@
import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { getNdkContext } from "$lib/ndk";
import { deleteEvent } from "$lib/services/deletion";
import {
EyeOutline,
EyeSlashOutline,
DotsVerticalOutline,
} from "flowbite-svelte-icons";
const {
rootId,
event,
onBlogUpdate,
active = true,
showActionsMenu = false,
commentsVisible = false,
highlightsVisible = false,
onToggleComments,
onToggleHighlights,
} = $props<{
rootId: string;
event: NDKEvent;
onBlogUpdate?: any;
active: boolean;
showActionsMenu?: boolean;
commentsVisible?: boolean;
highlightsVisible?: boolean;
onToggleComments?: () => void;
onToggleHighlights?: () => void;
}>();
const ndk = getNdkContext();
@ -84,6 +99,8 @@ @@ -84,6 +99,8 @@
function showBlog() {
onBlogUpdate?.(rootId);
}
let actionsMenuOpen = $state(false);
</script>
{#if title != null}
@ -117,6 +134,68 @@ @@ -117,6 +134,68 @@
{@render userBadge(authorPubkey, author, ndk)}
<span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span>
</div>
{#if showActionsMenu}
<div class="flex items-center">
<Button
type="button"
class="btn-leather !p-1 bg-primary-50 dark:bg-gray-800"
outline
onmouseenter={() => (actionsMenuOpen = true)}
>
<DotsVerticalOutline class="w-5 h-5" />
</Button>
{#if actionsMenuOpen}
<Popover
id="popover-blog-actions"
placement="bottom-end"
trigger="click"
class="popover-leather w-fit z-10"
onmouseleave={() => (actionsMenuOpen = false)}
>
<div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col text-nowrap">
<ul class="space-y-2">
<li>
<button
class="btn-leather w-full text-left"
onclick={() => {
onToggleComments?.();
actionsMenuOpen = false;
}}
>
{#if commentsVisible}
<EyeSlashOutline class="inline mr-2" />
Hide Comments
{:else}
<EyeOutline class="inline mr-2" />
Show Comments
{/if}
</button>
</li>
<li>
<button
class="btn-leather w-full text-left"
onclick={() => {
onToggleHighlights?.();
actionsMenuOpen = false;
}}
>
{#if highlightsVisible}
<EyeSlashOutline class="inline mr-2" />
Hide Highlights
{:else}
<EyeOutline class="inline mr-2" />
Show Highlights
{/if}
</button>
</li>
</ul>
</div>
</div>
</Popover>
{/if}
</div>
{/if}
</div>
<div

464
src/lib/components/publications/HighlightLayer.svelte

@ -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(filteredHighlights);
});
/**
@ -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(text);
// Try normalized match first (case-insensitive, whitespace-normalized)
let index = normalizedNodeText.indexOf(normalizedSearchText);
// 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 (highlights.length === 0) {
if (filteredHighlights.length === 0) {
return;
}
@ -591,8 +938,8 @@ @@ -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 @@ @@ -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 @@ @@ -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 = 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 @@ @@ -878,7 +1226,7 @@
</div>
{/if}
{#if visible && highlights.length > 0}
{#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"
>

83
src/lib/components/publications/Publication.svelte

@ -58,13 +58,21 @@ @@ -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<NDKEvent[]>([]);
let commentLayerRef: any = null;
let showArticleCommentUI = $state(false);
@ -118,6 +126,17 @@ @@ -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<Array<NDKEvent | null>>([]);
let isLoading = $state(false);
@ -652,6 +671,14 @@ @@ -652,6 +671,14 @@
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() {
return currentBlog !== null && $publicationColumnVisibility.inner;
@ -1540,22 +1567,44 @@ @@ -1540,22 +1567,44 @@
event={currentBlogEvent}
onBlogUpdate={loadBlog}
active={true}
showActionsMenu={true}
commentsVisible={commentsVisible}
highlightsVisible={highlightsVisible}
onToggleComments={toggleComments}
onToggleHighlights={toggleHighlights}
/>
{/if}
<!-- Article comments in discussion sidebar - only show when viewing full publication (not a section directly) -->
{#if !currentBlog && !isLeaf}
<!-- Article comments in discussion sidebar - show for full publication or blog entry -->
{#if (!currentBlog && !isLeaf) || (currentBlog && currentBlogEvent)}
<div class="flex flex-col w-full space-y-4">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
{#if articleComments.length === 0}
<p
class="text-sm text-gray-500 dark:text-gray-400 text-center py-4"
>
No comments yet. Be the first to comment!
</p>
{#if currentBlog && currentBlogEvent}
<!-- Blog entry comments -->
<SectionComments
sectionAddress={currentBlog}
comments={blogComments}
visible={commentsVisible}
/>
{#if blogComments.length === 0}
<p
class="text-sm text-gray-500 dark:text-gray-400 text-center py-4"
>
No comments yet. Be the first to comment!
</p>
{/if}
{:else}
<!-- Publication article comments -->
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
{#if articleComments.length === 0}
<p
class="text-sm text-gray-500 dark:text-gray-400 text-center py-4"
>
No comments yet. Be the first to comment!
</p>
{/if}
{/if}
</div>
{/if}
@ -1621,12 +1670,16 @@ @@ -1621,12 +1670,16 @@
{/if}
<!-- Highlight Layer Component -->
<!-- AI-NOTE: Pass currentViewAddress, rootAddress, and publicationType to filter highlights to current view -->
<HighlightLayer
bind:this={highlightLayerRef}
eventIds={allEventIds}
eventAddresses={allEventAddresses}
bind:visible={highlightsVisible}
{useMockHighlights}
currentViewAddress={currentViewAddress}
rootAddress={rootAddress}
publicationType={publicationType}
/>
<!-- Comment Layer Component -->

21
src/lib/components/publications/PublicationSection.svelte

@ -303,6 +303,27 @@ @@ -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);
});
});
</script>
<!-- Wrapper for positioning context -->

144
src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts

@ -8,6 +8,7 @@ import plantumlEncoder from "plantuml-encoder"; @@ -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( @@ -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 { @@ -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(
/<div class="listingblock">\s*<div class="content">\s*<pre class="highlight">\s*<code[^>]*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, "&quot;");
return `<div class="abc-block my-4">
<div id="${blockId}"
class="abc-diagram bg-white dark:bg-gray-800 px-6 py-4 rounded-lg border border-gray-300 dark:border-gray-600 shadow-lg"
data-abc-content="${escapedContent}"></div>
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show ABC source
</summary>
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto">
<code>${escapeHtml(rawContent)}</code>
</pre>
</details>
</div>`;
} catch (error) {
console.warn("Failed to process ABC block:", error);
return match;
}
},
);
// Fallback: match <pre> blocks whose content starts with X: (ABC notation header)
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre>([\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, "&quot;");
return `<div class="abc-block my-4">
<div id="${blockId}"
class="abc-diagram bg-white dark:bg-gray-800 px-6 py-4 rounded-lg border border-gray-300 dark:border-gray-600 shadow-lg"
data-abc-content="${escapedContent}"></div>
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show ABC source
</summary>
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto">
<code>${escapeHtml(rawContent)}</code>
</pre>
</details>
</div>`;
} 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 = '<p class="text-red-600 dark:text-red-400">Error rendering ABC notation. Please check the source.</p>';
}
});
}
}
// 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
*/

1
src/lib/utils/markup/asciidoctorExtensions.ts

@ -76,6 +76,7 @@ export function createAdvancedExtensions(): any { @@ -76,6 +76,7 @@ export function createAdvancedExtensions(): any {
registerDiagramBlock("plantuml");
registerDiagramBlock("tikz");
registerDiagramBlock("bpmn");
registerDiagramBlock("abc");
// --- END NEW ---
return extensions;

Loading…
Cancel
Save