- {@render userBadge(authorPubkey, author, ndk)}
-
{publishedAt()}
+ {#if active}
+
+
{
+ // Don't trigger if clicking on CardActions or its children
+ const target = e.target as HTMLElement;
+ if (target.closest('.card-actions') || target.closest('button[type="button"]')) {
+ return;
+ }
+ showBlog();
+ }}
+ onkeydown={(e: KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ const target = e.target as HTMLElement;
+ if (!target.closest('.card-actions') && !target.closest('button[type="button"]')) {
+ showBlog();
+ }
+ }
+ }}
+ >
+
+
+
+
+ {@render userBadge(authorPubkey, author, ndk)}
+ {publishedAt()}
+
-
-
- {#if image}
-
- {:else}
-
-
- {/if}
-
-
-
-
+
- {/if}
-
-
-
-
+
+
+
e.stopPropagation()}
+ onkeydown={(e) => e.stopPropagation()}
+ >
+
+
+
+
+
+ {:else}
+
+
showBlog()}
+ onkeydown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ showBlog();
+ }
+ }}
+ >
+
{title}
+
+
{publishedAt()}
+
•
+
{author}
-
+ {/if}
{/if}
diff --git a/src/lib/components/publications/CommentButton.svelte b/src/lib/components/publications/CommentButton.svelte
index fb6ee25..95ae02e 100644
--- a/src/lib/components/publications/CommentButton.svelte
+++ b/src/lib/components/publications/CommentButton.svelte
@@ -129,12 +129,6 @@
commentEvent.tags.push(["e", eventId, relayHint]);
}
- console.log("[CommentButton] Created NIP-22 comment event:", {
- kind: commentEvent.kind,
- tags: commentEvent.tags,
- content: commentEvent.content,
- });
-
return commentEvent;
}
@@ -179,8 +173,6 @@
await commentEvent.sign($userStore.signer);
}
- console.log("[CommentButton] Signed comment event:", commentEvent.rawEvent());
-
// Build relay list following the same pattern as eventServices
const relays = [
...communityRelays,
@@ -191,8 +183,6 @@
// Remove duplicates
const uniqueRelays = Array.from(new Set(relays));
- console.log("[CommentButton] Publishing to relays:", uniqueRelays);
-
const signedEvent = {
...plainEvent,
id: commentEvent.id,
@@ -217,11 +207,9 @@
clearTimeout(timeout);
if (ok) {
publishedCount++;
- console.log(`[CommentButton] Published to ${relayUrl}`);
WebSocketPool.instance.release(ws);
resolve();
} else {
- console.warn(`[CommentButton] ${relayUrl} rejected: ${message}`);
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
@@ -240,8 +228,6 @@
throw new Error("Failed to publish to any relays");
}
- console.log(`[CommentButton] Published to ${publishedCount} relay(s)`);
-
// Success!
success = true;
commentContent = "";
diff --git a/src/lib/components/publications/CommentLayer.svelte b/src/lib/components/publications/CommentLayer.svelte
index 0ddda39..26f574a 100644
--- a/src/lib/components/publications/CommentLayer.svelte
+++ b/src/lib/components/publications/CommentLayer.svelte
@@ -36,7 +36,6 @@
async function fetchComments() {
// Prevent concurrent fetches
if (loading) {
- console.log("[CommentLayer] Already loading, skipping fetch");
return;
}
@@ -55,8 +54,6 @@
// AI-NOTE: Mock mode allows testing comment UI without publishing to relays
// This is useful for development and demonstrating the comment system
if (useMockComments) {
- console.log(`[CommentLayer] MOCK MODE - Generating mock comments for ${allAddresses.length} sections`);
-
try {
// Generate mock comment data
const mockComments = generateMockCommentsForSections(allAddresses);
@@ -64,7 +61,6 @@
// Convert to NDKEvent instances (same as real events)
comments = mockComments.map(rawEvent => new NDKEventClass(ndk, rawEvent));
- console.log(`[CommentLayer] Generated ${comments.length} mock comments`);
loading = false;
return;
} catch (err) {
@@ -74,11 +70,6 @@
}
}
- console.log(`[CommentLayer] Fetching comments for:`, {
- eventIds: allEventIds,
- addresses: allAddresses
- });
-
try {
// Build filter for kind 1111 comment events
// IMPORTANT: Use only #a tags because filters are AND, not OR
@@ -96,8 +87,6 @@
filter["#e"] = allEventIds;
}
- console.log(`[CommentLayer] Fetching with filter:`, JSON.stringify(filter, null, 2));
-
// Build explicit relay set (same pattern as HighlightLayer)
const relays = [
...communityRelays,
@@ -105,7 +94,6 @@
...$activeInboxRelays,
];
const uniqueRelays = Array.from(new Set(relays));
- console.log(`[CommentLayer] Fetching from ${uniqueRelays.length} relays:`, uniqueRelays);
/**
* Use WebSocketPool with nostr-tools protocol instead of NDK
@@ -124,31 +112,55 @@
*/
const subscriptionId = `comments-${Date.now()}`;
const receivedEventIds = new Set
();
- let eoseCount = 0;
+ let responseCount = 0;
+ const totalRelays = uniqueRelays.length;
+
+ // AI-NOTE: Helper to check if all relays have responded and clear loading state early
+ const checkAllResponses = () => {
+ responseCount++;
+ if (responseCount >= totalRelays && loading) {
+ loading = false;
+ }
+ };
const fetchPromises = uniqueRelays.map(async (relayUrl) => {
try {
- console.log(`[CommentLayer] Connecting to ${relayUrl}`);
const ws = await WebSocketPool.instance.acquire(relayUrl);
return new Promise((resolve) => {
+ let released = false;
+ let resolved = false;
+
+ const releaseConnection = () => {
+ if (released) {
+ return;
+ }
+ released = true;
+ try {
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
+ ws.send(JSON.stringify(["CLOSE", subscriptionId]));
+ }
+ ws.removeEventListener("message", messageHandler);
+ WebSocketPool.instance.release(ws);
+ } catch (err) {
+ console.error(`[CommentLayer] Error releasing connection to ${relayUrl}:`, err);
+ }
+ };
+
+ const safeResolve = () => {
+ if (!resolved) {
+ resolved = true;
+ checkAllResponses();
+ resolve();
+ }
+ };
+
const messageHandler = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
- // Log ALL messages from relay.nostr.band for debugging
- if (relayUrl.includes('relay.nostr.band')) {
- console.log(`[CommentLayer] RAW message from ${relayUrl}:`, message);
- }
-
if (message[0] === "EVENT" && message[1] === subscriptionId) {
const rawEvent = message[2];
- console.log(`[CommentLayer] EVENT from ${relayUrl}:`, {
- id: rawEvent.id,
- kind: rawEvent.kind,
- content: rawEvent.content.substring(0, 50),
- tags: rawEvent.tags
- });
// Avoid duplicates
if (!receivedEventIds.has(rawEvent.id)) {
@@ -157,19 +169,11 @@
// Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent);
comments = [...comments, ndkEvent];
- console.log(`[CommentLayer] Added comment, total now: ${comments.length}`);
}
} else if (message[0] === "EOSE" && message[1] === subscriptionId) {
- eoseCount++;
- console.log(`[CommentLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`);
-
- // Close subscription
- ws.send(JSON.stringify(["CLOSE", subscriptionId]));
- ws.removeEventListener("message", messageHandler);
- WebSocketPool.instance.release(ws);
- resolve();
- } else if (message[0] === "NOTICE") {
- console.warn(`[CommentLayer] NOTICE from ${relayUrl}:`, message[1]);
+ // Close subscription and release connection
+ releaseConnection();
+ safeResolve();
}
} catch (err) {
console.error(`[CommentLayer] Error processing message from ${relayUrl}:`, err);
@@ -180,41 +184,25 @@
// Send REQ
const req = ["REQ", subscriptionId, filter];
- if (relayUrl.includes('relay.nostr.band')) {
- console.log(`[CommentLayer] Sending REQ to ${relayUrl}:`, JSON.stringify(req));
- } else {
- console.log(`[CommentLayer] Sending REQ to ${relayUrl}`);
- }
ws.send(JSON.stringify(req));
// Timeout per relay (5 seconds)
setTimeout(() => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify(["CLOSE", subscriptionId]));
- ws.removeEventListener("message", messageHandler);
- WebSocketPool.instance.release(ws);
- }
- resolve();
+ releaseConnection();
+ safeResolve();
}, 5000);
});
} catch (err) {
console.error(`[CommentLayer] Error connecting to ${relayUrl}:`, err);
+ // Mark this relay as responded if connection fails
+ checkAllResponses();
}
});
// Wait for all relays to respond or timeout
- await Promise.all(fetchPromises);
-
- console.log(`[CommentLayer] Fetched ${comments.length} comments`);
-
- if (comments.length > 0) {
- console.log(`[CommentLayer] Comments summary:`, comments.map(c => ({
- content: c.content.substring(0, 30) + "...",
- address: c.tags.find(t => t[0] === "a")?.[1],
- author: c.pubkey.substring(0, 8)
- })));
- }
-
+ await Promise.allSettled(fetchPromises);
+
+ // Ensure loading is cleared even if checkAllResponses didn't fire
loading = false;
} catch (err) {
@@ -232,8 +220,6 @@
const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0;
- console.log(`[CommentLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`);
-
// Only fetch if:
// 1. We have event data
// 2. The count has changed since last fetch
@@ -246,7 +232,6 @@
// Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => {
- console.log(`[CommentLayer] Event data stabilized at ${currentCount} events, fetching comments...`);
lastFetchedCount = currentCount;
fetchComments();
}, 500);
@@ -264,8 +249,6 @@
* Public method to refresh comments (e.g., after creating a new one)
*/
export function refresh() {
- console.log("[CommentLayer] Manual refresh triggered");
-
// Clear existing comments
comments = [];
diff --git a/src/lib/components/publications/HighlightLayer.svelte b/src/lib/components/publications/HighlightLayer.svelte
index 48b00f6..bcc1406 100644
--- a/src/lib/components/publications/HighlightLayer.svelte
+++ b/src/lib/components/publications/HighlightLayer.svelte
@@ -75,7 +75,6 @@
async function fetchHighlights() {
// Prevent concurrent fetches
if (loading) {
- console.log("[HighlightLayer] Already loading, skipping fetch");
return;
}
@@ -99,10 +98,6 @@
// AI-NOTE: Mock mode allows testing highlight UI without publishing to relays
// This is useful for development and demonstrating the highlight system
if (useMockHighlights) {
- console.log(
- `[HighlightLayer] MOCK MODE - Generating mock highlights for ${allAddresses.length} sections`,
- );
-
try {
// Generate mock highlight data
const mockHighlights = generateMockHighlightsForSections(allAddresses);
@@ -112,9 +107,6 @@
(rawEvent) => new NDKEventClass(ndk, rawEvent),
);
- console.log(
- `[HighlightLayer] Generated ${highlights.length} mock highlights`,
- );
loading = false;
return;
} catch (err) {
@@ -127,11 +119,6 @@
}
}
- console.log(`[HighlightLayer] Fetching highlights for:`, {
- eventIds: allEventIds,
- addresses: allAddresses,
- });
-
try {
// Build filter for kind 9802 highlight events
// IMPORTANT: Use only #a tags because filters are AND, not OR
@@ -149,11 +136,6 @@
filter["#e"] = allEventIds;
}
- console.log(
- `[HighlightLayer] Fetching with filter:`,
- JSON.stringify(filter, null, 2),
- );
-
// Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton)
const relays = [
...communityRelays,
@@ -161,10 +143,6 @@
...$activeInboxRelays,
];
const uniqueRelays = Array.from(new Set(relays));
- console.log(
- `[HighlightLayer] Fetching from ${uniqueRelays.length} relays:`,
- uniqueRelays,
- );
/**
* Use WebSocketPool with nostr-tools protocol instead of NDK
@@ -186,30 +164,41 @@
const fetchPromises = uniqueRelays.map(async (relayUrl) => {
try {
- console.log(`[HighlightLayer] Connecting to ${relayUrl}`);
const ws = await WebSocketPool.instance.acquire(relayUrl);
return new Promise((resolve) => {
+ let released = false;
+ let resolved = false;
+
+ const releaseConnection = () => {
+ if (released) {
+ return;
+ }
+ released = true;
+ try {
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
+ ws.send(JSON.stringify(["CLOSE", subscriptionId]));
+ }
+ ws.removeEventListener("message", messageHandler);
+ WebSocketPool.instance.release(ws);
+ } catch (err) {
+ console.error(`[HighlightLayer] Error releasing connection to ${relayUrl}:`, err);
+ }
+ };
+
+ const safeResolve = () => {
+ if (!resolved) {
+ resolved = true;
+ resolve();
+ }
+ };
+
const messageHandler = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
- // Log ALL messages from relay.nostr.band for debugging
- if (relayUrl.includes("relay.nostr.band")) {
- console.log(
- `[HighlightLayer] RAW message from ${relayUrl}:`,
- message,
- );
- }
-
if (message[0] === "EVENT" && message[1] === subscriptionId) {
const rawEvent = message[2];
- console.log(`[HighlightLayer] EVENT from ${relayUrl}:`, {
- id: rawEvent.id,
- kind: rawEvent.kind,
- content: rawEvent.content.substring(0, 50),
- tags: rawEvent.tags,
- });
// Avoid duplicates
if (!receivedEventIds.has(rawEvent.id)) {
@@ -218,29 +207,16 @@
// Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent);
highlights = [...highlights, ndkEvent];
- console.log(
- `[HighlightLayer] Added highlight, total now: ${highlights.length}`,
- );
}
} else if (
message[0] === "EOSE" &&
message[1] === subscriptionId
) {
eoseCount++;
- console.log(
- `[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`,
- );
- // Close subscription
- ws.send(JSON.stringify(["CLOSE", subscriptionId]));
- ws.removeEventListener("message", messageHandler);
- WebSocketPool.instance.release(ws);
- resolve();
- } else if (message[0] === "NOTICE") {
- console.warn(
- `[HighlightLayer] NOTICE from ${relayUrl}:`,
- message[1],
- );
+ // Close subscription and release connection
+ releaseConnection();
+ safeResolve();
}
} catch (err) {
console.error(
@@ -254,24 +230,12 @@
// Send REQ
const req = ["REQ", subscriptionId, filter];
- if (relayUrl.includes("relay.nostr.band")) {
- console.log(
- `[HighlightLayer] Sending REQ to ${relayUrl}:`,
- JSON.stringify(req),
- );
- } else {
- console.log(`[HighlightLayer] Sending REQ to ${relayUrl}`);
- }
ws.send(JSON.stringify(req));
// Timeout per relay (5 seconds)
setTimeout(() => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify(["CLOSE", subscriptionId]));
- ws.removeEventListener("message", messageHandler);
- WebSocketPool.instance.release(ws);
- }
- resolve();
+ releaseConnection();
+ safeResolve();
}, 5000);
});
} catch (err) {
@@ -285,19 +249,6 @@
// Wait for all relays to respond or timeout
await Promise.all(fetchPromises);
- console.log(`[HighlightLayer] Fetched ${highlights.length} highlights`);
-
- if (highlights.length > 0) {
- console.log(
- `[HighlightLayer] Highlights summary:`,
- highlights.map((h) => ({
- content: h.content.substring(0, 30) + "...",
- address: h.tags.find((t) => t[0] === "a")?.[1],
- author: h.pubkey.substring(0, 8),
- })),
- );
- }
-
loading = false;
// Rendering is handled by the visibility/highlights effect
@@ -321,9 +272,6 @@
targetAddress?: string,
): boolean {
if (!containerRef) {
- console.log(
- `[HighlightLayer] Cannot highlight by position - no containerRef`,
- );
return false;
}
@@ -333,30 +281,10 @@
const sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
searchRoot = sectionElement;
- console.log(
- `[HighlightLayer] Highlighting in specific section: ${targetAddress}`,
- );
- } else {
- console.log(
- `[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`,
- );
}
}
- console.log(
- `[HighlightLayer] Applying position-based highlight ${offsetStart}-${offsetEnd}`,
- );
- const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color);
-
- if (result) {
- console.log(
- `[HighlightLayer] Successfully applied position-based highlight`,
- );
- } else {
- console.log(`[HighlightLayer] Failed to apply position-based highlight`);
- }
-
- return result;
+ return highlightByOffset(searchRoot, offsetStart, offsetEnd, color);
}
/**
@@ -371,9 +299,6 @@
targetAddress?: string,
): void {
if (!containerRef || !text || text.trim().length === 0) {
- console.log(
- `[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`,
- );
return;
}
@@ -383,21 +308,9 @@
const sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
searchRoot = sectionElement;
- console.log(
- `[HighlightLayer] Searching in specific section: ${targetAddress}`,
- );
- } else {
- console.log(
- `[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`,
- );
}
}
- console.log(
- `[HighlightLayer] Searching for text: "${text}" in`,
- searchRoot,
- );
-
// Use TreeWalker to find all text nodes
const walker = document.createTreeWalker(
searchRoot,
@@ -412,22 +325,11 @@
}
// Search for the highlight text in text nodes
- console.log(
- `[HighlightLayer] Searching through ${textNodes.length} text nodes`,
- );
-
for (const textNode of textNodes) {
const nodeText = textNode.textContent || "";
const index = nodeText.toLowerCase().indexOf(text.toLowerCase());
if (index !== -1) {
- console.log(
- `[HighlightLayer] Found match in text node:`,
- nodeText.substring(
- Math.max(0, index - 20),
- Math.min(nodeText.length, index + text.length + 20),
- ),
- );
const parent = textNode.parentNode;
if (!parent) continue;
@@ -459,44 +361,26 @@
parent.replaceChild(fragment, textNode);
- console.log(`[HighlightLayer] Highlighted text:`, match);
return; // Only highlight first occurrence to avoid multiple highlights
}
}
-
- console.log(`[HighlightLayer] No match found for text: "${text}"`);
}
/**
* Render all highlights on the page
*/
function renderHighlights() {
- console.log(
- `[HighlightLayer] renderHighlights called - visible: ${visible}, containerRef: ${!!containerRef}, highlights: ${highlights.length}`,
- );
-
if (!visible || !containerRef) {
- console.log(
- `[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`,
- );
return;
}
if (highlights.length === 0) {
- console.log(`[HighlightLayer] No highlights to render`);
return;
}
// Clear existing highlights
clearHighlights();
- console.log(`[HighlightLayer] Rendering ${highlights.length} highlights`);
- console.log(`[HighlightLayer] Container element:`, containerRef);
- console.log(
- `[HighlightLayer] Container has children:`,
- containerRef.children.length,
- );
-
// Apply each highlight
for (const highlight of highlights) {
const content = highlight.content;
@@ -511,42 +395,19 @@
const hasOffset =
offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined;
- console.log(`[HighlightLayer] Rendering highlight:`, {
- hasOffset,
- offsetTag,
- content: content.substring(0, 50),
- contentLength: content.length,
- targetAddress,
- color,
- allTags: highlight.tags,
- });
-
if (hasOffset) {
// Use position-based highlighting
const offsetStart = parseInt(offsetTag[1], 10);
const offsetEnd = parseInt(offsetTag[2], 10);
if (!isNaN(offsetStart) && !isNaN(offsetEnd)) {
- console.log(
- `[HighlightLayer] Using position-based highlighting: ${offsetStart}-${offsetEnd}`,
- );
highlightByPosition(offsetStart, offsetEnd, color, targetAddress);
- } else {
- console.log(
- `[HighlightLayer] Invalid offset values, falling back to text search`,
- );
- if (content && content.trim().length > 0) {
- findAndHighlightText(content, color, targetAddress);
- }
- }
- } else {
- // Fall back to text-based highlighting
- console.log(`[HighlightLayer] Using text-based highlighting`);
- if (content && content.trim().length > 0) {
+ } else if (content && content.trim().length > 0) {
findAndHighlightText(content, color, targetAddress);
- } else {
- console.log(`[HighlightLayer] Skipping highlight - empty content`);
}
+ } else if (content && content.trim().length > 0) {
+ // Fall back to text-based highlighting
+ findAndHighlightText(content, color, targetAddress);
}
}
@@ -575,10 +436,6 @@
parent.normalize();
}
});
-
- console.log(
- `[HighlightLayer] Cleared ${highlightElements.length} highlights`,
- );
}
// Track the last fetched event count to know when to refetch
@@ -590,10 +447,6 @@
const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0;
- console.log(
- `[HighlightLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`,
- );
-
// Only fetch if:
// 1. We have event data
// 2. The count has changed since last fetch
@@ -606,9 +459,6 @@
// Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => {
- console.log(
- `[HighlightLayer] Event data stabilized at ${currentCount} events, fetching highlights...`,
- );
lastFetchedCount = currentCount;
fetchHighlights();
}, 500);
@@ -626,14 +476,8 @@
$effect(() => {
// This effect runs when either visible or highlights.length changes
const highlightCount = highlights.length;
- console.log(
- `[HighlightLayer] Visibility/highlights effect - visible: ${visible}, highlights: ${highlightCount}`,
- );
if (visible && highlightCount > 0) {
- console.log(
- `[HighlightLayer] Both visible and highlights ready, rendering...`,
- );
renderHighlights();
} else if (!visible) {
clearHighlights();
@@ -653,9 +497,6 @@
*/
async function fetchAuthorProfiles() {
const uniquePubkeys = Array.from(groupedHighlights.keys());
- console.log(
- `[HighlightLayer] Fetching profiles for ${uniquePubkeys.length} authors`,
- );
for (const pubkey of uniquePubkeys) {
try {
@@ -693,27 +534,17 @@
* Scroll to a specific highlight in the document
*/
function scrollToHighlight(highlight: NDKEvent) {
- console.log(
- `[HighlightLayer] scrollToHighlight called for:`,
- highlight.content.substring(0, 50),
- );
-
if (!containerRef) {
- console.warn(`[HighlightLayer] No containerRef available`);
return;
}
const content = highlight.content;
if (!content || content.trim().length === 0) {
- console.warn(`[HighlightLayer] No content in highlight`);
return;
}
// Find the highlight mark element
const highlightMarks = containerRef.querySelectorAll("mark.highlight");
- console.log(
- `[HighlightLayer] Found ${highlightMarks.length} highlight marks in DOM`,
- );
// Try exact match first
for (const mark of highlightMarks) {
@@ -721,9 +552,6 @@
const searchText = content.toLowerCase();
if (markText === searchText) {
- console.log(
- `[HighlightLayer] Found exact match, scrolling and flashing`,
- );
// Scroll to this element
mark.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -742,9 +570,6 @@
const searchText = content.toLowerCase();
if (markText.includes(searchText) || searchText.includes(markText)) {
- console.log(
- `[HighlightLayer] Found partial match, scrolling and flashing`,
- );
mark.scrollIntoView({ behavior: "smooth", block: "center" });
mark.classList.add("highlight-flash");
setTimeout(() => {
@@ -753,11 +578,6 @@
return;
}
}
-
- console.warn(
- `[HighlightLayer] Could not find highlight mark for:`,
- content.substring(0, 50),
- );
}
/**
@@ -770,7 +590,6 @@
try {
await navigator.clipboard.writeText(naddr);
copyFeedback = highlight.id;
- console.log(`[HighlightLayer] Copied naddr to clipboard:`, naddr);
// Clear feedback after 2 seconds
setTimeout(() => {
@@ -792,8 +611,6 @@
* Public method to refresh highlights (e.g., after creating a new one)
*/
export function refresh() {
- console.log("[HighlightLayer] Manual refresh triggered");
-
// Clear existing highlights
highlights = [];
clearHighlights();
diff --git a/src/lib/components/publications/HighlightSelectionHandler.svelte b/src/lib/components/publications/HighlightSelectionHandler.svelte
index 3e2efd6..d1930aa 100644
--- a/src/lib/components/publications/HighlightSelectionHandler.svelte
+++ b/src/lib/components/publications/HighlightSelectionHandler.svelte
@@ -97,7 +97,6 @@
// Don't use closest('.publication-leather') as Details also has that class
const publicationSection = target.closest("section[id]") as HTMLElement;
if (!publicationSection) {
- console.log("[HighlightSelectionHandler] No section[id] found, aborting");
return;
}
@@ -105,14 +104,6 @@
const sectionAddress = publicationSection.dataset.eventAddress;
const sectionEventId = publicationSection.dataset.eventId;
- console.log("[HighlightSelectionHandler] Selection in section:", {
- element: publicationSection,
- address: sectionAddress,
- eventId: sectionEventId,
- allDataAttrs: publicationSection.dataset,
- sectionId: publicationSection.id,
- });
-
currentSelection = selection;
selectedText = text;
selectedSectionAddress = sectionAddress;
@@ -155,12 +146,6 @@
selectedSectionAddress || publicationEvent.tagAddress();
const useEventId = selectedSectionEventId || publicationEvent.id;
- console.log("[HighlightSelectionHandler] Creating highlight with:", {
- address: useAddress,
- eventId: useEventId,
- fallbackUsed: !selectedSectionAddress,
- });
-
const tags: string[][] = [];
// Always prefer addressable events for publications
@@ -227,11 +212,6 @@
// Remove duplicates
const uniqueRelays = Array.from(new Set(relays));
- console.log(
- "[HighlightSelectionHandler] Publishing to relays:",
- uniqueRelays,
- );
-
const signedEvent = {
...plainEvent,
id: event.id,
@@ -256,15 +236,9 @@
clearTimeout(timeout);
if (ok) {
publishedCount++;
- console.log(
- `[HighlightSelectionHandler] Published to ${relayUrl}`,
- );
WebSocketPool.instance.release(ws);
resolve();
} else {
- console.warn(
- `[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`,
- );
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte
index fdfc7b1..092af49 100644
--- a/src/lib/components/publications/Publication.svelte
+++ b/src/lib/components/publications/Publication.svelte
@@ -8,19 +8,30 @@
SidebarWrapper,
Heading,
CloseButton,
- uiHelpers,
+ Textarea,
+ Popover,
+ P,
+ Modal,
} from "flowbite-svelte";
import { getContext, onDestroy, onMount } from "svelte";
import {
CloseOutline,
ExclamationCircleOutline,
+ MessageDotsOutline,
+ FilePenOutline,
+ DotsVerticalOutline,
+ EyeOutline,
+ EyeSlashOutline,
+ ClipboardCleanOutline,
+ TrashBinOutline,
} from "flowbite-svelte-icons";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte";
import Details from "$components/util/Details.svelte";
+ import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
+ import { neventEncode, naddrEncode } from "$lib/utils";
import { publicationColumnVisibility } from "$lib/stores";
import BlogHeader from "$components/cards/BlogHeader.svelte";
- import Interactions from "$components/util/Interactions.svelte";
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
import TableOfContents from "./TableOfContents.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.svelte";
@@ -28,15 +39,13 @@
import { deleteEvent } from "$lib/services/deletion";
import { getNdkContext, activeOutboxRelays } from "$lib/ndk";
import { goto } from "$app/navigation";
+ import { getMatchingTags } from "$lib/utils/nostrUtils";
import HighlightLayer from "./HighlightLayer.svelte";
- import { EyeOutline, EyeSlashOutline } from "flowbite-svelte-icons";
- import HighlightButton from "./HighlightButton.svelte";
import HighlightSelectionHandler from "./HighlightSelectionHandler.svelte";
import CommentLayer from "./CommentLayer.svelte";
- import CommentButton from "./CommentButton.svelte";
import SectionComments from "./SectionComments.svelte";
- import { Textarea, P } from "flowbite-svelte";
import { userStore } from "$lib/stores/userStore";
+ import CardActions from "$components/util/CardActions.svelte";
let { rootAddress, publicationType, indexEvent, publicationTree, toc } =
$props<{
@@ -55,7 +64,7 @@
let publicationContentRef: HTMLElement | null = $state(null);
// Comment layer state
- let commentsVisible = $state(true);
+ let commentsVisible = $state(false);
let comments = $state([]);
let commentLayerRef: any = null;
let showArticleCommentUI = $state(false);
@@ -63,6 +72,10 @@
let isSubmittingArticleComment = $state(false);
let articleCommentError = $state(null);
let articleCommentSuccess = $state(false);
+
+ // Publication header actions menu state
+ let publicationActionsOpen = $state(false);
+ let detailsModalOpen = $state(false);
// Toggle between mock and real data for testing (DEBUG MODE)
// Can be controlled via VITE_USE_MOCK_COMMENTS and VITE_USE_MOCK_HIGHLIGHTS environment variables
@@ -73,13 +86,6 @@
import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true",
);
- // Log initial state for debugging
- console.log("[Publication] Mock data initialized:", {
- envVars: {
- VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS,
- VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS,
- },
- });
// Derive all event IDs and addresses for highlight fetching
let allEventIds = $derived.by(() => {
@@ -114,111 +120,528 @@
let leaves = $state>([]);
let isLoading = $state(false);
let isDone = $state(false);
- let lastElementRef = $state(null);
+ let sentinelRef = $state(null);
+ let topSentinelRef = $state(null);
let activeAddress = $state(null);
let loadedAddresses = $state>(new Set());
let hasInitialized = $state(false);
let highlightModeActive = $state(false);
let publicationDeleted = $state(false);
+ let sidebarTop = $state(162); // Default to 162px (100px navbar + 62px ArticleNav)
+
+ // AI-NOTE: Batch loading configuration
+ const INITIAL_LOAD_COUNT = 30;
+ const AUTO_LOAD_BATCH_SIZE = 25;
+ const JUMP_WINDOW_SIZE = 5;
- let observer: IntersectionObserver;
-
+ /**
+ * Loads more events from the publication tree.
+ *
+ * @param count Number of events to load in this batch
+ */
async function loadMore(count: number) {
if (!publicationTree) {
console.warn("[Publication] publicationTree is not available");
return;
}
- console.log(
- `[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`,
- );
+ if (isLoading || isDone) {
+ return;
+ }
isLoading = true;
try {
+ const newEvents: Array = [];
+ let consecutiveNulls = 0;
+ const MAX_CONSECUTIVE_NULLS = 10; // Break if we get too many nulls in a row
+ const LOAD_TIMEOUT = 30000; // 30 second timeout per load operation
+
+ // Create a timeout promise to prevent hanging
+ const timeoutPromise = new Promise((_, reject) => {
+ setTimeout(() => {
+ reject(new Error(`Load timeout after ${LOAD_TIMEOUT}ms`));
+ }, LOAD_TIMEOUT);
+ });
+
+ // AI-NOTE: Load events incrementally so users see content immediately instead of staring at empty page
+ // Load events sequentially to maintain order, displaying them as they're loaded
for (let i = 0; i < count; i++) {
- const iterResult = await publicationTree.next();
- const { done, value } = iterResult;
-
- if (done) {
- console.log("[Publication] Iterator done, no more events");
- isDone = true;
- break;
- }
+ try {
+ const iterResult = await Promise.race([
+ publicationTree.next(),
+ timeoutPromise,
+ ]);
+
+ const { done, value } = iterResult;
+
+ if (done) {
+ isDone = true;
+ break;
+ }
- if (value) {
- const address = value.tagAddress();
- console.log(`[Publication] Got event: ${address} (${value.id})`);
- if (!loadedAddresses.has(address)) {
- loadedAddresses.add(address);
- leaves.push(value);
- console.log(`[Publication] Added event: ${address}`);
+ if (value) {
+ consecutiveNulls = 0; // Reset null counter
+ const address = value.tagAddress();
+ // Check both loadedAddresses and leaves to prevent duplicates
+ const alreadyInLeaves = leaves.some(leaf => leaf?.tagAddress() === address);
+ if (!loadedAddresses.has(address) && !alreadyInLeaves) {
+ loadedAddresses.add(address);
+ // AI-NOTE: Add event immediately to leaves so user sees it right away
+ leaves = [...leaves, value];
+ newEvents.push(value);
+ } else {
+ newEvents.push(null);
+ }
} else {
- console.warn(`[Publication] Duplicate event detected: ${address}`);
+ consecutiveNulls++;
+
+ // Break early if we're getting too many nulls - likely no more content
+ if (consecutiveNulls >= MAX_CONSECUTIVE_NULLS) {
+ isDone = true;
+ break;
+ }
+
+ newEvents.push(null);
+ }
+ } catch (error) {
+ console.error(`[Publication] Error getting next event (iteration ${i + 1}/${count}):`, error);
+ // Continue to next iteration instead of breaking entirely
+ newEvents.push(null);
+ consecutiveNulls++;
+
+ if (consecutiveNulls >= MAX_CONSECUTIVE_NULLS) {
+ break;
}
- } else {
- console.log("[Publication] Got null event");
- leaves.push(null);
+ }
+ }
+
+ // Check if we got valid events
+ const validEvents = newEvents.filter(e => e !== null);
+ if (validEvents.length === 0 && newEvents.length > 0) {
+ // We got through the loop but no valid events - might be done
+ if (consecutiveNulls >= MAX_CONSECUTIVE_NULLS) {
+ isDone = true;
}
}
} catch (error) {
console.error("[Publication] Error loading more content:", error);
+ // Don't mark as done on error - might be transient network issue
} finally {
isLoading = false;
- console.log(
- `[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`,
- );
+
+ // AI-NOTE: The ResizeObserver effect will handle checking sentinel position
+ // after content actually renders, so we don't need aggressive post-load checks here
}
}
- function setLastElementRef(el: HTMLElement, i: number) {
- if (i === leaves.length - 1) {
- lastElementRef = el;
+ /**
+ * Gets all section addresses (leaf entries only) from TOC in depth-first order.
+ */
+ function getAllSectionAddresses(): string[] {
+ const addresses: string[] = [];
+ for (const entry of toc) {
+ // Only include leaf entries (sections), not chapters
+ if (toc.leaves.has(entry.address)) {
+ addresses.push(entry.address);
+ }
}
+ return addresses;
}
- $effect(() => {
- if (!lastElementRef) {
+ /**
+ * Inserts events into leaves array in TOC order, ensuring no duplicates.
+ * Returns the updated leaves array.
+ */
+ function insertEventsInOrder(
+ eventsToInsert: Array,
+ allAddresses: string[]
+ ): Array {
+ const existingAddresses = new Set(leaves.map(leaf => leaf?.tagAddress()).filter(Boolean));
+ const newLeaves = [...leaves];
+
+ // Filter out nulls and duplicates
+ const validEvents = eventsToInsert.filter(event => {
+ if (!event) {
+ return false;
+ }
+ const address = event.tagAddress();
+ return address && !existingAddresses.has(address);
+ });
+
+ // Sort events by their TOC index
+ const sortedEvents = validEvents.sort((a, b) => {
+ const indexA = allAddresses.indexOf(a!.tagAddress());
+ const indexB = allAddresses.indexOf(b!.tagAddress());
+ return indexA - indexB;
+ });
+
+ // Insert each event at the correct position
+ for (const event of sortedEvents) {
+ const address = event!.tagAddress();
+ const index = allAddresses.indexOf(address);
+
+ // Find insertion point
+ let insertIndex = newLeaves.length;
+ for (let i = 0; i < newLeaves.length; i++) {
+ const leafAddress = newLeaves[i]?.tagAddress();
+ if (leafAddress) {
+ const leafIndex = allAddresses.indexOf(leafAddress);
+ if (leafIndex > index) {
+ insertIndex = i;
+ break;
+ }
+ }
+ }
+
+ // Only insert if not already present
+ if (!newLeaves.some(leaf => leaf?.tagAddress() === address)) {
+ newLeaves.splice(insertIndex, 0, event);
+ existingAddresses.add(address);
+ }
+ }
+
+ return newLeaves;
+ }
+
+ /**
+ * Loads sections before a given address in the TOC order.
+ */
+ async function loadSectionsBefore(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) {
+ if (!publicationTree || !toc) {
return;
}
- if (isDone) {
- observer?.unobserve(lastElementRef!);
+ const allAddresses = getAllSectionAddresses();
+ const referenceIndex = allAddresses.indexOf(referenceAddress);
+
+ if (referenceIndex === -1 || referenceIndex === 0) {
+ return; // Not found or already at beginning
+ }
+
+ const startIndex = Math.max(0, referenceIndex - count);
+ const addressesToLoad = allAddresses.slice(startIndex, referenceIndex).reverse();
+
+ // Filter out already loaded
+ const existingAddresses = new Set(leaves.map(leaf => leaf?.tagAddress()).filter(Boolean));
+ const addressesToLoadFiltered = addressesToLoad.filter(addr =>
+ !loadedAddresses.has(addr) && !existingAddresses.has(addr)
+ );
+
+ if (addressesToLoadFiltered.length === 0) {
return;
}
- observer?.observe(lastElementRef!);
- return () => observer?.unobserve(lastElementRef!);
- });
+ isLoading = true;
+ const newEvents: Array = [];
+
+ for (const address of addressesToLoadFiltered) {
+ try {
+ const event = await publicationTree.getEvent(address);
+ if (event) {
+ newEvents.push(event);
+ loadedAddresses.add(address);
+ } else {
+ newEvents.push(null);
+ }
+ } catch (error) {
+ console.error(`[Publication] Error loading section ${address}:`, error);
+ newEvents.push(null);
+ }
+ }
+
+ if (newEvents.length > 0) {
+ leaves = insertEventsInOrder(newEvents, allAddresses);
+ }
+
+ isLoading = false;
+ }
+
+ /**
+ * Loads sections after a given address in the TOC order.
+ */
+ async function loadSectionsAfter(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) {
+ if (!publicationTree || !toc || isLoading) {
+ return;
+ }
+
+ const allAddresses = getAllSectionAddresses();
+ const referenceIndex = allAddresses.indexOf(referenceAddress);
+
+ if (referenceIndex === -1) {
+ return;
+ }
+
+ const endIndex = Math.min(allAddresses.length - 1, referenceIndex + count);
+ const addressesToLoad = allAddresses.slice(referenceIndex + 1, endIndex + 1);
+
+ // Filter out already loaded
+ const existingAddresses = new Set(leaves.map(leaf => leaf?.tagAddress()).filter(Boolean));
+ const addressesToLoadFiltered = addressesToLoad.filter(addr =>
+ !loadedAddresses.has(addr) && !existingAddresses.has(addr)
+ );
+
+ if (addressesToLoadFiltered.length === 0) {
+ return;
+ }
+
+ isLoading = true;
+ const newEvents: Array = [];
+
+ for (const address of addressesToLoadFiltered) {
+ try {
+ const event = await publicationTree.getEvent(address);
+ if (event) {
+ newEvents.push(event);
+ loadedAddresses.add(address);
+ } else {
+ newEvents.push(null);
+ }
+ } catch (error) {
+ console.error(`[Publication] Error loading section ${address}:`, error);
+ newEvents.push(null);
+ }
+ }
+
+ if (newEvents.length > 0) {
+ leaves = insertEventsInOrder(newEvents, allAddresses);
+ }
+
+ isLoading = false;
+ }
+
+ /**
+ * Jumps to a specific section and loads a window of sections around it.
+ * This allows users to jump forward to sections that haven't been rendered yet.
+ * Also fills in any gaps between initially loaded sections and the jump window.
+ *
+ * @param targetAddress The address of the section to jump to
+ * @param windowSize Number of sections to load before and after the target (default: JUMP_WINDOW_SIZE)
+ */
+ async function jumpToSection(targetAddress: string, windowSize: number = JUMP_WINDOW_SIZE) {
+ if (!publicationTree || !toc) {
+ return;
+ }
+
+ // Check if target is already loaded
+ const alreadyLoaded = leaves.some(leaf => leaf?.tagAddress() === targetAddress);
+ if (alreadyLoaded) {
+ // Scroll to the section
+ const element = document.getElementById(targetAddress);
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ return;
+ }
+
+ const allAddresses = getAllSectionAddresses();
+ const targetIndex = allAddresses.indexOf(targetAddress);
+
+ if (targetIndex === -1) {
+ return;
+ }
+
+ // Find the last loaded section index
+ const existingAddresses = new Set(leaves.map(leaf => leaf?.tagAddress()).filter(Boolean));
+ let lastLoadedIndex = -1;
+ for (let i = 0; i < allAddresses.length; i++) {
+ if (existingAddresses.has(allAddresses[i])) {
+ lastLoadedIndex = i;
+ }
+ }
+
+ // Calculate jump window
+ const jumpStartIndex = Math.max(0, targetIndex - windowSize);
+ const jumpEndIndex = Math.min(allAddresses.length - 1, targetIndex + windowSize);
+
+ // Determine if we need to fill a gap between last loaded and jump window
+ let gapStartIndex = -1;
+ let gapEndIndex = -1;
+
+ if (lastLoadedIndex >= 0 && jumpStartIndex > lastLoadedIndex + 1) {
+ // There's a gap - fill it
+ gapStartIndex = lastLoadedIndex + 1;
+ gapEndIndex = jumpStartIndex - 1;
+ }
+
+ // Collect all addresses to load (gap + jump window)
+ const addressesToLoad: string[] = [];
+
+ // Add gap addresses if needed
+ if (gapStartIndex >= 0 && gapEndIndex >= gapStartIndex) {
+ for (let i = gapStartIndex; i <= gapEndIndex; i++) {
+ const addr = allAddresses[i];
+ if (!loadedAddresses.has(addr) && !existingAddresses.has(addr)) {
+ addressesToLoad.push(addr);
+ }
+ }
+ }
+
+ // Add jump window addresses
+ for (let i = jumpStartIndex; i <= jumpEndIndex; i++) {
+ const addr = allAddresses[i];
+ if (!loadedAddresses.has(addr) && !existingAddresses.has(addr)) {
+ addressesToLoad.push(addr);
+ }
+ }
+
+ // Load events
+ const windowEvents: Array<{ address: string; event: NDKEvent | null; index: number }> = [];
+ for (const address of addressesToLoad) {
+ try {
+ const event = await publicationTree.getEvent(address);
+ if (event) {
+ windowEvents.push({ address, event, index: allAddresses.indexOf(address) });
+ loadedAddresses.add(address);
+ }
+ } catch (error) {
+ console.error(`[Publication] Error loading section ${address}:`, error);
+ }
+ }
+
+ // Insert events in TOC order, ensuring no duplicates
+ const eventsToInsert: Array = windowEvents.map(({ event }) => event);
+ leaves = insertEventsInOrder(eventsToInsert, allAddresses);
+
+ // Set bookmark to target address for future sequential loading
+ publicationTree.setBookmark(targetAddress);
+
+ // Scroll to target section after a short delay to allow rendering
+ setTimeout(() => {
+ const element = document.getElementById(targetAddress);
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ // Update observer after DOM updates
+ updateFirstSectionObserver();
+ }, 100);
+ }
+
+ /**
+ * Background-loads all events in the publication tree in breadth-first order (level by level).
+ * This ensures the TOC is fully populated with all sections.
+ *
+ * Loads: root -> level 1 children -> level 2 children -> etc.
+ * Also resolves children for each entry to establish parent relationships in TOC.
+ *
+ * AI-NOTE: Throttled to avoid blocking main publication loading. Processes in small batches
+ * with delays to prevent overwhelming relays.
+ */
+ async function backgroundLoadAllEvents() {
+ if (!publicationTree || !toc) {
+ return;
+ }
+
+ // Throttling configuration
+ const BATCH_SIZE = 10; // Process 3 addresses at a time
+ const BATCH_DELAY_MS = 200; // 200ms delay between batches
+ const LEVEL_DELAY_MS = 500; // 500ms delay between levels
+
+ // Track which addresses we've processed to avoid duplicates
+ const processedAddresses = new Set();
+
+ // Start with root address
+ const queue: string[] = [rootAddress];
+ processedAddresses.add(rootAddress);
+
+ // Process level by level (breadth-first)
+ while (queue.length > 0) {
+ const currentLevelAddresses = [...queue];
+ queue.length = 0; // Clear queue for next level
+
+ // Process addresses in small batches to avoid overwhelming relays
+ for (let i = 0; i < currentLevelAddresses.length; i += BATCH_SIZE) {
+ const batch = currentLevelAddresses.slice(i, i + BATCH_SIZE);
+
+ // Process batch in parallel
+ const batchPromises = batch.map(async (address) => {
+ try {
+ // Get child addresses for this node - this triggers node resolution
+ const childAddresses = await publicationTree.getChildAddresses(address);
+
+ // Resolve children for this entry to establish parent relationships in TOC
+ const entry = toc.getEntry(address);
+ if (entry && !entry.childrenResolved) {
+ await entry.resolveChildren();
+ }
+
+ // Add valid children to queue for next level
+ for (const childAddress of childAddresses) {
+ if (childAddress && !processedAddresses.has(childAddress)) {
+ processedAddresses.add(childAddress);
+ queue.push(childAddress);
+
+ // Resolve the child event to populate TOC (non-blocking)
+ publicationTree.getEvent(childAddress).catch(() => {
+ // Silently handle errors in background loading
+ });
+ }
+ }
+ } catch (error) {
+ console.error(`[Publication] Error loading children for ${address}:`, error);
+ }
+ });
+
+ // Wait for batch to complete
+ await Promise.all(batchPromises);
+
+ // Small delay between batches to avoid blocking main loading
+ if (i + BATCH_SIZE < currentLevelAddresses.length) {
+ await new Promise(resolve => setTimeout(resolve, BATCH_DELAY_MS));
+ }
+ }
+
+ // Delay between levels to give main loading priority
+ if (queue.length > 0) {
+ await new Promise(resolve => setTimeout(resolve, LEVEL_DELAY_MS));
+ }
+ }
+ }
// #endregion
- // AI-NOTE: Combined effect to handle publicationTree changes and initial loading
+ // AI-NOTE: Combined effect to handle publicationTree changes and initial loading
// This prevents conflicts between separate effects that could cause duplicate loading
+ let publicationTreeInstance = $state(null);
+
$effect(() => {
- if (publicationTree) {
- // Reset state when publicationTree changes
- leaves = [];
- isLoading = false;
- isDone = false;
- lastElementRef = null;
- loadedAddresses = new Set();
- hasInitialized = false;
-
- // Reset the publication tree iterator to prevent duplicate events
- if (typeof publicationTree.resetIterator === "function") {
- publicationTree.resetIterator();
- }
+ if (!publicationTree) {
+ return;
+ }
- // AI-NOTE: Use setTimeout to ensure iterator reset completes before loading
- // This prevents race conditions where loadMore is called before the iterator is fully reset
- setTimeout(() => {
- // Load initial content after reset
- console.log("[Publication] Loading initial content after reset");
- hasInitialized = true;
- loadMore(12);
- }, 0);
+ // Only reset if publicationTree actually changed (different instance)
+ if (publicationTree === publicationTreeInstance && hasInitialized) {
+ return; // Already initialized with this tree, don't reset
}
+
+ // Reset state when publicationTree changes
+ leaves = [];
+ isLoading = false;
+ isDone = false;
+ sentinelRef = null;
+ loadedAddresses = new Set();
+ hasInitialized = false;
+ publicationTreeInstance = publicationTree;
+
+ // Reset the publication tree iterator to prevent duplicate events
+ if (typeof publicationTree.resetIterator === "function") {
+ publicationTree.resetIterator();
+ }
+
+ // Load initial content after reset
+ hasInitialized = true;
+ loadMore(INITIAL_LOAD_COUNT);
+
+ // Start background loading all events in level-layers for TOC
+ // This runs in the background and doesn't block the UI
+ // Wait a bit for toc to be initialized
+ setTimeout(() => {
+ if (toc && publicationTree) {
+ backgroundLoadAllEvents().catch(() => {
+ // Silently handle errors in background loading
+ });
+ }
+ }, 100);
});
// #region Columns visibility
@@ -227,9 +650,6 @@
let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041);
- const tocSidebarUi = uiHelpers();
- const closeTocSidebar = tocSidebarUi.close;
- const isTocOpen = $state($publicationColumnVisibility.toc);
function isInnerActive() {
return currentBlog !== null && $publicationColumnVisibility.inner;
@@ -243,12 +663,18 @@
publicationColumnVisibility.update((v) => ({ ...v, discussion: false }));
}
+ function viewDetails() {
+ detailsModalOpen = true;
+ publicationActionsOpen = false;
+ }
+
function loadBlog(rootId: string) {
- // depending on the size of the screen, also toggle blog list & discussion visibility
+ // depending on the size of the screen, also toggle discussion visibility
publicationColumnVisibility.update((current) => {
const updated = current;
if (window.innerWidth < 1024) {
- updated.blog = false;
+ // Don't set blog = false on mobile - we need it to show the article
+ // The blog list is already hidden via CSS (hidden md:flex)
updated.discussion = false;
}
updated.inner = true;
@@ -276,7 +702,6 @@
}
function handleCommentPosted() {
- console.log("[Publication] Comment posted, refreshing comment layer");
// Refresh the comment layer after a short delay to allow relay indexing
setTimeout(() => {
if (commentLayerRef) {
@@ -329,8 +754,6 @@
await commentEvent.sign();
await commentEvent.publish();
- console.log("[Publication] Article comment published:", commentEvent.id);
-
articleCommentSuccess = true;
articleCommentContent = "";
@@ -365,11 +788,7 @@
eventAddress: indexEvent.tagAddress(),
eventKind: indexEvent.kind,
reason: "User deleted publication",
- onSuccess: (deletionEventId) => {
- console.log(
- "[Publication] Deletion event published:",
- deletionEventId,
- );
+ onSuccess: () => {
publicationDeleted = true;
// Redirect after 2 seconds
@@ -399,17 +818,17 @@
* @param address The address of the event that was mounted.
*/
function onPublicationSectionMounted(el: HTMLElement, address: string) {
- // Update last element ref for the intersection observer.
- setLastElementRef(el, leaves.length);
+ // AI-NOTE: Using sentinel element for intersection observer instead of tracking last element
+ // The sentinel is a dedicated element placed after all sections for better performance
// Michael J - 08 July 2025 - NOTE: Updating the ToC from here somewhat breaks separation of
// concerns, since the TableOfContents component is primarily responsible for working with the
// ToC data structure. However, the Publication component has direct access to the needed DOM
// element already, and I want to avoid complicated callbacks between the two components.
// Update the ToC from the contents of the leaf section.
+ // AI-NOTE: TOC updates happen in parallel as sections mount, improving performance
const entry = toc.getEntry(address);
if (!entry) {
- console.warn(`[Publication] No parent found for ${address}`);
return;
}
toc.buildTocFromDocument(el, entry);
@@ -423,6 +842,20 @@
});
onMount(() => {
+ // Measure the actual navbar and ArticleNav heights to position sidebars correctly
+ const navbar = document.getElementById("navi");
+ const articleNav = document.querySelector("nav.navbar-leather");
+
+ if (navbar && articleNav) {
+ const navbarRect = navbar.getBoundingClientRect();
+ const articleNavRect = articleNav.getBoundingClientRect();
+ sidebarTop = articleNavRect.bottom;
+ } else if (navbar) {
+ // Fallback: if ArticleNav not found, use navbar height + estimated ArticleNav height
+ const navbarRect = navbar.getBoundingClientRect();
+ sidebarTop = navbarRect.bottom + 62; // Estimated ArticleNav height
+ }
+
// Set current columns depending on the publication type
const isBlog = publicationType === "blog";
publicationColumnVisibility.update((v) => ({
@@ -433,31 +866,6 @@
if (isLeaf || isBlog) {
publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
}
-
- // Set up the intersection observer.
- observer = new IntersectionObserver(
- (entries) => {
- entries.forEach((entry) => {
- if (
- entry.isIntersecting &&
- !isLoading &&
- !isDone &&
- publicationTree
- ) {
- loadMore(1);
- }
- });
- },
- { threshold: 0.5 },
- );
-
- // AI-NOTE: Removed duplicate loadMore call
- // Initial content loading is handled by the $effect that watches publicationTree
- // This prevents duplicate loading when both onMount and $effect trigger
-
- return () => {
- observer.disconnect();
- };
});
// Setup highlight layer container reference
@@ -467,12 +875,244 @@
}
});
+ // #region Infinite Scroll Observer State
+ // AI-NOTE: IntersectionObserver-based infinite scroll with debouncing
+ // Observes sentinels and first section element for upward scrolling
+
+ const UPWARD_LOAD_DEBOUNCE_MS = 3000;
+ const OBSERVER_UPDATE_DELAY_MS = 800;
+ const DOM_STABILIZATION_DELAY_MS = 500;
+
+ let lastUpwardLoadTime = 0;
+ let isUpdatingObserver = false;
+ let isLoadingUpward = $state(false);
+ let scrollObserver: IntersectionObserver | null = null;
+ let observedFirstSectionAddress: string | null = null;
+ let observerUpdateTimeout: number | null = null;
+ let ignoreNextFirstSectionIntersection = false;
+
+ /**
+ * Handles upward loading with proper debouncing and observer management.
+ */
+ async function handleUpwardLoad(referenceAddress: string, source: "top-sentinel" | "first-section") {
+ if (isLoadingUpward) {
+ return;
+ }
+
+ const now = Date.now();
+ if ((now - lastUpwardLoadTime) < UPWARD_LOAD_DEBOUNCE_MS) {
+ return;
+ }
+
+ const firstSection = leaves.filter(l => l !== null)[0];
+ if (!firstSection || firstSection.tagAddress() === rootAddress) {
+ return;
+ }
+
+ const firstAddress = firstSection.tagAddress();
+ if (referenceAddress !== firstAddress && source === "first-section") {
+ return;
+ }
+
+ isLoadingUpward = true;
+ lastUpwardLoadTime = now;
+
+ // Unobserve elements to prevent loop
+ if (observedFirstSectionAddress && scrollObserver) {
+ const firstElement = document.getElementById(observedFirstSectionAddress);
+ if (firstElement) {
+ scrollObserver.unobserve(firstElement);
+ }
+ }
+ if (scrollObserver && source === "top-sentinel" && topSentinelRef) {
+ scrollObserver.unobserve(topSentinelRef);
+ }
+
+ try {
+ await loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE);
+
+ // Wait for DOM stabilization before updating observer
+ setTimeout(() => {
+ if (source === "top-sentinel" && scrollObserver && topSentinelRef) {
+ scrollObserver.observe(topSentinelRef);
+ }
+ if (!isLoadingUpward) {
+ updateFirstSectionObserver();
+ }
+ }, DOM_STABILIZATION_DELAY_MS);
+ } catch (error) {
+ console.error(`[Publication] Error in upward load from ${source}:`, error);
+ setTimeout(() => {
+ if (source === "top-sentinel" && scrollObserver && topSentinelRef) {
+ scrollObserver.observe(topSentinelRef);
+ }
+ if (!isLoadingUpward) {
+ updateFirstSectionObserver();
+ }
+ }, DOM_STABILIZATION_DELAY_MS);
+ } finally {
+ isLoadingUpward = false;
+ }
+ }
+
+ /**
+ * Updates the observer to watch the current first section element.
+ * Called explicitly after loading sections before to avoid reactive loops.
+ */
+ function updateFirstSectionObserver() {
+ if (!scrollObserver || isLoading || isUpdatingObserver || isLoadingUpward) {
+ return;
+ }
+
+ if (observerUpdateTimeout !== null) {
+ clearTimeout(observerUpdateTimeout);
+ observerUpdateTimeout = null;
+ }
+
+ observerUpdateTimeout = window.setTimeout(() => {
+ if (!scrollObserver || isLoading || isUpdatingObserver || isLoadingUpward) {
+ return;
+ }
+
+ const firstSection = leaves.filter(l => l !== null)[0];
+ if (!firstSection) {
+ return;
+ }
+
+ const firstAddress = firstSection.tagAddress();
+
+ if (firstAddress === rootAddress || firstAddress === observedFirstSectionAddress) {
+ return;
+ }
+
+ isUpdatingObserver = true;
+
+ // Unobserve previous first section
+ if (observedFirstSectionAddress && scrollObserver) {
+ const prevElement = document.getElementById(observedFirstSectionAddress);
+ if (prevElement) {
+ scrollObserver.unobserve(prevElement);
+ }
+ }
+
+ // Observe new first section
+ const firstElement = document.getElementById(firstAddress);
+ if (firstElement && scrollObserver) {
+ scrollObserver.observe(firstElement);
+ observedFirstSectionAddress = firstAddress;
+ ignoreNextFirstSectionIntersection = true;
+ }
+
+ isUpdatingObserver = false;
+ observerUpdateTimeout = null;
+ }, OBSERVER_UPDATE_DELAY_MS);
+ }
+ // #endregion
+
+ $effect(() => {
+ if (!hasInitialized || !publicationTree || !toc) {
+ return;
+ }
+
+ let setupTimeout: number | null = null;
+
+ const setupObserver = () => {
+ if (scrollObserver) {
+ return;
+ }
+
+ const bottomSentinel = document.getElementById("publication-sentinel");
+ const topSentinel = document.getElementById("publication-top-sentinel");
+
+ if (!bottomSentinel && !topSentinel) {
+ return;
+ }
+
+ scrollObserver = new IntersectionObserver(
+ (entries) => {
+ if (isLoading || isDone || isUpdatingObserver || isLoadingUpward) {
+ return;
+ }
+
+ for (const entry of entries) {
+ if (!entry.isIntersecting) {
+ continue;
+ }
+
+ const targetId = entry.target.id;
+
+ if (targetId === "publication-sentinel") {
+ // Downward loading
+ const lastSection = leaves.filter(l => l !== null).slice(-1)[0];
+ if (lastSection) {
+ loadSectionsAfter(lastSection.tagAddress(), AUTO_LOAD_BATCH_SIZE);
+ } else {
+ loadMore(AUTO_LOAD_BATCH_SIZE);
+ }
+ break;
+ } else if (targetId === "publication-top-sentinel") {
+ // Upward loading from top sentinel
+ handleUpwardLoad("", "top-sentinel");
+ break;
+ } else {
+ // First section element intersection
+ if (ignoreNextFirstSectionIntersection) {
+ ignoreNextFirstSectionIntersection = false;
+ break;
+ }
+
+ const firstSection = leaves.filter(l => l !== null)[0];
+ if (firstSection && targetId === firstSection.tagAddress() && targetId !== rootAddress) {
+ handleUpwardLoad(targetId, "first-section");
+ }
+ break;
+ }
+ }
+ },
+ {
+ rootMargin: "1000px 0px 1000px 0px",
+ threshold: 0,
+ },
+ );
+
+ if (bottomSentinel) {
+ scrollObserver.observe(bottomSentinel);
+ }
+ if (topSentinel) {
+ scrollObserver.observe(topSentinel);
+ }
+
+ // Initial observer update after setup
+ setTimeout(() => {
+ updateFirstSectionObserver();
+ }, 200);
+ };
+
+ setupTimeout = window.setTimeout(() => {
+ setupObserver();
+ }, 100);
+
+ return () => {
+ if (setupTimeout !== null) {
+ clearTimeout(setupTimeout);
+ }
+ if (observerUpdateTimeout !== null) {
+ clearTimeout(observerUpdateTimeout);
+ }
+ if (scrollObserver) {
+ scrollObserver.disconnect();
+ scrollObserver = null;
+ observedFirstSectionAddress = null;
+ }
+ };
+ });
+
// #endregion
@@ -486,60 +1126,20 @@
// Refresh highlights after a short delay to allow relay indexing
setTimeout(() => {
if (highlightLayerRef) {
- console.log("[Publication] Refreshing highlights after creation");
highlightLayerRef.refresh();
}
}, 500);
}}
/>
-
+
-
-
- {#if publicationType !== "blog" && !isLeaf}
- {#if $publicationColumnVisibility.toc}
-
-
-
-
- publicationTree.setBookmark(address)}
- onLoadMore={() => {
- if (!isLoading && !isDone && publicationTree) {
- loadMore(4);
- }
- }}
- />
-
-
- {/if}
- {/if}
-
-
+
+
{#if $publicationColumnVisibility.main}
@@ -547,12 +1147,150 @@
+
+
+
e.stopPropagation()}
+ onkeydown={(e) => e.stopPropagation()}
+ >
+
(publicationActionsOpen = true)}
+ >
+
+
+ {#if publicationActionsOpen}
+
(publicationActionsOpen = false)}
+ >
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ {#if $userStore.signedIn && $userStore.pubkey === indexEvent.pubkey}
+ -
+
+
+ {/if}
+
+
+
+
+ {/if}
+
+
{#if publicationDeleted}
@@ -584,39 +1322,6 @@
-
-
-
-
-
-
-
-
-
-
-
{#if showArticleCommentUI}
@@ -665,6 +1370,22 @@
{/if}
+
+
+
+ {#if isLoadingUpward && leaves.length > 0}
+
+
+
Loading previous sections...
+
+ {/if}
+
+
{#each leaves as leaf, i}
{#if leaf == null}
@@ -674,6 +1395,8 @@
{:else}
{@const address = leaf.tagAddress()}
+ {@const publicationTitle = getMatchingTags(indexEvent, "title")[0]?.[1]}
+ {@const isFirstSection = i === 0}
onPublicationSectionMounted(el, address)}
/>
{/if}
{/each}
-
- {#if isLoading}
-
- {:else if !isDone}
-
- {:else}
+
+
+
+
+
+ {#if isDone}
You've reached the end of the publication.
+ {:else if isLoading}
+
{/if}
{/if}
-
+
{#if $publicationColumnVisibility.blog}
-
-
+
+
-
+
+
+
+
+ {#each leaves as leaf, i}
+ {#if leaf}
+
+ {/if}
+ {/each}
-
- {#each leaves as leaf, i}
- {#if leaf}
-
- {/if}
- {/each}
-
- {/if}
- {#if isInnerActive()}
- {#key currentBlog}
-
-
-
-
- {/key}
+
+ {#if isInnerActive()}
+ {#key currentBlog}
+
+ {#if currentBlogEvent && currentBlog}
+ {@const address = currentBlog}
+
onPublicationSectionMounted(el, address)}
+ />
+ {:else}
+
+ {/if}
+
+ {/key}
+ {/if}
+
{/if}
+
+
-
+
+ {#if $publicationColumnVisibility.discussion}
+
-
- {#if $publicationColumnVisibility.discussion}
-
@@ -795,9 +1551,61 @@
{/if}
+
+
+
+{#if publicationType !== "blog" && !isLeaf}
+
+ {
+ if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
+ closeToc();
+ }
+ }}
+ >
+
+
+
-
+{/if}
+
+
+
+
+
diff --git a/src/lib/components/publications/PublicationHeader.svelte b/src/lib/components/publications/PublicationHeader.svelte
index 1de1016..368a239 100644
--- a/src/lib/components/publications/PublicationHeader.svelte
+++ b/src/lib/components/publications/PublicationHeader.svelte
@@ -80,9 +80,9 @@
{#if title != null && href != null}
-
-
-
+
+
+
{#if image}
void;
allComments?: NDKEvent[];
commentsVisible?: boolean;
+ publicationTitle?: string;
+ isFirstSection?: boolean;
} = $props();
const asciidoctor: Asciidoctor = getContext("asciidoctor");
@@ -59,10 +63,6 @@
leafEvent.then((e) => {
if (e?.id) {
leafEventId = e.id;
- console.log(
- `[PublicationSection] Set leafEventId for ${address}:`,
- e.id,
- );
}
});
});
@@ -89,17 +89,32 @@
// AI-NOTE: Kind 30023 events contain Markdown content, not AsciiDoc
// Use parseAdvancedmarkup for 30023 events, Asciidoctor for 30041/30818 events
+ let processed: string;
if (event?.kind === 30023) {
- return await parseAdvancedmarkup(content);
+ processed = await parseAdvancedmarkup(content);
} else {
// For 30041 and 30818 events, use Asciidoctor (AsciiDoc)
const converted = asciidoctor.convert(content);
- const processed = await postProcessAdvancedAsciidoctorHtml(
+ processed = await postProcessAdvancedAsciidoctorHtml(
converted.toString(),
ndk,
);
- return processed;
}
+
+ // Remove redundant h1 title from first section if it matches publication title
+ if (isFirstSection && publicationTitle && typeof processed === 'string') {
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = processed;
+ const h1Elements = tempDiv.querySelectorAll('h1');
+ h1Elements.forEach((h1) => {
+ if (h1.textContent?.trim() === publicationTitle.trim()) {
+ h1.remove();
+ }
+ });
+ processed = tempDiv.innerHTML;
+ }
+
+ return processed;
});
let previousLeafEvent: NDKEvent | null = $derived.by(() => {
@@ -186,11 +201,7 @@
eventAddress: address,
eventKind: event.kind,
reason: "User deleted section",
- onSuccess: (deletionEventId) => {
- console.log(
- "[PublicationSection] Deletion event published:",
- deletionEventId,
- );
+ onSuccess: () => {
// Refresh the page to reflect the deletion
window.location.reload();
},
@@ -212,19 +223,11 @@
}
ref(sectionRef);
-
- // Log data attributes for debugging
- console.log(`[PublicationSection] Section mounted:`, {
- address,
- leafEventId,
- dataAddress: sectionRef.dataset.eventAddress,
- dataEventId: sectionRef.dataset.eventId,
- });
});
-
+
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
-
-
-
-
- {#await leafEvent then event}
- {#if event}
-
- {/if}
- {/await}
-
+
+
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
@@ -257,7 +248,21 @@
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}
- {@render sectionHeading(leafTitle, leafDepth)}
+
+
+
+ {#await leafEvent then event}
+ {#if event}
+
+ {/if}
+ {/await}
+
+ {@render sectionHeading(leafTitle, leafDepth)}
+
{/if}
{@render contentParagraph(
leafContent.toString(),
@@ -267,7 +272,7 @@
-
+
-
- {#await leafEvent then event}
- {#if event}
-
-
-
-
- {/if}
- {/await}
void;
onLoadMore?: () => void;
+ onClose?: () => void;
}>();
let entries = $derived.by
(() => {
const newEntries = [];
+ const rootEntry = rootAddress === toc.getRootEntry()?.address
+ ? toc.getRootEntry()
+ : toc.getEntry(rootAddress);
+
+ if (!rootEntry) {
+ return [];
+ }
+
+ // Filter entries that are direct children of rootAddress at the correct depth
for (const [_, entry] of toc.addressMap) {
+ // Must match the depth
if (entry.depth !== depth) {
continue;
}
-
+
+ // Check if entry is a direct child of rootAddress
+ // Primary check: parent relationship (set when resolveChildren is called)
+ // Fallback: entry is in rootEntry's children array
+ // Final fallback: depth-based check for root's direct children only
+ const isDirectChild =
+ entry.parent?.address === rootAddress ||
+ rootEntry.children.some((child: TocEntry) => child.address === entry.address) ||
+ (entry.depth === rootEntry.depth + 1 &&
+ rootAddress === toc.getRootEntry()?.address &&
+ !entry.parent); // Only use depth check if parent not set (temporary state)
+
+ if (!isDirectChild) {
+ continue;
+ }
+
newEntries.push(entry);
}
@@ -45,6 +71,36 @@
toc.expandedMap.set(address, expanded);
entry.resolveChildren();
+
+ // AI-NOTE: When expanding a chapter, scroll it to the top of the TOC so its children are visible
+ if (expanded) {
+ // Use setTimeout to allow the expansion animation to start and DOM to update
+ setTimeout(() => {
+ // Find the scrollable container (the div with overflow-y-auto in the TOC drawer)
+ const scrollableContainer = document.querySelector('.overflow-y-auto');
+ if (!scrollableContainer) {
+ return;
+ }
+
+ // Find all buttons in the TOC that match the entry title
+ const buttons = scrollableContainer.querySelectorAll('button');
+ for (const button of buttons) {
+ const buttonText = button.textContent?.trim();
+ if (buttonText === entry.title) {
+ // Find the parent container of the dropdown (the SidebarDropdownWrapper)
+ const dropdownContainer = button.closest('[class*="w-full"]');
+ if (dropdownContainer) {
+ // Scroll the chapter to the top of the TOC container
+ dropdownContainer.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ });
+ }
+ break;
+ }
+ }
+ }, 150);
+ }
}
function handleSectionClick(address: string) {
@@ -59,11 +115,13 @@
onSectionFocused?.(address);
+ // Close the drawer after navigation
+ onClose?.();
+
// Check if this is the last entry and trigger loading more events
const currentEntries = entries;
const lastEntry = currentEntries[currentEntries.length - 1];
if (lastEntry && lastEntry.address === address) {
- console.debug('[TableOfContents] Last entry clicked, triggering load more');
onLoadMore?.();
}
}
@@ -73,6 +131,27 @@
return currentVisibleSection === address;
}
+ // Calculate indentation based on depth
+ // Depth 2 = no indent (Beginning, root entry)
+ // Depth 3 = indent level 1 (30041 sections under 30040)
+ // Depth 4+ = more indentation
+ function getIndentClass(depth: number): string {
+ if (depth <= 2) {
+ return "";
+ }
+ // Each level beyond 2 adds 1rem (16px) of padding
+ const indentLevel = depth - 2;
+ // Use standard Tailwind classes: pl-4 (1rem), pl-8 (2rem), pl-12 (3rem), etc.
+ const paddingMap: Record = {
+ 1: "pl-4", // 1rem
+ 2: "pl-8", // 2rem
+ 3: "pl-12", // 3rem
+ 4: "pl-16", // 4rem
+ 5: "pl-20", // 5rem
+ };
+ return paddingMap[indentLevel] || `pl-[${indentLevel}rem]`;
+ }
+
// Set up intersection observer to track visible sections
onMount(() => {
observer = new IntersectionObserver(
@@ -153,17 +232,62 @@
+
+ {#if depth === 2}
+ {
+ e.preventDefault();
+ window.scrollTo({
+ top: 0,
+ behavior: 'smooth',
+ });
+ onClose?.();
+ }}
+ >
+
+
+ {/if}
+
+ {#if depth === 2}
+ {@const rootEntry = toc.getRootEntry()}
+ {#if rootEntry}
+ {@const isVisible = isEntryVisible(rootEntry.address)}
+ {
+ const element = document.getElementById(rootEntry.address);
+ if (element) {
+ element.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ });
+ }
+ onClose?.();
+ }}
+ >
+
+
+ {/if}
+ {/if}
{#each entries as entry, index}
{@const address = entry.address}
{@const expanded = toc.expandedMap.get(address) ?? false}
{@const isLeaf = toc.leaves.has(address)}
{@const isVisible = isEntryVisible(address)}
+ {@const indentClass = getIndentClass(entry.depth)}
{#if isLeaf}
handleSectionClick(address)}
>
@@ -172,10 +296,11 @@
{@const childDepth = depth + 1}
expanded, (open) => setEntryExpanded(address, open)}
>
-
+
{/if}
{/each}
diff --git a/src/lib/components/util/ArticleNav.svelte b/src/lib/components/util/ArticleNav.svelte
index 3b2f9de..d6eff39 100644
--- a/src/lib/components/util/ArticleNav.svelte
+++ b/src/lib/components/util/ArticleNav.svelte
@@ -36,11 +36,14 @@
let lastScrollY = $state(0);
let isVisible = $state(true);
+ let navbarTop = $state(100); // Default to 100px
// Function to toggle column visibility
function toggleColumn(column: "toc" | "blog" | "inner" | "discussion") {
+ console.log("[ArticleNav] toggleColumn called with:", column);
publicationColumnVisibility.update((current) => {
const newValue = !current[column];
+ console.log("[ArticleNav] Toggling", column, "from", current[column], "to", newValue);
const updated = { ...current, [column]: newValue };
if (window.innerWidth < 1400 && column === "blog" && newValue) {
@@ -93,6 +96,16 @@
});
}
+ function handleBlogTocClick() {
+ if ($publicationColumnVisibility.inner) {
+ // Viewing article: go back to TOC
+ backToBlog();
+ } else if ($publicationColumnVisibility.blog) {
+ // Showing TOC: toggle it (though it should stay visible)
+ toggleColumn("blog");
+ }
+ }
+
function handleScroll() {
if (window.innerWidth < 768) {
const currentScrollY = window.scrollY;
@@ -139,6 +152,13 @@
let unsubscribe: () => void;
onMount(() => {
+ // Measure the actual navbar height to position ArticleNav correctly
+ const navbar = document.getElementById("navi");
+ if (navbar) {
+ const rect = navbar.getBoundingClientRect();
+ navbarTop = rect.bottom;
+ }
+
window.addEventListener("scroll", handleScroll);
unsubscribe = publicationColumnVisibility.subscribe(() => {
isVisible = true; // show navbar when store changes
@@ -152,40 +172,38 @@