UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

314 lines (275 loc) 11.2 kB
import { PluginField } from '../../interfaces'; import { Controller } from '../../modules/contract'; import { CommandMenu } from '../../modules/ui'; import { dom, converter, keyCodeMap } from '../../helper'; import { ResolveButton } from '../../core/section/constructor'; const { debounce } = converter; /** * @typedef {Object} SlashCommandItem * @property {string} key - Unique identifier for the item. * @property {string} title - Display label. * @property {string} [icon] - Icon key from `$.icons` (e.g. `'h1'`) or a raw HTML string (e.g. `'<svg>...</svg>'`). * @property {string[]} [keywords] - Extra search terms used in addition to the title. * @property {function(SunEditor.Deps, SlashCommandActionContext): void | Promise<void>} action - Invoked when the item is selected. * The trigger string (e.g. `/hea`) is already removed from the editor and the caret restored to that position before `action` runs. * The action owns its history entry — calling an L3 wrapper (`$.html.*`, `$.format.*`, `$.inline.apply`) auto-pushes; otherwise call `$.history.push(false)` manually. */ /** * @typedef {Object} SlashCommandActionContext * @property {string} triggerChar - The configured trigger character (e.g. `'/'`). * @property {string} query - The text the user typed after the trigger (e.g. `'hea'`). * @property {SlashCommandItem} item - The selected item. */ /** * @typedef {Object} SlashCommandPluginOptions * @property {string} [triggerChar='/'] - Character that opens the command menu. Single character recommended. * @property {Array<SlashCommandItem | string>} items - Menu entries. Strings resolve via `ResolveButton` * (plugin names, built-in commands like `'bold'`); objects are custom items with their own `action`. * Required. * @property {number} [delayTime=120] - Debounce delay (ms) before the input is inspected for the trigger. * @property {number} [limitSize=10] - Maximum number of items shown in the dropdown. * @property {string} [emptyMessage] - Message shown when no items match the query. If unset, the menu closes on no match. * @property {function(SlashCommandItem, { icons: Object }): string} [renderItem] - Custom item HTML renderer. * Applied only to custom item objects; plugin-name entries always render with the canonical BlockHandle row. * ```js * SUNEDITOR.create('#editor', { * plugins: [slashCommand], * slashCommand: { * triggerChar: '/', * items: [ * { * key: 'h1', * title: 'Heading 1', * icon: 'h1', * keywords: ['header', 'title'], * // A line-level tag (H1-H6, P): `setLine` CHANGES the current line's tag → `<h1>…</h1>`. * // Do NOT use `applyBlock` here — it WRAPS (`<h1><p>…</p></h1>`) and traps Enter inside. * action: ($) => $.format.setLine(document.createElement('H1')), * }, * { * key: 'code', * title: 'Code block', * keywords: ['pre'], * // A br-line block (PRE): `setBrLine` converts the line to a `<br>`-separated code block. * action: ($) => $.format.setBrLine(document.createElement('PRE')), * }, * { * key: 'quote', * title: 'Quote', * keywords: ['blockquote'], * // A container block (BLOCKQUOTE, DIV…): `applyBlock` WRAPS the selected lines → `<blockquote>…</blockquote>`. * action: ($) => $.format.applyBlock(document.createElement('BLOCKQUOTE')), * }, * 'bold', * 'image', * 'blockStyle', * ], * }, * }); * ``` */ /** * @class * @description Slash Command plugin * - Notion / Tiptap style "/" command menu. Triggered by the configured character (default `'/'`). * - Menu rendering + dispatch is delegated to {@link CommandMenu}, which is also used by BlockHandle's * action menu — both menus share the exact same row HTML and dispatch behavior. * - SlashCommand-specific concerns kept here: input watching, debounce, trigger detection, * trigger text deletion before dispatch, empty-state row. */ class SlashCommand extends PluginField { static key = 'slashCommand'; static className = ''; #triggerChar; #limitSize; #emptyMessage; /** @type {CommandMenu} */ #menu; #lastTriggerPos = 0; #anchorOffset = 0; #anchorNode = null; #internalClose = false; /** * @constructor * @param {SunEditor.Kernel} kernel * @param {SlashCommandPluginOptions} pluginOptions */ constructor(kernel, pluginOptions) { super(kernel); this.#triggerChar = typeof pluginOptions.triggerChar === 'string' && pluginOptions.triggerChar.length > 0 ? pluginOptions.triggerChar : '/'; this.#limitSize = typeof pluginOptions.limitSize === 'number' && pluginOptions.limitSize > 0 ? pluginOptions.limitSize : 10; this.#emptyMessage = typeof pluginOptions.emptyMessage === 'string' ? pluginOptions.emptyMessage : ''; const delayTime = typeof pluginOptions.delayTime === 'number' ? pluginOptions.delayTime : 120; this.#menu = new CommandMenu(this, this.$, { items: Array.isArray(pluginOptions.items) ? pluginOptions.items : [], resolveButton: ResolveButton, renderCustomItem: typeof pluginOptions.renderItem === 'function' ? pluginOptions.renderItem : null, selectMenuParams: { position: 'bottom-left', dir: 'ltr', minWidth: '200px', maxHeight: '320px', closeMethod: () => this.#onMenuClose(), }, }); const controllerEl = CreateHTML_controller(); this.controller = new Controller( this, this.$, controllerEl, { position: 'bottom', escGuard: () => this.#menu.hasOpenSubPanel(), }, null, ); this.#menu.attach(/** @type {HTMLElement} */ (controllerEl.firstElementChild), this.#onSelectItem.bind(this), { class: 'se-block-action-menu se-slash-command-menu', }); this.onInput = debounce(this.onInput.bind(this), delayTime); } /** * @hook Editor.EventManager * @description ESC while the menu is still pending must not let that scheduled `onInput` re-open the menu after the dismiss. * @type {SunEditor.Hook.Event.OnKeyDown} * @param {SunEditor.HookParams.KeyEvent} params */ onKeyDown({ event }) { if (keyCodeMap.isEsc(event.code)) /** @type {{ cancel?: () => void }} */ (this.onInput).cancel?.(); } /** * @hook Editor.EventManager * @type {SunEditor.Hook.Event.OnInput} */ onInput() { const items = this.#menu.getItems(); if (items.length === 0) return; const sel = this.$.selection.get(); if (!sel.rangeCount) { this.#closeMenu(); return; } const anchorNode = sel.anchorNode; const anchorOffset = sel.anchorOffset; if (!anchorNode || typeof anchorNode.textContent !== 'string') { this.#closeMenu(); return; } const textBeforeCursor = anchorNode.textContent.substring(0, anchorOffset); const trigger = this.#triggerChar; const lastPos = textBeforeCursor.lastIndexOf(trigger); if (lastPos === -1) { this.#closeMenu(); return; } const query = textBeforeCursor.substring(lastPos + trigger.length, anchorOffset); // Reject when the query contains whitespace (user moved past the slash word). if (/\s/.test(query)) { this.#closeMenu(); return; } // Trigger must sit at the start of the line or be preceded by whitespace / zero-width. const beforeChar = textBeforeCursor[lastPos - 1]; if (beforeChar && beforeChar.trim() !== '' && !dom.check.isZeroWidth(beforeChar)) { this.#closeMenu(); return; } const filtered = this.#menu.filter(query, this.#limitSize); if (filtered.length === 0) { if (this.#emptyMessage) { this.#renderEmpty(anchorNode); this.#cacheAnchor(anchorNode, lastPos, anchorOffset); } else { this.#closeMenu(); } return; } this.controller.open(anchorNode, null, { isWWTarget: true, initMethod: null, addOffset: null }); this.#menu.createRows(filtered); this.#menu.open(); this.#menu.setItem(0); this.#cacheAnchor(anchorNode, lastPos, anchorOffset); } /** * @description Close the menu from the plugin itself (invalid query, or after a selection). Flags * the close as internal so `#onMenuClose` does not treat it as a user dismiss. */ #closeMenu() { this.#internalClose = true; this.#menu.close(); this.#internalClose = false; } /** * @description SelectMenu `closeMethod`. On a user dismiss (ESC / outside-click) — i.e. not an * internal close and not a selection — drop the typed "/query" and restore the caret to where the * trigger was, leaving the editor in a clean state. Always tears down the controller. */ #onMenuClose() { if (!this.#internalClose) this.#removeTrigger(); this.#anchorNode = null; this.controller.close(); } /** * @description Remove the trigger + query text (`/hea`) and collapse the caret to the trigger * position. No-op if the cached anchor is stale (text changed / node detached). */ #removeTrigger() { const anchorNode = this.#anchorNode; if (!anchorNode || !anchorNode.parentNode) return; if (anchorNode.textContent?.[this.#lastTriggerPos] !== this.#triggerChar) return; this.$.selection.setRange(anchorNode, this.#lastTriggerPos, anchorNode, this.#anchorOffset); const range = this.$.selection.getRange(); if (range && !range.collapsed) this.$.html.remove(); } /** * @param {Node} anchorNode * @param {number} lastPos * @param {number} anchorOffset */ #cacheAnchor(anchorNode, lastPos, anchorOffset) { this.#anchorNode = anchorNode; this.#lastTriggerPos = lastPos; this.#anchorOffset = anchorOffset; } /** * @description Render the no-match row as a single non-clickable entry. * @param {Node} targetNode */ #renderEmpty(targetNode) { this.controller.open(targetNode, null, { isWWTarget: true, initMethod: null, addOffset: null }); this.#menu.selectMenu.create([null], [`<span class="se-slash-empty">${this.#emptyMessage}</span>`]); this.#menu.open(); } /** * @description Click handler. Removes the trigger string from the editor, then hands the item * to {@link CommandMenu#dispatch} — which routes to `plugin.open/show/action`, * `commandDispatcher.run`, or the custom item's `action` callback. * @param {import('../../modules/ui/CommandMenu.js').ResolvedItem | null} item * @returns {boolean | undefined} */ #onSelectItem(item) { if (!item) return false; const anchorNode = this.#anchorNode; if (!anchorNode) return false; const triggerChar = this.#triggerChar; const query = anchorNode.textContent.substring(this.#lastTriggerPos + triggerChar.length, this.#anchorOffset); // Remove the trigger + query, leaving the caret at the trigger position so the action // (insert block, run command, etc.) operates from a clean cursor. this.$.selection.setRange(anchorNode, this.#lastTriggerPos, anchorNode, this.#anchorOffset); const range = this.$.selection.getRange(); if (range && !range.collapsed) this.$.html.remove(); this.#closeMenu(); this.#menu.dispatch(item, { triggerChar, query, item: item.raw }); } } /** * @returns {HTMLElement} */ function CreateHTML_controller() { return dom.utils.createElement('DIV', { class: 'se-controller se-empty-controller' }, '<div></div>'); } export default SlashCommand;