288 lines
11 KiB
TypeScript
288 lines
11 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';
|
||
|
||
/** Inline SVG icons for slash menu items (16×16, stroke-based, currentColor) */
|
||
const SLASH_ICONS: Record<string, string> = {
|
||
text: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="4" y1="3" x2="12" y2="3"/><line x1="8" y1="3" x2="8" y2="13"/><line x1="6" y1="13" x2="10" y2="13"/></svg>',
|
||
heading1: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 3v10M8 3v10M2 8h6"/><text x="10.5" y="13" font-size="7" fill="currentColor" stroke="none" font-family="system-ui" font-weight="700">1</text></svg>',
|
||
heading2: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 3v10M8 3v10M2 8h6"/><text x="10.5" y="13" font-size="7" fill="currentColor" stroke="none" font-family="system-ui" font-weight="700">2</text></svg>',
|
||
heading3: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 3v10M8 3v10M2 8h6"/><text x="10.5" y="13" font-size="7" fill="currentColor" stroke="none" font-family="system-ui" font-weight="700">3</text></svg>',
|
||
bulletList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="6" y1="4" x2="14" y2="4"/><line x1="6" y1="8" x2="14" y2="8"/><line x1="6" y1="12" x2="14" y2="12"/><circle cx="3" cy="4" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="8" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="12" r="1" fill="currentColor" stroke="none"/></svg>',
|
||
orderedList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="14" y2="12"/><text x="1.5" y="5.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">1</text><text x="1.5" y="9.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">2</text><text x="1.5" y="13.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">3</text></svg>',
|
||
taskList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="5" height="5" rx="1"/><polyline points="3.5 4.5 4.5 5.5 6 3.5"/><line x1="9" y1="4.5" x2="14" y2="4.5"/><rect x="2" y="9" width="5" height="5" rx="1"/><line x1="9" y1="11.5" x2="14" y2="11.5"/></svg>',
|
||
codeBlock: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="5 6 3.5 8 5 10"/><polyline points="11 6 12.5 8 11 10"/><line x1="9" y1="5" x2="7" y2="11"/></svg>',
|
||
blockquote: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="2" x2="3" y2="14"/><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="12" y2="12"/></svg>',
|
||
horizontalRule: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="2" y1="8" x2="14" y2="8"/><circle cx="4" cy="8" r="0.5" fill="currentColor"/><circle cx="8" cy="8" r="0.5" fill="currentColor"/><circle cx="12" cy="8" r="0.5" fill="currentColor"/></svg>',
|
||
image: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="13" height="11" rx="2"/><circle cx="5.5" cy="6" r="1.5"/><path d="M14.5 10.5l-3.5-3.5-5 5"/></svg>',
|
||
};
|
||
|
||
export interface SlashMenuItem {
|
||
title: string;
|
||
icon: string;
|
||
description: string;
|
||
command: (editor: Editor) => void;
|
||
}
|
||
|
||
export const SLASH_ITEMS: SlashMenuItem[] = [
|
||
{
|
||
title: 'Text',
|
||
icon: 'text',
|
||
description: 'Plain paragraph text',
|
||
command: (e) => e.chain().focus().setParagraph().run(),
|
||
},
|
||
{
|
||
title: 'Heading 1',
|
||
icon: 'heading1',
|
||
description: 'Large section heading',
|
||
command: (e) => e.chain().focus().setHeading({ level: 1 }).run(),
|
||
},
|
||
{
|
||
title: 'Heading 2',
|
||
icon: 'heading2',
|
||
description: 'Medium section heading',
|
||
command: (e) => e.chain().focus().setHeading({ level: 2 }).run(),
|
||
},
|
||
{
|
||
title: 'Heading 3',
|
||
icon: 'heading3',
|
||
description: 'Small section heading',
|
||
command: (e) => e.chain().focus().setHeading({ level: 3 }).run(),
|
||
},
|
||
{
|
||
title: 'Bullet List',
|
||
icon: 'bulletList',
|
||
description: 'Unordered bullet list',
|
||
command: (e) => e.chain().focus().toggleBulletList().run(),
|
||
},
|
||
{
|
||
title: 'Numbered List',
|
||
icon: 'orderedList',
|
||
description: 'Ordered numbered list',
|
||
command: (e) => e.chain().focus().toggleOrderedList().run(),
|
||
},
|
||
{
|
||
title: 'Task List',
|
||
icon: 'taskList',
|
||
description: 'Checklist with checkboxes',
|
||
command: (e) => e.chain().focus().toggleTaskList().run(),
|
||
},
|
||
{
|
||
title: 'Code Block',
|
||
icon: 'codeBlock',
|
||
description: 'Syntax-highlighted code block',
|
||
command: (e) => e.chain().focus().toggleCodeBlock().run(),
|
||
},
|
||
{
|
||
title: 'Blockquote',
|
||
icon: 'blockquote',
|
||
description: 'Indented quote block',
|
||
command: (e) => e.chain().focus().toggleBlockquote().run(),
|
||
},
|
||
{
|
||
title: 'Horizontal Rule',
|
||
icon: 'horizontalRule',
|
||
description: 'Visual divider line',
|
||
command: (e) => e.chain().focus().setHorizontalRule().run(),
|
||
},
|
||
{
|
||
title: 'Image',
|
||
icon: 'image',
|
||
description: 'Insert an image from URL',
|
||
command: (e) => {
|
||
// Dispatch custom event for parent to show URL popover
|
||
const event = new CustomEvent('slash-insert-image', { bubbles: true, composed: true });
|
||
(e.view.dom as HTMLElement).dispatchEvent(event);
|
||
},
|
||
},
|
||
];
|
||
|
||
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 = `<div class="slash-menu__header">Insert block</div>` +
|
||
filteredItems
|
||
.map(
|
||
(item, i) =>
|
||
`<div class="slash-menu-item${i === selectedIndex ? ' selected' : ''}" data-index="${i}">
|
||
<span class="slash-menu-icon">${SLASH_ICONS[item.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>
|
||
${i === selectedIndex ? '<span class="slash-menu-hint">Enter</span>' : ''}
|
||
</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;
|
||
}
|
||
},
|
||
};
|
||
},
|
||
});
|
||
}
|