feat(MarkdownEditor): enhance Markdown rendering with new plugins and components

- Integrated rehype-raw and rehype-slug for improved Markdown processing.
- Added remark-toc for automatic table of contents generation.
- Refactored Markdown components for better styling and functionality.
- Updated package.json to include new dependencies for enhanced Markdown features.
This commit is contained in:
Julien Froidefond
2025-10-24 09:49:56 +02:00
parent b60e74b1ff
commit f7f77a49dc
3 changed files with 467 additions and 353 deletions

View File

@@ -4,14 +4,274 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import remarkToc from 'remark-toc';
import rehypeRaw from 'rehype-raw';
import rehypeSlug from 'rehype-slug';
import rehypeSanitize from 'rehype-sanitize';
import { Highlight, themes } from 'prism-react-renderer';
import { Eye, EyeOff, Edit3, X, CheckSquare2 } from 'lucide-react';
import { TagInput } from '@/components/ui/TagInput';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { TaskSelectorWithData } from '@/components/shared/TaskSelectorWithData';
import { MermaidRenderer } from '@/components/ui/MermaidRenderer';
import { Tag, Task } from '@/lib/types';
import type { Components } from 'react-markdown';
// Fonction pour générer les composants Markdown réutilisables
const createMarkdownComponents = (
isPreviewMode: boolean
): Partial<Components> => ({
// Titres avec tailles différentes selon le mode
h1: ({ children }: { children?: React.ReactNode }) => (
<h1
className={
isPreviewMode
? 'text-4xl font-bold text-[var(--foreground)] mb-8 mt-10 first:mt-0 bg-gradient-to-r from-[var(--primary)] to-[var(--accent)] bg-clip-text text-transparent'
: 'text-4xl font-bold text-[var(--foreground)] mb-8 mt-10 first:mt-0 bg-gradient-to-r from-[var(--primary)] to-[var(--accent)] bg-clip-text text-transparent'
}
>
{children}
</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2
className={
isPreviewMode
? 'text-3xl font-bold text-[var(--foreground)] mb-6 mt-8 border-b border-[var(--border)]/30 pb-2'
: 'text-3xl font-bold text-[var(--foreground)] mb-6 mt-8 border-b border-[var(--border)]/30 pb-2'
}
>
{children}
</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3
className={
isPreviewMode
? 'text-2xl font-semibold text-[var(--foreground)] mb-4 mt-6'
: 'text-2xl font-semibold text-[var(--foreground)] mb-4 mt-6'
}
>
{children}
</h3>
),
h4: ({ children }: { children?: React.ReactNode }) => (
<h4
className={
isPreviewMode
? 'text-xl font-semibold text-[var(--foreground)] mb-3 mt-5'
: 'text-xl font-semibold text-[var(--foreground)] mb-3 mt-5'
}
>
{children}
</h4>
),
h5: ({ children }: { children?: React.ReactNode }) => (
<h5
className={
isPreviewMode
? 'text-lg font-semibold text-[var(--foreground)] mb-2 mt-4'
: 'text-lg font-semibold text-[var(--foreground)] mb-2 mt-4'
}
>
{children}
</h5>
),
h6: ({ children }: { children?: React.ReactNode }) => (
<h6
className={
isPreviewMode
? 'text-base font-semibold text-[var(--foreground)] mb-2 mt-3 text-[var(--muted-foreground)]'
: 'text-base font-semibold text-[var(--foreground)] mb-2 mt-3 text-[var(--muted-foreground)]'
}
>
{children}
</h6>
),
p: ({ children }: { children?: React.ReactNode }) => (
<p className="text-[var(--foreground)] mb-4 leading-relaxed">{children}</p>
),
// Listes avec le même style partout
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="mb-0 pl-4">{children}</ul>
),
ol: ({ children }: { children?: React.ReactNode }) => (
<ol className="mb-0 pl-4 list-decimal list-inside">{children}</ol>
),
li: ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
const isTaskListItem = className?.includes('task-list-item');
if (isTaskListItem) {
return (
<li className="!m-0 py-0.5 text-[var(--foreground)]">{children}</li>
);
}
return (
<li className="!m-0 py-0.5 text-[var(--foreground)] flex items-start gap-2">
<span className="w-1.5 h-1.5 bg-[var(--primary)] rounded-full mt-2 flex-shrink-0"></span>
<span>{children}</span>
</li>
);
},
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="border-l-4 border-[var(--primary)] pl-4 py-2 my-4 bg-[var(--card)]/20 backdrop-blur-sm rounded-r-lg">
<div className="text-[var(--muted-foreground)] italic">{children}</div>
</blockquote>
),
code: (({
inline,
className,
children,
...props
}: {
inline?: boolean;
className?: string;
children?: React.ReactNode;
} & Record<string, unknown>) => {
if (inline) {
return (
<code className="bg-[var(--card)]/60 px-2 py-1 rounded text-[var(--accent)] font-mono text-sm border border-[var(--border)]/40">
{children}
</code>
);
}
return (
<code className={className} {...props}>
{children}
</code>
);
}) as Components['code'],
pre: ({ children }: { children?: React.ReactNode }) => {
let codeElement: string | null = null;
let className = '';
if (React.isValidElement(children)) {
const props = children.props as {
children?: string;
className?: string;
};
codeElement = props?.children || null;
className = props?.className || '';
}
const isMermaid =
className.includes('language-mermaid') ||
(typeof codeElement === 'string' &&
(codeElement.trim().startsWith('graph') ||
codeElement.trim().startsWith('flowchart') ||
codeElement.trim().startsWith('sequenceDiagram') ||
codeElement.trim().startsWith('gantt') ||
codeElement.trim().startsWith('pie') ||
codeElement.trim().startsWith('gitgraph') ||
codeElement.trim().startsWith('journey') ||
codeElement.trim().startsWith('stateDiagram') ||
codeElement.trim().startsWith('classDiagram') ||
codeElement.trim().startsWith('erDiagram') ||
codeElement.trim().startsWith('mindmap') ||
codeElement.trim().startsWith('timeline')));
if (isMermaid && typeof codeElement === 'string') {
return (
<div className="my-6">
<MermaidRenderer chart={codeElement} />
</div>
);
}
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'javascript';
return (
<Highlight
theme={themes.vsDark}
code={codeElement || ''}
language={language}
>
{({
className: highlightClassName,
style,
tokens,
getLineProps,
getTokenProps,
}) => {
// Supprimer les lignes vides à la fin
const filteredTokens = tokens.filter((line, i) => {
// Garder toutes les lignes sauf la dernière si elle est vide
if (i === tokens.length - 1) {
return (
line.length > 0 &&
line.some((token) => token.content.trim() !== '')
);
}
return true;
});
return (
<pre
className="bg-[var(--card)]/60 border border-[var(--border)]/60 rounded-lg p-4 overflow-x-auto backdrop-blur-sm font-mono text-sm mb-6"
style={{
...style,
background: 'color-mix(in srgb, var(--card) 80%, transparent)',
}}
>
<code className={highlightClassName}>
{filteredTokens.map((line, i) => (
<div key={i} {...getLineProps({ line })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</code>
</pre>
);
}}
</Highlight>
);
},
a: ({ children, href }: { children?: React.ReactNode; href?: string }) => (
<a
href={href}
className="text-[var(--primary)] hover:text-[var(--primary)]/80 underline decoration-[var(--primary)]/50 hover:decoration-[var(--primary)] transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
table: ({ children }: { children?: React.ReactNode }) => (
<div className="overflow-x-auto my-6">
<table className="min-w-full border border-[var(--border)]/60 rounded-lg overflow-hidden">
{children}
</table>
</div>
),
thead: ({ children }: { children?: React.ReactNode }) => (
<thead className="bg-[var(--card)]/40">{children}</thead>
),
tbody: ({ children }: { children?: React.ReactNode }) => (
<tbody className="divide-y divide-[var(--border)]/60">{children}</tbody>
),
tr: ({ children }: { children?: React.ReactNode }) => (
<tr className="hover:bg-[var(--card)]/20 transition-colors">{children}</tr>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th className="px-4 py-2 text-left text-[var(--foreground)] font-semibold border-b border-[var(--border)]/60">
{children}
</th>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td className="px-4 py-2 text-[var(--foreground)]">{children}</td>
),
hr: () => <hr className="my-8 border-t-2 border-[var(--border)]/30" />,
});
interface MarkdownEditorProps {
value: string;
@@ -444,161 +704,18 @@ export function MarkdownEditor({
<div className="flex-1 overflow-auto p-6 bg-[var(--background)]/50 backdrop-blur-sm">
<div className="prose prose-sm max-w-none prose-headings:text-[var(--foreground)] prose-p:text-[var(--foreground)] prose-strong:text-[var(--foreground)] prose-strong:font-bold prose-em:text-[var(--muted-foreground)] prose-code:text-[var(--accent)] prose-pre:bg-[var(--card)]/60 prose-pre:border prose-pre:border-[var(--border)]/60 prose-blockquote:border-[var(--primary)] prose-blockquote:text-[var(--muted-foreground)] prose-a:text-[var(--primary)] prose-table:border-[var(--border)]/60 prose-th:bg-[var(--card)]/40 prose-th:text-[var(--foreground)] prose-td:text-[var(--foreground)]">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeSanitize]}
components={{
// Custom styling for better integration
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-[var(--foreground)] mb-6 mt-8 first:mt-0 bg-gradient-to-r from-[var(--primary)] to-[var(--accent)] bg-clip-text text-transparent">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-[var(--foreground)] mb-4 mt-6 border-b border-[var(--border)]/30 pb-2">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-[var(--foreground)] mb-3 mt-5 flex items-center gap-2">
<span className="w-2 h-2 bg-[var(--primary)] rounded-full"></span>
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-[var(--foreground)] mb-2 mt-4">
{children}
</h4>
),
h5: ({ children }) => (
<h5 className="text-base font-semibold text-[var(--foreground)] mb-2 mt-3">
{children}
</h5>
),
h6: ({ children }) => (
<h6 className="text-sm font-semibold text-[var(--foreground)] mb-2 mt-3 text-[var(--muted-foreground)]">
{children}
</h6>
),
p: ({ children }) => (
<p className="text-[var(--foreground)] mb-4 leading-relaxed">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 space-y-2">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-4 space-y-2 list-decimal list-inside">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-[var(--foreground)] flex items-start gap-2">
<span className="w-1.5 h-1.5 bg-[var(--primary)] rounded-full mt-2 flex-shrink-0"></span>
<span>{children}</span>
</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-[var(--primary)] pl-4 py-2 my-4 bg-[var(--card)]/20 backdrop-blur-sm rounded-r-lg">
<div className="text-[var(--muted-foreground)] italic">
{children}
</div>
</blockquote>
),
code: ({ children, className }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-[var(--card)]/60 px-2 py-1 rounded text-[var(--accent)] font-mono text-sm border border-[var(--border)]/40">
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
pre: ({ children }) => {
// Check if this is a mermaid code block
let codeElement: string | null = null;
let className = '';
if (React.isValidElement(children)) {
const props = children.props as {
children?: string;
className?: string;
};
codeElement = props?.children || null;
className = props?.className || '';
}
const isMermaid =
className.includes('language-mermaid') ||
(typeof codeElement === 'string' &&
(codeElement.trim().startsWith('graph') ||
codeElement.trim().startsWith('flowchart') ||
codeElement.trim().startsWith('sequenceDiagram') ||
codeElement.trim().startsWith('gantt') ||
codeElement.trim().startsWith('pie') ||
codeElement.trim().startsWith('gitgraph') ||
codeElement.trim().startsWith('journey') ||
codeElement.trim().startsWith('stateDiagram') ||
codeElement.trim().startsWith('classDiagram') ||
codeElement.trim().startsWith('erDiagram') ||
codeElement.trim().startsWith('mindmap') ||
codeElement.trim().startsWith('timeline')));
if (isMermaid && typeof codeElement === 'string') {
return (
<div className="my-6">
<MermaidRenderer chart={codeElement} />
</div>
);
}
return (
<pre className="bg-[var(--card)]/60 border border-[var(--border)]/60 rounded-lg p-4 overflow-x-auto backdrop-blur-sm">
{children}
</pre>
);
},
a: ({ children, href }) => (
<a
href={href}
className="text-[var(--primary)] hover:text-[var(--primary)]/80 underline decoration-[var(--primary)]/50 hover:decoration-[var(--primary)] transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
table: ({ children }) => (
<div className="overflow-x-auto mb-6 rounded-lg border border-[var(--border)]/60">
<table className="min-w-full">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border-b border-[var(--border)]/60 bg-[var(--card)]/40 px-4 py-3 text-left font-semibold text-[var(--foreground)] backdrop-blur-sm">
{children}
</th>
),
td: ({ children }) => (
<td className="border-b border-[var(--border)]/30 px-4 py-3 text-[var(--foreground)]">
{children}
</td>
),
hr: () => (
<hr className="my-8 border-0 h-px bg-gradient-to-r from-transparent via-[var(--border)] to-transparent" />
),
strong: ({ children }) => (
<strong className="font-bold text-[var(--foreground)]">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-[var(--muted-foreground)]">
{children}
</em>
),
}}
remarkPlugins={[
remarkGfm,
[
remarkToc,
{
heading: '(table[ -]of[ -])?contents?|toc|sommaire',
tight: true,
},
],
]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeSanitize]}
components={createMarkdownComponents(true)}
>
{value || "*Commencez à écrire pour voir l'aperçu...*"}
</ReactMarkdown>
@@ -670,163 +787,18 @@ export function MarkdownEditor({
<div className="flex-1 overflow-auto p-6 bg-[var(--background)]/50 backdrop-blur-sm">
<div className="prose prose-sm max-w-none prose-headings:text-[var(--foreground)] prose-p:text-[var(--foreground)] prose-strong:text-[var(--foreground)] prose-strong:font-bold prose-em:text-[var(--muted-foreground)] prose-code:text-[var(--accent)] prose-pre:bg-[var(--card)]/60 prose-pre:border prose-pre:border-[var(--border)]/60 prose-blockquote:border-[var(--primary)] prose-blockquote:text-[var(--muted-foreground)] prose-a:text-[var(--primary)] prose-table:border-[var(--border)]/60 prose-th:bg-[var(--card)]/40 prose-th:text-[var(--foreground)] prose-td:text-[var(--foreground)]">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeSanitize]}
components={{
// Custom styling for better integration
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-[var(--foreground)] mb-6 mt-8 first:mt-0 bg-gradient-to-r from-[var(--primary)] to-[var(--accent)] bg-clip-text text-transparent">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-[var(--foreground)] mb-4 mt-6 border-b border-[var(--border)]/30 pb-2">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-[var(--foreground)] mb-3 mt-5 flex items-center gap-2">
<span className="w-2 h-2 bg-[var(--primary)] rounded-full"></span>
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-[var(--foreground)] mb-2 mt-4">
{children}
</h4>
),
h5: ({ children }) => (
<h5 className="text-base font-semibold text-[var(--foreground)] mb-2 mt-3">
{children}
</h5>
),
h6: ({ children }) => (
<h6 className="text-sm font-semibold text-[var(--foreground)] mb-2 mt-3 text-[var(--muted-foreground)]">
{children}
</h6>
),
p: ({ children }) => (
<p className="text-[var(--foreground)] mb-4 leading-relaxed">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 space-y-2">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-4 space-y-2 list-decimal list-inside">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-[var(--foreground)] flex items-start gap-2">
<span className="w-1.5 h-1.5 bg-[var(--primary)] rounded-full mt-2 flex-shrink-0"></span>
<span>{children}</span>
</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-[var(--primary)] pl-4 py-2 my-4 bg-[var(--card)]/20 backdrop-blur-sm rounded-r-lg">
<div className="text-[var(--muted-foreground)] italic">
{children}
</div>
</blockquote>
),
code: ({ children, className }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-[var(--card)]/60 px-2 py-1 rounded text-[var(--accent)] font-mono text-sm border border-[var(--border)]/40">
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
pre: ({ children }) => {
// Check if this is a mermaid code block
let codeElement: string | null = null;
let className = '';
if (React.isValidElement(children)) {
const props = children.props as {
children?: string;
className?: string;
};
codeElement = props?.children || null;
className = props?.className || '';
}
const isMermaid =
className.includes('language-mermaid') ||
(typeof codeElement === 'string' &&
(codeElement.trim().startsWith('graph') ||
codeElement.trim().startsWith('flowchart') ||
codeElement
.trim()
.startsWith('sequenceDiagram') ||
codeElement.trim().startsWith('gantt') ||
codeElement.trim().startsWith('pie') ||
codeElement.trim().startsWith('gitgraph') ||
codeElement.trim().startsWith('journey') ||
codeElement.trim().startsWith('stateDiagram') ||
codeElement.trim().startsWith('classDiagram') ||
codeElement.trim().startsWith('erDiagram') ||
codeElement.trim().startsWith('mindmap') ||
codeElement.trim().startsWith('timeline')));
if (isMermaid && typeof codeElement === 'string') {
return (
<div className="my-6">
<MermaidRenderer chart={codeElement} />
</div>
);
}
return (
<pre className="bg-[var(--card)]/60 border border-[var(--border)]/60 rounded-lg p-4 overflow-x-auto backdrop-blur-sm">
{children}
</pre>
);
},
a: ({ children, href }) => (
<a
href={href}
className="text-[var(--primary)] hover:text-[var(--primary)]/80 underline decoration-[var(--primary)]/50 hover:decoration-[var(--primary)] transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
table: ({ children }) => (
<div className="overflow-x-auto mb-6 rounded-lg border border-[var(--border)]/60">
<table className="min-w-full">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border-b border-[var(--border)]/60 bg-[var(--card)]/40 px-4 py-3 text-left font-semibold text-[var(--foreground)] backdrop-blur-sm">
{children}
</th>
),
td: ({ children }) => (
<td className="border-b border-[var(--border)]/30 px-4 py-3 text-[var(--foreground)]">
{children}
</td>
),
hr: () => (
<hr className="my-8 border-0 h-px bg-gradient-to-r from-transparent via-[var(--border)] to-transparent" />
),
strong: ({ children }) => (
<strong className="font-bold text-[var(--foreground)]">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-[var(--muted-foreground)]">
{children}
</em>
),
}}
remarkPlugins={[
remarkGfm,
[
remarkToc,
{
heading: '(table[ -]of[ -])?contents?|toc|sommaire',
tight: true,
},
],
]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeSanitize]}
components={createMarkdownComponents(false)}
>
{value || "*Commencez à écrire pour voir l'aperçu...*"}
</ReactMarkdown>