@sertxudeveloper/markdown-editor
Version:
A customizable markdown editor for your projects
299 lines (226 loc) • 9.67 kB
text/typescript
import Plugin from '../Plugin';
import Editor from '../../Editor';
import { debounce } from 'lodash';
import getCaretCoordinates from 'textarea-caret';
import { expandSelectedText, insertText, isAtCursor } from '../../utils/Utils';
export type FeedItem = {
id: string;
name: string;
};
export type MentionFeed = {
prefix: string;
feed: [FeedItem] | Function;
minimumCharacters?: number;
pattern?: RegExp;
itemRenderer?: Function;
};
export type Mention = {
mention: string;
start: number;
end: number;
};
export default class Mentions extends Plugin {
feeds: MentionFeed[];
debouncedHandler = debounce(this.handleMentions, 100);
mentionsListbox?: HTMLDivElement;
constructor(editor: Editor) {
super(editor);
this.initializeFeeds();
}
getKey(): string {
return 'mentions';
}
getTitle(): string {
return 'Mentions';
}
getIcon(): string {
return '';
}
onKeyDown(event: KeyboardEvent): void {
if (this.editor.config.mentions.length === 0) return;
this.editor.textarea.addEventListener('keyup', this.onKeyUp.bind(this), { once: true });
if (this.mentionsListbox) {
let items = this.mentionsListbox.querySelectorAll('.mentions-listbox-item');
if (event.key === 'ArrowUp') {
event.preventDefault();
let current = this.mentionsListbox.querySelector('.mentions-listbox-item-active');
if (!current) {
items[items.length - 1].classList.add('mentions-listbox-item-active');
} else {
let index = Array.from(items).indexOf(current);
current.classList.remove('mentions-listbox-item-active');
if (index > 0) {
items[index - 1].classList.add('mentions-listbox-item-active');
} else {
items[items.length - 1].classList.add('mentions-listbox-item-active');
}
}
} else if (event.key === 'ArrowDown') {
event.preventDefault();
let current = this.mentionsListbox.querySelector('.mentions-listbox-item-active');
if (!current) {
items[0].classList.add('mentions-listbox-item-active');
} else {
let index = Array.from(items).indexOf(current);
current.classList.remove('mentions-listbox-item-active');
if (index + 1 < items.length) {
items[index + 1].classList.add('mentions-listbox-item-active');
} else {
items[0].classList.add('mentions-listbox-item-active');
}
}
} else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
let current: HTMLDivElement = this.mentionsListbox.querySelector(
'.mentions-listbox-item-active'
);
if (current) {
current.click();
}
}
}
}
/** Use keyup instead of keydown event, pressed character not inserted until keyup */
onKeyUp(event: KeyboardEvent): void {
// Check if the key is a letter or a number
if (event.key.length > 1 && event.key !== 'Backspace' && event.key !== 'Delete') return;
this.debouncedHandler(event);
}
execute(value: string = ''): void {
// throw new Error("Method not implemented.");
}
extractMentions(text: string): Mention[] {
let mention;
let mentions = [];
// Get the mentions from each feed
for (const feed of this.feeds) {
while ((mention = feed.pattern.exec(text)) !== null) {
mentions.push({
mention: mention[0],
start: mention.index,
end: mention.index + mention[0].length,
});
}
}
return mentions;
}
initializeFeeds() {
const feedsWithPattern = this.editor.config.mentions.map((feed) => ({
...feed,
pattern: this.createRegExp(feed.prefix, feed.minimumCharacters || 0),
}));
this.feeds = feedsWithPattern;
}
createRegExp(prefix: string, minimumCharacters: number): RegExp {
return new RegExp(
`${prefix}[a-z\\d]*(?:[a-z\\d.]|-(?=[a-z\\d.])){${minimumCharacters},38}(?!\\w)`,
'gi'
);
}
handleMentions(event: KeyboardEvent): void {
const textarea = this.editor.textarea;
let mentions = this.extractMentions(textarea.value);
if (!mentions.length) return this.destroyMentionsListbox();
const shouldSearch = mentions.some(({ mention, start, end }) => {
if (isAtCursor(textarea, start, end)) {
let [cursorTop, cursorLeft] = this.updateListboxPosition(textarea, start);
// Split the prefix and the search term
let prefix = mention.substring(0, 1);
let search = mention.substring(1);
this.createMentionsListbox(prefix, search, cursorTop, cursorLeft);
return true;
}
return false;
});
if (!shouldSearch) return this.destroyMentionsListbox();
}
updateListboxPosition(textarea: HTMLTextAreaElement, position: number): [string, string] {
const coordinates = getCaretCoordinates(textarea, position);
let cursorTop = coordinates.top + 25 + 'px';
let cursorLeft = coordinates.left + 'px';
return [cursorTop, cursorLeft];
}
destroyMentionsListbox(): void {
if (this.mentionsListbox) this.mentionsListbox.remove();
this.mentionsListbox = null;
}
createMentionsListbox(
prefix: string,
search: string,
cursorTop: string,
cursorLeft: string
): void {
if (this.mentionsListbox) this.destroyMentionsListbox();
let feed = this.feeds.find((feed) => feed.prefix === prefix);
if (!feed) return;
if (feed.minimumCharacters && search.length < feed.minimumCharacters) return;
this.mentionsListbox = document.createElement('div');
this.mentionsListbox.classList.add('mentions-listbox');
this.mentionsListbox.style.position = 'absolute';
this.mentionsListbox.style.top = cursorTop;
this.mentionsListbox.style.left = cursorLeft;
// Fetch the mentions, render them, add the click event handler and append them to the listbox
this.fetchMentions(prefix, search).then((mentions) => {
this.mentionsListbox.innerHTML = '';
let mentionsElements = mentions.map((item): HTMLElement => {
let element;
if (feed.itemRenderer && typeof feed.itemRenderer === 'function') {
element = feed.itemRenderer(item);
} else {
element = this.itemRenderer(item);
}
// Add the click event handler
element.addEventListener('click', this.onMentionClick.bind(this, item));
element.role = 'button';
return element;
});
if (!mentionsElements.length) return;
this.mentionsListbox.append(...mentionsElements);
// Mark the first item as active
mentionsElements[0].classList.add('mentions-listbox-item-active');
this.editor.textarea.parentElement.appendChild(this.mentionsListbox);
});
}
fetchMentions(prefix: string, search: string): Promise<FeedItem[]> {
let feed = this.feeds.find((feed) => feed.prefix === prefix);
if (!feed) return Promise.reject(new Error(`No feed found for prefix: ${prefix}`));
if (feed.feed instanceof Array) {
// Search the feed for the search term and return the results
return Promise.resolve(
feed.feed.filter(
(item) =>
item.name.toLowerCase().includes(search.toLowerCase()) ||
item.id.toLowerCase().includes(search.toLowerCase())
)
);
}
let promise = feed.feed(search);
return Promise.resolve(promise);
}
itemRenderer(item: FeedItem): HTMLElement {
const itemContainer = document.createElement('div');
itemContainer.setAttribute('role', 'button');
itemContainer.classList.add('mentions-listbox-item');
const nameElement = document.createElement('span');
nameElement.innerText = item.name;
nameElement.classList.add('mentions-listbox-item-name');
itemContainer.appendChild(nameElement);
const idElement = document.createElement('span');
idElement.innerText = item.id;
idElement.classList.add('mentions-listbox-item-id');
itemContainer.appendChild(idElement);
return itemContainer;
}
onMentionClick(item: FeedItem, event: Event): void {
event.preventDefault();
event.stopPropagation();
expandSelectedText(this.editor.textarea, '', '');
let position = this.editor.textarea.selectionStart + item.id.length + 1;
insertText(this.editor.textarea, {
text: item.id + ' ',
selectionStart: position,
selectionEnd: position,
});
this.destroyMentionsListbox();
}
}