editor.js/src/components/modules/toolbar/conversion.ts
Peter Savchenko 3272efc3f7
chore(linting): eslint updated, code linted (#2174)
* update eslint + autofix

* a bunch of eslint fixes

* some spelling & eslint fixes

* fix some eslint errors and spells

* Update __module.ts

* a bunch of eslint fixes in tests

* Update cypress.yml

* Update cypress.yml

* fix cypress docker image name

* fixes for tests

* more tests fixed

* rm rule ignore

* rm another ignored rule

* Update .eslintrc
2022-11-25 21:56:50 +04:00

377 lines
11 KiB
TypeScript

import Module from '../../__module';
import $ from '../../dom';
import * as _ from '../../utils';
import { SavedData } from '../../../../types/data-formats';
import Flipper from '../../flipper';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import { clean } from '../../utils/sanitizer';
import { ToolboxConfigEntry, BlockToolData } from '../../../../types';
/**
* HTML Elements used for ConversionToolbar
*/
interface ConversionToolbarNodes {
wrapper: HTMLElement;
tools: HTMLElement;
}
/**
* Block Converter
*
* @todo Make the Conversion Toolbar no-module but a standalone class, like Toolbox
*/
export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
/**
* CSS getter
*/
public static get CSS(): { [key: string]: string } {
return {
conversionToolbarWrapper: 'ce-conversion-toolbar',
conversionToolbarShowed: 'ce-conversion-toolbar--showed',
conversionToolbarTools: 'ce-conversion-toolbar__tools',
conversionToolbarLabel: 'ce-conversion-toolbar__label',
conversionTool: 'ce-conversion-tool',
conversionToolHidden: 'ce-conversion-tool--hidden',
conversionToolIcon: 'ce-conversion-tool__icon',
conversionToolFocused: 'ce-conversion-tool--focused',
conversionToolActive: 'ce-conversion-tool--active',
};
}
/**
* Conversion Toolbar open/close state
*
* @type {boolean}
*/
public opened = false;
/**
* Available tools data
*/
private tools: {name: string; toolboxItem: ToolboxConfigEntry; button: HTMLElement}[] = [];
/**
* Instance of class that responses for leafing buttons by arrows/tab
*
* @type {Flipper|null}
*/
private flipper: Flipper = null;
/**
* Callback that fill be fired on open/close and accepts an opening state
*/
private togglingCallback = null;
/**
* Create UI of Conversion Toolbar
*/
public make(): HTMLElement {
this.nodes.wrapper = $.make('div', [
ConversionToolbar.CSS.conversionToolbarWrapper,
...(this.isRtl ? [ this.Editor.UI.CSS.editorRtlFix ] : []),
]);
this.nodes.tools = $.make('div', ConversionToolbar.CSS.conversionToolbarTools);
const label = $.make('div', ConversionToolbar.CSS.conversionToolbarLabel, {
textContent: I18n.ui(I18nInternalNS.ui.inlineToolbar.converter, 'Convert to'),
});
/**
* Add Tools that has 'import' method
*/
this.addTools();
/**
* Prepare Flipper to be able to leaf tools by arrows/tab
*/
this.enableFlipper();
$.append(this.nodes.wrapper, label);
$.append(this.nodes.wrapper, this.nodes.tools);
return this.nodes.wrapper;
}
/**
* Deactivates flipper and removes all nodes
*/
public destroy(): void {
/**
* Sometimes (in read-only mode) there is no Flipper
*/
if (this.flipper) {
this.flipper.deactivate();
this.flipper = null;
}
this.removeAllNodes();
}
/**
* Toggle conversion dropdown visibility
*
* @param {Function} [togglingCallback] — callback that will accept opening state
*/
public toggle(togglingCallback?: (openedState: boolean) => void): void {
if (!this.opened) {
this.open();
} else {
this.close();
}
if (_.isFunction(togglingCallback)) {
this.togglingCallback = togglingCallback;
}
}
/**
* Shows Conversion Toolbar
*/
public open(): void {
this.filterTools();
this.opened = true;
this.nodes.wrapper.classList.add(ConversionToolbar.CSS.conversionToolbarShowed);
/**
* We use RAF to prevent bubbling Enter keydown on first dropdown item
* Conversion flipper will be activated after dropdown will open
*/
window.requestAnimationFrame(() => {
this.flipper.activate(this.tools.map(tool => tool.button).filter((button) => {
return !button.classList.contains(ConversionToolbar.CSS.conversionToolHidden);
}));
this.flipper.focusFirst();
if (_.isFunction(this.togglingCallback)) {
this.togglingCallback(true);
}
});
}
/**
* Closes Conversion Toolbar
*/
public close(): void {
this.opened = false;
this.flipper.deactivate();
this.nodes.wrapper.classList.remove(ConversionToolbar.CSS.conversionToolbarShowed);
if (_.isFunction(this.togglingCallback)) {
this.togglingCallback(false);
}
}
/**
* Returns true if it has more than one tool available for convert in
*/
public hasTools(): boolean {
if (this.tools.length === 1) {
return this.tools[0].name !== this.config.defaultBlock;
}
return true;
}
/**
* Replaces one Block with another
* For that Tools must provide import/export methods
*
* @param {string} replacingToolName - name of Tool which replaces current
* @param blockDataOverrides - Block data overrides. Could be passed in case if Multiple Toolbox items specified
*/
public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
/**
* At first, we get current Block data
*/
const currentBlockTool = this.Editor.BlockManager.currentBlock.tool;
const savedBlock = await this.Editor.BlockManager.currentBlock.save() as SavedData;
const blockData = savedBlock.data;
/**
* Getting a class of replacing Tool
*/
const replacingTool = this.Editor.Tools.blockTools.get(replacingToolName);
/**
* Export property can be:
* 1) Function — Tool defines which data to return
* 2) String — the name of saved property
*
* In both cases returning value must be a string
*/
let exportData = '';
const exportProp = currentBlockTool.conversionConfig.export;
if (_.isFunction(exportProp)) {
exportData = exportProp(blockData);
} else if (_.isString(exportProp)) {
exportData = blockData[exportProp];
} else {
_.log('Conversion «export» property must be a string or function. ' +
'String means key of saved data object to export. Function should export processed string to export.');
return;
}
/**
* Clean exported data with replacing sanitizer config
*/
const cleaned: string = clean(
exportData,
replacingTool.sanitizeConfig
);
/**
* «import» property can be Function or String
* function — accept imported string and compose tool data object
* string — the name of data field to import
*/
let newBlockData = {};
const importProp = replacingTool.conversionConfig.import;
if (_.isFunction(importProp)) {
newBlockData = importProp(cleaned);
} else if (_.isString(importProp)) {
newBlockData[importProp] = cleaned;
} else {
_.log('Conversion «import» property must be a string or function. ' +
'String means key of tool data to import. Function accepts a imported string and return composed tool data.');
return;
}
/**
* If this conversion fired by the one of multiple Toolbox items,
* extend converted data with this item's "data" overrides
*/
if (blockDataOverrides) {
newBlockData = Object.assign(newBlockData, blockDataOverrides);
}
this.Editor.BlockManager.replace({
tool: replacingToolName,
data: newBlockData,
});
this.Editor.BlockSelection.clearSelection();
this.close();
this.Editor.InlineToolbar.close();
_.delay(() => {
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 10)();
}
/**
* Iterates existing Tools and inserts to the ConversionToolbar
* if tools have ability to import
*/
private addTools(): void {
const tools = this.Editor.Tools.blockTools;
Array
.from(tools.entries())
.forEach(([name, tool]) => {
const conversionConfig = tool.conversionConfig;
/**
* Skip tools without «import» rule specified
*/
if (!conversionConfig || !conversionConfig.import) {
return;
}
tool.toolbox.forEach((toolboxItem) =>
this.addToolIfValid(name, toolboxItem)
);
});
}
/**
* Inserts a tool to the ConversionToolbar if the tool's toolbox config is valid
*
* @param name - tool's name
* @param toolboxSettings - tool's single toolbox setting
*/
private addToolIfValid(name: string, toolboxSettings: ToolboxConfigEntry): void {
/**
* Skip tools that don't pass 'toolbox' property
*/
if (_.isEmpty(toolboxSettings) || !toolboxSettings.icon) {
return;
}
this.addTool(name, toolboxSettings);
}
/**
* Add tool to the Conversion Toolbar
*
* @param toolName - name of Tool to add
* @param toolboxItem - tool's toolbox item data
*/
private addTool(toolName: string, toolboxItem: ToolboxConfigEntry): void {
const tool = $.make('div', [ ConversionToolbar.CSS.conversionTool ]);
const icon = $.make('div', [ ConversionToolbar.CSS.conversionToolIcon ]);
tool.dataset.tool = toolName;
icon.innerHTML = toolboxItem.icon;
$.append(tool, icon);
$.append(tool, $.text(I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(toolName))));
$.append(this.nodes.tools, tool);
this.tools.push({
name: toolName,
button: tool,
toolboxItem: toolboxItem,
});
this.listeners.on(tool, 'click', async () => {
await this.replaceWithBlock(toolName, toolboxItem.data);
});
}
/**
* Hide current Tool and show others
*/
private async filterTools(): Promise<void> {
const { currentBlock } = this.Editor.BlockManager;
const currentBlockActiveToolboxEntry = await currentBlock.getActiveToolboxEntry();
/**
* Compares two Toolbox entries
*
* @param entry1 - entry to compare
* @param entry2 - entry to compare with
*/
function isTheSameToolboxEntry(entry1, entry2): boolean {
return entry1.icon === entry2.icon && entry1.title === entry2.title;
}
this.tools.forEach(tool => {
let hidden = false;
if (currentBlockActiveToolboxEntry) {
const isToolboxItemActive = isTheSameToolboxEntry(currentBlockActiveToolboxEntry, tool.toolboxItem);
hidden = (tool.button.dataset.tool === currentBlock.name && isToolboxItemActive);
}
tool.button.hidden = hidden;
tool.button.classList.toggle(ConversionToolbar.CSS.conversionToolHidden, hidden);
});
}
/**
* Prepare Flipper to be able to leaf tools by arrows/tab
*/
private enableFlipper(): void {
this.flipper = new Flipper({
focusedItemClass: ConversionToolbar.CSS.conversionToolFocused,
});
}
}