diff --git a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts new file mode 100644 index 0000000..352db65 --- /dev/null +++ b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { editorViewCtx, type Editor } from '@milkdown/kit/core' +import { TextSelection } from '@milkdown/kit/prose/state' +import { getMarkdown } from '@milkdown/kit/utils' +import VisualMarkdownEditor from './VisualMarkdownEditor.vue' + +type EditorComponent = { getEditor: () => Editor | undefined } + +const mounted: VueWrapper[] = [] + +async function waitForEditor(wrapper: VueWrapper): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + const editor = (wrapper.vm as unknown as EditorComponent).getEditor() + if (editor) { + try { + editor.action(getMarkdown()) + return editor + } catch { /* editor is still creating */ } + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } + throw new Error('Milkdown editor did not become ready') +} + +function selectText(editor: Editor, from: number, to: number) { + editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, from, to))) + view.focus() + }) +} + +beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) +}) + +afterEach(() => { + mounted.splice(0).forEach((wrapper) => wrapper.unmount()) + document.body.innerHTML = '' +}) + +describe('VisualMarkdownEditor formatting toolbars', () => { + it('applies bold from the top toolbar to the selected text', async () => { + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + selectText(editor, 1, 6) + + await wrapper.get('[aria-label="加粗"]').trigger('pointerdown') + + expect(editor.action(getMarkdown())).toContain('**alpha** beta') + }) + + it('applies italic from the floating toolbar to the selected text', async () => { + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + selectText(editor, 1, 6) + await new Promise((resolve) => setTimeout(resolve, 80)) + + const floatingItalic = document.querySelector('.milkdown-toolbar [data-toolbar-item="italic"]') + expect(floatingItalic).not.toBeNull() + floatingItalic?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + + expect(editor.action(getMarkdown())).toContain('*alpha* beta') + }) + + it('writes a custom input font size into markdown for the selected text', async () => { + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + selectText(editor, 1, 6) + + await wrapper.get('[aria-label="自定义字号"]').setValue(22) + await wrapper.get('[aria-label="应用自定义字号"]').trigger('pointerdown') + + expect(editor.action(getMarkdown())).toContain('alpha beta') + }) + + it('turns a heading back into a normal paragraph', async () => { + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '# alpha' }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + + await wrapper.get('[aria-label="标题级别"]').setValue('paragraph') + + expect(editor.action(getMarkdown()).trim()).toBe('alpha') + }) +}) diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue index e350ff6..7ac9c7c 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.vue +++ b/frontend/src/features/editor/VisualMarkdownEditor.vue @@ -8,6 +8,7 @@ import { toggleInlineCodeCommand, toggleLinkCommand, toggleStrongCommand, + turnIntoTextCommand, wrapInBulletListCommand, wrapInHeadingCommand, wrapInOrderedListCommand, @@ -28,6 +29,7 @@ const editorStore = useEditorStore() const settingsStore = useSettingsStore() const editorRoot = ref(null) const loading = ref(true) +const fontSizeInput = ref(16) let crepe: Crepe | null = null type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inline-code' | 'code-block' | 'inline-math' | 'math-block' @@ -70,9 +72,11 @@ function applyLink() { } function applyHeading(event: Event) { - const level = Number((event.target as HTMLSelectElement).value) - if (!level || !crepe) return - crepe.editor.action(callCommand(wrapInHeadingCommand.key, level)) + const value = (event.target as HTMLSelectElement).value + if (!value || !crepe) return + crepe.editor.action(value === 'paragraph' + ? callCommand(turnIntoTextCommand.key) + : callCommand(wrapInHeadingCommand.key, Number(value))) editorRoot.value?.querySelector('.ProseMirror')?.focus() ;(event.target as HTMLSelectElement).value = '' } @@ -80,10 +84,19 @@ function applyHeading(event: Event) { function applyFontSize(event: Event) { const size = Number((event.target as HTMLSelectElement).value) if (!size || !crepe) return - applyMarkdownFontSize(crepe.editor, size) + fontSizeInput.value = size + applyFontSizeValue() ;(event.target as HTMLSelectElement).value = '' } +function applyFontSizeValue() { + if (!crepe) return + const size = Math.min(96, Math.max(8, Math.round(Number(fontSizeInput.value)))) + if (!Number.isFinite(size)) return + fontSizeInput.value = size + applyMarkdownFontSize(crepe.editor, size) +} + onMounted(async () => { crepe = new Crepe({ root: editorRoot.value, @@ -117,6 +130,14 @@ onMounted(async () => { confirmButton: '确认', inputPlaceholder: '粘贴链接地址…', }, + [Crepe.Feature.Toolbar]: { + boldLabel: '加粗', + italicLabel: '斜体', + strikethroughLabel: '删除线', + codeLabel: '行内代码', + latexLabel: '行内公式', + linkLabel: '链接', + }, [Crepe.Feature.BlockEdit]: { textGroup: { label: '文本', @@ -159,6 +180,8 @@ onMounted(async () => { }) onBeforeUnmount(() => { void crepe?.destroy() }) + +defineExpose({ getEditor: () => crepe?.editor })