feat: 重构 Skill 管理与编辑工作台

- 统一列表、分类、新建与详情页的产品交互

- 提供 Markdown 实时编辑、源码与脚本资源工作台

- 实现能力自动保存、导入导出及完整状态反馈
This commit is contained in:
2026-07-27 18:54:57 +08:00
parent 2a9e882ac6
commit 0dc5c3ca55
78 changed files with 16413 additions and 799 deletions

View File

@@ -0,0 +1,21 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts', '!**/*.test.ts', '!**/*.spec.ts'],
},
],
});

View File

@@ -0,0 +1,58 @@
{
"name": "@easyflow-core/editor-ui",
"version": "1.0.0",
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm unbuild",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css",
"**/*.vue"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
},
"./markdown-live-editor": {
"types": "./src/MarkdownLiveEditor.vue",
"development": "./src/MarkdownLiveEditor.vue",
"default": "./dist/MarkdownLiveEditor.vue"
}
},
"publishConfig": {
"exports": {
".": {
"default": "./dist/index.mjs"
},
"./markdown-live-editor": {
"default": "./dist/MarkdownLiveEditor.vue"
}
}
},
"dependencies": {
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language": "^6.12.2",
"@codemirror/legacy-modes": "^6.5.1",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.15",
"@milkdown/crepe": "^7.21.2",
"@milkdown/kit": "^7.21.2",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"unified": "^11.0.5",
"vue": "catalog:"
}
}

View File

@@ -0,0 +1,85 @@
import { flushPromises, mount } from '@vue/test-utils';
import { beforeEach, expect, it, vi } from 'vitest';
import CodeEditor from './CodeEditor.vue';
const languageMocks = vi.hoisted(() => {
let releasePython = () => undefined;
const pythonReady = new Promise<void>((resolve) => {
releasePython = resolve;
});
return {
javascript: vi.fn(() => []),
python: vi.fn(() => []),
pythonReady,
releasePython: () => releasePython(),
};
});
vi.mock('@codemirror/lang-python', async () => {
await languageMocks.pythonReady;
return { python: languageMocks.python };
});
vi.mock('@codemirror/lang-javascript', () => ({
javascript: languageMocks.javascript,
}));
beforeEach(() => {
languageMocks.javascript.mockReset().mockReturnValue([]);
languageMocks.python.mockReset().mockReturnValue([]);
});
it('mounts the latest language when it changes during initial loading', async () => {
const wrapper = mount(CodeEditor, {
attachTo: document.body,
props: { language: 'python', modelValue: 'const value = 1;' },
});
await Promise.resolve();
expect(wrapper.attributes('aria-busy')).toBe('true');
expect(wrapper.text()).toContain('正在加载编辑器');
await wrapper.setProps({ language: 'javascript' });
await flushPromises();
expect(languageMocks.javascript).toHaveBeenCalledOnce();
expect(wrapper.findAll('.cm-editor')).toHaveLength(1);
languageMocks.releasePython();
await flushPromises();
expect(wrapper.findAll('.cm-editor')).toHaveLength(1);
wrapper.unmount();
});
it('shows a recoverable state when a language extension fails', async () => {
let releaseRetry = () => undefined;
const retryReady = new Promise<never[]>((resolve) => {
releaseRetry = () => resolve([]);
});
languageMocks.javascript.mockImplementationOnce(() => {
throw new Error('load failed');
});
languageMocks.javascript.mockImplementationOnce(() => retryReady as never);
const wrapper = mount(CodeEditor, {
attachTo: document.body,
props: { language: 'javascript', modelValue: 'const value = 1;' },
});
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain('编辑器加载失败');
expect(wrapper.find('.cm-editor').exists()).toBe(false);
await wrapper.get('[role="alert"] button').trigger('click');
expect(wrapper.attributes('aria-busy')).toBe('true');
expect(wrapper.text()).toContain('正在加载编辑器');
releaseRetry();
await flushPromises();
expect(wrapper.find('[role="alert"]').exists()).toBe(false);
expect(wrapper.findAll('.cm-editor')).toHaveLength(1);
wrapper.unmount();
});

View File

