feat: 重塑技能库与统一文件工作台

- 统一分类检索、新建导入和发布交互

- 使用单一文件工作台编辑全部 Skill 资源

- 下沉可复用 Markdown 与代码编辑能力
This commit is contained in:
2026-08-14 18:58:20 +08:00
parent 77d66e1b42
commit 5de3b209c1
38 changed files with 2354 additions and 5810 deletions

View File

@@ -0,0 +1,45 @@
# Editor UI
`@easyflow-core/editor-ui` 提供管理端通用的代码与 Markdown 编辑组件。组件样式随组件一起加载,并统一消费 EasyFlow 设计 Token业务页面无需覆盖 Milkdown 或 CodeMirror 内部样式。
## Markdown 实时编辑器
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { MarkdownLiveEditor } from '@easyflow-core/editor-ui';
const content = ref('# 标题');
const readonly = ref(false);
const props = defineProps<{
saveDocument: (content: string) => Promise<void>;
resolveImageUrl: (url: string) => Promise<string> | string;
uploadImage: (file: File) => Promise<string>;
}>();
</script>
<template>
<MarkdownLiveEditor
v-model="content"
:readonly="readonly"
placeholder="输入 Markdown 内容…"
:resolve-image-url="props.resolveImageUrl"
:upload-image="props.uploadImage"
@save="props.saveDocument(content)"
/>
</template>
```
主要接口:
- `v-model`Markdown 原文。
- `readonly`:只读模式。
- `placeholder`:空文档占位文案。
- `resolveImageUrl`:将 Markdown 图片地址解析为可预览地址。
- `uploadImage`:上传图片并返回写入 Markdown 的地址。
- `save`:用户按下 `Ctrl/Cmd + S`
- `linkClick`:用户点击相对链接。
- `fidelityLoss`:内容包含实时模式无法保真的语法,调用方应切换源码模式。
源码编辑场景可直接使用 `MarkdownSourceEditor`

View File

@@ -17,6 +17,7 @@ import {
foldGutter,
foldKeymap,
indentOnInput,
StreamLanguage,
syntaxHighlighting,
} from '@codemirror/language';
import {
@@ -77,25 +78,53 @@ let languageRequest = 0;
let syncingFromOutside = false;
async function loadLanguage(language: EditorLanguage): Promise<Extension> {
if (language === 'css') {
const { css } = await import('@codemirror/legacy-modes/mode/css');
return StreamLanguage.define(css);
}
if (language === 'html') {
const { html } = await import('@codemirror/legacy-modes/mode/xml');
return StreamLanguage.define(html);
}
if (language === 'java') {
const { java } = await import('@codemirror/legacy-modes/mode/clike');
return StreamLanguage.define(java);
}
if (language === 'python') {
const { python } = await import('@codemirror/lang-python');
return python();
}
if (language === 'javascript' || language === 'json') {
if (language === 'json') {
const { json } = await import('@codemirror/legacy-modes/mode/javascript');
return StreamLanguage.define(json);
}
if (language === 'javascript' || language === 'typescript') {
const { javascript } = await import('@codemirror/lang-javascript');
return javascript({ jsx: language === 'javascript', typescript: true });
return javascript({
jsx: true,
typescript: language === 'typescript',
});
}
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'),
]);
const { shell } = await import('@codemirror/legacy-modes/mode/shell');
return StreamLanguage.define(shell);
}
if (language === 'sql') {
const { standardSQL } = await import('@codemirror/legacy-modes/mode/sql');
return StreamLanguage.define(standardSQL);
}
if (language === 'xml') {
const { xml } = await import('@codemirror/legacy-modes/mode/xml');
return StreamLanguage.define(xml);
}
if (language === 'yaml') {
const { yaml } = await import('@codemirror/legacy-modes/mode/yaml');
return StreamLanguage.define(yaml);
}
return [];
}

View File

@@ -90,7 +90,7 @@ describe('markdown live editor DOM safety', () => {
'.milkdown-frontmatter',
);
expect(frontmatterNode).not.toBeNull();
expect(wrapper.text()).toContain('Skill 元数据');
expect(wrapper.text()).toContain('YAML 元数据');
expect(wrapper.text()).toContain('name: research-skill');
expect(wrapper.text()).toContain('Use this skill');
expect(frontmatterNode?.getAttribute('contenteditable')).toBe('false');

View File

@@ -4,7 +4,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MarkdownLiveEditor from './MarkdownLiveEditor.vue';
interface MockFeatureConfig {
advancedGroup?: Record<string, unknown>;
listGroup?: Record<string, unknown>;
onUpload?: (file: File) => Promise<string>;
proxyDomURL?: (url: string) => Promise<string> | string;
text?: string;
textGroup?: Record<string, unknown>;
}
interface MockCrepeConfig {
defaultValue?: string;
featureConfigs?: Record<string, MockFeatureConfig>;
}
const mockState = vi.hoisted(() => ({
config: undefined as MockCrepeConfig | undefined,
dispatch: vi.fn(),
destroy: vi.fn(async () => undefined),
listSpreadNodes: [] as Array<{
@@ -14,16 +29,30 @@ const mockState = vi.hoisted(() => ({
type: { name: string };
}>,
createPromise: undefined as Promise<void> | undefined,
firstNode: undefined as
| undefined
| { nodeSize: number; type: { name: string } },
markdown: '# Initial',
markdownUpdated: undefined as
| ((ctx: unknown, value: string) => void)
| undefined,
replaceAll: vi.fn(),
resolve: vi.fn((position: number) => ({ position })),
selectionFrom: 2,
setSelection: vi.fn(),
setNodeMarkup: vi.fn(),
setReadonly: vi.fn(),
setTransactionMeta: vi.fn(),
}));
vi.mock('@milkdown/kit/prose/state', () => ({
TextSelection: {
near: (position: { position: number }) => ({
position: position.position,
}),
},
}));
vi.mock('@milkdown/kit/utils', () => ({
$nodeSchema: () => [{ id: 'nodeSchema' }, { id: 'node' }],
$remark: () => [{ id: 'remark' }, { id: 'plugin' }],
@@ -39,6 +68,7 @@ vi.mock('@milkdown/crepe', () => {
class MockCrepe {
static Feature = {
AI: 'ai',
BlockEdit: 'block-edit',
ImageBlock: 'image-block',
Latex: 'latex',
Placeholder: 'placeholder',
@@ -56,6 +86,8 @@ vi.mock('@milkdown/crepe', () => {
if (slice.name === 'editorState') {
return {
doc: {
firstChild: mockState.firstNode,
resolve: mockState.resolve,
descendants: (
callback: (
node: (typeof mockState.listSpreadNodes)[number],
@@ -66,9 +98,11 @@ vi.mock('@milkdown/crepe', () => {
callback(node, node.position),
),
},
selection: { from: mockState.selectionFrom },
tr: {
setMeta: mockState.setTransactionMeta,
setNodeMarkup: mockState.setNodeMarkup,
setSelection: mockState.setSelection,
},
};
}
@@ -82,7 +116,8 @@ vi.mock('@milkdown/crepe', () => {
use: () => undefined,
};
setReadonly = mockState.setReadonly;
constructor(config: { defaultValue?: string }) {
constructor(config: MockCrepeConfig) {
mockState.config = config;
mockState.markdown = config.defaultValue || '';
}
getMarkdown = () => mockState.markdown;
@@ -101,10 +136,15 @@ vi.mock('@milkdown/crepe', () => {
describe('markdownLiveEditor', () => {
beforeEach(() => {
mockState.config = undefined;
mockState.destroy.mockClear();
mockState.createPromise = undefined;
mockState.dispatch.mockClear();
mockState.firstNode = undefined;
mockState.replaceAll.mockClear();
mockState.resolve.mockClear();
mockState.selectionFrom = 2;
mockState.setSelection.mockClear();
mockState.setReadonly.mockClear();
mockState.markdown = '# Initial';
mockState.markdownUpdated = undefined;
@@ -115,6 +155,133 @@ describe('markdownLiveEditor', () => {
afterEach(() => vi.useRealTimers());
it('uses generic Markdown copy by default', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '' },
});
await flushPromises();
expect(mockState.config?.featureConfigs?.placeholder?.text).toBe(
'输入 Markdown 内容…',
);
const upload = mockState.config?.featureConfigs?.['image-block']?.onUpload;
await expect(upload?.(new File(['image'], 'image.png'))).rejects.toThrow(
'当前编辑器未配置图片上传',
);
expect(wrapper.emitted('error')?.[0]?.[0]).toEqual(
new Error('当前编辑器未配置图片上传'),
);
wrapper.unmount();
});
it('passes reusable placeholder and image callbacks to Crepe', async () => {
const resolveImageUrl = vi.fn((url: string) => `/preview/${url}`);
const uploadImage = vi.fn(async (file: File) => `/uploads/${file.name}`);
const wrapper = mount(MarkdownLiveEditor, {
props: {
modelValue: '',
placeholder: '编写发布说明…',
resolveImageUrl,
uploadImage,
},
});
await flushPromises();
const config = mockState.config?.featureConfigs;
expect(config?.placeholder?.text).toBe('编写发布说明…');
expect(await config?.['image-block']?.proxyDomURL?.('asset.png')).toBe(
'/preview/asset.png',
);
expect(
await config?.['image-block']?.onUpload?.(
new File(['image'], 'asset.png'),
),
).toBe('/uploads/asset.png');
expect(resolveImageUrl).toHaveBeenCalledWith('asset.png');
expect(uploadImage).toHaveBeenCalledOnce();
wrapper.unmount();
});
it('localizes the block menu and exposes one keyboard menu trigger', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await flushPromises();
expect(mockState.config?.featureConfigs?.['block-edit']).toMatchObject({
advancedGroup: {
codeBlock: { label: '代码块' },
image: { label: '图片' },
label: '更多',
table: { label: '表格' },
},
listGroup: {
bulletList: { label: '无序列表' },
label: '列表',
orderedList: { label: '有序列表' },
},
textGroup: {
h1: { label: '一级标题' },
label: '文本',
text: { label: '正文' },
},
});
const host = wrapper.get('.easyflow-markdown-live-editor__host').element;
const handle = document.createElement('div');
handle.className = 'milkdown-block-handle';
handle.innerHTML =
'<div class="operation-item"></div><div class="operation-item"></div>';
const menu = document.createElement('div');
menu.className = 'milkdown-slash-menu';
host.append(handle, menu);
await flushPromises();
const hiddenAddButton = handle.firstElementChild as HTMLElement;
const trigger = handle.lastElementChild as HTMLElement;
await vi.waitFor(() => {
expect(hiddenAddButton.getAttribute('aria-hidden')).toBe('true');
expect(trigger.getAttribute('aria-label')).toBe('打开内容块菜单');
expect(trigger.getAttribute('role')).toBe('button');
expect(trigger.tabIndex).toBe(0);
expect(menu.getAttribute('aria-label')).toBe('内容块菜单');
});
const pointerup = vi.fn();
hiddenAddButton.addEventListener('pointerup', pointerup);
trigger.click();
expect(pointerup).toHaveBeenCalledOnce();
trigger.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }),
);
expect(pointerup).toHaveBeenCalledTimes(2);
mockState.markdownUpdated?.({}, '# Menu edit');
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
'# Menu edit',
]);
wrapper.unmount();
});
it('emits the save shortcut only while the editor is writable', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await flushPromises();
const host = wrapper.get('.easyflow-markdown-live-editor__host');
await host.trigger('keydown', { key: 's', metaKey: true });
expect(wrapper.emitted('save')).toHaveLength(1);
await wrapper.setProps({ readonly: true });
await host.trigger('keydown', { ctrlKey: true, key: 's' });
expect(wrapper.emitted('save')).toHaveLength(1);
wrapper.unmount();
});
it('uses a flush replace for external values without emitting a delayed model loop', async () => {
vi.useFakeTimers();
const wrapper = mount(MarkdownLiveEditor, {
@@ -129,7 +296,7 @@ describe('markdownLiveEditor', () => {
await wrapper
.get('.easyflow-markdown-live-editor__host')
.trigger('beforeinput');
.trigger('keydown', { key: 'Enter' });
mockState.markdownUpdated?.({}, '# User edit');
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
'# User edit',
@@ -158,6 +325,27 @@ describe('markdownLiveEditor', () => {
]);
});
it('redirects body input after immutable leading frontmatter', async () => {
mockState.firstNode = {
nodeSize: 1,
type: { name: 'frontmatter' },
};
mockState.selectionFrom = 0;
mockState.setSelection.mockReturnValue({ redirected: true });
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '---\nname: review\n---\n\n# Initial' },
});
await flushPromises();
await wrapper
.get('.easyflow-markdown-live-editor__host')
.trigger('beforeinput');
expect(mockState.resolve).toHaveBeenCalledWith(1);
expect(mockState.setSelection).toHaveBeenCalledWith({ position: 1 });
expect(mockState.dispatch).toHaveBeenCalledWith({ redirected: true });
});
it('ignores lossless normalization emitted after the editor is ready', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n- second' },

View File

@@ -3,6 +3,7 @@ import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { Crepe } from '@milkdown/crepe';
import { editorStateCtx, editorViewCtx } from '@milkdown/kit/core';
import { TextSelection } from '@milkdown/kit/prose/state';
import { replaceAll } from '@milkdown/kit/utils';
import {
@@ -26,7 +27,7 @@ const props = withDefaults(
uploadImage?: (file: File) => Promise<string>;
}>(),
{
placeholder: '输入 Skill 指令…',
placeholder: '输入 Markdown 内容…',
readonly: false,
resolveImageUrl: undefined,
uploadImage: undefined,
@@ -64,12 +65,37 @@ async function createEditor() {
const crepe = new Crepe({
defaultValue: initialModelValue,
featureConfigs: {
[Crepe.Feature.BlockEdit]: {
textGroup: {
label: '文本',
text: { label: '正文' },
h1: { label: '一级标题' },
h2: { label: '二级标题' },
h3: { label: '三级标题' },
h4: { label: '四级标题' },
h5: { label: '五级标题' },
h6: { label: '六级标题' },
quote: { label: '引用' },
divider: { label: '分隔线' },
},
listGroup: {
label: '列表',
bulletList: { label: '无序列表' },
orderedList: { label: '有序列表' },
taskList: { label: '任务列表' },
},
advancedGroup: {
label: '更多',
image: { label: '图片' },
codeBlock: { label: '代码块' },
table: { label: '表格' },
math: { label: '公式' },
},
},
[Crepe.Feature.ImageBlock]: {
onUpload: async (file) => {
if (props.uploadImage) return props.uploadImage(file);
const error = new Error(
'当前上下文不支持直接上传图片,请从资源文件树上传',
);
const error = new Error('当前编辑器未配置图片上传');
emit('error', error);
throw error;
},
@@ -199,32 +225,36 @@ watch(
onMounted(createEditor);
onMounted(() => {
hostRef.value?.addEventListener('click', handleBlockMenuClick);
hostRef.value?.addEventListener('click', handleLinkClick);
hostRef.value?.addEventListener('click', handleFrontmatterActivate);
hostRef.value?.addEventListener('beforeinput', markUserEdited);
hostRef.value?.addEventListener('beforeinput', prepareUserEdit, true);
hostRef.value?.addEventListener('drop', markUserEdited);
hostRef.value?.addEventListener('keydown', handleKeydown);
hostRef.value?.addEventListener('paste', markUserEdited);
hostRef.value?.addEventListener('keydown', handleKeydown, true);
hostRef.value?.addEventListener('paste', prepareUserEdit, true);
hostRef.value?.addEventListener('pointerup', prepareBlockMenuEdit, true);
if (hostRef.value) {
domObserver = new MutationObserver(sanitizeRenderedLinks);
domObserver = new MutationObserver(refreshRenderedContent);
domObserver.observe(hostRef.value, {
attributeFilter: ['href'],
attributes: true,
childList: true,
subtree: true,
});
sanitizeRenderedLinks();
refreshRenderedContent();
}
});
onBeforeUnmount(async () => {
destroyed = true;
ready = false;
hostRef.value?.removeEventListener('click', handleBlockMenuClick);
hostRef.value?.removeEventListener('click', handleLinkClick);
hostRef.value?.removeEventListener('click', handleFrontmatterActivate);
hostRef.value?.removeEventListener('beforeinput', markUserEdited);
hostRef.value?.removeEventListener('beforeinput', prepareUserEdit, true);
hostRef.value?.removeEventListener('drop', markUserEdited);
hostRef.value?.removeEventListener('keydown', handleKeydown);
hostRef.value?.removeEventListener('paste', markUserEdited);
hostRef.value?.removeEventListener('keydown', handleKeydown, true);
hostRef.value?.removeEventListener('paste', prepareUserEdit, true);
hostRef.value?.removeEventListener('pointerup', prepareBlockMenuEdit, true);
domObserver?.disconnect();
domObserver = undefined;
await crepeRef.value?.destroy();
@@ -260,10 +290,67 @@ function handleFrontmatterActivate(event: Event) {
emit('frontmatterActivate');
}
function handleBlockMenuClick(event: MouseEvent) {
const target = event.target as HTMLElement | null;
const trigger = target?.closest<HTMLElement>(
'.milkdown-block-handle .operation-item:last-child',
);
if (!trigger) return;
event.preventDefault();
event.stopPropagation();
activateBlockMenu(trigger);
}
function activateBlockMenu(trigger: HTMLElement) {
trigger.parentElement
?.querySelector<HTMLElement>('.operation-item:first-child')
?.dispatchEvent(
new Event('pointerup', { bubbles: true, cancelable: true }),
);
}
function markUserEdited() {
hasUserEdited = true;
}
function prepareBlockMenuEdit(event: PointerEvent) {
const target = event.target as HTMLElement | null;
if (
target?.closest(
'.milkdown-block-handle .operation-item, .milkdown-slash-menu .menu-group li',
)
) {
markUserEdited();
}
}
/**
* Redirects typing and paste operations after the immutable frontmatter atom.
* ProseMirror otherwise allows a gap selection before the first atom, which
* would serialize body content ahead of the required YAML metadata block.
*/
function prepareUserEdit() {
markUserEdited();
const crepe = crepeRef.value;
if (!crepe) return;
crepe.editor.action((ctx) => {
const state = ctx.get(editorStateCtx);
const firstNode = state.doc.firstChild;
if (
firstNode?.type.name !== 'frontmatter' ||
state.selection.from > firstNode.nodeSize
) {
return;
}
const view = ctx.get(editorViewCtx);
const bodySelection = TextSelection.near(
state.doc.resolve(firstNode.nodeSize),
1,
);
view.dispatch(state.tr.setSelection(bodySelection));
});
}
function sanitizeRenderedLinks() {
hostRef.value
?.querySelectorAll<HTMLAnchorElement>('a[href]')
@@ -281,11 +368,53 @@ function sanitizeRenderedLinks() {
});
}
function refreshRenderedContent() {
sanitizeRenderedLinks();
enhanceBlockMenu();
}
function enhanceBlockMenu() {
const host = hostRef.value;
if (!host) return;
host
.querySelectorAll<HTMLElement>(
'.milkdown-block-handle .operation-item:first-child',
)
.forEach((hiddenAddButton) => {
hiddenAddButton.setAttribute('aria-hidden', 'true');
hiddenAddButton.setAttribute('tabindex', '-1');
});
host
.querySelectorAll<HTMLElement>(
'.milkdown-block-handle .operation-item:last-child',
)
.forEach((trigger) => {
trigger.setAttribute('aria-label', '打开内容块菜单');
trigger.setAttribute('role', 'button');
trigger.setAttribute('tabindex', '0');
trigger.setAttribute('title', '打开内容块菜单');
});
host.querySelectorAll<HTMLElement>('.milkdown-slash-menu').forEach((menu) => {
menu.setAttribute('aria-label', '内容块菜单');
});
}
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement | null;
const blockMenuTrigger = target?.closest<HTMLElement>(
'.milkdown-block-handle .operation-item:last-child',
);
if (blockMenuTrigger && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
activateBlockMenu(blockMenuTrigger);
return;
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
if (!props.readonly) emit('save');
return;
}
prepareUserEdit();
}
function reportFidelityLoss(crepe: Crepe) {
@@ -370,12 +499,14 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
flex: 1;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
background: hsl(var(--surface-panel));
}
.easyflow-markdown-live-editor__host {
flex: 1;
width: 100%;
min-width: 0;
min-height: 100%;
}
@@ -405,7 +536,7 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
--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-inline-code: hsl(var(--nav-item-active-foreground));
--crepe-color-error: hsl(var(--destructive));
--crepe-color-hover: hsl(var(--nav-item-hover));
--crepe-color-selected: hsl(var(--nav-item-active));
@@ -431,62 +562,108 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
font-size: 15px;
line-height: 1.76;
color: hsl(var(--text-strong));
caret-color: hsl(var(--primary));
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(.ProseMirror::selection),
.easyflow-markdown-live-editor :deep(.ProseMirror *::selection) {
color: hsl(var(--text-strong));
background: hsl(var(--nav-item-active));
}
.easyflow-markdown-live-editor :deep(.ProseMirror .ProseMirror-selectednode) {
outline: 2px solid hsl(var(--primary) / 64%);
outline-offset: 2px;
background: hsl(var(--nav-item-active));
}
.easyflow-markdown-live-editor
:deep(.ProseMirror img.ProseMirror-selectednode) {
outline-color: hsl(var(--primary));
background: transparent;
}
.easyflow-markdown-live-editor :deep(.ProseMirror-focused) {
--prosemirror-virtual-cursor-color: hsl(var(--primary));
}
.easyflow-markdown-live-editor :deep(.ProseMirror-gapcursor::after) {
border-top: 2px solid hsl(var(--primary));
}
.easyflow-markdown-live-editor :deep(.crepe-drop-cursor) {
height: 2px !important;
background-color: hsl(var(--primary)) !important;
border-radius: var(--radius-pill);
opacity: 0.9 !important;
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter) {
box-sizing: border-box;
margin-block: var(--space-4);
padding: var(--space-3) var(--space-4);
margin-block: var(--space-4);
cursor: pointer;
background: hsl(var(--surface-subtle));
border: 0;
border-left: 3px solid hsl(var(--primary) / 36%);
border-radius: 0;
border: 1px solid hsl(var(--line-subtle));
border-left: 3px solid hsl(var(--primary) / 58%);
border-radius: var(--radius-control);
transition:
border-color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard);
background-color var(--motion-duration-fast) var(--motion-ease-standard),
box-shadow 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:focus-visible) {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 2px;
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__title) {
margin-bottom: var(--space-1);
font-size: 12px;
font-weight: 600;
color: hsl(var(--nav-item-active-foreground));
letter-spacing: 0.02em;
color: hsl(var(--text-muted));
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__code) {
margin: 0;
padding: 0;
margin: 0;
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
font-size: 12.5px;
line-height: 1.65;
color: hsl(var(--text-strong));
overflow-wrap: anywhere;
white-space: pre-wrap;
word-break: break-word;
background: transparent;
border: 0;
}
.easyflow-markdown-live-editor :deep(a) {
font-weight: 520;
color: hsl(var(--primary));
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.easyflow-markdown-live-editor :deep(a:hover) {
text-decoration-thickness: 2px;
}
.easyflow-markdown-live-editor :deep(a:focus-visible) {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 2px;
border-radius: 3px;
}
.easyflow-markdown-live-editor :deep(img) {
max-width: 100%;
border-radius: var(--radius);
@@ -500,6 +677,20 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
letter-spacing: -0.012em;
}
.easyflow-markdown-live-editor :deep(strong) {
font-weight: 680;
color: hsl(var(--text-strong));
}
.easyflow-markdown-live-editor :deep(em) {
color: hsl(var(--foreground));
}
.easyflow-markdown-live-editor :deep(del) {
color: hsl(var(--text-muted));
text-decoration-thickness: 1.5px;
}
.easyflow-markdown-live-editor :deep(h1) {
padding-bottom: var(--space-3);
margin-top: 0;
@@ -528,24 +719,119 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
.easyflow-markdown-live-editor :deep(blockquote) {
padding: var(--space-2) var(--space-4);
color: hsl(var(--text-muted));
color: hsl(var(--foreground));
background: hsl(var(--surface-subtle));
border-left: 3px solid hsl(var(--primary) / 48%);
border-left: 3px solid hsl(var(--primary) / 64%);
border-radius: 0 var(--radius-control) var(--radius-control) 0;
}
.easyflow-markdown-live-editor :deep(pre) {
.easyflow-markdown-live-editor :deep(.milkdown-code-block),
.easyflow-markdown-live-editor
:deep(.ProseMirror pre:not(.milkdown-frontmatter__code)) {
padding: var(--space-4);
overflow: auto;
background: hsl(var(--surface-contrast-soft));
color: hsl(var(--text-strong));
background: hsl(var(--surface-subtle));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-control);
}
.easyflow-markdown-live-editor :deep(.milkdown-code-block) {
padding: var(--space-2) var(--space-4) var(--space-4);
box-shadow: var(--shadow-subtle);
}
.easyflow-markdown-live-editor :deep(.milkdown-code-block.selected) {
outline: 2px solid hsl(var(--primary) / 64%);
outline-offset: 1px;
}
.easyflow-markdown-live-editor :deep(.milkdown-code-block .cm-editor),
.easyflow-markdown-live-editor :deep(.milkdown-code-block .cm-gutters) {
color: hsl(var(--text-strong));
background: hsl(var(--surface-subtle));
}
.easyflow-markdown-live-editor :deep(.milkdown-code-block .cm-gutters) {
color: hsl(var(--text-muted));
border-right: 1px solid hsl(var(--line-subtle));
}
.easyflow-markdown-live-editor :deep(.milkdown-code-block .cm-cursor) {
border-left: 2px solid hsl(var(--primary));
}
.easyflow-markdown-live-editor
:deep(.milkdown-code-block .cm-selectionBackground) {
background: hsl(var(--nav-item-active)) !important;
}
.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(.ProseMirror :not(pre) > code) {
display: inline;
padding: 2px 5px;
font-weight: 560;
color: hsl(var(--nav-item-active-foreground));
background: hsl(var(--surface-contrast-soft));
border: 1px solid hsl(var(--line-subtle));
border-radius: 5px;
}
.easyflow-markdown-live-editor :deep(ul > li::marker),
.easyflow-markdown-live-editor :deep(ol > li::marker) {
font-weight: 600;
color: hsl(var(--text-muted));
}
.easyflow-markdown-live-editor :deep(.milkdown-list-item-block .label-wrapper) {
min-height: 26px;
font-weight: 600;
color: hsl(var(--text-muted));
}
.easyflow-markdown-live-editor
:deep(.milkdown-list-item-block .label-wrapper svg) {
color: currentcolor;
fill: currentcolor;
}
.easyflow-markdown-live-editor
:deep(.milkdown-list-item-block .label-wrapper .label) {
height: auto;
padding-block: 0;
line-height: 1.76;
}
.easyflow-markdown-live-editor
:deep(
.milkdown-list-item-block > .list-item > .children > [data-content-dom] > p
) {
margin-block: 0;
}
.easyflow-markdown-live-editor
:deep(
.milkdown-list-item-block
> .list-item
> .children
> [data-content-dom]
> p
+ p
) {
margin-top: var(--space-2);
}
.easyflow-markdown-live-editor :deep(hr) {
height: 1px;
padding: 0;
margin-block: var(--space-6);
background: hsl(var(--line-subtle));
}
.easyflow-markdown-live-editor :deep(table) {
width: 100%;
border-collapse: collapse;
@@ -563,6 +849,155 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
background: hsl(var(--surface-subtle));
}
.easyflow-markdown-live-editor :deep(.crepe-placeholder::before) {
color: hsl(var(--text-muted));
opacity: 0.82;
}
.easyflow-markdown-live-editor :deep(.milkdown-toolbar),
.easyflow-markdown-live-editor :deep(.milkdown-link-preview),
.easyflow-markdown-live-editor :deep(.milkdown-link-edit),
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu),
.easyflow-markdown-live-editor :deep(.language-picker) {
color: hsl(var(--foreground));
background: hsl(var(--surface-elevated));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-toolbar);
box-shadow: var(--shadow-toolbar);
}
.easyflow-markdown-live-editor :deep(.milkdown-block-handle) {
gap: 0;
}
.easyflow-markdown-live-editor
:deep(.milkdown-block-handle .operation-item:first-child) {
display: none;
}
.easyflow-markdown-live-editor
:deep(.milkdown-block-handle .operation-item:last-child) {
width: 30px;
height: 30px;
color: hsl(var(--text-muted));
border-radius: var(--radius-control);
}
.easyflow-markdown-live-editor
:deep(.milkdown-block-handle .operation-item:last-child svg) {
width: 20px;
height: 20px;
fill: currentcolor;
}
.easyflow-markdown-live-editor
:deep(.milkdown-block-handle .operation-item:last-child:hover),
.easyflow-markdown-live-editor
:deep(.milkdown-block-handle .operation-item:last-child:focus-visible) {
color: hsl(var(--nav-item-active-foreground));
background: hsl(var(--nav-item-active));
}
.easyflow-markdown-live-editor
:deep(.milkdown-block-handle .operation-item:last-child:focus-visible) {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 2px;
}
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu) {
min-width: 280px;
max-width: min(320px, calc(100vw - var(--space-8)));
background: hsl(var(--surface-elevated));
}
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .tab-group) {
padding: var(--space-2) var(--space-2) 0;
border-bottom-color: hsl(var(--line-subtle));
}
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .tab-group ul) {
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
}
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .tab-group li) {
padding: 6px var(--space-2);
color: hsl(var(--text-muted));
}
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .tab-group li:hover) {
color: hsl(var(--foreground));
background: hsl(var(--nav-item-hover));
}
.easyflow-markdown-live-editor
:deep(.milkdown-slash-menu .tab-group li.selected) {
color: hsl(var(--nav-item-active-foreground));
background: hsl(var(--nav-item-active));
}
.easyflow-markdown-live-editor
:deep(.milkdown-slash-menu .menu-groups .menu-group h6) {
padding: 10px var(--space-2);
color: hsl(var(--text-muted));
text-transform: none;
}
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .menu-groups) {
max-height: min(216px, 28vh);
padding: 0 var(--space-2) var(--space-2);
}
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .menu-group li) {
gap: var(--space-3);
min-width: 248px;
padding: 10px var(--space-2);
color: hsl(var(--foreground));
}
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .menu-group li:hover),
.easyflow-markdown-live-editor
:deep(.milkdown-slash-menu .menu-group li.hover) {
color: hsl(var(--nav-item-active-foreground));
background: hsl(var(--nav-item-active));
}
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .menu-group li svg) {
color: hsl(var(--text-muted));
fill: hsl(var(--text-muted));
}
.easyflow-markdown-live-editor
:deep(.milkdown-slash-menu .menu-group li:hover svg),
.easyflow-markdown-live-editor
:deep(.milkdown-slash-menu .menu-group li.hover svg) {
color: hsl(var(--primary));
fill: hsl(var(--primary));
}
.easyflow-markdown-live-editor :deep(.milkdown button),
.easyflow-markdown-live-editor :deep(.milkdown input) {
color: hsl(var(--foreground));
border-radius: var(--radius-control);
}
.easyflow-markdown-live-editor :deep(.milkdown button:hover),
.easyflow-markdown-live-editor :deep(.milkdown button:active) {
color: hsl(var(--nav-item-active-foreground));
background: hsl(var(--nav-item-active));
}
.easyflow-markdown-live-editor :deep(.milkdown button:focus-visible),
.easyflow-markdown-live-editor :deep(.milkdown input:focus-visible) {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 2px;
}
.easyflow-markdown-live-editor :deep(.milkdown button:disabled) {
cursor: not-allowed;
opacity: 0.5;
}
@supports not (color: color-mix(in srgb, red, blue)) {
.easyflow-markdown-live-editor :deep(.ProseMirror code),
.easyflow-markdown-live-editor :deep(.ProseMirror pre) {
@@ -599,7 +1034,7 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
: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);
color: hsl(var(--text-muted));
}
.easyflow-markdown-live-editor
@@ -610,7 +1045,7 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
}
.easyflow-markdown-live-editor :deep(.crepe-drop-cursor) {
background-color: var(--crepe-color-outline);
background-color: hsl(var(--primary));
}
}

View File

@@ -64,4 +64,38 @@ describe('live markdown normalization', () => {
const source = '# Title\n\n<br />\n\nText\n';
expect(normalizeLiveMarkdownUpdate(source, source)).toBe(source);
});
it('moves body input after immutable leading frontmatter', () => {
const source = [
'---',
'name: review',
'description: Review contracts',
'---',
'',
'# Instructions',
].join('\n');
const editorMarkdown = [
'New paragraph',
'',
'---',
'name: review',
'description: Review contracts',
'---',
'',
'# Instructions',
].join('\n');
expect(normalizeLiveMarkdownUpdate(source, editorMarkdown)).toBe(
[
'---',
'name: review',
'description: Review contracts',
'---',
'',
'New paragraph',
'',
'# Instructions',
].join('\n'),
);
});
});

View File

@@ -4,6 +4,7 @@ import { unified } from 'unified';
const markdownParser = unified().use(remarkParse).use(remarkGfm);
const STANDALONE_BREAK_LINE = /^[\t ]*<br\s*\/?>[\t ]*$/im;
const LEADING_FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
/**
* Compares Markdown by parsed GFM structure so harmless marker formatting is
@@ -25,11 +26,40 @@ export function hasEquivalentMarkdownSemantics(
* 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');
const orderedMarkdown = keepFrontmatterFirst(source, markdown);
if (STANDALONE_BREAK_LINE.test(source)) return orderedMarkdown;
return orderedMarkdown
.replaceAll(/^[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '')
.replaceAll(/\n{2,}[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '\n\n')
.replaceAll(/\n{2,}[\t ]*<br\s*\/?>[\t ]*$/gi, '\n');
}
/**
* Keeps immutable YAML frontmatter at the start when an editor selection is
* placed before its atom node. The typed body is preserved immediately after
* the metadata block, so the saved file remains valid standard Markdown.
*/
function keepFrontmatterFirst(source: string, markdown: string) {
const sourceMatch = LEADING_FRONTMATTER.exec(source);
if (!sourceMatch) return markdown;
const normalizedMarkdown = markdown
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n');
const frontmatter = `---\n${(sourceMatch[1] || '')
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n')}\n---`;
const frontmatterIndex = normalizedMarkdown.indexOf(frontmatter);
if (frontmatterIndex <= 0) return normalizedMarkdown;
const leadingBody = normalizedMarkdown.slice(0, frontmatterIndex).trim();
const trailingBody = normalizedMarkdown
.slice(frontmatterIndex + frontmatter.length)
.replace(/^\n+/, '');
const body = [leadingBody, trailingBody]
.filter((part) => part.length > 0)
.join('\n\n');
return body ? `${frontmatter}\n\n${body}` : `${frontmatter}\n`;
}
function semanticTree(markdown: string) {

View File

@@ -59,7 +59,7 @@ export const remarkFrontmatterMilkdown = $remark(
/**
* 把 mdast `yaml` 节点映射为 ProseMirror 块节点,作为只读原子块渲染,
* 序列化时再写回 mdast `yaml` 节点。这样 SKILL.md 的 frontmatter 在实时
* 序列化时再写回 mdast `yaml` 节点。这样 Markdown 文档的 frontmatter 在实时
* 编辑模式下既能可视化呈现,又不会被 Milkdown 改写破坏结构。
*/
export const frontmatterSchema = $nodeSchema('frontmatter', () => ({
@@ -91,9 +91,9 @@ export const frontmatterSchema = $nodeSchema('frontmatter', () => ({
role: 'button',
tabindex: '0',
title: '点击进入源码模式编辑',
'aria-label': 'Skill 元数据,点击进入源码模式编辑',
'aria-label': 'YAML 元数据,点击进入源码模式编辑',
},
['div', { class: 'milkdown-frontmatter__title' }, 'Skill 元数据'],
['div', { class: 'milkdown-frontmatter__title' }, 'YAML 元数据'],
['pre', { class: 'milkdown-frontmatter__code' }, node.attrs.value],
],
parseMarkdown: {

View File

@@ -1,10 +1,17 @@
export type EditorLanguage =
| 'css'
| 'html'
| 'java'
| 'javascript'
| 'json'
| 'markdown'
| 'python'
| 'shell'
| 'text';
| 'sql'
| 'text'
| 'typescript'
| 'xml'
| 'yaml';
export interface EditorCursorPosition {
column: number;

View File

@@ -164,8 +164,7 @@ onBeforeUnmount(() =>
overflow: hidden;
background: hsl(var(--surface-panel));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-panel);
box-shadow: var(--shadow-subtle);
border-radius: var(--radius-toolbar);
}
.document-editor-workbench__tree {
@@ -177,6 +176,7 @@ onBeforeUnmount(() =>
}
.document-editor-workbench__main {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;

View File

@@ -5,13 +5,13 @@ import { computed, ref } from 'vue';
import {
ChevronRight,
Ellipsis,
File,
FileCode2,
FileImage,
FileText,
FolderClosed,
FolderOpen,
Ellipsis,
Plus,
} from '@easyflow/icons';
@@ -69,6 +69,7 @@ function activate() {
emit('focusPath', props.node.path);
if (isDirectory.value) {
expanded.value = !expanded.value;
return;
}
emit('select', props.node);
}

View File

@@ -138,7 +138,7 @@ describe('file tree panel keyboard navigation', () => {
expectFocused('references/nested/deep.md');
});
it('selects files and directories with Enter or Space', async () => {
it('selects files while directory activation only toggles expansion', async () => {
const wrapper = mountTree();
treeItem('references/guide.md')?.dispatchEvent(
@@ -153,10 +153,7 @@ describe('file tree panel keyboard navigation', () => {
);
await nextTick();
expect(treeItem('references')?.getAttribute('aria-expanded')).toBe('false');
expect(wrapper.emitted('select')).toHaveLength(2);
expect(wrapper.emitted('select')?.[1]?.[0]).toMatchObject({
path: 'references',
});
expect(wrapper.emitted('select')).toHaveLength(1);
});
it('emits contextual create and file operations from row actions', async () => {