1import { type ReactNode, useDeferredValue, useId, useState } from "react";
2import ReactMarkdown from "react-markdown";
3import { Streamdown } from "streamdown";
4import { Markdown } from "../src/Markdown.ts";
5import { components, reactMarkdownComponents, streamdownComponents } from "./markdown-components";
6import { initialMarkdown, processor, reactMarkdownRemarkPlugins } from "./markdown-demo";
7
8type Renderer = "memo" | "streamdown" | "react-markdown";
9
10const rendererOptions: Array<{ label: string; value: Renderer }> = [
11 {
12 label: "@clo/react-markdown",
13 value: "memo",
14 },
15 {
16 label: "streamdown",
17 value: "streamdown",
18 },
19 {
20 label: "react-markdown",
21 value: "react-markdown",
22 },
23];
24
25type ToggleSwitchProps = {
26 checked: boolean;
27 label: string;
28 onChange: (checked: boolean) => void;
29};
30
31function ToggleSwitch({ checked, label, onChange }: ToggleSwitchProps) {
32 const id = useId();
33
34 return (
35 <label
36 className="inline-flex items-center gap-3 text-xs font-medium text-stone-600"
37 htmlFor={id}
38 >
39 <span>{label}</span>
40 <span className="relative inline-flex h-6 w-11 shrink-0">
41 <input
42 checked={checked}
43 className="peer sr-only"
44 id={id}
45 onChange={(event) => onChange(event.target.checked)}
46 type="checkbox"
47 />
48 <span className="absolute inset-0 rounded-full bg-stone-300 transition peer-checked:bg-emerald-500 peer-focus-visible:ring-2 peer-focus-visible:ring-emerald-200 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white" />
49 <span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition peer-checked:translate-x-5" />
50 </span>
51 </label>
52 );
53}
54
55function getRendererButtonClass(isActive: boolean) {
56 return isActive
57 ? "rounded bg-white px-2.5 py-1 text-xs font-medium text-stone-900 shadow-sm"
58 : "rounded px-2.5 py-1 text-xs text-stone-600 hover:text-stone-900";
59}
60
61type PreviewPanelProps = {
62 children: ReactNode;
63 header: ReactNode;
64 toolbar: ReactNode;
65};
66
67function PreviewPanel({ children, header, toolbar }: PreviewPanelProps) {
68 return (
69 <article className="flex min-h-[18rem] flex-col overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm xl:min-h-0">
70 <header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-stone-200 px-4 py-3">
71 {header}
72 <div className="flex min-h-6 items-center justify-end">{toolbar}</div>
73 </header>
74 <div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
75 </article>
76 );
77}
78
79export function App() {
80 const [content, setContent] = useState(initialMarkdown);
81 const deferredContent = useDeferredValue(content);
82 const [predictionEnabled, setPredictionEnabled] = useState(true);
83 const [renderer, setRenderer] = useState<Renderer>("memo");
84
85 const previewToolbar =
86 renderer === "memo" ? (
87 <ToggleSwitch
88 checked={predictionEnabled}
89 label="Prediction"
90 onChange={setPredictionEnabled}
91 />
92 ) : renderer === "streamdown" ? (
93 <ToggleSwitch
94 checked={predictionEnabled}
95 label="Unterminated Block Parsing"
96 onChange={setPredictionEnabled}
97 />
98 ) : (
99 <span className="rounded-full border border-stone-200 bg-stone-50 px-2.5 py-1 text-xs font-medium text-stone-500">
100 No prediction support
101 </span>
102 );
103
104 return (
105 <main className="flex h-dvh w-full flex-col overflow-hidden bg-stone-100">
106 <section className="grid min-h-0 flex-1 gap-4 overflow-auto p-4 xl:grid-cols-[minmax(22rem,30rem)_minmax(0,1fr)]">
107 <section className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm xl:min-h-0">
108 <div className="flex shrink-0 items-center justify-end border-b border-stone-200 px-4 py-3">
109 <button
110 className="rounded border border-stone-300 px-2.5 py-1 text-xs text-stone-700 hover:bg-stone-50"
111 onClick={() => setContent(initialMarkdown)}
112 type="button"
113 >
114 Reset
115 </button>
116 </div>
117 <label className="block min-h-0 flex-1">
118 <span className="sr-only">Markdown source</span>
119 <textarea
120 className="h-full min-h-0 w-full resize-none overflow-auto bg-transparent p-4 font-mono text-sm leading-6 text-stone-900 outline-none"
121 onChange={(event) => setContent(event.target.value)}
122 placeholder="Write markdown here..."
123 spellCheck={false}
124 value={content}
125 />
126 </label>
127 </section>
128
129 <PreviewPanel
130 header={
131 <div
132 aria-label="Preview renderer"
133 className="inline-flex rounded-md border border-stone-300 bg-stone-50 p-0.5"
134 role="tablist"
135 >
136 {rendererOptions.map(({ label, value }) => (
137 <button
138 key={value}
139 aria-selected={renderer === value}
140 className={getRendererButtonClass(renderer === value)}
141 onClick={() => setRenderer(value)}
142 role="tab"
143 type="button"
144 >
145 {label}
146 </button>
147 ))}
148 </div>
149 }
150 toolbar={previewToolbar}
151 >
152 {renderer === "memo" ? (
153 <div className="preview-prose">
154 <Markdown
155 components={components}
156 content={deferredContent}
157 predict={predictionEnabled}
158 processor={processor}
159 />
160 </div>
161 ) : renderer === "streamdown" ? (
162 <Streamdown
163 className="preview-prose"
164 components={streamdownComponents}
165 parseIncompleteMarkdown={predictionEnabled}
166 >
167 {deferredContent}
168 </Streamdown>
169 ) : (
170 <div className="preview-prose">
171 <ReactMarkdown
172 components={reactMarkdownComponents}
173 remarkPlugins={reactMarkdownRemarkPlugins}
174 >
175 {deferredContent}
176 </ReactMarkdown>
177 </div>
178 )}
179 </PreviewPanel>
180 </section>
181 </main>
182 );
183}