From 1029f1bad3d7e1a6fac161855be8e7344280ae52 Mon Sep 17 00:00:00 2001 From: silberengel Date: Tue, 21 Oct 2025 19:53:21 +0200 Subject: [PATCH 01/31] fix the npm build --- package-lock.json | 3 ++- src/lib/a/primitives/ADetails.svelte | 3 ++- src/lib/a/primitives/ANostrUser.svelte | 11 ++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb7839e..6bc273d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "node-emoji": "^2.2.0", "nostr-tools": "2.15.x", "plantuml-encoder": "^1.4.0", - "qrcode": "^1.5.4" + "qrcode": "^1.5.4", + "tailwindcss": "^4.1.11" }, "devDependencies": { "@playwright/test": "^1.54.1", diff --git a/src/lib/a/primitives/ADetails.svelte b/src/lib/a/primitives/ADetails.svelte index 33e636a..7e095a5 100644 --- a/src/lib/a/primitives/ADetails.svelte +++ b/src/lib/a/primitives/ADetails.svelte @@ -57,6 +57,7 @@ defaultOpen = false, forceHide = false, class: className = "", + children, } = $props(); let open = $derived(defaultOpen); $effect(() => { @@ -98,6 +99,6 @@ {#if !(tech && !$showTech && forceHide)}
- + {@render children()}
{/if} diff --git a/src/lib/a/primitives/ANostrUser.svelte b/src/lib/a/primitives/ANostrUser.svelte index 510f2da..4a172a0 100644 --- a/src/lib/a/primitives/ANostrUser.svelte +++ b/src/lib/a/primitives/ANostrUser.svelte @@ -96,6 +96,7 @@ badgeLimit = 6, href = undefined as string | undefined, class: className = "", + badges, } = $props(); // Derived view-model @@ -207,11 +208,11 @@ {#if showBadges} - - {#if nativeBadges} - - {/if} - + {#if badges} + {@render badges()} + {:else if nativeBadges} + + {/if} {/if} From e8db4e89af2b3b63b99ed6e3f0ccf8f5743b32af Mon Sep 17 00:00:00 2001 From: silberengel Date: Fri, 5 Dec 2025 08:14:55 +0100 Subject: [PATCH 02/31] fix table of contents button on publications and articles --- .../publications/Publication.svelte | 97 ++++++++++--------- .../publications/TableOfContents.svelte | 11 ++- src/lib/components/util/ArticleNav.svelte | 14 +-- 3 files changed, 67 insertions(+), 55 deletions(-) diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index fdfc7b1..82824e4 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -8,7 +8,6 @@ SidebarWrapper, Heading, CloseButton, - uiHelpers, } from "flowbite-svelte"; import { getContext, onDestroy, onMount } from "svelte"; import { @@ -227,9 +226,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; @@ -472,7 +468,7 @@
@@ -494,46 +490,7 @@ />
- -
- {#if publicationType !== "blog" && !isLeaf} - {#if $publicationColumnVisibility.toc} - - - - - publicationTree.setBookmark(address)} - onLoadMore={() => { - if (!isLoading && !isDone && publicationTree) { - loadMore(4); - } - }} - /> - - - {/if} - {/if} -
+
{#if $publicationColumnVisibility.main} @@ -799,6 +756,56 @@
+ +{#if publicationType !== "blog" && !isLeaf} + +
{ + if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { + closeToc(); + } + }} + >
+ + +
+
+
+ +
+
+ + publicationTree.setBookmark(address)} + onLoadMore={() => { + if (!isLoading && !isDone && publicationTree) { + loadMore(4); + } + }} + onClose={closeToc} + /> +
+
+
+{/if} + void; onLoadMore?: () => void; + onClose?: () => void; }>(); let entries = $derived.by(() => { @@ -59,6 +60,9 @@ 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]; @@ -172,10 +176,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..aa56b3f 100644 --- a/src/lib/components/util/ArticleNav.svelte +++ b/src/lib/components/util/ArticleNav.svelte @@ -152,7 +152,7 @@
diff --git a/src/lib/components/util/ArticleNav.svelte b/src/lib/components/util/ArticleNav.svelte index 795f3cc..2d8183e 100644 --- a/src/lib/components/util/ArticleNav.svelte +++ b/src/lib/components/util/ArticleNav.svelte @@ -93,6 +93,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; @@ -160,11 +170,13 @@
{#if isIndexEvent} {#if publicationType === "blog"} + From db9cf1c2797220c42d04e51d15b4368cd7507fea Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 08:10:59 +0100 Subject: [PATCH 06/31] fix dark and light-view contrast for the whole app, to meet accesibility standards remove unused navigation bars fixed hover effects --- src/app.css | 154 ++++++++++++++---- src/app.html | 8 +- src/lib/a/nav/ANavbar.svelte | 12 +- src/lib/components/Navigation.svelte | 48 ------ .../publications/Publication.svelte | 10 +- src/lib/components/util/CardActions.svelte | 5 +- src/lib/stores/themeStore.ts | 13 +- src/styles/a/cards.css | 28 ++-- src/styles/base.css | 2 +- src/styles/notifications.css | 12 +- src/styles/publications.css | 2 +- src/styles/visualize.css | 20 +-- src/theme-tokens.css | 11 ++ 13 files changed, 200 insertions(+), 125 deletions(-) delete mode 100644 src/lib/components/Navigation.svelte diff --git a/src/app.css b/src/app.css index 89a56e5..538ac07 100644 --- a/src/app.css +++ b/src/app.css @@ -202,7 +202,7 @@ @apply text-base font-semibold; } - /* Heading links - primary-600 (light) / primary-400 (dark) for hover */ + /* Heading links - primary-600 (light, more golden) / primary-300 (dark) for hover */ h1 a, h2 a, h3 a, @@ -216,7 +216,7 @@ h5.h-leather a, h6.h-leather a { @apply text-gray-900 dark:text-gray-100 hover:text-primary-600 - dark:hover:text-primary-400; + dark:hover:text-primary-300; } /* === LEATHER COMPONENTS === */ @@ -242,8 +242,8 @@ } div[role="tooltip"] button.btn-leather { - @apply hover:text-primary-600 dark:hover:text-primary-400 - hover:border-primary-600 dark:hover:border-primary-400 hover:bg-gray-200 + @apply hover:text-primary-700 dark:hover:text-primary-300 + hover:border-primary-700 dark:hover:border-primary-300 hover:bg-gray-200 dark:hover:bg-gray-700; } @@ -301,7 +301,7 @@ div.modal-leather > div > h4 a, div.modal-leather > div > h5 a, div.modal-leather > div > h6 a { - @apply hover:text-primary-600 dark:hover:text-primary-400; + @apply hover:text-primary-700 dark:hover:text-primary-300; } /* Navbar */ @@ -314,17 +314,70 @@ } nav.navbar-leather svg { - @apply fill-gray-900 hover:fill-primary-600 dark:fill-gray-100 - dark:hover:fill-primary-400; + @apply fill-gray-900 hover:fill-primary-700 dark:fill-gray-100 + dark:hover:fill-primary-300; } /* NavBrand hover - all text highlights together */ #navi a:hover h1, #navi a:hover p { - @apply !text-primary-600 dark:!text-primary-400; + @apply !text-primary-600 dark:!text-primary-300; transition: color 0.2s ease-in-out; } + /* Navbar menu items hover effect - ALL items get same background hover */ + #navi ul li.navbar-menu-item, + #navi ul li:has(.navbar-menu-item), + #navi li.navbar-menu-item { + @apply rounded px-2 py-1 transition-colors; + } + + #navi ul li.navbar-menu-item:hover, + #navi ul li:has(.navbar-menu-item):hover, + #navi li.navbar-menu-item:hover, + #navi ul li.navbar-menu-item:has(button:hover), + #navi ul li.navbar-menu-item:has(div:hover), + #navi ul li.navbar-menu-item:has(span:hover), + #navi ul li.navbar-menu-item:has(a:hover), + #navi ul li.navbar-menu-item:has(img:hover), + #navi ul li.navbar-menu-item:has(svg:hover), + #navi ul li.navbar-menu-item:has([class*="Avatar"]:hover) { + @apply !bg-primary-100; + } + + .dark #navi ul li.navbar-menu-item:hover, + .dark #navi ul li:has(.navbar-menu-item):hover, + .dark #navi li.navbar-menu-item:hover, + .dark #navi ul li.navbar-menu-item:has(button:hover), + .dark #navi ul li.navbar-menu-item:has(div:hover), + .dark #navi ul li.navbar-menu-item:has(span:hover), + .dark #navi ul li.navbar-menu-item:has(a:hover), + .dark #navi ul li.navbar-menu-item:has(img:hover), + .dark #navi ul li.navbar-menu-item:has(svg:hover), + .dark #navi ul li.navbar-menu-item:has([class*="Avatar"]:hover) { + @apply !bg-primary-800; + } + + /* Explore text color - matches chevron in dark mode */ + #navi ul li.navbar-menu-item:first-of-type { + @apply text-primary-800 dark:text-white cursor-pointer; + } + + /* Remove ALL backgrounds from ALL child elements - use universal selector with max specificity */ + #navi ul li.navbar-menu-item *, + #navi ul li.navbar-menu-item *:hover, + #navi ul li.navbar-menu-item *:focus, + #navi ul li.navbar-menu-item *:active, + #navi ul li.navbar-menu-item:hover *, + #navi ul li.navbar-menu-item:hover *:hover, + #navi ul li.navbar-menu-item:hover *:focus, + #navi ul li.navbar-menu-item:hover *:active { + background-color: transparent !important; + background: transparent !important; + background-image: none !important; + box-shadow: none !important; + } + nav.navbar-leather h1, nav.navbar-leather h2, nav.navbar-leather h3, @@ -340,7 +393,7 @@ nav.navbar-leather h4 a, nav.navbar-leather h5 a, nav.navbar-leather h6 a { - @apply hover:text-primary-600 dark:hover:text-primary-400; + @apply hover:text-primary-700 dark:hover:text-primary-300; } div.textarea-leather { @@ -430,23 +483,23 @@ /* Lists */ .ol-leather li a, .ul-leather li a { - @apply text-gray-900 dark:text-gray-100 hover:text-primary-600 - dark:hover:text-primary-400; + @apply text-gray-900 dark:text-gray-100 hover:text-primary-700 + dark:hover:text-primary-300; } - /* Links - consistent hover colors */ + /* Links - consistent hover colors - improved contrast */ .link { - @apply underline cursor-pointer hover:text-primary-600 - dark:hover:text-primary-400; + @apply underline cursor-pointer hover:text-primary-700 + dark:hover:text-primary-300; } .npub-badge { - @apply inline-flex space-x-1 items-center text-primary-600 - dark:text-primary-500 hover:underline me-2 px-2 py-0.5 rounded-sm border - border-primary-600 dark:border-primary-500; + @apply inline-flex space-x-1 items-center text-primary-700 + dark:text-primary-300 hover:underline me-2 px-2 py-0.5 rounded-sm border + border-primary-700 dark:border-primary-300; svg { - @apply fill-primary-600 dark:fill-primary-500; + @apply fill-primary-700 dark:fill-primary-300; } } @@ -455,6 +508,19 @@ } } +/* Force remove backgrounds from DarkMode button - outside layer for max priority */ +#navi ul li.navbar-menu-item:nth-child(2) *, +#navi ul li.navbar-menu-item:nth-child(2) *:hover, +#navi ul li.navbar-menu-item:nth-child(2) *:focus, +#navi ul li.navbar-menu-item:nth-child(2) *:active, +#navi ul li.navbar-menu-item:nth-child(2):hover *, +#navi ul li.navbar-menu-item:nth-child(2):hover *:hover { + background-color: transparent !important; + background: transparent !important; + background-image: none !important; + box-shadow: none !important; +} + @layer components { nav a { text-decoration-line: none !important; @@ -464,6 +530,23 @@ @apply block mx-auto my-4; } + /* Fix white wrapper behind buttons on publication content in light mode */ + main.publication div.flex.gap-2, + main.publication div.flex.justify-between { + @apply bg-transparent; + } + + /* Override Flowbite light button white background in light mode to be more subtle */ + main.publication :global(button.bg-gray-100), + main.publication :global(button.bg-gray-50) { + @apply !bg-primary-100 !border-primary-200 !text-primary-800; + } + + main.publication :global(button.bg-gray-100:hover), + main.publication :global(button.bg-gray-50:hover) { + @apply !bg-primary-200 !border-primary-300; + } + /* Legend */ .leather-legend { @apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 @@ -488,7 +571,7 @@ } .leather-legend button { - @apply dark:text-white; + @apply text-gray-900 dark:text-gray-100; } .publication-leather { @@ -527,10 +610,10 @@ } } - /* All links - consistent hover behavior */ + /* All links - consistent hover behavior - improved contrast */ a { - @apply underline cursor-pointer hover:text-primary-600 - dark:hover:text-primary-400; + @apply underline cursor-pointer hover:text-primary-700 + dark:hover:text-primary-300; } .imageblock { @@ -567,10 +650,14 @@ } } - /* Footnotes */ + /* Footnotes - improved contrast */ .footnote-ref { text-decoration: none; - color: var(--color-primary-500); + color: var(--color-primary-700); + } + + .dark .footnote-ref { + color: var(--color-primary-300); } .footnotes { @@ -600,12 +687,21 @@ .footnote-backref { text-decoration: none; margin-left: 0.5rem; - color: var(--color-primary-500); + color: var(--color-primary-700); + } + + .dark .footnote-backref { + color: var(--color-primary-300); } .note-leather .footnote-ref, .note-leather .footnote-backref { - color: var(--color-primary-500); + color: var(--color-primary-700); + } + + .dark .note-leather .footnote-ref, + .dark .note-leather .footnote-backref { + color: var(--color-primary-300); } /* Scrollable content */ @@ -678,10 +774,10 @@ @apply focus:border-primary-600 dark:focus:border-primary-400; } - /* Table of Contents highlighting */ + /* Table of Contents highlighting - improved contrast */ .toc-highlight { - @apply bg-primary-300 dark:bg-primary-700 border-s-4 border-primary-600 - rounded dark:border-primary-400 font-medium; + @apply bg-primary-300 dark:bg-primary-700 border-s-4 border-primary-700 + rounded dark:border-primary-300 font-medium text-gray-900 dark:text-gray-100; transition: all 0.2s ease-in-out; } diff --git a/src/app.html b/src/app.html index 0ccb5a2..7a7ff22 100644 --- a/src/app.html +++ b/src/app.html @@ -8,8 +8,12 @@ - - -
- -
-

Alexandria

-

- READ THE ORIGINAL. MAKE CONNECTIONS. CULTIVATE KNOWLEDGE. -

-
-
-
-
- - -
- - Publications - Compose - Visualize - Getting Started - Events - {#if userState.signedIn} - My Notes - {/if} - About - Contact - - - - -
diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 22068d0..171d625 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -505,7 +505,7 @@
-
-
+
+
-
+
+ + {#if publicationActionsOpen} + (publicationActionsOpen = false)} + > +
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • + {#if $userStore.signedIn && $userStore.pubkey === indexEvent.pubkey} +
  • + +
  • + {/if} +
+
+
+
+ {/if} +
+
{#if publicationDeleted} @@ -557,39 +736,6 @@
- -
-
- - -
-
- - -
-
{#if showArticleCommentUI} @@ -647,6 +793,8 @@ {:else} {@const address = leaf.tagAddress()} + {@const publicationTitle = getMatchingTags(indexEvent, "title")[0]?.[1]} + {@const isFirstSection = i === 0} onPublicationSectionMounted(el, address)} /> {/if} @@ -861,3 +1011,11 @@ bind:comments {useMockComments} /> + + +
+ +
diff --git a/src/lib/components/publications/PublicationSection.svelte b/src/lib/components/publications/PublicationSection.svelte index 54cadaf..c273352 100644 --- a/src/lib/components/publications/PublicationSection.svelte +++ b/src/lib/components/publications/PublicationSection.svelte @@ -26,6 +26,8 @@ ref, allComments = [], commentsVisible = true, + publicationTitle, + isFirstSection = false, }: { address: string; rootAddress: string; @@ -35,6 +37,8 @@ ref: (ref: HTMLElement) => void; allComments?: NDKEvent[]; commentsVisible?: boolean; + publicationTitle?: string; + isFirstSection?: boolean; } = $props(); const asciidoctor: Asciidoctor = getContext("asciidoctor"); @@ -89,17 +93,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(() => { @@ -224,7 +243,7 @@ -
+
{: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 +264,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 +288,7 @@
-
+
- - {#await leafEvent then event} - {#if event} - - - {/if} - {/await}
+ + {#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} diff --git a/src/lib/components/util/ArticleNav.svelte b/src/lib/components/util/ArticleNav.svelte index 34d395d..bb19cc6 100644 --- a/src/lib/components/util/ArticleNav.svelte +++ b/src/lib/components/util/ArticleNav.svelte @@ -40,8 +40,10 @@ // 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) { diff --git a/src/lib/components/util/CardActions.svelte b/src/lib/components/util/CardActions.svelte index 4b30246..11c7bdf 100644 --- a/src/lib/components/util/CardActions.svelte +++ b/src/lib/components/util/CardActions.svelte @@ -26,10 +26,16 @@ import { WebSocketPool } from "$lib/data_structures/websocket_pool"; // Component props - let { event, onDelete, sectionAddress } = $props<{ + let { + event, + onDelete, + sectionAddress, + detailsModalOpen = $bindable(false) + } = $props<{ event: NDKEvent; onDelete?: () => void; sectionAddress?: string; // If provided, shows "Comment on section" option + detailsModalOpen?: boolean; // Bindable prop to control modal from outside }>(); const ndk = getNdkContext(); @@ -72,8 +78,7 @@ event.tags.find((t: string[]) => t[0] === "identifier")?.[1] ?? null, ); - // UI state - let detailsModalOpen: boolean = $state(false); + // UI state - detailsModalOpen is now a bindable prop let isOpen: boolean = $state(false); // Comment modal state @@ -535,7 +540,7 @@

- Index author: {@render userBadge(event.pubkey, author, ndk)} + {event.kind === 30040 ? "Index author" : "Article author"}: {@render userBadge(event.pubkey, author, ndk)}

diff --git a/src/lib/components/util/Details.svelte b/src/lib/components/util/Details.svelte index 56c1a85..c8db27e 100644 --- a/src/lib/components/util/Details.svelte +++ b/src/lib/components/util/Details.svelte @@ -14,10 +14,11 @@ // isModal // - don't show interactions in modal view // - don't show all the details when _not_ in modal view - let { event, isModal = false, onDelete } = $props<{ + let { event, isModal = false, onDelete, hideActions = false } = $props<{ event: any; isModal?: boolean; onDelete?: () => void; + hideActions?: boolean; }>(); let title: string = $derived(getMatchingTags(event, "title")[0]?.[1]); @@ -66,7 +67,7 @@
- {#if !isModal} + {#if !isModal && !hideActions}

+ {:else if !isModal && hideActions} +
+ +

{@render userBadge(event.pubkey, undefined, ndk)}

+
{/if}
- + {title} {/snippet} From dd3699287f9986b4d66224e96f0d8264bb772a03 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 12:42:46 +0100 Subject: [PATCH 11/31] add a beginning entry to the ToC, so that you can scroll all the way to the top of the page --- .../publications/TableOfContents.svelte | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/lib/components/publications/TableOfContents.svelte b/src/lib/components/publications/TableOfContents.svelte index 8003fc5..117769a 100644 --- a/src/lib/components/publications/TableOfContents.svelte +++ b/src/lib/components/publications/TableOfContents.svelte @@ -157,6 +157,24 @@ + + {#if depth === 2} + { + e.preventDefault(); + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + onClose?.(); + }} + > + + + {/if} {#if depth === 2} {@const rootEntry = toc.getRootEntry()} From 39fe4ce3d92df08f5e7bdfe95789a275422abd5d Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 12:48:32 +0100 Subject: [PATCH 12/31] fix toc indentation --- .../publications/TableOfContents.svelte | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/lib/components/publications/TableOfContents.svelte b/src/lib/components/publications/TableOfContents.svelte index 117769a..97b3af3 100644 --- a/src/lib/components/publications/TableOfContents.svelte +++ b/src/lib/components/publications/TableOfContents.svelte @@ -77,6 +77,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( @@ -163,6 +184,7 @@ label="Beginning" href="#" spanClass="px-2 text-ellipsis" + class={getIndentClass(2)} onclick={(e) => { e.preventDefault(); window.scrollTo({ @@ -184,7 +206,7 @@ label={rootEntry.title} href={`#${rootEntry.address}`} spanClass="px-2 text-ellipsis" - class={`${isVisible ? "toc-highlight" : ""} `} + class={`${getIndentClass(rootEntry.depth)} ${isVisible ? "toc-highlight" : ""} `} onclick={() => { const element = document.getElementById(rootEntry.address); if (element) { @@ -205,12 +227,13 @@ {@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)} > @@ -219,7 +242,7 @@ {@const childDepth = depth + 1} expanded, (open) => setEntryExpanded(address, open)} > From b3e4d7b053a8ef55912c54698d81703e78b0bece Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 14:16:19 +0100 Subject: [PATCH 13/31] don't let publication header menu button leak through article header --- src/lib/components/publications/Publication.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 7ed067d..11a5e98 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -572,7 +572,7 @@
e.stopPropagation()} From 33eced1fb0f0df62f2ada7bb117f22d51952cee7 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 16:06:58 +0100 Subject: [PATCH 14/31] fix publication loading switch from manual loading to infinite scroll --- .../publications/Publication.svelte | 442 +++++++++++++----- 1 file changed, 321 insertions(+), 121 deletions(-) diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 11a5e98..6f912cf 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -127,112 +127,215 @@ let leaves = $state>([]); let isLoading = $state(false); let isDone = $state(false); - let lastElementRef = $state(null); + let sentinelRef = $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: Cooldown to prevent rapid re-triggering of loadMore + let lastLoadTime = $state(0); + const LOAD_COOLDOWN_MS = 500; // Reduced to 500ms for more responsive loading - let observer: IntersectionObserver; + // AI-NOTE: Batch loading configuration for improved lazy-loading + // Initial load fills ~2 viewport heights, auto-load batches for smooth infinite scroll + const INITIAL_LOAD_COUNT = 30; + const AUTO_LOAD_BATCH_SIZE = 25; + /** + * 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; } + if (isLoading) { + console.debug("[Publication] Already loading, skipping"); + return; + } + + // Cooldown check to prevent rapid re-triggering + const now = Date.now(); + const timeSinceLastLoad = now - lastLoadTime; + if (timeSinceLastLoad < LOAD_COOLDOWN_MS) { + console.debug(`[Publication] Load cooldown active (${timeSinceLastLoad}ms < ${LOAD_COOLDOWN_MS}ms), skipping`); + return; + } + + if (isDone) { + console.debug("[Publication] Already done, skipping loadMore"); + return; + } + console.log( - `[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`, + `[Publication] Auto-loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`, ); isLoading = true; + lastLoadTime = now; 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); + }); + + // Load events sequentially to maintain order, but build batches for TOC updates 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) { + console.log("[Publication] Iterator done, no more events"); + 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(); + if (!loadedAddresses.has(address)) { + loadedAddresses.add(address); + newEvents.push(value); + console.debug(`[Publication] Queued event: ${address} (${value.id})`); + } else { + console.warn(`[Publication] Duplicate event detected: ${address}`); + newEvents.push(null); // Keep index consistent + } } else { - console.warn(`[Publication] Duplicate event detected: ${address}`); + consecutiveNulls++; + console.log(`[Publication] Got null event (${consecutiveNulls}/${MAX_CONSECUTIVE_NULLS} consecutive nulls)`); + + // Break early if we're getting too many nulls - likely no more content + if (consecutiveNulls >= MAX_CONSECUTIVE_NULLS) { + console.log("[Publication] Too many consecutive null events, assuming no more content"); + 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) { + console.log("[Publication] Too many errors/consecutive nulls, stopping load"); + break; } - } else { - console.log("[Publication] Got null event"); - leaves.push(null); } } + + // Add all new events at once for better performance and to trigger TOC updates in parallel + const validEvents = newEvents.filter(e => e !== null); + if (validEvents.length > 0) { + const previousLeavesCount = leaves.length; + leaves = [...leaves, ...newEvents]; + console.log( + `[Publication] Added ${validEvents.length} events. Previous: ${previousLeavesCount}, Total: ${leaves.length}`, + ); + + // Log sentinel position after adding content + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (sentinelRef) { + const rect = sentinelRef.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const distanceBelowViewport = rect.top - viewportHeight; + console.log("[Publication] Sentinel position after loadMore", { + leavesCount: leaves.length, + sentinelTop: rect.top, + viewportHeight, + distanceBelowViewport, + isConnected: sentinelRef.isConnected, + }); + } + }); + }); + } else if (newEvents.length > 0) { + // We got through the loop but no valid events - might be done + console.log("[Publication] Completed load but got no valid events", { + newEventsLength: newEvents.length, + consecutiveNulls, + }); + if (consecutiveNulls >= MAX_CONSECUTIVE_NULLS) { + isDone = true; + } + } else { + console.warn("[Publication] loadMore completed but no events were loaded", { + count, + newEventsLength: newEvents.length, + validEventsLength: validEvents.length, + }); + } } 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}`, - ); + console.log(`[Publication] Load complete. isLoading: ${isLoading}, isDone: ${isDone}, leaves: ${leaves.length}`); + + // 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; - } - } + // #endregion + // 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 (!lastElementRef) { + if (!publicationTree) { return; } - if (isDone) { - observer?.unobserve(lastElementRef!); - return; + // Only reset if publicationTree actually changed (different instance) + if (publicationTree === publicationTreeInstance && hasInitialized) { + return; // Already initialized with this tree, don't reset } - observer?.observe(lastElementRef!); - return () => observer?.unobserve(lastElementRef!); - }); - - // #endregion - - // AI-NOTE: Combined effect to handle publicationTree changes and initial loading - // This prevents conflicts between separate effects that could cause duplicate loading - $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(); - } - - // 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); + console.log("[Publication] New publication tree detected, resetting state"); + + // 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 + console.log("[Publication] Loading initial content"); + hasInitialized = true; + loadMore(INITIAL_LOAD_COUNT); }); // #region Columns visibility @@ -416,14 +519,15 @@ * @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}`); @@ -464,62 +568,149 @@ if (isLeaf || isBlog) { publicationColumnVisibility.update((v) => ({ ...v, toc: false })); } + }); + + // Setup highlight layer container reference + $effect(() => { + if (publicationContentRef && highlightLayerRef) { + highlightLayerRef.setContainer(publicationContentRef); + } + }); + + // AI-NOTE: Simple IntersectionObserver-based infinite scroll + // Uses a single, reliable mechanism to detect when sentinel is near viewport + // Queries DOM directly to avoid bind:this timing issues + $effect(() => { + // Track reactive dependencies + const initialized = hasInitialized; + const tree = publicationTree; - // Set up the intersection observer. - observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if ( - entry.isIntersecting && - !isLoading && - !isDone && - publicationTree - ) { - loadMore(1); - } + // Early return if not ready + if (!initialized || !tree) { + return; + } + + let observer: IntersectionObserver | null = null; + let checkInterval: number | null = null; + let setupInterval: number | null = null; + let isSetup = false; + + const getSentinel = (): HTMLElement | null => { + return document.getElementById("publication-sentinel"); + }; + + const checkAndLoad = () => { + if (isLoading || isDone) { + return; + } + + const currentSentinel = getSentinel(); + if (!currentSentinel || !currentSentinel.isConnected) { + return; + } + + const rect = currentSentinel.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const distanceBelowViewport = rect.top - viewportHeight; + + // Load if sentinel is within 1000px of viewport + if (distanceBelowViewport <= 1000 && distanceBelowViewport > -100) { + console.log("[Publication] Sentinel near viewport, loading more", { + distanceBelowViewport, + sentinelTop: rect.top, + viewportHeight, }); - }, - { threshold: 0.5 }, - ); + loadMore(AUTO_LOAD_BATCH_SIZE); + } + }; + + const setupObserver = () => { + if (isSetup || !hasInitialized || !publicationTree) { + return; + } + + const sentinel = getSentinel(); + if (!sentinel || !sentinel.isConnected) { + return; + } + + // Already set up + if (observer) { + return; + } + + console.log("[Publication] Setting up IntersectionObserver for infinite scroll", { + hasSentinel: !!sentinel, + isConnected: sentinel.isConnected, + }); - // 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 - - // Set up the intersection observer. - observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if ( - entry.isIntersecting && - !isLoading && - !isDone && - publicationTree - ) { - loadMore(1); + observer = new IntersectionObserver( + (entries) => { + // Check current state + if (isLoading || isDone) { + return; } - }); - }, - { threshold: 0.5 }, - ); + + for (const entry of entries) { + if (entry.isIntersecting) { + console.log("[Publication] Sentinel intersecting, loading more", { + intersectionRatio: entry.intersectionRatio, + boundingClientRect: entry.boundingClientRect, + }); + + loadMore(AUTO_LOAD_BATCH_SIZE); + break; + } + } + }, + { + // Trigger when sentinel is 1000px below viewport + rootMargin: "0px 0px 1000px 0px", + threshold: 0, + }, + ); - // 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 + observer.observe(sentinel); + isSetup = true; + + // Clear setup interval since we're now set up + if (setupInterval !== null) { + clearInterval(setupInterval); + setupInterval = null; + } + + console.log("[Publication] Observing sentinel", { + sentinelTop: sentinel.getBoundingClientRect().top, + viewportHeight: window.innerHeight, + }); + }; + // Try to set up immediately + setupObserver(); + + // Poll to set up observer when sentinel becomes available + setupInterval = window.setInterval(setupObserver, 100); + + // Fallback: check periodically in case IntersectionObserver doesn't fire + checkInterval = window.setInterval(checkAndLoad, 1000); + // Cleanup return () => { - observer.disconnect(); + if (setupInterval !== null) { + clearInterval(setupInterval); + } + if (checkInterval !== null) { + clearInterval(checkInterval); + } + if (observer) { + observer.disconnect(); + observer = null; + } + isSetup = false; + console.log("[Publication] Cleaned up IntersectionObserver"); }; }); - // Setup highlight layer container reference - $effect(() => { - if (publicationContentRef && highlightLayerRef) { - highlightLayerRef.setContainer(publicationContentRef); - } - }); - // #endregion @@ -809,17 +1000,25 @@ /> {/if} {/each} -
- {#if isLoading} - - {:else if !isDone} - - {:else} + + + + +
+ {#if isDone}

You've reached the end of the publication.

+ {:else if isLoading} +
+
+ Loading more... +
{/if}
@@ -984,7 +1183,8 @@ publicationTree.setBookmark(address)} onLoadMore={() => { if (!isLoading && !isDone && publicationTree) { - loadMore(4); + // AI-NOTE: TOC load more triggers auto-loading with standard batch size + loadMore(AUTO_LOAD_BATCH_SIZE); } }} onClose={closeToc} From 5f288295fcd17cc95d632515c4275dc9b00d14f7 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 16:22:03 +0100 Subject: [PATCH 15/31] revamp toc for background-loading of the entire publication map --- .../publications/Publication.svelte | 101 ++++++++++++++++++ .../publications/TableOfContents.svelte | 29 ++++- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 6f912cf..e257a76 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -300,6 +300,94 @@ } } + /** + * 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) { + console.warn("[Publication] publicationTree or toc is not available for background loading"); + return; + } + + console.log("[Publication] Starting background load of all events in level-layers (throttled)"); + + // 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((error: unknown) => { + console.debug(`[Publication] Error fetching child event ${childAddress}:`, error); + }); + } + } + } 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)); + } + } + + console.log(`[Publication] Completed level, processed ${currentLevelAddresses.length} addresses, queued ${queue.length} for next level`); + + // Delay between levels to give main loading priority + if (queue.length > 0) { + await new Promise(resolve => setTimeout(resolve, LEVEL_DELAY_MS)); + } + } + + console.log("[Publication] Background load complete, processed", processedAddresses.size, "addresses"); + } + // #endregion // AI-NOTE: Combined effect to handle publicationTree changes and initial loading @@ -336,6 +424,19 @@ console.log("[Publication] Loading initial content"); 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((error) => { + console.error("[Publication] Error in background loading:", error); + }); + } else { + console.warn("[Publication] toc or publicationTree not available for background loading"); + } + }, 100); }); // #region Columns visibility diff --git a/src/lib/components/publications/TableOfContents.svelte b/src/lib/components/publications/TableOfContents.svelte index 97b3af3..e01acf8 100644 --- a/src/lib/components/publications/TableOfContents.svelte +++ b/src/lib/components/publications/TableOfContents.svelte @@ -12,7 +12,7 @@ import Self from "./TableOfContents.svelte"; import { onMount, onDestroy } from "svelte"; - let { depth, onSectionFocused, onLoadMore, onClose, toc } = $props<{ + let { rootAddress, depth, onSectionFocused, onLoadMore, onClose, toc } = $props<{ rootAddress: string; depth: number; toc: TableOfContents; @@ -23,11 +23,36 @@ 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); } From 448009ebfaacef5fe172f6e010afb0b1c345baa4 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 16:45:08 +0100 Subject: [PATCH 16/31] allow jumping-in from the toc to a newly-rendered section trigger infinite scrolling from the jump-in position --- .../publications/Publication.svelte | 502 ++++++++++++++++-- 1 file changed, 470 insertions(+), 32 deletions(-) diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index e257a76..14c03ac 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -128,6 +128,7 @@ let isLoading = $state(false); let isDone = $state(false); let sentinelRef = $state(null); + let topSentinelRef = $state(null); let activeAddress = $state(null); let loadedAddresses = $state>(new Set()); let hasInitialized = $state(false); @@ -137,12 +138,18 @@ // AI-NOTE: Cooldown to prevent rapid re-triggering of loadMore let lastLoadTime = $state(0); - const LOAD_COOLDOWN_MS = 500; // Reduced to 500ms for more responsive loading + let lastLoadBeforeTime = $state(0); + let lastLoadBeforeAddress = $state(null); + let justLoadedBefore = $state(false); // Flag to prevent immediate re-triggering + const LOAD_COOLDOWN_MS = 2000; // Increased to 2 seconds to prevent loops // AI-NOTE: Batch loading configuration for improved lazy-loading // Initial load fills ~2 viewport heights, auto-load batches for smooth infinite scroll const INITIAL_LOAD_COUNT = 30; const AUTO_LOAD_BATCH_SIZE = 25; + + // AI-NOTE: Jump-to-section configuration + const JUMP_WINDOW_SIZE = 5; // Load 5 sections before and 5 after the target /** * Loads more events from the publication tree. @@ -300,6 +307,288 @@ } } + /** + * Loads sections before a given address in the TOC order. + * + * @param referenceAddress The address to load sections before + * @param count Number of sections to load + */ + async function loadSectionsBefore(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) { + if (!publicationTree || !toc || isLoading) { + return; + } + + // Cooldown check to prevent rapid re-triggering + const now = Date.now(); + const timeSinceLastLoad = now - lastLoadBeforeTime; + if (timeSinceLastLoad < LOAD_COOLDOWN_MS) { + console.debug(`[Publication] Load before cooldown active (${timeSinceLastLoad}ms < ${LOAD_COOLDOWN_MS}ms), skipping`); + return; + } + + // Prevent loading the same address repeatedly + if (lastLoadBeforeAddress === referenceAddress && timeSinceLastLoad < LOAD_COOLDOWN_MS * 2) { + console.debug(`[Publication] Already loading before ${referenceAddress}, skipping`); + return; + } + + // Get all addresses from TOC in depth-first order + const allAddresses: string[] = []; + for (const entry of toc) { + allAddresses.push(entry.address); + } + + const referenceIndex = allAddresses.indexOf(referenceAddress); + if (referenceIndex === -1) { + console.warn(`[Publication] Reference address ${referenceAddress} not found in TOC`); + return; + } + + // Check if we've reached the beginning + if (referenceIndex === 0) { + console.debug(`[Publication] Already at beginning of publication, no more sections to load before`); + return; + } + + // Get addresses before the reference + const startIndex = Math.max(0, referenceIndex - count); + const addressesToLoad = allAddresses.slice(startIndex, referenceIndex).reverse(); // Reverse to load closest first + + // Filter out already loaded addresses + const addressesToLoadFiltered = addressesToLoad.filter(addr => !loadedAddresses.has(addr)); + + if (addressesToLoadFiltered.length === 0) { + console.debug(`[Publication] All sections before ${referenceAddress} are already loaded`); + return; + } + + console.log(`[Publication] Loading ${addressesToLoadFiltered.length} sections before ${referenceAddress}`); + + isLoading = true; + lastLoadBeforeTime = now; + lastLoadBeforeAddress = referenceAddress; + 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); + } + } + + // Insert at the beginning of leaves array + const validEvents = newEvents.filter(e => e !== null); + if (validEvents.length > 0) { + leaves = [...newEvents.reverse(), ...leaves]; // Reverse back to maintain order + console.log(`[Publication] Loaded ${validEvents.length} sections before ${referenceAddress}`); + + // Set flag to prevent immediate re-triggering + justLoadedBefore = true; + setTimeout(() => { + justLoadedBefore = false; + }, LOAD_COOLDOWN_MS * 2); // Keep flag for 4 seconds + + // Note: setupObserver runs periodically and will pick up the new first section + } else { + // No new sections loaded - clear the tracking to allow retry later + lastLoadBeforeAddress = null; + justLoadedBefore = false; + } + + isLoading = false; + } + + /** + * Loads sections after a given address in the TOC order. + * + * @param referenceAddress The address to load sections after + * @param count Number of sections to load + */ + async function loadSectionsAfter(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) { + if (!publicationTree || !toc || isLoading) { + return; + } + + // Get all addresses from TOC in depth-first order + const allAddresses: string[] = []; + for (const entry of toc) { + allAddresses.push(entry.address); + } + + const referenceIndex = allAddresses.indexOf(referenceAddress); + if (referenceIndex === -1) { + console.warn(`[Publication] Reference address ${referenceAddress} not found in TOC`); + return; + } + + // Get addresses after the reference + const endIndex = Math.min(allAddresses.length - 1, referenceIndex + count); + const addressesToLoad = allAddresses.slice(referenceIndex + 1, endIndex + 1); + + console.log(`[Publication] Loading ${addressesToLoad.length} sections after ${referenceAddress}`); + + isLoading = true; + const newEvents: Array = []; + + for (const address of addressesToLoad) { + // Skip if already loaded + if (loadedAddresses.has(address)) { + continue; + } + + 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); + } + } + + // Find where to insert in leaves array (after the reference address) + if (newEvents.length > 0) { + const referenceIndexInLeaves = leaves.findIndex( + leaf => leaf?.tagAddress() === referenceAddress + ); + + if (referenceIndexInLeaves !== -1) { + // Insert after the reference + const before = leaves.slice(0, referenceIndexInLeaves + 1); + const after = leaves.slice(referenceIndexInLeaves + 1); + leaves = [...before, ...newEvents, ...after]; + } else { + // Reference not in leaves, append to end + leaves = [...leaves, ...newEvents]; + } + + console.log(`[Publication] Loaded ${newEvents.filter(e => e !== null).length} sections after ${referenceAddress}`); + } + + 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. + * + * @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) { + console.warn("[Publication] publicationTree or toc not available for jump-to-section"); + return; + } + + // Check if target is already loaded + const alreadyLoaded = leaves.some(leaf => leaf?.tagAddress() === targetAddress); + if (alreadyLoaded) { + console.log(`[Publication] Section ${targetAddress} already loaded, scrolling to it`); + // Scroll to the section + const element = document.getElementById(targetAddress); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + return; + } + + console.log(`[Publication] Jumping to section ${targetAddress} with window size ${windowSize}`); + + // Get all addresses from TOC in depth-first order + const allAddresses: string[] = []; + for (const entry of toc) { + allAddresses.push(entry.address); + } + + // Find target address index + const targetIndex = allAddresses.indexOf(targetAddress); + if (targetIndex === -1) { + console.warn(`[Publication] Target address ${targetAddress} not found in TOC`); + return; + } + + // Calculate window bounds + const startIndex = Math.max(0, targetIndex - windowSize); + const endIndex = Math.min(allAddresses.length - 1, targetIndex + windowSize); + const windowAddresses = allAddresses.slice(startIndex, endIndex + 1); + + console.log(`[Publication] Loading window: ${windowAddresses.length} sections (indices ${startIndex}-${endIndex})`); + + // Load events for the window + const windowEvents: Array<{ address: string; event: NDKEvent | null; index: number }> = []; + for (const address of windowAddresses) { + // Skip if already loaded + if (loadedAddresses.has(address)) { + continue; + } + + 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 into leaves array at correct positions + // We need to maintain order based on TOC order + const newLeaves = [...leaves]; + + for (const { address, event, index } of windowEvents) { + // Find where to insert this event in the leaves array + // We want to insert it at a position that maintains TOC order + let insertIndex = newLeaves.length; + + // Find the first position where the next address in TOC order appears + 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; + } + } + } + + // Insert the event at the calculated position + newLeaves.splice(insertIndex, 0, event); + console.log(`[Publication] Inserted section ${address} at position ${insertIndex}`); + } + + // Update leaves array + leaves = newLeaves; + + // 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' }); + } + }, 100); + + console.log(`[Publication] Jump-to-section complete. Loaded ${windowEvents.length} sections around ${targetAddress}`); + } + /** * 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. @@ -700,28 +989,106 @@ return document.getElementById("publication-sentinel"); }; + const getTopSentinel = (): HTMLElement | null => { + return document.getElementById("publication-top-sentinel"); + }; + + let lastCheckTime = 0; + const CHECK_COOLDOWN_MS = 2000; // Only check every 2 seconds to prevent loops + const checkAndLoad = () => { - if (isLoading || isDone) { + // Cooldown check to prevent rapid checking + const now = Date.now(); + if (now - lastCheckTime < CHECK_COOLDOWN_MS) { return; } + lastCheckTime = now; - const currentSentinel = getSentinel(); - if (!currentSentinel || !currentSentinel.isConnected) { + if (isLoading || isDone || !toc) { return; } - const rect = currentSentinel.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const distanceBelowViewport = rect.top - viewportHeight; + // Check bottom sentinel for loading more sections after + const bottomSentinel = getSentinel(); + if (bottomSentinel && bottomSentinel.isConnected) { + const rect = bottomSentinel.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const distanceBelowViewport = rect.top - viewportHeight; + + // Load if sentinel is within 1000px of viewport + if (distanceBelowViewport <= 1000 && distanceBelowViewport > -100) { + // Find the last loaded section + const lastLoadedSection = leaves.filter(l => l !== null).slice(-1)[0]; + if (lastLoadedSection) { + const lastAddress = lastLoadedSection.tagAddress(); + console.log("[Publication] Bottom sentinel near viewport, loading more after", lastAddress); + loadSectionsAfter(lastAddress, AUTO_LOAD_BATCH_SIZE); + } else { + loadMore(AUTO_LOAD_BATCH_SIZE); + } + } + } - // Load if sentinel is within 1000px of viewport - if (distanceBelowViewport <= 1000 && distanceBelowViewport > -100) { - console.log("[Publication] Sentinel near viewport, loading more", { - distanceBelowViewport, - sentinelTop: rect.top, - viewportHeight, - }); - loadMore(AUTO_LOAD_BATCH_SIZE); + // Check if we're near the top - load sections before when scrolling up + const firstLoadedSection = leaves.filter(l => l !== null)[0]; + + if (firstLoadedSection) { + const firstAddress = firstLoadedSection.tagAddress(); + + // Check if we're at the beginning - don't load if first section is the root + if (firstAddress === rootAddress) { + // Already at beginning, skip + return; + } + + const firstSectionElement = document.getElementById(firstAddress); + if (firstSectionElement) { + const rect = firstSectionElement.getBoundingClientRect(); + const distanceFromTop = rect.top; + const scrollY = window.scrollY || window.pageYOffset; + + // Load if: + // 1. First section is visible or near viewport (within 2000px below top), OR + // 2. First section is above viewport but within 3000px (user scrolling up toward it), OR + // 3. User has scrolled near the top of the document (scrollY < 1000) and first section is above viewport + const isNearOrVisible = distanceFromTop <= 2000 && distanceFromTop > -100; + const isAboveButClose = distanceFromTop < -100 && distanceFromTop > -3000; + const isScrolledToTop = scrollY < 1000 && distanceFromTop < 0; + + if (isNearOrVisible || isAboveButClose || isScrolledToTop) { + // Double-check we're not already loading, haven't just loaded, and haven't just loaded before + if (!isLoading && !justLoadedBefore && lastLoadBeforeAddress !== firstAddress) { + console.log("[Publication] checkAndLoad: First section near viewport, loading more before", firstAddress, { + distanceFromTop, + scrollY, + firstSectionTop: rect.top, + viewportHeight: window.innerHeight, + isNearOrVisible, + isAboveButClose, + isScrolledToTop, + }); + loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE); + } else { + console.debug("[Publication] checkAndLoad: Skipping", { + isLoading, + justLoadedBefore, + lastLoadBeforeAddress, + firstAddress, + }); + } + } else { + console.debug("[Publication] checkAndLoad: First section not near enough", { + firstAddress, + distanceFromTop, + scrollY, + threshold: "2000px to -3000px or scrollY < 1000", + }); + } + } else { + console.debug("[Publication] checkAndLoad: First section element not found in DOM", firstAddress); + } + } else { + console.debug("[Publication] checkAndLoad: No first loaded section"); } }; @@ -731,7 +1098,10 @@ } const sentinel = getSentinel(); - if (!sentinel || !sentinel.isConnected) { + const topSentinel = getTopSentinel(); + + // Need at least one sentinel to be ready + if ((!sentinel || !sentinel.isConnected) && (!topSentinel || !topSentinel.isConnected)) { return; } @@ -741,37 +1111,85 @@ } console.log("[Publication] Setting up IntersectionObserver for infinite scroll", { - hasSentinel: !!sentinel, - isConnected: sentinel.isConnected, + hasBottomSentinel: !!sentinel, + hasTopSentinel: !!topSentinel, + bottomSentinelConnected: sentinel?.isConnected, + topSentinelConnected: topSentinel?.isConnected, }); observer = new IntersectionObserver( (entries) => { // Check current state - if (isLoading || isDone) { + if (isLoading || isDone || !toc) { return; } for (const entry of entries) { if (entry.isIntersecting) { - console.log("[Publication] Sentinel intersecting, loading more", { - intersectionRatio: entry.intersectionRatio, - boundingClientRect: entry.boundingClientRect, - }); + const sentinelId = entry.target.id; - loadMore(AUTO_LOAD_BATCH_SIZE); + if (sentinelId === "publication-sentinel") { + // Bottom sentinel - load sections after + const lastLoadedSection = leaves.filter(l => l !== null).slice(-1)[0]; + if (lastLoadedSection) { + const lastAddress = lastLoadedSection.tagAddress(); + console.log("[Publication] Bottom sentinel intersecting, loading more after", lastAddress); + loadSectionsAfter(lastAddress, AUTO_LOAD_BATCH_SIZE); + } else { + loadMore(AUTO_LOAD_BATCH_SIZE); + } + } else if (sentinelId === "publication-top-sentinel") { + // Top sentinel - load sections before + const firstLoadedSection = leaves.filter(l => l !== null)[0]; + if (firstLoadedSection) { + const firstAddress = firstLoadedSection.tagAddress(); + // Don't load if we're at the root + if (firstAddress !== rootAddress) { + console.log("[Publication] Top sentinel intersecting, loading more before", firstAddress); + loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE); + } + } + } else { + // Check if this is the first section element + const firstLoadedSection = leaves.filter(l => l !== null)[0]; + if (firstLoadedSection && entry.target.id === firstLoadedSection.tagAddress()) { + const firstAddress = firstLoadedSection.tagAddress(); + // Don't load if we're at the root + if (firstAddress !== rootAddress) { + console.log("[Publication] First section intersecting near top, loading more before", firstAddress); + loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE); + } + } + } break; } } }, { - // Trigger when sentinel is 1000px below viewport - rootMargin: "0px 0px 1000px 0px", + // Trigger when sentinel is 2000px from viewport (above or below) + // Larger margin for upward scrolling detection + rootMargin: "2000px 0px 2000px 0px", threshold: 0, }, ); - observer.observe(sentinel); + // Observe both sentinels + if (sentinel) { + observer.observe(sentinel); + } + if (topSentinel) { + observer.observe(topSentinel); + } + + // Also observe the first section element if available + const firstLoadedSection = leaves.filter(l => l !== null)[0]; + if (firstLoadedSection) { + const firstSectionElement = document.getElementById(firstLoadedSection.tagAddress()); + if (firstSectionElement) { + observer.observe(firstSectionElement); + } + } + isSetup = true; // Clear setup interval since we're now set up @@ -780,8 +1198,9 @@ setupInterval = null; } - console.log("[Publication] Observing sentinel", { - sentinelTop: sentinel.getBoundingClientRect().top, + console.log("[Publication] Observing sentinels", { + hasBottomSentinel: !!sentinel, + hasTopSentinel: !!topSentinel, viewportHeight: window.innerHeight, }); }; @@ -793,7 +1212,8 @@ setupInterval = window.setInterval(setupObserver, 100); // Fallback: check periodically in case IntersectionObserver doesn't fire - checkInterval = window.setInterval(checkAndLoad, 1000); + // Increased interval to 3 seconds to prevent loops (cooldown is 2 seconds) + checkInterval = window.setInterval(checkAndLoad, 3000); // Cleanup return () => { @@ -1076,6 +1496,22 @@
{/if} + + +
+ {#if isLoading && leaves.length > 0} +
+
+ Loading previous sections... +
+ {/if} +
+ {#each leaves as leaf, i} {#if leaf == null} @@ -1280,8 +1716,10 @@ {rootAddress} {toc} depth={2} - onSectionFocused={(address: string) => - publicationTree.setBookmark(address)} + onSectionFocused={(address: string) => { + // Jump to section instead of just setting bookmark + jumpToSection(address); + }} onLoadMore={() => { if (!isLoading && !isDone && publicationTree) { // AI-NOTE: TOC load more triggers auto-loading with standard batch size From 83ef816b4613a470ab2f4c387fb185b1677ba467 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 17:05:14 +0100 Subject: [PATCH 17/31] refactor --- .../publications/Publication.svelte | 456 ++++-------------- 1 file changed, 91 insertions(+), 365 deletions(-) diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 14c03ac..db65813 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -136,20 +136,10 @@ let publicationDeleted = $state(false); let sidebarTop = $state(162); // Default to 162px (100px navbar + 62px ArticleNav) - // AI-NOTE: Cooldown to prevent rapid re-triggering of loadMore - let lastLoadTime = $state(0); - let lastLoadBeforeTime = $state(0); - let lastLoadBeforeAddress = $state(null); - let justLoadedBefore = $state(false); // Flag to prevent immediate re-triggering - const LOAD_COOLDOWN_MS = 2000; // Increased to 2 seconds to prevent loops - - // AI-NOTE: Batch loading configuration for improved lazy-loading - // Initial load fills ~2 viewport heights, auto-load batches for smooth infinite scroll + // AI-NOTE: Batch loading configuration const INITIAL_LOAD_COUNT = 30; const AUTO_LOAD_BATCH_SIZE = 25; - - // AI-NOTE: Jump-to-section configuration - const JUMP_WINDOW_SIZE = 5; // Load 5 sections before and 5 after the target + const JUMP_WINDOW_SIZE = 5; /** * Loads more events from the publication tree. @@ -162,30 +152,11 @@ return; } - if (isLoading) { - console.debug("[Publication] Already loading, skipping"); + if (isLoading || isDone) { return; } - // Cooldown check to prevent rapid re-triggering - const now = Date.now(); - const timeSinceLastLoad = now - lastLoadTime; - if (timeSinceLastLoad < LOAD_COOLDOWN_MS) { - console.debug(`[Publication] Load cooldown active (${timeSinceLastLoad}ms < ${LOAD_COOLDOWN_MS}ms), skipping`); - return; - } - - if (isDone) { - console.debug("[Publication] Already done, skipping loadMore"); - return; - } - - console.log( - `[Publication] Auto-loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`, - ); - isLoading = true; - lastLoadTime = now; try { const newEvents: Array = []; @@ -219,13 +190,13 @@ if (value) { consecutiveNulls = 0; // Reset null counter const address = value.tagAddress(); - if (!loadedAddresses.has(address)) { + // Check both loadedAddresses and leaves to prevent duplicates + const alreadyInLeaves = leaves.some(leaf => leaf?.tagAddress() === address); + if (!loadedAddresses.has(address) && !alreadyInLeaves) { loadedAddresses.add(address); newEvents.push(value); - console.debug(`[Publication] Queued event: ${address} (${value.id})`); } else { - console.warn(`[Publication] Duplicate event detected: ${address}`); - newEvents.push(null); // Keep index consistent + newEvents.push(null); } } else { consecutiveNulls++; @@ -307,66 +278,49 @@ } } + /** + * 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; + } + /** * Loads sections before a given address in the TOC order. - * - * @param referenceAddress The address to load sections before - * @param count Number of sections to load */ async function loadSectionsBefore(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) { if (!publicationTree || !toc || isLoading) { return; } - // Cooldown check to prevent rapid re-triggering - const now = Date.now(); - const timeSinceLastLoad = now - lastLoadBeforeTime; - if (timeSinceLastLoad < LOAD_COOLDOWN_MS) { - console.debug(`[Publication] Load before cooldown active (${timeSinceLastLoad}ms < ${LOAD_COOLDOWN_MS}ms), skipping`); - return; - } - - // Prevent loading the same address repeatedly - if (lastLoadBeforeAddress === referenceAddress && timeSinceLastLoad < LOAD_COOLDOWN_MS * 2) { - console.debug(`[Publication] Already loading before ${referenceAddress}, skipping`); - return; - } - - // Get all addresses from TOC in depth-first order - const allAddresses: string[] = []; - for (const entry of toc) { - allAddresses.push(entry.address); - } - + const allAddresses = getAllSectionAddresses(); const referenceIndex = allAddresses.indexOf(referenceAddress); - if (referenceIndex === -1) { - console.warn(`[Publication] Reference address ${referenceAddress} not found in TOC`); - return; - } - - // Check if we've reached the beginning - if (referenceIndex === 0) { - console.debug(`[Publication] Already at beginning of publication, no more sections to load before`); - return; + + if (referenceIndex === -1 || referenceIndex === 0) { + return; // Not found or already at beginning } - // Get addresses before the reference const startIndex = Math.max(0, referenceIndex - count); - const addressesToLoad = allAddresses.slice(startIndex, referenceIndex).reverse(); // Reverse to load closest first + const addressesToLoad = allAddresses.slice(startIndex, referenceIndex).reverse(); - // Filter out already loaded addresses - const addressesToLoadFiltered = addressesToLoad.filter(addr => !loadedAddresses.has(addr)); + // 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) { - console.debug(`[Publication] All sections before ${referenceAddress} are already loaded`); return; } - console.log(`[Publication] Loading ${addressesToLoadFiltered.length} sections before ${referenceAddress}`); - isLoading = true; - lastLoadBeforeTime = now; - lastLoadBeforeAddress = referenceAddress; const newEvents: Array = []; for (const address of addressesToLoadFiltered) { @@ -384,23 +338,9 @@ } } - // Insert at the beginning of leaves array const validEvents = newEvents.filter(e => e !== null); if (validEvents.length > 0) { - leaves = [...newEvents.reverse(), ...leaves]; // Reverse back to maintain order - console.log(`[Publication] Loaded ${validEvents.length} sections before ${referenceAddress}`); - - // Set flag to prevent immediate re-triggering - justLoadedBefore = true; - setTimeout(() => { - justLoadedBefore = false; - }, LOAD_COOLDOWN_MS * 2); // Keep flag for 4 seconds - - // Note: setupObserver runs periodically and will pick up the new first section - } else { - // No new sections loaded - clear the tracking to allow retry later - lastLoadBeforeAddress = null; - justLoadedBefore = false; + leaves = [...newEvents.reverse(), ...leaves]; } isLoading = false; @@ -408,42 +348,36 @@ /** * Loads sections after a given address in the TOC order. - * - * @param referenceAddress The address to load sections after - * @param count Number of sections to load */ async function loadSectionsAfter(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) { if (!publicationTree || !toc || isLoading) { return; } - // Get all addresses from TOC in depth-first order - const allAddresses: string[] = []; - for (const entry of toc) { - allAddresses.push(entry.address); - } - + const allAddresses = getAllSectionAddresses(); const referenceIndex = allAddresses.indexOf(referenceAddress); + if (referenceIndex === -1) { - console.warn(`[Publication] Reference address ${referenceAddress} not found in TOC`); return; } - // Get addresses after the reference const endIndex = Math.min(allAddresses.length - 1, referenceIndex + count); const addressesToLoad = allAddresses.slice(referenceIndex + 1, endIndex + 1); - console.log(`[Publication] Loading ${addressesToLoad.length} sections after ${referenceAddress}`); + // 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 addressesToLoad) { - // Skip if already loaded - if (loadedAddresses.has(address)) { - continue; - } - + for (const address of addressesToLoadFiltered) { try { const event = await publicationTree.getEvent(address); if (event) { @@ -458,23 +392,18 @@ } } - // Find where to insert in leaves array (after the reference address) if (newEvents.length > 0) { const referenceIndexInLeaves = leaves.findIndex( leaf => leaf?.tagAddress() === referenceAddress ); if (referenceIndexInLeaves !== -1) { - // Insert after the reference const before = leaves.slice(0, referenceIndexInLeaves + 1); const after = leaves.slice(referenceIndexInLeaves + 1); leaves = [...before, ...newEvents, ...after]; } else { - // Reference not in leaves, append to end leaves = [...leaves, ...newEvents]; } - - console.log(`[Publication] Loaded ${newEvents.filter(e => e !== null).length} sections after ${referenceAddress}`); } isLoading = false; @@ -505,36 +434,27 @@ return; } - console.log(`[Publication] Jumping to section ${targetAddress} with window size ${windowSize}`); - - // Get all addresses from TOC in depth-first order - const allAddresses: string[] = []; - for (const entry of toc) { - allAddresses.push(entry.address); - } - - // Find target address index + const allAddresses = getAllSectionAddresses(); const targetIndex = allAddresses.indexOf(targetAddress); + if (targetIndex === -1) { console.warn(`[Publication] Target address ${targetAddress} not found in TOC`); return; } - // Calculate window bounds const startIndex = Math.max(0, targetIndex - windowSize); const endIndex = Math.min(allAddresses.length - 1, targetIndex + windowSize); const windowAddresses = allAddresses.slice(startIndex, endIndex + 1); - console.log(`[Publication] Loading window: ${windowAddresses.length} sections (indices ${startIndex}-${endIndex})`); + // Filter out already loaded + const existingAddresses = new Set(leaves.map(leaf => leaf?.tagAddress()).filter(Boolean)); + const addressesToLoad = windowAddresses.filter(addr => + !loadedAddresses.has(addr) && !existingAddresses.has(addr) + ); - // Load events for the window + // Load events const windowEvents: Array<{ address: string; event: NDKEvent | null; index: number }> = []; - for (const address of windowAddresses) { - // Skip if already loaded - if (loadedAddresses.has(address)) { - continue; - } - + for (const address of addressesToLoad) { try { const event = await publicationTree.getEvent(address); if (event) { @@ -546,16 +466,15 @@ } } - // Insert events into leaves array at correct positions - // We need to maintain order based on TOC order + // Insert events in TOC order const newLeaves = [...leaves]; - for (const { address, event, index } of windowEvents) { - // Find where to insert this event in the leaves array - // We want to insert it at a position that maintains TOC order - let insertIndex = newLeaves.length; + // Skip if already in leaves + if (newLeaves.some(leaf => leaf?.tagAddress() === address)) { + continue; + } - // Find the first position where the next address in TOC order appears + let insertIndex = newLeaves.length; for (let i = 0; i < newLeaves.length; i++) { const leafAddress = newLeaves[i]?.tagAddress(); if (leafAddress) { @@ -566,13 +485,9 @@ } } } - - // Insert the event at the calculated position newLeaves.splice(insertIndex, 0, event); - console.log(`[Publication] Inserted section ${address} at position ${insertIndex}`); } - // Update leaves array leaves = newLeaves; // Set bookmark to target address for future sequential loading @@ -968,267 +883,78 @@ }); // AI-NOTE: Simple IntersectionObserver-based infinite scroll - // Uses a single, reliable mechanism to detect when sentinel is near viewport - // Queries DOM directly to avoid bind:this timing issues $effect(() => { - // Track reactive dependencies - const initialized = hasInitialized; - const tree = publicationTree; - - // Early return if not ready - if (!initialized || !tree) { + if (!hasInitialized || !publicationTree || !toc) { return; } let observer: IntersectionObserver | null = null; - let checkInterval: number | null = null; - let setupInterval: number | null = null; - let isSetup = false; - - const getSentinel = (): HTMLElement | null => { - return document.getElementById("publication-sentinel"); - }; - - const getTopSentinel = (): HTMLElement | null => { - return document.getElementById("publication-top-sentinel"); - }; - - let lastCheckTime = 0; - const CHECK_COOLDOWN_MS = 2000; // Only check every 2 seconds to prevent loops - - const checkAndLoad = () => { - // Cooldown check to prevent rapid checking - const now = Date.now(); - if (now - lastCheckTime < CHECK_COOLDOWN_MS) { - return; - } - lastCheckTime = now; - - if (isLoading || isDone || !toc) { - return; - } - - // Check bottom sentinel for loading more sections after - const bottomSentinel = getSentinel(); - if (bottomSentinel && bottomSentinel.isConnected) { - const rect = bottomSentinel.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const distanceBelowViewport = rect.top - viewportHeight; - - // Load if sentinel is within 1000px of viewport - if (distanceBelowViewport <= 1000 && distanceBelowViewport > -100) { - // Find the last loaded section - const lastLoadedSection = leaves.filter(l => l !== null).slice(-1)[0]; - if (lastLoadedSection) { - const lastAddress = lastLoadedSection.tagAddress(); - console.log("[Publication] Bottom sentinel near viewport, loading more after", lastAddress); - loadSectionsAfter(lastAddress, AUTO_LOAD_BATCH_SIZE); - } else { - loadMore(AUTO_LOAD_BATCH_SIZE); - } - } - } - - // Check if we're near the top - load sections before when scrolling up - const firstLoadedSection = leaves.filter(l => l !== null)[0]; - - if (firstLoadedSection) { - const firstAddress = firstLoadedSection.tagAddress(); - - // Check if we're at the beginning - don't load if first section is the root - if (firstAddress === rootAddress) { - // Already at beginning, skip - return; - } - - const firstSectionElement = document.getElementById(firstAddress); - if (firstSectionElement) { - const rect = firstSectionElement.getBoundingClientRect(); - const distanceFromTop = rect.top; - const scrollY = window.scrollY || window.pageYOffset; - - // Load if: - // 1. First section is visible or near viewport (within 2000px below top), OR - // 2. First section is above viewport but within 3000px (user scrolling up toward it), OR - // 3. User has scrolled near the top of the document (scrollY < 1000) and first section is above viewport - const isNearOrVisible = distanceFromTop <= 2000 && distanceFromTop > -100; - const isAboveButClose = distanceFromTop < -100 && distanceFromTop > -3000; - const isScrolledToTop = scrollY < 1000 && distanceFromTop < 0; - - if (isNearOrVisible || isAboveButClose || isScrolledToTop) { - // Double-check we're not already loading, haven't just loaded, and haven't just loaded before - if (!isLoading && !justLoadedBefore && lastLoadBeforeAddress !== firstAddress) { - console.log("[Publication] checkAndLoad: First section near viewport, loading more before", firstAddress, { - distanceFromTop, - scrollY, - firstSectionTop: rect.top, - viewportHeight: window.innerHeight, - isNearOrVisible, - isAboveButClose, - isScrolledToTop, - }); - loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE); - } else { - console.debug("[Publication] checkAndLoad: Skipping", { - isLoading, - justLoadedBefore, - lastLoadBeforeAddress, - firstAddress, - }); - } - } else { - console.debug("[Publication] checkAndLoad: First section not near enough", { - firstAddress, - distanceFromTop, - scrollY, - threshold: "2000px to -3000px or scrollY < 1000", - }); - } - } else { - console.debug("[Publication] checkAndLoad: First section element not found in DOM", firstAddress); - } - } else { - console.debug("[Publication] checkAndLoad: No first loaded section"); - } - }; - + let setupTimeout: number | null = null; + const setupObserver = () => { - if (isSetup || !hasInitialized || !publicationTree) { - return; - } - - const sentinel = getSentinel(); - const topSentinel = getTopSentinel(); - - // Need at least one sentinel to be ready - if ((!sentinel || !sentinel.isConnected) && (!topSentinel || !topSentinel.isConnected)) { + if (observer) { return; } - // Already set up - if (observer) { + const bottomSentinel = document.getElementById("publication-sentinel"); + const topSentinel = document.getElementById("publication-top-sentinel"); + + if (!bottomSentinel && !topSentinel) { return; } - console.log("[Publication] Setting up IntersectionObserver for infinite scroll", { - hasBottomSentinel: !!sentinel, - hasTopSentinel: !!topSentinel, - bottomSentinelConnected: sentinel?.isConnected, - topSentinelConnected: topSentinel?.isConnected, - }); - observer = new IntersectionObserver( (entries) => { - // Check current state - if (isLoading || isDone || !toc) { + if (isLoading || isDone) { return; } for (const entry of entries) { - if (entry.isIntersecting) { - const sentinelId = entry.target.id; - - if (sentinelId === "publication-sentinel") { - // Bottom sentinel - load sections after - const lastLoadedSection = leaves.filter(l => l !== null).slice(-1)[0]; - if (lastLoadedSection) { - const lastAddress = lastLoadedSection.tagAddress(); - console.log("[Publication] Bottom sentinel intersecting, loading more after", lastAddress); - loadSectionsAfter(lastAddress, AUTO_LOAD_BATCH_SIZE); - } else { - loadMore(AUTO_LOAD_BATCH_SIZE); - } - } else if (sentinelId === "publication-top-sentinel") { - // Top sentinel - load sections before - const firstLoadedSection = leaves.filter(l => l !== null)[0]; - if (firstLoadedSection) { - const firstAddress = firstLoadedSection.tagAddress(); - // Don't load if we're at the root - if (firstAddress !== rootAddress) { - console.log("[Publication] Top sentinel intersecting, loading more before", firstAddress); - loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE); - } - } + if (!entry.isIntersecting) { + continue; + } + + const targetId = entry.target.id; + + if (targetId === "publication-sentinel") { + const lastSection = leaves.filter(l => l !== null).slice(-1)[0]; + if (lastSection) { + loadSectionsAfter(lastSection.tagAddress(), AUTO_LOAD_BATCH_SIZE); } else { - // Check if this is the first section element - const firstLoadedSection = leaves.filter(l => l !== null)[0]; - if (firstLoadedSection && entry.target.id === firstLoadedSection.tagAddress()) { - const firstAddress = firstLoadedSection.tagAddress(); - // Don't load if we're at the root - if (firstAddress !== rootAddress) { - console.log("[Publication] First section intersecting near top, loading more before", firstAddress); - loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE); - } - } + loadMore(AUTO_LOAD_BATCH_SIZE); + } + } else if (targetId === "publication-top-sentinel") { + const firstSection = leaves.filter(l => l !== null)[0]; + if (firstSection && firstSection.tagAddress() !== rootAddress) { + loadSectionsBefore(firstSection.tagAddress(), AUTO_LOAD_BATCH_SIZE); } - break; } + break; } }, { - // Trigger when sentinel is 2000px from viewport (above or below) - // Larger margin for upward scrolling detection - rootMargin: "2000px 0px 2000px 0px", + rootMargin: "1000px 0px 1000px 0px", threshold: 0, }, ); - // Observe both sentinels - if (sentinel) { - observer.observe(sentinel); + if (bottomSentinel) { + observer.observe(bottomSentinel); } if (topSentinel) { observer.observe(topSentinel); } - - // Also observe the first section element if available - const firstLoadedSection = leaves.filter(l => l !== null)[0]; - if (firstLoadedSection) { - const firstSectionElement = document.getElementById(firstLoadedSection.tagAddress()); - if (firstSectionElement) { - observer.observe(firstSectionElement); - } - } - - isSetup = true; - - // Clear setup interval since we're now set up - if (setupInterval !== null) { - clearInterval(setupInterval); - setupInterval = null; - } - - console.log("[Publication] Observing sentinels", { - hasBottomSentinel: !!sentinel, - hasTopSentinel: !!topSentinel, - viewportHeight: window.innerHeight, - }); }; - // Try to set up immediately - setupObserver(); - - // Poll to set up observer when sentinel becomes available - setupInterval = window.setInterval(setupObserver, 100); - - // Fallback: check periodically in case IntersectionObserver doesn't fire - // Increased interval to 3 seconds to prevent loops (cooldown is 2 seconds) - checkInterval = window.setInterval(checkAndLoad, 3000); + setupTimeout = window.setTimeout(setupObserver, 100); - // Cleanup return () => { - if (setupInterval !== null) { - clearInterval(setupInterval); - } - if (checkInterval !== null) { - clearInterval(checkInterval); + if (setupTimeout !== null) { + clearTimeout(setupTimeout); } if (observer) { observer.disconnect(); - observer = null; } - isSetup = false; - console.log("[Publication] Cleaned up IntersectionObserver"); }; }); From 2cb8266297986d895b439ef2444f4a7c2ab9d425 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 17:11:26 +0100 Subject: [PATCH 18/31] fix refactor --- .../publications/Publication.svelte | 148 +++++++++++++----- 1 file changed, 110 insertions(+), 38 deletions(-) diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index db65813..b07bc82 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -292,6 +292,61 @@ return addresses; } + /** + * 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. */ @@ -338,9 +393,8 @@ } } - const validEvents = newEvents.filter(e => e !== null); - if (validEvents.length > 0) { - leaves = [...newEvents.reverse(), ...leaves]; + if (newEvents.length > 0) { + leaves = insertEventsInOrder(newEvents, allAddresses); } isLoading = false; @@ -393,17 +447,7 @@ } if (newEvents.length > 0) { - const referenceIndexInLeaves = leaves.findIndex( - leaf => leaf?.tagAddress() === referenceAddress - ); - - if (referenceIndexInLeaves !== -1) { - const before = leaves.slice(0, referenceIndexInLeaves + 1); - const after = leaves.slice(referenceIndexInLeaves + 1); - leaves = [...before, ...newEvents, ...after]; - } else { - leaves = [...leaves, ...newEvents]; - } + leaves = insertEventsInOrder(newEvents, allAddresses); } isLoading = false; @@ -466,29 +510,9 @@ } } - // Insert events in TOC order - const newLeaves = [...leaves]; - for (const { address, event, index } of windowEvents) { - // Skip if already in leaves - if (newLeaves.some(leaf => leaf?.tagAddress() === address)) { - continue; - } - - 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; - } - } - } - newLeaves.splice(insertIndex, 0, event); - } - - leaves = newLeaves; + // 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); @@ -883,6 +907,7 @@ }); // AI-NOTE: Simple IntersectionObserver-based infinite scroll + // Observes sentinels and first section element for upward scrolling $effect(() => { if (!hasInitialized || !publicationTree || !toc) { return; @@ -890,6 +915,8 @@ let observer: IntersectionObserver | null = null; let setupTimeout: number | null = null; + let updateInterval: number | null = null; + let observedFirstSection: string | null = null; const setupObserver = () => { if (observer) { @@ -928,6 +955,12 @@ if (firstSection && firstSection.tagAddress() !== rootAddress) { loadSectionsBefore(firstSection.tagAddress(), AUTO_LOAD_BATCH_SIZE); } + } else { + // This is the first section element + const firstSection = leaves.filter(l => l !== null)[0]; + if (firstSection && targetId === firstSection.tagAddress() && targetId !== rootAddress) { + loadSectionsBefore(targetId, AUTO_LOAD_BATCH_SIZE); + } } break; } @@ -946,12 +979,51 @@ } }; - setupTimeout = window.setTimeout(setupObserver, 100); + // Update observer when first section changes (e.g., after jump) + const updateFirstSectionObserver = () => { + if (!observer || leaves.length === 0) { + return; + } + + const firstSection = leaves.filter(l => l !== null)[0]; + if (!firstSection) { + return; + } + + const firstAddress = firstSection.tagAddress(); + if (firstAddress === observedFirstSection || firstAddress === rootAddress) { + return; + } + + // Unobserve previous first section if it changed + if (observedFirstSection) { + const prevElement = document.getElementById(observedFirstSection); + if (prevElement) { + observer.unobserve(prevElement); + } + } + + // Observe new first section + const firstElement = document.getElementById(firstAddress); + if (firstElement) { + observer.observe(firstElement); + observedFirstSection = firstAddress; + } + }; + + setupTimeout = window.setTimeout(() => { + setupObserver(); + // Start updating when first section changes + updateInterval = window.setInterval(updateFirstSectionObserver, 500); + }, 100); return () => { if (setupTimeout !== null) { clearTimeout(setupTimeout); } + if (updateInterval !== null) { + clearInterval(updateInterval); + } if (observer) { observer.disconnect(); } From 6651b8394b61ad082cae3473c8e73ea30ba8d033 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 22:47:23 +0100 Subject: [PATCH 19/31] expand upward from jump --- .../publications/CommentLayer.svelte | 42 +- .../publications/HighlightLayer.svelte | 42 +- .../publications/Publication.svelte | 399 +++++++++++++++--- 3 files changed, 403 insertions(+), 80 deletions(-) diff --git a/src/lib/components/publications/CommentLayer.svelte b/src/lib/components/publications/CommentLayer.svelte index 0ddda39..849114e 100644 --- a/src/lib/components/publications/CommentLayer.svelte +++ b/src/lib/components/publications/CommentLayer.svelte @@ -132,6 +132,32 @@ 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; + resolve(); + } + }; + const messageHandler = (event: MessageEvent) => { try { const message = JSON.parse(event.data); @@ -163,11 +189,9 @@ 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(); + // Close subscription and release connection + releaseConnection(); + safeResolve(); } else if (message[0] === "NOTICE") { console.warn(`[CommentLayer] NOTICE from ${relayUrl}:`, message[1]); } @@ -189,12 +213,8 @@ // 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) { diff --git a/src/lib/components/publications/HighlightLayer.svelte b/src/lib/components/publications/HighlightLayer.svelte index 48b00f6..b9d707e 100644 --- a/src/lib/components/publications/HighlightLayer.svelte +++ b/src/lib/components/publications/HighlightLayer.svelte @@ -190,6 +190,32 @@ 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); @@ -231,11 +257,9 @@ `[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`, ); - // Close subscription - ws.send(JSON.stringify(["CLOSE", subscriptionId])); - ws.removeEventListener("message", messageHandler); - WebSocketPool.instance.release(ws); - resolve(); + // Close subscription and release connection + releaseConnection(); + safeResolve(); } else if (message[0] === "NOTICE") { console.warn( `[HighlightLayer] NOTICE from ${relayUrl}:`, @@ -266,12 +290,8 @@ // 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) { diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index b07bc82..4907384 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -351,14 +351,31 @@ * Loads sections before a given address in the TOC order. */ async function loadSectionsBefore(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) { - if (!publicationTree || !toc || isLoading) { + console.log("[Publication] loadSectionsBefore called:", { + referenceAddress, + count, + hasPublicationTree: !!publicationTree, + hasToc: !!toc, + isLoading, + isLoadingUpward + }); + + if (!publicationTree || !toc) { + console.log("[Publication] loadSectionsBefore: Early return (missing dependencies)"); return; } const allAddresses = getAllSectionAddresses(); const referenceIndex = allAddresses.indexOf(referenceAddress); + console.log("[Publication] loadSectionsBefore: Reference index:", { + referenceIndex, + totalAddresses: allAddresses.length, + referenceAddress + }); + if (referenceIndex === -1 || referenceIndex === 0) { + console.log("[Publication] loadSectionsBefore: Early return (not found or at beginning)"); return; // Not found or already at beginning } @@ -371,7 +388,14 @@ !loadedAddresses.has(addr) && !existingAddresses.has(addr) ); + console.log("[Publication] loadSectionsBefore: Addresses to load:", { + total: addressesToLoad.length, + filtered: addressesToLoadFiltered.length, + addresses: addressesToLoadFiltered + }); + if (addressesToLoadFiltered.length === 0) { + console.log("[Publication] loadSectionsBefore: Early return (no addresses to load)"); return; } @@ -393,8 +417,19 @@ } } + console.log("[Publication] loadSectionsBefore: Loaded events:", { + total: newEvents.length, + valid: newEvents.filter(e => e !== null).length + }); + if (newEvents.length > 0) { + const beforeCount = leaves.length; leaves = insertEventsInOrder(newEvents, allAddresses); + console.log("[Publication] loadSectionsBefore: Updated leaves:", { + before: beforeCount, + after: leaves.length, + added: leaves.length - beforeCount + }); } isLoading = false; @@ -456,6 +491,7 @@ /** * 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) @@ -486,15 +522,52 @@ return; } - const startIndex = Math.max(0, targetIndex - windowSize); - const endIndex = Math.min(allAddresses.length - 1, targetIndex + windowSize); - const windowAddresses = allAddresses.slice(startIndex, endIndex + 1); - - // Filter out already loaded + // Find the last loaded section index const existingAddresses = new Set(leaves.map(leaf => leaf?.tagAddress()).filter(Boolean)); - const addressesToLoad = windowAddresses.filter(addr => - !loadedAddresses.has(addr) && !existingAddresses.has(addr) - ); + 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; + console.log(`[Publication] Gap detected: sections ${gapStartIndex}-${gapEndIndex} need to be loaded`); + } + + // 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); + } + } + + console.log(`[Publication] Jump-to-section: loading ${addressesToLoad.length} sections (gap: ${gapStartIndex >= 0 ? `${gapStartIndex}-${gapEndIndex}` : 'none'}, window: ${jumpStartIndex}-${jumpEndIndex})`); // Load events const windowEvents: Array<{ address: string; event: NDKEvent | null; index: number }> = []; @@ -523,6 +596,8 @@ if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'start' }); } + // Update observer after DOM updates + updateFirstSectionObserver(); }, 100); console.log(`[Publication] Jump-to-section complete. Loaded ${windowEvents.length} sections around ${targetAddress}`); @@ -906,20 +981,109 @@ } }); - // AI-NOTE: Simple IntersectionObserver-based infinite scroll + // AI-NOTE: IntersectionObserver-based infinite scroll with debouncing // Observes sentinels and first section element for upward scrolling + // Simplified to prevent reactive loops - observer updates happen explicitly after loading + let lastUpwardLoadTime = 0; + const UPWARD_LOAD_DEBOUNCE_MS = 3000; // Prevent loading more than once per 3 seconds + let isUpdatingObserver = false; // Prevent concurrent observer updates + let isLoadingUpward = false; // Prevent multiple simultaneous upward loads + + // Store observer and state in module scope + let scrollObserver: IntersectionObserver | null = null; + let observedFirstSectionAddress: string | null = null; + let observerUpdateTimeout: number | null = null; + let ignoreNextFirstSectionIntersection = false; // Ignore first intersection when we just started observing + + /** + * 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) { + console.log("[Publication] updateFirstSectionObserver skipped:", { + hasObserver: !!scrollObserver, + isLoading, + isUpdatingObserver, + isLoadingUpward + }); + return; + } + + // Clear any pending update + if (observerUpdateTimeout !== null) { + clearTimeout(observerUpdateTimeout); + observerUpdateTimeout = null; + } + + // Debounce updates + observerUpdateTimeout = window.setTimeout(() => { + if (!scrollObserver || isLoading || isUpdatingObserver || isLoadingUpward) { + console.log("[Publication] updateFirstSectionObserver timeout skipped:", { + hasObserver: !!scrollObserver, + isLoading, + isUpdatingObserver, + isLoadingUpward + }); + return; + } + + const firstSection = leaves.filter(l => l !== null)[0]; + if (!firstSection) { + console.log("[Publication] updateFirstSectionObserver: No first section found"); + return; + } + + const firstAddress = firstSection.tagAddress(); + + // Don't observe root address or if already observing this section + if (firstAddress === rootAddress || firstAddress === observedFirstSectionAddress) { + console.log("[Publication] updateFirstSectionObserver: Skipping (root or already observed):", { + firstAddress, + rootAddress, + observedFirstSectionAddress + }); + return; + } + + console.log("[Publication] updateFirstSectionObserver: Observing new first section:", firstAddress); + isUpdatingObserver = true; + + // Unobserve previous first section if it changed + if (observedFirstSectionAddress) { + const prevElement = document.getElementById(observedFirstSectionAddress); + if (prevElement && scrollObserver) { + scrollObserver.unobserve(prevElement); + console.log("[Publication] Unobserved previous first section:", observedFirstSectionAddress); + } + } + + // Observe new first section + const firstElement = document.getElementById(firstAddress); + if (firstElement && scrollObserver) { + scrollObserver.observe(firstElement); + observedFirstSectionAddress = firstAddress; + // Ignore the first intersection event (it will fire immediately if element is already in viewport) + ignoreNextFirstSectionIntersection = true; + console.log("[Publication] Now observing first section:", firstAddress, "(will ignore first intersection)"); + } else { + console.warn("[Publication] First section element not found in DOM:", firstAddress); + } + + isUpdatingObserver = false; + observerUpdateTimeout = null; + }, 800); // Increased delay to allow DOM to fully render and stabilize + } + $effect(() => { if (!hasInitialized || !publicationTree || !toc) { return; } - let observer: IntersectionObserver | null = null; let setupTimeout: number | null = null; - let updateInterval: number | null = null; - let observedFirstSection: string | null = null; const setupObserver = () => { - if (observer) { + if (scrollObserver) { return; } @@ -930,12 +1094,14 @@ return; } - observer = new IntersectionObserver( + scrollObserver = new IntersectionObserver( (entries) => { - if (isLoading || isDone) { + if (isLoading || isDone || isUpdatingObserver || isLoadingUpward) { return; } + const now = Date.now(); + for (const entry of entries) { if (!entry.isIntersecting) { continue; @@ -951,15 +1117,159 @@ loadMore(AUTO_LOAD_BATCH_SIZE); } } else if (targetId === "publication-top-sentinel") { + // Double-check isLoadingUpward here as well (defensive check) + if (isLoadingUpward) { + console.log("[Publication] Top sentinel intersection ignored (already loading upward)"); + return; + } + + // Debounce upward loads + if ((now - lastUpwardLoadTime) < UPWARD_LOAD_DEBOUNCE_MS) { + console.log("[Publication] Upward load debounced, time since last:", now - lastUpwardLoadTime); + return; + } const firstSection = leaves.filter(l => l !== null)[0]; if (firstSection && firstSection.tagAddress() !== rootAddress) { - loadSectionsBefore(firstSection.tagAddress(), AUTO_LOAD_BATCH_SIZE); + const firstAddress = firstSection.tagAddress(); + console.log("[Publication] Top sentinel intersecting, loading sections before:", firstAddress); + + // Prevent multiple simultaneous upward loads + isLoadingUpward = true; + lastUpwardLoadTime = now; + + // Temporarily unobserve first section and top sentinel to prevent loop + if (observedFirstSectionAddress && scrollObserver) { + const firstElement = document.getElementById(observedFirstSectionAddress); + if (firstElement) { + scrollObserver.unobserve(firstElement); + console.log("[Publication] Unobserved first section for upward load:", observedFirstSectionAddress); + } + } + if (scrollObserver && entry.target) { + scrollObserver.unobserve(entry.target); + console.log("[Publication] Unobserved top sentinel to prevent loop"); + } + + Promise.resolve(loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE)) + .then(() => { + console.log("[Publication] Upward load complete, waiting for DOM stabilization"); + // Wait longer for DOM to fully stabilize before updating observer + setTimeout(() => { + // Only update observer if we're not still loading upward + if (!isLoadingUpward) { + // Re-observe top sentinel + if (scrollObserver && topSentinelRef) { + scrollObserver.observe(topSentinelRef); + console.log("[Publication] Re-observed top sentinel"); + } + updateFirstSectionObserver(); + } else { + console.log("[Publication] Skipping observer update (still loading upward)"); + // Still re-observe top sentinel even if we skip the update + if (scrollObserver && topSentinelRef) { + scrollObserver.observe(topSentinelRef); + } + } + }, 500); + }) + .catch((error) => { + console.error("[Publication] Error loading sections before:", error); + // Re-observe top sentinel and first section even on error + setTimeout(() => { + // Only update observer if we're not still loading upward + if (!isLoadingUpward) { + if (scrollObserver && topSentinelRef) { + scrollObserver.observe(topSentinelRef); + } + updateFirstSectionObserver(); + } else { + console.log("[Publication] Skipping observer update on error (still loading upward)"); + // Still re-observe top sentinel even if we skip the update + if (scrollObserver && topSentinelRef) { + scrollObserver.observe(topSentinelRef); + } + } + }, 500); + }) + .finally(() => { + isLoadingUpward = false; + console.log("[Publication] isLoadingUpward reset to false"); + }); + } else { + console.log("[Publication] Top sentinel intersecting but no valid first section or at root"); } } else { // This is the first section element + + // Double-check isLoadingUpward here as well (defensive check) + if (isLoadingUpward) { + console.log("[Publication] First section intersection ignored (already loading upward)"); + return; + } + + // Ignore first intersection event when we just started observing (prevents immediate loop) + if (ignoreNextFirstSectionIntersection) { + console.log("[Publication] Ignoring first intersection event (just started observing)"); + ignoreNextFirstSectionIntersection = false; + return; + } + + // Debounce upward loads + if ((now - lastUpwardLoadTime) < UPWARD_LOAD_DEBOUNCE_MS) { + console.log("[Publication] First section load debounced, time since last:", now - lastUpwardLoadTime); + return; + } const firstSection = leaves.filter(l => l !== null)[0]; if (firstSection && targetId === firstSection.tagAddress() && targetId !== rootAddress) { - loadSectionsBefore(targetId, AUTO_LOAD_BATCH_SIZE); + console.log("[Publication] First section element intersecting, loading sections before:", targetId); + + // Prevent multiple simultaneous upward loads + isLoadingUpward = true; + lastUpwardLoadTime = now; + + // Temporarily unobserve this element to prevent loop + if (scrollObserver) { + scrollObserver.unobserve(entry.target); + console.log("[Publication] Unobserved first section element for upward load:", targetId); + } + + Promise.resolve(loadSectionsBefore(targetId, AUTO_LOAD_BATCH_SIZE)) + .then(() => { + console.log("[Publication] Upward load complete (first section), waiting for DOM stabilization"); + // Wait longer for DOM to fully stabilize before updating observer + setTimeout(() => { + // Only update observer if we're not still loading upward + if (!isLoadingUpward) { + updateFirstSectionObserver(); + } else { + console.log("[Publication] Skipping updateFirstSectionObserver (still loading upward)"); + } + }, 500); + }) + .catch((error) => { + console.error("[Publication] Error loading sections before:", error); + // Re-observe first section even on error + setTimeout(() => { + // Only update observer if we're not still loading upward + if (!isLoadingUpward) { + updateFirstSectionObserver(); + } else { + console.log("[Publication] Skipping updateFirstSectionObserver on error (still loading upward)"); + } + }, 500); + }) + .finally(() => { + isLoadingUpward = false; + console.log("[Publication] isLoadingUpward (first section) reset to false"); + }); + } else { + console.log("[Publication] First section element intersecting but conditions not met:", { + hasFirstSection: !!firstSection, + targetId, + firstAddress: firstSection?.tagAddress(), + isRoot: targetId === rootAddress, + matches: firstSection && targetId === firstSection.tagAddress() + }); } } break; @@ -972,60 +1282,33 @@ ); if (bottomSentinel) { - observer.observe(bottomSentinel); + scrollObserver.observe(bottomSentinel); } if (topSentinel) { - observer.observe(topSentinel); - } - }; - - // Update observer when first section changes (e.g., after jump) - const updateFirstSectionObserver = () => { - if (!observer || leaves.length === 0) { - return; - } - - const firstSection = leaves.filter(l => l !== null)[0]; - if (!firstSection) { - return; - } - - const firstAddress = firstSection.tagAddress(); - if (firstAddress === observedFirstSection || firstAddress === rootAddress) { - return; - } - - // Unobserve previous first section if it changed - if (observedFirstSection) { - const prevElement = document.getElementById(observedFirstSection); - if (prevElement) { - observer.unobserve(prevElement); - } - } - - // Observe new first section - const firstElement = document.getElementById(firstAddress); - if (firstElement) { - observer.observe(firstElement); - observedFirstSection = firstAddress; + scrollObserver.observe(topSentinel); } + + // Initial observer update after setup + setTimeout(() => { + updateFirstSectionObserver(); + }, 200); }; setupTimeout = window.setTimeout(() => { setupObserver(); - // Start updating when first section changes - updateInterval = window.setInterval(updateFirstSectionObserver, 500); }, 100); return () => { if (setupTimeout !== null) { clearTimeout(setupTimeout); } - if (updateInterval !== null) { - clearInterval(updateInterval); + if (observerUpdateTimeout !== null) { + clearTimeout(observerUpdateTimeout); } - if (observer) { - observer.disconnect(); + if (scrollObserver) { + scrollObserver.disconnect(); + scrollObserver = null; + observedFirstSectionAddress = null; } }; }); From bd07fe2fe8859a25759a635d8f28b32af1515dc7 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 22:52:55 +0100 Subject: [PATCH 20/31] refactor --- .../publications/Publication.svelte | 280 ++++++------------ 1 file changed, 93 insertions(+), 187 deletions(-) diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 4907384..7012368 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -981,19 +981,91 @@ } }); + // #region Infinite Scroll Observer State // AI-NOTE: IntersectionObserver-based infinite scroll with debouncing // Observes sentinels and first section element for upward scrolling - // Simplified to prevent reactive loops - observer updates happen explicitly after loading - let lastUpwardLoadTime = 0; - const UPWARD_LOAD_DEBOUNCE_MS = 3000; // Prevent loading more than once per 3 seconds - let isUpdatingObserver = false; // Prevent concurrent observer updates - let isLoadingUpward = false; // Prevent multiple simultaneous upward loads - // Store observer and state in module scope + 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 = false; let scrollObserver: IntersectionObserver | null = null; let observedFirstSectionAddress: string | null = null; let observerUpdateTimeout: number | null = null; - let ignoreNextFirstSectionIntersection = false; // Ignore first intersection when we just started observing + let ignoreNextFirstSectionIntersection = false; + + /** + * Handles upward loading with proper debouncing and observer management. + */ + async function handleUpwardLoad(referenceAddress: string, source: "top-sentinel" | "first-section") { + if (isLoadingUpward) { + console.log(`[Publication] Upward load from ${source} ignored (already loading)`); + return; + } + + const now = Date.now(); + if ((now - lastUpwardLoadTime) < UPWARD_LOAD_DEBOUNCE_MS) { + console.log(`[Publication] Upward load from ${source} debounced, time since last:`, now - lastUpwardLoadTime); + return; + } + + const firstSection = leaves.filter(l => l !== null)[0]; + if (!firstSection || firstSection.tagAddress() === rootAddress) { + console.log(`[Publication] Upward load from ${source} skipped (no valid first section or at root)`); + return; + } + + const firstAddress = firstSection.tagAddress(); + if (referenceAddress !== firstAddress && source === "first-section") { + console.log(`[Publication] Upward load from first-section skipped (address mismatch)`); + return; + } + + console.log(`[Publication] Upward load from ${source}, loading sections before:`, firstAddress); + 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); + console.log(`[Publication] Upward load from ${source} complete`); + + // 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. @@ -1001,60 +1073,37 @@ */ function updateFirstSectionObserver() { if (!scrollObserver || isLoading || isUpdatingObserver || isLoadingUpward) { - console.log("[Publication] updateFirstSectionObserver skipped:", { - hasObserver: !!scrollObserver, - isLoading, - isUpdatingObserver, - isLoadingUpward - }); return; } - // Clear any pending update if (observerUpdateTimeout !== null) { clearTimeout(observerUpdateTimeout); observerUpdateTimeout = null; } - // Debounce updates observerUpdateTimeout = window.setTimeout(() => { if (!scrollObserver || isLoading || isUpdatingObserver || isLoadingUpward) { - console.log("[Publication] updateFirstSectionObserver timeout skipped:", { - hasObserver: !!scrollObserver, - isLoading, - isUpdatingObserver, - isLoadingUpward - }); return; } const firstSection = leaves.filter(l => l !== null)[0]; if (!firstSection) { - console.log("[Publication] updateFirstSectionObserver: No first section found"); return; } const firstAddress = firstSection.tagAddress(); - // Don't observe root address or if already observing this section if (firstAddress === rootAddress || firstAddress === observedFirstSectionAddress) { - console.log("[Publication] updateFirstSectionObserver: Skipping (root or already observed):", { - firstAddress, - rootAddress, - observedFirstSectionAddress - }); return; } - console.log("[Publication] updateFirstSectionObserver: Observing new first section:", firstAddress); isUpdatingObserver = true; - // Unobserve previous first section if it changed - if (observedFirstSectionAddress) { + // Unobserve previous first section + if (observedFirstSectionAddress && scrollObserver) { const prevElement = document.getElementById(observedFirstSectionAddress); - if (prevElement && scrollObserver) { + if (prevElement) { scrollObserver.unobserve(prevElement); - console.log("[Publication] Unobserved previous first section:", observedFirstSectionAddress); } } @@ -1063,17 +1112,14 @@ if (firstElement && scrollObserver) { scrollObserver.observe(firstElement); observedFirstSectionAddress = firstAddress; - // Ignore the first intersection event (it will fire immediately if element is already in viewport) ignoreNextFirstSectionIntersection = true; - console.log("[Publication] Now observing first section:", firstAddress, "(will ignore first intersection)"); - } else { - console.warn("[Publication] First section element not found in DOM:", firstAddress); } isUpdatingObserver = false; observerUpdateTimeout = null; - }, 800); // Increased delay to allow DOM to fully render and stabilize + }, OBSERVER_UPDATE_DELAY_MS); } + // #endregion $effect(() => { if (!hasInitialized || !publicationTree || !toc) { @@ -1100,8 +1146,6 @@ return; } - const now = Date.now(); - for (const entry of entries) { if (!entry.isIntersecting) { continue; @@ -1110,169 +1154,31 @@ 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") { - // Double-check isLoadingUpward here as well (defensive check) - if (isLoadingUpward) { - console.log("[Publication] Top sentinel intersection ignored (already loading upward)"); - return; - } - - // Debounce upward loads - if ((now - lastUpwardLoadTime) < UPWARD_LOAD_DEBOUNCE_MS) { - console.log("[Publication] Upward load debounced, time since last:", now - lastUpwardLoadTime); - return; - } - const firstSection = leaves.filter(l => l !== null)[0]; - if (firstSection && firstSection.tagAddress() !== rootAddress) { - const firstAddress = firstSection.tagAddress(); - console.log("[Publication] Top sentinel intersecting, loading sections before:", firstAddress); - - // Prevent multiple simultaneous upward loads - isLoadingUpward = true; - lastUpwardLoadTime = now; - - // Temporarily unobserve first section and top sentinel to prevent loop - if (observedFirstSectionAddress && scrollObserver) { - const firstElement = document.getElementById(observedFirstSectionAddress); - if (firstElement) { - scrollObserver.unobserve(firstElement); - console.log("[Publication] Unobserved first section for upward load:", observedFirstSectionAddress); - } - } - if (scrollObserver && entry.target) { - scrollObserver.unobserve(entry.target); - console.log("[Publication] Unobserved top sentinel to prevent loop"); - } - - Promise.resolve(loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE)) - .then(() => { - console.log("[Publication] Upward load complete, waiting for DOM stabilization"); - // Wait longer for DOM to fully stabilize before updating observer - setTimeout(() => { - // Only update observer if we're not still loading upward - if (!isLoadingUpward) { - // Re-observe top sentinel - if (scrollObserver && topSentinelRef) { - scrollObserver.observe(topSentinelRef); - console.log("[Publication] Re-observed top sentinel"); - } - updateFirstSectionObserver(); - } else { - console.log("[Publication] Skipping observer update (still loading upward)"); - // Still re-observe top sentinel even if we skip the update - if (scrollObserver && topSentinelRef) { - scrollObserver.observe(topSentinelRef); - } - } - }, 500); - }) - .catch((error) => { - console.error("[Publication] Error loading sections before:", error); - // Re-observe top sentinel and first section even on error - setTimeout(() => { - // Only update observer if we're not still loading upward - if (!isLoadingUpward) { - if (scrollObserver && topSentinelRef) { - scrollObserver.observe(topSentinelRef); - } - updateFirstSectionObserver(); - } else { - console.log("[Publication] Skipping observer update on error (still loading upward)"); - // Still re-observe top sentinel even if we skip the update - if (scrollObserver && topSentinelRef) { - scrollObserver.observe(topSentinelRef); - } - } - }, 500); - }) - .finally(() => { - isLoadingUpward = false; - console.log("[Publication] isLoadingUpward reset to false"); - }); - } else { - console.log("[Publication] Top sentinel intersecting but no valid first section or at root"); - } + // Upward loading from top sentinel + handleUpwardLoad("", "top-sentinel"); + break; } else { - // This is the first section element - - // Double-check isLoadingUpward here as well (defensive check) - if (isLoadingUpward) { - console.log("[Publication] First section intersection ignored (already loading upward)"); - return; - } - - // Ignore first intersection event when we just started observing (prevents immediate loop) + // First section element intersection if (ignoreNextFirstSectionIntersection) { - console.log("[Publication] Ignoring first intersection event (just started observing)"); ignoreNextFirstSectionIntersection = false; - return; + break; } - // Debounce upward loads - if ((now - lastUpwardLoadTime) < UPWARD_LOAD_DEBOUNCE_MS) { - console.log("[Publication] First section load debounced, time since last:", now - lastUpwardLoadTime); - return; - } const firstSection = leaves.filter(l => l !== null)[0]; if (firstSection && targetId === firstSection.tagAddress() && targetId !== rootAddress) { - console.log("[Publication] First section element intersecting, loading sections before:", targetId); - - // Prevent multiple simultaneous upward loads - isLoadingUpward = true; - lastUpwardLoadTime = now; - - // Temporarily unobserve this element to prevent loop - if (scrollObserver) { - scrollObserver.unobserve(entry.target); - console.log("[Publication] Unobserved first section element for upward load:", targetId); - } - - Promise.resolve(loadSectionsBefore(targetId, AUTO_LOAD_BATCH_SIZE)) - .then(() => { - console.log("[Publication] Upward load complete (first section), waiting for DOM stabilization"); - // Wait longer for DOM to fully stabilize before updating observer - setTimeout(() => { - // Only update observer if we're not still loading upward - if (!isLoadingUpward) { - updateFirstSectionObserver(); - } else { - console.log("[Publication] Skipping updateFirstSectionObserver (still loading upward)"); - } - }, 500); - }) - .catch((error) => { - console.error("[Publication] Error loading sections before:", error); - // Re-observe first section even on error - setTimeout(() => { - // Only update observer if we're not still loading upward - if (!isLoadingUpward) { - updateFirstSectionObserver(); - } else { - console.log("[Publication] Skipping updateFirstSectionObserver on error (still loading upward)"); - } - }, 500); - }) - .finally(() => { - isLoadingUpward = false; - console.log("[Publication] isLoadingUpward (first section) reset to false"); - }); - } else { - console.log("[Publication] First section element intersecting but conditions not met:", { - hasFirstSection: !!firstSection, - targetId, - firstAddress: firstSection?.tagAddress(), - isRoot: targetId === rootAddress, - matches: firstSection && targetId === firstSection.tagAddress() - }); + handleUpwardLoad(targetId, "first-section"); } + break; } - break; } }, { From ec52af21267c779bdf3d6de3b635bf75d888c7bf Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 22:55:22 +0100 Subject: [PATCH 21/31] fix hashtag contrast --- src/lib/components/cards/BlogHeader.svelte | 2 +- src/lib/components/util/Details.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/cards/BlogHeader.svelte b/src/lib/components/cards/BlogHeader.svelte index a7f23e6..f3b35e9 100644 --- a/src/lib/components/cards/BlogHeader.svelte +++ b/src/lib/components/cards/BlogHeader.svelte @@ -144,7 +144,7 @@ {#if hashtags}
{#each hashtags as tag} - #{tag} + #{tag} {/each}
{/if} diff --git a/src/lib/components/util/Details.svelte b/src/lib/components/util/Details.svelte index c8db27e..f1e8745 100644 --- a/src/lib/components/util/Details.svelte +++ b/src/lib/components/util/Details.svelte @@ -142,7 +142,7 @@ {#each hashtags as tag} {/each} From 691f24588ae50840d4a3ad322ea1f8b0728d8b9d Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 22:57:40 +0100 Subject: [PATCH 22/31] fix highlightlayer and commentlayer releases --- src/lib/data_structures/websocket_pool.ts | 36 ++++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/lib/data_structures/websocket_pool.ts b/src/lib/data_structures/websocket_pool.ts index e1c1c02..0bbff5f 100644 --- a/src/lib/data_structures/websocket_pool.ts +++ b/src/lib/data_structures/websocket_pool.ts @@ -178,19 +178,39 @@ export class WebSocketPool { * URL, the connection is passed to the requestor in the queue. Otherwise, the connection is * marked as available. * - * @param handle - The WebSocketHandle to release. + * This method is defensive: if the connection is no longer in the pool (e.g., it was already + * closed and removed), it returns silently rather than throwing an error. + * + * @param ws - The WebSocket connection to release. */ public release(ws: WebSocket): void { - const normalizedUrl = this.#normalizeUrl(ws.url); - const handle = this.#pool.get(normalizedUrl); - if (!handle) { - throw new Error( - "[WebSocketPool] Attempted to release an unmanaged WebSocket connection.", + // AI-NOTE: Defensive check - if WebSocket is closed or doesn't have a URL, skip release + if (!ws || !ws.url) { + console.warn( + "[WebSocketPool] Attempted to release an invalid WebSocket connection (no URL).", ); + return; } - if (--handle.refCount === 0) { - this.#startIdleTimer(handle); + try { + const normalizedUrl = this.#normalizeUrl(ws.url); + const handle = this.#pool.get(normalizedUrl); + if (!handle) { + // AI-NOTE: Connection may have been removed due to closure or error - this is acceptable + console.debug( + `[WebSocketPool] Connection to ${normalizedUrl} is no longer in pool (likely already closed).`, + ); + return; + } + + if (--handle.refCount === 0) { + this.#startIdleTimer(handle); + } + } catch (error) { + // AI-NOTE: If URL normalization fails or other errors occur, log but don't throw + console.warn( + `[WebSocketPool] Error releasing connection: ${error}. This may occur if the connection was already closed.`, + ); } } From 130b69df3061720b614525139e46405955a39f50 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 22:59:45 +0100 Subject: [PATCH 23/31] make top-sentinel less prominent, so that there is less empty space at the top --- src/lib/components/publications/Publication.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 7012368..88a4d08 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -1488,11 +1488,11 @@
{#if isLoading && leaves.length > 0} -
+
Loading previous sections...
From 9069784e78feb55265264a024a0ea9534439e676 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 23:02:38 +0100 Subject: [PATCH 24/31] close comment-loading message faster --- .../publications/CommentLayer.svelte | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/lib/components/publications/CommentLayer.svelte b/src/lib/components/publications/CommentLayer.svelte index 849114e..f765721 100644 --- a/src/lib/components/publications/CommentLayer.svelte +++ b/src/lib/components/publications/CommentLayer.svelte @@ -124,7 +124,17 @@ */ 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) { + console.log(`[CommentLayer] All ${totalRelays} relays have responded, clearing loading state`); + loading = false; + } + }; const fetchPromises = uniqueRelays.map(async (relayUrl) => { try { @@ -154,6 +164,7 @@ const safeResolve = () => { if (!resolved) { resolved = true; + checkAllResponses(); resolve(); } }; @@ -186,8 +197,7 @@ 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})`); + console.log(`[CommentLayer] EOSE from ${relayUrl} (${responseCount + 1}/${totalRelays})`); // Close subscription and release connection releaseConnection(); @@ -219,11 +229,16 @@ }); } 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); + await Promise.allSettled(fetchPromises); + + // Ensure loading is cleared even if checkAllResponses didn't fire + loading = false; console.log(`[CommentLayer] Fetched ${comments.length} comments`); @@ -235,8 +250,6 @@ }))); } - loading = false; - } catch (err) { console.error(`[CommentLayer] Error fetching comments:`, err); loading = false; From 4db477f0b9352d1f3cb9d8e3b7e207adc8907792 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 23:05:27 +0100 Subject: [PATCH 25/31] immediately display initial fetch --- src/lib/components/publications/Publication.svelte | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 88a4d08..f17f488 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -171,7 +171,8 @@ }, LOAD_TIMEOUT); }); - // Load events sequentially to maintain order, but build batches for TOC updates + // 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++) { try { const iterResult = await Promise.race([ @@ -194,7 +195,10 @@ 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); + console.log(`[Publication] Added event ${i + 1}/${count} immediately. Total: ${leaves.length}`); } else { newEvents.push(null); } @@ -224,13 +228,11 @@ } } - // Add all new events at once for better performance and to trigger TOC updates in parallel + // Log final summary (events already added incrementally above) const validEvents = newEvents.filter(e => e !== null); if (validEvents.length > 0) { - const previousLeavesCount = leaves.length; - leaves = [...leaves, ...newEvents]; console.log( - `[Publication] Added ${validEvents.length} events. Previous: ${previousLeavesCount}, Total: ${leaves.length}`, + `[Publication] Load complete. Added ${validEvents.length} events. Total: ${leaves.length}`, ); // Log sentinel position after adding content From 056be3d6cf68a087c4bfbab3ab7851f7a72e3307 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 23:08:31 +0100 Subject: [PATCH 26/31] fix toc button on blog view --- src/lib/components/util/ArticleNav.svelte | 25 ++++++++++++++--------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/lib/components/util/ArticleNav.svelte b/src/lib/components/util/ArticleNav.svelte index bb19cc6..d6eff39 100644 --- a/src/lib/components/util/ArticleNav.svelte +++ b/src/lib/components/util/ArticleNav.svelte @@ -181,16 +181,21 @@
{#if isIndexEvent} {#if publicationType === "blog"} - - + + {#if !($publicationColumnVisibility.blog && !$publicationColumnVisibility.inner)} + + {/if} {:else if !$publicationColumnVisibility.discussion}