feat(editor): 斜杠命令空状态显示「无匹配命令」
搜索无结果时浮层显示居中提示(非空白卡片)。同步修两个空列表 bug: - ArrowUp/Down 对 % 0 产生 NaN(除零) - Enter 被吞(preventDefault 后无项可选,用户回车无反应) 空列表时方向键/回车放行(return false),仅 Escape 拦截关闭。 导出 createPopup 补 6 个 happy-dom 用例。
This commit is contained in:
parent
9a90514470
commit
7fbaae7622
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user