21 changed files with 1632 additions and 1027 deletions
@ -1,172 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import { ndkInstance } from "$lib/ndk"; |
|
||||||
import { naddrEncode } from "$lib/utils"; |
|
||||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
||||||
import { standardRelays } from "../consts"; |
|
||||||
import { Card, Img } from "flowbite-svelte"; |
|
||||||
import CardActions from "$components/util/CardActions.svelte"; |
|
||||||
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
|
||||||
import { goto } from '$app/navigation'; |
|
||||||
import { getUserMetadata, toNpub, getMatchingTags } from "$lib/utils/nostrUtils"; |
|
||||||
|
|
||||||
const { event } = $props<{ event: NDKEvent }>(); |
|
||||||
|
|
||||||
const relays = $derived.by(() => { |
|
||||||
return $ndkInstance.activeUser?.relayUrls ?? standardRelays; |
|
||||||
}); |
|
||||||
|
|
||||||
const href = $derived.by(() => { |
|
||||||
const d = event.getMatchingTags("d")[0]?.[1]; |
|
||||||
if (d != null) { |
|
||||||
return `publication?d=${d}`; |
|
||||||
} else { |
|
||||||
return `publication?id=${naddrEncode(event, relays)}`; |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); |
|
||||||
let authorTag: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? ''); |
|
||||||
let pTag: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? ''); |
|
||||||
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1'); |
|
||||||
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); |
|
||||||
let authorPubkey: string = $derived( |
|
||||||
event.getMatchingTags("p")[0]?.[1] ?? null, |
|
||||||
); |
|
||||||
let hashtags: string[] = $derived(event.getMatchingTags('t').map((tag: string[]) => tag[1])); |
|
||||||
|
|
||||||
// New: fetch profile display name for authorPubkey |
|
||||||
let authorDisplayName = $state<string | undefined>(undefined); |
|
||||||
let imageLoaded = $state(false); |
|
||||||
let imageError = $state(false); |
|
||||||
|
|
||||||
function isValidNostrPubkey(str: string): boolean { |
|
||||||
return /^[a-f0-9]{64}$/i.test(str) || (str.startsWith('npub1') && str.length >= 59 && str.length <= 63); |
|
||||||
} |
|
||||||
|
|
||||||
function navigateToHashtagSearch(tag: string): void { |
|
||||||
const encoded = encodeURIComponent(tag); |
|
||||||
goto(`/events?t=${encoded}`, { |
|
||||||
replaceState: false, |
|
||||||
keepFocus: true, |
|
||||||
noScroll: true, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
function generatePastelColor(eventId: string): string { |
|
||||||
// Use the first 6 characters of the event ID to generate a pastel color |
|
||||||
const hash = eventId.substring(0, 6); |
|
||||||
const r = parseInt(hash.substring(0, 2), 16); |
|
||||||
const g = parseInt(hash.substring(2, 4), 16); |
|
||||||
const b = parseInt(hash.substring(4, 6), 16); |
|
||||||
|
|
||||||
// Convert to pastel by mixing with white (lightening the color) |
|
||||||
const pastelR = Math.round((r + 255) / 2); |
|
||||||
const pastelG = Math.round((g + 255) / 2); |
|
||||||
const pastelB = Math.round((b + 255) / 2); |
|
||||||
|
|
||||||
return `rgb(${pastelR}, ${pastelG}, ${pastelB})`; |
|
||||||
} |
|
||||||
|
|
||||||
function handleImageLoad() { |
|
||||||
imageLoaded = true; |
|
||||||
} |
|
||||||
|
|
||||||
function handleImageError() { |
|
||||||
imageError = true; |
|
||||||
} |
|
||||||
|
|
||||||
$effect(() => { |
|
||||||
if (authorPubkey) { |
|
||||||
getUserMetadata(toNpub(authorPubkey) as string).then((profile) => { |
|
||||||
authorDisplayName = |
|
||||||
profile.displayName || |
|
||||||
(profile as any).display_name || |
|
||||||
authorTag || |
|
||||||
authorPubkey; |
|
||||||
}); |
|
||||||
} else { |
|
||||||
authorDisplayName = undefined; |
|
||||||
} |
|
||||||
}); |
|
||||||
</script> |
|
||||||
|
|
||||||
{#if title != null && href != null} |
|
||||||
<Card |
|
||||||
class="ArticleBox card-leather max-w-md h-64 flex flex-row overflow-hidden" |
|
||||||
> |
|
||||||
<div class="w-24 h-full overflow-hidden flex-shrink-0"> |
|
||||||
{#if image && !imageError} |
|
||||||
<div class="w-full h-full relative"> |
|
||||||
<!-- Pastel placeholder --> |
|
||||||
<div |
|
||||||
class="w-full h-full transition-opacity duration-300" |
|
||||||
style="background-color: {generatePastelColor(event.id)}; opacity: {imageLoaded ? '0' : '1'}" |
|
||||||
></div> |
|
||||||
<!-- Image --> |
|
||||||
<img |
|
||||||
src={image} |
|
||||||
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-300" |
|
||||||
style="opacity: {imageLoaded ? '1' : '0'}" |
|
||||||
onload={handleImageLoad} |
|
||||||
onerror={handleImageError} |
|
||||||
loading="lazy" |
|
||||||
alt="Publication cover" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
{:else} |
|
||||||
<!-- Pastel placeholder when no image or image failed to load --> |
|
||||||
<div |
|
||||||
class="w-full h-full" |
|
||||||
style="background-color: {generatePastelColor(event.id)}" |
|
||||||
></div> |
|
||||||
{/if} |
|
||||||
</div> |
|
||||||
<div class="flex flex-col flex-grow p-4 relative"> |
|
||||||
<div class="absolute top-2 right-2 z-10"> |
|
||||||
<CardActions {event} /> |
|
||||||
</div> |
|
||||||
<button |
|
||||||
class="flex flex-col space-y-2 text-left w-full bg-transparent border-none p-0 hover:underline pr-8" |
|
||||||
onclick={() => goto(`/${href}`)} |
|
||||||
> |
|
||||||
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2> |
|
||||||
<h3 class='text-base font-normal'> |
|
||||||
by |
|
||||||
{#if authorTag && pTag && isValidNostrPubkey(pTag)} |
|
||||||
{authorTag} {@render userBadge(pTag, '')} |
|
||||||
{:else if authorTag} |
|
||||||
{authorTag} |
|
||||||
{:else if pTag && isValidNostrPubkey(pTag)} |
|
||||||
{@render userBadge(pTag, '')} |
|
||||||
{:else if authorPubkey != null} |
|
||||||
{@render userBadge(authorPubkey, authorDisplayName)} |
|
||||||
{:else} |
|
||||||
unknown |
|
||||||
{/if} |
|
||||||
</h3> |
|
||||||
{#if version != "1"} |
|
||||||
<h3 |
|
||||||
class="text-base font-medium text-primary-700 dark:text-primary-300" |
|
||||||
> |
|
||||||
version: {version} |
|
||||||
</h3> |
|
||||||
{/if} |
|
||||||
</button> |
|
||||||
{#if hashtags.length > 0} |
|
||||||
<div class="tags mt-auto pt-2 flex flex-wrap gap-1"> |
|
||||||
{#each hashtags as tag (tag)} |
|
||||||
<button |
|
||||||
class="text-xs text-primary-600 dark:text-primary-500 hover:text-primary-800 dark:hover:text-primary-300 hover:underline cursor-pointer" |
|
||||||
onclick={(e: MouseEvent) => { |
|
||||||
e.stopPropagation(); |
|
||||||
navigateToHashtagSearch(tag); |
|
||||||
}} |
|
||||||
> |
|
||||||
#{tag} |
|
||||||
</button> |
|
||||||
{/each} |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
</div> |
|
||||||
</Card> |
|
||||||
{/if} |
|
||||||
@ -1,198 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import type { PublicationTree } from "$lib/data_structures/publication_tree"; |
|
||||||
import { |
|
||||||
contentParagraph, |
|
||||||
sectionHeading, |
|
||||||
} from "$lib/snippets/PublicationSnippets.svelte"; |
|
||||||
import { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
||||||
import { TextPlaceholder } from "flowbite-svelte"; |
|
||||||
import { getContext } from "svelte"; |
|
||||||
import type { Asciidoctor, Document } from "asciidoctor"; |
|
||||||
import { getMatchingTags } from "$lib/utils/nostrUtils"; |
|
||||||
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor"; |
|
||||||
import { goto } from '$app/navigation'; |
|
||||||
|
|
||||||
let { |
|
||||||
address, |
|
||||||
rootAddress, |
|
||||||
leaves, |
|
||||||
ref, |
|
||||||
}: { |
|
||||||
address: string; |
|
||||||
rootAddress: string; |
|
||||||
leaves: Array<NDKEvent | null>; |
|
||||||
ref: (ref: HTMLElement) => void; |
|
||||||
} = $props(); |
|
||||||
|
|
||||||
console.debug(`[PublicationSection] Received address: ${address}`); |
|
||||||
console.debug(`[PublicationSection] Root address: ${rootAddress}`); |
|
||||||
console.debug(`[PublicationSection] Leaves count: ${leaves.length}`); |
|
||||||
|
|
||||||
const publicationTree: PublicationTree = getContext("publicationTree"); |
|
||||||
const asciidoctor: Asciidoctor = getContext("asciidoctor"); |
|
||||||
|
|
||||||
let leafEvent: Promise<NDKEvent | null> = $derived.by( |
|
||||||
async () => { |
|
||||||
console.debug(`[PublicationSection] Getting event for address: ${address}`); |
|
||||||
const event = await publicationTree.getEvent(address); |
|
||||||
console.debug(`[PublicationSection] Retrieved event: ${event?.id}`); |
|
||||||
return event; |
|
||||||
}, |
|
||||||
); |
|
||||||
|
|
||||||
let rootEvent: Promise<NDKEvent | null> = $derived.by( |
|
||||||
async () => await publicationTree.getEvent(rootAddress), |
|
||||||
); |
|
||||||
|
|
||||||
let publicationType: Promise<string | undefined> = $derived.by( |
|
||||||
async () => (await rootEvent)?.getMatchingTags("type")[0]?.[1], |
|
||||||
); |
|
||||||
|
|
||||||
let leafHierarchy: Promise<NDKEvent[]> = $derived.by( |
|
||||||
async () => await publicationTree.getHierarchy(address), |
|
||||||
); |
|
||||||
|
|
||||||
let leafTitle: Promise<string | undefined> = $derived.by( |
|
||||||
async () => (await leafEvent)?.getMatchingTags("title")[0]?.[1], |
|
||||||
); |
|
||||||
|
|
||||||
let leafContent: Promise<string | Document> = $derived.by(async () => { |
|
||||||
const rawContent = (await leafEvent)?.content ?? ""; |
|
||||||
const asciidoctorHtml = asciidoctor.convert(rawContent); |
|
||||||
return await postProcessAdvancedAsciidoctorHtml(asciidoctorHtml.toString()); |
|
||||||
}); |
|
||||||
|
|
||||||
let leafHashtags: Promise<string[]> = $derived.by( |
|
||||||
async () => (await leafEvent)?.getMatchingTags("t").map((tag: string[]) => tag[1]) ?? [], |
|
||||||
); |
|
||||||
|
|
||||||
let previousLeafEvent: NDKEvent | null = $derived.by(() => { |
|
||||||
let index: number; |
|
||||||
let event: NDKEvent | null = null; |
|
||||||
let decrement = 1; |
|
||||||
|
|
||||||
do { |
|
||||||
index = leaves.findIndex((leaf) => leaf?.tagAddress() === address); |
|
||||||
if (index === 0) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
event = leaves[index - decrement++]; |
|
||||||
} while (event == null && index - decrement >= 0); |
|
||||||
|
|
||||||
return event; |
|
||||||
}); |
|
||||||
|
|
||||||
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by( |
|
||||||
async () => { |
|
||||||
if (!previousLeafEvent) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress()); |
|
||||||
}, |
|
||||||
); |
|
||||||
|
|
||||||
let divergingBranches = $derived.by(async () => { |
|
||||||
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([ |
|
||||||
leafHierarchy, |
|
||||||
previousLeafHierarchy, |
|
||||||
]); |
|
||||||
|
|
||||||
const branches: [NDKEvent, number][] = []; |
|
||||||
|
|
||||||
if (!previousLeafHierarchyValue) { |
|
||||||
for (let i = 0; i < leafHierarchyValue.length - 1; i++) { |
|
||||||
branches.push([leafHierarchyValue[i], i]); |
|
||||||
} |
|
||||||
return branches; |
|
||||||
} |
|
||||||
|
|
||||||
const minLength = Math.min( |
|
||||||
leafHierarchyValue.length, |
|
||||||
previousLeafHierarchyValue.length, |
|
||||||
); |
|
||||||
|
|
||||||
// Find the first diverging node. |
|
||||||
let divergingIndex = 0; |
|
||||||
while ( |
|
||||||
divergingIndex < minLength && |
|
||||||
leafHierarchyValue[divergingIndex].tagAddress() === |
|
||||||
previousLeafHierarchyValue[divergingIndex].tagAddress() |
|
||||||
) { |
|
||||||
divergingIndex++; |
|
||||||
} |
|
||||||
|
|
||||||
// Add all branches from the first diverging node to the current leaf. |
|
||||||
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) { |
|
||||||
branches.push([leafHierarchyValue[i], i]); |
|
||||||
} |
|
||||||
|
|
||||||
return branches; |
|
||||||
}); |
|
||||||
|
|
||||||
let sectionRef: HTMLElement; |
|
||||||
|
|
||||||
function navigateToHashtagSearch(tag: string): void { |
|
||||||
const encoded = encodeURIComponent(tag); |
|
||||||
goto(`/events?t=${encoded}`, { |
|
||||||
replaceState: false, |
|
||||||
keepFocus: true, |
|
||||||
noScroll: true, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
$effect(() => { |
|
||||||
if (!sectionRef) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
ref(sectionRef); |
|
||||||
}); |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script> |
|
||||||
|
|
||||||
<section |
|
||||||
id={address} |
|
||||||
bind:this={sectionRef} |
|
||||||
class="publication-leather content-visibility-auto" |
|
||||||
> |
|
||||||
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches, leafEvent, leafHashtags], )} |
|
||||||
<TextPlaceholder size="xxl" /> |
|
||||||
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches, resolvedLeafEvent, hashtags]} |
|
||||||
{@const contentString = leafContent.toString()} |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{#each divergingBranches as [branch, depth]} |
|
||||||
{@render sectionHeading( |
|
||||||
getMatchingTags(branch, "title")[0]?.[1] ?? "", |
|
||||||
depth, |
|
||||||
)} |
|
||||||
{/each} |
|
||||||
{#if leafTitle} |
|
||||||
{@const leafDepth = leafHierarchy.length - 1} |
|
||||||
{@render sectionHeading(leafTitle, leafDepth)} |
|
||||||
{/if} |
|
||||||
{@render contentParagraph( |
|
||||||
contentString, |
|
||||||
publicationType ?? "article", |
|
||||||
false, |
|
||||||
)} |
|
||||||
{#if hashtags.length > 0} |
|
||||||
<div class="tags my-2 flex flex-wrap gap-1"> |
|
||||||
{#each hashtags as tag (tag)} |
|
||||||
<button |
|
||||||
class="text-sm text-primary-600 dark:text-primary-500 hover:text-primary-800 dark:hover:text-primary-300 hover:underline cursor-pointer" |
|
||||||
onclick={(e: MouseEvent) => { |
|
||||||
e.stopPropagation(); |
|
||||||
navigateToHashtagSearch(tag); |
|
||||||
}} |
|
||||||
> |
|
||||||
#{tag} |
|
||||||
</button> |
|
||||||
{/each} |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
{/await} |
|
||||||
</section> |
|
||||||
@ -1,28 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
||||||
import { nip19 } from "nostr-tools"; |
|
||||||
export let notes: NDKEvent[] = []; |
|
||||||
// check if notes is empty |
|
||||||
if (notes.length === 0) { |
|
||||||
console.debug("notes is empty"); |
|
||||||
} |
|
||||||
</script> |
|
||||||
|
|
||||||
<div class="toc"> |
|
||||||
<h2>Table of contents</h2> |
|
||||||
<ul> |
|
||||||
{#each notes as note} |
|
||||||
<li> |
|
||||||
<a href="#{nip19.noteEncode(note.id)}" |
|
||||||
>{note.getMatchingTags("title")[0][1]}</a |
|
||||||
> |
|
||||||
</li> |
|
||||||
{/each} |
|
||||||
</ul> |
|
||||||
</div> |
|
||||||
|
|
||||||
<style> |
|
||||||
.toc h2 { |
|
||||||
text-align: center; |
|
||||||
} |
|
||||||
</style> |
|
||||||
@ -0,0 +1,64 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { naddrEncode } from '$lib/utils'; |
||||||
|
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
import { standardRelays } from '../../consts'; |
||||||
|
import { Card, Img } from "flowbite-svelte"; |
||||||
|
import CardActions from "$components/util/CardActions.svelte"; |
||||||
|
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
||||||
|
|
||||||
|
const { event } = $props<{ event: NDKEvent }>(); |
||||||
|
|
||||||
|
const relays = $derived.by(() => { |
||||||
|
return $ndkInstance.activeUser?.relayUrls ?? standardRelays; |
||||||
|
}); |
||||||
|
|
||||||
|
const href = $derived.by(() => { |
||||||
|
const d = event.getMatchingTags('d')[0]?.[1]; |
||||||
|
if (d != null) { |
||||||
|
return `publication?d=${d}`; |
||||||
|
} else { |
||||||
|
return `publication?id=${naddrEncode(event, relays)}`; |
||||||
|
} |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); |
||||||
|
let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); |
||||||
|
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1'); |
||||||
|
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); |
||||||
|
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); |
||||||
|
|
||||||
|
console.log("PublicationHeader event:", event); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if title != null && href != null} |
||||||
|
<Card class='ArticleBox card-leather max-w-md flex flex-row space-x-2'> |
||||||
|
{#if image} |
||||||
|
<div class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden"> |
||||||
|
<Img src={image} class="rounded w-full h-full object-cover"/> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<div class='col flex flex-row flex-grow space-x-4'> |
||||||
|
<div class="flex flex-col flex-grow"> |
||||||
|
<a href="/{href}" class='flex flex-col space-y-2'> |
||||||
|
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2> |
||||||
|
<h3 class='text-base font-normal'> |
||||||
|
by |
||||||
|
{#if authorPubkey != null} |
||||||
|
{@render userBadge(authorPubkey, author)} |
||||||
|
{:else} |
||||||
|
{author} |
||||||
|
{/if} |
||||||
|
</h3> |
||||||
|
{#if version != '1'} |
||||||
|
<h3 class='text-base font-thin'>version: {version}</h3> |
||||||
|
{/if} |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
<div class="flex flex-col justify-start items-center"> |
||||||
|
<CardActions event={event} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Card> |
||||||
|
{/if} |
||||||
@ -0,0 +1,122 @@ |
|||||||
|
<script lang='ts'> |
||||||
|
import type { PublicationTree } from "$lib/data_structures/publication_tree"; |
||||||
|
import { contentParagraph, sectionHeading } from "$lib/snippets/PublicationSnippets.svelte"; |
||||||
|
import { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import { TextPlaceholder } from "flowbite-svelte"; |
||||||
|
import { getContext } from "svelte"; |
||||||
|
import type { Asciidoctor, Document } from "asciidoctor"; |
||||||
|
import { getMatchingTags } from '$lib/utils/nostrUtils'; |
||||||
|
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte"; |
||||||
|
|
||||||
|
let { |
||||||
|
address, |
||||||
|
rootAddress, |
||||||
|
leaves, |
||||||
|
ref, |
||||||
|
}: { |
||||||
|
address: string, |
||||||
|
rootAddress: string, |
||||||
|
leaves: Array<NDKEvent | null>, |
||||||
|
ref: (ref: HTMLElement) => void, |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
const publicationTree: SveltePublicationTree = getContext('publicationTree'); |
||||||
|
const asciidoctor: Asciidoctor = getContext('asciidoctor'); |
||||||
|
|
||||||
|
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () => |
||||||
|
await publicationTree.getEvent(address)); |
||||||
|
|
||||||
|
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () => |
||||||
|
await publicationTree.getEvent(rootAddress)); |
||||||
|
|
||||||
|
let publicationType: Promise<string | undefined> = $derived.by(async () => |
||||||
|
(await rootEvent)?.getMatchingTags('type')[0]?.[1]); |
||||||
|
|
||||||
|
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () => |
||||||
|
await publicationTree.getHierarchy(address)); |
||||||
|
|
||||||
|
let leafTitle: Promise<string | undefined> = $derived.by(async () => |
||||||
|
(await leafEvent)?.getMatchingTags('title')[0]?.[1]); |
||||||
|
|
||||||
|
let leafContent: Promise<string | Document> = $derived.by(async () => |
||||||
|
asciidoctor.convert((await leafEvent)?.content ?? '')); |
||||||
|
|
||||||
|
let previousLeafEvent: NDKEvent | null = $derived.by(() => { |
||||||
|
let index: number; |
||||||
|
let event: NDKEvent | null = null; |
||||||
|
let decrement = 1; |
||||||
|
|
||||||
|
do { |
||||||
|
index = leaves.findIndex(leaf => leaf?.tagAddress() === address); |
||||||
|
if (index === 0) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
event = leaves[index - decrement++]; |
||||||
|
} while (event == null && index - decrement >= 0); |
||||||
|
|
||||||
|
return event; |
||||||
|
}); |
||||||
|
|
||||||
|
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => { |
||||||
|
if (!previousLeafEvent) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress()); |
||||||
|
}); |
||||||
|
|
||||||
|
let divergingBranches = $derived.by(async () => { |
||||||
|
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]); |
||||||
|
|
||||||
|
const branches: [NDKEvent, number][] = []; |
||||||
|
|
||||||
|
if (!previousLeafHierarchyValue) { |
||||||
|
for (let i = 0; i < leafHierarchyValue.length - 1; i++) { |
||||||
|
branches.push([leafHierarchyValue[i], i]); |
||||||
|
} |
||||||
|
return branches; |
||||||
|
} |
||||||
|
|
||||||
|
const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length); |
||||||
|
|
||||||
|
// Find the first diverging node. |
||||||
|
let divergingIndex = 0; |
||||||
|
while ( |
||||||
|
divergingIndex < minLength && |
||||||
|
leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress() |
||||||
|
) { |
||||||
|
divergingIndex++; |
||||||
|
} |
||||||
|
|
||||||
|
// Add all branches from the first diverging node to the current leaf. |
||||||
|
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) { |
||||||
|
branches.push([leafHierarchyValue[i], i]); |
||||||
|
} |
||||||
|
|
||||||
|
return branches; |
||||||
|
}); |
||||||
|
|
||||||
|
let sectionRef: HTMLElement; |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (!sectionRef) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
ref(sectionRef); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<section id={address} bind:this={sectionRef} class='publication-leather content-visibility-auto'> |
||||||
|
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} |
||||||
|
<TextPlaceholder size='xxl' /> |
||||||
|
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} |
||||||
|
{#each divergingBranches as [branch, depth]} |
||||||
|
{@render sectionHeading(getMatchingTags(branch, 'title')[0]?.[1] ?? '', depth)} |
||||||
|
{/each} |
||||||
|
{#if leafTitle} |
||||||
|
{@const leafDepth = leafHierarchy.length - 1} |
||||||
|
{@render sectionHeading(leafTitle, leafDepth)} |
||||||
|
{/if} |
||||||
|
{@render contentParagraph(leafContent.toString(), publicationType ?? 'article', false)} |
||||||
|
{/await} |
||||||
|
</section> |
||||||
@ -0,0 +1,77 @@ |
|||||||
|
<script lang='ts'> |
||||||
|
import { |
||||||
|
TableOfContents, |
||||||
|
type TocEntry |
||||||
|
} from '$lib/components/publications/table_of_contents.svelte'; |
||||||
|
import { getContext } from 'svelte'; |
||||||
|
import { SidebarDropdownWrapper, SidebarGroup, SidebarItem } from 'flowbite-svelte'; |
||||||
|
import Self from './TableOfContents.svelte'; |
||||||
|
|
||||||
|
let { |
||||||
|
depth, |
||||||
|
onSectionFocused, |
||||||
|
} = $props<{ |
||||||
|
rootAddress: string; |
||||||
|
depth: number; |
||||||
|
onSectionFocused?: (address: string) => void; |
||||||
|
}>(); |
||||||
|
|
||||||
|
let toc = getContext('toc') as TableOfContents; |
||||||
|
|
||||||
|
let entries = $derived.by<TocEntry[]>(() => { |
||||||
|
const newEntries = []; |
||||||
|
for (const [_, entry] of toc.addressMap) { |
||||||
|
if (entry.depth !== depth) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
newEntries.push(entry); |
||||||
|
} |
||||||
|
|
||||||
|
return newEntries; |
||||||
|
}); |
||||||
|
|
||||||
|
function setEntryExpanded(address: string, expanded: boolean = false) { |
||||||
|
const entry = toc.getEntry(address); |
||||||
|
if (!entry) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
toc.expandedMap.set(address, expanded); |
||||||
|
entry.resolveChildren(); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<!-- TODO: Figure out how to style indentations. --> |
||||||
|
<!-- TODO: Make group title fonts the same as entry title fonts. --> |
||||||
|
<SidebarGroup> |
||||||
|
{#each entries as entry} |
||||||
|
{@const address = entry.address} |
||||||
|
{@const expanded = toc.expandedMap.get(address) ?? false} |
||||||
|
{@const isLeaf = toc.leaves.has(address)} |
||||||
|
{#if isLeaf} |
||||||
|
<SidebarItem |
||||||
|
label={entry.title} |
||||||
|
href={`#${address}`} |
||||||
|
spanClass='px-2 text-ellipsis' |
||||||
|
onclick={() => onSectionFocused?.(address)} |
||||||
|
/> |
||||||
|
{:else} |
||||||
|
{@const childDepth = depth + 1} |
||||||
|
<SidebarDropdownWrapper |
||||||
|
label={entry.title} |
||||||
|
btnClass='flex items-center p-2 w-full font-normal text-gray-900 rounded-lg transition duration-75 group hover:bg-primary-50 dark:text-white dark:hover:bg-primary-800' |
||||||
|
bind:isOpen={ |
||||||
|
() => expanded, |
||||||
|
(open) => setEntryExpanded(address, open) |
||||||
|
} |
||||||
|
> |
||||||
|
<Self |
||||||
|
rootAddress={address} |
||||||
|
depth={childDepth} |
||||||
|
onSectionFocused={onSectionFocused} |
||||||
|
/> |
||||||
|
</SidebarDropdownWrapper> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
</SidebarGroup> |
||||||
@ -0,0 +1,111 @@ |
|||||||
|
import { SvelteSet } from "svelte/reactivity"; |
||||||
|
import { PublicationTree } from "../../data_structures/publication_tree.ts"; |
||||||
|
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
|
||||||
|
export class SveltePublicationTree { |
||||||
|
resolvedAddresses: SvelteSet<string> = new SvelteSet(); |
||||||
|
|
||||||
|
#publicationTree: PublicationTree; |
||||||
|
#nodeResolvedObservers: Array<(address: string) => void> = []; |
||||||
|
#bookmarkMovedObservers: Array<(address: string) => void> = []; |
||||||
|
|
||||||
|
constructor(rootEvent: NDKEvent, ndk: NDK) { |
||||||
|
this.#publicationTree = new PublicationTree(rootEvent, ndk); |
||||||
|
|
||||||
|
this.#publicationTree.onNodeResolved(this.#handleNodeResolved); |
||||||
|
this.#publicationTree.onBookmarkMoved(this.#handleBookmarkMoved); |
||||||
|
} |
||||||
|
|
||||||
|
// #region Proxied Public Methods
|
||||||
|
|
||||||
|
getChildAddresses(address: string): Promise<Array<string | null>> { |
||||||
|
return this.#publicationTree.getChildAddresses(address); |
||||||
|
} |
||||||
|
|
||||||
|
getEvent(address: string): Promise<NDKEvent | null> { |
||||||
|
return this.#publicationTree.getEvent(address); |
||||||
|
} |
||||||
|
|
||||||
|
getHierarchy(address: string): Promise<NDKEvent[]> { |
||||||
|
return this.#publicationTree.getHierarchy(address); |
||||||
|
} |
||||||
|
|
||||||
|
async getParent(address: string): Promise<NDKEvent | null> { |
||||||
|
const hierarchy = await this.getHierarchy(address); |
||||||
|
|
||||||
|
// The last element in the hierarchy is the event with the given address, so the parent is the
|
||||||
|
// second to last element.
|
||||||
|
return hierarchy.at(-2) ?? null; |
||||||
|
} |
||||||
|
|
||||||
|
setBookmark(address: string) { |
||||||
|
this.#publicationTree.setBookmark(address); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Registers an observer function that is invoked whenever a new node is resolved. |
||||||
|
* @param observer The observer function. |
||||||
|
*/ |
||||||
|
onNodeResolved(observer: (address: string) => void) { |
||||||
|
this.#nodeResolvedObservers.push(observer); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Registers an observer function that is invoked whenever the bookmark is moved. |
||||||
|
* @param observer The observer function. |
||||||
|
*/ |
||||||
|
onBookmarkMoved(observer: (address: string) => void) { |
||||||
|
this.#bookmarkMovedObservers.push(observer); |
||||||
|
} |
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Proxied Async Iterator Methods
|
||||||
|
|
||||||
|
[Symbol.asyncIterator](): AsyncIterator<NDKEvent | null> { |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
next(): Promise<IteratorResult<NDKEvent | null>> { |
||||||
|
return this.#publicationTree.next(); |
||||||
|
} |
||||||
|
|
||||||
|
previous(): Promise<IteratorResult<NDKEvent | null>> { |
||||||
|
return this.#publicationTree.previous(); |
||||||
|
} |
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Private Methods
|
||||||
|
|
||||||
|
/** |
||||||
|
* Observer function that is invoked whenever a new node is resolved on the publication tree. |
||||||
|
*
|
||||||
|
* @param address The address of the resolved node. |
||||||
|
*
|
||||||
|
* This member is declared as an arrow function to ensure that the correct `this` context is |
||||||
|
* used when the function is invoked in this class's constructor. |
||||||
|
*/ |
||||||
|
#handleNodeResolved = (address: string) => { |
||||||
|
this.resolvedAddresses.add(address); |
||||||
|
for (const observer of this.#nodeResolvedObservers) { |
||||||
|
observer(address); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Observer function that is invoked whenever the bookmark is moved on the publication tree. |
||||||
|
*
|
||||||
|
* @param address The address of the new bookmark. |
||||||
|
*
|
||||||
|
* This member is declared as an arrow function to ensure that the correct `this` context is |
||||||
|
* used when the function is invoked in this class's constructor. |
||||||
|
*/ |
||||||
|
#handleBookmarkMoved = (address: string) => { |
||||||
|
for (const observer of this.#bookmarkMovedObservers) { |
||||||
|
observer(address); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// #endregion
|
||||||
|
} |
||||||
@ -0,0 +1,292 @@ |
|||||||
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; |
||||||
|
import { SveltePublicationTree } from './svelte_publication_tree.svelte.ts'; |
||||||
|
import type { NDKEvent } from '../../utils/nostrUtils.ts'; |
||||||
|
import { indexKind } from '../../consts.ts'; |
||||||
|
|
||||||
|
export interface TocEntry { |
||||||
|
address: string; |
||||||
|
title: string; |
||||||
|
href?: string; |
||||||
|
children: TocEntry[]; |
||||||
|
parent?: TocEntry; |
||||||
|
depth: number; |
||||||
|
childrenResolved: boolean; |
||||||
|
resolveChildren: () => Promise<void>; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Maintains a table of contents (ToC) for a `SveltePublicationTree`. Since publication trees are |
||||||
|
* conceptually infinite and lazy-loading, the ToC represents only the portion of the tree that has |
||||||
|
* been "discovered". The ToC is updated as new nodes are resolved within the publication tree. |
||||||
|
*
|
||||||
|
* @see SveltePublicationTree |
||||||
|
*/ |
||||||
|
export class TableOfContents { |
||||||
|
public addressMap: SvelteMap<string, TocEntry> = new SvelteMap(); |
||||||
|
public expandedMap: SvelteMap<string, boolean> = new SvelteMap(); |
||||||
|
public leaves: SvelteSet<string> = new SvelteSet(); |
||||||
|
|
||||||
|
#root: TocEntry | null = null; |
||||||
|
#publicationTree: SveltePublicationTree; |
||||||
|
#pagePathname: string; |
||||||
|
|
||||||
|
/** |
||||||
|
* 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.#init(rootAddress); |
||||||
|
} |
||||||
|
|
||||||
|
// #region Public Methods
|
||||||
|
|
||||||
|
/** |
||||||
|
* 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; |
||||||
|
} |
||||||
|
|
||||||
|
getEntry(address: string): TocEntry | undefined { |
||||||
|
return this.addressMap.get(address); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Builds a table of contents from the DOM subtree rooted at `parentElement`. |
||||||
|
*
|
||||||
|
* @param parentElement The root of the DOM subtree containing the content to be added to the |
||||||
|
* ToC. |
||||||
|
* @param parentAddress The address of the event corresponding to the DOM subtree root indicated |
||||||
|
* by `parentElement`. |
||||||
|
*
|
||||||
|
* This function is intended for use on segments of HTML markup that are not directly derived |
||||||
|
* from a structure publication of the kind supported by `PublicationTree`. It may be used to |
||||||
|
* produce a table of contents from the contents of a kind `30041` event with AsciiDoc markup, or |
||||||
|
* from a kind `30023` event with Markdown content. |
||||||
|
*/ |
||||||
|
buildTocFromDocument( |
||||||
|
parentElement: HTMLElement, |
||||||
|
parentEntry: TocEntry, |
||||||
|
) { |
||||||
|
parentElement |
||||||
|
.querySelectorAll<HTMLHeadingElement>(`h${parentEntry.depth}`) |
||||||
|
.forEach((header) => { |
||||||
|
// TODO: Correctly update ToC state from DOM.
|
||||||
|
const title = header.textContent?.trim(); |
||||||
|
const id = header.id; |
||||||
|
|
||||||
|
// Only create an entry if the header has an ID and a title.
|
||||||
|
if (id && title) { |
||||||
|
const href = `${this.#pagePathname}#${id}`; |
||||||
|
|
||||||
|
// TODO: Check this logic.
|
||||||
|
const tocEntry: TocEntry = { |
||||||
|
address: parentEntry.address, |
||||||
|
title, |
||||||
|
href, |
||||||
|
depth: parentEntry.depth + 1, |
||||||
|
children: [], |
||||||
|
childrenResolved: true, |
||||||
|
resolveChildren: () => Promise.resolve(), |
||||||
|
}; |
||||||
|
parentEntry.children.push(tocEntry); |
||||||
|
this.expandedMap.set(tocEntry.address, false); |
||||||
|
|
||||||
|
this.buildTocFromDocument(header, tocEntry); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Iterator Methods
|
||||||
|
|
||||||
|
/** |
||||||
|
* Iterates over all ToC entries in depth-first order. |
||||||
|
*/ |
||||||
|
*[Symbol.iterator](): IterableIterator<TocEntry> { |
||||||
|
function* traverse(entry: TocEntry | null): IterableIterator<TocEntry> { |
||||||
|
if (!entry) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
yield entry; |
||||||
|
|
||||||
|
if (entry.children) { |
||||||
|
for (const child of entry.children) { |
||||||
|
yield* traverse(child); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
yield* traverse(this.#root); |
||||||
|
} |
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Private Methods
|
||||||
|
|
||||||
|
/** |
||||||
|
* Initializes the ToC from the associated publication tree. |
||||||
|
*
|
||||||
|
* @param rootAddress The address of the publication's root event. |
||||||
|
*
|
||||||
|
* Michael J - 07 July 2025 - NOTE: Since the publication tree is conceptually infinite and |
||||||
|
* lazy-loading, the ToC is not guaranteed to contain all the nodes at any layer until the |
||||||
|
* publication has been fully resolved. |
||||||
|
*
|
||||||
|
* Michael J - 07 July 2025 - TODO: If the relay provides event metadata, use the metadata to |
||||||
|
* initialize the ToC with all of its first-level children. |
||||||
|
*/ |
||||||
|
async #init(rootAddress: string) { |
||||||
|
const rootEvent = await this.#publicationTree.getEvent(rootAddress); |
||||||
|
if (!rootEvent) { |
||||||
|
throw new Error(`[ToC] Root event ${rootAddress} not found.`); |
||||||
|
} |
||||||
|
|
||||||
|
this.#root = await this.#buildTocEntry(rootAddress); |
||||||
|
|
||||||
|
this.addressMap.set(rootAddress, this.#root); |
||||||
|
|
||||||
|
// Handle any other nodes that have already been resolved in parallel.
|
||||||
|
await Promise.all( |
||||||
|
Array.from(this.#publicationTree.resolvedAddresses).map((address) => |
||||||
|
this.#buildTocEntryFromResolvedNode(address) |
||||||
|
) |
||||||
|
); |
||||||
|
|
||||||
|
// Set up an observer to handle progressive resolution of the publication tree.
|
||||||
|
this.#publicationTree.onNodeResolved((address: string) => { |
||||||
|
this.#buildTocEntryFromResolvedNode(address); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
#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]'; |
||||||
|
} |
||||||
|
|
||||||
|
async #buildTocEntry(address: string): Promise<TocEntry> { |
||||||
|
// Michael J - 07 July 2025 - NOTE: This arrow function is nested so as to use its containing
|
||||||
|
// scope in its operation. Do not move it to the top level without ensuring it still has access
|
||||||
|
// to the necessary variables.
|
||||||
|
const resolver = async () => { |
||||||
|
if (entry.childrenResolved) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const event = await this.#publicationTree.getEvent(entry.address); |
||||||
|
if (event?.kind !== indexKind) { |
||||||
|
// TODO: Build ToC entries from HTML markup in this case.
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const childAddresses = await this.#publicationTree.getChildAddresses(entry.address); |
||||||
|
for (const childAddress of childAddresses) { |
||||||
|
if (!childAddress) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Michael J - 16 June 2025 - This duplicates logic in the outer function, but is necessary
|
||||||
|
// here so that we can determine whether to render an entry as a leaf before it is fully
|
||||||
|
// resolved.
|
||||||
|
if (childAddress.split(':')[0] !== indexKind.toString()) { |
||||||
|
this.leaves.add(childAddress); |
||||||
|
} |
||||||
|
|
||||||
|
// 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 childEntry = await this.#buildTocEntry(childAddress); |
||||||
|
childEntry.parent = entry; |
||||||
|
childEntry.depth = entry.depth + 1; |
||||||
|
entry.children.push(childEntry); |
||||||
|
this.addressMap.set(childAddress, childEntry); |
||||||
|
} |
||||||
|
|
||||||
|
await this.#matchChildrenToTagOrder(entry); |
||||||
|
|
||||||
|
entry.childrenResolved = true; |
||||||
|
} |
||||||
|
|
||||||
|
const event = await this.#publicationTree.getEvent(address); |
||||||
|
if (!event) { |
||||||
|
throw new Error(`[ToC] Event ${address} not found.`); |
||||||
|
} |
||||||
|
|
||||||
|
const depth = (await this.#publicationTree.getHierarchy(address)).length; |
||||||
|
|
||||||
|
const entry: TocEntry = { |
||||||
|
address, |
||||||
|
title: this.#getTitle(event), |
||||||
|
href: `${this.#pagePathname}#${address}`, |
||||||
|
children: [], |
||||||
|
depth, |
||||||
|
childrenResolved: false, |
||||||
|
resolveChildren: resolver, |
||||||
|
}; |
||||||
|
this.expandedMap.set(address, false); |
||||||
|
|
||||||
|
// Michael J - 16 June 2025 - We determine whether to add a leaf both here and in the inner
|
||||||
|
// resolver function. The resolver function is called when entries are resolved by expanding
|
||||||
|
// a ToC entry, and we'll reach the block below when entries are resolved by the publication
|
||||||
|
// tree.
|
||||||
|
if (event.kind !== indexKind) { |
||||||
|
this.leaves.add(address); |
||||||
|
} |
||||||
|
|
||||||
|
return entry; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Reorders the children of a ToC entry to match the order of 'a' tags in the corresponding |
||||||
|
* Nostr index event. |
||||||
|
*
|
||||||
|
* @param entry The ToC entry to reorder. |
||||||
|
*
|
||||||
|
* This function has a time complexity of `O(n log n)`, where `n` is the number of children the |
||||||
|
* parent event has. Average size of `n` is small enough to be negligible. |
||||||
|
*/ |
||||||
|
async #matchChildrenToTagOrder(entry: TocEntry) { |
||||||
|
const parentEvent = await this.#publicationTree.getEvent(entry.address); |
||||||
|
if (parentEvent?.kind === indexKind) { |
||||||
|
const tagOrder = parentEvent.getMatchingTags('a').map(tag => tag[1]); |
||||||
|
const addressToOrdinal = new Map<string, number>(); |
||||||
|
|
||||||
|
// Build map of addresses to their ordinals from tag order
|
||||||
|
tagOrder.forEach((address, index) => { |
||||||
|
addressToOrdinal.set(address, index); |
||||||
|
}); |
||||||
|
|
||||||
|
entry.children.sort((a, b) => { |
||||||
|
const aOrdinal = addressToOrdinal.get(a.address) ?? Number.MAX_SAFE_INTEGER; |
||||||
|
const bOrdinal = addressToOrdinal.get(b.address) ?? Number.MAX_SAFE_INTEGER; |
||||||
|
return aOrdinal - bOrdinal; |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
#buildTocEntryFromResolvedNode(address: string) { |
||||||
|
if (this.addressMap.has(address)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.#buildTocEntry(address).then((entry) => { |
||||||
|
this.addressMap.set(address, entry); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// #endregion
|
||||||
|
} |
||||||
@ -1,150 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import { |
|
||||||
Heading, |
|
||||||
Sidebar, |
|
||||||
SidebarGroup, |
|
||||||
SidebarItem, |
|
||||||
SidebarWrapper, |
|
||||||
} from "flowbite-svelte"; |
|
||||||
import { onMount } from "svelte"; |
|
||||||
import { pharosInstance, tocUpdate } from "$lib/parser"; |
|
||||||
import { publicationColumnVisibility } from "$lib/stores"; |
|
||||||
|
|
||||||
let { rootId } = $props<{ rootId: string }>(); |
|
||||||
|
|
||||||
if (rootId !== $pharosInstance.getRootIndexId()) { |
|
||||||
console.error("Root ID does not match parser root index ID"); |
|
||||||
} |
|
||||||
|
|
||||||
const tocBreakpoint = 1140; |
|
||||||
|
|
||||||
let activeHash = $state(window.location.hash); |
|
||||||
|
|
||||||
interface TocItem { |
|
||||||
label: string; |
|
||||||
hash: string; |
|
||||||
} |
|
||||||
|
|
||||||
// Get TOC items from parser |
|
||||||
let tocItems = $state<TocItem[]>([]); |
|
||||||
|
|
||||||
$effect(() => { |
|
||||||
// This will re-run whenever tocUpdate changes |
|
||||||
tocUpdate; |
|
||||||
const items: TocItem[] = []; |
|
||||||
const childIds = $pharosInstance.getChildIndexIds(rootId); |
|
||||||
console.log("TOC rootId:", rootId, "childIds:", childIds); |
|
||||||
const processNode = (nodeId: string) => { |
|
||||||
const title = $pharosInstance.getIndexTitle(nodeId); |
|
||||||
if (title) { |
|
||||||
items.push({ |
|
||||||
label: title, |
|
||||||
hash: `#${nodeId}`, |
|
||||||
}); |
|
||||||
} |
|
||||||
const children = $pharosInstance.getChildIndexIds(nodeId); |
|
||||||
children.forEach(processNode); |
|
||||||
}; |
|
||||||
childIds.forEach(processNode); |
|
||||||
tocItems = items; |
|
||||||
}); |
|
||||||
|
|
||||||
function normalizeHashPath(str: string): string { |
|
||||||
return str |
|
||||||
.toLowerCase() |
|
||||||
.replace(/\s+/g, "-") |
|
||||||
.replace(/[^\w-]/g, ""); |
|
||||||
} |
|
||||||
|
|
||||||
function scrollToElementWithOffset() { |
|
||||||
const hash = window.location.hash; |
|
||||||
if (hash) { |
|
||||||
const targetElement = document.querySelector(hash); |
|
||||||
if (targetElement) { |
|
||||||
const headerOffset = 80; |
|
||||||
const elementPosition = targetElement.getBoundingClientRect().top; |
|
||||||
const offsetPosition = elementPosition + window.scrollY - headerOffset; |
|
||||||
|
|
||||||
window.scrollTo({ |
|
||||||
top: offsetPosition, |
|
||||||
behavior: "auto", |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
function updateActiveHash() { |
|
||||||
activeHash = window.location.hash; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Hides the table of contents sidebar when the window shrinks below a certain size. This |
|
||||||
* prevents the sidebar from occluding the article content. |
|
||||||
*/ |
|
||||||
function setTocVisibilityOnResize() { |
|
||||||
// Always show TOC on laptop and larger screens, collapsible only on small/medium |
|
||||||
publicationColumnVisibility.update((v) => ({ |
|
||||||
...v, |
|
||||||
toc: window.innerWidth >= tocBreakpoint, |
|
||||||
})); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Hides the table of contents sidebar when the user clicks outside of it. |
|
||||||
*/ |
|
||||||
function hideTocOnClick(ev: MouseEvent) { |
|
||||||
const target = ev.target as HTMLElement; |
|
||||||
|
|
||||||
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// Only allow hiding TOC on screens smaller than tocBreakpoint |
|
||||||
if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) { |
|
||||||
publicationColumnVisibility.update((v) => ({ ...v, toc: false })); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
onMount(() => { |
|
||||||
// Always check whether the TOC sidebar should be visible. |
|
||||||
setTocVisibilityOnResize(); |
|
||||||
|
|
||||||
window.addEventListener("hashchange", updateActiveHash); |
|
||||||
window.addEventListener("hashchange", scrollToElementWithOffset); |
|
||||||
// Also handle the case where the user lands on the page with a hash in the URL |
|
||||||
scrollToElementWithOffset(); |
|
||||||
|
|
||||||
window.addEventListener("resize", setTocVisibilityOnResize); |
|
||||||
window.addEventListener("click", hideTocOnClick); |
|
||||||
|
|
||||||
return () => { |
|
||||||
window.removeEventListener("hashchange", updateActiveHash); |
|
||||||
window.removeEventListener("hashchange", scrollToElementWithOffset); |
|
||||||
window.removeEventListener("resize", setTocVisibilityOnResize); |
|
||||||
window.removeEventListener("click", hideTocOnClick); |
|
||||||
}; |
|
||||||
}); |
|
||||||
</script> |
|
||||||
|
|
||||||
<!-- TODO: Get TOC from parser. --> |
|
||||||
{#if $publicationColumnVisibility.toc} |
|
||||||
<Sidebar class="sidebar-leather left-0"> |
|
||||||
<SidebarWrapper> |
|
||||||
<SidebarGroup class="sidebar-group-leather"> |
|
||||||
<Heading tag="h1" class="h-leather !text-lg">Table of contents</Heading> |
|
||||||
<p> |
|
||||||
(This ToC is only for demo purposes, and is not fully-functional.) |
|
||||||
</p> |
|
||||||
{#each tocItems as item} |
|
||||||
<SidebarItem |
|
||||||
class="sidebar-item-leather {activeHash === item.hash |
|
||||||
? 'bg-primary-200 font-bold' |
|
||||||
: ''}" |
|
||||||
label={item.label} |
|
||||||
href={item.hash} |
|
||||||
/> |
|
||||||
{/each} |
|
||||||
</SidebarGroup> |
|
||||||
</SidebarWrapper> |
|
||||||
</Sidebar> |
|
||||||
{/if} |
|
||||||
Loading…
Reference in new issue