@@ -0,0 +1,419 @@
<script setup lang="ts">
import type { Extension } from '@codemirror/state';
import type { EditorLanguage } from './types';
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from '@codemirror/commands';
import {
bracketMatching,
defaultHighlightStyle,
foldGutter,
foldKeymap,
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import {
closeSearchPanel,
highlightSelectionMatches,
openSearchPanel,
searchKeymap,
} from '@codemirror/search';
import { Compartment, EditorState } from '@codemirror/state';
import {
drawSelection,
dropCursor,
placeholder as editorPlaceholder,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
} from '@codemirror/view';
const props = withDefaults(
defineProps<{
autofocus?: boolean;
disabled?: boolean;
language?: EditorLanguage;
lineWrapping?: boolean;
loading?: boolean;
modelValue: string;
placeholder?: string;
readonly?: boolean;
}>(),
{
autofocus: false,
disabled: false,
language: 'text',
lineWrapping: true,
loading: false,
placeholder: '',
readonly: false,
},
);
const emit = defineEmits<{
cursorChange: [{ column: number; line: number }];
save: [];
'update:modelValue': [value: string];
}>();
const hostRef = ref<HTMLElement>();
const viewRef = shallowRef<EditorView>();
const languageError = ref('');
const languageLoading = ref(false);
const languageSlot = new Compartment();
const readonlySlot = new Compartment();
const wrappingSlot = new Compartment();
let languageRequest = 0;
let syncingFromOutside = false;
async function loadLanguage(language: EditorLanguage): Promise<Extension> {
if (language === 'python') {
const { python } = await import('@codemirror/lang-python');
return python();
}
if (language === 'javascript' || language === 'json') {
const { javascript } = await import('@codemirror/lang-javascript');
return javascript({ jsx: language === 'javascript', typescript: true });
}
if (language === 'markdown') {
const { markdown } = await import('@codemirror/lang-markdown');
return markdown();
}
if (language === 'shell') {
const [{ StreamLanguage }, { shell }] = await Promise.all([
import('@codemirror/language'),
import('@codemirror/legacy-modes/mode/shell'),
]);
return StreamLanguage.define(shell);
}
return [];
}
function readonlyExtensions(readonly: boolean): Extension[] {
return [EditorState.readOnly.of(readonly), EditorView.editable.of(!readonly)];
}
function isReadonly() {
return props.readonly || props.disabled || props.loading;
}
function wrappingExtension(enabled: boolean): Extension {
return enabled ? EditorView.lineWrapping : [];
}
function createState(doc: string, language: Extension) {
return EditorState.create({
doc,
extensions: [
lineNumbers(),
foldGutter(),
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
highlightActiveLineGutter(),
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
editorPlaceholder(props.placeholder),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => {
if (isReadonly()) return true;
emit('save');
return true;
},
},
{
key: 'Escape',
run: (view) => {
if (closeSearchPanel(view)) return true;
view.contentDOM.blur();
hostRef.value?.focus();
return true;
},
},
indentWithTab,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
...foldKeymap,
]),
languageSlot.of(language),
readonlySlot.of(readonlyExtensions(isReadonly())),
wrappingSlot.of(wrappingExtension(props.lineWrapping)),
EditorView.updateListener.of((update) => {
if (update.docChanged && !syncingFromOutside) {
emit('update:modelValue', update.state.doc.toString());
}
if (update.selectionSet || update.docChanged) {
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
emit('cursorChange', {
column: head - line.from + 1,
line: line.number,
});
}
}),
EditorView.theme({
'&': {
backgroundColor: 'hsl(var(--surface-panel))',
color: 'hsl(var(--foreground))',
fontSize: '13px',
height: '100%',
},
'&.cm-focused': { outline: 'none' },
'.cm-content': { caretColor: 'hsl(var(--primary))', padding: '16px 0' },
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'hsl(var(--primary))',
},
'.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'hsl(var(--primary) / 0.16)',
},
'.cm-gutters': {
backgroundColor: 'hsl(var(--surface-subtle))',
borderRight: '1px solid hsl(var(--line-subtle))',
color: 'hsl(var(--text-muted))',
},
'.cm-activeLine, .cm-activeLineGutter': {
backgroundColor: 'hsl(var(--nav-item-hover) / 0.62)',
},
'.cm-foldPlaceholder': {
backgroundColor: 'hsl(var(--surface-contrast-soft))',
border: '0',
color: 'hsl(var(--text-muted))',
},
'.cm-search': {
backgroundColor: 'hsl(var(--surface-elevated))',
borderBottom: '1px solid hsl(var(--line-subtle))',
color: 'hsl(var(--foreground))',
padding: '8px 12px',
},
}),
],
});
}
async function mountEditor() {
if (!hostRef.value) return;
const request = ++languageRequest;
languageError.value = '';
languageLoading.value = true;
try {
const language = await loadLanguage(props.language);
if (!hostRef.value || request !== languageRequest) return;
viewRef.value?.destroy();
viewRef.value = new EditorView({
parent: hostRef.value,
state: createState(props.modelValue || '', language),
});
if (props.autofocus) viewRef.value.focus();
} catch {
if (request === languageRequest) languageError.value = '编辑器加载失败';
} finally {
if (request === languageRequest) languageLoading.value = false;
}
}
async function updateLanguage(language: EditorLanguage) {
const view = viewRef.value;
if (!view) return;
const request = ++languageRequest;
languageError.value = '';
languageLoading.value = true;
try {
const extension = await loadLanguage(language);
if (!viewRef.value || request !== languageRequest) return;
viewRef.value.dispatch({ effects: languageSlot.reconfigure(extension) });
} catch {
if (request === languageRequest) languageError.value = '语法高亮加载失败';
} finally {
if (request === languageRequest) languageLoading.value = false;
}
}
function handleLanguageChange(language: EditorLanguage) {
if (viewRef.value) {
void updateLanguage(language);
return;
}
// 首次语言扩展仍在加载时重新发起挂载generation 会使旧请求失效。
void mountEditor();
}
function retryLanguage() {
if (viewRef.value) void updateLanguage(props.language);
else void mountEditor();
}
function focus() {
viewRef.value?.focus();
}
function openSearch() {
if (viewRef.value) openSearchPanel(viewRef.value);
}
function scrollToLine(lineNumber: number, column = 1) {
const view = viewRef.value;
if (!view) return;
const safeLine = Math.min(Math.max(lineNumber, 1), view.state.doc.lines);
const line = view.state.doc.line(safeLine);
const position = Math.min(line.from + Math.max(column - 1, 0), line.to);
view.dispatch({
effects: EditorView.scrollIntoView(position, { y: 'center' }),
selection: { anchor: position },
});
view.focus();
}
watch(
() => props.modelValue,
(value = '') => {
const view = viewRef.value;
if (!view) return;
const nextValue = value;
const currentValue = view.state.doc.toString();
if (nextValue === currentValue) return;
syncingFromOutside = true;
view.dispatch({
changes: { from: 0, insert: nextValue, to: currentValue.length },
});
syncingFromOutside = false;
},
);
watch(() => props.language, handleLanguageChange);
watch(
() => [props.readonly, props.disabled, props.loading],
() => {
viewRef.value?.dispatch({
effects: readonlySlot.reconfigure(readonlyExtensions(isReadonly())),
});
},
);
watch(
() => props.lineWrapping,
(value) => {
viewRef.value?.dispatch({
effects: wrappingSlot.reconfigure(wrappingExtension(value)),
});
},
);
onMounted(mountEditor);
onBeforeUnmount(() => {
languageRequest++;
viewRef.value?.destroy();
});
defineExpose({ focus, openSearch, scrollToLine });
</script>
<template>
<div
ref="hostRef"
class="easyflow-code-editor"
:class="{
'is-disabled': disabled,
'is-loading': loading || languageLoading,
}"
:aria-busy="loading || languageLoading"
:aria-disabled="disabled || loading"
role="region"
tabindex="-1"
aria-label="代码编辑器 Esc 退出编辑区域"
>
<div
v-if="loading || languageLoading"
class="easyflow-code-editor__loading"
aria-live="polite"
>
正在加载编辑器
</div>
<div
v-else-if="languageError"
class="easyflow-code-editor__error"
role="alert"
>
<span>{{ languageError }}</span>
<button type="button" @click="retryLanguage">重试</button>
</div>
</div>
</template>
<style scoped>
.easyflow-code-editor {
position: relative;
min-height: 0;
overflow: hidden;
background: hsl(var(--surface-panel));
border: 1px solid transparent;
border-radius: var(--radius-control);
transition:
border-color var(--motion-duration-fast) var(--motion-ease-standard),
box-shadow var(--motion-duration-fast) var(--motion-ease-standard);
}
.easyflow-code-editor:focus-within {
border-color: hsl(var(--primary) / 72%);
box-shadow: 0 0 0 2px hsl(var(--primary) / 12%);
}
.easyflow-code-editor.is-disabled {
cursor: not-allowed;
opacity: 0.62;
}
.easyflow-code-editor__loading,
.easyflow-code-editor__error {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
color: hsl(var(--text-muted));
pointer-events: auto;
background: hsl(var(--surface-panel) / 90%);
}
.easyflow-code-editor__error {
gap: var(--space-2);
color: hsl(var(--destructive));
}
.easyflow-code-editor__error button {
min-height: 32px;
padding: 0 var(--space-3);
color: hsl(var(--nav-item-active-foreground));
cursor: pointer;
background: hsl(var(--nav-item-active));
border: 0;
border-radius: var(--radius-control);
}
.easyflow-code-editor__error button:hover {
background: hsl(var(--nav-tool-hover));
}
.easyflow-code-editor__error button:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,118 @@
import { flushPromises, mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import CodeViewer from './CodeViewer.vue';
const originalClipboard = navigator.clipboard;
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
});
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: originalCreateObjectURL,
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: originalRevokeObjectURL,
});
});
describe('code viewer', () => {
it('copies visible content and emits the shared download action', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const wrapper = mount(CodeViewer, {
global: { stubs: { CodeEditor: true } },
props: {
content: '{"ready":true}',
downloadMode: 'emit',
filename: 'references/data.json',
language: 'json',
},
});
const copyButton = wrapper
.findAll('button')
.find((button) => button.text() === '复制');
const downloadButton = wrapper
.findAll('button')
.find((button) => button.text() === '下载');
if (!copyButton || !downloadButton) throw new Error('缺少查看器操作按钮');
await copyButton.trigger('click');
await flushPromises();
expect(writeText).toHaveBeenCalledWith('{"ready":true}');
expect(copyButton.text()).toBe('已复制');
await downloadButton.trigger('click');
expect(wrapper.emitted('download')).toHaveLength(1);
wrapper.unmount();
});
it('downloads an empty local text file and always releases its object URL', async () => {
const createObjectURL = vi.fn(() => 'blob:empty-source');
const revokeObjectURL = vi.fn();
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectURL,
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: revokeObjectURL,
});
const click = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => undefined);
const wrapper = mount(CodeViewer, {
global: { stubs: { CodeEditor: true } },
props: { content: '', filename: 'empty.txt' },
});
const downloadButton = wrapper
.findAll('button')
.find((button) => button.text() === '下载');
await downloadButton?.trigger('click');
expect(createObjectURL).toHaveBeenCalledOnce();
expect(createObjectURL.mock.calls[0]?.[0]).toBeInstanceOf(Blob);
expect(click).toHaveBeenCalledOnce();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:empty-source');
wrapper.unmount();
});
it('leaves fullscreen mode with Escape', async () => {
const wrapper = mount(CodeViewer, {
global: { stubs: { CodeEditor: true } },
props: { content: 'echo ready', filename: 'scripts/check.sh' },
});
const fullscreenButton = wrapper
.findAll('button')
.find((button) => button.text() === '全屏');
await fullscreenButton?.trigger('click');
expect(wrapper.classes()).toContain('is-fullscreen');
expect(
wrapper
.findAll('button')
.find((button) => button.text() === '退出全屏')
?.attributes('aria-pressed'),
).toBe('true');
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await wrapper.vm.$nextTick();
expect(wrapper.classes()).not.toContain('is-fullscreen');
wrapper.unmount();
});
});

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import type { EditorLanguage } from './types';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import CodeEditor from './CodeEditor.vue';
const props = withDefaults(
defineProps<{
content: string;
downloadMode?: 'emit' | 'local';
filename?: string;
hash?: string;
highlight?: boolean;
language?: EditorLanguage;
size?: number;
}>(),
{
filename: 'source.txt',
downloadMode: 'local',
hash: '',
highlight: true,
language: 'text',
size: undefined,
},
);
const emit = defineEmits<{
download: [];
error: [error: Error];
}>();
const editorRef = ref<InstanceType<typeof CodeEditor>>();
const fullscreen = ref(false);
const copied = ref(false);
let copiedTimer: number | undefined;
const sizeLabel = computed(() => {
const bytes = props.size ?? new TextEncoder().encode(props.content).length;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
});
async function copyContent() {
try {
await navigator.clipboard.writeText(props.content);
copied.value = true;
window.clearTimeout(copiedTimer);
copiedTimer = window.setTimeout(() => (copied.value = false), 1600);
} catch (error_) {
copied.value = false;
emit('error', error_ instanceof Error ? error_ : new Error('复制失败'));
}
}
function downloadContent() {
if (props.downloadMode === 'emit') {
emit('download');
return;
}
const url = URL.createObjectURL(
new Blob([props.content], { type: 'text/plain;charset=utf-8' }),
);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = props.filename;
try {
anchor.click();
} finally {
URL.revokeObjectURL(url);
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && fullscreen.value) {
event.preventDefault();
fullscreen.value = false;
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown));
onBeforeUnmount(() => {
window.clearTimeout(copiedTimer);
window.removeEventListener('keydown', handleKeydown);
});
</script>
<template>
<section
class="easyflow-code-viewer"
:class="{ 'is-fullscreen': fullscreen }"
:aria-label="`${filename} 只读内容`"
>
<header class="easyflow-code-viewer__toolbar">
<div class="easyflow-code-viewer__identity">
<strong>{{ filename }}</strong>
<span>{{ language }}</span>
<span>{{ sizeLabel }}</span>
<span v-if="hash" :title="hash">{{ hash.slice(0, 10) }}</span>
</div>
<div class="easyflow-code-viewer__actions">
<button type="button" @click="editorRef?.openSearch()">搜索</button>
<button type="button" @click="copyContent">
<span aria-live="polite">{{ copied ? '已复制' : '复制' }}</span>
</button>
<button
type="button"
:aria-pressed="fullscreen"
@click="fullscreen = !fullscreen"
>
{{ fullscreen ? '退出全屏' : '全屏' }}
</button>
<button type="button" @click="downloadContent">下载</button>
</div>
</header>
<CodeEditor
ref="editorRef"
class="easyflow-code-viewer__editor"
:language="highlight ? language : 'text'"
:model-value="content"
readonly
/>
</section>
</template>
<style scoped>
.easyflow-code-viewer {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: hsl(var(--surface-panel));
}
.easyflow-code-viewer.is-fullscreen {
position: fixed;
inset: var(--space-4);
z-index: var(--popup-z-index);
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-panel);
box-shadow: var(--shadow-float);
}
.easyflow-code-viewer__toolbar {
display: flex;
gap: var(--space-4);
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 0 var(--space-4);
border-bottom: 1px solid hsl(var(--line-subtle));
}
.easyflow-code-viewer__identity,
.easyflow-code-viewer__actions {
display: flex;
gap: var(--space-2);
align-items: center;
}
.easyflow-code-viewer__identity span {
font-size: 12px;
color: hsl(var(--text-muted));
}
.easyflow-code-viewer__actions button {
padding: 5px 9px;
color: hsl(var(--text-muted));
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--radius-control);
}
.easyflow-code-viewer__actions button:hover {
color: hsl(var(--foreground));
background: hsl(var(--nav-item-hover));
}
.easyflow-code-viewer__actions button:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 2px;
}
.easyflow-code-viewer__editor {
flex: 1;
}
</style>

