suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
325 lines (275 loc) • 10.8 kB
JavaScript
import { PluginCommand, PluginDropdown } from '../../interfaces';
import { converter, dom } from '../../helper';
import { Controller } from '../../modules/contract';
import { SelectMenu } from '../../modules/ui';
void PluginDropdown;
const DEFAULT_LANGS = ['javascript', 'typescript', 'html', 'css', 'json', 'python', 'java', 'c', 'cpp', 'csharp', 'go', 'rust', 'ruby', 'php', 'swift', 'kotlin', 'sql', 'bash', 'markdown', 'xml', 'yaml'];
/**
* @typedef {Object} CodeBlockPluginOptions
* @property {Array<string>} [langs] - List of selectable programming languages for code blocks.
* - Defaults to 21 common languages
* - [javascript, typescript, html, css, json, python, java, c, cpp, csharp, go, rust, ruby, php, swift, kotlin, sql, bash, markdown, xml, yaml].
* - Set to empty array `[]` to disable language selection UI entirely.
* ```js
* { codeBlock: { langs: ['javascript', 'python', 'html', 'css'] } }
* ```
*/
/**
* @class
* @implements {PluginDropdown}
* @description Code block plugin — toggles `<pre>` formatting with language selection.
* - Toolbar: command button (toggle `<pre>`) + optional dropdown (language list)
* - Hover UI: shows language selector on `<pre>` hover (Controller + SelectMenu)
* - I/O conversion: `<pre class="language-xxx">` ↔ `<pre><code class="language-xxx">`
*/
class CodeBlock extends PluginCommand {
static key = 'codeBlock';
static className = '';
#preTag;
#langItems;
#langs;
// hover UI
#hoverButton;
#hoverSelectMenu;
#hoverController;
#hoverCurrentPre;
#mouseLeaveEvent;
#removeEventFunc;
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
* @param {CodeBlockPluginOptions} pluginOptions - Configuration options for the CodeBlock plugin.
*/
constructor(kernel, pluginOptions) {
super(kernel);
this.title = this.$.lang.codeBlock || 'Code Block';
this.icon = 'code_block';
this.#preTag = dom.utils.createElement('PRE');
this.#langs = pluginOptions?.langs ?? DEFAULT_LANGS;
if (!this.#langs.length) return;
/**
* ──────────────────────────────────
* [[ langs select ]]
* ──────────────────────────────────
*/
// ───────────────── [[toolbar dropdown type]] ─────────────────
this.afterItem = dom.utils.createElement(
'button',
{ class: 'se-btn se-tooltip se-sub-arrow-btn', type: 'button', 'data-command': CodeBlock.key, 'data-type': 'dropdown' },
`${this.$.icons.arrow_down}<span class="se-tooltip-inner"><span class="se-tooltip-text">${this.$.lang.codeLanguage || 'Language'}</span></span>`,
);
const menu = CreateDropdownHTML(this.$, this.#langs);
this.#langItems = menu.querySelectorAll('li button');
this.$.menu.initDropdownTarget({ key: CodeBlock.key, type: 'dropdown' }, menu);
// ───────────────── [hover UI] ─────────────────
// controller
const containerEl = dom.utils.createElement('DIV', { class: 'se-controller se-code-lang' });
this.#hoverButton = dom.utils.createElement('DIV', { class: 'se-code-lang-button' });
this.#updateHoverButtonText('');
containerEl.appendChild(this.#hoverButton);
this.#hoverController = new Controller(this, this.$, containerEl, { position: 'top', isWWTarget: true });
// mouseleave handler
this.#removeEventFunc = converter.debounce((e) => {
this.#mouseLeaveEvent = this.$.eventManager.removeEvent(this.#mouseLeaveEvent);
if (e && containerEl.contains(e.relatedTarget)) {
this.#addCtrlLeaveEvent();
} else {
this.#hideHover();
}
}, 0);
// SelectMenu
this.#hoverSelectMenu = new SelectMenu(this.$, {
position: 'bottom-right',
dir: this.$.options.get('_rtl') ? 'rtl' : 'ltr',
maxHeight: '214px',
minWidth: '132px',
closeMethod: this.#removeEventFunc,
});
this.#hoverSelectMenu.on(this.#hoverButton, this.#onHoverSelect.bind(this));
this.#buildHoverMenu('');
// selectMenu
this.$.eventManager.addEvent(this.#hoverButton, 'click', (e) => {
e.preventDefault();
e.stopPropagation();
if (this.#hoverSelectMenu.isOpen) {
this.#hoverSelectMenu.close();
} else {
const currentLang = this.#getPreLang(this.#hoverCurrentPre);
this.#buildHoverMenu(currentLang);
const items = this.#hoverSelectMenu.items;
const idx = currentLang ? items.indexOf(currentLang) : 0;
this.#hoverSelectMenu.open(null, idx >= 0 ? `[data-index="${idx}"]` : null);
}
});
}
/**
* @hook Editor.EventManager
* @type {SunEditor.Hook.Event.OnMouseMove}
*/
onMouseMove({ event }) {
if (!this.#hoverController) return;
const eventTarget = dom.query.getEventTarget(event);
const pre = eventTarget.closest('pre');
if (pre && !this.#isHoverOpen() && this.$.ui.opendControllers.length === 0) {
this.#showHover(pre);
}
}
/**
* @hook Editor.EventManager
* @type {SunEditor.Hook.Event.Active}
*/
active(element, target) {
if (/^PRE$/i.test(element?.nodeName)) {
dom.utils.addClass(target, 'active');
return true;
}
dom.utils.removeClass(target, 'active');
return false;
}
/**
* @override
* @type {PluginCommand['action']}
*/
action(target) {
const lang = target?.getAttribute('data-value') || '';
const selNode = this.$.selection.getNode();
const currentPre = dom.query.getParentElement(selNode, (el) => /^PRE$/i.test(el.nodeName));
if (currentPre && !lang) {
// toggle off: convert <pre> to default line
this.$.format.setLine(dom.utils.createElement(this.$.options.get('defaultLine')));
} else {
// toggle on or change language
if (!currentPre) {
this.$.format.setBrLine(this.#preTag.cloneNode(false));
}
if (lang) {
const pre = dom.query.getParentElement(this.$.selection.getNode(), (el) => /^PRE$/i.test(el.nodeName));
if (pre) this.#setLang(pre, lang);
}
}
this.$.menu.dropdownOff();
this.$.focusManager.focus();
this.$.history.push(false);
}
/**
* @impl Dropdown
* @type {PluginDropdown['on']}
*/
on() {
if (!this.#langItems) return;
const currentLang = this.#getPreLang(this.$.selection.getNode());
for (let i = 0, len = this.#langItems.length; i < len; i++) {
const item = this.#langItems[i];
dom.utils.toggleClass(item, 'active', item.getAttribute('data-value') === currentLang);
}
}
/**
* @description Shows the hover language selector over the given pre element.
* @param {HTMLElement} preElement
*/
#showHover(preElement) {
if (this.#hoverCurrentPre === preElement && this.#hoverController.isOpen) return;
if (this.#hoverCurrentPre && this.#hoverCurrentPre !== preElement) {
dom.utils.removeClass(this.#hoverCurrentPre, 'se-pre-code-focus');
}
this.#hoverCurrentPre = preElement;
dom.utils.addClass(preElement, 'se-pre-code-focus');
this.#hoverController.open(preElement, null, { passive: true, addOffset: { right: preElement.offsetWidth } });
this.#updateHoverButtonText(this.#getPreLang(preElement));
this.#addPreLeaveEvent();
}
#hideHover() {
if (this.#hoverSelectMenu?.isOpen) return;
this.#closeHover();
}
#closeHover() {
if (this.#hoverSelectMenu?.isOpen) this.#hoverSelectMenu.close();
dom.utils.removeClass(this.#hoverCurrentPre, 'se-pre-code-focus');
this.#hoverController.close(true);
}
/** @hook Module.Controller */
controllerClose() {
if (this.#hoverCurrentPre) {
dom.utils.removeClass(this.#hoverCurrentPre, 'se-pre-code-focus');
this.#hoverCurrentPre = null;
}
}
#onHoverSelect(langValue) {
if (!this.#hoverCurrentPre) return;
this.#setLang(this.#hoverCurrentPre, langValue);
this.#updateHoverButtonText(langValue);
this.#hoverSelectMenu.close();
this.#hideHover();
this.$.focusManager.focus();
this.$.history.push(false);
}
#addPreLeaveEvent() {
this.#mouseLeaveEvent ??= this.$.eventManager.addEvent(this.#hoverCurrentPre, 'mouseleave', this.#removeEventFunc);
}
#addCtrlLeaveEvent() {
this.#mouseLeaveEvent ??= this.$.eventManager.addEvent(this.#hoverController.form, 'mouseleave', this.#removeEventFunc);
}
#buildHoverMenu(currentLang) {
const noneLabel = this.$.lang.codeLanguage_none || 'None';
const hasExtra = currentLang && !this.#langs.includes(currentLang);
const items = hasExtra ? ['', currentLang, ...this.#langs] : ['', ...this.#langs];
const menus = hasExtra ? [noneLabel, currentLang, ...this.#langs] : [noneLabel, ...this.#langs];
this.#hoverSelectMenu.create(items, menus);
}
#updateHoverButtonText(lang) {
this.#hoverButton.innerHTML = /* html */ `<span class="se-code-lang-icon"></></span><span class="se-code-lang-text">${lang || this.$.lang.codeLanguage || 'Language'}</span>`;
}
#isHoverOpen() {
return this.#hoverSelectMenu?.isOpen || this.#hoverController?.isOpen;
}
/**
* @description Get the language from a pre element's class.
* @param {?Node} preOrChild - The pre element or a node inside it
* @returns {string}
*/
#getPreLang(preOrChild) {
const pre = preOrChild?.nodeName === 'PRE' ? preOrChild : dom.query.getParentElement(preOrChild, (el) => /^PRE$/i.test(el.nodeName));
if (!pre) return '';
return /** @type {HTMLElement} */ (pre).className.match(/language-(\S+)/)?.[1] || '';
}
/**
* @description Set language class on a pre element.
* @param {HTMLElement} pre
* @param {string} lang
*/
#setLang(pre, lang) {
pre.className = pre.className.replace(/\s*language-\S+/g, '').trim();
if (lang) {
dom.utils.addClass(pre, 'language-' + lang);
pre.setAttribute('data-se-lang', lang);
} else {
pre.removeAttribute('data-se-lang');
}
}
/**
* @description Cleans up resources.
*/
destroy() {
if (this.#hoverCurrentPre) {
dom.utils.removeClass(this.#hoverCurrentPre, 'se-pre-code-focus');
}
this.#hoverController?.form?.parentNode?.removeChild(this.#hoverController.form);
this.#hoverCurrentPre = null;
}
}
/**
* @param {SunEditor.Deps} $
* @param {string[]} langs
* @returns {HTMLElement}
*/
function CreateDropdownHTML($, langs) {
const noneLabel = $.lang.codeLanguage_none || 'None';
let list = '<div class="se-list-inner"><ul class="se-list-basic">';
list += `<li><button type="button" class="se-btn se-btn-list" data-command="codeBlock" data-value="" title="${noneLabel}">${noneLabel}</button></li>`;
for (const lang of langs) {
list += `<li><button type="button" class="se-btn se-btn-list" data-command="codeBlock" data-value="${lang}" title="${lang}">${lang}</button></li>`;
}
list += '</ul></div>';
return dom.utils.createElement('DIV', { class: 'se-dropdown se-list-layer se-list-code-block' }, list);
}
export default CodeBlock;