/** * T078: Note viewer with rendered markdown, metadata, and backlinks * T081-T082: Wikilink click handling and broken link styling * T009: Font size buttons (A-, A, A+) for content adjustment * T037-T052: Table of Contents panel integration */ import { useMemo, useEffect } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Edit, Trash2, Calendar, Tag as TagIcon, ArrowLeft, Volume2, Pause, Play, Square, Type, List } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { Slider } from '@/components/ui/slider'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; import type { Note } from '@/types/note'; import type { BacklinkResult } from '@/services/api'; import { createWikilinkComponent, resetSlugCache } from '@/lib/markdown.tsx'; import { markdownToPlainText } from '@/lib/markdownToText'; import { useTableOfContents } from '@/hooks/useTableOfContents'; import { TableOfContents } from '@/components/TableOfContents'; type FontSizePreset = 'small' | 'medium' | 'large'; interface NoteViewerProps { note: Note; backlinks: BacklinkResult[]; onEdit?: () => void; onDelete?: () => void; onWikilinkClick: (linkText: string) => void; ttsStatus?: 'idle' | 'loading' | 'playing' | 'paused' | 'error'; onTtsToggle?: () => void; onTtsStop?: () => void; ttsDisabledReason?: string; ttsVolume?: number; onTtsVolumeChange?: (volume: number) => void; fontSize?: FontSizePreset; onFontSizeChange?: (size: FontSizePreset) => void; } export function NoteViewer({ note, backlinks, onEdit, onDelete, onWikilinkClick, ttsStatus = 'idle', onTtsToggle, onTtsStop, ttsDisabledReason, ttsVolume = 0.7, onTtsVolumeChange, fontSize = 'medium', onFontSizeChange, }: NoteViewerProps) { // T042-T047: Table of Contents hook const { headings, isOpen: isTocOpen, setIsOpen: setIsTocOpen, scrollToHeading } = useTableOfContents(); // Reset slug cache when note changes to ensure unique IDs useEffect(() => { resetSlugCache(); }, [note.note_path]); // Create custom markdown components with wikilink handler const markdownComponents = useMemo( () => createWikilinkComponent(onWikilinkClick), [onWikilinkClick] ); // Pre-process markdown to convert wikilinks to standard links // [[Link]] -> [Link](wikilink:Link) // [[Link|Alias]] -> [Alias](wikilink:Link) const processedBody = useMemo(() => { if (!note.body) return ''; const processed = note.body.replace(/\[\[([^\]]+)\]\]/g, (_match, content) => { const [link, alias] = content.split('|'); const displayText = alias || link; const href = `wikilink:${encodeURIComponent(link)}`; return `[${displayText}](${href})`; }); // console.log('Processed Body:', processed); return processed; }, [note.body]); // T031-T035: Calculate reading time from note body const readingTime = useMemo(() => { const plainText = markdownToPlainText(note.body); // T032: Extract word count const wordCount = plainText.trim().split(/\s+/).length; // T033: Calculate minutes at 200 WPM const minutes = Math.ceil(wordCount / 200); // T034: Return null if <1 minute (200 words threshold) return minutes >= 1 ? `${minutes} min read` : null; }, [note.body]); const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); }; return (
{note.note_path}