View File

@@ -0,0 +1,123 @@
import { flushPromises, mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import MarkdownLiveEditor from './MarkdownLiveEditor.vue';
describe('markdown live editor DOM safety', () => {
it('renders raw HTML as text and removes unsafe link navigation', async () => {
const wrapper = mount(MarkdownLiveEditor, {
attachTo: document.body,
props: {
modelValue: [
'<img src="x" onerror="window.__skillXss = true">',
'',
'[unsafe](javascript:alert(1))',
].join('\n'),
},
});
for (let index = 0; index < 6; index++) await flushPromises();
expect(
wrapper.element.querySelector('img[onerror], script, iframe'),
).toBeNull();
expect(wrapper.text()).toContain('<img src="x"');
expect(wrapper.element.querySelector('a[href^="javascript:"]')).toBeNull();
wrapper.unmount();
});
it('keeps an ordinary tight list in the live editor without fidelity loss', async () => {
const wrapper = mount(MarkdownLiveEditor, {
attachTo: document.body,
props: { modelValue: '- first\n- second' },
});
for (let index = 0; index < 6; index++) await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
expect(wrapper.text()).toContain('first');
expect(wrapper.text()).toContain('second');
wrapper.unmount();
});
it('does not rewrite a loaded Skill instruction document', async () => {
const source = [
'# Instructions',
'',
'Use this skill to verify the complete Skill management workflow.',
'',
'## Expected result',
'',
'- Markdown renders while editing.',
'- Scripts are displayed and edited without execution.',
'- Assets can be previewed safely.',
].join('\n');
const wrapper = mount(MarkdownLiveEditor, {
attachTo: document.body,
props: { modelValue: source },
});
for (let index = 0; index < 8; index++) await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
wrapper.unmount();
});
it('renders a YAML frontmatter block as a read-only atom and preserves it on round-trip', async () => {
const source = [
'---',
'name: research-skill',
'description: Research helper for the team.',
'---',
'',
'# Instructions',
'',
'Use this skill to look up references.',
].join('\n');
const wrapper = mount(MarkdownLiveEditor, {
attachTo: document.body,
props: { modelValue: source },
});
for (let index = 0; index < 8; index++) await flushPromises();
const frontmatterNode = wrapper.element.querySelector(
'.milkdown-frontmatter',
);
expect(frontmatterNode).not.toBeNull();
expect(wrapper.text()).toContain('Skill 元数据');
expect(wrapper.text()).toContain('name: research-skill');
expect(wrapper.text()).toContain('Use this skill');
expect(frontmatterNode?.getAttribute('contenteditable')).toBe('false');
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
wrapper.unmount();
});
it('emits frontmatterActivate when the frontmatter block is clicked', async () => {
const source = ['---', 'name: research-skill', '---', '', '# Body'].join(
'\n',
);
const wrapper = mount(MarkdownLiveEditor, {
attachTo: document.body,
props: { modelValue: source },
});
for (let index = 0; index < 8; index++) await flushPromises();
const frontmatterNode = wrapper.element.querySelector<HTMLElement>(
'.milkdown-frontmatter',
);
expect(frontmatterNode).not.toBeNull();
frontmatterNode?.click();
expect(wrapper.emitted('frontmatterActivate')).toHaveLength(1);
wrapper.unmount();
});
});

View File

@@ -0,0 +1,410 @@
import { flushPromises, mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MarkdownLiveEditor from './MarkdownLiveEditor.vue';
const mockState = vi.hoisted(() => ({
dispatch: vi.fn(),
destroy: vi.fn(async () => undefined),
listSpreadNodes: [] as Array<{
attrs: Record<string, unknown>;
marks: unknown[];
position: number;
type: { name: string };
}>,
createPromise: undefined as Promise<void> | undefined,
markdown: '# Initial',
markdownUpdated: undefined as
| ((ctx: unknown, value: string) => void)
| undefined,
replaceAll: vi.fn(),
setNodeMarkup: vi.fn(),
setReadonly: vi.fn(),
setTransactionMeta: vi.fn(),
}));
vi.mock('@milkdown/kit/utils', () => ({
$nodeSchema: () => [{ id: 'nodeSchema' }, { id: 'node' }],
$remark: () => [{ id: 'remark' }, { id: 'plugin' }],
replaceAll: (value: string, flush: boolean) => {
mockState.replaceAll(value, flush);
return () => {
mockState.markdown = value;
};
},
}));
vi.mock('@milkdown/crepe', () => {
class MockCrepe {
static Feature = {
AI: 'ai',
ImageBlock: 'image-block',
Latex: 'latex',
Placeholder: 'placeholder',
TopBar: 'top-bar',
};
create = vi.fn(async () => mockState.createPromise);
destroy = mockState.destroy;
editor = {
action: (action: (ctx: unknown) => void) =>
action({
get: (slice: { name?: string }) => {
if (slice.name === 'editorState') {
return {
doc: {
descendants: (
callback: (
node: (typeof mockState.listSpreadNodes)[number],
position: number,
) => void,
) =>
mockState.listSpreadNodes.forEach((node) =>
callback(node, node.position),
),
},
tr: {
setMeta: mockState.setTransactionMeta,
setNodeMarkup: mockState.setNodeMarkup,
},
};
}
if (slice.name === 'editorView') {
return { dispatch: mockState.dispatch };
}
return undefined;
},
}),
config: () => undefined,
use: () => undefined,
};
setReadonly = mockState.setReadonly;
constructor(config: { defaultValue?: string }) {
mockState.markdown = config.defaultValue || '';
}
getMarkdown = () => mockState.markdown;
on = (
register: (listener: {
markdownUpdated: (callback: typeof mockState.markdownUpdated) => void;
}) => void,
) => {
register({
markdownUpdated: (callback) => (mockState.markdownUpdated = callback),
});
};
}
return { Crepe: MockCrepe };
});
describe('markdownLiveEditor', () => {
beforeEach(() => {
mockState.destroy.mockClear();
mockState.createPromise = undefined;
mockState.dispatch.mockClear();
mockState.replaceAll.mockClear();
mockState.setReadonly.mockClear();
mockState.markdown = '# Initial';
mockState.markdownUpdated = undefined;
mockState.listSpreadNodes.length = 0;
mockState.setNodeMarkup.mockClear();
mockState.setTransactionMeta.mockClear();
});
afterEach(() => vi.useRealTimers());
it('uses a flush replace for external values without emitting a delayed model loop', async () => {
vi.useFakeTimers();
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await flushPromises();
await wrapper.setProps({ modelValue: '# External' });
expect(mockState.replaceAll).toHaveBeenCalledWith('# External', true);
vi.advanceTimersByTime(250);
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
await wrapper
.get('.easyflow-markdown-live-editor__host')
.trigger('beforeinput');
mockState.markdownUpdated?.({}, '# User edit');
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
'# User edit',
]);
});
it('does not mark the document changed while Crepe normalizes its initial value', async () => {
let resolveCreate: (() => void) | undefined;
mockState.createPromise = new Promise<void>((resolve) => {
resolveCreate = resolve;
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
mockState.markdownUpdated?.({}, '# Normalized during create');
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
resolveCreate?.();
await flushPromises();
await wrapper
.get('.easyflow-markdown-live-editor__host')
.trigger('beforeinput');
mockState.markdownUpdated?.({}, '# User edit');
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
'# User edit',
]);
});
it('ignores lossless normalization emitted after the editor is ready', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n- second' },
});
await flushPromises();
mockState.markdownUpdated?.({}, '* first\n* second\n');
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
});
it('locks the live editor when initial markdown cannot be preserved', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = '* normalized';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- original' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
mockState.markdownUpdated?.({}, '* normalized');
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
mockState.markdownUpdated?.({}, '* user edit');
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
});
it('accepts line-ending and final-newline normalization as lossless', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = '# Heading\n';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Heading\r\n' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
expect(mockState.setReadonly).toHaveBeenLastCalledWith(false);
});
it('accepts common Crepe list and delimiter normalization as lossless', async () => {
const source = [
'# Review',
'',
'- first',
'- second',
'',
'---',
'',
'| A | B |',
'| --- | :---: |',
'| 1 | 2 |',
].join('\n');
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = [
'# Review',
'',
'* first',
'* second',
'',
'***',
'',
'| A | B |',
'| - | :-: |',
'| 1 | 2 |',
'',
].join('\n');
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: source },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
expect(mockState.setReadonly).toHaveBeenLastCalledWith(false);
});
it('keeps internal list spread repairs out of undo history', async () => {
mockState.listSpreadNodes.push({
attrs: { spread: 'false' },
marks: [],
position: 4,
type: { name: 'bullet_list' },
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n- second' },
});
await flushPromises();
expect(mockState.setNodeMarkup).toHaveBeenCalledWith(
4,
undefined,
{ spread: false },
[],
);
expect(mockState.setTransactionMeta).toHaveBeenCalledWith(
'addToHistory',
false,
);
expect(mockState.dispatch).toHaveBeenCalledOnce();
wrapper.unmount();
});
it('repairs delayed list spread normalization before updating the model', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n- second' },
});
await flushPromises();
mockState.listSpreadNodes.push({
attrs: { spread: 'false' },
marks: [],
position: 4,
type: { name: 'bullet_list' },
});
mockState.markdown = '* first\n\n* second';
mockState.dispatch.mockImplementationOnce(() => {
mockState.markdown = '* first\n* second';
});
mockState.markdownUpdated?.({}, '* first\n\n* second');
expect(mockState.setNodeMarkup).toHaveBeenCalledWith(
4,
undefined,
{ spread: false },
[],
);
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
wrapper.unmount();
});
it('does not mask changes inside fenced code blocks', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = ['```text', '* changed', '```'].join('\n');
});
const wrapper = mount(MarkdownLiveEditor, {
props: {
modelValue: ['```text', '- changed', '```'].join('\n'),
},
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
});
it('does not mask changes inside indented code blocks', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = ' * changed';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: ' - changed' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
});
it('does not treat a setext heading underline as a thematic break', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = ['Heading', '***'].join('\n');
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: ['Heading', '---'].join('\n') },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
});
it('does not merge lists separated by different bullet markers', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = '* first\n* second';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n* second' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
});
it('does not collapse a tight list into a loose list', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = '* first\n\n* second';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n- second' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
});
it('does not normalize invalid table-like text as a GFM table', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = 'A | B | C\n- | -';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: 'A | B | C\n--- | ---' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
});
it('awaits editor destruction when unmounted', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await flushPromises();
wrapper.unmount();
await flushPromises();
expect(mockState.destroy).toHaveBeenCalledOnce();
});
it('applies the latest model value when it changes during creation', async () => {
let resolveCreate: (() => void) | undefined;
mockState.createPromise = new Promise<void>((resolve) => {
resolveCreate = resolve;
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await wrapper.setProps({ modelValue: '# Latest' });
resolveCreate?.();
await flushPromises();
expect(mockState.replaceAll).toHaveBeenCalledWith('# Latest', true);
});
it('applies the latest readonly state when it changes during creation', async () => {
let resolveCreate: (() => void) | undefined;
mockState.createPromise = new Promise<void>((resolve) => {
resolveCreate = resolve;
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial', readonly: false },
});
await wrapper.setProps({ readonly: true });
resolveCreate?.();
await flushPromises();
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
});
});

