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