Files
blogflare/src/lib/components/admin/MarkdownEditor.svelte
T
seahi 71c59837eb
continuous-integration/drone/push Build is passing
feat: 为 Markdown 编辑器添加表格预览组件
2026-09-23 10:51:23 +08:00

508 lines
12 KiB
Svelte

<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { onMount, onDestroy } from 'svelte';
import { EditorView, basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { languages } from '@codemirror/language-data';
import { placeholder as cmPlaceholder } from '@codemirror/view';
import {
Bold,
CodeXml,
Heading,
Image as ImageIcon,
Info,
Italic,
Link,
List,
ListOrdered,
SquareSplitVertical,
Strikethrough
} from 'lucide-svelte';
import { addToast } from '$lib/stores/toast.svelte';
import { imagePreviewPlugin } from '$lib/editor/imagePreviewPlugin';
import { tableFormatterPlugin, tablePreviewField } from '$lib/editor/tableFormatterPlugin';
let {
value = $bindable(''),
placeholderText = '支持 Markdown 格式...',
onUpload
} = $props<{
value?: string;
placeholderText?: string;
onUpload?: (file: { url: string; key: string; filename: string }) => void;
}>();
let editorContainer: HTMLDivElement;
let view: EditorView;
let isUpdating = false;
let showImageMenu = $state(false);
let fileInput: HTMLInputElement;
let isUploadingImage = $state(false);
async function handleImageUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
isUploadingImage = true;
try {
const res = await fetch('/admin/api/upload', {
method: 'POST',
body: formData
});
const data = (await res.json()) as any;
if (data.success) {
insertAtCursor('![', `](${data.url})`, file.name || '图片');
addToast('图片上传成功', 'success');
if (onUpload) {
onUpload({ url: data.url, key: data.key, filename: data.filename });
}
} else {
addToast(data.error || '上传失败', 'error');
}
} catch (err: any) {
console.error(err);
addToast('网络错误,上传失败', 'error');
} finally {
isUploadingImage = false;
input.value = '';
showImageMenu = false;
}
}
const customTheme = EditorView.theme({
'&': {
color: 'var(--text-color)',
backgroundColor: 'rgba(0, 0, 0, 0.015)',
fontFamily: 'inherit',
fontSize: '1.05rem',
border: '1px solid rgba(0, 0, 0, 0.12)',
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
minHeight: '400px',
height: '100%'
},
'&.cm-focused': {
outline: 'none',
borderColor: 'var(--accent-color, #419b38)',
boxShadow: '0 0 0 3px rgba(65, 155, 56, 0.08)'
},
'.cm-content': {
minHeight: '400px',
padding: '12px 14px',
lineHeight: '1.8'
},
'.cm-scroller': {
overflow: 'auto',
minHeight: '400px'
},
'.cm-gutters': {
backgroundColor: 'rgba(0, 0, 0, 0.03)',
color: 'rgba(0, 0, 0, 0.4)',
borderRight: '1px solid rgba(0, 0, 0, 0.12)',
borderTopLeftRadius: '0',
borderBottomLeftRadius: '0'
},
'.cm-activeLineGutter': {
backgroundColor: 'rgba(0, 0, 0, 0.08)'
}
});
onMount(() => {
const state = EditorState.create({
doc: value,
extensions: [
basicSetup,
cmPlaceholder(placeholderText),
markdown({
base: markdownLanguage,
codeLanguages: languages
}),
imagePreviewPlugin,
tableFormatterPlugin,
tablePreviewField,
customTheme,
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
isUpdating = true;
value = update.state.doc.toString();
isUpdating = false;
}
})
]
});
view = new EditorView({
state,
parent: editorContainer
});
});
onDestroy(() => {
if (view) {
view.destroy();
}
});
$effect(() => {
if (view && value !== view.state.doc.toString() && !isUpdating) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value }
});
}
});
function insertAtCursor(before: string, after: string = '', defaultText: string = '') {
if (!view) return;
const selection = view.state.selection.main;
const selectedText = view.state.sliceDoc(selection.from, selection.to);
const textToInsert = selectedText
? before + selectedText + after
: before + defaultText + after;
view.dispatch({
changes: {
from: selection.from,
to: selection.to,
insert: textToInsert
},
selection: {
anchor: selection.from + textToInsert.length
}
});
view.focus();
if (!selectedText && defaultText) {
view.dispatch({
selection: {
anchor: selection.from + before.length,
head: selection.from + before.length + defaultText.length
}
});
}
}
function insertMore() {
insertAtCursor('\n<!--more-->\n', '', '');
}
let showAdmonitionMenu = $state(false);
const admonitionTypes = [
{ type: 'note', title: '注意', label: '注意 (Note)' },
{ type: 'abstract', title: '摘要', label: '摘要 (Abstract)' },
{ type: 'info', title: '信息', label: '信息 (Info)' },
{ type: 'tip', title: '技巧', label: '技巧 (Tip)' },
{ type: 'success', title: '成功', label: '成功 (Success)' },
{ type: 'question', title: '问题', label: '问题 (Question)' },
{ type: 'warning', title: '警告', label: '警告 (Warning)' },
{ type: 'failure', title: '失败', label: '失败 (Failure)' },
{ type: 'danger', title: '危险', label: '危险 (Danger)' },
{ type: 'bug', title: 'Bug', label: 'Bug' },
{ type: 'example', title: '示例', label: '示例 (Example)' },
{ type: 'quote', title: '引用', label: '引用 (Quote)' }
];
function insertAdmonition(type: string, title: string) {
const openTag = `{{< admonition ${type} "${title}" >}}\n`;
const closeTag = `\n{{< /admonition >}}`;
insertAtCursor('\n' + openTag, closeTag, '在这里输入提示内容...');
showAdmonitionMenu = false;
}
</script>
<div class="editor-wrapper">
<div class="md-toolbar">
<button
type="button"
class="md-btn"
onclick={() => insertAtCursor('**', '**', '粗体')}
title="加粗"
>
<Bold aria-hidden="true" />
</button>
<button
type="button"
class="md-btn"
onclick={() => insertAtCursor('*', '*', '斜体')}
title="斜体"
>
<Italic aria-hidden="true" />
</button>
<button
type="button"
class="md-btn"
onclick={() => insertAtCursor('~~', '~~', '删除线')}
title="删除线"
>
<Strikethrough aria-hidden="true" />
</button>
<button
type="button"
class="md-btn"
onclick={() => insertAtCursor('### ', '', '标题')}
title="标题"
>
<Heading aria-hidden="true" />
</button>
<div class="md-divider"></div>
<button
type="button"
class="md-btn"
onclick={() => insertAtCursor('- ', '', '列表项')}
title="无序列表"
>
<List aria-hidden="true" />
</button>
<button
type="button"
class="md-btn"
onclick={() => insertAtCursor('1. ', '', '列表项')}
title="有序列表"
>
<ListOrdered aria-hidden="true" />
</button>
<button
type="button"
class="md-btn"
onclick={() => insertAtCursor('[', '](https://)', '链接文字')}
title="链接"
>
<Link aria-hidden="true" />
</button>
<div class="toolbar-dropdown-wrapper">
{#if showImageMenu}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div class="dropdown-overlay" onclick={() => (showImageMenu = false)} role="button"></div>
{/if}
<button
type="button"
class="md-btn"
onclick={() => (showImageMenu = !showImageMenu)}
title="图片"
>
<ImageIcon aria-hidden="true" />
</button>
{#if showImageMenu}
<div class="toolbar-dropdown">
<button
type="button"
class="dropdown-item"
onclick={() => {
insertAtCursor('![', '](https://)', '图片描述');
showImageMenu = false;
}}
>
插入图片链接
</button>
<button
type="button"
class="dropdown-item"
onclick={() => fileInput.click()}
disabled={isUploadingImage}
>
{isUploadingImage ? '正在上传...' : '上传本地图片'}
</button>
</div>
{/if}
</div>
<input
type="file"
accept="image/*"
style="display:none;"
bind:this={fileInput}
onchange={handleImageUpload}
/>
<button
type="button"
class="md-btn"
onclick={() => insertAtCursor('```\n', '\n```', 'code')}
title="代码块"
>
<CodeXml aria-hidden="true" />
</button>
<div class="md-divider"></div>
<div class="toolbar-dropdown-wrapper">
{#if showAdmonitionMenu}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class="dropdown-overlay"
onclick={() => (showAdmonitionMenu = false)}
role="button"
></div>
{/if}
<button
type="button"
class="md-btn"
onclick={() => (showAdmonitionMenu = !showAdmonitionMenu)}
title="提示框 (Admonition)"
>
<Info aria-hidden="true" />
</button>
{#if showAdmonitionMenu}
<div class="toolbar-dropdown">
{#each admonitionTypes as item (item.type)}
<button
type="button"
class="dropdown-item"
onclick={() => insertAdmonition(item.type, item.title)}
>
{item.label}
</button>
{/each}
</div>
{/if}
</div>
<button type="button" class="md-btn" onclick={insertMore} title="插入摘要分割线">
<SquareSplitVertical aria-hidden="true" />
</button>
</div>
<div bind:this={editorContainer} class="editor-container"></div>
</div>
<style>
.editor-wrapper {
display: flex;
flex-direction: column;
width: 100%;
}
.editor-container {
width: 100%;
}
.md-toolbar {
/* 吸顶:紧贴后台固定顶栏(高度 58px)下方,滚动时保持可见 */
position: sticky;
top: 58px;
/* 编辑器 .cm-scroller 是 position: relative + z-index: 0,且 DOM 更靠后,
必须抬高层级,否则正文会盖在工具条上(看起来像半透明) */
z-index: 20;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
margin-bottom: 10px;
padding: 4px;
border: 1px solid rgba(0, 0, 0, 0.12);
/* 不透明背景,避免滚动内容从工具条后面透出来 */
background-color: var(--bg-color, #ffffff);
background-image: linear-gradient(rgba(0, 0, 0, 0.015), rgba(0, 0, 0, 0.015));
}
.md-btn {
background: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: var(--text-color);
opacity: 0.7;
transition:
background-color 0.18s ease,
color 0.18s ease,
opacity 0.18s ease;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 4px;
flex: none;
}
.md-btn:hover {
opacity: 1;
color: var(--accent-color, #419b38);
background-color: rgba(65, 155, 56, 0.1);
}
.md-btn:active {
background-color: rgba(65, 155, 56, 0.18);
}
.md-btn:focus-visible {
opacity: 1;
outline: 2px solid var(--accent-color, #419b38);
outline-offset: -2px;
}
.md-btn :global(svg) {
width: 18px;
height: 18px;
display: block;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
.md-divider {
flex: none;
width: 1px;
height: 16px;
background: rgba(0, 0, 0, 0.14);
margin: 0 5px;
}
.toolbar-dropdown-wrapper {
position: relative;
display: inline-block;
}
.dropdown-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
.toolbar-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--bg-color, #ffffff);
border: 1px solid var(--text-color);
z-index: 100;
width: 140px;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
}
.dropdown-item {
background: transparent;
border: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 8px 12px;
text-align: left;
font-family: inherit;
font-size: 0.85rem;
color: var(--text-color);
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover {
background: var(--text-color);
color: var(--bg-color, #ffffff);
}
</style>