UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

598 lines (538 loc) 20.5 kB
import { dom, env } from '../../helper'; import SelectMenu from './SelectMenu.js'; const { _w } = env; /** * @description Resolve an icon spec to an HTML string. Accepts a key from `$.icons`, * a raw HTML snippet (anything starting with `<`), or empty. * @param {string} icon * @param {Object} icons * @returns {string} */ export function resolveIconHTML(icon, icons) { if (!icon || typeof icon !== 'string') return ''; if (icon.charAt(0) === '<') return icon; return icons[icon] || ''; } /** * @description Build the canonical command-menu row HTML. Single source of truth for the * BlockHandle action menu and the SlashCommand menu, so they look identical. * @param {string} label * @param {string} iconHTML * @returns {string} */ export function buildRowHTML(label, iconHTML) { const inner = iconHTML ? `<span class="se-block-menu-icon">${iconHTML}</span><span class="se-block-menu-label">${label}</span>` : `<span class="se-block-menu-label se-block-menu-label-full">${label}</span>`; return `<button type="button" class="se-cmd-row">${inner}</button>`; } /** * @typedef {Object} CommandMenuItem * @property {string} [key] - Optional identifier. * @property {string} title - Display label. * @property {string} [icon] - `$.icons` key or raw HTML snippet. * @property {string[]} [keywords] - Extra search terms (used by the filter). * @property {function(SunEditor.Deps, *): void | Promise<void>} action - Custom callback. The second * argument is the host-supplied context (e.g. `{block}` for BlockHandle, `{triggerChar, query}` for SlashCommand). */ /** * @typedef {Object} SubmenuChild * @property {string} pluginName - Owning plugin key. * @property {HTMLElement} element - The original toolbar dropdown button element. Passed to `plugin.action(element)` on click. */ /** * @typedef {Object} ResolvedItem * @property {'custom'|'plugin'|'submenu'|'dropdownFree'} kind * @property {string} title * @property {string} iconHTML * @property {string} search - Lower-cased haystack for substring filtering. * @property {CommandMenuItem} [raw] * @property {string} [name] - Plugin key. * @property {string} [type] - Plugin type (`'modal'`, `'command'`, etc.). * @property {string} [command] * @property {SubmenuChild[]} [children] - For `kind: 'submenu'` — clickable child rows (one per dropdown item). * @property {string[]} [childMenus] - For `kind: 'submenu'` — HTML for each child row. */ /** * @typedef {Object} CommandMenuParams * @property {Array<string | CommandMenuItem>} items - Raw menu entries. * @property {function(string, Object, *, Object, Object): ({title: string, icon: string, type: string, command: string} | null)} resolveButton * Caller-provided plugin/button resolver. Inject `ResolveButton` from `core/section/constructor`. * Injected (not imported) because this module lives under `src/modules/` and cannot import from `src/core/*`. * @property {Object} selectMenuParams - Base SelectMenu params (`position`, `minWidth`, `keydownTarget`, etc.). * @property {function(CommandMenuItem, { icons: Object }): string} [renderCustomItem] - Optional renderer * applied to custom (object) items only. Plugin-string items always render with `buildRowHTML`. */ /** * @class * @description Shared command-menu module used by BlockHandle and SlashCommand. * - Owns the underlying `SelectMenu`, renders rows with the canonical BlockHandle row HTML. * - Supports four item kinds: * - `'custom'` — user-defined `{title, icon?, action}` invoked with `($, hostContext)`. * - `'plugin'` — plain plugin (modal/browser/command/popup) dispatched via `plugin.open/show/action`. * - `'submenu'` — dropdown plugin auto-expanded inline (children come from `$.menu.itemsMap`). * - `'dropdownFree'` — dropdown-free plugin (e.g. `fontColor`, `table`) shown with a submenu arrow * and an attached hover flyout that reuses the plugin's toolbar dropdown DOM. * - Handles blur prevention on mousedown, hover flyout lifecycle, and click-to-toggle for dropdown-free. * * Host responsibilities (kept outside this module): * - Pre-dispatch side effects (BlockHandle: `expandRangeToFullLines`; SlashCommand: delete trigger text). * - Anchor element selection (BlockHandle anchors to the drag button; SlashCommand to a Controller wrapper). * - Context object for custom-item actions. */ class CommandMenu { #$; #resolveButton; #rawItems; #resolved = null; #renderCustomItem; /** @type {Map<number, { name: string, plugin: any, li: HTMLElement }>} */ #freeMap = new Map(); /** @type {?{ dropdown: HTMLElement, plugin: any, originalParent: ?Node, anchorLi: HTMLElement, evClick: ?SunEditor.Event.Info }} */ #flyoutState = null; /** * @type {Array<{ name: string, idx: number }>} */ #pendingTargets = []; /** * @type {Array<{ name: string, target: HTMLElement }>} */ #registered = []; /** @type {SelectMenu} */ selectMenu; /** * @constructor * @param {*} _host - Reserved for future host-aware behavior (kept for API symmetry with `Modal`/`Controller`). * @param {SunEditor.Deps} $ - Deps bag. * @param {CommandMenuParams} params */ constructor(_host, $, params) { this.#$ = $; this.#resolveButton = params.resolveButton; this.#rawItems = Array.isArray(params.items) ? params.items : []; this.#renderCustomItem = typeof params.renderCustomItem === 'function' ? params.renderCustomItem : null; // Wrap the host's closeMethod so the flyout is always torn down with the menu. const userClose = params.selectMenuParams?.closeMethod; this.selectMenu = new SelectMenu($, { ...params.selectMenuParams, closeMethod: () => { this.#closeFlyout(); this.#unregisterAll(); userClose?.(); }, subEscMethod: () => { if (!this.#flyoutState) return false; const anchorLi = this.#flyoutState.anchorLi; this.#closeFlyout(); const idx = anchorLi ? Number(anchorLi.getAttribute('data-index')) : NaN; if (!Number.isNaN(idx)) this.selectMenu.setItem(idx); return true; }, }); } /** * @description Bind the menu to a reference element and register the click handler. * @param {HTMLElement} referElement * @param {(item: ResolvedItem|null) => void} onSelect - Called with the resolved item picked by the user. * Hosts run their pre-dispatch hook here (delete trigger, etc.) and then call `dispatch(item, ctx)`. * @param {{ class?: string, style?: string }} [attr] */ attach(referElement, onSelect, attr = {}) { this.selectMenu.on(referElement, /** @type {*} */ (onSelect), attr); this.#$.eventManager.addEvent(this.selectMenu.form, 'mousedown', (e) => { if (env.isMobile) { this.#$.store.set('_preventBlur', true); } else { e.preventDefault(); } }); this.#$.eventManager.addEvent(this.selectMenu.form, 'mousemove', this.#onMenuMouseMove.bind(this)); } /** * @description Replace the items list (e.g. when host options change). Invalidates the resolved cache. * @param {Array<string | CommandMenuItem>} items */ setItems(items) { this.#rawItems = Array.isArray(items) ? items : []; this.#resolved = null; } /** * @description Lazily normalize raw items. Lazy because plugin instances (`$.plugins`) may not * be available during the host's constructor. * @returns {ResolvedItem[]} */ getItems() { if (this.#resolved) return this.#resolved; /** @type {ResolvedItem[]} */ const out = []; for (const entry of this.#rawItems) { if (typeof entry === 'string') { const r = this.#resolveButton(entry, this.#$.plugins, this.#$.options, this.#$.icons, this.#$.lang); if (!r) continue; const type = r.type || ''; // Dropdown-free if (/dropdown-free/.test(type)) { out.push({ kind: 'dropdownFree', title: r.title, iconHTML: r.icon || '', search: (r.title + ' ' + entry).toLowerCase(), name: entry, type, }); continue; } // Dropdown if (/dropdown/.test(type)) { const menuItems = this.#$.menu?.itemsMap?.[entry] || []; if (menuItems.length > 0) { out.push({ kind: 'submenu', title: r.title, iconHTML: r.icon || '', search: (r.title + ' ' + entry).toLowerCase(), name: entry, children: menuItems.map((mi) => ({ pluginName: entry, element: mi._element })), childMenus: menuItems.map((mi) => { const iconEl = mi._element?.querySelector('.se-list-icon') || mi._element?.querySelector('svg'); const icon = iconEl ? iconEl.outerHTML : ''; const label = icon ? `${icon}<span>${mi.title}</span>` : mi.title; return `<span class="se-block-submenu-item">${label}</span>`; }), }); continue; } } out.push({ kind: 'plugin', title: r.title, iconHTML: r.icon || '', search: (r.title + ' ' + entry).toLowerCase(), name: entry, type, command: r.command, }); } else if (entry && typeof entry === 'object' && typeof entry.action === 'function') { const title = entry.title || entry.key || ''; const keywords = Array.isArray(entry.keywords) ? entry.keywords.join(' ') : ''; out.push({ kind: 'custom', title, iconHTML: resolveIconHTML(entry.icon, this.#$.icons), search: (title + ' ' + keywords).toLowerCase(), raw: entry, }); } } this.#resolved = out; return out; } /** * @description Case-insensitive substring filter on `title + keywords + plugin name`. * Empty query returns all items (up to `limit`). * @param {string} query * @param {number} [limit=Infinity] * @returns {ResolvedItem[]} */ filter(query, limit = Infinity) { const items = this.getItems(); const q = (query || '').toLowerCase().trim(); const out = []; for (const it of items) { if (!q || it.search.indexOf(q) !== -1) out.push(it); if (out.length >= limit) break; } return out; } /** * @description Build row HTML for a list of resolved items. * @param {ResolvedItem[]} items * @returns {string[]} */ renderRows(items) { const ctx = { icons: this.#$.icons }; const arrow = `<span class="se-submenu-arrow">${this.#$.icons.menu_arrow_right}</span>`; return items.map((it) => { if (it.kind === 'custom' && this.#renderCustomItem) return this.#renderCustomItem(it.raw, ctx); const row = buildRowHTML(it.title, it.iconHTML); return it.kind === 'dropdownFree' ? row + arrow : row; }); } /** * @description Populate the SelectMenu rows. * @param {ResolvedItem[]} items */ createRows(items) { // SlashCommand rebuilds rows on every keystroke; previous `li`s become detached. Drop their // registrations before the SelectMenu rewrite so toolbar map never points at orphaned nodes. this.#unregisterAll(); const selectItems = items.map((it) => it.kind === 'submenu' ? { children: it.children, childMenus: it.childMenus } : it, ); this.selectMenu.create(selectItems, this.renderRows(items)); this.#freeMap.clear(); this.#pendingTargets.length = 0; items.forEach((it, i) => { if (it.kind === 'dropdownFree') { this.#freeMap.set(i, { name: it.name, plugin: this.#$.plugins[it.name], li: this.selectMenu.menus[i], }); } if (it.kind === 'plugin' || it.kind === 'dropdownFree') { this.#pendingTargets.push({ name: it.name, idx: i }); } }); // SlashCommand stays "open" across createRows calls — re-register immediately so the freshly // built `li` set takes over from the discarded one without waiting for the next open(). if (this.selectMenu.isOpen) this.#registerAll(); } /** * @description Open the SelectMenu. Lazily (re-)registers menu rows into * `commandDispatcher.targets` so `selectionState.update` paints the `active` class on them through * @param {string} [position] */ open(position) { this.selectMenu.open(position); if (this.#registered.length === 0) this.#registerAll(); } /** * @description Close the SelectMenu. Also unregisters menu rows from `commandDispatcher.targets` */ close() { this.#unregisterAll(); this.selectMenu.close(); } /** * @description Whether a sub-panel (native submenu or dropdown-free flyout) is currently open. * Lets an owning Controller keep the menu open on ESC and close only the sub-panel. * @returns {boolean} */ hasOpenSubPanel() { return this.selectMenu.hasOpenSubmenu() || !!this.#flyoutState; } /** * @description Push every `#pendingTargets` row's inner `<button>` into the toolbar target map */ #registerAll() { const cd = this.#$.commandDispatcher; for (const { name, idx } of this.#pendingTargets) { const btn = this.#resolveButtonTarget(idx); if (!btn) continue; cd.registerTargets(name, btn); this.#registered.push({ name, target: btn }); } // Bust the dedup cache so applyTagEffect re-runs even when the selection node is unchanged. this.#$.store.set('_lastSelectionNode', null); this.#$.eventManager.applyTagEffect(); } /** * @description Drop every `(cmd, target)` pair this instance registered. Safe to call repeatedly. */ #unregisterAll() { if (this.#registered.length === 0) return; const cd = this.#$.commandDispatcher; for (const { name, target } of this.#registered) cd.unregisterTargets(name, target); this.#registered.length = 0; } /** * @description Resolve the registration target for menu index `idx`. * (defensive — should always exist after {@link buildRowHTML}). * @param {number} idx * @returns {?HTMLButtonElement} */ #resolveButtonTarget(idx) { const li = this.selectMenu.menus[idx]; if (!li) return null; return /** @type {?HTMLButtonElement} */ (li.querySelector('button.se-cmd-row') || li); } /** * @description Highlight a row. * @param {number} index */ setItem(index) { this.selectMenu.setItem(index); } /** * @description Soft-hide / soft-show without changing open state. Pass-through. * @param {boolean} hidden */ setHidden(hidden) { this.selectMenu.setHidden(hidden); } get isOpen() { return this.selectMenu.isOpen; } /** * @description Dispatch a resolved item. For custom items, calls `raw.action($, ctx)`. For plugin * items, routes to `plugin.open/show/action` or `commandDispatcher.run` (built-ins). For dropdown-free * items, toggles the hover flyout * @param {ResolvedItem | SubmenuChild | null} item * @param {*} [ctx] - Context passed to custom-item `action` (host-specific shape). * @returns {boolean} `true` if a plugin was dispatched */ dispatch(item, ctx) { if (!item) return false; const asChild = /** @type {SubmenuChild} */ (/** @type {unknown} */ (item)); if (asChild.pluginName && asChild.element) { const plugin = this.#$.plugins[asChild.pluginName]; plugin?.action?.(asChild.element); this.#$.history.push(false); return true; } const resolved = /** @type {ResolvedItem} */ (item); if (resolved.kind === 'custom') { try { resolved.raw.action(this.#$, ctx); } catch (e) { console.error('[SUNEDITOR.commandMenu.customAction]', e); } return false; } if (resolved.kind === 'dropdownFree') { const plugin = this.#$.plugins[resolved.name]; if (!plugin) return false; if (this.#flyoutState?.plugin === plugin) { this.#closeFlyout(); return false; } const idx = this.selectMenu.items.indexOf(/** @type {*} */ (resolved)); const anchorLi = idx >= 0 ? this.selectMenu.menus[idx] : null; if (anchorLi) this.#openFlyout(resolved.name, plugin, anchorLi); return false; } const plugin = resolved.name ? this.#$.plugins[resolved.name] : null; if (plugin) { const type = resolved.type || ''; if (/modal/.test(type)) plugin.open?.(); else if (/browser/.test(type)) plugin.open?.(null); else if (/popup/.test(type)) plugin.show?.(); else plugin.action?.(dom.utils.createElement('BUTTON', { type: 'button', 'data-command': resolved.name })); this.#$.history.push(false); return true; } if (resolved.command) { this.#$.commandDispatcher.run(resolved.command, null, null); } return false; } /** * @description Mousemove handler on the menu form. For dropdown-free rows. * @param {MouseEvent} e */ #onMenuMouseMove(e) { if (this.#freeMap.size === 0) return; const target = /** @type {HTMLElement} */ (e.target); if (this.#flyoutState?.dropdown?.contains(target)) return; const li = target.closest?.('li[data-index]'); if (!li) return; const idx = Number(li.getAttribute('data-index')); const free = this.#freeMap.get(idx); if (free) { if (this.#flyoutState?.plugin !== free.plugin) { this.#openFlyout(free.name, free.plugin, free.li); } } else if (this.#flyoutState) { this.#closeFlyout(); } } /** * @description Open a dropdown-free plugin's flyout. * @param {string} name * @param {*} plugin * @param {HTMLElement} anchorLi */ #openFlyout(name, plugin, anchorLi) { if (this.#flyoutState) this.#closeFlyout(); const dropdown = this.#$.menu?.targetMap?.[name]; if (!dropdown || !this.selectMenu.form) return; const originalParent = dropdown.parentNode; this.selectMenu.form.appendChild(dropdown); this.#positionFlyout(dropdown, anchorLi, this.selectMenu.form); dom.utils.addClass(anchorLi, 'se-submenu-open'); plugin.on?.(anchorLi); // Clicking inside the flyout's dropdown should dismiss both the flyout and the parent menu — // matches the toolbar behavior where a click commits the selection. const evClick = this.#$.eventManager.addEvent(dropdown, 'click', () => _w.setTimeout(() => { this.#closeFlyout(); this.selectMenu.close(); }, 0), ); this.#flyoutState = { dropdown, plugin, originalParent, anchorLi, evClick }; } /** * @description Close the open flyout (if any). Restores the dropdown DOM to its original parent * (the menuTray) so the toolbar can use it again. */ #closeFlyout() { const s = this.#flyoutState; if (!s) return; this.#flyoutState = null; this.#$?.eventManager.removeEvent(s.evClick); s.dropdown.style.cssText = ''; s.dropdown.style.display = 'none'; if (s.anchorLi) dom.utils.removeClass(s.anchorLi, 'se-submenu-open'); if (s.originalParent && s.originalParent !== s.dropdown.parentNode) { s.originalParent.appendChild(s.dropdown); } s.plugin?.off?.(); } /** * @description Place `el` next to `anchor`, coordinates relative to `container`. Mirrors * `SelectMenu.#openSubmenu`: prefers the text-direction side, flips on overflow, shifts on * double overflow. * @param {HTMLElement} el * @param {HTMLElement} anchor * @param {HTMLElement} container */ #positionFlyout(el, anchor, container) { const a = anchor.getBoundingClientRect(); const c = container.getBoundingClientRect(); const isRtl = !!this.#$.options.get('_rtl'); el.style.position = 'absolute'; el.style.right = ''; el.style.visibility = 'hidden'; el.style.display = 'block'; const elW = el.offsetWidth; const elH = el.offsetHeight; const vpW = _w.innerWidth; const vpH = _w.innerHeight; const gap = 4; // Horizontal: preferred side follows text direction. Flip if it overflows; if both overflow, // keep the smaller-overflow side and shift inward by the missing amount. const rightVP = a.right + gap; const leftVP = a.left - elW - gap; const rightOverflow = Math.max(0, rightVP + elW - vpW); const leftOverflow = Math.max(0, -leftVP); const prefer = isRtl ? 'left' : 'right'; let leftPx; if (prefer === 'right') { if (rightOverflow === 0) leftPx = rightVP; else if (leftOverflow === 0) leftPx = leftVP; else if (rightOverflow <= leftOverflow) leftPx = rightVP - rightOverflow; else leftPx = leftVP + leftOverflow; } else { if (leftOverflow === 0) leftPx = leftVP; else if (rightOverflow === 0) leftPx = rightVP; else if (leftOverflow <= rightOverflow) leftPx = leftVP + leftOverflow; else leftPx = rightVP - rightOverflow; } el.style.left = leftPx - c.left + 'px'; // Vertical: top-aligned with anchor, flip on overflow. const topVP = a.top; const bottomVP = a.bottom - elH; const downOverflow = Math.max(0, topVP + elH - vpH); const upOverflow = Math.max(0, -bottomVP); let topPx; if (downOverflow === 0) topPx = topVP; else if (upOverflow === 0) topPx = bottomVP; else if (downOverflow <= upOverflow) topPx = topVP - downOverflow; else topPx = bottomVP + upOverflow; el.style.top = topPx - c.top + 'px'; el.style.visibility = ''; } } export default CommandMenu;