新增根 biome.json(v2.5,v2 起原生支持 monorepo,子包自动向上查找):
- formatter: 2 空格缩进、单引号、行宽 100、LF、尾逗号、箭头函数括号
(匹配现有代码风格)
- linter: recommended preset + 项目化裁剪
- useTemplate/useTemplate off(geometry.ts 故意保留字符串拼接,
且测试用精确浮点断言锁定行为)
- noNonNullAssertion off(测试与 DOM 访问里的 ! 是惯用写法)
- noConsole off(4 个 lib 用 console.error/warn 做错误上报)
- CSS 关闭 noDescendingSpecificity/noDuplicateProperties
(手写 CSS 的 fallback 模式)
- useImportType/useNodejsImportProtocol/noUnusedVariables warn
- vcs.useIgnoreFile 自动读 .gitignore;排除 public/target/node_modules/
dist/.dioxus/static + Tailwind 入口 input.css(含 @theme 等 Biome 无法
解析的 at-rules)
首次格式化覆盖 29 个源文件:import 排序、node: 协议、引号/缩进统一,
并修复 index.ts 两处 forEach 回调隐式返回值(useIterableCallbackReturn)。
90 个 vitest 用例全绿,确认无语义改动。
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
import { type Editor, Extension, type Range } from '@tiptap/core';
|
||
import { PluginKey } from '@tiptap/pm/state';
|
||
import { Suggestion, type SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion';
|
||
|
||
interface CommandItem {
|
||
title: string;
|
||
description: string;
|
||
icon: string;
|
||
command: (props: { editor: Editor; range: Range }) => void;
|
||
}
|
||
|
||
/**
|
||
* 斜杠命令扩展的选项。
|
||
*
|
||
* `onImageUpload` 由宿主注入(参见 index.ts),用于把用户选择的图片文件
|
||
* 上传到服务端并返回可访问的 URL。未提供时"上传图片"命令会被隐藏,
|
||
* 只保留"图片链接"(手动填 URL)。
|
||
*/
|
||
export interface SlashCommandOptions {
|
||
onImageUpload?: (file: File) => Promise<string>;
|
||
/** 由 index.ts 注入:直接调 coordinator.insertUploading(走占位符 + 上传)。 */
|
||
onInsertUploading?: (file: File) => void;
|
||
}
|
||
|
||
const SlashCommandPluginKey = new PluginKey('slashCommand');
|
||
|
||
/**
|
||
* 斜杠命令扩展。
|
||
*
|
||
* `onImageUpload` 通过 `addOptions` 注入,"上传图片"命令据此决定是否出现。
|
||
*/
|
||
export const SlashCommand = Extension.create<SlashCommandOptions>({
|
||
name: 'slashCommand',
|
||
|
||
addOptions() {
|
||
return {
|
||
onImageUpload: undefined,
|
||
onInsertUploading: undefined,
|
||
};
|
||
},
|
||
|
||
addProseMirrorPlugins() {
|
||
// 依据是否提供上传回调,决定可用命令集。
|
||
const uploadFn = this.options.onImageUpload;
|
||
const COMMANDS: CommandItem[] = [
|
||
{
|
||
title: '标题 1',
|
||
description: '大标题',
|
||
icon: 'H1',
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run();
|
||
},
|
||
},
|
||
{
|
||
title: '标题 2',
|
||
description: '中标题',
|
||
icon: 'H2',
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run();
|
||
},
|
||
},
|
||
{
|
||
title: '标题 3',
|
||
description: '小标题',
|
||
icon: 'H3',
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run();
|
||
},
|
||
},
|
||
{
|
||
title: '无序列表',
|
||
description: '创建无序列表',
|
||
icon: '•',
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||
},
|
||
},
|
||
{
|
||
title: '有序列表',
|
||
description: '创建有序列表',
|
||
icon: '1.',
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||
},
|
||
},
|
||
{
|
||
title: '任务列表',
|
||
description: '创建任务列表',
|
||
icon: '☑',
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||
},
|
||
},
|
||
{
|
||
title: '引用',
|
||
description: '插入引用块',
|
||
icon: '❝',
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||
},
|
||
},
|
||
{
|
||
title: '代码块',
|
||
description: '插入代码块',
|
||
icon: '<>',
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||
},
|
||
},
|
||
{
|
||
title: '分割线',
|
||
description: '插入水平分割线',
|
||
icon: '—',
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||
},
|
||
},
|
||
{
|
||
title: '表格',
|
||
description: '插入 3×3 表格',
|
||
icon: '▦',
|
||
command: ({ editor, range }) => {
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.deleteRange(range)
|
||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||
.run();
|
||
},
|
||
},
|
||
];
|
||
|
||
// 图片相关命令:上传命令仅在上传回调可用时才出现。
|
||
if (uploadFn) {
|
||
COMMANDS.push({
|
||
title: '上传图片',
|
||
description: '从本地选择并上传图片',
|
||
icon: '📤',
|
||
command: ({ editor, range }) => {
|
||
// 必须先删掉 /命令 文本,文件选择对话框会阻塞,关闭后 range 可能失效。
|
||
editor.chain().focus().deleteRange(range).run();
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||
input.addEventListener('change', () => {
|
||
const file = input.files?.[0];
|
||
if (!file) return;
|
||
// 优先走 coordinator(占位符 + 上传),否则退回直接上传(无占位符)
|
||
if (this.options.onInsertUploading) {
|
||
this.options.onInsertUploading(file);
|
||
} else if (uploadFn) {
|
||
uploadFn(file)
|
||
.then((url) => {
|
||
editor.chain().focus().setImage({ src: url }).run();
|
||
})
|
||
.catch((err) => {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
console.error('[SlashCommand] Upload failed:', msg);
|
||
});
|
||
}
|
||
});
|
||
// click() 会立即触发原生文件选择器;回调在用户选择文件后异步执行。
|
||
input.click();
|
||
},
|
||
});
|
||
}
|
||
|
||
COMMANDS.push(
|
||
{
|
||
title: '图片链接',
|
||
description: '通过 URL 插入图片',
|
||
icon: '🖼',
|
||
command: ({ editor, range }) => {
|
||
const url = window.prompt('输入图片 URL');
|
||
if (url && isValidUrl(url)) {
|
||
editor.chain().focus().deleteRange(range).setImage({ src: url }).run();
|
||
}
|
||
},
|
||
},
|
||
{
|
||
title: '链接',
|
||
description: '插入链接',
|
||
icon: '🔗',
|
||
command: ({ editor, range }) => {
|
||
const url = window.prompt('输入链接 URL');
|
||
if (!url || !isValidUrl(url)) return;
|
||
// deleteRange 后光标停在 range.to;先插入 URL 文本,再选中刚插入的范围设 link
|
||
// (setLink 需要非空选区才生效,原顺序 setLink 在空选区无效)。
|
||
const insertFrom = range.to;
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.deleteRange(range)
|
||
.insertContent(url)
|
||
.setTextSelection({ from: insertFrom, to: insertFrom + url.length })
|
||
.setLink({ href: url })
|
||
.run();
|
||
},
|
||
},
|
||
);
|
||
|
||
return [
|
||
Suggestion<CommandItem>({
|
||
pluginKey: SlashCommandPluginKey,
|
||
editor: this.editor,
|
||
char: '/',
|
||
items: ({ query }) => {
|
||
return COMMANDS.filter(
|
||
(item) =>
|
||
item.title.toLowerCase().includes(query.toLowerCase()) ||
|
||
item.description.toLowerCase().includes(query.toLowerCase()),
|
||
);
|
||
},
|
||
render() {
|
||
let popup: SlashPopup | null = null;
|
||
|
||
return {
|
||
onStart(props) {
|
||
popup = createPopup(props);
|
||
},
|
||
onUpdate(props) {
|
||
if (!popup) return;
|
||
popup.updateItems(props.items);
|
||
popup.updatePosition();
|
||
},
|
||
onKeyDown(props) {
|
||
if (!popup) return false;
|
||
return popup.onKeyDown(props);
|
||
},
|
||
onExit() {
|
||
if (popup) {
|
||
popup.destroy();
|
||
popup = null;
|
||
}
|
||
},
|
||
};
|
||
},
|
||
command: ({ editor, range, props: item }) => {
|
||
item.command({ editor, range });
|
||
},
|
||
}),
|
||
];
|
||
},
|
||
});
|
||
|
||
/** 斜杠命令浮层实例:供 Suggestion render 生命周期驱动。 */
|
||
interface SlashPopup {
|
||
component: HTMLElement;
|
||
updateItems(items: CommandItem[]): void;
|
||
updatePosition(): void;
|
||
onKeyDown(props: SuggestionKeyDownProps): boolean;
|
||
destroy(): void;
|
||
}
|
||
|
||
/** 校验图片/链接 URL:只允许 http(s) 和 data:image。拒绝 javascript: 等。 */
|
||
export function isValidUrl(url: string): boolean {
|
||
return /^https?:\/\//i.test(url) || /^data:image\//i.test(url);
|
||
}
|
||
|
||
function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
|
||
const component = document.createElement('div');
|
||
component.classList.add('slash-command');
|
||
|
||
const list = document.createElement('div');
|
||
list.classList.add('slash-command-list');
|
||
component.appendChild(list);
|
||
|
||
let selectedIndex = 0;
|
||
let currentItems: CommandItem[] = [];
|
||
|
||
function renderItems(items: CommandItem[]) {
|
||
currentItems = items;
|
||
list.innerHTML = '';
|
||
selectedIndex = 0;
|
||
|
||
items.forEach((item, index) => {
|
||
const el = document.createElement('div');
|
||
el.classList.add('slash-command-item');
|
||
if (index === 0) el.classList.add('is-selected');
|
||
|
||
el.innerHTML = `
|
||
<div class="slash-command-item-icon">${item.icon}</div>
|
||
<div class="slash-command-item-text">
|
||
<div class="slash-command-item-title">${item.title}</div>
|
||
<div class="slash-command-item-desc">${item.description}</div>
|
||
</div>
|
||
`;
|
||
|
||
el.addEventListener('click', () => {
|
||
props.command(item);
|
||
});
|
||
|
||
el.addEventListener('mouseenter', () => {
|
||
selectedIndex = index;
|
||
updateSelection();
|
||
});
|
||
|
||
list.appendChild(el);
|
||
});
|
||
}
|
||
|
||
function updateSelection() {
|
||
const children = list.children;
|
||
for (let i = 0; i < children.length; i++) {
|
||
if (i === selectedIndex) {
|
||
children[i].classList.add('is-selected');
|
||
} else {
|
||
children[i].classList.remove('is-selected');
|
||
}
|
||
}
|
||
children[selectedIndex]?.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
function selectItem() {
|
||
if (currentItems[selectedIndex]) {
|
||
props.command(currentItems[selectedIndex]);
|
||
}
|
||
}
|
||
|
||
function updatePosition() {
|
||
const rect = props.clientRect?.();
|
||
if (!rect) return;
|
||
component.style.left = `${rect.left}px`;
|
||
component.style.top = `${rect.bottom + 4}px`;
|
||
}
|
||
|
||
renderItems(props.items);
|
||
document.body.appendChild(component);
|
||
updatePosition();
|
||
|
||
return {
|
||
component,
|
||
updateItems(items: CommandItem[]) {
|
||
renderItems(items);
|
||
},
|
||
updatePosition,
|
||
onKeyDown({ event }: SuggestionKeyDownProps): boolean {
|
||
if (event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length;
|
||
updateSelection();
|
||
return true;
|
||
}
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
selectedIndex = (selectedIndex + 1) % currentItems.length;
|
||
updateSelection();
|
||
return true;
|
||
}
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
selectItem();
|
||
return true;
|
||
}
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
destroy() {
|
||
component.remove();
|
||
},
|
||
};
|
||
}
|