Add WYSIWYG Markdown editor (EasyMDE) to wiki pages
Deploy to LXC / deploy (push) Successful in 21s

Reusable MarkdownEditor component using EasyMDE:
- Toolbar: bold, italic, headings, quotes, lists, links, images,
  tables, code blocks, preview, side-by-side, fullscreen
- Dark mode support with custom CSS overrides
- Value syncs via bind:value for form submission
- Applied to both wiki new and edit pages
- Replaces the plain textarea + manual preview toggle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 16:03:37 +07:00
parent f937394b5e
commit bc73595018
5 changed files with 189 additions and 46 deletions
+71 -1
View File
@@ -16,6 +16,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.4.1", "dotenv": "^17.4.1",
"drizzle-orm": "^0.38.4", "drizzle-orm": "^0.38.4",
"easymde": "^2.20.0",
"marked": "^18.0.0", "marked": "^18.0.0",
"pg": "^8.13.1", "pg": "^8.13.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -2526,6 +2527,15 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/codemirror": {
"version": "5.60.17",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.17.tgz",
"integrity": "sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw==",
"license": "MIT",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -2537,7 +2547,12 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true, "license": "MIT"
},
"node_modules/@types/marked": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.2.tgz",
"integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
@@ -2579,6 +2594,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2719,6 +2743,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codemirror": {
"version": "5.65.21",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.21.tgz",
"integrity": "sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ==",
"license": "MIT"
},
"node_modules/codemirror-spell-checker": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz",
"integrity": "sha512-2Tl6n0v+GJRsC9K3MLCdLaMOmvWL0uukajNJseorZJsslaxZyZMgENocPU8R0DyoTAiKsyqiemSOZo7kjGV0LQ==",
"license": "MIT",
"dependencies": {
"typo-js": "*"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -3012,6 +3051,31 @@
} }
} }
}, },
"node_modules/easymde": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.20.0.tgz",
"integrity": "sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ==",
"license": "MIT",
"dependencies": {
"@types/codemirror": "^5.60.10",
"@types/marked": "^4.0.7",
"codemirror": "^5.65.15",
"codemirror-spell-checker": "1.1.2",
"marked": "^4.1.0"
}
},
"node_modules/easymde/node_modules/marked": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -4867,6 +4931,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/typo-js": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.3.1.tgz",
"integrity": "sha512-elJkpCL6Z77Ghw0Lv0lGnhBAjSTOQ5FhiVOCfOuxhaoTT2xtLVbqikYItK5HHchzPbHEUFAcjOH669T2ZzeCbg==",
"license": "BSD-3-Clause"
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+1
View File
@@ -23,6 +23,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.4.1", "dotenv": "^17.4.1",
"drizzle-orm": "^0.38.4", "drizzle-orm": "^0.38.4",
"easymde": "^2.20.0",
"marked": "^18.0.0", "marked": "^18.0.0",
"pg": "^8.13.1", "pg": "^8.13.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
interface Props {
name?: string;
value?: string;
placeholder?: string;
}
let { name = 'content', value = $bindable(''), placeholder = 'Write in Markdown...' }: Props = $props();
let textareaEl: HTMLTextAreaElement;
let editor: any = null;
onMount(async () => {
if (!browser) return;
const EasyMDE = (await import('easymde')).default;
await import('easymde/dist/easymde.min.css');
editor = new EasyMDE({
element: textareaEl,
initialValue: value,
spellChecker: false,
autofocus: false,
placeholder,
status: false,
minHeight: '300px',
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', 'image', 'table', 'code', '|',
'preview', 'side-by-side', 'fullscreen', '|',
'guide'
],
sideBySideFullscreen: false
});
editor.codemirror.on('change', () => {
value = editor.value();
});
});
onDestroy(() => {
if (editor) {
editor.toTextArea();
editor = null;
}
});
</script>
<div class="markdown-editor">
<textarea bind:this={textareaEl} id={name}></textarea>
<input type="hidden" {name} value={value} />
</div>
<style>
.markdown-editor :global(.EasyMDEContainer) {
border: none;
}
.markdown-editor :global(.EasyMDEContainer .CodeMirror) {
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-family: ui-monospace, monospace;
font-size: 0.875rem;
}
.markdown-editor :global(.editor-toolbar) {
border: 1px solid #d1d5db;
border-bottom: none;
border-radius: 0.375rem 0.375rem 0 0;
opacity: 1;
}
.markdown-editor :global(.editor-toolbar button) {
color: #6b7280 !important;
}
.markdown-editor :global(.editor-toolbar button:hover) {
background: #f3f4f6;
}
.markdown-editor :global(.editor-toolbar button.active) {
background: #e5e7eb;
}
/* Dark mode */
:global(.dark) .markdown-editor :global(.CodeMirror) {
background: #374151;
color: #f3f4f6;
border-color: #4b5563;
}
:global(.dark) .markdown-editor :global(.editor-toolbar) {
background: #1f2937;
border-color: #4b5563;
}
:global(.dark) .markdown-editor :global(.editor-toolbar button) {
color: #9ca3af !important;
}
:global(.dark) .markdown-editor :global(.editor-toolbar button:hover) {
background: #374151;
}
:global(.dark) .markdown-editor :global(.CodeMirror-cursor) {
border-color: #f3f4f6;
}
:global(.dark) .markdown-editor :global(.editor-preview) {
background: #374151;
color: #f3f4f6;
}
:global(.dark) .markdown-editor :global(.editor-preview-side) {
background: #374151;
color: #f3f4f6;
border-color: #4b5563;
}
</style>
+3 -22
View File
@@ -1,14 +1,11 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { marked } from 'marked';
import TagInput from '$lib/components/ui/TagInput.svelte'; import TagInput from '$lib/components/ui/TagInput.svelte';
import MarkdownEditor from '$lib/components/ui/MarkdownEditor.svelte';
let { data, form } = $props(); let { data, form } = $props();
let content = $state(data.page.content); let content = $state(data.page.content);
let showPreview = $state(false);
const renderedContent = $derived(marked(content) as string);
</script> </script>
<svelte:head> <svelte:head>
@@ -54,24 +51,8 @@
</div> </div>
<div> <div>
<div class="mb-1 flex items-center justify-between"> <span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Content * (Markdown)</span>
<label for="content" class="text-sm font-medium text-gray-700 dark:text-gray-300">Content * (Markdown)</label> <MarkdownEditor bind:value={content} />
<button type="button" onclick={() => (showPreview = !showPreview)}
class="rounded-md px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20">
{showPreview ? 'Editor' : 'Preview'}
</button>
</div>
{#if showPreview}
<div class="prose prose-sm dark:prose-invert min-h-[300px] max-w-none rounded-md border border-gray-300 bg-white p-4 dark:border-gray-600 dark:bg-gray-700">
{@html renderedContent}
</div>
<input type="hidden" name="content" value={content} />
{:else}
<textarea id="content" name="content" rows="20" required
bind:value={content}
class="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"></textarea>
{/if}
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
+3 -23
View File
@@ -1,14 +1,11 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { marked } from 'marked';
import TagInput from '$lib/components/ui/TagInput.svelte'; import TagInput from '$lib/components/ui/TagInput.svelte';
import MarkdownEditor from '$lib/components/ui/MarkdownEditor.svelte';
let { data, form } = $props(); let { data, form } = $props();
let content = $state(''); let content = $state('');
let showPreview = $state(false);
const renderedContent = $derived(marked(content) as string);
</script> </script>
<svelte:head> <svelte:head>
@@ -54,25 +51,8 @@
<!-- Markdown editor --> <!-- Markdown editor -->
<div> <div>
<div class="mb-1 flex items-center justify-between"> <span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Content * (Markdown)</span>
<label for="content" class="text-sm font-medium text-gray-700 dark:text-gray-300">Content * (Markdown)</label> <MarkdownEditor bind:value={content} placeholder="Write your guide in Markdown..." />
<button type="button" onclick={() => (showPreview = !showPreview)}
class="rounded-md px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20">
{showPreview ? 'Editor' : 'Preview'}
</button>
</div>
{#if showPreview}
<div class="prose prose-sm dark:prose-invert min-h-[300px] max-w-none rounded-md border border-gray-300 bg-white p-4 dark:border-gray-600 dark:bg-gray-700">
{@html renderedContent}
</div>
<input type="hidden" name="content" value={content} />
{:else}
<textarea id="content" name="content" rows="15" required
bind:value={content}
class="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
placeholder="# Getting Started&#10;&#10;Write your guide in Markdown...&#10;&#10;## Steps&#10;&#10;1. First step&#10;2. Second step&#10;&#10;**Bold text** and `code` are supported."></textarea>
{/if}
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">