feat(editor): 斜杠命令空状态显示「无匹配命令」

搜索无结果时浮层显示居中提示(非空白卡片)。同步修两个空列表 bug:
- ArrowUp/Down 对 % 0 产生 NaN(除零)
- Enter 被吞(preventDefault 后无项可选,用户回车无反应)

空列表时方向键/回车放行(return false),仅 Escape 拦截关闭。
导出 createPopup 补 6 个 happy-dom 用例。
This commit is contained in:
xfy 2026-07-07 10:24:54 +08:00
parent 9a90514470
commit 7fbaae7622
3 changed files with 114 additions and 7 deletions

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import type { SuggestionProps } from '@tiptap/suggestion';
import { isValidUrl, matchCommand } from '../slash-command'; import { afterEach, describe, expect, it } from 'vitest';
import { createPopup, isValidUrl, matchCommand } from '../slash-command';
/** /**
* isValidUrl * isValidUrl
@ -133,3 +134,82 @@ describe('matchCommand', () => {
}); });
}); });
}); });
/**
* createPopup happy-dom
*
*
*/
describe('createPopup 空状态', () => {
afterEach(() => {
document.body.innerHTML = '';
});
/** 构造最小 SuggestionProps mock。 */
function mockProps(
items: Parameters<typeof matchCommand>[0][],
): SuggestionProps<Parameters<typeof matchCommand>[0]> {
return {
items,
editor: {} as any,
range: {} as any,
query: '',
text: '',
command: () => {},
clientRect: () => null,
} as unknown as SuggestionProps<Parameters<typeof matchCommand>[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();
});
});

View File

@ -298,7 +298,7 @@ export function isValidUrl(url: string): boolean {
return /^https?:\/\//i.test(url) || /^data:image\//i.test(url); return /^https?:\/\//i.test(url) || /^data:image\//i.test(url);
} }
function createPopup(props: SuggestionProps<CommandItem>): SlashPopup { export function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
const component = document.createElement('div'); const component = document.createElement('div');
component.classList.add('slash-command'); component.classList.add('slash-command');
@ -314,6 +314,15 @@ function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
list.innerHTML = ''; list.innerHTML = '';
selectedIndex = 0; 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) => { items.forEach((item, index) => {
const el = document.createElement('div'); const el = document.createElement('div');
el.classList.add('slash-command-item'); el.classList.add('slash-command-item');
@ -376,6 +385,15 @@ function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
}, },
updatePosition, updatePosition,
onKeyDown({ event }: SuggestionKeyDownProps): boolean { 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') { if (event.key === 'ArrowUp') {
event.preventDefault(); event.preventDefault();
selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length; selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length;
@ -393,10 +411,6 @@ function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
selectItem(); selectItem();
return true; return true;
} }
if (event.key === 'Escape') {
event.preventDefault();
return true;
}
return false; return false;
}, },
destroy() { destroy() {

View File

@ -347,6 +347,19 @@
line-height: 1.3; 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) ========== */ /* ========== Task List (Enhanced) ========== */
.tiptap-editor ul[data-type='taskList'] { .tiptap-editor ul[data-type='taskList'] {
list-style: none; list-style: none;