|
13 | 13 | * `onSave` (the Drive page re-uploads server files as a new version, or re-encrypts and |
14 | 14 | * replaces Zero-Knowledge files). |
15 | 15 | */ |
16 | | -import { useMemo, useState } from 'react'; |
| 16 | +import { useMemo, useRef, useState } from 'react'; |
17 | 17 | import Markdown from 'react-markdown'; |
18 | 18 | import remarkGfm from 'remark-gfm'; |
19 | 19 | import rehypeHighlight from 'rehype-highlight'; |
20 | 20 | 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'; |
22 | 41 | import { codeLanguage, isHtml, isMarkdown } from '@/lib/fileType'; |
23 | 42 | import { useT } from '@/lib/i18n'; |
24 | 43 | import { confirm, toast } from '@/components/ui/overlays'; |
@@ -131,7 +150,9 @@ export function TextDocument({ |
131 | 150 |
|
132 | 151 | {/* Body */} |
133 | 152 | <div className="min-h-0 flex-1 overflow-auto"> |
134 | | - {editing ? ( |
| 153 | + {editing && md ? ( |
| 154 | + <MarkdownEditor value={text} onChange={setText} /> |
| 155 | + ) : editing ? ( |
135 | 156 | <textarea |
136 | 157 | value={text} |
137 | 158 | onChange={(e) => setText(e.target.value)} |
@@ -164,6 +185,124 @@ export function TextDocument({ |
164 | 185 | ); |
165 | 186 | } |
166 | 187 |
|
| 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 | + |
167 | 306 | /** Read-only, syntax-highlighted code/text block. */ |
168 | 307 | function CodeBlock({ name, text, highlight }: { name: string; text: string; highlight: boolean }) { |
169 | 308 | const htmlOut = useMemo(() => { |
|
0 commit comments