feat: 支持知识库文档批量删除与分块内联编辑

This commit is contained in:
2026-09-04 11:33:09 +08:00
parent cc7f0c1a43
commit ebade41e40
15 changed files with 3473 additions and 499 deletions

View File

@@ -22,6 +22,7 @@ const mockState = vi.hoisted(() => ({
config: undefined as MockCrepeConfig | undefined,
dispatch: vi.fn(),
destroy: vi.fn(async () => undefined),
focus: vi.fn(),
listSpreadNodes: [] as Array<{
attrs: Record<string, unknown>;
marks: unknown[];
@@ -107,7 +108,7 @@ vi.mock('@milkdown/crepe', () => {
};
}
if (slice.name === 'editorView') {
return { dispatch: mockState.dispatch };
return { dispatch: mockState.dispatch, focus: mockState.focus };
}
return undefined;
},
@@ -141,6 +142,7 @@ describe('markdownLiveEditor', () => {
mockState.createPromise = undefined;
mockState.dispatch.mockClear();
mockState.firstNode = undefined;
mockState.focus.mockClear();
mockState.replaceAll.mockClear();
mockState.resolve.mockClear();
mockState.selectionFrom = 2;
@@ -251,6 +253,7 @@ describe('markdownLiveEditor', () => {
const pointerup = vi.fn();
hiddenAddButton.addEventListener('pointerup', pointerup);
trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
trigger.click();
expect(pointerup).toHaveBeenCalledOnce();
trigger.dispatchEvent(
@@ -265,6 +268,297 @@ describe('markdownLiveEditor', () => {
wrapper.unmount();
});
it('adds accessible labels to the selected-text toolbar', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await flushPromises();
const toolbar = document.createElement('div');
toolbar.className = 'milkdown-toolbar';
toolbar.innerHTML = Array.from(
{ length: 5 },
() => '<button class="toolbar-item"></button>',
).join('');
wrapper.get('.easyflow-markdown-live-editor__host').element.append(toolbar);
await vi.waitFor(() => {
expect(toolbar.getAttribute('role')).toBe('toolbar');
expect(toolbar.getAttribute('aria-label')).toBe('文本格式');
expect(
[...toolbar.querySelectorAll('.toolbar-item')].map((item) =>
item.getAttribute('aria-label'),
),
).toEqual(['加粗', '斜体', '删除线', '行内代码', '链接']);
});
wrapper.unmount();
});
it('shows the inline toolbar only for a text selection inside this editor', async () => {
const wrapper = mount(MarkdownLiveEditor, {
attachTo: document.body,
props: { modelValue: '# Initial', variant: 'inline' },
});
await flushPromises();
const host = wrapper.get('.easyflow-markdown-live-editor__host').element;
const text = document.createElement('p');
text.textContent = '可选择文本';
const toolbar = document.createElement('div');
toolbar.className = 'milkdown-toolbar';
toolbar.innerHTML = '<button class="toolbar-item"></button>';
host.append(text, toolbar);
await vi.waitFor(() =>
expect(toolbar.dataset.easyflowVisible).toBe('false'),
);
const range = document.createRange();
range.selectNodeContents(text);
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
document.dispatchEvent(new Event('selectionchange'));
await vi.waitFor(() =>
expect(toolbar.dataset.easyflowVisible).toBe('true'),
);
selection?.collapseToEnd();
document.dispatchEvent(new Event('selectionchange'));
await vi.waitFor(() =>
expect(toolbar.dataset.easyflowVisible).toBe('false'),
);
wrapper.unmount();
});
it('keeps hidden table controls out of navigation and labels them when shown', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await flushPromises();
const tableBlock = document.createElement('div');
tableBlock.className = 'milkdown-table-block';
tableBlock.innerHTML = [
'<div class="handle cell-handle" data-role="col-drag-handle" data-show="false">',
'<div class="button-group"><button></button><button></button><button></button><button></button></div>',
'</div>',
].join('');
wrapper
.get('.easyflow-markdown-live-editor__host')
.element.append(tableBlock);
const handle = tableBlock.querySelector<HTMLElement>('.handle');
expect(handle).not.toBeNull();
if (!handle) {
throw new Error('Expected table handle to exist');
}
await vi.waitFor(() => {
expect(handle.getAttribute('aria-hidden')).toBe('true');
expect(
[...handle.querySelectorAll('button')].map((button) => [
button.getAttribute('aria-label'),
button.getAttribute('tabindex'),
]),
).toEqual([
['左对齐', '-1'],
['居中对齐', '-1'],
['右对齐', '-1'],
['删除列', '-1'],
]);
});
handle.dataset.show = 'true';
await vi.waitFor(() => {
expect(handle.hasAttribute('aria-hidden')).toBe(false);
expect(handle.querySelector('button')?.hasAttribute('tabindex')).toBe(
false,
);
});
wrapper.unmount();
});
it('publishes meaningful toolbar transactions without requiring keyboard input', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await flushPromises();
const toolbar = document.createElement('div');
toolbar.className = 'milkdown-toolbar';
const toolbarButton = document.createElement('button');
toolbarButton.className = 'toolbar-item';
toolbar.append(toolbarButton);
wrapper.get('.easyflow-markdown-live-editor__host').element.append(toolbar);
toolbarButton.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true }),
);
mockState.markdown = '**Initial**';
mockState.markdownUpdated?.({}, '**Initial**');
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
'**Initial**',
]);
wrapper.unmount();
});
it('flushes the current document before emitting the save shortcut', async () => {
const events: string[] = [];
const wrapper = mount(MarkdownLiveEditor, {
props: {
modelValue: '# Initial',
onSave: () => events.push('save'),
'onUpdate:modelValue': (value: string) =>
events.push(`update:${value}`),
},
});
await flushPromises();
mockState.markdown = '# Last input';
await wrapper
.get('.easyflow-markdown-live-editor__host')
.trigger('beforeinput');
await wrapper
.get('.easyflow-markdown-live-editor__host')
.trigger('keydown', { key: 's', metaKey: true });
(wrapper.vm as unknown as { flushMarkdown: () => string }).flushMarkdown();
expect(events).toEqual(['update:# Last input', 'save']);
wrapper.unmount();
});
it('keeps the original source when a flush only changes equivalent formatting', async () => {
const source = '- first\n- second';
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: source },
});
await flushPromises();
mockState.markdown = '* first\n* second';
const flushed = (
wrapper.vm as unknown as { flushMarkdown: () => string }
).flushMarkdown();
expect(flushed).toBe(source);
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
wrapper.unmount();
});
it('does not flush internal document changes before the user edits', async () => {
const source = '# Initial';
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: source },
});
await flushPromises();
mockState.markdown = '## Internal normalization';
const flushed = (
wrapper.vm as unknown as { flushMarkdown: () => string }
).flushMarkdown();
expect(flushed).toBe(source);
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
wrapper.unmount();
});
it('does not treat keyboard text selection as an edit', async () => {
const source = '# Initial';
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: source },
});
await flushPromises();
const host = wrapper.get('.easyflow-markdown-live-editor__host');
await host.trigger('keydown', { key: 'Home' });
await host.trigger('keydown', { key: 'End', shiftKey: true });
mockState.markdown = '## Internal normalization';
const flushed = (
wrapper.vm as unknown as { flushMarkdown: () => string }
).flushMarkdown();
expect(flushed).toBe(source);
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
wrapper.unmount();
});
it('publishes undo and redo changes after a previous update was synchronized', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await flushPromises();
const host = wrapper.get('.easyflow-markdown-live-editor__host');
await host.trigger('beforeinput');
mockState.markdown = '# Edited';
mockState.markdownUpdated?.({}, '# Edited');
await host.trigger('keydown', { key: 'z', metaKey: true });
await wrapper.setProps({ modelValue: '# Edited' });
mockState.markdown = '# Initial';
mockState.markdownUpdated?.({}, '# Initial');
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['# Initial']);
await wrapper.setProps({ modelValue: '# Initial' });
await host.trigger('keydown', { key: 'z', metaKey: true, shiftKey: true });
mockState.markdown = '# Edited';
mockState.markdownUpdated?.({}, '# Edited');
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['# Edited']);
await wrapper.setProps({ modelValue: '# Edited' });
await host.trigger('keydown', { ctrlKey: true, key: 'y' });
mockState.markdown = '# Initial';
expect(
(
wrapper.vm as unknown as { flushMarkdown: () => string }
).flushMarkdown(),
).toBe('# Initial');
wrapper.unmount();
});
it('treats table-handle dragging as a user edit', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await flushPromises();
const handle = document.createElement('div');
handle.className = 'handle';
const tableBlock = document.createElement('div');
tableBlock.className = 'milkdown-table-block';
tableBlock.append(handle);
wrapper
.get('.easyflow-markdown-live-editor__host')
.element.append(tableBlock);
handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
mockState.markdown = '| moved |';
expect(
(
wrapper.vm as unknown as { flushMarkdown: () => string }
).flushMarkdown(),
).toBe('| moved |');
wrapper.unmount();
});
it('focuses without selecting content in the inline autofocus variant', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: {
autofocus: true,
modelValue: '# Initial',
variant: 'inline',
},
});
await flushPromises();
expect(wrapper.get('.easyflow-markdown-live-editor').classes()).toContain(
'is-inline',
);
expect(mockState.focus).toHaveBeenCalledOnce();
expect(mockState.setSelection).not.toHaveBeenCalled();
wrapper.unmount();
});
it('emits the save shortcut only while the editor is writable', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },

