From 7fbaae762292246ce9efe3a19bb306267a045533 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 7 Jul 2026 10:24:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=96=9C=E6=9D=A0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E7=A9=BA=E7=8A=B6=E6=80=81=E6=98=BE=E7=A4=BA=E3=80=8C?= =?UTF-8?q?=E6=97=A0=E5=8C=B9=E9=85=8D=E5=91=BD=E4=BB=A4=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 搜索无结果时浮层显示居中提示(非空白卡片)。同步修两个空列表 bug: - ArrowUp/Down 对 % 0 产生 NaN(除零) - Enter 被吞(preventDefault 后无项可选,用户回车无反应) 空列表时方向键/回车放行(return false),仅 Escape 拦截关闭。 导出 createPopup 补 6 个 happy-dom 用例。 --- .../src/__tests__/slash-command.test.ts | 84 ++++++++++++++++++- libs/tiptap-editor/src/slash-command.ts | 24 ++++-- libs/tiptap-editor/src/style.css | 13 +++ 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/libs/tiptap-editor/src/__tests__/slash-command.test.ts b/libs/tiptap-editor/src/__tests__/slash-command.test.ts index ba7d60d..f365c4f 100644 --- a/libs/tiptap-editor/src/__tests__/slash-command.test.ts +++ b/libs/tiptap-editor/src/__tests__/slash-command.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest'; -import { isValidUrl, matchCommand } from '../slash-command'; +import type { SuggestionProps } from '@tiptap/suggestion'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createPopup, isValidUrl, matchCommand } from '../slash-command'; /** * isValidUrl 纯函数测试。 @@ -133,3 +134,82 @@ describe('matchCommand', () => { }); }); }); + +/** + * createPopup 空状态测试(happy-dom)。 + * + * 搜索无结果时浮层应显示「无匹配命令」提示,而非空白卡片。 + */ +describe('createPopup 空状态', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + /** 构造最小 SuggestionProps mock。 */ + function mockProps( + items: Parameters[0][], + ): SuggestionProps[0]> { + return { + items, + editor: {} as any, + range: {} as any, + query: '', + text: '', + command: () => {}, + clientRect: () => null, + } as unknown as SuggestionProps[0]>; + } + + it('items 为空时显示「无匹配命令」提示', () => { + const popup = createPopup(mockProps([])); + document.body.appendChild(popup.component); + const empty = document.querySelector('.slash-command-empty'); + expect(empty).not.toBeNull(); + expect(empty?.textContent).toBe('无匹配命令'); + // 且不渲染任何列表项 + expect(document.querySelectorAll('.slash-command-item')).toHaveLength(0); + }); + + it('items 非空时不显示空状态提示', () => { + const item = { title: '代码块', description: '插入代码块', icon: '<>', command: () => {} }; + const popup = createPopup(mockProps([item])); + document.body.appendChild(popup.component); + expect(document.querySelector('.slash-command-empty')).toBeNull(); + expect(document.querySelectorAll('.slash-command-item')).toHaveLength(1); + }); + + it('空列表时 Enter 不被拦截(return false,让回车正常输入)', () => { + const popup = createPopup(mockProps([])); + const handled = popup.onKeyDown({ + event: new KeyboardEvent('keydown', { key: 'Enter' }), + } as any); + expect(handled).toBe(false); + }); + + it('空列表时 ArrowUp/Down 不被拦截(避免 % 0 产生 NaN)', () => { + const popup = createPopup(mockProps([])); + expect( + popup.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'ArrowUp' }) } as any), + ).toBe(false); + expect( + popup.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) } as any), + ).toBe(false); + }); + + it('空列表时 Escape 仍关闭浮层(return true)', () => { + const popup = createPopup(mockProps([])); + const handled = popup.onKeyDown({ + event: new KeyboardEvent('keydown', { key: 'Escape' }), + } as any); + expect(handled).toBe(true); + }); + + it('updateItems 传空数组时切换到空状态', () => { + const item = { title: '代码块', description: '插入代码块', icon: '<>', command: () => {} }; + const popup = createPopup(mockProps([item])); + document.body.appendChild(popup.component); + expect(document.querySelector('.slash-command-empty')).toBeNull(); + popup.updateItems([]); + expect(document.querySelector('.slash-command-empty')).not.toBeNull(); + }); +}); diff --git a/libs/tiptap-editor/src/slash-command.ts b/libs/tiptap-editor/src/slash-command.ts index 0074121..a14f7c1 100644 --- a/libs/tiptap-editor/src/slash-command.ts +++ b/libs/tiptap-editor/src/slash-command.ts @@ -298,7 +298,7 @@ export function isValidUrl(url: string): boolean { return /^https?:\/\//i.test(url) || /^data:image\//i.test(url); } -function createPopup(props: SuggestionProps): SlashPopup { +export function createPopup(props: SuggestionProps): SlashPopup { const component = document.createElement('div'); component.classList.add('slash-command'); @@ -314,6 +314,15 @@ function createPopup(props: SuggestionProps): SlashPopup { list.innerHTML = ''; selectedIndex = 0; + // 空状态:显示提示,不渲染列表项。 + if (items.length === 0) { + const empty = document.createElement('div'); + empty.classList.add('slash-command-empty'); + empty.textContent = '无匹配命令'; + list.appendChild(empty); + return; + } + items.forEach((item, index) => { const el = document.createElement('div'); el.classList.add('slash-command-item'); @@ -376,6 +385,15 @@ function createPopup(props: SuggestionProps): SlashPopup { }, updatePosition, onKeyDown({ event }: SuggestionKeyDownProps): boolean { + // 空列表时不拦截键盘:避免 % 0 产生 NaN,也避免吞掉 Enter(让用户正常输入)。 + // Escape 仍拦截(关闭浮层)。 + if (event.key === 'Escape') { + event.preventDefault(); + return true; + } + if (currentItems.length === 0) { + return false; + } if (event.key === 'ArrowUp') { event.preventDefault(); selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length; @@ -393,10 +411,6 @@ function createPopup(props: SuggestionProps): SlashPopup { selectItem(); return true; } - if (event.key === 'Escape') { - event.preventDefault(); - return true; - } return false; }, destroy() { diff --git a/libs/tiptap-editor/src/style.css b/libs/tiptap-editor/src/style.css index 1f02d5f..5b7d356 100644 --- a/libs/tiptap-editor/src/style.css +++ b/libs/tiptap-editor/src/style.css @@ -347,6 +347,19 @@ line-height: 1.3; } +.slash-command-empty { + padding: 12px 16px; + font-size: 13px; + /* Latte overlay0 */ + color: #9ca0b0; + text-align: center; +} + +.dark .slash-command-empty { + /* Mocha overlay0 */ + color: #6c7086; +} + /* ========== Task List (Enhanced) ========== */ .tiptap-editor ul[data-type='taskList'] { list-style: none;