import {Mention} from '@tiptap/extension-mention'; import type {Editor, Range} from '@tiptap/core'; import tippy, {Instance as TippyInstance, sticky} from 'tippy.js'; import type {SuggestionProps} from '@tiptap/suggestion'; // This will be set from the component let availableVariables: string[] = []; export function setAvailableVariables(variables: string[]) { availableVariables = variables || []; } // Suggestion component that will be rendered class VariableSuggestionList { public element: HTMLDivElement; private items: string[]; private selectedIndex: number; private command: (props: {id: string}) => void; constructor(props: SuggestionProps) { this.items = Array.isArray(props.items) ? props.items : []; this.selectedIndex = 0; this.command = props.command; this.element = document.createElement('div'); this.element.className = 'variable-suggestion-list'; this.render(); } render() { if (!Array.isArray(this.items) || this.items.length === 0) { this.element.innerHTML = '
No variables found
'; return; } this.element.innerHTML = this.items .map( (item, index) => `
{{${item}}}
`, ) .join(''); // Add click handlers this.element.querySelectorAll('.suggestion-item').forEach((el, index) => { el.addEventListener('click', () => { this.selectItem(index); }); }); } selectItem(index: number) { const item = this.items[index]; if (item && this.command) { this.command({id: item}); } } onKeyDown(event: KeyboardEvent): boolean { if (event.key === 'ArrowUp') { this.upHandler(); return true; } if (event.key === 'ArrowDown') { this.downHandler(); return true; } if (event.key === 'Enter') { this.enterHandler(); return true; } return false; } upHandler() { if (!Array.isArray(this.items) || this.items.length === 0) return; this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length; this.render(); } downHandler() { if (!Array.isArray(this.items) || this.items.length === 0) return; this.selectedIndex = (this.selectedIndex + 1) % this.items.length; this.render(); } enterHandler() { if (!Array.isArray(this.items) || this.items.length === 0) return; this.selectItem(this.selectedIndex); } update(props: SuggestionProps) { this.items = Array.isArray(props.items) ? props.items : []; this.selectedIndex = 0; this.render(); } destroy() { this.element.remove(); } } export const VariableMention = Mention.configure({ HTMLAttributes: { class: 'variable-mention', }, renderLabel({node}) { return `{{${node.attrs.id}}}`; }, suggestion: { char: '{{', items: ({query}) => { const safeVariables = Array.isArray(availableVariables) ? availableVariables : []; const allVariables = ['email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl', ...safeVariables]; const uniqueVariables = Array.from(new Set(allVariables)).filter(v => typeof v === 'string'); if (!query) { return uniqueVariables.slice(0, 10); } const lowerQuery = query.toLowerCase(); return uniqueVariables.filter(item => item.toLowerCase().startsWith(lowerQuery)).slice(0, 10); }, command: ({editor, range, props}: {editor: Editor; range: Range; props: {id: string | null}}) => { // Delete the {{ trigger characters and insert the variable as plain text if (!props.id) return; editor.chain().focus().deleteRange(range).insertContent(`{{${props.id}}}`).run(); }, render: () => { let component: VariableSuggestionList; let popup: TippyInstance[]; let scrollHandler: (() => void) | null = null; return { onStart: (props: SuggestionProps) => { component = new VariableSuggestionList(props); if (!props.clientRect) { return; } popup = tippy('body', { getReferenceClientRect: props.clientRect as () => DOMRect, appendTo: () => document.body, content: component.element, showOnCreate: true, interactive: true, trigger: 'manual', placement: 'bottom-start', theme: 'variable-suggestion', plugins: [sticky], sticky: 'reference', popperOptions: { strategy: 'fixed', }, }); // Update position on scroll scrollHandler = () => { if (popup?.[0] && props.clientRect) { popup[0].setProps({ getReferenceClientRect: props.clientRect as () => DOMRect, }); } }; // Find the scrolling editor container and add listener const editorContainer = document.querySelector('.overflow-y-auto'); if (editorContainer) { editorContainer.addEventListener('scroll', scrollHandler); } window.addEventListener('scroll', scrollHandler, true); }, onUpdate(props: SuggestionProps) { component?.update(props); if (!props.clientRect) { return; } popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect as () => DOMRect, }); }, onKeyDown(props: {event: KeyboardEvent}) { if (props.event.key === 'Escape') { popup?.[0]?.hide(); return true; } return component?.onKeyDown(props.event) || false; }, onExit() { // Clean up scroll listeners if (scrollHandler) { const editorContainer = document.querySelector('.overflow-y-auto'); if (editorContainer) { editorContainer.removeEventListener('scroll', scrollHandler); } window.removeEventListener('scroll', scrollHandler, true); } popup?.[0]?.destroy(); component?.destroy(); }, }; }, }, });