View File

@@ -0,0 +1,630 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { Crepe } from '@milkdown/crepe';
import { editorStateCtx, editorViewCtx } from '@milkdown/kit/core';
import { replaceAll } from '@milkdown/kit/utils';
import {
hasEquivalentMarkdownSemantics,
normalizeLiveMarkdownUpdate,
} from './markdown-fidelity';
import {
isSafeMarkdownLinkHref,
resolveSafeExternalMarkdownImageUrl,
} from './markdown-security';
import { installFrontmatter } from './milkdown-frontmatter';
import '@milkdown/crepe/theme/common/style.css';
const props = withDefaults(
defineProps<{
modelValue: string;
placeholder?: string;
readonly?: boolean;
resolveImageUrl?: (url: string) => Promise<string> | string;
uploadImage?: (file: File) => Promise<string>;
}>(),
{
placeholder: '输入 Skill 指令…',
readonly: false,
resolveImageUrl: undefined,
uploadImage: undefined,
},
);
const emit = defineEmits<{
error: [error: Error];
fidelityLoss: [];
frontmatterActivate: [];
linkClick: [href: string];
ready: [];
save: [];
'update:modelValue': [value: string];
}>();
const hostRef = ref<HTMLElement>();
const crepeRef = shallowRef<Crepe>();
const loading = ref(true);
const failed = ref(false);
const fidelityLoss = ref(false);
let destroyed = false;
let hasUserEdited = false;
let ready = false;
let syncingFromOutside = false;
let synchronizedMarkdown = '';
let domObserver: MutationObserver | undefined;
async function createEditor() {
if (!hostRef.value) return;
const initialModelValue = props.modelValue || '';
loading.value = true;
failed.value = false;
fidelityLoss.value = false;
const crepe = new Crepe({
defaultValue: initialModelValue,
featureConfigs: {
[Crepe.Feature.ImageBlock]: {
onUpload: async (file) => {
if (props.uploadImage) return props.uploadImage(file);
const error = new Error(
'当前上下文不支持直接上传图片,请从资源文件树上传',
);
emit('error', error);
throw error;
},
proxyDomURL: (url) =>
props.resolveImageUrl
? props.resolveImageUrl(url)
: resolveSafeExternalMarkdownImageUrl(url),
},
[Crepe.Feature.Placeholder]: {
mode: 'block',
text: props.placeholder,
},
},
features: {
[Crepe.Feature.AI]: false,
[Crepe.Feature.ImageBlock]: true,
[Crepe.Feature.Latex]: false,
[Crepe.Feature.TopBar]: false,
},
root: hostRef.value,
});
installFrontmatter(crepe.editor);
crepe.setReadonly(props.readonly);
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
const repairedListSpread = normalizeListSpreadAttributes(crepe);
const editorMarkdown = repairedListSpread
? crepe.getMarkdown()
: markdown;
const nextMarkdown = normalizeLiveMarkdownUpdate(
props.modelValue || '',
editorMarkdown,
);
if (
!ready ||
fidelityLoss.value ||
syncingFromOutside ||
!hasUserEdited ||
nextMarkdown === props.modelValue ||
nextMarkdown === synchronizedMarkdown ||
!hasMeaningfulMarkdownDifference(props.modelValue || '', nextMarkdown)
) {
synchronizedMarkdown = nextMarkdown;
return;
}
synchronizedMarkdown = nextMarkdown;
emit('update:modelValue', nextMarkdown);
});
});
try {
await crepe.create();
if (destroyed) {
await crepe.destroy();
return;
}
const latestModelValue = props.modelValue || '';
if (latestModelValue !== initialModelValue) {
syncingFromOutside = true;
try {
crepe.editor.action(replaceAll(latestModelValue, true));
} finally {
syncingFromOutside = false;
}
}
normalizeListSpreadAttributes(crepe);
synchronizedMarkdown = normalizeLiveMarkdownUpdate(
latestModelValue,
crepe.getMarkdown(),
);
if (
hasMeaningfulMarkdownDifference(latestModelValue, synchronizedMarkdown)
) {
reportFidelityLoss(crepe);
}
crepe.setReadonly(props.readonly || fidelityLoss.value);
crepeRef.value = crepe;
ready = true;
emit('ready');
} catch (error_) {
failed.value = true;
const error =
error_ instanceof Error ? error_ : new Error('实时编辑器加载失败');
emit('error', error);
try {
await crepe.destroy();
} catch {
// Best-effort cleanup after a partial editor initialization.
}
} finally {
loading.value = false;
}
}
watch(
() => props.modelValue,
(value) => {
const crepe = crepeRef.value;
if (!crepe) return;
const editorMarkdown = crepe.getMarkdown();
const comparableMarkdown = normalizeLiveMarkdownUpdate(
value || '',
editorMarkdown,
);
if (comparableMarkdown === (value || '')) {
synchronizedMarkdown = comparableMarkdown;
return;
}
syncingFromOutside = true;
hasUserEdited = false;
try {
crepe.editor.action(replaceAll(value || '', true));
normalizeListSpreadAttributes(crepe);
synchronizedMarkdown = crepe.getMarkdown();
if (hasMeaningfulMarkdownDifference(value || '', synchronizedMarkdown)) {
reportFidelityLoss(crepe);
}
} finally {
syncingFromOutside = false;
}
},
);
watch(
() => props.readonly,
(value) => crepeRef.value?.setReadonly(value || fidelityLoss.value),
);
onMounted(createEditor);
onMounted(() => {
hostRef.value?.addEventListener('click', handleLinkClick);
hostRef.value?.addEventListener('click', handleFrontmatterActivate);
hostRef.value?.addEventListener('beforeinput', markUserEdited);
hostRef.value?.addEventListener('drop', markUserEdited);
hostRef.value?.addEventListener('keydown', handleKeydown);
hostRef.value?.addEventListener('paste', markUserEdited);
if (hostRef.value) {
domObserver = new MutationObserver(sanitizeRenderedLinks);
domObserver.observe(hostRef.value, {
attributeFilter: ['href'],
attributes: true,
childList: true,
subtree: true,
});
sanitizeRenderedLinks();
}
});
onBeforeUnmount(async () => {
destroyed = true;
ready = false;
hostRef.value?.removeEventListener('click', handleLinkClick);
hostRef.value?.removeEventListener('click', handleFrontmatterActivate);
hostRef.value?.removeEventListener('beforeinput', markUserEdited);
hostRef.value?.removeEventListener('drop', markUserEdited);
hostRef.value?.removeEventListener('keydown', handleKeydown);
hostRef.value?.removeEventListener('paste', markUserEdited);
domObserver?.disconnect();
domObserver = undefined;
await crepeRef.value?.destroy();
crepeRef.value = undefined;
});
function handleLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement | null;
const anchor = target?.closest<HTMLAnchorElement>('a[href]');
if (!anchor) return;
const href = anchor.getAttribute('href') || '';
if (!href || !isSafeMarkdownLinkHref(href)) {
event.preventDefault();
emit('error', new Error('该链接协议不受支持'));
return;
}
if (/^https?:\/\//i.test(href)) {
anchor.rel = 'noopener noreferrer';
anchor.target = '_blank';
return;
}
if (/^(?:mailto:|#)/i.test(href)) return;
event.preventDefault();
emit('linkClick', href);
}
function handleFrontmatterActivate(event: Event) {
const target = event.target as HTMLElement | null;
if (!target) return;
const frontmatter = target.closest<HTMLElement>('.milkdown-frontmatter');
if (!frontmatter) return;
event.preventDefault();
emit('frontmatterActivate');
}
function markUserEdited() {
hasUserEdited = true;
}
function sanitizeRenderedLinks() {
hostRef.value
?.querySelectorAll<HTMLAnchorElement>('a[href]')
.forEach((anchor) => {
const href = anchor.getAttribute('href') || '';
if (!isSafeMarkdownLinkHref(href)) {
anchor.removeAttribute('href');
anchor.setAttribute('aria-disabled', 'true');
return;
}
if (/^https?:\/\//i.test(href)) {
anchor.rel = 'noopener noreferrer';
anchor.target = '_blank';
}
});
}
function handleKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
if (!props.readonly) emit('save');
}
}
function reportFidelityLoss(crepe: Crepe) {
if (fidelityLoss.value) return;
fidelityLoss.value = true;
crepe.setReadonly(true);
emit('fidelityLoss');
}
function hasMeaningfulMarkdownDifference(source: string, normalized: string) {
return !hasEquivalentMarkdownSemantics(source, normalized);
}
function normalizeListSpreadAttributes(crepe: Crepe) {
let repaired = false;
crepe.editor.action((ctx) => {
const state = ctx.get(editorStateCtx);
const transaction = state.tr;
let changed = false;
state.doc.descendants((node, position) => {
if (
!['bullet_list', 'list_item', 'ordered_list'].includes(node.type.name)
) {
return;
}
const spread = node.attrs.spread;
if (spread !== 'true' && spread !== 'false') return;
transaction.setNodeMarkup(
position,
undefined,
{ ...node.attrs, spread: spread === 'true' },
node.marks,
);
changed = true;
});
if (changed) {
repaired = true;
transaction.setMeta('addToHistory', false);
ctx.get(editorViewCtx).dispatch(transaction);
}
});
return repaired;
}
</script>
<template>
<div class="easyflow-markdown-live-editor">
<div
ref="hostRef"
class="easyflow-markdown-live-editor__host"
role="region"
aria-label="Markdown 实时编辑器"
></div>
<div
v-if="loading"
class="easyflow-markdown-live-editor__state"
aria-live="polite"
>
正在加载编辑器
</div>
<div
v-else-if="failed"
class="easyflow-markdown-live-editor__state is-error"
role="alert"
>
实时编辑器加载失败请切换到源码模式继续编辑
</div>
<div
v-else-if="fidelityLoss"
class="easyflow-markdown-live-editor__state is-error"
role="alert"
>
当前内容包含实时编辑器无法保真处理的语法请切换到源码模式
</div>
</div>
</template>
<style scoped>
.easyflow-markdown-live-editor {
position: relative;
display: flex;
flex: 1;
min-height: 0;
overflow: auto;
background: hsl(var(--surface-panel));
}
.easyflow-markdown-live-editor__host {
flex: 1;
width: 100%;
min-height: 100%;
}
.easyflow-markdown-live-editor__state {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: hsl(var(--text-muted));
background: hsl(var(--surface-panel) / 92%);
}
.easyflow-markdown-live-editor__state.is-error {
color: hsl(var(--destructive));
}
.easyflow-markdown-live-editor :deep(.milkdown) {
--crepe-color-background: hsl(var(--surface-panel));
--crepe-color-on-background: hsl(var(--foreground));
--crepe-color-surface: hsl(var(--surface-subtle));
--crepe-color-surface-low: hsl(var(--surface-contrast-soft));
--crepe-color-on-surface: hsl(var(--foreground));
--crepe-color-on-surface-variant: hsl(var(--text-muted));
--crepe-color-outline: hsl(var(--line-subtle));
--crepe-color-primary: hsl(var(--primary));
--crepe-color-secondary: hsl(var(--nav-item-active));
--crepe-color-on-secondary: hsl(var(--nav-item-active-foreground));
--crepe-color-inverse: hsl(var(--foreground));
--crepe-color-on-inverse: hsl(var(--surface-panel));
--crepe-color-inline-code: hsl(var(--destructive));
--crepe-color-error: hsl(var(--destructive));
--crepe-color-hover: hsl(var(--nav-item-hover));
--crepe-color-selected: hsl(var(--nav-item-active));
--crepe-color-inline-area: hsl(var(--surface-contrast-soft));
--crepe-font-default: var(--font-family);
--crepe-font-title: var(--font-family);
--crepe-font-code:
ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
--crepe-shadow-1: var(--shadow-subtle);
--crepe-shadow-2: var(--shadow-toolbar);
min-height: 100%;
color: hsl(var(--foreground));
background: transparent;
}
.easyflow-markdown-live-editor :deep(.ProseMirror) {
box-sizing: border-box;
width: min(100%, 960px);
min-height: 100%;
padding: var(--space-8) clamp(var(--space-4), 5vw, 72px) 64px;
margin: 0 auto;
font-size: 15px;
line-height: 1.76;
color: hsl(var(--text-strong));
background: transparent;
box-shadow: none;
}
.easyflow-markdown-live-editor :deep(.ProseMirror:focus-visible) {
outline: none;
box-shadow: inset 3px 0 hsl(var(--primary) / 28%);
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter) {
box-sizing: border-box;
margin-block: var(--space-4);
padding: var(--space-3) var(--space-4);
cursor: pointer;
background: hsl(var(--surface-subtle));
border: 0;
border-left: 3px solid hsl(var(--primary) / 36%);
border-radius: 0;
transition:
border-color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard);
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter:hover),
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter:focus-visible) {
background: hsl(var(--surface-contrast-soft));
border-left-color: hsl(var(--primary));
outline: none;
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__title) {
margin-bottom: var(--space-1);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: hsl(var(--text-muted));
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__code) {
margin: 0;
padding: 0;
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
font-size: 12.5px;
line-height: 1.65;
color: hsl(var(--text-strong));
white-space: pre-wrap;
word-break: break-word;
background: transparent;
border: 0;
}
.easyflow-markdown-live-editor :deep(a) {
color: hsl(var(--primary));
text-underline-offset: 3px;
}
.easyflow-markdown-live-editor :deep(img) {
max-width: 100%;
border-radius: var(--radius);
}
.easyflow-markdown-live-editor :deep(h1),
.easyflow-markdown-live-editor :deep(h2),
.easyflow-markdown-live-editor :deep(h3),
.easyflow-markdown-live-editor :deep(h4) {
color: hsl(var(--text-strong));
letter-spacing: -0.012em;
}
.easyflow-markdown-live-editor :deep(h1) {
padding-bottom: var(--space-3);
margin-top: 0;
font-size: 28px;
border-bottom: 1px solid hsl(var(--line-subtle));
}
.easyflow-markdown-live-editor :deep(h2) {
margin-top: var(--space-8);
font-size: 21px;
}
.easyflow-markdown-live-editor :deep(h3) {
margin-top: var(--space-6);
font-size: 17px;
}
.easyflow-markdown-live-editor :deep(p),
.easyflow-markdown-live-editor :deep(ul),
.easyflow-markdown-live-editor :deep(ol),
.easyflow-markdown-live-editor :deep(blockquote),
.easyflow-markdown-live-editor :deep(pre),
.easyflow-markdown-live-editor :deep(table) {
margin-block: var(--space-4);
}
.easyflow-markdown-live-editor :deep(blockquote) {
padding: var(--space-2) var(--space-4);
color: hsl(var(--text-muted));
background: hsl(var(--surface-subtle));
border-left: 3px solid hsl(var(--primary) / 48%);
}
.easyflow-markdown-live-editor :deep(pre) {
padding: var(--space-4);
overflow: auto;
background: hsl(var(--surface-contrast-soft));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-control);
}
.easyflow-markdown-live-editor :deep(code) {
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
font-size: 0.9em;
}
.easyflow-markdown-live-editor :deep(table) {
width: 100%;
border-collapse: collapse;
}
.easyflow-markdown-live-editor :deep(th),
.easyflow-markdown-live-editor :deep(td) {
padding: var(--space-2) var(--space-3);
text-align: left;
border: 1px solid hsl(var(--line-subtle));
}
.easyflow-markdown-live-editor :deep(th) {
font-weight: 600;
background: hsl(var(--surface-subtle));
}
@supports not (color: color-mix(in srgb, red, blue)) {
.easyflow-markdown-live-editor :deep(.ProseMirror code),
.easyflow-markdown-live-editor :deep(.ProseMirror pre) {
background: var(--crepe-color-inline-area);
}
.easyflow-markdown-live-editor :deep(.ProseMirror hr),
.easyflow-markdown-live-editor :deep(.milkdown-toolbar .divider),
.easyflow-markdown-live-editor :deep(.milkdown-top-bar .top-bar-divider) {
background-color: var(--crepe-color-outline);
}
.easyflow-markdown-live-editor
:deep(.ProseMirror hr.ProseMirror-selectednode) {
background-color: var(--crepe-color-selected);
}
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .tab-group),
.easyflow-markdown-live-editor :deep(.milkdown-table-block td),
.easyflow-markdown-live-editor :deep(.milkdown-table-block th),
.easyflow-markdown-live-editor :deep(.milkdown-top-bar) {
border-color: var(--crepe-color-outline);
}
.easyflow-markdown-live-editor
:deep(.milkdown-slash-menu .menu-groups .menu-group h6),
.easyflow-markdown-live-editor
:deep(.milkdown-code-block .preview-panel .preview-label) {
color: var(--crepe-color-on-surface);
}
.easyflow-markdown-live-editor :deep(.crepe-placeholder::before),
.easyflow-markdown-live-editor
:deep(.milkdown-image-block .image-edit .link-importer .placeholder),
.easyflow-markdown-live-editor
:deep(.milkdown-image-inline .link-importer .placeholder) {
color: var(--crepe-color-on-background);
}
.easyflow-markdown-live-editor
:deep(.milkdown-image-block.selected > .image-edit::before),
.easyflow-markdown-live-editor
:deep(.milkdown-image-block.selected > .image-wrapper::before) {
background: var(--crepe-color-selected);
}
.easyflow-markdown-live-editor :deep(.crepe-drop-cursor) {
background-color: var(--crepe-color-outline);
}
}
@media (max-width: 720px) {
.easyflow-markdown-live-editor :deep(.ProseMirror) {
padding: var(--space-6) var(--space-4) var(--space-8);
box-shadow: none;
}
}
@media (prefers-reduced-motion: reduce) {
.easyflow-markdown-live-editor :deep(*) {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref } from 'vue';
import CodeEditor from './CodeEditor.vue';
withDefaults(
defineProps<{
highlight?: boolean;
lineWrapping?: boolean;
modelValue: string;
readonly?: boolean;
}>(),
{ highlight: true, lineWrapping: true, readonly: false },
);
const emit = defineEmits<{
cursorChange: [{ column: number; line: number }];
save: [];
'update:modelValue': [value: string];
}>();
const editorRef = ref<InstanceType<typeof CodeEditor>>();
defineExpose({
focus: () => editorRef.value?.focus(),
openSearch: () => editorRef.value?.openSearch(),
scrollToLine: (line: number, column = 1) =>
editorRef.value?.scrollToLine(line, column),
});
</script>
<template>
<CodeEditor
ref="editorRef"
:model-value="modelValue"
:language="highlight ? 'markdown' : 'text'"
:line-wrapping="lineWrapping"
:readonly="readonly"
@cursor-change="emit('cursorChange', $event)"
@save="emit('save')"
@update:model-value="emit('update:modelValue', $event)"
/>
</template>

View File

@@ -0,0 +1,6 @@
export { default as CodeEditor } from './CodeEditor.vue';
export { default as CodeViewer } from './CodeViewer.vue';
export * from './markdown-security';
export { default as MarkdownLiveEditor } from './MarkdownLiveEditor.vue';
export { default as MarkdownSourceEditor } from './MarkdownSourceEditor.vue';
export * from './types';

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import {
hasEquivalentMarkdownSemantics,
normalizeLiveMarkdownUpdate,
} from './markdown-fidelity';
describe('markdown semantic fidelity', () => {
it('accepts equivalent list markers, rules, and table delimiter widths', () => {
const source = [
'- first',
'- second',
'',
'---',
'',
'| A | B |',
'| --- | :---: |',
'| 1 | 2 |',
].join('\n');
const normalized = [
'* first',
'* second',
'',
'***',
'',
'| A | B |',
'| - | :-: |',
'| 1 | 2 |',
'',
].join('\n');
expect(hasEquivalentMarkdownSemantics(source, normalized)).toBe(true);
});
it('preserves a valid single-column table', () => {
expect(
hasEquivalentMarkdownSemantics('| A |\n| --- |', '| A |\n| - |'),
).toBe(true);
});
it.each([
['mixed list boundaries', '- first\n* second', '* first\n* second'],
['tight and loose lists', '- first\n- second', '* first\n\n* second'],
['setext and thematic breaks', 'Heading\n---', 'Heading\n***'],
['fenced code content', '```\n- code\n```', '```\n* code\n```'],
['indented code content', ' - code', ' * code'],
['invalid table-like text', 'A | B | C\n--- | ---', 'A | B | C\n- | -'],
])('rejects %s changes', (_name, source, normalized) => {
expect(hasEquivalentMarkdownSemantics(source, normalized)).toBe(false);
});
});
describe('live markdown normalization', () => {
it('removes empty paragraph markers introduced by the live editor', () => {
expect(
normalizeLiveMarkdownUpdate(
'# Title\n\nFirst\n\nSecond\n',
'# Title\n\n<br />\n\nFirst\n\n<br />\n\nSecond\n',
),
).toBe('# Title\n\nFirst\n\nSecond\n');
});
it('preserves explicit standalone HTML breaks from source mode', () => {
const source = '# Title\n\n<br />\n\nText\n';
expect(normalizeLiveMarkdownUpdate(source, source)).toBe(source);
});
});

View File

@@ -0,0 +1,43 @@
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
const markdownParser = unified().use(remarkParse).use(remarkGfm);
const STANDALONE_BREAK_LINE = /^[\t ]*<br\s*\/?>[\t ]*$/im;
/**
* Compares Markdown by parsed GFM structure so harmless marker formatting is
* accepted while list, table, heading, code, and raw HTML changes are rejected.
*/
export function hasEquivalentMarkdownSemantics(
source: string,
normalized: string,
) {
try {
return semanticTree(source) === semanticTree(normalized);
} catch {
return false;
}
}
/**
* Removes blank-paragraph markers generated by the live editor without
* changing an explicit HTML break that already exists in the source.
*/
export function normalizeLiveMarkdownUpdate(source: string, markdown: string) {
if (STANDALONE_BREAK_LINE.test(source)) return markdown;
return markdown
.replace(/^[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '')
.replace(/\n{2,}[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '\n\n')
.replace(/\n{2,}[\t ]*<br\s*\/?>[\t ]*$/gi, '\n');
}
function semanticTree(markdown: string) {
const normalizedLineEndings = markdown
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n');
return JSON.stringify(
markdownParser.parse(normalizedLineEndings),
(key, value) => (key === 'position' ? undefined : value),
);
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
EMPTY_IMAGE_DATA_URL,
isBlockedMarkdownImageUrl,
isSafeMarkdownLinkHref,
resolveSafeExternalMarkdownImageUrl,
} from './markdown-security';
describe('markdown image URL safety', () => {
it.each([
'javascript:alert(1)',
'file:///etc/passwd',
'blob:https://example.com/foreign',
'data:image/svg+xml,<svg onload=alert(1)>',
'http://example.com/insecure.png',
'//example.com/image.png',
'../assets/image.png',
])('blocks URLs that require an application resolver: %s', (url) => {
expect(isBlockedMarkdownImageUrl(url)).toBe(true);
expect(resolveSafeExternalMarkdownImageUrl(url)).toBe(EMPTY_IMAGE_DATA_URL);
});
it('allows credential-free HTTPS images', () => {
expect(resolveSafeExternalMarkdownImageUrl('https://cdn.test/a.png')).toBe(
'https://cdn.test/a.png',
);
expect(
isBlockedMarkdownImageUrl('https://user:secret@cdn.test/a.png'),
).toBe(true);
});
it.each([
'javascript:alert(1)',
'file:///etc/passwd',
'blob:https://example.com/foreign',
'data:text/html,<script>alert(1)</script>',
'//example.com/path',
'https://user:secret@example.com/path',
])('blocks unsafe rendered links: %s', (href) => {
expect(isSafeMarkdownLinkHref(href)).toBe(false);
});
it.each([
'#section',
'mailto:team@example.com',
'https://example.com/path',
'http://example.com/path',
'../references/guide.md',
])('allows supported rendered links: %s', (href) => {
expect(isSafeMarkdownLinkHref(href)).toBe(true);
});
});

View File

@@ -0,0 +1,47 @@
const EMPTY_IMAGE_DATA_URL =
'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
/** Return a safe URL for an externally hosted Markdown image. */
export function resolveSafeExternalMarkdownImageUrl(url: string) {
const value = url.trim();
try {
const parsed = new URL(value);
if (parsed.protocol === 'https:' && !parsed.username && !parsed.password) {
return parsed.href;
}
} catch {
// Relative and malformed values require an application-level resolver.
}
return EMPTY_IMAGE_DATA_URL;
}
export function isBlockedMarkdownImageUrl(url: string) {
return resolveSafeExternalMarkdownImageUrl(url) === EMPTY_IMAGE_DATA_URL;
}
/** Keep only navigable web/mail/anchor links and application-resolved relative paths. */
export function isSafeMarkdownLinkHref(href: string) {
const value = href.trim();
if (!value || hasControlCharacter(value) || value.startsWith('//')) {
return false;
}
if (value.startsWith('#') || /^mailto:/i.test(value)) return true;
if (/^https?:\/\//i.test(value)) {
try {
const parsed = new URL(value);
return !parsed.username && !parsed.password;
} catch {
return false;
}
}
return !/^[a-z][a-z\d+.-]*:/i.test(value);
}
function hasControlCharacter(value: string) {
return [...value].some((character) => {
const code = character.codePointAt(0) || 0;
return code < 32 || code === 127;
});
}
export { EMPTY_IMAGE_DATA_URL };

View File

@@ -0,0 +1,132 @@
import type { Editor } from '@milkdown/kit/core';
import { remarkStringifyOptionsCtx } from '@milkdown/kit/core';
import { $nodeSchema, $remark } from '@milkdown/kit/utils';
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
interface MdastNode {
children?: unknown[];
position?: {
end?: { offset?: number };
start?: { offset?: number };
};
type?: string;
value?: unknown;
}
interface MdastTree {
children?: MdastNode[];
}
interface MdastFile {
value?: unknown;
}
function getStringValue(node: unknown): string {
if (typeof node !== 'object' || node === null || !('value' in node)) {
return '';
}
return typeof node.value === 'string' ? node.value : '';
}
/**
* 自实现的 frontmatter remark 插件:在 mdast 阶段把文档开头的
* `---\n...\n---` 包装成 `yaml` 节点,避免默认 remark 把它解析成
* thematic break + 段落文本。
*
* 注意:`$remark` 工厂需要三层函数:`(ctx) => (processor) => (tree, file) => void`。
*/
export const remarkFrontmatterMilkdown = $remark(
'remarkFrontmatter',
() => () => (tree: MdastTree, file: MdastFile) => {
const value = String(file.value ?? '');
const match = FRONTMATTER_PATTERN.exec(value);
if (!match) return;
const endOffset = match[0].length;
const yamlNode = {
type: 'yaml',
value: match[1] || '',
};
const children = Array.isArray(tree.children) ? tree.children : [];
const filtered = children.filter((child) => {
const start = child.position?.start?.offset ?? Number.POSITIVE_INFINITY;
return start >= endOffset;
});
tree.children = [yamlNode as MdastNode, ...filtered];
},
);
/**
* 把 mdast `yaml` 节点映射为 ProseMirror 块节点,作为只读原子块渲染,
* 序列化时再写回 mdast `yaml` 节点。这样 SKILL.md 的 frontmatter 在实时
* 编辑模式下既能可视化呈现,又不会被 Milkdown 改写破坏结构。
*/
export const frontmatterSchema = $nodeSchema('frontmatter', () => ({
group: 'block',
atom: true,
isolating: true,
defining: true,
draggable: false,
allowGapCursor: false,
content: '',
attrs: {
value: { default: '' },
},
parseDOM: [
{
tag: 'div[data-frontmatter]',
getAttrs: (dom) => ({
value: (dom as HTMLElement).dataset.value || '',
}),
},
],
toDOM: (node) => [
'div',
{
'data-frontmatter': '',
'data-value': node.attrs.value,
contenteditable: 'false',
class: 'milkdown-frontmatter',
role: 'button',
tabindex: '0',
title: '点击进入源码模式编辑',
'aria-label': 'Skill 元数据,点击进入源码模式编辑',
},
['div', { class: 'milkdown-frontmatter__title' }, 'Skill 元数据'],
['pre', { class: 'milkdown-frontmatter__code' }, node.attrs.value],
],
parseMarkdown: {
match: (node) => node.type === 'yaml',
runner: (state, node, type) => {
state.addNode(type, { value: getStringValue(node) });
},
},
toMarkdown: {
match: (node) => node.type.name === 'frontmatter',
runner: (state, node) => {
state.addNode('yaml', undefined, node.attrs.value as string);
},
},
}));
/**
* 在 Crepe 创建前安装 frontmatter 支持。
*
* 在 {@link import('@milkdown/crepe').Crepe} 实例化后、`create()` 之前调用:
* `crepe.addFeature(installFrontmatter)`,或直接 `crepe.editor.use(...)`。
*/
export function installFrontmatter(editor: Editor) {
editor.config((ctx) => {
ctx.update(remarkStringifyOptionsCtx, (prev) => ({
...prev,
handlers: {
...(prev as { handlers?: Record<string, unknown> }).handlers,
yaml: (node: { value?: unknown }) =>
`---\n${typeof node.value === 'string' ? node.value : ''}\n---\n\n`,
},
}));
});
editor.use(remarkFrontmatterMilkdown);
editor.use(frontmatterSchema);
}

View File

@@ -0,0 +1,20 @@
export type EditorLanguage =
| 'javascript'
| 'json'
| 'markdown'
| 'python'
| 'shell'
| 'text';
export interface EditorCursorPosition {
column: number;
line: number;
}
export type EditorSaveState = 'dirty' | 'error' | 'saved' | 'saving';
/** Rich Markdown is disabled above this size to keep editing responsive. */
export const MAX_LIVE_MARKDOWN_CHARACTERS = 300_000;
/** Syntax parsing is disabled above this size while plain-text editing remains available. */
export const MAX_HIGHLIGHTED_TEXT_CHARACTERS = 800_000;

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@easyflow/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}