yggdrasil/libs/tiptap-editor/src/slash-command.ts
xfy b1b1c88a74 chore(biome): 引入 Biome v2.5 monorepo 配置并格式化全量源码
新增根 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 用例全绿,确认无语义改动。
2026-07-02 17:54:46 +08:00

366 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
},
};
}