View File

@@ -20,17 +20,21 @@ import '@milkdown/crepe/theme/common/style.css';
const props = withDefaults(
defineProps<{
autofocus?: boolean;
modelValue: string;
placeholder?: string;
readonly?: boolean;
resolveImageUrl?: (url: string) => Promise<string> | string;
uploadImage?: (file: File) => Promise<string>;
variant?: 'document' | 'inline';
}>(),
{
autofocus: false,
placeholder: '输入 Markdown 内容…',
readonly: false,
resolveImageUrl: undefined,
uploadImage: undefined,
variant: 'document',
},
);
@@ -55,6 +59,15 @@ let ready = false;
let syncingFromOutside = false;
let synchronizedMarkdown = '';
let domObserver: MutationObserver | undefined;
let selectionFrame: number | undefined;
const ENHANCED_DOM_SELECTOR = [
'a[href]',
'.milkdown-block-handle',
'.milkdown-slash-menu',
'.milkdown-table-block .handle',
'.milkdown-toolbar',
'.toolbar-item',
].join(',');
async function createEditor() {
if (!hostRef.value) return;
@@ -173,6 +186,7 @@ async function createEditor() {
crepe.setReadonly(props.readonly || fidelityLoss.value);
crepeRef.value = crepe;
ready = true;
if (props.autofocus && !props.readonly && !fidelityLoss.value) focus();
emit('ready');
} catch (error_) {
failed.value = true;
@@ -229,14 +243,16 @@ onMounted(() => {
hostRef.value?.addEventListener('click', handleLinkClick);
hostRef.value?.addEventListener('click', handleFrontmatterActivate);
hostRef.value?.addEventListener('beforeinput', prepareUserEdit, true);
hostRef.value?.addEventListener('drop', markUserEdited);
hostRef.value?.addEventListener('drop', markUserEdited, true);
hostRef.value?.addEventListener('keydown', handleKeydown, true);
hostRef.value?.addEventListener('focusout', handleEditorFocusOut, true);
hostRef.value?.addEventListener('paste', prepareUserEdit, true);
hostRef.value?.addEventListener('pointerup', prepareBlockMenuEdit, true);
hostRef.value?.addEventListener('pointerdown', prepareControlEdit, true);
document.addEventListener('selectionchange', handleSelectionChange);
if (hostRef.value) {
domObserver = new MutationObserver(refreshRenderedContent);
domObserver = new MutationObserver(handleDomMutations);
domObserver.observe(hostRef.value, {
attributeFilter: ['href'],
attributeFilter: ['data-show', 'href'],
attributes: true,
childList: true,
subtree: true,
@@ -251,10 +267,13 @@ onBeforeUnmount(async () => {
hostRef.value?.removeEventListener('click', handleLinkClick);
hostRef.value?.removeEventListener('click', handleFrontmatterActivate);
hostRef.value?.removeEventListener('beforeinput', prepareUserEdit, true);
hostRef.value?.removeEventListener('drop', markUserEdited);
hostRef.value?.removeEventListener('drop', markUserEdited, true);
hostRef.value?.removeEventListener('keydown', handleKeydown, true);
hostRef.value?.removeEventListener('focusout', handleEditorFocusOut, true);
hostRef.value?.removeEventListener('paste', prepareUserEdit, true);
hostRef.value?.removeEventListener('pointerup', prepareBlockMenuEdit, true);
hostRef.value?.removeEventListener('pointerdown', prepareControlEdit, true);
document.removeEventListener('selectionchange', handleSelectionChange);
if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame);
domObserver?.disconnect();
domObserver = undefined;
await crepeRef.value?.destroy();
@@ -313,11 +332,17 @@ function markUserEdited() {
hasUserEdited = true;
}
function prepareBlockMenuEdit(event: PointerEvent) {
function prepareControlEdit(event: PointerEvent) {
const target = event.target as HTMLElement | null;
if (
target?.closest(
'.milkdown-block-handle .operation-item, .milkdown-slash-menu .menu-group li',
[
'.milkdown-block-handle .operation-item',
'.milkdown-slash-menu li',
'.milkdown-table-block .handle',
'.milkdown-table-block button',
'.milkdown-toolbar .toolbar-item',
].join(','),
)
) {
markUserEdited();
@@ -329,8 +354,10 @@ function prepareBlockMenuEdit(event: PointerEvent) {
* 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();
function prepareUserEdit(event?: Event) {
if (!(event instanceof KeyboardEvent) || isEditingKey(event)) {
markUserEdited();
}
const crepe = crepeRef.value;
if (!crepe) return;
crepe.editor.action((ctx) => {
@@ -351,6 +378,15 @@ function prepareUserEdit() {
});
}
function isEditingKey(event: KeyboardEvent) {
if (['Backspace', 'Delete', 'Enter', 'Tab'].includes(event.key)) return true;
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) return true;
return (
(event.metaKey || event.ctrlKey) &&
['b', 'i', 'y', 'z'].includes(event.key.toLowerCase())
);
}
function sanitizeRenderedLinks() {
hostRef.value
?.querySelectorAll<HTMLAnchorElement>('a[href]')
@@ -371,6 +407,112 @@ function sanitizeRenderedLinks() {
function refreshRenderedContent() {
sanitizeRenderedLinks();
enhanceBlockMenu();
enhanceTableControls();
enhanceToolbar();
}
function handleDomMutations(records: MutationRecord[]) {
const needsRefresh = records.some((record) => {
if (record.type === 'attributes') return true;
return [...record.addedNodes].some(
(node) =>
node instanceof Element &&
(node.matches(ENHANCED_DOM_SELECTOR) ||
Boolean(node.querySelector(ENHANCED_DOM_SELECTOR))),
);
});
if (needsRefresh) refreshRenderedContent();
}
function enhanceTableControls() {
const host = hostRef.value;
if (!host) return;
const buttonLabels: Record<string, string[]> = {
'col-drag-handle': ['左对齐', '居中对齐', '右对齐', '删除列'],
'row-drag-handle': ['删除行'],
'x-line-drag-handle': ['插入行'],
'y-line-drag-handle': ['插入列'],
};
host
.querySelectorAll<HTMLElement>('.milkdown-table-block .handle[data-role]')
.forEach((handle) => {
const isVisible = handle.dataset.show !== 'false';
if (isVisible) handle.removeAttribute('aria-hidden');
else handle.setAttribute('aria-hidden', 'true');
const labels = buttonLabels[handle.dataset.role || ''] || [];
handle
.querySelectorAll<HTMLButtonElement>('button')
.forEach((button, index) => {
const label = labels[index] || '表格操作';
button.setAttribute('aria-label', label);
button.setAttribute('title', label);
if (isVisible) button.removeAttribute('tabindex');
else button.setAttribute('tabindex', '-1');
});
});
}
function enhanceToolbar() {
const toolbar =
hostRef.value?.querySelector<HTMLElement>('.milkdown-toolbar');
if (!toolbar) return;
toolbar.setAttribute('aria-label', '文本格式');
toolbar.setAttribute('role', 'toolbar');
const labels = ['加粗', '斜体', '删除线', '行内代码', '链接'];
toolbar
.querySelectorAll<HTMLButtonElement>('.toolbar-item')
.forEach((button, index) => {
const label = labels[index] || `格式操作 ${index + 1}`;
button.setAttribute('aria-label', label);
button.setAttribute('title', label);
});
updateInlineToolbarVisibility();
}
function handleSelectionChange() {
if (props.variant !== 'inline') return;
if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame);
selectionFrame = requestAnimationFrame(() => {
selectionFrame = undefined;
updateInlineToolbarVisibility();
});
}
function handleEditorFocusOut() {
if (props.variant !== 'inline') return;
requestAnimationFrame(() => {
if (!hostRef.value?.contains(document.activeElement)) {
setInlineToolbarVisible(false);
}
});
}
function updateInlineToolbarVisibility() {
if (props.variant !== 'inline') return;
const host = hostRef.value;
const selection = document.getSelection();
const visible = Boolean(
host &&
selection &&
!selection.isCollapsed &&
selection.toString().trim() &&
selection.anchorNode &&
selection.focusNode &&
host.contains(selection.anchorNode) &&
host.contains(selection.focusNode),
);
setInlineToolbarVisible(visible);
}
function setInlineToolbarVisible(visible: boolean) {
const toolbar =
hostRef.value?.querySelector<HTMLElement>('.milkdown-toolbar');
if (!toolbar) return;
toolbar.dataset.easyflowVisible = String(visible);
toolbar.setAttribute('aria-hidden', String(!visible));
toolbar
.querySelectorAll<HTMLButtonElement>('.toolbar-item')
.forEach((button) => button.setAttribute('tabindex', visible ? '0' : '-1'));
}
function enhanceBlockMenu() {
@@ -409,12 +551,20 @@ function handleKeydown(event: KeyboardEvent) {
activateBlockMenu(blockMenuTrigger);
return;
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
if (!props.readonly) emit('save');
if (event.key === 'Escape' && props.variant === 'inline') {
document.getSelection()?.collapseToEnd();
setInlineToolbarVisible(false);
return;
}
prepareUserEdit();
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
if (!props.readonly) {
flushMarkdown();
emit('save');
}
return;
}
prepareUserEdit(event);
}
function reportFidelityLoss(crepe: Crepe) {
@@ -458,10 +608,47 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
});
return repaired;
}
function flushMarkdown() {
const crepe = crepeRef.value;
if (!crepe || !ready || fidelityLoss.value) return props.modelValue || '';
if (!hasUserEdited) return props.modelValue || '';
normalizeListSpreadAttributes(crepe);
const nextMarkdown = normalizeLiveMarkdownUpdate(
props.modelValue || '',
crepe.getMarkdown(),
);
const hasMeaningfulDifference = hasMeaningfulMarkdownDifference(
props.modelValue || '',
nextMarkdown,
);
if (!hasMeaningfulDifference) {
synchronizedMarkdown = nextMarkdown;
return props.modelValue || '';
}
const shouldEmit =
!syncingFromOutside &&
nextMarkdown !== props.modelValue &&
nextMarkdown !== synchronizedMarkdown;
synchronizedMarkdown = nextMarkdown;
if (shouldEmit) emit('update:modelValue', nextMarkdown);
return nextMarkdown;
}
function focus() {
const crepe = crepeRef.value;
if (!crepe || props.readonly || fidelityLoss.value) return;
crepe.editor.action((ctx) => ctx.get(editorViewCtx).focus());
}
defineExpose({ flushMarkdown, focus });
</script>
<template>
<div class="easyflow-markdown-live-editor">
<div
class="easyflow-markdown-live-editor"
:class="[`is-${variant}`, { 'is-readonly': readonly || fidelityLoss }]"
>
<div
ref="hostRef"
class="easyflow-markdown-live-editor__host"
@@ -998,6 +1185,128 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
opacity: 0.5;
}
.easyflow-markdown-live-editor.is-inline {
min-height: 64px;
overflow: visible;
scrollbar-gutter: auto;
font-size: 14px;
line-height: 1.72;
background: transparent;
}
.easyflow-markdown-live-editor.is-inline .easyflow-markdown-live-editor__host,
.easyflow-markdown-live-editor.is-inline :deep(.milkdown) {
min-height: 0;
}
.easyflow-markdown-live-editor.is-inline :deep(.milkdown) {
--crepe-color-selected: hsl(var(--text-muted) / 38%);
}
.easyflow-markdown-live-editor.is-inline :deep(.ProseMirror) {
width: 100%;
min-height: 64px;
padding: var(--space-1) 0;
margin: 0;
font-size: inherit;
line-height: inherit;
}
.easyflow-markdown-live-editor.is-inline :deep(.ProseMirror > p) {
padding: 0;
margin: 0;
font-size: inherit;
line-height: inherit;
}
.easyflow-markdown-live-editor.is-inline :deep(.ProseMirror::selection),
.easyflow-markdown-live-editor.is-inline :deep(.ProseMirror *::selection) {
color: hsl(var(--text-strong));
background: hsl(var(--text-muted) / 38%);
}
.easyflow-markdown-live-editor.is-inline
:deep(.ProseMirror .ProseMirror-selectednode) {
background: hsl(var(--text-muted) / 14%);
outline: 1px solid hsl(var(--text-muted) / 52%);
outline-offset: 1px;
}
.easyflow-markdown-live-editor.is-inline :deep(.milkdown-block-handle) {
display: none;
}
.easyflow-markdown-live-editor.is-inline :deep(.milkdown-toolbar) {
max-width: calc(100vw - var(--space-8));
overflow-x: auto;
}
.easyflow-markdown-live-editor.is-inline
:deep(.milkdown-toolbar[data-easyflow-visible='false']) {
visibility: hidden;
pointer-events: none;
opacity: 0;
}
.easyflow-markdown-live-editor.is-inline
:deep(.milkdown-toolbar .toolbar-item) {
width: 28px;
height: 28px;
padding: var(--space-1);
margin: var(--space-1);
}
.easyflow-markdown-live-editor.is-inline
:deep(.milkdown-toolbar .toolbar-item svg) {
width: 20px;
height: 20px;
color: hsl(var(--text-muted));
fill: hsl(var(--text-muted));
}
.easyflow-markdown-live-editor.is-inline
:deep(.milkdown-toolbar .toolbar-item.active svg) {
color: hsl(var(--text-strong));
fill: hsl(var(--text-strong));
}
.easyflow-markdown-live-editor.is-inline :deep(.milkdown-toolbar .divider) {
height: 20px;
margin: var(--space-2) var(--space-1);
}
.easyflow-markdown-live-editor.is-inline :deep(.milkdown-table-block) {
max-width: 100%;
overflow: visible;
}
.easyflow-markdown-live-editor.is-inline
:deep(.milkdown-table-block .table-wrapper) {
overflow: visible;
}
.easyflow-markdown-live-editor.is-inline
:deep(.milkdown-table-block .table-wrapper > table.children) {
display: block;
width: 100%;
max-width: 100%;
overflow-x: auto;
}
.easyflow-markdown-live-editor.is-inline :deep(th),
.easyflow-markdown-live-editor.is-inline :deep(td) {
min-width: 8rem;
overflow-wrap: normal;
word-break: normal;
}
.easyflow-markdown-live-editor.is-readonly :deep(.milkdown-toolbar),
.easyflow-markdown-live-editor.is-readonly :deep(.milkdown-block-handle),
.easyflow-markdown-live-editor.is-readonly :deep(.milkdown-table-block .handle),
.easyflow-markdown-live-editor.is-readonly :deep(.milkdown-slash-menu) {
display: none !important;
}
@supports not (color: color-mix(in srgb, red, blue)) {
.easyflow-markdown-live-editor :deep(.ProseMirror code),
.easyflow-markdown-live-editor :deep(.ProseMirror pre) {

View File

@@ -1,5 +1,9 @@
export { default as CodeEditor } from './CodeEditor.vue';
export { default as CodeViewer } from './CodeViewer.vue';
export {
containsRawHtml,
hasEquivalentMarkdownSemantics,
} from './markdown-fidelity';
export * from './markdown-security';
export { default as MarkdownLiveEditor } from './MarkdownLiveEditor.vue';
export { default as MarkdownSourceEditor } from './MarkdownSourceEditor.vue';

View File

@@ -1,10 +1,19 @@
import { describe, expect, it } from 'vitest';
import {
containsRawHtml,
hasEquivalentMarkdownSemantics,
normalizeLiveMarkdownUpdate,
} from './markdown-fidelity';
describe('raw HTML detection', () => {
it('detects rendered HTML but ignores HTML-looking text inside code', () => {
expect(containsRawHtml('<table><tr><td>A</td></tr></table>')).toBe(true);
expect(containsRawHtml('`<table>`')).toBe(false);
expect(containsRawHtml('```html\n<table>\n```')).toBe(false);
});
});
describe('markdown semantic fidelity', () => {
it('accepts equivalent list markers, rules, and table delimiter widths', () => {
const source = [

View File

@@ -21,6 +21,24 @@ export function hasEquivalentMarkdownSemantics(
}
}
/**
* Raw HTML is preserved as text by the live editor for safety, so callers that
* promise rendered editing should use their source-mode fallback instead.
*/
export function containsRawHtml(markdown: string) {
try {
const nodes: MarkdownNode[] = [markdownParser.parse(markdown)];
while (nodes.length > 0) {
const node = nodes.pop();
if (node?.type === 'html') return true;
if (node?.children) nodes.push(...node.children);
}
return false;
} catch {
return false;
}
}
/**
* Removes blank-paragraph markers generated by the live editor without
* changing an explicit HTML break that already exists in the source.
@@ -71,3 +89,8 @@ function semanticTree(markdown: string) {
(key, value) => (key === 'position' ? undefined : value),
);
}
type MarkdownNode = {
children?: MarkdownNode[];
type?: string;
};