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.
139 lines
4.4 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
});
|