@ckeditor/ckeditor5-list
Version:
Ordered and unordered lists feature to CKEditor 5.
380 lines (379 loc) • 17.6 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module list/listproperties/listpropertiesui
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { IconBulletedList, IconNumberedList, IconListStyleCircle, IconListStyleDecimal, IconListStyleDecimalLeadingZero, IconListStyleDisc, IconListStyleLowerLatin, IconListStyleLowerRoman, IconListStyleSquare, IconListStyleUpperLatin, IconListStyleUpperRoman } from 'ckeditor5/src/icons.js';
import { ButtonView, SplitButtonView, createDropdown, focusChildOnDropdownOpen, MenuBarMenuView } from 'ckeditor5/src/ui.js';
import ListPropertiesView from './ui/listpropertiesview.js';
import { getNormalizedConfig } from './utils/config.js';
import '../../theme/liststyles.css';
/**
* The list properties UI plugin. It introduces the extended `'bulletedList'` and `'numberedList'` toolbar
* buttons that allow users to control such aspects of list as the marker, start index or order.
*
* **Note**: Buttons introduced by this plugin override implementations from the {@link module:list/list/listui~ListUI}
* (because they share the same names).
*/
export default class ListPropertiesUI extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'ListPropertiesUI';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
init() {
const editor = this.editor;
const t = editor.locale.t;
const propertiesConfig = editor.config.get('list.properties');
const normalizedConfig = getNormalizedConfig(propertiesConfig);
const stylesListTypes = normalizedConfig.styles.listTypes;
// Note: When this plugin does not register the "bulletedList" dropdown due to properties configuration,
// a simple button will be still registered under the same name by ListUI as a fallback. This should happen
// in most editor configuration because the List plugin automatically requires ListUI.
if (stylesListTypes.includes('bulleted')) {
const styleDefinitions = [
{
label: t('Toggle the disc list style'),
tooltip: t('Disc'),
type: 'disc',
icon: IconListStyleDisc
},
{
label: t('Toggle the circle list style'),
tooltip: t('Circle'),
type: 'circle',
icon: IconListStyleCircle
},
{
label: t('Toggle the square list style'),
tooltip: t('Square'),
type: 'square',
icon: IconListStyleSquare
}
];
const buttonLabel = t('Bulleted List');
const styleGridAriaLabel = t('Bulleted list styles toolbar');
const commandName = 'bulletedList';
editor.ui.componentFactory.add(commandName, getDropdownViewCreator({
editor,
normalizedConfig,
parentCommandName: commandName,
buttonLabel,
buttonIcon: IconBulletedList,
styleGridAriaLabel,
styleDefinitions
}));
// Add the menu bar item for bulleted list.
editor.ui.componentFactory.add(`menuBar:${commandName}`, getMenuBarStylesMenuCreator({
editor,
normalizedConfig,
parentCommandName: commandName,
buttonLabel,
styleGridAriaLabel,
styleDefinitions
}));
}
// Note: When this plugin does not register the "numberedList" dropdown due to properties configuration,
// a simple button will be still registered under the same name by ListUI as a fallback. This should happen
// in most editor configuration because the List plugin automatically requires ListUI.
if (stylesListTypes.includes('numbered') || propertiesConfig.startIndex || propertiesConfig.reversed) {
const styleDefinitions = [
{
label: t('Toggle the decimal list style'),
tooltip: t('Decimal'),
type: 'decimal',
icon: IconListStyleDecimal
},
{
label: t('Toggle the decimal with leading zero list style'),
tooltip: t('Decimal with leading zero'),
type: 'decimal-leading-zero',
icon: IconListStyleDecimalLeadingZero
},
{
label: t('Toggle the lower–roman list style'),
tooltip: t('Lower–roman'),
type: 'lower-roman',
icon: IconListStyleLowerRoman
},
{
label: t('Toggle the upper–roman list style'),
tooltip: t('Upper-roman'),
type: 'upper-roman',
icon: IconListStyleUpperRoman
},
{
label: t('Toggle the lower–latin list style'),
tooltip: t('Lower-latin'),
type: 'lower-latin',
icon: IconListStyleLowerLatin
},
{
label: t('Toggle the upper–latin list style'),
tooltip: t('Upper-latin'),
type: 'upper-latin',
icon: IconListStyleUpperLatin
}
];
const buttonLabel = t('Numbered List');
const styleGridAriaLabel = t('Numbered list styles toolbar');
const commandName = 'numberedList';
editor.ui.componentFactory.add(commandName, getDropdownViewCreator({
editor,
normalizedConfig,
parentCommandName: commandName,
buttonLabel,
buttonIcon: IconNumberedList,
styleGridAriaLabel,
styleDefinitions
}));
// Menu bar menu does not display list start index or reverse UI. If there are no styles enabled,
// the menu makes no sense and should be omitted.
if (stylesListTypes.includes('numbered')) {
editor.ui.componentFactory.add(`menuBar:${commandName}`, getMenuBarStylesMenuCreator({
editor,
normalizedConfig,
parentCommandName: commandName,
buttonLabel,
styleGridAriaLabel,
styleDefinitions
}));
}
}
}
}
/**
* A helper that returns a function that creates a split button with a toolbar in the dropdown,
* which in turn contains buttons allowing users to change list styles in the context of the current selection.
*
* @param options.editor
* @param options.normalizedConfig List properties configuration.
* @param options.parentCommandName The name of the higher-order editor command associated with
* the set of particular list styles (e.g. "bulletedList" for "disc", "circle", and "square" styles).
* @param options.buttonLabel Label of the main part of the split button.
* @param options.buttonIcon The SVG string of an icon for the main part of the split button.
* @param options.styleGridAriaLabel The ARIA label for the styles grid in the split button dropdown.
* @param options.styleDefinitions Definitions of the style buttons.
* @returns A function that can be passed straight into {@link module:ui/componentfactory~ComponentFactory#add}.
*/
function getDropdownViewCreator({ editor, normalizedConfig, parentCommandName, buttonLabel, buttonIcon, styleGridAriaLabel, styleDefinitions }) {
const parentCommand = editor.commands.get(parentCommandName);
return (locale) => {
const dropdownView = createDropdown(locale, SplitButtonView);
const mainButtonView = dropdownView.buttonView;
dropdownView.bind('isEnabled').to(parentCommand);
dropdownView.class = 'ck-list-styles-dropdown';
// Main button was clicked.
mainButtonView.on('execute', () => {
editor.execute(parentCommandName);
editor.editing.view.focus();
});
mainButtonView.set({
label: buttonLabel,
icon: buttonIcon,
tooltip: true,
isToggleable: true
});
mainButtonView.bind('isOn').to(parentCommand, 'value', value => !!value);
dropdownView.once('change:isOpen', () => {
const listPropertiesView = createListPropertiesView({
editor,
normalizedConfig,
dropdownView,
parentCommandName,
styleGridAriaLabel,
styleDefinitions
});
dropdownView.panelView.children.add(listPropertiesView);
});
// Focus the editable after executing the command.
// Overrides a default behaviour where the focus is moved to the dropdown button (#12125).
dropdownView.on('execute', () => {
editor.editing.view.focus();
});
return dropdownView;
};
}
/**
* A helper that returns a function (factory) that creates individual buttons used by users to change styles
* of lists.
*
* @param options.editor
* @param options.listStyleCommand The instance of the `ListStylesCommand` class.
* @param options.parentCommandName The name of the higher-order command associated with a
* particular list style (e.g. "bulletedList" is associated with "square" and "numberedList" is associated with "roman").
* @returns A function that can be passed straight into {@link module:ui/componentfactory~ComponentFactory#add}.
*/
function getStyleButtonCreator({ editor, listStyleCommand, parentCommandName }) {
const locale = editor.locale;
const parentCommand = editor.commands.get(parentCommandName);
return ({ label, type, icon, tooltip }) => {
const button = new ButtonView(locale);
button.set({ label, icon, tooltip });
button.bind('isOn').to(listStyleCommand, 'value', value => value === type);
button.on('execute', () => {
// If the content the selection is anchored to is a list, let's change its style.
if (parentCommand.value) {
// Remove the list when the current list style is the same as the one that would normally be applied.
if (listStyleCommand.value === type) {
editor.execute(parentCommandName);
}
// If the current list style is not set in the model or the style is different than the
// one to be applied, simply apply the new style.
else if (listStyleCommand.value !== type) {
editor.execute('listStyle', { type });
}
}
// Otherwise, leave the creation of the styled list to the `ListStyleCommand`.
else {
editor.model.change(() => {
editor.execute('listStyle', { type });
});
}
});
return button;
};
}
/**
* A helper that creates the properties view for the individual style dropdown.
*
* @param options.editor Editor instance.
* @param options.normalizedConfig List properties configuration.
* @param options.dropdownView Styles dropdown view that hosts the properties view.
* @param options.parentCommandName The name of the higher-order editor command associated with
* the set of particular list styles (e.g. "bulletedList" for "disc", "circle", and "square" styles).
* @param options.styleDefinitions Definitions of the style buttons.
* @param options.styleGridAriaLabel An assistive technologies label set on the grid of styles (if the grid is rendered).
*/
function createListPropertiesView({ editor, normalizedConfig, dropdownView, parentCommandName, styleDefinitions, styleGridAriaLabel }) {
const locale = editor.locale;
const enabledProperties = {
...normalizedConfig,
...(parentCommandName != 'numberedList' ? {
startIndex: false,
reversed: false
} : null)
};
const listType = parentCommandName.replace('List', '');
let styleButtonViews = null;
if (normalizedConfig.styles.listTypes.includes(listType)) {
const listStyleCommand = editor.commands.get('listStyle');
const styleButtonCreator = getStyleButtonCreator({
editor,
parentCommandName,
listStyleCommand
});
const configuredListStylesTypes = normalizedConfig.styles.listStyleTypes;
let filteredDefinitions = styleDefinitions;
if (configuredListStylesTypes) {
const allowedTypes = configuredListStylesTypes[listType];
if (allowedTypes) {
filteredDefinitions = styleDefinitions.filter(def => allowedTypes.includes(def.type));
}
}
const isStyleTypeSupported = getStyleTypeSupportChecker(listStyleCommand);
styleButtonViews = filteredDefinitions
.filter(isStyleTypeSupported)
.map(styleButtonCreator);
}
const listPropertiesView = new ListPropertiesView(locale, {
styleGridAriaLabel,
enabledProperties,
styleButtonViews
});
if (normalizedConfig.styles.listTypes.includes(listType)) {
// Accessibility: focus the first active style when opening the dropdown.
focusChildOnDropdownOpen(dropdownView, () => {
return listPropertiesView.stylesView.children.find((child) => child.isOn);
});
}
if (enabledProperties.startIndex) {
const listStartCommand = editor.commands.get('listStart');
listPropertiesView.startIndexFieldView.bind('isEnabled').to(listStartCommand);
listPropertiesView.startIndexFieldView.fieldView.bind('value').to(listStartCommand);
listPropertiesView.on('listStart', (evt, data) => editor.execute('listStart', data));
}
if (enabledProperties.reversed) {
const listReversedCommand = editor.commands.get('listReversed');
listPropertiesView.reversedSwitchButtonView.bind('isEnabled').to(listReversedCommand);
listPropertiesView.reversedSwitchButtonView.bind('isOn').to(listReversedCommand, 'value', value => !!value);
listPropertiesView.on('listReversed', () => {
const isReversed = listReversedCommand.value;
editor.execute('listReversed', { reversed: !isReversed });
});
}
// Make sure applying styles closes the dropdown.
listPropertiesView.delegate('execute').to(dropdownView);
return listPropertiesView;
}
/**
* A helper that creates the list style submenu for menu bar.
*
* @param editor Editor instance.
* @param normalizedConfig List properties configuration.
* @param parentCommandName Name of the list command.
* @param buttonLabel Label of the menu button.
* @param styleGridAriaLabel ARIA label of the styles grid.
*/
function getMenuBarStylesMenuCreator({ editor, normalizedConfig, parentCommandName, buttonLabel, styleGridAriaLabel, styleDefinitions }) {
return (locale) => {
const menuView = new MenuBarMenuView(locale);
const listCommand = editor.commands.get(parentCommandName);
const listStyleCommand = editor.commands.get('listStyle');
const isStyleTypeSupported = getStyleTypeSupportChecker(listStyleCommand);
const styleButtonCreator = getStyleButtonCreator({
editor,
parentCommandName,
listStyleCommand
});
const configuredListStylesTypes = normalizedConfig.styles.listStyleTypes;
let filteredDefinitions = styleDefinitions;
if (configuredListStylesTypes) {
const listType = listCommand.type;
const allowedTypes = configuredListStylesTypes[listType];
if (allowedTypes) {
filteredDefinitions = styleDefinitions.filter(def => allowedTypes.includes(def.type));
}
}
const styleButtonViews = filteredDefinitions.filter(isStyleTypeSupported).map(styleButtonCreator);
const listPropertiesView = new ListPropertiesView(locale, {
styleGridAriaLabel,
enabledProperties: {
...normalizedConfig,
// Disable list start index and reversed in the menu bar.
startIndex: false,
reversed: false
},
styleButtonViews
});
listPropertiesView.delegate('execute').to(menuView);
menuView.buttonView.set({
label: buttonLabel,
icon: parentCommandName === 'bulletedList' ? IconBulletedList : IconNumberedList
});
menuView.panelView.children.add(listPropertiesView);
menuView.bind('isEnabled').to(listCommand, 'isEnabled');
menuView.on('execute', () => {
editor.editing.view.focus();
});
return menuView;
};
}
function getStyleTypeSupportChecker(listStyleCommand) {
if (typeof listStyleCommand.isStyleTypeSupported == 'function') {
return (styleDefinition) => listStyleCommand.isStyleTypeSupported(styleDefinition.type);
}
else {
return () => true;
}
}