feat: 支持知识库文档批量删除与分块内联编辑
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user