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

288 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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