508 lines
12 KiB
Svelte
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('`, 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('', '图片描述');
|
|
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>
|