learning_ai_common_plat/packages/rich-text/src/slashMenu.ts
saravanakumardb1 fc8502ac0c feat(rich-text): @bytelyst/rich-text@0.1.0 on Tiptap v3
RichTextEditor (toolbar + slash menu + async mentions, SSR-safe via
immediatelyRender:false) + RichTextViewer (generateHTML, server-renderable) +
standalone Toolbar. Pure filterSlashItems/filterUsers helpers. 12/12 vitest
(incl. live editor mount + bold toggle in happy-dom); tsc clean; published to
Gitea.
2026-05-28 18:20:34 -07:00

139 lines
4.4 KiB
TypeScript

import { Extension } from '@tiptap/core';
import type { Editor, Range } from '@tiptap/core';
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion';
import { ReactRenderer } from '@tiptap/react';
import { SlashMenuList } from './SlashMenuList.js';
export interface SlashItem {
title: string;
description: string;
aliases?: string[];
command: (props: { editor: Editor; range: Range }) => void;
}
/** The built-in block actions offered by the slash menu. */
export const defaultSlashItems: SlashItem[] = [
{
title: 'Heading 1',
description: 'Big section heading',
aliases: ['h1', 'title'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run(),
},
{
title: 'Heading 2',
description: 'Medium section heading',
aliases: ['h2', 'subtitle'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run(),
},
{
title: 'Bullet list',
description: 'Unordered list',
aliases: ['ul', 'unordered'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleBulletList().run(),
},
{
title: 'Numbered list',
description: 'Ordered list',
aliases: ['ol', 'ordered'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleOrderedList().run(),
},
{
title: 'Quote',
description: 'Blockquote',
aliases: ['blockquote'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleBlockquote().run(),
},
{
title: 'Code block',
description: 'Monospaced code',
aliases: ['code', 'pre'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
];
/** Pure, case-insensitive filter over title + aliases — unit-testable. */
export function filterSlashItems(
query: string,
items: SlashItem[] = defaultSlashItems
): SlashItem[] {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(
item =>
item.title.toLowerCase().includes(q) ||
(item.aliases ?? []).some(a => a.toLowerCase().includes(q))
);
}
/**
* `/`-triggered command menu. Built on `@tiptap/suggestion`; the popup is a
* React component positioned at the caret. Only mounts while the user is
* typing a slash command, so it never appears on first render.
*/
export const SlashCommands = Extension.create({
name: 'slashCommands',
addProseMirrorPlugins() {
return [
Suggestion<SlashItem>({
editor: this.editor,
char: '/',
startOfLine: false,
items: ({ query }) => filterSlashItems(query),
command: ({ editor, range, props }) => props.command({ editor, range }),
render: () => {
let component: ReactRenderer<{
onKeyDown: (p: SuggestionKeyDownProps) => boolean;
}> | null = null;
let popup: HTMLDivElement | null = null;
const position = (props: SuggestionProps<SlashItem>) => {
if (!popup) return;
const rect = props.clientRect?.();
if (!rect) return;
popup.style.left = `${rect.left}px`;
popup.style.top = `${rect.bottom + 4}px`;
};
return {
onStart: (props: SuggestionProps<SlashItem>) => {
component = new ReactRenderer(SlashMenuList, { props, editor: props.editor });
popup = document.createElement('div');
popup.setAttribute('data-slash-menu', 'true');
popup.style.position = 'absolute';
popup.style.zIndex = '50';
popup.appendChild(component.element);
document.body.appendChild(popup);
position(props);
},
onUpdate: (props: SuggestionProps<SlashItem>) => {
component?.updateProps(props);
position(props);
},
onKeyDown: (props: SuggestionKeyDownProps) => {
if (props.event.key === 'Escape') {
popup?.remove();
return true;
}
return component?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
popup?.remove();
popup = null;
component?.destroy();
component = null;
},
};
},
}),
];
},
});