270 lines
6.8 KiB
TypeScript
270 lines
6.8 KiB
TypeScript
/**
|
|
* Slash command ProseMirror plugin for Tiptap.
|
|
*
|
|
* Detects '/' typed at the start of an empty block and shows a floating menu
|
|
* with block type options. Keyboard navigation: arrow keys + Enter + Escape.
|
|
*/
|
|
|
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
import type { EditorView } from '@tiptap/pm/view';
|
|
import type { Editor } from '@tiptap/core';
|
|
|
|
export interface SlashMenuItem {
|
|
title: string;
|
|
icon: string;
|
|
description: string;
|
|
command: (editor: Editor) => void;
|
|
}
|
|
|
|
export const SLASH_ITEMS: SlashMenuItem[] = [
|
|
{
|
|
title: 'Text',
|
|
icon: 'Aa',
|
|
description: 'Plain paragraph text',
|
|
command: (e) => e.chain().focus().setParagraph().run(),
|
|
},
|
|
{
|
|
title: 'Heading 1',
|
|
icon: 'H1',
|
|
description: 'Large section heading',
|
|
command: (e) => e.chain().focus().setHeading({ level: 1 }).run(),
|
|
},
|
|
{
|
|
title: 'Heading 2',
|
|
icon: 'H2',
|
|
description: 'Medium section heading',
|
|
command: (e) => e.chain().focus().setHeading({ level: 2 }).run(),
|
|
},
|
|
{
|
|
title: 'Heading 3',
|
|
icon: 'H3',
|
|
description: 'Small section heading',
|
|
command: (e) => e.chain().focus().setHeading({ level: 3 }).run(),
|
|
},
|
|
{
|
|
title: 'Bullet List',
|
|
icon: '•',
|
|
description: 'Unordered bullet list',
|
|
command: (e) => e.chain().focus().toggleBulletList().run(),
|
|
},
|
|
{
|
|
title: 'Numbered List',
|
|
icon: '1.',
|
|
description: 'Ordered numbered list',
|
|
command: (e) => e.chain().focus().toggleOrderedList().run(),
|
|
},
|
|
{
|
|
title: 'Task List',
|
|
icon: '☑',
|
|
description: 'Checklist with checkboxes',
|
|
command: (e) => e.chain().focus().toggleTaskList().run(),
|
|
},
|
|
{
|
|
title: 'Code Block',
|
|
icon: '</>',
|
|
description: 'Syntax-highlighted code block',
|
|
command: (e) => e.chain().focus().toggleCodeBlock().run(),
|
|
},
|
|
{
|
|
title: 'Blockquote',
|
|
icon: '“',
|
|
description: 'Indented quote block',
|
|
command: (e) => e.chain().focus().toggleBlockquote().run(),
|
|
},
|
|
{
|
|
title: 'Horizontal Rule',
|
|
icon: '—',
|
|
description: 'Visual divider line',
|
|
command: (e) => e.chain().focus().setHorizontalRule().run(),
|
|
},
|
|
{
|
|
title: 'Image',
|
|
icon: '📷',
|
|
description: 'Insert an image from URL',
|
|
command: (e) => {
|
|
const url = prompt('Image URL:');
|
|
if (url) e.chain().focus().setImage({ src: url }).run();
|
|
},
|
|
},
|
|
];
|
|
|
|
const pluginKey = new PluginKey('slashCommand');
|
|
|
|
export function createSlashCommandPlugin(editor: Editor, shadowRoot: ShadowRoot): Plugin {
|
|
let menuEl: HTMLDivElement | null = null;
|
|
let selectedIndex = 0;
|
|
let filteredItems: SlashMenuItem[] = [];
|
|
let query = '';
|
|
let active = false;
|
|
let triggerPos = -1;
|
|
|
|
function show(view: EditorView) {
|
|
if (!menuEl) {
|
|
menuEl = document.createElement('div');
|
|
menuEl.className = 'slash-menu';
|
|
shadowRoot.appendChild(menuEl);
|
|
}
|
|
active = true;
|
|
selectedIndex = 0;
|
|
query = '';
|
|
filteredItems = SLASH_ITEMS;
|
|
updateMenuContent();
|
|
positionMenu(view);
|
|
menuEl.style.display = 'block';
|
|
}
|
|
|
|
function hide() {
|
|
active = false;
|
|
query = '';
|
|
triggerPos = -1;
|
|
if (menuEl) menuEl.style.display = 'none';
|
|
}
|
|
|
|
function updateMenuContent() {
|
|
if (!menuEl) return;
|
|
menuEl.innerHTML = filteredItems
|
|
.map(
|
|
(item, i) =>
|
|
`<div class="slash-menu-item${i === selectedIndex ? ' selected' : ''}" data-index="${i}">
|
|
<span class="slash-menu-icon">${item.icon}</span>
|
|
<div class="slash-menu-text">
|
|
<div class="slash-menu-title">${item.title}</div>
|
|
<div class="slash-menu-desc">${item.description}</div>
|
|
</div>
|
|
</div>`,
|
|
)
|
|
.join('');
|
|
|
|
// Click handlers
|
|
menuEl.querySelectorAll('.slash-menu-item').forEach((el) => {
|
|
el.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
const idx = parseInt((el as HTMLElement).dataset.index || '0');
|
|
executeItem(idx);
|
|
});
|
|
el.addEventListener('mouseenter', () => {
|
|
selectedIndex = parseInt((el as HTMLElement).dataset.index || '0');
|
|
updateMenuContent();
|
|
});
|
|
});
|
|
}
|
|
|
|
function positionMenu(view: EditorView) {
|
|
if (!menuEl) return;
|
|
const { from } = view.state.selection;
|
|
const coords = view.coordsAtPos(from);
|
|
const shadowHost = shadowRoot.host as HTMLElement;
|
|
const hostRect = shadowHost.getBoundingClientRect();
|
|
|
|
menuEl.style.left = `${coords.left - hostRect.left}px`;
|
|
menuEl.style.top = `${coords.bottom - hostRect.top + 4}px`;
|
|
}
|
|
|
|
function filterItems() {
|
|
const q = query.toLowerCase();
|
|
filteredItems = q
|
|
? SLASH_ITEMS.filter(
|
|
(item) =>
|
|
item.title.toLowerCase().includes(q) || item.description.toLowerCase().includes(q),
|
|
)
|
|
: SLASH_ITEMS;
|
|
selectedIndex = Math.min(selectedIndex, Math.max(0, filteredItems.length - 1));
|
|
updateMenuContent();
|
|
}
|
|
|
|
function executeItem(index: number) {
|
|
const item = filteredItems[index];
|
|
if (!item) return;
|
|
|
|
// Delete the slash + query text
|
|
const { state } = editor.view;
|
|
const tr = state.tr.delete(triggerPos, state.selection.from);
|
|
editor.view.dispatch(tr);
|
|
|
|
item.command(editor);
|
|
hide();
|
|
}
|
|
|
|
return new Plugin({
|
|
key: pluginKey,
|
|
props: {
|
|
handleKeyDown(view, event) {
|
|
if (active) {
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
selectedIndex = (selectedIndex + 1) % filteredItems.length;
|
|
updateMenuContent();
|
|
return true;
|
|
}
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
selectedIndex = (selectedIndex - 1 + filteredItems.length) % filteredItems.length;
|
|
updateMenuContent();
|
|
return true;
|
|
}
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
executeItem(selectedIndex);
|
|
return true;
|
|
}
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
hide();
|
|
return true;
|
|
}
|
|
if (event.key === 'Backspace') {
|
|
if (query.length === 0) {
|
|
// Backspace deletes the '/', close menu
|
|
hide();
|
|
return false; // let ProseMirror handle the deletion
|
|
}
|
|
query = query.slice(0, -1);
|
|
filterItems();
|
|
return false; // let ProseMirror handle the deletion
|
|
}
|
|
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
|
|
query += event.key;
|
|
filterItems();
|
|
if (filteredItems.length === 0) {
|
|
hide();
|
|
}
|
|
return false; // let ProseMirror insert the character
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
|
|
handleTextInput(view, from, to, text) {
|
|
if (text === '/' && !active) {
|
|
// Check if cursor is at start of an empty block
|
|
const { $from } = view.state.selection;
|
|
const isAtStart = $from.parentOffset === 0;
|
|
const isEmpty = $from.parent.textContent === '';
|
|
if (isAtStart && isEmpty) {
|
|
triggerPos = from;
|
|
// Defer show to after the '/' is inserted
|
|
setTimeout(() => show(view), 0);
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
|
|
view() {
|
|
return {
|
|
update(view) {
|
|
if (active && menuEl) {
|
|
positionMenu(view);
|
|
}
|
|
},
|
|
destroy() {
|
|
if (menuEl) {
|
|
menuEl.remove();
|
|
menuEl = null;
|
|
}
|
|
},
|
|
};
|
|
},
|
|
});
|
|
}
|