|
|
|
@ -136,20 +136,10 @@ |
|
|
|
let publicationDeleted = $state(false); |
|
|
|
let publicationDeleted = $state(false); |
|
|
|
let sidebarTop = $state(162); // Default to 162px (100px navbar + 62px ArticleNav) |
|
|
|
let sidebarTop = $state(162); // Default to 162px (100px navbar + 62px ArticleNav) |
|
|
|
|
|
|
|
|
|
|
|
// AI-NOTE: Cooldown to prevent rapid re-triggering of loadMore |
|
|
|
// AI-NOTE: Batch loading configuration |
|
|
|
let lastLoadTime = $state<number>(0); |
|
|
|
|
|
|
|
let lastLoadBeforeTime = $state<number>(0); |
|
|
|
|
|
|
|
let lastLoadBeforeAddress = $state<string | null>(null); |
|
|
|
|
|
|
|
let justLoadedBefore = $state<boolean>(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 INITIAL_LOAD_COUNT = 30; |
|
|
|
const AUTO_LOAD_BATCH_SIZE = 25; |
|
|
|
const AUTO_LOAD_BATCH_SIZE = 25; |
|
|
|
|
|
|
|
const JUMP_WINDOW_SIZE = 5; |
|
|
|
// 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. |
|
|
|
* Loads more events from the publication tree. |
|
|
|
@ -162,30 +152,11 @@ |
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (isLoading) { |
|
|
|
if (isLoading || isDone) { |
|
|
|
console.debug("[Publication] Already loading, skipping"); |
|
|
|
|
|
|
|
return; |
|
|
|
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; |
|
|
|
isLoading = true; |
|
|
|
lastLoadTime = now; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
try { |
|
|
|
const newEvents: Array<NDKEvent | null> = []; |
|
|
|
const newEvents: Array<NDKEvent | null> = []; |
|
|
|
@ -219,13 +190,13 @@ |
|
|
|
if (value) { |
|
|
|
if (value) { |
|
|
|
consecutiveNulls = 0; // Reset null counter |
|
|
|
consecutiveNulls = 0; // Reset null counter |
|
|
|
const address = value.tagAddress(); |
|
|
|
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); |
|
|
|
loadedAddresses.add(address); |
|
|
|
newEvents.push(value); |
|
|
|
newEvents.push(value); |
|
|
|
console.debug(`[Publication] Queued event: ${address} (${value.id})`); |
|
|
|
|
|
|
|
} else { |
|
|
|
} else { |
|
|
|
console.warn(`[Publication] Duplicate event detected: ${address}`); |
|
|
|
newEvents.push(null); |
|
|
|
newEvents.push(null); // Keep index consistent |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
} else { |
|
|
|
} else { |
|
|
|
consecutiveNulls++; |
|
|
|
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. |
|
|
|
* 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) { |
|
|
|
async function loadSectionsBefore(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) { |
|
|
|
if (!publicationTree || !toc || isLoading) { |
|
|
|
if (!publicationTree || !toc || isLoading) { |
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Cooldown check to prevent rapid re-triggering |
|
|
|
const allAddresses = getAllSectionAddresses(); |
|
|
|
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); |
|
|
|
const referenceIndex = allAddresses.indexOf(referenceAddress); |
|
|
|
if (referenceIndex === -1) { |
|
|
|
|
|
|
|
console.warn(`[Publication] Reference address ${referenceAddress} not found in TOC`); |
|
|
|
if (referenceIndex === -1 || referenceIndex === 0) { |
|
|
|
return; |
|
|
|
return; // Not found or already at beginning |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 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 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 |
|
|
|
// Filter out already loaded |
|
|
|
const addressesToLoadFiltered = addressesToLoad.filter(addr => !loadedAddresses.has(addr)); |
|
|
|
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) { |
|
|
|
if (addressesToLoadFiltered.length === 0) { |
|
|
|
console.debug(`[Publication] All sections before ${referenceAddress} are already loaded`); |
|
|
|
|
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
console.log(`[Publication] Loading ${addressesToLoadFiltered.length} sections before ${referenceAddress}`); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
isLoading = true; |
|
|
|
isLoading = true; |
|
|
|
lastLoadBeforeTime = now; |
|
|
|
|
|
|
|
lastLoadBeforeAddress = referenceAddress; |
|
|
|
|
|
|
|
const newEvents: Array<NDKEvent | null> = []; |
|
|
|
const newEvents: Array<NDKEvent | null> = []; |
|
|
|
|
|
|
|
|
|
|
|
for (const address of addressesToLoadFiltered) { |
|
|
|
for (const address of addressesToLoadFiltered) { |
|
|
|
@ -384,23 +338,9 @@ |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Insert at the beginning of leaves array |
|
|
|
|
|
|
|
const validEvents = newEvents.filter(e => e !== null); |
|
|
|
const validEvents = newEvents.filter(e => e !== null); |
|
|
|
if (validEvents.length > 0) { |
|
|
|
if (validEvents.length > 0) { |
|
|
|
leaves = [...newEvents.reverse(), ...leaves]; // Reverse back to maintain order |
|
|
|
leaves = [...newEvents.reverse(), ...leaves]; |
|
|
|
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; |
|
|
|
isLoading = false; |
|
|
|
@ -408,42 +348,36 @@ |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
/** |
|
|
|
* Loads sections after a given address in the TOC order. |
|
|
|
* 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) { |
|
|
|
async function loadSectionsAfter(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) { |
|
|
|
if (!publicationTree || !toc || isLoading) { |
|
|
|
if (!publicationTree || !toc || isLoading) { |
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Get all addresses from TOC in depth-first order |
|
|
|
const allAddresses = getAllSectionAddresses(); |
|
|
|
const allAddresses: string[] = []; |
|
|
|
|
|
|
|
for (const entry of toc) { |
|
|
|
|
|
|
|
allAddresses.push(entry.address); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const referenceIndex = allAddresses.indexOf(referenceAddress); |
|
|
|
const referenceIndex = allAddresses.indexOf(referenceAddress); |
|
|
|
|
|
|
|
|
|
|
|
if (referenceIndex === -1) { |
|
|
|
if (referenceIndex === -1) { |
|
|
|
console.warn(`[Publication] Reference address ${referenceAddress} not found in TOC`); |
|
|
|
|
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Get addresses after the reference |
|
|
|
|
|
|
|
const endIndex = Math.min(allAddresses.length - 1, referenceIndex + count); |
|
|
|
const endIndex = Math.min(allAddresses.length - 1, referenceIndex + count); |
|
|
|
const addressesToLoad = allAddresses.slice(referenceIndex + 1, endIndex + 1); |
|
|
|
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; |
|
|
|
isLoading = true; |
|
|
|
const newEvents: Array<NDKEvent | null> = []; |
|
|
|
const newEvents: Array<NDKEvent | null> = []; |
|
|
|
|
|
|
|
|
|
|
|
for (const address of addressesToLoad) { |
|
|
|
for (const address of addressesToLoadFiltered) { |
|
|
|
// Skip if already loaded |
|
|
|
|
|
|
|
if (loadedAddresses.has(address)) { |
|
|
|
|
|
|
|
continue; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
try { |
|
|
|
const event = await publicationTree.getEvent(address); |
|
|
|
const event = await publicationTree.getEvent(address); |
|
|
|
if (event) { |
|
|
|
if (event) { |
|
|
|
@ -458,23 +392,18 @@ |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Find where to insert in leaves array (after the reference address) |
|
|
|
|
|
|
|
if (newEvents.length > 0) { |
|
|
|
if (newEvents.length > 0) { |
|
|
|
const referenceIndexInLeaves = leaves.findIndex( |
|
|
|
const referenceIndexInLeaves = leaves.findIndex( |
|
|
|
leaf => leaf?.tagAddress() === referenceAddress |
|
|
|
leaf => leaf?.tagAddress() === referenceAddress |
|
|
|
); |
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
if (referenceIndexInLeaves !== -1) { |
|
|
|
if (referenceIndexInLeaves !== -1) { |
|
|
|
// Insert after the reference |
|
|
|
|
|
|
|
const before = leaves.slice(0, referenceIndexInLeaves + 1); |
|
|
|
const before = leaves.slice(0, referenceIndexInLeaves + 1); |
|
|
|
const after = leaves.slice(referenceIndexInLeaves + 1); |
|
|
|
const after = leaves.slice(referenceIndexInLeaves + 1); |
|
|
|
leaves = [...before, ...newEvents, ...after]; |
|
|
|
leaves = [...before, ...newEvents, ...after]; |
|
|
|
} else { |
|
|
|
} else { |
|
|
|
// Reference not in leaves, append to end |
|
|
|
|
|
|
|
leaves = [...leaves, ...newEvents]; |
|
|
|
leaves = [...leaves, ...newEvents]; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
console.log(`[Publication] Loaded ${newEvents.filter(e => e !== null).length} sections after ${referenceAddress}`); |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
isLoading = false; |
|
|
|
isLoading = false; |
|
|
|
@ -505,36 +434,27 @@ |
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
console.log(`[Publication] Jumping to section ${targetAddress} with window size ${windowSize}`); |
|
|
|
const allAddresses = getAllSectionAddresses(); |
|
|
|
|
|
|
|
|
|
|
|
// 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); |
|
|
|
const targetIndex = allAddresses.indexOf(targetAddress); |
|
|
|
|
|
|
|
|
|
|
|
if (targetIndex === -1) { |
|
|
|
if (targetIndex === -1) { |
|
|
|
console.warn(`[Publication] Target address ${targetAddress} not found in TOC`); |
|
|
|
console.warn(`[Publication] Target address ${targetAddress} not found in TOC`); |
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Calculate window bounds |
|
|
|
|
|
|
|
const startIndex = Math.max(0, targetIndex - windowSize); |
|
|
|
const startIndex = Math.max(0, targetIndex - windowSize); |
|
|
|
const endIndex = Math.min(allAddresses.length - 1, targetIndex + windowSize); |
|
|
|
const endIndex = Math.min(allAddresses.length - 1, targetIndex + windowSize); |
|
|
|
const windowAddresses = allAddresses.slice(startIndex, endIndex + 1); |
|
|
|
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 }> = []; |
|
|
|
const windowEvents: Array<{ address: string; event: NDKEvent | null; index: number }> = []; |
|
|
|
for (const address of windowAddresses) { |
|
|
|
for (const address of addressesToLoad) { |
|
|
|
// Skip if already loaded |
|
|
|
|
|
|
|
if (loadedAddresses.has(address)) { |
|
|
|
|
|
|
|
continue; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
try { |
|
|
|
const event = await publicationTree.getEvent(address); |
|
|
|
const event = await publicationTree.getEvent(address); |
|
|
|
if (event) { |
|
|
|
if (event) { |
|
|
|
@ -546,16 +466,15 @@ |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Insert events into leaves array at correct positions |
|
|
|
// Insert events in TOC order |
|
|
|
// We need to maintain order based on TOC order |
|
|
|
|
|
|
|
const newLeaves = [...leaves]; |
|
|
|
const newLeaves = [...leaves]; |
|
|
|
|
|
|
|
|
|
|
|
for (const { address, event, index } of windowEvents) { |
|
|
|
for (const { address, event, index } of windowEvents) { |
|
|
|
// Find where to insert this event in the leaves array |
|
|
|
// Skip if already in leaves |
|
|
|
// We want to insert it at a position that maintains TOC order |
|
|
|
if (newLeaves.some(leaf => leaf?.tagAddress() === address)) { |
|
|
|
let insertIndex = newLeaves.length; |
|
|
|
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++) { |
|
|
|
for (let i = 0; i < newLeaves.length; i++) { |
|
|
|
const leafAddress = newLeaves[i]?.tagAddress(); |
|
|
|
const leafAddress = newLeaves[i]?.tagAddress(); |
|
|
|
if (leafAddress) { |
|
|
|
if (leafAddress) { |
|
|
|
@ -566,13 +485,9 @@ |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Insert the event at the calculated position |
|
|
|
|
|
|
|
newLeaves.splice(insertIndex, 0, event); |
|
|
|
newLeaves.splice(insertIndex, 0, event); |
|
|
|
console.log(`[Publication] Inserted section ${address} at position ${insertIndex}`); |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Update leaves array |
|
|
|
|
|
|
|
leaves = newLeaves; |
|
|
|
leaves = newLeaves; |
|
|
|
|
|
|
|
|
|
|
|
// Set bookmark to target address for future sequential loading |
|
|
|
// Set bookmark to target address for future sequential loading |
|
|
|
@ -968,267 +883,78 @@ |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
// AI-NOTE: Simple IntersectionObserver-based infinite scroll |
|
|
|
// 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(() => { |
|
|
|
$effect(() => { |
|
|
|
// Track reactive dependencies |
|
|
|
if (!hasInitialized || !publicationTree || !toc) { |
|
|
|
const initialized = hasInitialized; |
|
|
|
|
|
|
|
const tree = publicationTree; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Early return if not ready |
|
|
|
|
|
|
|
if (!initialized || !tree) { |
|
|
|
|
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let observer: IntersectionObserver | null = null; |
|
|
|
let observer: IntersectionObserver | null = null; |
|
|
|
let checkInterval: number | null = null; |
|
|
|
let setupTimeout: 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"); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const setupObserver = () => { |
|
|
|
const setupObserver = () => { |
|
|
|
if (isSetup || !hasInitialized || !publicationTree) { |
|
|
|
if (observer) { |
|
|
|
return; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const sentinel = getSentinel(); |
|
|
|
|
|
|
|
const topSentinel = getTopSentinel(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Need at least one sentinel to be ready |
|
|
|
|
|
|
|
if ((!sentinel || !sentinel.isConnected) && (!topSentinel || !topSentinel.isConnected)) { |
|
|
|
|
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Already set up |
|
|
|
const bottomSentinel = document.getElementById("publication-sentinel"); |
|
|
|
if (observer) { |
|
|
|
const topSentinel = document.getElementById("publication-top-sentinel"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!bottomSentinel && !topSentinel) { |
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
console.log("[Publication] Setting up IntersectionObserver for infinite scroll", { |
|
|
|
|
|
|
|
hasBottomSentinel: !!sentinel, |
|
|
|
|
|
|
|
hasTopSentinel: !!topSentinel, |
|
|
|
|
|
|
|
bottomSentinelConnected: sentinel?.isConnected, |
|
|
|
|
|
|
|
topSentinelConnected: topSentinel?.isConnected, |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
observer = new IntersectionObserver( |
|
|
|
observer = new IntersectionObserver( |
|
|
|
(entries) => { |
|
|
|
(entries) => { |
|
|
|
// Check current state |
|
|
|
if (isLoading || isDone) { |
|
|
|
if (isLoading || isDone || !toc) { |
|
|
|
|
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) { |
|
|
|
for (const entry of entries) { |
|
|
|
if (entry.isIntersecting) { |
|
|
|
if (!entry.isIntersecting) { |
|
|
|
const sentinelId = entry.target.id; |
|
|
|
continue; |
|
|
|
|
|
|
|
} |
|
|
|
if (sentinelId === "publication-sentinel") { |
|
|
|
|
|
|
|
// Bottom sentinel - load sections after |
|
|
|
const targetId = entry.target.id; |
|
|
|
const lastLoadedSection = leaves.filter(l => l !== null).slice(-1)[0]; |
|
|
|
|
|
|
|
if (lastLoadedSection) { |
|
|
|
if (targetId === "publication-sentinel") { |
|
|
|
const lastAddress = lastLoadedSection.tagAddress(); |
|
|
|
const lastSection = leaves.filter(l => l !== null).slice(-1)[0]; |
|
|
|
console.log("[Publication] Bottom sentinel intersecting, loading more after", lastAddress); |
|
|
|
if (lastSection) { |
|
|
|
loadSectionsAfter(lastAddress, AUTO_LOAD_BATCH_SIZE); |
|
|
|
loadSectionsAfter(lastSection.tagAddress(), 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 { |
|
|
|
} else { |
|
|
|
// Check if this is the first section element |
|
|
|
loadMore(AUTO_LOAD_BATCH_SIZE); |
|
|
|
const firstLoadedSection = leaves.filter(l => l !== null)[0]; |
|
|
|
} |
|
|
|
if (firstLoadedSection && entry.target.id === firstLoadedSection.tagAddress()) { |
|
|
|
} else if (targetId === "publication-top-sentinel") { |
|
|
|
const firstAddress = firstLoadedSection.tagAddress(); |
|
|
|
const firstSection = leaves.filter(l => l !== null)[0]; |
|
|
|
// Don't load if we're at the root |
|
|
|
if (firstSection && firstSection.tagAddress() !== rootAddress) { |
|
|
|
if (firstAddress !== rootAddress) { |
|
|
|
loadSectionsBefore(firstSection.tagAddress(), AUTO_LOAD_BATCH_SIZE); |
|
|
|
console.log("[Publication] First section intersecting near top, loading more before", firstAddress); |
|
|
|
|
|
|
|
loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
break; |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
break; |
|
|
|
} |
|
|
|
} |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ |
|
|
|
{ |
|
|
|
// Trigger when sentinel is 2000px from viewport (above or below) |
|
|
|
rootMargin: "1000px 0px 1000px 0px", |
|
|
|
// Larger margin for upward scrolling detection |
|
|
|
|
|
|
|
rootMargin: "2000px 0px 2000px 0px", |
|
|
|
|
|
|
|
threshold: 0, |
|
|
|
threshold: 0, |
|
|
|
}, |
|
|
|
}, |
|
|
|
); |
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
// Observe both sentinels |
|
|
|
if (bottomSentinel) { |
|
|
|
if (sentinel) { |
|
|
|
observer.observe(bottomSentinel); |
|
|
|
observer.observe(sentinel); |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
if (topSentinel) { |
|
|
|
if (topSentinel) { |
|
|
|
observer.observe(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 |
|
|
|
setupTimeout = window.setTimeout(setupObserver, 100); |
|
|
|
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); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Cleanup |
|
|
|
|
|
|
|
return () => { |
|
|
|
return () => { |
|
|
|
if (setupInterval !== null) { |
|
|
|
if (setupTimeout !== null) { |
|
|
|
clearInterval(setupInterval); |
|
|
|
clearTimeout(setupTimeout); |
|
|
|
} |
|
|
|
|
|
|
|
if (checkInterval !== null) { |
|
|
|
|
|
|
|
clearInterval(checkInterval); |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
if (observer) { |
|
|
|
if (observer) { |
|
|
|
observer.disconnect(); |
|
|
|
observer.disconnect(); |
|
|
|
observer = null; |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
isSetup = false; |
|
|
|
|
|
|
|
console.log("[Publication] Cleaned up IntersectionObserver"); |
|
|
|
|
|
|
|
}; |
|
|
|
}; |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|