/*!
* Jodit Editor PRO (https://xdsoft.net/jodit/)
* See LICENSE.md in the project root for license information.
* Copyright (c) 2013-2022 Valeriy Chupurnov. All rights reserved. https://xdsoft.net/jodit/pro/
*/
import type { IJodit, IPopup } from 'jodit/types';
import type {
AutoCompleteSource,
IAutoCompleteItem,
IAutoCompleteCustomFeed
} from './interface';
import { Plugin } from 'jodit/core/plugin';
import { Config } from 'jodit/config';
import { autobind, debounce } from 'jodit/core/decorators';
import {
isArray,
isFunction,
isString,
isPromise,
trim
} from 'jodit/core/helpers';
import { Dom, Popup } from 'jodit/modules';
import { Autocomplete } from './ui/autocomplete';
import {
KEY_DOWN,
KEY_ENTER,
KEY_ESC,
KEY_TAB,
KEY_UP
} from 'jodit/core/constants';
import { Jodit } from '../../index';
import {
getTextLeftOfCursor,
replaceTextLeftOfCursorAfterSpace
} from './helpers';
declare module 'jodit/config' {
interface Config {
autocomplete: {
maxItems: number;
isMatchedQuery: (query: string, value: string) => boolean;
itemRenderer: (item: IAutoCompleteItem) => string | HTMLElement;
insertValueRenderer: (
item: IAutoCompleteItem
) => string | HTMLElement;
sources: AutoCompleteSource[];
};
}
}
Config.prototype.autocomplete = {
sources: [],
maxItems: 50,
isMatchedQuery: (query, value): boolean =>
value.toLowerCase().indexOf(query.toLowerCase()) === 0,
itemRenderer: (item): string => item.title ?? item.value,
insertValueRenderer: (item): string => item.value + ' '
};
export class autocomplete extends Plugin {
/** @override */
override requires = ['enter', 'license'];
/** @override */
override hasStyle = !Jodit.fatMode;
private list!: Autocomplete;
private popup!: IPopup;
private sources: AutoCompleteSource[] = [];
// autocomplete source가 비동기로 동작할 경우, 이전 요청을 취소하기 위한 변수
private currentRequest = 0;
/** @override */
protected afterInit(jodit: IJodit): void {
this.popup = new Popup(jodit);
this.popup.setMod('padding', false);
this.list = new Autocomplete(jodit);
this.popup.setContent(this.list.container);
jodit.e
.on('select.autocomplete', (item: IAutoCompleteItem) => {
let insertValue: string | HTMLElement = item.value;
if (item.isMention && item.mentionUser?.id) {
// 멘션일 경우, insertValueRenderer, itemRenderer를 사용하지 않는다.
// { title, value, isMention, mentionUser } 형태로 넘어온다.
const containerDiv = jodit.c.element('span');
const elm = jodit.c.span('user-mention', item.value);
elm.setAttribute(`data-mention-${item.mentionUser.id}`, '');
elm.style.backgroundColor = '#F8F8F8';
elm.style.borderRadius = '4px';
elm.style.padding = '2px 4px';
elm.style.margin = '0 4px';
elm.style.cursor = 'pointer';
elm.style.userSelect = 'none';
elm.style.color = '#6563FF';
elm.setAttribute('contenteditable', 'false');
// const nbspNode = jodit.c.fromHTML(' ');
containerDiv.appendChild(elm);
// containerDiv.appendChild(nbspNode);
insertValue = containerDiv;
// insertValue = elm;
} else if (isFunction(item.insertValueRenderer)) {
insertValue = item.insertValueRenderer(item);
}
if (!Dom.isNode(insertValue)) {
insertValue = jodit.createInside.fromHTML(insertValue);
}
replaceTextLeftOfCursorAfterSpace(jodit.s.range, insertValue);
this.j.s.setCursorBefore(insertValue);
this.j.s.setCursorAfter(insertValue);
this.popup.close();
})
.on('keydown.autocomplete', this.onKeyDown)
.on('keydown.autocomplete', this.onKeyControlDown, {
top: true
})
.on('beforeEnter.autocomplete', this.onEnter, { top: true })
.on('autocomplete.autocomplete', this.onAutoComplete)
.on(
'registerAutocompleteSource.autocomplete',
this.registerAutocompleteSource
);
}
@autobind
private registerAutocompleteSource(source: AutoCompleteSource): void {
this.sources.push(source);
}
private static isControlKey(key: string): boolean {
return (
key === KEY_DOWN ||
key === KEY_UP ||
key === KEY_ENTER ||
key === KEY_TAB
);
}
@debounce()
private async onKeyDown(e: KeyboardEvent): Promise {
if (e.key === KEY_ESC) {
this.popup.isOpened && this.popup.close();
return;
}
const { s } = this.j;
if (
autocomplete.isControlKey(e.key) ||
!s.isInsideArea ||
!s.isCollapsed
) {
return;
}
const range = this.j.s.sel?.rangeCount
? this.j.s.sel?.getRangeAt(0)
: null;
const query = range && getTextLeftOfCursor(range).split(' ').pop();
if (query && trim(query).length) {
this.currentRequest++;
const currentRequestId = this.currentRequest;
const result = await this.onAutoComplete(query);
if (currentRequestId === this.currentRequest && result.length) {
return this.openPopup(result);
}
}
if (this.popup.isOpened) {
this.popup.close();
}
}
@autobind
private onKeyControlDown(e: KeyboardEvent): false | void {
if (this.popup.isOpened && autocomplete.isControlKey(e.key)) {
switch (e.key) {
case KEY_DOWN:
this.list.selectNext();
break;
case KEY_UP:
this.list.selectPrevious();
break;
case KEY_TAB:
this.list.select();
break;
}
this.j.e.stopPropagation(e.type);
return false;
}
}
@autobind
private onEnter(): false | void {
if (this.popup.isOpened) {
this.list.select();
return false;
}
}
@autobind
private async onAutoComplete(query: string): Promise {
const result: IAutoCompleteItem[] = [];
await Promise.all(
this.sources
.concat(this.j.o.autocomplete.sources)
.map(async source => {
result.push(...(await this.resolveFeed(query, source)));
})
);
return result;
}
@autobind
async resolveFeed(
query: string,
feed: AutoCompleteSource | IAutoCompleteCustomFeed['feed'],
baseSource?: IAutoCompleteCustomFeed
): Promise {
let parts: T[];
if (isPromise(feed)) {
feed = await feed;
}
if (isFunction(feed)) {
parts = await feed(query);
} else if (isArray(feed)) {
const arrayFeed = feed as Array,
{ isMatchedQuery } = this.j.o.autocomplete;
parts = arrayFeed
.filter(item => {
if (isString(item)) {
return isMatchedQuery(query, item);
}
return isMatchedQuery(query, item.value);
})
.map(item => {
if (isString(item)) {
return { title: item, value: item } as T;
}
return item;
});
} else {
parts = await this.resolveFeed(query, feed.feed, feed);
}
if (parts && isArray(parts)) {
const { itemRenderer, insertValueRenderer, maxItems } =
this.j.o.autocomplete;
parts = parts.map(item => ({
itemRenderer: baseSource?.itemRenderer ?? itemRenderer,
insertValueRenderer:
baseSource?.insertValueRenderer ?? insertValueRenderer,
...item
}));
return parts.slice(0, maxItems);
}
return [];
}
private openPopup(result: IAutoCompleteItem[]): void {
this.list.build(result);
this.popup.open(() => this.j.s.range.getBoundingClientRect());
}
/** @override */
protected beforeDestruct(jodit: IJodit): void {
jodit.e
.off(this.list)
.off('keydown.autocomplete', this.onKeyDown)
.off('autocomplete.autocomplete', this.onAutoComplete)
.off('.autocomplete');
this.list.destruct();
this.popup.destruct();
}
}
Jodit.plugins.add('autocomplete', autocomplete);