feat(markdown): add MarkdownReadView component with dark theme and checkbox toggling

This commit is contained in:
Hiren Kangad
2026-03-13 11:21:27 +05:30
parent 0d40b2b973
commit ced187bbd3
@@ -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 (
<div style={containerStyle}>
<div style={headerStyle}>
<span style={{ fontSize: '13px' }}>📄</span>
<span style={{ color: '#999', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</span>
<span style={{ color: '#555', fontSize: '9px', background: '#333', padding: '1px 6px', borderRadius: '3px' }}>MD</span>
</div>
<div style={bodyStyle}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={{
h1: ({ children }) => <h1 style={{ color: '#fff', fontSize: '20px', fontWeight: 700, margin: '0 0 10px 0', lineHeight: 1.3 }}>{children}</h1>,
h2: ({ children }) => <h2 style={{ color: '#fff', fontSize: '16px', fontWeight: 700, margin: '12px 0 8px 0', lineHeight: 1.3 }}>{children}</h2>,
h3: ({ children }) => <h3 style={{ color: '#fff', fontSize: '14px', fontWeight: 700, margin: '10px 0 6px 0', lineHeight: 1.3 }}>{children}</h3>,
p: ({ children }) => <p style={{ margin: '0 0 10px 0', color: textColor }}>{children}</p>,
strong: ({ children }) => <strong style={{ color: '#fff' }}>{children}</strong>,
em: ({ children }) => <em>{children}</em>,
del: ({ children }) => <del style={{ color: '#888' }}>{children}</del>,
blockquote: ({ children }) => (
<blockquote style={{ borderLeft: `3px solid ${accentColor}`, paddingLeft: '14px', margin: '10px 0', color: '#999', fontStyle: 'italic' }}>
{children}
</blockquote>
),
code: ({ className, children }) => {
const isBlock = className?.startsWith('language-');
if (isBlock) {
const lang = className?.replace('language-', '') ?? '';
return (
<div style={{ background: '#1a1a2e', border: '1px solid #333', borderRadius: '6px', margin: '10px 0', overflow: 'hidden' }}>
{lang && <div style={{ padding: '4px 14px', color: '#555', fontSize: '9px', borderBottom: '1px solid #333' }}>{lang}</div>}
<pre style={{ margin: 0, padding: '10px 14px', overflow: 'auto' }}>
<code style={{ fontFamily: "'Fira Code', Consolas, monospace", fontSize: '11px', color: '#b0b0b0' }}>{children}</code>
</pre>
</div>
);
}
return <code style={{ background: '#1a1a2e', padding: '1px 5px', borderRadius: '3px', fontFamily: "'Fira Code', Consolas, monospace", fontSize: '12px' }}>{children}</code>;
},
ul: ({ children }) => <ul style={{ margin: '0 0 10px 0', paddingLeft: '18px' }}>{children}</ul>,
ol: ({ children }) => <ol style={{ margin: '0 0 10px 0', paddingLeft: '18px' }}>{children}</ol>,
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 (
<li style={{ listStyle: 'none', marginLeft: '-18px', marginBottom: '4px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
onClick={(e) => { 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 && <span style={{ color: '#fff', fontSize: '10px' }}></span>}
</div>
<span style={checked ? { color: '#888', textDecoration: 'line-through' } : {}}>{children}</span>
</li>
);
}
return <li style={{ marginBottom: '4px' }}>{children}</li>;
},
table: ({ children }) => (
<table style={{ borderCollapse: 'collapse', width: '100%', margin: '10px 0', fontSize: '12px' }}>{children}</table>
),
th: ({ children }) => (
<th style={{ border: '1px solid #333', padding: '6px 10px', textAlign: 'left', background: '#2a2a42', color: '#ccc', fontWeight: 600 }}>{children}</th>
),
td: ({ children }) => (
<td style={{ border: '1px solid #333', padding: '6px 10px', color: textColor }}>{children}</td>
),
hr: () => <hr style={{ border: 'none', borderTop: '1px solid #333', margin: '14px 0' }} />,
a: ({ children, href }) => <a href={href} style={{ color: accentColor }} target="_blank" rel="noopener noreferrer">{children}</a>,
}}
>
{displayContent}
</ReactMarkdown>
</div>
</div>
);
}