@moontra/moonui-pro
Version:
Premium React components for MoonUI - Advanced UI library with 50+ pro components including performance, interactive, and gesture components
220 lines (190 loc) • 7.08 kB
text/typescript
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { SlashCommand } from '@/lib/ai-providers';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
slashCommands: {
selectSlashCommand: (index: number) => ReturnType;
};
}
}
interface SlashCommandsOptions {
commands: SlashCommand[];
onSelectCommand: (command: SlashCommand) => void;
}
const slashCommandsPluginKey = new PluginKey('slashCommands');
export const SlashCommandsExtension = Extension.create<SlashCommandsOptions>({
name: 'slashCommands',
addOptions() {
return {
commands: [],
onSelectCommand: () => {},
};
},
addCommands() {
return {
selectSlashCommand: (index: number) => ({ editor }) => {
const state = slashCommandsPluginKey.getState(editor.state);
if (state && state.active && state.filteredCommands[index]) {
const command = state.filteredCommands[index];
// Clear the slash command
const { from, to } = state.range;
editor.chain()
.deleteRange({ from, to })
.focus()
.run();
// Execute the command
this.options.onSelectCommand(command);
return true;
}
return false;
},
};
},
addKeyboardShortcuts() {
return {
ArrowUp: ({ editor }) => {
const state = slashCommandsPluginKey.getState(editor.state);
if (state && state.active) {
const newIndex = state.selectedIndex > 0 ? state.selectedIndex - 1 : state.filteredCommands.length - 1;
editor.view.dispatch(
editor.state.tr.setMeta(slashCommandsPluginKey, { selectedIndex: newIndex })
);
return true;
}
return false;
},
ArrowDown: ({ editor }) => {
const state = slashCommandsPluginKey.getState(editor.state);
if (state && state.active) {
const newIndex = state.selectedIndex < state.filteredCommands.length - 1 ? state.selectedIndex + 1 : 0;
editor.view.dispatch(
editor.state.tr.setMeta(slashCommandsPluginKey, { selectedIndex: newIndex })
);
return true;
}
return false;
},
Enter: ({ editor }) => {
const state = slashCommandsPluginKey.getState(editor.state);
if (state && state.active) {
editor.commands.selectSlashCommand(state.selectedIndex);
return true;
}
return false;
},
Escape: ({ editor }) => {
const state = slashCommandsPluginKey.getState(editor.state);
if (state && state.active) {
editor.view.dispatch(
editor.state.tr.setMeta(slashCommandsPluginKey, { active: false })
);
return true;
}
return false;
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: slashCommandsPluginKey,
state: {
init() {
return {
active: false,
range: { from: 0, to: 0 },
query: '',
filteredCommands: [],
selectedIndex: 0,
};
},
apply(transaction, value, oldState, newState) {
const meta = transaction.getMeta(slashCommandsPluginKey);
if (meta) {
return { ...value, ...meta };
}
// Check for slash character
const { selection } = newState;
const { empty, $from } = selection;
if (!empty) {
return { ...value, active: false };
}
const textBefore = $from.parent.textBetween(
Math.max(0, $from.parentOffset - 50),
$from.parentOffset,
null,
'\ufffc'
);
const match = textBefore.match(/\/(\w*)$/);
if (match) {
const query = match[1].toLowerCase();
const filteredCommands = this.options.commands.filter(cmd =>
cmd.command.toLowerCase().includes(query) ||
cmd.description.toLowerCase().includes(query)
);
return {
active: true,
range: {
from: $from.pos - match[0].length,
to: $from.pos,
},
query,
filteredCommands,
selectedIndex: 0,
};
}
return { ...value, active: false };
},
},
props: {
decorations(state) {
const pluginState = this.getState(state);
if (!pluginState.active) {
return DecorationSet.empty;
}
// Slash menu decoration
const { from } = pluginState.range;
const decoration = Decoration.widget(from, () => {
const container = document.createElement('div');
container.className = 'slash-commands-menu';
pluginState.filteredCommands.forEach((cmd, index) => {
const item = document.createElement('div');
item.className = `slash-command-item ${index === pluginState.selectedIndex ? 'selected' : ''}`;
if (cmd.icon) {
const iconWrapper = document.createElement('div');
iconWrapper.innerHTML = cmd.icon;
item.appendChild(iconWrapper);
}
const textWrapper = document.createElement('div');
const commandName = document.createElement('div');
commandName.className = 'command-name';
commandName.textContent = `/${cmd.command}`;
textWrapper.appendChild(commandName);
const commandDesc = document.createElement('div');
commandDesc.className = 'command-description';
commandDesc.textContent = cmd.description;
textWrapper.appendChild(commandDesc);
item.appendChild(textWrapper);
item.addEventListener('click', () => {
const editor = (state as any).editor;
editor.commands.selectSlashCommand(index);
});
container.appendChild(item);
});
if (pluginState.filteredCommands.length === 0) {
const empty = document.createElement('div');
empty.className = 'text-gray-500 p-3 text-sm';
empty.textContent = 'No commands found';
container.appendChild(empty);
}
return container;
});
return DecorationSet.create(state.doc, [decoration]);
},
},
}),
];
},
});