Files
blogflare/src/lib/components/admin/MarkdownEditor.svelte
T

568 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { onMount, onDestroy, type Snippet } from 'svelte';
import { foldedRanges, unfoldEffect } from '@codemirror/language';
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 { prepareUpload } from '$lib/uploads';
import { imagePreviewPlugin } from '$lib/editor/imagePreviewPlugin';
import { tableFormatterPlugin } from '$lib/editor/tableFormatterPlugin';
let {
value = $bindable(''),
placeholderText = '支持 Markdown 格式...',
onUpload,
toolbarEnd,
onPositionChange
} = $props<{
value?: string;
placeholderText?: string;
onUpload?: (file: { url: string; key: string; filename: string }) => void;
toolbarEnd?: Snippet;
onPositionChange?: (position: number) => void;
}>();
let editorContainer: HTMLDivElement;
let view: EditorView;
let toolbar: HTMLDivElement;
let isUpdating = false;
let scrollFrame = 0;
// A clamped jump near the document end must not replace the clicked heading's highlight.
let navigationPending = false;
export function currentPosition() {
return view?.state.selection.main.head ?? 0;
}
export function navigateTo(position: number) {
if (!view) return;
navigationPending = true;
const anchor = Math.min(Math.max(position, 0), view.state.doc.length);
const effects = [
EditorView.scrollIntoView(anchor, {
y: 'start',
yMargin: 58 + toolbar.getBoundingClientRect().height + 12
})
];
foldedRanges(view.state).between(0, view.state.doc.length, (from, to) => {
if (from <= anchor && to >= anchor) effects.push(unfoldEffect.of({ from, to }));
});
view.dispatch({ selection: { anchor }, effects });
view.focus();
}
function trackScroll() {
if (scrollFrame) return;
scrollFrame = requestAnimationFrame(() => {
scrollFrame = 0;
if (!view || navigationPending) return;
const rect = view.contentDOM.getBoundingClientRect();
const top = Math.max(toolbar.getBoundingClientRect().bottom + 120, rect.top + 1);
if (top >= window.innerHeight || top >= rect.bottom) return;
const position = view.posAtCoords({ x: rect.left + 8, y: top }, false);
if (position !== null) onPositionChange?.(position);
});
}
let showImageMenu = $state(false);
let fileInput: HTMLInputElement;
let isUploadingImage = $state(false);
async function handleImageUpload(e: Event) {
if (isUploadingImage) return;
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
isUploadingImage = true;
try {
const formData = new FormData();
formData.append('file', await prepareUpload(file));
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(err instanceof Error ? err.message : '图片处理或上传失败', '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'
},
'&.cm-focused': {
outline: 'none',
borderColor: 'var(--accent-color, #419b38)',
boxShadow: '0 0 0 3px rgba(65, 155, 56, 0.08)'
},
// 同时撑高正文和单列 gutter;.cm-gutters 的 min-height 由 CodeMirror 内联管理。
'.cm-content, .cm-gutter': {
minHeight: '400px'
},
'.cm-content': {
padding: '12px 14px',
lineHeight: '1.8'
},
'.cm-scroller': {
overflow: 'auto'
},
'.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,
customTheme,
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
isUpdating = true;
value = update.state.doc.toString();
isUpdating = false;
}
if (update.selectionSet || update.docChanged) {
onPositionChange?.(update.state.selection.main.head);
}
})
]
});
view = new EditorView({
state,
parent: editorContainer
});
window.addEventListener('scroll', trackScroll, { passive: true });
const resumeScrollTracking = () => {
navigationPending = false;
};
const resumeEvents = ['scrollend', 'wheel', 'touchstart', 'pointerdown', 'keydown'] as const;
for (const event of resumeEvents)
window.addEventListener(event, resumeScrollTracking, { passive: true });
return () => {
window.removeEventListener('scroll', trackScroll);
for (const event of resumeEvents) window.removeEventListener(event, resumeScrollTracking);
cancelAnimationFrame(scrollFrame);
};
});
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" bind:this={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>
{@render toolbarEnd?.()}
</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: 0px;
/* 移动端需要额外 offset 请在 media query 中处理 */
/* 编辑器 .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>