/**
* Internal floating toolbar manager.
*
* Renders schema-declared toolbar chrome against one active editor host
* contract so text and non-text hosts share the same floating UI behavior.
*
* Exposes: SFE.ToolbarManager
*/
(function() {
'use strict';
window.MWP = window.MWP || {};
window.MWP.SFE = window.MWP.SFE || {};
const SFE = window.MWP.SFE;
SFE.ManagerData = SFE.ManagerData || {};
const TOOLBAR_FORMAT_ICONS = {
undo: '',
redo: '',
bold: '',
italic: '',
strikethrough: '',
link: '',
alignNone: '',
alignWide: '',
alignFull: '',
alignLeft: '',
alignCenter: '',
alignRight: '',
textAlignLeft: '',
textAlignCenter: '',
textAlignRight: '',
orderedList: '',
unorderedList: '',
indent: '',
outdent: '',
textAlignmentDropdown: '',
replaceMedia: 'Replace',
};
/**
* Create one runtime toolbar button definition.
*
* @param {Object} config Button configuration.
* @returns {Object} Runtime toolbar button definition.
*/
function createToolbarButton(config = {}) {
return {
icon: config.icon || '',
title: config.title || '',
action: typeof config.action === 'function' ? config.action : () => {},
className: config.className || '',
formatKey: config.formatKey || '',
formatType: config.formatType || '',
value: Object.prototype.hasOwnProperty.call(config, 'value') ? config.value : undefined,
tag: config.tag || '',
activeTags: Array.isArray(config.activeTags) ? config.activeTags : [],
};
}
/**
* Create one runtime toolbar dropdown definition.
*
* @param {Object} config Dropdown configuration.
* @returns {Object} Runtime toolbar dropdown definition.
*/
function createToolbarDropdown(config = {}) {
return {
type: 'dropdown',
title: config.title || '',
defaultIcon: config.defaultIcon || '',
options: Array.isArray(config.options) ? config.options.filter(Boolean) : [],
formatKey: config.formatKey || '',
};
}
/**
* Execute one schema block-attribute operation from a toolbar action.
*
* @param {Object|null} editor Editor host.
* @param {string} operationId Schema operation id.
* @param {*} value Requested operation value.
* @returns {void}
*/
function executeSchemaBlockAttributeOperation(editor, operationId, value) {
const operationExecutor = SFE.SchemaOperationExecutor || null;
if (!operationExecutor || typeof operationExecutor.executeBlockAttributeOperation !== 'function') {
return;
}
operationExecutor.executeBlockAttributeOperation({
editorHost: editor,
operationId,
value,
saveHistory: true,
});
}
/**
* Execute one schema list operation from a toolbar action.
*
* @param {Object|null} editor Editor host.
* @param {string} operationId Schema or primitive operation id.
* @returns {void}
*/
function executeSchemaListOperation(editor, operationId) {
if (typeof editor?.executeListStructureOperation !== 'function') {
return;
}
editor.executeListStructureOperation({
kind: operationId,
});
}
/**
* Execute one list type switch from a toolbar action.
*
* @param {Object|null} editor Editor host.
* @param {string} listType List type token.
* @returns {void}
*/
function executeListTypeOperation(editor, listType) {
const createTag = listType === 'ordered' ? 'ol' : 'ul';
const oppositeTag = listType === 'ordered' ? 'UL' : 'OL';
const operationExecutor = SFE.SchemaOperationExecutor || null;
if (editor?._linkUIActive && typeof editor.closeLinkUI === 'function') {
editor.closeLinkUI();
}
const selection = window.getSelection();
if (selection?.rangeCount > 0) {
const range = selection.getRangeAt(0);
let startLi = range.startContainer;
let endLi = range.endContainer;
while (startLi && startLi !== editor?.element && startLi.tagName !== 'LI') {
startLi = startLi.parentNode;
}
while (endLi && endLi !== editor?.element && endLi.tagName !== 'LI') {
endLi = endLi.parentNode;
}
if (startLi && endLi && startLi !== endLi) {
return;
}
}
if (
(editor?.element?.tagName === 'UL' || editor?.element?.tagName === 'OL') &&
operationExecutor &&
typeof operationExecutor.executeCurrentListTypeChange === 'function'
) {
const result = operationExecutor.executeCurrentListTypeChange({
editorHost: editor,
value: listType,
});
if (result) {
return;
}
}
const listItem = typeof editor?.getCurrentListItem === 'function'
? editor.getCurrentListItem()
: null;
if (!listItem) {
if (typeof editor?.insertListManual === 'function') {
editor.insertListManual(createTag);
setTimeout(() => {
const list = typeof editor?.getParentList === 'function'
? editor.getParentList()
: null;
if (list) {
list.classList.add('wp-block-list');
}
}, 10);
}
return;
}
const currentList = listItem.parentNode;
if (
currentList &&
currentList.tagName === oppositeTag &&
typeof editor?.changeListType === 'function'
) {
editor.changeListType(currentList, createTag);
}
}
/**
* Build one toolbar button for a schema token that toggles inline formatting.
*
* @param {string} token Token name.
* @param {string} title Button title.
* @param {string} icon Icon markup.
* @param {string} tagName Inline tag name.
* @param {string[]} activeTags Active-tag list for toolbar state.
* @returns {Object} Runtime toolbar button definition.
*/
function createInlineFormatButton(token, title, icon, tagName, activeTags) {
return createToolbarButton({
formatKey: token,
title,
icon,
activeTags,
action: (editor) => {
if (typeof editor?.toggleInlineFormat !== 'function') {
return;
}
editor.executeAction(() => {
editor.toggleInlineFormat(tagName);
}, { saveHistory: false });
},
});
}
/**
* Build one toolbar button for a schema token that opens link editing.
*
* @param {string} token Token name.
* @param {string} title Button title.
* @returns {Object} Runtime toolbar button definition.
*/
function createLinkButton(token, title) {
return createToolbarButton({
formatKey: token,
title,
icon: TOOLBAR_FORMAT_ICONS.link,
action: (editor) => {
if (typeof editor?.showLinkUI !== 'function') {
return;
}
const usesElementScopedLinkEditing = typeof editor.supportsElementLinkEditing === 'function'
? editor.supportsElementLinkEditing()
: false;
const existingLink = typeof editor.getParentElement === 'function'
? editor.getParentElement('a')
: null;
editor.showLinkUI(usesElementScopedLinkEditing ? null : existingLink);
},
});
}
/**
* Determine whether schema declares element-scoped link editing for the
* active text component.
*
* @param {HTMLElement|null} element Active editable element.
* @param {Object} editorOptions Normalized component editor options.
* @returns {boolean} True when the component edits its root anchor directly.
*/
function supportsElementScopedLinkToken(element, editorOptions = {}) {
if (!element || element.tagName !== 'A') {
return false;
}
const inlineFormatCapabilities = (
editorOptions?.inlineFormatCapabilities &&
typeof editorOptions.inlineFormatCapabilities === 'object'
)
? editorOptions.inlineFormatCapabilities
: null;
const attributeCapabilities = (
editorOptions?.attributeCapabilities &&
typeof editorOptions.attributeCapabilities === 'object'
)
? editorOptions.attributeCapabilities
: null;
const buttonLinkCapability = inlineFormatCapabilities?.buttonLink;
const buttonLinkTag = typeof buttonLinkCapability?.tag === 'string'
? buttonLinkCapability.tag.trim().toLowerCase()
: '';
if (buttonLinkTag !== 'a') {
return false;
}
const attributes = Array.isArray(attributeCapabilities?.buttonLink?.attributes)
? attributeCapabilities.buttonLink.attributes
.map(value => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean)
: [];
return attributes.includes('url');
}
/**
* Build one toolbar button for a schema list indentation action.
*
* @param {string} token Token name.
* @param {string} title Button title.
* @param {string} icon Icon markup.
* @param {string} operationId Schema list operation id.
* @returns {Object} Runtime toolbar button definition.
*/
function createListIndentButton(token, title, icon, operationId) {
return createToolbarButton({
formatKey: token,
title,
icon,
action: (editor) => {
executeSchemaListOperation(editor, operationId);
},
});
}
/**
* Build one toolbar option for schema-backed block alignment.
*
* @param {string} optionKey Icon/format lookup key.
* @param {string} title Option title.
* @param {string} value Alignment value.
* @returns {Object} Runtime toolbar option definition.
*/
function createBlockAlignOption(optionKey, title, value) {
return createToolbarButton({
formatKey: optionKey,
formatType: 'blockAlign',
title,
icon: TOOLBAR_FORMAT_ICONS[optionKey],
value,
action: (editor) => {
executeSchemaBlockAttributeOperation(editor, 'set_align', value);
},
});
}
/**
* Build one toolbar option for schema-backed text alignment.
*
* @param {string} optionKey Icon/format lookup key.
* @param {string} title Option title.
* @param {string} value Alignment value.
* @returns {Object} Runtime toolbar option definition.
*/
function createTextAlignmentOption(optionKey, title, value) {
return createToolbarButton({
formatKey: optionKey,
formatType: 'textAlignment',
title,
icon: TOOLBAR_FORMAT_ICONS[optionKey],
value,
action: (editor) => {
executeSchemaBlockAttributeOperation(editor, 'set_text_align', value);
},
});
}
/**
* Build one toolbar option for schema-backed heading level changes.
*
* @param {string|number} level Heading level or element-tag value.
* @returns {Object} Runtime toolbar option definition.
*/
function createHeadingLevelOption(level) {
const value = typeof level === 'string' ? level.trim().toLowerCase() : level;
const tag = typeof value === 'number' ? `h${value}` : value;
const levelNumber = Number.parseInt(String(tag).replace(/^h/i, ''), 10);
const title = tag === 'p' ? 'Paragraph' : tag === 'div' ? 'Div' : `Heading ${levelNumber}`;
return createToolbarButton({
formatKey: String(tag),
title,
icon: title,
tag,
value,
action: (editor) => {
executeSchemaBlockAttributeOperation(editor, 'set_heading_level', value);
},
});
}
/**
* Return the canonical built-in block-align toolbar option map.
*
* @returns {Map} Built-in block-align options keyed by value.
*/
function getBlockAlignOptionMap() {
return new Map([
['none', createBlockAlignOption('alignNone', 'None', 'none')],
['wide', createBlockAlignOption('alignWide', 'Wide Width', 'wide')],
['full', createBlockAlignOption('alignFull', 'Full Width', 'full')],
['left', createBlockAlignOption('alignLeft', 'Align Left', 'left')],
['center', createBlockAlignOption('alignCenter', 'Align Center', 'center')],
['right', createBlockAlignOption('alignRight', 'Align Right', 'right')],
]);
}
/**
* Create the schema-backed text-alignment dropdown.
*
* @returns {Object} Runtime toolbar dropdown definition.
*/
function createTextAlignmentDropdown() {
return createToolbarDropdown({
formatKey: 'textAlignment',
title: 'Text Alignment',
defaultIcon: TOOLBAR_FORMAT_ICONS.textAlignmentDropdown,
options: [
createTextAlignmentOption('textAlignLeft', 'Align Text Left', 'left'),
createTextAlignmentOption('textAlignCenter', 'Align Text Center', 'center'),
createTextAlignmentOption('textAlignRight', 'Align Text Right', 'right'),
],
});
}
function getSchemaBlockAlignValues(editorOptions = {}) {
const operations = Array.isArray(editorOptions?.operations)
? editorOptions.operations
: [];
const operation = operations.find(candidate => (
String(candidate?.id || '').trim() === 'set_align' &&
String(candidate?.kind || '').trim() === 'block_attribute_change'
)) || null;
const operationValues = Array.isArray(operation?.values)
? operation.values
: null;
const capabilityValues = Array.isArray(editorOptions?.attributeCapabilities?.align?.values)
? editorOptions.attributeCapabilities.align.values
: null;
const values = operationValues && operationValues.length
? operationValues
: capabilityValues;
return Array.isArray(values)
? values.map(value => String(value || '').trim().toLowerCase()).filter(Boolean)
: [];
}
function createBlockAlignDropdown(editorOptions = {}) {
const allowedValues = getSchemaBlockAlignValues(editorOptions);
const optionMap = getBlockAlignOptionMap();
const options = allowedValues.map(value => optionMap.get(value)).filter(Boolean);
if (!options.length) {
return null;
}
return createToolbarDropdown({
formatKey: 'align',
title: 'Align',
defaultIcon: options[0].icon,
options
});
}
/**
* Create the schema-backed heading-level dropdown.
*
* @returns {Object} Runtime toolbar dropdown definition.
*/
function createHeadingDropdown(editorOptions = {}) {
const operation = (editorOptions.operations || []).find(candidate => (
String(candidate?.id || '').trim() === 'set_heading_level'
)) || null;
const values = Array.isArray(operation?.values)
? operation.values
: (editorOptions.attributeCapabilities?.headingLevels?.values || []);
return createToolbarDropdown({
formatKey: 'headingLevels',
title: 'Heading Level',
defaultIcon: 'H',
options: values.map(createHeadingLevelOption),
});
}
/**
* Create the shared media-replace toolbar button.
*
* @returns {Object} Runtime toolbar button definition.
*/
function createReplaceMediaButton() {
return createToolbarButton({
formatKey: 'replaceMedia',
icon: TOOLBAR_FORMAT_ICONS.replaceMedia,
title: 'Replace Media',
className: 'mwp-sfe-editor-btn-text',
action: (editor) => {
if (typeof editor?.showMediaReplaceUI === 'function') {
editor.showMediaReplaceUI();
}
}
});
}
/**
* Resolve one built-in schema toolbar token to a runtime format definition.
*
* @param {string} token Built-in schema toolbar token.
* @param {HTMLElement|null} element Active editable element.
* @param {Object} editorOptions Normalized editor options.
* @returns {Object|null} Runtime toolbar definition or null.
*/
function resolveSchemaFormatToken(token, element, editorOptions = {}) {
const normalizedToken = typeof token === 'string' ? token.trim() : '';
if (!normalizedToken) return null;
switch (normalizedToken) {
case 'undo':
return createToolbarButton({
formatKey: 'undo',
title: 'Undo',
icon: TOOLBAR_FORMAT_ICONS.undo,
action: (editor) => editor?.undo?.(),
});
case 'redo':
return createToolbarButton({
formatKey: 'redo',
title: 'Redo',
icon: TOOLBAR_FORMAT_ICONS.redo,
action: (editor) => editor?.redo?.(),
});
case 'bold':
return createInlineFormatButton('bold', 'Bold', TOOLBAR_FORMAT_ICONS.bold, 'strong', ['strong', 'b']);
case 'italic':
return createInlineFormatButton('italic', 'Italic', TOOLBAR_FORMAT_ICONS.italic, 'em', ['em', 'i']);
case 'strikethrough':
return createInlineFormatButton('strikethrough', 'Strikethrough', TOOLBAR_FORMAT_ICONS.strikethrough, 's', ['s', 'strike']);
case 'link':
return createLinkButton('link', 'Link');
case 'buttonLink':
return !supportsElementScopedLinkToken(element, editorOptions)
? null
: createLinkButton('buttonLink', 'Button Link');
case 'textAlignment':
return createTextAlignmentDropdown();
case 'align':
return createBlockAlignDropdown(editorOptions);
case 'headingLevels':
return createHeadingDropdown(editorOptions);
case 'orderedList':
return createToolbarButton({
formatKey: 'orderedList',
title: 'Ordered List',
icon: TOOLBAR_FORMAT_ICONS.orderedList,
action: (editor) => executeListTypeOperation(editor, 'ordered'),
});
case 'unorderedList':
return createToolbarButton({
formatKey: 'unorderedList',
title: 'Unordered List',
icon: TOOLBAR_FORMAT_ICONS.unorderedList,
action: (editor) => executeListTypeOperation(editor, 'unordered'),
});
case 'indent':
return createListIndentButton('indent', 'Indent', TOOLBAR_FORMAT_ICONS.indent, 'indent_list_item');
case 'outdent':
return createListIndentButton('outdent', 'Outdent', TOOLBAR_FORMAT_ICONS.outdent, 'outdent_list_item');
case 'replaceMedia':
return createReplaceMediaButton();
default:
return null;
}
}
/**
* Convert one nested schema format token spec into concrete toolbar configs.
*
* @param {Array} formatsSpec Nested schema token spec.
* @param {HTMLElement|null} element Active editable element.
* @param {Object} editorOptions Normalized editor options.
* @param {number} depth Current recursion depth.
* @returns {Array