feat(markdown): add MarkdownReadView component with dark theme and checkbox toggling
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user