feat: 重构 Skill 管理与编辑工作台
- 统一列表、分类、新建与详情页的产品交互 - 提供 Markdown 实时编辑、源码与脚本资源工作台 - 实现能力自动保存、导入导出及完整状态反馈
This commit is contained in:
@@ -85,6 +85,27 @@
|
||||
/* Border radius for card, input and buttons */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Shared workspace and editor rhythm */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--radius-control: 10px;
|
||||
--radius-toolbar: 12px;
|
||||
--radius-panel: 16px;
|
||||
--radius-float: 20px;
|
||||
--radius-pill: 999px;
|
||||
--motion-duration-fast: 120ms;
|
||||
--motion-duration-base: 160ms;
|
||||
--motion-duration-medium: 220ms;
|
||||
--motion-duration-slow: 280ms;
|
||||
--motion-ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--motion-ease-decelerate: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--motion-ease-accelerate: cubic-bezier(0.3, 0, 1, 1);
|
||||
|
||||
/* ============= custom ============= */
|
||||
|
||||
/* 遮罩颜色 */
|
||||
|
||||
@@ -28,6 +28,12 @@ export {
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
File,
|
||||
FileCode2,
|
||||
FileImage,
|
||||
FileText,
|
||||
FolderClosed,
|
||||
FolderOpen,
|
||||
FoldHorizontal,
|
||||
Fullscreen,
|
||||
Github,
|
||||
@@ -43,10 +49,10 @@ export {
|
||||
LogOut,
|
||||
MailCheck,
|
||||
Maximize,
|
||||
MessageSquare,
|
||||
ArrowRightFromLine as MdiMenuClose,
|
||||
ArrowLeftFromLine as MdiMenuOpen,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Minimize,
|
||||
Minimize2,
|
||||
MoonStar,
|
||||
@@ -67,6 +73,7 @@ export {
|
||||
Sun,
|
||||
SunMoon,
|
||||
SwatchBook,
|
||||
Upload,
|
||||
UserRoundPen,
|
||||
X,
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@easyflow/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user