Skip to content

Commit a2a4083

Browse files
feat(drive): WYSIWYG-style Markdown editor with live preview for .md files (#6)
Editing a Markdown file now shows a formatting toolbar (bold, italic, strikethrough, code, headings, lists, checklist, quote, link) plus a live preview pane that re-renders as you type, à la Obsidian. Ctrl+B / Ctrl+I shortcuts and a side-by-side preview toggle included.
1 parent 33c3d26 commit a2a4083

2 files changed

Lines changed: 166 additions & 3 deletions

File tree

apps/web/src/components/TextDocument.tsx

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,31 @@
1313
* `onSave` (the Drive page re-uploads server files as a new version, or re-encrypts and
1414
* replaces Zero-Knowledge files).
1515
*/
16-
import { useMemo, useState } from 'react';
16+
import { useMemo, useRef, useState } from 'react';
1717
import Markdown from 'react-markdown';
1818
import remarkGfm from 'remark-gfm';
1919
import rehypeHighlight from 'rehype-highlight';
2020
import hljs from 'highlight.js';
21-
import { Eye, Code2, Pencil, Save, Loader2, Play } from 'lucide-react';
21+
import {
22+
Eye,
23+
Code2,
24+
Pencil,
25+
Save,
26+
Loader2,
27+
Play,
28+
Bold,
29+
Italic,
30+
Strikethrough,
31+
Code,
32+
Heading1,
33+
Heading2,
34+
List,
35+
ListOrdered,
36+
ListChecks,
37+
Quote,
38+
Link2,
39+
Columns2,
40+
} from 'lucide-react';
2241
import { codeLanguage, isHtml, isMarkdown } from '@/lib/fileType';
2342
import { useT } from '@/lib/i18n';
2443
import { confirm, toast } from '@/components/ui/overlays';
@@ -131,7 +150,9 @@ export function TextDocument({
131150

132151
{/* Body */}
133152
<div className="min-h-0 flex-1 overflow-auto">
134-
{editing ? (
153+
{editing && md ? (
154+
<MarkdownEditor value={text} onChange={setText} />
155+
) : editing ? (
135156
<textarea
136157
value={text}
137158
onChange={(e) => setText(e.target.value)}
@@ -164,6 +185,124 @@ export function TextDocument({
164185
);
165186
}
166187

188+
/**
189+
* Markdown editor: a source textarea with a formatting toolbar and a live preview beside it that
190+
* re-renders as you type — the "what you type is what you get" feel, without a heavy WYSIWYG engine.
191+
*/
192+
function MarkdownEditor({ value, onChange }: { value: string; onChange: (v: string) => void }) {
193+
const { t } = useT();
194+
const ref = useRef<HTMLTextAreaElement>(null);
195+
const [preview, setPreview] = useState(true);
196+
197+
/** Wrap the current selection with `before`/`after` (e.g. **bold**). */
198+
function surround(before: string, after = before) {
199+
const ta = ref.current;
200+
if (!ta) return;
201+
const s = ta.selectionStart;
202+
const e = ta.selectionEnd;
203+
const next = value.slice(0, s) + before + value.slice(s, e) + after + value.slice(e);
204+
onChange(next);
205+
requestAnimationFrame(() => {
206+
ta.focus();
207+
ta.selectionStart = s + before.length;
208+
ta.selectionEnd = e + before.length;
209+
});
210+
}
211+
/** Prefix every line touched by the selection (headings, lists, quotes). */
212+
function prefixLines(prefix: string) {
213+
const ta = ref.current;
214+
if (!ta) return;
215+
const s = ta.selectionStart;
216+
const e = ta.selectionEnd;
217+
const lineStart = value.lastIndexOf('\n', s - 1) + 1;
218+
const block = value.slice(lineStart, e);
219+
const prefixed = block.split('\n').map((l) => prefix + l).join('\n');
220+
const next = value.slice(0, lineStart) + prefixed + value.slice(e);
221+
onChange(next);
222+
requestAnimationFrame(() => {
223+
ta.focus();
224+
ta.selectionStart = lineStart;
225+
ta.selectionEnd = e + (prefixed.length - block.length);
226+
});
227+
}
228+
229+
const tools: { icon: typeof Bold; label: string; run: () => void }[] = [
230+
{ icon: Bold, label: t('mdedit.bold'), run: () => surround('**') },
231+
{ icon: Italic, label: t('mdedit.italic'), run: () => surround('*') },
232+
{ icon: Strikethrough, label: t('mdedit.strike'), run: () => surround('~~') },
233+
{ icon: Code, label: t('mdedit.code'), run: () => surround('`') },
234+
{ icon: Heading1, label: t('mdedit.h1'), run: () => prefixLines('# ') },
235+
{ icon: Heading2, label: t('mdedit.h2'), run: () => prefixLines('## ') },
236+
{ icon: List, label: t('mdedit.bullet'), run: () => prefixLines('- ') },
237+
{ icon: ListOrdered, label: t('mdedit.number'), run: () => prefixLines('1. ') },
238+
{ icon: ListChecks, label: t('mdedit.check'), run: () => prefixLines('- [ ] ') },
239+
{ icon: Quote, label: t('mdedit.quote'), run: () => prefixLines('> ') },
240+
{ icon: Link2, label: t('mdedit.link'), run: () => surround('[', '](https://)') },
241+
];
242+
243+
function onKeyDown(e: React.KeyboardEvent) {
244+
if (!(e.metaKey || e.ctrlKey)) return;
245+
const k = e.key.toLowerCase();
246+
if (k === 'b') {
247+
e.preventDefault();
248+
surround('**');
249+
} else if (k === 'i') {
250+
e.preventDefault();
251+
surround('*');
252+
}
253+
}
254+
255+
return (
256+
<div className="flex h-full flex-col">
257+
<div className="flex shrink-0 flex-wrap items-center gap-0.5 border-b border-white/10 px-2 py-1.5">
258+
{tools.map((tool) => (
259+
<button
260+
key={tool.label}
261+
type="button"
262+
title={tool.label}
263+
aria-label={tool.label}
264+
onClick={tool.run}
265+
className="rounded-md p-1.5 text-zinc-400 transition hover:bg-white/5 hover:text-zinc-100"
266+
>
267+
<tool.icon size={15} />
268+
</button>
269+
))}
270+
<div className="ml-auto">
271+
<button
272+
type="button"
273+
title={t('mdedit.togglePreview')}
274+
onClick={() => setPreview((p) => !p)}
275+
className={`rounded-md p-1.5 transition hover:bg-white/5 ${preview ? 'text-violet-300' : 'text-zinc-400 hover:text-zinc-100'}`}
276+
>
277+
<Columns2 size={15} />
278+
</button>
279+
</div>
280+
</div>
281+
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
282+
<textarea
283+
ref={ref}
284+
value={value}
285+
onChange={(e) => onChange(e.target.value)}
286+
onKeyDown={onKeyDown}
287+
spellCheck={false}
288+
className={`min-h-0 w-full flex-1 resize-none bg-transparent p-4 font-mono text-xs leading-relaxed text-zinc-200 outline-none ${preview ? 'md:w-1/2' : ''}`}
289+
/>
290+
{preview && (
291+
<div className="md-prose min-h-0 flex-1 overflow-auto border-t border-white/10 p-4 md:w-1/2 md:border-l md:border-t-0">
292+
{value.length < HIGHLIGHT_LIMIT ? (
293+
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
294+
{value}
295+
</Markdown>
296+
) : (
297+
<Markdown remarkPlugins={[remarkGfm]}>{value}</Markdown>
298+
)}
299+
</div>
300+
)}
301+
</div>
302+
</div>
303+
);
304+
}
305+
167306
/** Read-only, syntax-highlighted code/text block. */
168307
function CodeBlock({ name, text, highlight }: { name: string; text: string; highlight: boolean }) {
169308
const htmlOut = useMemo(() => {

apps/web/src/lib/i18n.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,18 @@ const fr: Dict = {
176176
'picker.moveHere': 'Déplacer ici',
177177
'picker.alreadyHere': 'Déjà à cet emplacement',
178178
'picker.dest': 'Destination : {name}',
179+
'mdedit.bold': 'Gras',
180+
'mdedit.italic': 'Italique',
181+
'mdedit.strike': 'Barré',
182+
'mdedit.code': 'Code',
183+
'mdedit.h1': 'Titre 1',
184+
'mdedit.h2': 'Titre 2',
185+
'mdedit.bullet': 'Liste à puces',
186+
'mdedit.number': 'Liste numérotée',
187+
'mdedit.check': 'Case à cocher',
188+
'mdedit.quote': 'Citation',
189+
'mdedit.link': 'Lien',
190+
'mdedit.togglePreview': 'Aperçu côte à côte',
179191
'drive.undone': 'Action annulée',
180192
'drive.redone': 'Action rétablie',
181193
'drive.scUndo': 'Annuler (déplacer / supprimer / renommer)',
@@ -815,6 +827,18 @@ const en: Dict = {
815827
'picker.moveHere': 'Move here',
816828
'picker.alreadyHere': 'Already here',
817829
'picker.dest': 'Destination: {name}',
830+
'mdedit.bold': 'Bold',
831+
'mdedit.italic': 'Italic',
832+
'mdedit.strike': 'Strikethrough',
833+
'mdedit.code': 'Code',
834+
'mdedit.h1': 'Heading 1',
835+
'mdedit.h2': 'Heading 2',
836+
'mdedit.bullet': 'Bulleted list',
837+
'mdedit.number': 'Numbered list',
838+
'mdedit.check': 'Checklist',
839+
'mdedit.quote': 'Quote',
840+
'mdedit.link': 'Link',
841+
'mdedit.togglePreview': 'Side-by-side preview',
818842
'drive.undone': 'Undone',
819843
'drive.redone': 'Redone',
820844
'drive.scUndo': 'Undo (move / delete / rename)',

0 commit comments

Comments
 (0)