diff --git a/frontend/src/components/MarkdownReadView.tsx b/frontend/src/components/MarkdownReadView.tsx new file mode 100644 index 0000000..23b2745 --- /dev/null +++ b/frontend/src/components/MarkdownReadView.tsx @@ -0,0 +1,201 @@ +/** + * MarkdownReadView — renders markdown content in read mode using + * react-markdown + remark-gfm. Handles dark-theme styling and + * clickable task checkboxes. + */ + +import React, { useCallback } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeSanitize from 'rehype-sanitize'; +import type { Element } from 'hast'; +import { + MD_HEADER_BG, + MD_MAX_CONTENT_LENGTH, + extractTitle, +} from '../canvas/markdownDefaults'; + +interface MarkdownReadViewProps { + content: string; + textColor: string; + accentColor: string; + bgColor: string; + padding: number; + onCheckboxToggle?: (newContent: string) => void; +} + +/** Check if the Nth checkbox (outside fenced code blocks) is checked. */ +function isCheckboxChecked(content: string, index: number): boolean { + let count = 0; + let inCodeBlock = false; + for (const line of content.split('\n')) { + if (line.trimStart().startsWith('```')) { inCodeBlock = !inCodeBlock; continue; } + if (inCodeBlock) continue; + if (/^\s*-\s*\[[ xX]\]/.test(line)) { + if (count === index) return /^\s*-\s*\[[xX]\]/.test(line); + count++; + } + } + return false; +} + +/** Toggle the Nth checkbox (outside fenced code blocks) in a markdown string. */ +function toggleCheckbox(content: string, index: number): string { + let count = 0; + let inCodeBlock = false; + + return content.split('\n').map((line) => { + if (line.trimStart().startsWith('```')) { + inCodeBlock = !inCodeBlock; + return line; + } + if (inCodeBlock) return line; + + const unchecked = /^(\s*-\s*)\[ \]/; + const checked = /^(\s*-\s*)\[x\]/i; + + if (unchecked.test(line) || checked.test(line)) { + if (count === index) { + count++; + if (unchecked.test(line)) { + return line.replace(unchecked, '$1[x]'); + } else { + return line.replace(checked, '$1[ ]'); + } + } + count++; + } + return line; + }).join('\n'); +} + +export default function MarkdownReadView(props: MarkdownReadViewProps) { + const { content, textColor, accentColor, bgColor: _bgColor, padding, onCheckboxToggle } = props; + + const displayContent = content.length > MD_MAX_CONTENT_LENGTH + ? content.slice(0, MD_MAX_CONTENT_LENGTH) + '\n\n---\n*Content truncated*' + : content; + + const title = extractTitle(content); + + const checkboxIndexRef = React.useRef(0); + checkboxIndexRef.current = 0; + + const handleCheckboxClick = useCallback((idx: number) => { + if (!onCheckboxToggle) return; + const newContent = toggleCheckbox(content, idx); + onCheckboxToggle(newContent); + }, [content, onCheckboxToggle]); + + const containerStyle: React.CSSProperties = { + color: textColor, + fontFamily: 'Inter, system-ui, sans-serif', + fontSize: '13px', + lineHeight: '1.6', + }; + + const headerStyle: React.CSSProperties = { + padding: '7px 14px', + background: MD_HEADER_BG, + borderBottom: '1px solid #333', + display: 'flex', + alignItems: 'center', + gap: '7px', + fontSize: '11px', + }; + + const bodyStyle: React.CSSProperties = { + padding: `${padding}px`, + }; + + return ( +
+
+ 📄 + {title} + MD +
+
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + del: ({ children }) => {children}, + blockquote: ({ children }) => ( +
+ {children} +
+ ), + code: ({ className, children }) => { + const isBlock = className?.startsWith('language-'); + if (isBlock) { + const lang = className?.replace('language-', '') ?? ''; + return ( +
+ {lang &&
{lang}
} +
+                      {children}
+                    
+
+ ); + } + return {children}; + }, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children, node }) => { + const hastNode = node as Element | undefined; + const classList = hastNode?.properties?.className; + const isTask = Array.isArray(classList) + ? classList.includes('task-list-item') + : typeof classList === 'string' && classList.includes('task-list-item'); + + if (isTask) { + const idx = checkboxIndexRef.current++; + const checked = isCheckboxChecked(content, idx); + return ( +
  • +
    { e.stopPropagation(); handleCheckboxClick(idx); }} + style={{ + width: '14px', height: '14px', borderRadius: '3px', cursor: 'pointer', flexShrink: 0, + pointerEvents: 'auto', + ...(checked + ? { background: accentColor, display: 'flex', alignItems: 'center', justifyContent: 'center' } + : { border: '1.5px solid #555' }), + }} + > + {checked && } +
    + {children} +
  • + ); + } + + return
  • {children}
  • ; + }, + table: ({ children }) => ( + {children}
    + ), + th: ({ children }) => ( + {children} + ), + td: ({ children }) => ( + {children} + ), + hr: () =>
    , + a: ({ children, href }) => {children}, + }} + > + {displayContent} +
    +
    +
    + ); +}