feat: 重塑技能库与统一文件工作台
- 统一分类检索、新建导入和发布交互 - 使用单一文件工作台编辑全部 Skill 资源 - 下沉可复用 Markdown 与代码编辑能力
This commit is contained in:
45
easyflow-ui-admin/packages/@core/ui-kit/editor-ui/README.md
Normal file
45
easyflow-ui-admin/packages/@core/ui-kit/editor-ui/README.md
Normal 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`。
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user