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:
Generated
+71
-1
@@ -16,6 +16,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.4.1",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"easymde": "^2.20.0",
|
||||
"marked": "^18.0.0",
|
||||
"pg": "^8.13.1",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -2526,6 +2527,15 @@
|
||||
"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": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -2537,7 +2547,12 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"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"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
@@ -2579,6 +2594,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -2719,6 +2743,21 @@
|
||||
"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": {
|
||||
"version": "4.2.3",
|
||||
"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": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -4867,6 +4931,12 @@
|
||||
"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": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.4.1",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"easymde": "^2.20.0",
|
||||
"marked": "^18.0.0",
|
||||
"pg": "^8.13.1",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
@@ -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>
|
||||
@@ -1,14 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { marked } from 'marked';
|
||||
import TagInput from '$lib/components/ui/TagInput.svelte';
|
||||
import MarkdownEditor from '$lib/components/ui/MarkdownEditor.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let content = $state(data.page.content);
|
||||
let showPreview = $state(false);
|
||||
|
||||
const renderedContent = $derived(marked(content) as string);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -54,24 +51,8 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<label for="content" class="text-sm font-medium text-gray-700 dark:text-gray-300">Content * (Markdown)</label>
|
||||
<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}
|
||||
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Content * (Markdown)</span>
|
||||
<MarkdownEditor bind:value={content} />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { marked } from 'marked';
|
||||
import TagInput from '$lib/components/ui/TagInput.svelte';
|
||||
import MarkdownEditor from '$lib/components/ui/MarkdownEditor.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let content = $state('');
|
||||
let showPreview = $state(false);
|
||||
|
||||
const renderedContent = $derived(marked(content) as string);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -54,25 +51,8 @@
|
||||
|
||||
<!-- Markdown editor -->
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<label for="content" class="text-sm font-medium text-gray-700 dark:text-gray-300">Content * (Markdown)</label>
|
||||
<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 Write your guide in Markdown... ## Steps 1. First step 2. Second step **Bold text** and `code` are supported."></textarea>
|
||||
{/if}
|
||||
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Content * (Markdown)</span>
|
||||
<MarkdownEditor bind:value={content} placeholder="Write your guide in Markdown..." />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user