rspace-online/modules/rnotes/components/slash-command.ts

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