UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

388 lines (344 loc) 13.5 kB
import { PluginField } from '../../interfaces'; import { Controller } from '../../modules/contract'; import { ApiManager } from '../../modules/manager'; import { SelectMenu } from '../../modules/ui'; import { dom, converter } from '../../helper'; const { debounce } = converter; /** * @description Default render function for dropdown items. * @param {{key: string, name?: string}} item - The data item. * @returns {string} HTML string for the dropdown item. */ function defaultRenderItem(item) { return `<div class="se-autocomplete-item"><span>${item.key}</span>${item.name ? `<span>${item.name}</span>` : ''}</div>`; } /** * @description Default select handler. Creates a span element with the trigger text + key. * @param {{key: string}} item - The selected data item. * @param {string} triggerText - The trigger character. * @returns {{tag: string, attrs: Object, text: string}} Descriptor for element creation. */ function defaultOnSelect(item, triggerText) { return { tag: 'span', attrs: { 'data-se-autocomplete': triggerText + item.key }, text: triggerText + item.key, }; } /** * @typedef {Object} AutocompleteTriggerConfig * @property {Array<{key: string, [x: string]: any}>} [data] - Static data array. Each item must have a `key` field. Mutually exclusive with `apiUrl`. * ```js * // data * [{ key: 'john', name: 'John Doe', url: '/users/john' }] * ``` * @property {string} [apiUrl] - API endpoint URL. Supports `{key}` and `{limitSize}` placeholders. Mutually exclusive with `data`. * @property {Object<string, string>} [apiHeaders] - HTTP headers for the API request. * @property {function(Object, XMLHttpRequest): Array<{key: string}>} [transformResponse] - Transforms parsed JSON response into an array of data items. * @property {number} [limitSize] - Override global `limitSize` for this trigger. * @property {number} [searchStartLength] - Override global `searchStartLength` for this trigger. * @property {boolean} [useCachingData] - Override global `useCachingData` for this trigger. * @property {boolean} [useCachingFieldData] - Override global `useCachingFieldData` for this trigger. * @property {function({key: string, [x: string]: any}, string): string} [renderItem] - Custom dropdown item renderer. Receives `(item, triggerText)`, returns HTML string. * @property {function({key: string, [x: string]: any}, string): (string|Element|{tag: string, attrs?: Object, text?: string})} [onSelect] - Custom selection handler. Returns: * - `string`: inserted as text node * - `Element`: inserted as-is * - `{tag, attrs, text}`: creates element via `dom.utils.createElement` */ /** * @typedef {Object} AutocompletePluginOptions * @property {number} [delayTime=120] - Debounce delay in ms before processing input. * @property {number} [limitSize=5] - Maximum number of items to display in the dropdown. * @property {number} [searchStartLength=0] - Minimum input length before triggering search. * @property {boolean} [useCachingData=true] - Whether to cache query responses per trigger. * @property {boolean} [useCachingFieldData=true] - Whether to cache selected items for priority display. * @property {Object<string, AutocompleteTriggerConfig>} triggers - Per-trigger configurations keyed by trigger character. * ```js * // Basic usage with static data — mention trigger * const editor = SUNEDITOR.create('#editor', { * plugins: [autocomplete], * autocomplete: { * triggers: { * '@': { * data: [ * { key: 'john', name: 'John Doe' }, * { key: 'jane', name: 'Jane Smith' }, * ], * }, * }, * }, * }); * * // API-based trigger with custom rendering and selection * const editor = SUNEDITOR.create('#editor', { * plugins: [autocomplete], * autocomplete: { * delayTime: 200, * limitSize: 10, * triggers: { * '@': { * apiUrl: '/api/users?q={key}&limit={limitSize}', * apiHeaders: { Authorization: 'Bearer TOKEN' }, * transformResponse: (json) => json.data.map((u) => ({ key: u.username, name: u.displayName, id: u.id })), * renderItem: (item) => `<div class="user-item"><strong>${item.key}</strong> <span>${item.name}</span></div>`, * onSelect: (item, trigger) => ({ * tag: 'a', * attrs: { href: `/users/${item.id}`, 'data-se-autocomplete': trigger + item.key }, * text: trigger + item.key, * }), * }, * '#': { * apiUrl: '/api/tags?q={key}', * transformResponse: (json) => json.tags, * searchStartLength: 2, * useCachingData: false, * }, * }, * }, * }); * ``` */ /** * @class * @description Autocomplete Plugin * - A generic autocomplete plugin supporting multiple trigger characters. * - Each trigger can have its own data source, rendering, and selection behavior. * - Supports static data arrays and API-based data fetching. * - Uses per-trigger caching for optimized performance. */ class Autocomplete extends PluginField { static key = 'autocomplete'; static className = ''; #lastTriggerPos = 0; #anchorOffset = 0; #anchorNode = null; #activeTrigger = null; /** * @constructor * @param {SunEditor.Kernel} kernel - The Kernel instance * @param {AutocompletePluginOptions} pluginOptions */ constructor(kernel, pluginOptions) { super(kernel); // global defaults const limitSize = pluginOptions.limitSize || 5; const searchStartLength = pluginOptions.searchStartLength || 0; const delayTime = typeof pluginOptions.delayTime === 'number' ? pluginOptions.delayTime : 120; const useCachingData = pluginOptions.useCachingData ?? true; const useCachingFieldData = pluginOptions.useCachingFieldData ?? true; // build trigger contexts this.triggerContexts = new Map(); const triggers = pluginOptions.triggers || {}; for (const [triggerChar, config] of Object.entries(triggers)) { const triggerLimit = config.limitSize ?? limitSize; this.triggerContexts.set(triggerChar, { trigger: triggerChar, limitSize: triggerLimit, searchStartLength: config.searchStartLength ?? searchStartLength, directData: config.data || null, apiUrl: config.apiUrl?.replace(/\s/g, '').replace(/\{limitSize\}/i, String(triggerLimit)) || '', apiHeaders: config.apiHeaders || null, transformResponse: config.transformResponse || null, renderItem: config.renderItem || defaultRenderItem, onSelect: config.onSelect || defaultOnSelect, apiManager: config.apiUrl ? new ApiManager(this, this.$, { headers: config.apiHeaders }) : null, cachingData: (config.useCachingData ?? useCachingData) ? new Map() : null, cachingFieldData: (config.useCachingFieldData ?? useCachingFieldData) ? [] : null, }); } // sort triggers by length descending (longest match first) this.sortedTriggers = [...this.triggerContexts.keys()].sort((a, b) => b.length - a.length); // controller const controllerEl = CreateHTML_controller(); this.selectMenu = new SelectMenu(this.$, { position: 'right-bottom', dir: 'ltr', closeMethod: () => this.controller.close(), }); this.controller = new Controller( this, this.$, controllerEl, { position: 'bottom', initMethod: () => { this.#cancelActiveApi(); this.selectMenu.close(); }, }, null, ); this.selectMenu.on(controllerEl.firstElementChild, this.#onSelectItem.bind(this)); // onInput debounce this.onInput = debounce(this.onInput.bind(this), delayTime); } /** * @description Cancels the active trigger's in-flight API request. */ #cancelActiveApi() { if (this.#activeTrigger?.apiManager) { this.#activeTrigger.apiManager.cancel(); } } /** * @hook Editor.EventManager * @type {SunEditor.Hook.Event.OnInputAsync} */ async onInput() { this.#cancelActiveApi(); const sel = this.$.selection.get(); if (!sel.rangeCount) { this.selectMenu.close(); return; } const anchorNode = sel.anchorNode; const anchorOffset = sel.anchorOffset; const textBeforeCursor = anchorNode.textContent.substring(0, anchorOffset); // find matching trigger (longest first) for (const trigger of this.sortedTriggers) { const lastPos = textBeforeCursor.lastIndexOf(trigger); if (lastPos === -1) continue; const query = textBeforeCursor.substring(lastPos + trigger.length, anchorOffset); const beforeText = textBeforeCursor[lastPos - 1]?.trim(); if (!/\s/.test(query) && (!beforeText || dom.check.isZeroWidth(beforeText))) { const ctx = this.triggerContexts.get(trigger); if (query.length < ctx.searchStartLength) return; const anchorParent = anchorNode.parentNode; if (dom.check.isAnchor(anchorParent) && !anchorParent.getAttribute('data-se-autocomplete')) { return; } try { this.#activeTrigger = ctx; await this.#createList(ctx, query, anchorNode); this.#lastTriggerPos = lastPos; this.#anchorNode = anchorNode; this.#anchorOffset = anchorOffset; return; } catch (error) { console.warn('[SUNEDITOR.autocomplete.api] ', error); } } continue; } this.selectMenu.close(); } /** * @description Generates the autocomplete dropdown list. * @param {Object} ctx - The trigger context. * @param {string} value - The query text after the trigger. * @param {Node} targetNode - The node where the trigger was detected. * @returns {Promise<boolean>} */ async #createList(ctx, value, targetNode) { const limit = ctx.limitSize; const lowerValue = value.toLowerCase(); let response = null; if (ctx.cachingData) { response = ctx.cachingData.get(value); } if (!response) { if (ctx.directData) { response = ctx.directData .filter((item) => item.key.toLowerCase().startsWith(lowerValue)) .slice(0, limit); } else { const xmlHttp = await ctx.apiManager.asyncCall({ method: 'GET', url: this.#createUrl(ctx, value) }); const json = JSON.parse(xmlHttp.responseText); response = ctx.transformResponse ? ctx.transformResponse(json, xmlHttp) : json; } } if (ctx.cachingFieldData) { const uniqueKeys = new Set(); response = ctx.cachingFieldData .concat(response) .filter(({ key }) => { if (uniqueKeys.has(key)) return false; uniqueKeys.add(key); return key.toLowerCase().startsWith(lowerValue); }) .slice(0, limit); } if (!response?.length) { this.selectMenu.close(); return false; } const list = []; const menus = []; for (let i = 0, len = response.length, v; i < len; i++) { v = response[i]; list.push(v); menus.push(ctx.renderItem(v, ctx.trigger)); } // controller open this.controller.open(targetNode, null, { isWWTarget: true, initMethod: null, addOffset: null }); // select menu create this.selectMenu.create(list, menus); this.selectMenu.open(); this.selectMenu.setItem(0); if (ctx.cachingData) ctx.cachingData.set(value, list); return true; } /** * @description Constructs the API request URL with the query value. * @param {Object} ctx - The trigger context. * @param {string} key - The query text. * @returns {string} */ #createUrl(ctx, key) { return ctx.apiUrl.replace(/\{key\}/i, key); } /** * @description Handles item selection from the dropdown. * @param {{key: string, [x: string]: any}} item - The selected data item. * @returns {boolean} */ #onSelectItem(item) { if (!item) return false; const ctx = this.#activeTrigger; if (!ctx) return false; const result = ctx.onSelect(item, ctx.trigger); let insertedNode = null; const anchorParent = this.#anchorNode.parentNode; if (typeof result === 'string') { // plain text insertion this.$.selection.setRange(this.#anchorNode, this.#lastTriggerPos, this.#anchorNode, this.#anchorOffset); insertedNode = dom.utils.createTextNode(result); if (!this.$.html.insertNode(insertedNode, { afterNode: null, skipCharCount: false })) return false; } else { // element insertion (descriptor or DOM element) let element; if (result?.nodeType) { element = result; } else if (result?.tag) { element = dom.utils.createElement(result.tag.toUpperCase(), result.attrs || {}, result.text || ''); } else { return false; } if (anchorParent.getAttribute?.('data-se-autocomplete')) { // update existing autocomplete element in-place for (const attr of [...anchorParent.attributes]) anchorParent.removeAttribute(attr.name); for (const attr of [...element.attributes]) anchorParent.setAttribute(attr.name, attr.value); anchorParent.textContent = element.textContent; insertedNode = anchorParent; } else { this.$.selection.setRange(this.#anchorNode, this.#lastTriggerPos, this.#anchorNode, this.#anchorOffset); if (!this.$.html.insertNode(element, { afterNode: null, skipCharCount: false })) return false; insertedNode = element; } } this.selectMenu.close(); const space = dom.utils.createTextNode('\u00A0'); insertedNode.parentNode.insertBefore(space, insertedNode.nextSibling); this.$.selection.setRange(space, 1, space, 1); if (ctx.cachingFieldData && !ctx.cachingFieldData.some((data) => data.key === item.key)) { ctx.cachingFieldData.push(item); } } } /** * @returns {HTMLElement} */ function CreateHTML_controller() { return dom.utils.createElement('DIV', { class: 'se-controller se-empty-controller' }, '<div></div>'); } export default Autocomplete;