Browse Source

Update ToC on publication tree node resolution

master
buttercat1791 9 months ago
parent
commit
ddf8b9006b
  1. 198
      src/lib/components/publications/table_of_contents.svelte.ts

198
src/lib/components/publications/table_of_contents.svelte.ts

@ -1,98 +1,52 @@ @@ -1,98 +1,52 @@
import { SveltePublicationTree } from "./svelte_publication_tree.svelte.ts";
import { SvelteMap } from 'svelte/reactivity';
import type { SveltePublicationTree } from './svelte_publication_tree.svelte.ts';
import type { NDKEvent } from '../../utils/nostrUtils.ts';
export interface TocEntry {
address: string;
title: string;
href: string;
href?: string;
children: TocEntry[];
parent?: TocEntry;
depth: number;
expanded: boolean;
children: Array<TocEntry> | null;
}
export class TableOfContents {
#tocRoot: TocEntry | null = null;
#addresses = $state<Map<string, TocEntry>>(new Map());
public addressMap: SvelteMap<string, TocEntry> = new SvelteMap();
#root: TocEntry | null = null;
#publicationTree: SveltePublicationTree;
#pagePathname: string;
/**
* Constructor for the `TableOfContents` class. The constructed ToC initially contains only the
* root entry. Additional entries must be inserted programmatically using class methods.
*
* The `TableOfContents` class should be instantiated as a page-scoped singleton so that
* `pagePathname` is correct wherever the instance is used. The singleton should be made
* made available to the entire component tree under that page.
* Constructs a `TableOfContents` from a `SveltePublicationTree`.
*
* @param rootAddress The address of the root event.
* @param publicationTree The SveltePublicationTree instance.
* @param pagePathname The current page pathname for href generation.
*/
constructor(rootAddress: string, publicationTree: SveltePublicationTree, pagePathname: string) {
this.#publicationTree = publicationTree;
this.#pagePathname = pagePathname;
this.insertIntoTocFromPublicationTree(rootAddress);
void this.#initRoot(rootAddress);
this.#publicationTree.onNodeResolved((address: string) => {
void this.#handleNodeResolved(address);
});
}
#normalizeHashPath(title: string): string {
// TODO: Confirm this uses good normalization logic to produce unique hrefs within the page.
return title.toLowerCase().replace(/ /g, '-');
}
// #region Public Methods
get addresses(): Map<string, TocEntry> {
return this.#addresses;
/**
* Returns the root entry of the ToC.
*
* @returns The root entry of the ToC, or `null` if the ToC has not been initialized.
*/
getRootEntry(): TocEntry | null {
return this.#root;
}
async insertIntoTocFromPublicationTree(address: string): Promise<void> {
const targetEvent = await this.#publicationTree.getEvent(address);
if (!targetEvent) {
console.warn(`[ToC] Event ${address} not found.`);
// TODO: Determine how to handle this case in the UI.
return;
}
const hierarchyEvents = await this.#publicationTree.getHierarchy(address);
if (hierarchyEvents.length === 0) {
// This means we are at root.
return;
}
// Michael J 05 May 2025 - In this loop, we assume that the parent of the current event has
// already been populated into the ToC. As long as the root is set when the component is
// initialized, this code will work fine.
let currentParentTocNode: TocEntry | null = this.#tocRoot;
for (let i = 0; i < hierarchyEvents.length; i++) {
const currentEvent = hierarchyEvents[i];
const currentAddress = currentEvent.tagAddress();
if (this.#addresses.has(currentAddress)) {
continue;
}
const currentEventChildAddresses = await this.#publicationTree.getChildAddresses(currentAddress);
for (const address of currentEventChildAddresses) {
if (address === null) {
continue;
}
const childEvent = await this.#publicationTree.getEvent(address);
if (!childEvent) {
console.warn(`[ToC] Event ${address} not found.`);
continue;
}
currentParentTocNode!.children ??= [];
const childTocEntry: TocEntry = {
address,
title: childEvent.getMatchingTags('title')[0][1],
href: `${this.#pagePathname}#${this.#normalizeHashPath(childEvent.getMatchingTags('title')[0][1])}`,
depth: i + 1,
expanded: false,
children: null,
};
currentParentTocNode!.children.push(childTocEntry);
this.#addresses.set(address, childTocEntry);
}
currentParentTocNode = this.#addresses.get(currentAddress)!;
}
getEntry(address: string): TocEntry | undefined {
return this.addressMap.get(address);
}
/**
@ -128,10 +82,8 @@ export class TableOfContents { @@ -128,10 +82,8 @@ export class TableOfContents {
title,
href,
depth,
expanded: false,
children: null,
children: [],
};
parentEntry.children ??= [];
parentEntry.children.push(tocEntry);
this.buildTocFromDocument(header, tocEntry, depth + 1);
@ -139,6 +91,10 @@ export class TableOfContents { @@ -139,6 +91,10 @@ export class TableOfContents {
});
}
// #endregion
// #region Iterator Methods
/**
* Iterates over all ToC entries in depth-first order.
*/
@ -157,6 +113,92 @@ export class TableOfContents { @@ -157,6 +113,92 @@ export class TableOfContents {
}
}
yield* traverse(this.#tocRoot);
yield* traverse(this.#root);
}
// #endregion
// #region Private Methods
async #initRoot(rootAddress: string) {
const rootEvent = await this.#publicationTree.getEvent(rootAddress);
if (!rootEvent) {
throw new Error(`[ToC] Root event ${rootAddress} not found.`);
}
this.#root = {
address: rootAddress,
title: this.#getTitle(rootEvent),
children: [],
depth: 0,
};
this.addressMap.set(rootAddress, this.#root);
// Handle any other nodes that have already been resolved.
await this.#handleNodeResolved(rootAddress);
}
async #handleNodeResolved(address: string) {
if (this.addressMap.has(address)) {
return;
}
const event = await this.#publicationTree.getEvent(address);
if (!event) {
return;
}
const parentEvent = await this.#publicationTree.getParent(address);
const parentAddress = parentEvent?.tagAddress();
if (!parentAddress) {
// All non-root nodes must have a parent.
if (!this.#root || address !== this.#root.address) {
throw new Error(`[ToC] Parent not found for address ${address}`);
}
return;
}
const parentEntry = this.addressMap.get(parentAddress);
if (!parentEntry) {
throw new Error(`[ToC] Parent ToC entry not found for address ${address}`);
}
const entry: TocEntry = {
address,
title: this.#getTitle(event),
children: [],
parent: parentEntry,
depth: parentEntry.depth + 1,
};
// Michael J - 05 June 2025 - The `getChildAddresses` method forces node resolution on the
// publication tree. This is acceptable here, because the tree is always resolved top-down.
// Therefore, by the time we handle a node's resolution, its parent and siblings have already
// been resolved.
const childAddresses = await this.#publicationTree.getChildAddresses(parentAddress);
const filteredChildAddresses = childAddresses.filter((a): a is string => !!a);
const insertIndex = filteredChildAddresses.findIndex(a => a === address);
if (insertIndex === -1 || insertIndex > parentEntry.children.length) {
parentEntry.children.push(entry);
} else {
parentEntry.children.splice(insertIndex, 0, entry);
}
this.addressMap.set(address, entry);
}
#getTitle(event: NDKEvent | null): string {
if (!event) {
// TODO: What do we want to return in this case?
return '[untitled]';
}
const titleTag = event.getMatchingTags?.('title')?.[0]?.[1];
return titleTag || event.tagAddress() || '[untitled]';
}
#normalizeHashPath(title: string): string {
// TODO: Confirm this uses good normalization logic to produce unique hrefs within the page.
return title.toLowerCase().replace(/ /g, '-');
}
// #endregion
}

Loading…
Cancel
Save