diff --git a/src/components/ImageWithLightbox/index.tsx b/src/components/ImageWithLightbox/index.tsx
index 1c180fb..3642c04 100644
--- a/src/components/ImageWithLightbox/index.tsx
+++ b/src/components/ImageWithLightbox/index.tsx
@@ -62,7 +62,7 @@ export default function ImageWithLightbox({
key={0}
className={className}
classNames={{
- wrapper: cn('rounded-lg border cursor-zoom-in', classNames.wrapper),
+ wrapper: cn('rounded-lg cursor-zoom-in', classNames.wrapper),
errorPlaceholder: 'aspect-square h-[30vh]'
}}
image={image}
diff --git a/src/components/Note/Article/index.tsx b/src/components/Note/Article/index.tsx
index ddbf3bf..3af8c49 100644
--- a/src/components/Note/Article/index.tsx
+++ b/src/components/Note/Article/index.tsx
@@ -8,9 +8,9 @@ import { useMemo, useState, useEffect, useRef } from 'react'
import { useEventFieldParser } from '@/hooks/useContentParser'
import WebPreview from '../../WebPreview'
import HighlightSourcePreview from '../../UniversalContent/HighlightSourcePreview'
-import TableOfContents from '../../UniversalContent/TableOfContents'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
+import { ExtendedKind } from '@/constants'
export default function Article({
event,
@@ -23,6 +23,14 @@ export default function Article({
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const [isInfoOpen, setIsInfoOpen] = useState(false)
+ // Determine if this is an article-type event that should show ToC and Article Info
+ const isArticleType = useMemo(() => {
+ return event.kind === kinds.LongFormArticle ||
+ event.kind === ExtendedKind.WIKI_ARTICLE ||
+ event.kind === ExtendedKind.PUBLICATION ||
+ event.kind === ExtendedKind.PUBLICATION_CONTENT
+ }, [event.kind])
+
// Use the comprehensive content parser
const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', {
enableMath: true,
@@ -93,6 +101,46 @@ export default function Article({
}
}, [parsedContent])
+ // Add ToC return buttons to section headers
+ useEffect(() => {
+ if (!contentRef.current || !isArticleType || !parsedContent) return
+
+ const addTocReturnButtons = () => {
+ const headers = contentRef.current?.querySelectorAll('h1, h2, h3, h4, h5, h6')
+ if (!headers) return
+
+ headers.forEach((header) => {
+ // Skip if button already exists
+ if (header.querySelector('.toc-return-btn')) return
+
+ // Create the return button
+ const returnBtn = document.createElement('span')
+ returnBtn.className = 'toc-return-btn'
+ returnBtn.innerHTML = '↑ ToC'
+ returnBtn.title = 'Return to Table of Contents'
+
+ // Add click handler
+ returnBtn.addEventListener('click', (e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ // Scroll to the ToC
+ const tocElement = document.getElementById('toc')
+ if (tocElement) {
+ tocElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ }
+ })
+
+ // Add the button to the header
+ header.appendChild(returnBtn)
+ })
+ }
+
+ // Add buttons after a short delay to ensure content is rendered
+ const timeoutId = setTimeout(addTocReturnButtons, 100)
+
+ return () => clearTimeout(timeoutId)
+ }, [parsedContent?.html, isArticleType])
+
if (isLoading) {
return (
@@ -130,41 +178,16 @@ export default function Article({
{metadata.image && (
)}
- {/* Table of Contents */}
-
-
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
-
-
- {/* Hashtags */}
- {parsedContent.hashtags.length > 0 && (
-
- {parsedContent.hashtags.map((tag) => (
-
{
- e.stopPropagation()
- push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
- }}
- >
- #{tag}
-
- ))}
-
- )}
+
- {/* Collapsible Article Info */}
- {(parsedContent.media.length > 0 || parsedContent.links.length > 0 || parsedContent.nostrLinks.length > 0 || parsedContent.highlightSources.length > 0) && (
+ {/* Collapsible Article Info - only for article-type events */}
+ {isArticleType && (parsedContent.media.length > 0 || parsedContent.links.length > 0 || parsedContent.nostrLinks.length > 0 || parsedContent.highlightSources.length > 0 || parsedContent.hashtags.length > 0) && (
)}
diff --git a/src/components/Note/SimpleContent/index.tsx b/src/components/Note/SimpleContent/index.tsx
new file mode 100644
index 0000000..ce248fc
--- /dev/null
+++ b/src/components/Note/SimpleContent/index.tsx
@@ -0,0 +1,31 @@
+import { Event } from 'nostr-tools'
+import { useEventFieldParser } from '@/hooks/useContentParser'
+
+export default function SimpleContent({
+ event,
+ className
+}: {
+ event: Event
+ className?: string
+}) {
+ // Use the comprehensive content parser but without ToC
+ const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', {
+ enableMath: true,
+ enableSyntaxHighlighting: true
+ })
+
+ if (isLoading) {
+ return Loading...
+ }
+
+ if (error) {
+ return Error loading content
+ }
+
+ return (
+
+ {/* Render content without ToC and Article Info */}
+
+
+ )
+}
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx
index bed9430..a183296 100644
--- a/src/components/Note/index.tsx
+++ b/src/components/Note/index.tsx
@@ -28,6 +28,7 @@ import IValue from './IValue'
import LiveEvent from './LiveEvent'
import LongFormArticlePreview from './LongFormArticlePreview'
import Article from './Article'
+import SimpleContent from './SimpleContent'
import PublicationCard from './PublicationCard'
import WikiCard from './WikiCard'
import MutedNote from './MutedNote'
@@ -110,14 +111,6 @@ export default function Note({
) : (
)
- } else if (event.kind === ExtendedKind.WIKI_CHAPTER) {
- content = showFull ? (
-
- ) : (
-
-
Wiki Chapter (part of publication)
-
- )
} else if (event.kind === ExtendedKind.PUBLICATION) {
content = showFull ? (
@@ -159,7 +152,7 @@ export default function Note({
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content =
} else {
- content =
+ content =
}
return (
diff --git a/src/components/UniversalContent/TableOfContents.tsx b/src/components/UniversalContent/TableOfContents.tsx
deleted file mode 100644
index a3d4ef9..0000000
--- a/src/components/UniversalContent/TableOfContents.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-/**
- * Compact Table of Contents component for articles
- */
-
-import { useEffect, useState } from 'react'
-import { ChevronDown, ChevronRight, Hash } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
-
-interface TocItem {
- id: string
- text: string
- level: number
-}
-
-interface TableOfContentsProps {
- content: string
- className?: string
-}
-
-export default function TableOfContents({ content, className }: TableOfContentsProps) {
- const [isOpen, setIsOpen] = useState(false)
- const [tocItems, setTocItems] = useState([])
-
- useEffect(() => {
- // Parse content for headings
- const parser = new DOMParser()
- const doc = parser.parseFromString(content, 'text/html')
-
- const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6')
- const items: TocItem[] = []
-
- headings.forEach((heading, index) => {
- const level = parseInt(heading.tagName.charAt(1))
- const text = heading.textContent?.trim() || ''
-
- if (text) {
- // Use existing ID if available, otherwise generate one
- const existingId = heading.id
- const id = existingId || `heading-${index}-${text.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
-
- items.push({
- id,
- text,
- level
- })
- }
- })
-
- setTocItems(items)
- }, [content])
-
- if (tocItems.length === 0) {
- return null
- }
-
- const scrollToHeading = (item: TocItem) => {
- // Try to find the element in the actual DOM
- const element = document.getElementById(item.id)
- if (element) {
- element.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- inline: 'nearest'
- })
- setIsOpen(false)
- } else {
- // Fallback: try to find by text content
- const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6')
- for (const heading of headings) {
- if (heading.textContent?.trim() === item.text) {
- heading.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- inline: 'nearest'
- })
- setIsOpen(false)
- break
- }
- }
- }
- }
-
- return (
-
-
-
-
-
-
-
- {tocItems.map((item) => (
-
- ))}
-
-
-
-
- )
-}
diff --git a/src/index.css b/src/index.css
index ca18fa8..cd8d258 100644
--- a/src/index.css
+++ b/src/index.css
@@ -140,6 +140,197 @@
}
}
+/* AsciiDoc Table of Contents Styling */
+#toc {
+ @apply bg-muted/30 rounded-lg p-3 sm:p-4 mb-4 sm:mb-6;
+}
+
+#toc h2 {
+ @apply text-lg font-semibold mb-3 text-foreground border-b border-border pb-2;
+}
+
+#toc ul {
+ @apply space-y-1;
+}
+
+#toc li {
+ @apply list-none;
+}
+
+#toc a {
+ @apply text-sm text-muted-foreground hover:text-foreground transition-colors duration-200 block py-1 px-2 rounded hover:bg-primary/10 hover:border-l-2 hover:border-primary hover:pl-3;
+}
+
+#toc .sectlevel1 a {
+ @apply font-medium text-foreground;
+}
+
+#toc .sectlevel2 a {
+ @apply ml-4;
+}
+
+#toc .sectlevel3 a {
+ @apply ml-8;
+}
+
+#toc .sectlevel4 a {
+ @apply ml-12;
+}
+
+#toc .sectlevel5 a {
+ @apply ml-16;
+}
+
+#toc .sectlevel6 a {
+ @apply ml-20;
+}
+
+/* Hide the raw AsciiDoc ToC text when styled ToC is present */
+.asciidoc-toc-raw {
+ display: none;
+}
+
+/* AsciiDoc Section Headers Styling */
+.asciidoc-content h1,
+.asciidoc-content h2,
+.asciidoc-content h3,
+.asciidoc-content h4,
+.asciidoc-content h5,
+.asciidoc-content h6 {
+ @apply scroll-mt-24; /* Add padding when scrolling to headers - increased for better visibility */
+ scroll-behavior: smooth;
+ position: relative;
+}
+
+/* ToC return button styling */
+.asciidoc-content h1 .toc-return-btn,
+.asciidoc-content h2 .toc-return-btn,
+.asciidoc-content h3 .toc-return-btn,
+.asciidoc-content h4 .toc-return-btn,
+.asciidoc-content h5 .toc-return-btn,
+.asciidoc-content h6 .toc-return-btn {
+ @apply absolute right-0 top-1/2 -translate-y-1/2 opacity-0 transition-all duration-200 text-muted-foreground hover:text-foreground cursor-pointer;
+ font-size: 0.75rem;
+ line-height: 1;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.375rem;
+ background: hsl(var(--background) / 0.9);
+ backdrop-filter: blur(8px);
+ border: 1px solid hsl(var(--border));
+ white-space: nowrap;
+ z-index: 10;
+ box-shadow: 0 1px 3px hsl(var(--foreground) / 0.1);
+}
+
+.asciidoc-content h1:hover .toc-return-btn,
+.asciidoc-content h2:hover .toc-return-btn,
+.asciidoc-content h3:hover .toc-return-btn,
+.asciidoc-content h4:hover .toc-return-btn,
+.asciidoc-content h5:hover .toc-return-btn,
+.asciidoc-content h6:hover .toc-return-btn {
+ @apply opacity-100;
+ transform: translateY(-50%) scale(1.05);
+ background: hsl(var(--primary) / 0.1);
+ border-color: hsl(var(--primary) / 0.3);
+}
+
+/* Show button on mobile when header is tapped */
+@media (hover: none) {
+ .asciidoc-content h1:active .toc-return-btn,
+ .asciidoc-content h2:active .toc-return-btn,
+ .asciidoc-content h3:active .toc-return-btn,
+ .asciidoc-content h4:active .toc-return-btn,
+ .asciidoc-content h5:active .toc-return-btn,
+ .asciidoc-content h6:active .toc-return-btn {
+ @apply opacity-100;
+ }
+}
+
+/* AsciiDoc Footnotes Styling */
+.asciidoc-content .footnote {
+ @apply text-xs text-muted-foreground;
+ margin-bottom: 0.5rem; /* Add some spacing between footnotes */
+}
+
+.asciidoc-content .footnote a {
+ @apply text-primary hover:underline;
+}
+
+.asciidoc-content .footnote-backref {
+ @apply ml-1 text-primary hover:underline;
+}
+
+.asciidoc-content .footnoteref {
+ @apply text-primary hover:underline;
+ vertical-align: super;
+ font-size: 0.75em;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+/* Add scroll padding to footnote anchors and content elements */
+.asciidoc-content [id^="footnote-"],
+.asciidoc-content [id*="footnote"],
+.asciidoc-content [id*="_footnote"],
+.asciidoc-content [id*="_ref"],
+.asciidoc-content p,
+.asciidoc-content li {
+ scroll-margin-top: 8rem; /* Scroll padding for footnote anchors and content */
+}
+
+/* Also add scroll padding to any element that might be a footnote target */
+.asciidoc-content *[id] {
+ scroll-margin-top: 8rem;
+}
+
+.asciidoc-content .footnoteref:hover {
+ @apply underline;
+}
+
+.asciidoc-content .footnotes {
+ @apply mt-8 pt-4 border-t border-border;
+ scroll-margin-top: 4rem; /* Ensure footnotes section doesn't scroll out of view */
+}
+
+.asciidoc-content .footnotes ol {
+ @apply space-y-3; /* Increased spacing between footnote entries */
+}
+
+.asciidoc-content .footnotes li {
+ @apply text-sm text-muted-foreground;
+ display: block; /* Ensure each footnote is on its own line */
+ margin-bottom: 0.75rem; /* Additional spacing for better readability */
+}
+
+.asciidoc-content .footnotes li p {
+ @apply mb-2;
+ margin-top: 0; /* Remove top margin for cleaner look */
+}
+
+.asciidoc-content h1 {
+ @apply text-xl sm:text-2xl font-bold mt-6 sm:mt-8 mb-3 sm:mb-4 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
+}
+
+.asciidoc-content h2 {
+ @apply text-lg sm:text-xl font-semibold mt-5 sm:mt-6 mb-2 sm:mb-3 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
+}
+
+.asciidoc-content h3 {
+ @apply text-base sm:text-lg font-medium mt-4 sm:mt-5 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
+}
+
+.asciidoc-content h4 {
+ @apply text-sm sm:text-base font-medium mt-3 sm:mt-4 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
+}
+
+.asciidoc-content h5 {
+ @apply text-xs sm:text-sm font-medium mt-3 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
+}
+
+.asciidoc-content h6 {
+ @apply text-xs font-medium mt-3 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
+}
+
@keyframes progressFill {
0% {
width: 0%;
diff --git a/src/services/content-parser.service.ts b/src/services/content-parser.service.ts
index c220a8d..b510c95 100644
--- a/src/services/content-parser.service.ts
+++ b/src/services/content-parser.service.ts
@@ -4,9 +4,9 @@
*/
import { detectMarkupType, getMarkupClasses, MarkupType } from '@/lib/markup-detection'
-import { Event, nip19 } from 'nostr-tools'
+import { Event, kinds, nip19 } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event'
-import { URL_REGEX } from '@/constants'
+import { URL_REGEX, ExtendedKind } from '@/constants'
import { TImetaInfo } from '@/types'
export interface ParsedContent {
@@ -69,7 +69,13 @@ class ContentParserService {
const cssClasses = getMarkupClasses(markupType)
// Extract all content elements
- const media = this.extractAllMedia(content, event)
+ // For article-type events, don't extract media as it should be rendered inline
+ const isArticleType = eventKind === kinds.LongFormArticle ||
+ eventKind === ExtendedKind.WIKI_ARTICLE ||
+ eventKind === ExtendedKind.PUBLICATION ||
+ eventKind === ExtendedKind.PUBLICATION_CONTENT
+
+ const media = isArticleType ? [] : this.extractAllMedia(content, event)
const links = this.extractLinks(content)
const hashtags = this.extractHashtags(content)
const nostrLinks = this.extractNostrLinks(content)
@@ -121,6 +127,9 @@ class ContentParserService {
'showtitle': true,
'sectanchors': true,
'sectlinks': true,
+ 'toc': 'left',
+ 'toclevels': 6,
+ 'toc-title': 'Table of Contents',
'source-highlighter': options.enableSyntaxHighlighting ? 'highlight.js' : 'none',
'stem': options.enableMath ? 'latexmath' : 'none'
}
@@ -131,8 +140,11 @@ class ContentParserService {
// Process wikilinks in the HTML output
const processedHtml = this.processWikilinksInHtml(htmlString)
- // Clean up any leftover markdown syntax
- return this.cleanupMarkdown(processedHtml)
+ // Clean up any leftover markdown syntax and hide raw ToC text
+ const cleanedHtml = this.cleanupMarkdown(processedHtml)
+
+ // Hide any raw AsciiDoc ToC text that might appear in the content
+ return this.hideRawTocText(cleanedHtml)
} catch (error) {
console.error('AsciiDoc parsing error:', error)
return this.parsePlainText(content)
@@ -190,15 +202,63 @@ class ContentParserService {
})
asciidoc = asciidoc.replace(/`([^`]+)`/g, '`$1`') // Inline code
- // Convert blockquotes
- asciidoc = asciidoc.replace(/^>\s+(.+)$/gm, '____\n$1\n____')
+ // Convert blockquotes - handle multiline blockquotes properly with separate attribution
+ asciidoc = asciidoc.replace(/^(>\s+.+(?:\n>\s+.+)*)/gm, (match) => {
+ const lines = match.split('\n').map(line => line.replace(/^>\s*/, '')) // Remove '>' and optional space from each line
+
+ let quoteBodyLines: string[] = []
+ let attributionLine: string | undefined
+
+ // Find the last line that looks like an attribution (starts with '—' or '--')
+ for (let i = lines.length - 1; i >= 0; i--) {
+ const line = lines[i].trim()
+ if (line.startsWith('—') || line.startsWith('--')) {
+ attributionLine = line
+ quoteBodyLines = lines.slice(0, i) // Everything before the attribution is the quote body
+ break
+ }
+ }
+
+ const quoteContent = quoteBodyLines.filter(l => l.trim() !== '').join('\n').trim()
+
+ if (attributionLine) {
+ // Remove leading '—' or '--' from the attribution line
+ let cleanedAttribution = attributionLine.replace(/^[—-]+/, '').trim()
+
+ let author = ''
+ let source = ''
+
+ // Try to find a link:url[text] pattern (already converted from markdown links)
+ // Example: "George Bernard Shaw, link:https://www.goodreads.com/work/quotes/376394[Man and Superman]"
+ const linkMatch = cleanedAttribution.match(/^(.*?),?\s*link:([^[\\]]+)\[([^\\]]+)\]$/)
+
+ if (linkMatch) {
+ author = linkMatch[1].trim()
+ // Use the AsciiDoc link format directly in the source attribute
+ source = `link:${linkMatch[2].trim()}[${linkMatch[3].trim()}]`
+ } else {
+ // If no link, assume the whole thing is author or author, sourceText
+ const parts = cleanedAttribution.split(',').map(p => p.trim())
+ author = parts[0]
+ if (parts.length > 1) {
+ source = parts.slice(1).join(', ').trim()
+ }
+ }
+
+ // AsciiDoc blockquote with attribution: [quote, author, source]
+ return `[quote, ${author}, ${source}]\n____\n${quoteContent}\n____`
+ } else {
+ // If no attribution line is found, render as a regular AsciiDoc blockquote
+ return `____\n${quoteContent}\n____`
+ }
+ })
// Convert lists
asciidoc = asciidoc.replace(/^(\s*)\*\s+(.+)$/gm, '$1* $2') // Unordered lists
asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2') // Ordered lists
// Convert links
- asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1[$2]')
+ asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, 'link:$2[$1]')
// Convert images
asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, 'image::$2[$1]')
@@ -209,6 +269,26 @@ class ContentParserService {
// Convert horizontal rules
asciidoc = asciidoc.replace(/^---$/gm, '\'\'\'')
+ // Convert footnotes - handle both references and definitions for auto-numbering
+ const footnoteDefinitions: { [id: string]: string } = {}
+ let tempAsciidoc = asciidoc
+
+ // First, extract all footnote definitions and remove them from the content
+ // This regex captures [^id]: text including multi-line content
+ tempAsciidoc = tempAsciidoc.replace(/^\[\^([^\]]+)\]:\s*([\s\S]*?)(?=\n\[\^|\n---|\n##|\n###|\n####|\n#####|\n######|$)/gm, (_, id, text) => {
+ footnoteDefinitions[id] = text.trim()
+ return '' // Remove the definition line from the content
+ })
+
+ // Then, replace all footnote references [^id] with AsciiDoc's auto-numbered footnote syntax
+ // using the extracted definitions.
+ asciidoc = tempAsciidoc.replace(/\[\^([^\]]+)\]/g, (match, id) => {
+ if (footnoteDefinitions[id]) {
+ return `footnote:[${footnoteDefinitions[id]}]`
+ }
+ return match // If definition not found, leave as is
+ })
+
return asciidoc
}
@@ -701,6 +781,35 @@ class ContentParserService {
return ''
}
}
+
+ /**
+ * Hide raw AsciiDoc ToC text that might appear in the content
+ */
+ private hideRawTocText(html: string): string {
+ // Hide any raw ToC text that might be generated by AsciiDoc
+ // This includes patterns like "# Table of Contents (5)" and plain text lists
+ let cleaned = html
+
+ // Hide raw ToC headings and content
+ cleaned = cleaned.replace(
+ /]*>.*?Table of Contents.*?\(\d+\).*?<\/h[1-6]>/gi,
+ ''
+ )
+
+ // Hide raw ToC lists that might appear as plain text
+ cleaned = cleaned.replace(
+ /]*>.*?Table of Contents.*?\(\d+\).*?<\/p>/gi,
+ ''
+ )
+
+ // Hide any remaining raw ToC text patterns
+ cleaned = cleaned.replace(
+ /
]*>.*?Assumptions.*?\[n=0\].*?<\/p>/gi,
+ ''
+ )
+
+ return cleaned
+ }
}
// Export singleton instance