mirror of
https://github.com/codex-team/editor.js
synced 2024-05-01 06:13:16 +02:00
feat(shortcuts): convert block by tools shortcut (#2419)
* feat(conversion): allow to convert block using shortcut * display shortcuts in conversion toolbar * tests for the blocks.convert * tests for the toolbox shortcuts * Update CHANGELOG.md * Update toolbox.cy.ts * rm unused imports * firefox test fixed * test errors via to.throw
This commit is contained in:
parent
41dc65274d
commit
022320940e
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
@ -8,6 +8,7 @@
|
|||
"colspan",
|
||||
"contenteditable",
|
||||
"contentless",
|
||||
"Convertable",
|
||||
"cssnano",
|
||||
"cssnext",
|
||||
"Debouncer",
|
||||
|
|
|
@ -3,9 +3,12 @@
|
|||
### 2.28.0
|
||||
|
||||
- `New` - Block ids now displayed in DOM via a data-id attribute. Could be useful for plugins that want access a Block's element by id.
|
||||
- `New` - The `.convert(blockId, newType)` API method added
|
||||
- `Improvement` - The Delete keydown at the end of the Block will now work opposite a Backspace at the start. Next Block will be removed (if empty) or merged with the current one.
|
||||
- `Improvement` - The Delete keydown will work like a Backspace when several Blocks are selected.
|
||||
- `Improvement` - If we have two empty Blocks, and press Backspace at the start of the second one, the previous will be removed instead of current.
|
||||
- `Improvement` - Tools shortcuts could be used to convert one Block to another.
|
||||
- `Improvement` - Tools shortcuts displayed in the Conversion Toolbar
|
||||
|
||||
### 2.27.2
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ import { TunesMenuConfigItem } from '../../../types/tools';
|
|||
import { isMutationBelongsToElement } from '../utils/mutations';
|
||||
import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
|
||||
import { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
|
||||
import { convertBlockDataToString } from '../utils/blocks';
|
||||
|
||||
/**
|
||||
* Interface describes Block class constructor argument
|
||||
|
@ -723,6 +724,15 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports Block data as string using conversion config
|
||||
*/
|
||||
public async exportDataAsString(): Promise<string> {
|
||||
const blockData = await this.data;
|
||||
|
||||
return convertBlockDataToString(blockData, this.tool.conversionConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make default Block wrappers and put Tool`s content there
|
||||
*
|
||||
|
|
|
@ -4,6 +4,7 @@ import * as _ from './../../utils';
|
|||
import BlockAPI from '../../block/api';
|
||||
import Module from '../../__module';
|
||||
import Block from '../../block';
|
||||
import { capitalize } from './../../utils';
|
||||
|
||||
/**
|
||||
* @class BlocksAPI
|
||||
|
@ -33,6 +34,7 @@ export default class BlocksAPI extends Module {
|
|||
insert: this.insert,
|
||||
update: this.update,
|
||||
composeBlockData: this.composeBlockData,
|
||||
convert: this.convert,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -311,4 +313,42 @@ export default class BlocksAPI extends Module {
|
|||
tunes: block.tunes,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts block to another type. Both blocks should provide the conversionConfig.
|
||||
*
|
||||
* @param id - id of the existing block to convert. Should provide 'conversionConfig.export' method
|
||||
* @param newType - new block type. Should provide 'conversionConfig.import' method
|
||||
* @param dataOverrides - optional data overrides for the new block
|
||||
* @throws Error if conversion is not possible
|
||||
*/
|
||||
private convert = (id: string, newType: string, dataOverrides?: BlockToolData): void => {
|
||||
const { BlockManager, Tools } = this.Editor;
|
||||
const blockToConvert = BlockManager.getBlockById(id);
|
||||
|
||||
if (!blockToConvert) {
|
||||
throw new Error(`Block with id "${id}" not found`);
|
||||
}
|
||||
|
||||
const originalBlockTool = Tools.blockTools.get(blockToConvert.name);
|
||||
const targetBlockTool = Tools.blockTools.get(newType);
|
||||
|
||||
if (!targetBlockTool) {
|
||||
throw new Error(`Block Tool with type "${newType}" not found`);
|
||||
}
|
||||
|
||||
const originalBlockConvertable = originalBlockTool?.conversionConfig?.export !== undefined;
|
||||
const targetBlockConvertable = targetBlockTool.conversionConfig?.import !== undefined;
|
||||
|
||||
if (originalBlockConvertable && targetBlockConvertable) {
|
||||
BlockManager.convert(blockToConvert, newType, dataOverrides);
|
||||
} else {
|
||||
const unsupportedBlockTypes = [
|
||||
!originalBlockConvertable ? capitalize(blockToConvert.name) : false,
|
||||
!targetBlockConvertable ? capitalize(newType) : false,
|
||||
].filter(Boolean).join(' and ');
|
||||
|
||||
throw new Error(`Conversion from "${blockToConvert.name}" to "${newType}" is not possible. ${unsupportedBlockTypes} tool(s) should provide a "conversionConfig"`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded';
|
|||
import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved';
|
||||
import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged';
|
||||
import { BlockChanged } from '../events';
|
||||
import { clean } from '../utils/sanitizer';
|
||||
import { convertStringToBlockData } from '../utils/blocks';
|
||||
|
||||
/**
|
||||
* @typedef {BlockManager} BlockManager
|
||||
|
@ -319,21 +321,19 @@ export default class BlockManager extends Module {
|
|||
}
|
||||
|
||||
/**
|
||||
* Replace current working block
|
||||
* Replace passed Block with the new one with specified Tool and data
|
||||
*
|
||||
* @param {object} options - replace options
|
||||
* @param {string} options.tool — plugin name
|
||||
* @param {BlockToolData} options.data — plugin data
|
||||
* @returns {Block}
|
||||
* @param block - block to replace
|
||||
* @param newTool - new Tool name
|
||||
* @param data - new Tool data
|
||||
*/
|
||||
public replace({
|
||||
tool = this.config.defaultBlock,
|
||||
data = {},
|
||||
}): Block {
|
||||
return this.insert({
|
||||
tool,
|
||||
public replace(block: Block, newTool: string, data: BlockToolData): void {
|
||||
const blockIndex = this.getBlockIndex(block);
|
||||
|
||||
this.insert({
|
||||
tool: newTool,
|
||||
data,
|
||||
index: this.currentBlockIndex,
|
||||
index: blockIndex,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
@ -732,6 +732,62 @@ export default class BlockManager extends Module {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts passed Block to the new Tool
|
||||
* Uses Conversion Config
|
||||
*
|
||||
* @param blockToConvert - Block that should be converted
|
||||
* @param targetToolName - name of the Tool to convert to
|
||||
* @param blockDataOverrides - optional new Block data overrides
|
||||
*/
|
||||
public async convert(blockToConvert: Block, targetToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
|
||||
/**
|
||||
* At first, we get current Block data
|
||||
*/
|
||||
const savedBlock = await blockToConvert.save();
|
||||
|
||||
if (!savedBlock) {
|
||||
throw new Error('Could not convert Block. Failed to extract original Block data.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Getting a class of the replacing Tool
|
||||
*/
|
||||
const replacingTool = this.Editor.Tools.blockTools.get(targetToolName);
|
||||
|
||||
if (!replacingTool) {
|
||||
throw new Error(`Could not convert Block. Tool «${targetToolName}» not found.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Using Conversion Config "export" we get a stringified version of the Block data
|
||||
*/
|
||||
const exportedData = await blockToConvert.exportDataAsString();
|
||||
|
||||
/**
|
||||
* Clean exported data with replacing sanitizer config
|
||||
*/
|
||||
const cleanData: string = clean(
|
||||
exportedData,
|
||||
replacingTool.sanitizeConfig
|
||||
);
|
||||
|
||||
/**
|
||||
* Now using Conversion Config "import" we compose a new Block data
|
||||
*/
|
||||
let newBlockData = convertStringToBlockData(cleanData, replacingTool.conversionConfig);
|
||||
|
||||
/**
|
||||
* Optional data overrides.
|
||||
* Used for example, by the Multiple Toolbox Items feature, where a single Tool provides several Toolbox items with "data" overrides
|
||||
*/
|
||||
if (blockDataOverrides) {
|
||||
newBlockData = Object.assign(newBlockData, blockDataOverrides);
|
||||
}
|
||||
|
||||
this.replace(blockToConvert, replacingTool.name, newBlockData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets current Block Index -1 which means unknown
|
||||
* and clear highlights
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
|
@ -34,6 +32,7 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
|
|||
conversionTool: 'ce-conversion-tool',
|
||||
conversionToolHidden: 'ce-conversion-tool--hidden',
|
||||
conversionToolIcon: 'ce-conversion-tool__icon',
|
||||
conversionToolSecondaryLabel: 'ce-conversion-tool__secondary-label',
|
||||
|
||||
conversionToolFocused: 'ce-conversion-tool--focused',
|
||||
conversionToolActive: 'ce-conversion-tool--active',
|
||||
|
@ -179,90 +178,21 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
|
|||
* 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
|
||||
* @param blockDataOverrides - If this conversion fired by the one of multiple Toolbox items, extend converted data with this item's "data" overrides
|
||||
*/
|
||||
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;
|
||||
const { BlockManager, BlockSelection, InlineToolbar, Caret } = this.Editor;
|
||||
|
||||
/**
|
||||
* Getting a class of replacing Tool
|
||||
*/
|
||||
const replacingTool = this.Editor.Tools.blockTools.get(replacingToolName);
|
||||
BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides);
|
||||
|
||||
/**
|
||||
* 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();
|
||||
BlockSelection.clearSelection();
|
||||
|
||||
this.close();
|
||||
this.Editor.InlineToolbar.close();
|
||||
InlineToolbar.close();
|
||||
|
||||
_.delay(() => {
|
||||
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
}, 10)();
|
||||
window.requestAnimationFrame(() => {
|
||||
Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -283,7 +213,7 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
|
|||
if (!conversionConfig || !conversionConfig.import) {
|
||||
return;
|
||||
}
|
||||
tool.toolbox.forEach((toolboxItem) =>
|
||||
tool.toolbox?.forEach((toolboxItem) =>
|
||||
this.addToolIfValid(name, toolboxItem)
|
||||
);
|
||||
});
|
||||
|
@ -322,6 +252,16 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
|
|||
$.append(tool, icon);
|
||||
$.append(tool, $.text(I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(toolName))));
|
||||
|
||||
const shortcut = this.Editor.Tools.blockTools.get(toolName)?.shortcut;
|
||||
|
||||
if (shortcut) {
|
||||
const shortcutEl = $.make('span', ConversionToolbar.CSS.conversionToolSecondaryLabel, {
|
||||
innerText: _.beautifyShortcut(shortcut),
|
||||
});
|
||||
|
||||
$.append(tool, shortcutEl);
|
||||
}
|
||||
|
||||
$.append(this.nodes.tools, tool);
|
||||
this.tools.push({
|
||||
name: toolName,
|
||||
|
|
|
@ -136,7 +136,7 @@ export default class BlockTool extends BaseTool<IBlockTool> {
|
|||
/**
|
||||
* Returns Tool conversion configuration
|
||||
*/
|
||||
public get conversionConfig(): ConversionConfig {
|
||||
public get conversionConfig(): ConversionConfig | undefined {
|
||||
return this.constructable[InternalBlockToolSettings.ConversionConfig];
|
||||
}
|
||||
|
||||
|
|
|
@ -307,6 +307,26 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
|
|||
on: this.api.ui.nodes.redactor,
|
||||
handler: (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
|
||||
const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex);
|
||||
|
||||
/**
|
||||
* Try to convert current Block to shortcut's tool
|
||||
* If conversion is not possible, insert a new Block below
|
||||
*/
|
||||
if (currentBlock) {
|
||||
try {
|
||||
this.api.blocks.convert(currentBlock.id, toolName);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
this.api.caret.setToBlock(currentBlockIndex, 'end');
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
this.insertNewBlock(toolName);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import type { ConversionConfig } from '../../../types/configs/conversion-config';
|
||||
import type { BlockToolData } from '../../../types/tools/block-tool-data';
|
||||
import type Block from '../block';
|
||||
import { isFunction, isString, log } from '../utils';
|
||||
|
||||
/**
|
||||
* Check if two blocks could be merged.
|
||||
|
@ -13,3 +16,57 @@ import type Block from '../block';
|
|||
export function areBlocksMergeable(targetBlock: Block, blockToMerge: Block): boolean {
|
||||
return targetBlock.mergeable && targetBlock.name === blockToMerge.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Using conversionConfig, convert block data to string.
|
||||
*
|
||||
* @param blockData - block data to convert
|
||||
* @param conversionConfig - tool's conversion config
|
||||
*/
|
||||
export function convertBlockDataToString(blockData: BlockToolData, conversionConfig?: ConversionConfig ): string {
|
||||
const exportProp = conversionConfig?.export;
|
||||
|
||||
if (isFunction(exportProp)) {
|
||||
return exportProp(blockData);
|
||||
} else if (isString(exportProp)) {
|
||||
return blockData[exportProp];
|
||||
} else {
|
||||
/**
|
||||
* Tool developer provides 'export' property, but it is not correct. Warn him.
|
||||
*/
|
||||
if (exportProp !== undefined) {
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Using conversionConfig, convert string to block data.
|
||||
*
|
||||
* @param stringToImport - string to convert
|
||||
* @param conversionConfig - tool's conversion config
|
||||
*/
|
||||
export function convertStringToBlockData(stringToImport: string, conversionConfig?: ConversionConfig): BlockToolData {
|
||||
const importProp = conversionConfig?.import;
|
||||
|
||||
if (isFunction(importProp)) {
|
||||
return importProp(stringToImport);
|
||||
} else if (isString(importProp)) {
|
||||
return {
|
||||
[importProp]: stringToImport,
|
||||
};
|
||||
} else {
|
||||
/**
|
||||
* Tool developer provides 'import' property, but it is not correct. Warn him.
|
||||
*/
|
||||
if (importProp !== undefined) {
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
transition: transform 100ms ease, opacity 100ms ease;
|
||||
transform: translateY(-8px);
|
||||
left: -1px;
|
||||
width: 150px;
|
||||
width: 190px;
|
||||
margin-top: 5px;
|
||||
box-sizing: content-box;
|
||||
|
||||
|
@ -78,4 +78,19 @@
|
|||
animation: bounceIn 0.75s 1;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
&__secondary-label {
|
||||
color: var(--grayText);
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
letter-spacing: -0.1em;
|
||||
padding-right: 5px;
|
||||
margin-bottom: -2px;
|
||||
opacity: 0.6;
|
||||
|
||||
@media (--mobile){
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
54
test/cypress/fixtures/tools/ToolMock.ts
Normal file
54
test/cypress/fixtures/tools/ToolMock.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { BlockTool, BlockToolConstructorOptions } from '../../../../types';
|
||||
|
||||
/**
|
||||
* Simple structure for Tool data
|
||||
*/
|
||||
interface MockToolData {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common class for Tool mocking.
|
||||
* Extend this class to create a mock for your Tool with specific properties.
|
||||
*/
|
||||
export default class ToolMock implements BlockTool {
|
||||
/**
|
||||
* Tool data
|
||||
*/
|
||||
private data: MockToolData;
|
||||
|
||||
/**
|
||||
* Creates new Tool instance
|
||||
*
|
||||
* @param options - tool constructor options
|
||||
*/
|
||||
constructor(options: BlockToolConstructorOptions<MockToolData>) {
|
||||
this.data = options.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single content editable element as tools element
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
const contenteditable = document.createElement('div');
|
||||
|
||||
if (this.data && this.data.text) {
|
||||
contenteditable.innerHTML = this.data.text;
|
||||
}
|
||||
|
||||
contenteditable.contentEditable = 'true';
|
||||
|
||||
return contenteditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save method mock, returns block innerHTML
|
||||
*
|
||||
* @param block - element rendered by the render method
|
||||
*/
|
||||
public save(block: HTMLElement): MockToolData {
|
||||
return {
|
||||
text: block.innerHTML,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,4 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type EditorJS from '../../../../types/index';
|
||||
import { ConversionConfig, ToolboxConfig } from '../../../../types';
|
||||
import ToolMock from '../../fixtures/tools/ToolMock';
|
||||
|
||||
/**
|
||||
* There will be described test cases of 'blocks.*' API
|
||||
*/
|
||||
|
@ -16,18 +19,6 @@ describe('api.blocks', () => {
|
|||
],
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if (this.editorInstance) {
|
||||
this.editorInstance.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* api.blocks.getById(id)
|
||||
*/
|
||||
|
@ -36,7 +27,11 @@ describe('api.blocks', () => {
|
|||
* Check that api.blocks.getByUd(id) returns the Block for existed id
|
||||
*/
|
||||
it('should return Block API for existed id', () => {
|
||||
cy.get('@editorInstance').then(async (editor: any) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
const block = editor.blocks.getById(firstBlock.id);
|
||||
|
||||
expect(block).not.to.be.undefined;
|
||||
|
@ -48,7 +43,11 @@ describe('api.blocks', () => {
|
|||
* Check that api.blocks.getByUd(id) returns null for the not-existed id
|
||||
*/
|
||||
it('should return null for not-existed id', () => {
|
||||
cy.get('@editorInstance').then(async (editor: any) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
expect(editor.blocks.getById('not-existed-id')).to.be.null;
|
||||
});
|
||||
});
|
||||
|
@ -62,7 +61,11 @@ describe('api.blocks', () => {
|
|||
* Check if block is updated in DOM
|
||||
*/
|
||||
it('should update block in DOM', () => {
|
||||
cy.get('@editorInstance').then(async (editor: any) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
const idToUpdate = firstBlock.id;
|
||||
const newBlockData = {
|
||||
text: 'Updated text',
|
||||
|
@ -83,7 +86,11 @@ describe('api.blocks', () => {
|
|||
* Check if block's data is updated after saving
|
||||
*/
|
||||
it('should update block in saved data', () => {
|
||||
cy.get('@editorInstance').then(async (editor: any) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
const idToUpdate = firstBlock.id;
|
||||
const newBlockData = {
|
||||
text: 'Updated text',
|
||||
|
@ -91,7 +98,7 @@ describe('api.blocks', () => {
|
|||
|
||||
editor.blocks.update(idToUpdate, newBlockData);
|
||||
|
||||
const output = await (editor as any).save();
|
||||
const output = await editor.save();
|
||||
const text = output.blocks[0].data.text;
|
||||
|
||||
expect(text).to.be.eq(newBlockData.text);
|
||||
|
@ -102,7 +109,11 @@ describe('api.blocks', () => {
|
|||
* When incorrect id passed, editor should not update any block
|
||||
*/
|
||||
it('shouldn\'t update any block if not-existed id passed', () => {
|
||||
cy.get('@editorInstance').then(async (editor: any) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
const idToUpdate = 'wrong-id-123';
|
||||
const newBlockData = {
|
||||
text: 'Updated text',
|
||||
|
@ -125,7 +136,11 @@ describe('api.blocks', () => {
|
|||
*/
|
||||
describe('.insert()', function () {
|
||||
it('should preserve block id if it is passed', function () {
|
||||
cy.get('@editorInstance').then(async (editor: any) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
const type = 'paragraph';
|
||||
const data = { text: 'codex' };
|
||||
const config = undefined;
|
||||
|
@ -141,4 +156,167 @@ describe('api.blocks', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('.convert()', function () {
|
||||
it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import"', function () {
|
||||
/**
|
||||
* Mock of Tool with conversionConfig
|
||||
*/
|
||||
class ConvertableTool extends ToolMock {
|
||||
/**
|
||||
* Specify how to import string data to this Tool
|
||||
*/
|
||||
public static get conversionConfig(): ConversionConfig {
|
||||
return {
|
||||
import: 'text',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify how to display Tool in a Toolbox
|
||||
*/
|
||||
public static get toolbox(): ToolboxConfig {
|
||||
return {
|
||||
icon: '',
|
||||
title: 'Convertable tool',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const existingBlock = {
|
||||
id: 'test-id-123',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Some text',
|
||||
},
|
||||
};
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
convertableTool: {
|
||||
class: ConvertableTool,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
existingBlock,
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
/**
|
||||
* Call the 'convert' api method
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { convert } = editor.blocks;
|
||||
|
||||
convert(existingBlock.id, 'convertableTool');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers -- wait for block to be converted
|
||||
cy.wait(100);
|
||||
|
||||
/**
|
||||
* Check that block was converted
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(1);
|
||||
expect(blocks[0].type).to.eq('convertableTool');
|
||||
expect(blocks[0].data.text).to.eq(existingBlock.data.text);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if nonexisting Block id passed', function () {
|
||||
cy.createEditor({}).as('editorInstance');
|
||||
|
||||
/**
|
||||
* Call the 'convert' api method with nonexisting Block id
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const fakeId = 'WRNG_ID';
|
||||
const { convert } = editor.blocks;
|
||||
|
||||
const exec = (): void => convert(fakeId, 'convertableTool');
|
||||
|
||||
expect(exec).to.throw(`Block with id "${fakeId}" not found`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if nonexisting Tool name passed', function () {
|
||||
const existingBlock = {
|
||||
id: 'test-id-123',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Some text',
|
||||
},
|
||||
};
|
||||
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
existingBlock,
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
/**
|
||||
* Call the 'convert' api method with nonexisting tool name
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const nonexistingToolName = 'WRNG_TOOL_NAME';
|
||||
const { convert } = editor.blocks;
|
||||
|
||||
const exec = (): void => convert(existingBlock.id, nonexistingToolName);
|
||||
|
||||
expect(exec).to.throw(`Block Tool with type "${nonexistingToolName}" not found`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if some tool does not provide "conversionConfig"', function () {
|
||||
const existingBlock = {
|
||||
id: 'test-id-123',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Some text',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock of Tool without conversionConfig
|
||||
*/
|
||||
class ToolWithoutConversionConfig extends ToolMock {}
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
nonConvertableTool: {
|
||||
class: ToolWithoutConversionConfig,
|
||||
shortcut: 'CMD+SHIFT+H',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
existingBlock,
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
/**
|
||||
* Call the 'convert' api method with tool that does not provide "conversionConfig"
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { convert } = editor.blocks;
|
||||
|
||||
const exec = (): void => convert(existingBlock.id, 'nonConvertableTool');
|
||||
|
||||
expect(exec).to.throw(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
103
test/cypress/tests/ui/toolbox.cy.ts
Normal file
103
test/cypress/tests/ui/toolbox.cy.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
import type EditorJS from '../../../../types/index';
|
||||
import { ConversionConfig, ToolboxConfig } from '../../../../types/index';
|
||||
import ToolMock from '../../fixtures/tools/ToolMock';
|
||||
|
||||
describe('Toolbox', function () {
|
||||
describe('Shortcuts', function () {
|
||||
it('should covert current Block to the Shortcuts\'s Block if both tools provides a "conversionConfig" ', function () {
|
||||
/**
|
||||
* Mock of Tool with conversionConfig
|
||||
*/
|
||||
class ConvertableTool extends ToolMock {
|
||||
/**
|
||||
* Specify how to import string data to this Tool
|
||||
*/
|
||||
public static get conversionConfig(): ConversionConfig {
|
||||
return {
|
||||
import: 'text',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify how to display Tool in a Toolbox
|
||||
*/
|
||||
public static get toolbox(): ToolboxConfig {
|
||||
return {
|
||||
icon: '',
|
||||
title: 'Convertable tool',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
convertableTool: {
|
||||
class: ConvertableTool,
|
||||
shortcut: 'CMD+SHIFT+H',
|
||||
},
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.click()
|
||||
.type('Some text')
|
||||
.type('{cmd}{shift}H'); // call a shortcut
|
||||
|
||||
/**
|
||||
* Check that block was converted
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(1);
|
||||
expect(blocks[0].type).to.eq('convertableTool');
|
||||
expect(blocks[0].data.text).to.eq('Some text');
|
||||
});
|
||||
});
|
||||
|
||||
it('should insert a Shortcuts\'s Block below the current if some (original or target) tool does not provide a "conversionConfig" ', function () {
|
||||
/**
|
||||
* Mock of Tool with conversionConfig
|
||||
*/
|
||||
class ToolWithoutConversionConfig extends ToolMock {
|
||||
/**
|
||||
* Specify how to display Tool in a Toolbox
|
||||
*/
|
||||
public static get toolbox(): ToolboxConfig {
|
||||
return {
|
||||
icon: '',
|
||||
title: 'Convertable tool',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
nonConvertableTool: {
|
||||
class: ToolWithoutConversionConfig,
|
||||
shortcut: 'CMD+SHIFT+H',
|
||||
},
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.click()
|
||||
.type('Some text')
|
||||
.type('{cmd}{shift}H'); // call a shortcut
|
||||
|
||||
/**
|
||||
* Check that the new block was appended
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(2);
|
||||
expect(blocks[1].type).to.eq('nonConvertableTool');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
11
types/api/blocks.d.ts
vendored
11
types/api/blocks.d.ts
vendored
|
@ -130,4 +130,15 @@ export interface Blocks {
|
|||
* @param data - the new data
|
||||
*/
|
||||
update(id: string, data: BlockToolData): void;
|
||||
|
||||
/**
|
||||
* Converts block to another type. Both blocks should provide the conversionConfig.
|
||||
*
|
||||
* @param id - id of the existed block to convert. Should provide 'conversionConfig.export' method
|
||||
* @param newType - new block type. Should provide 'conversionConfig.import' method
|
||||
* @param dataOverrides - optional data overrides for the new block
|
||||
*
|
||||
* @throws Error if conversion is not possible
|
||||
*/
|
||||
convert(id: string, newType: string, dataOverrides?: BlockToolData): void;
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ export interface ConversionConfig {
|
|||
* 1. String — the key of Tool data object to fill it with imported string on render.
|
||||
* 2. Function — method that accepts importing string and composes Tool data to render.
|
||||
*/
|
||||
import: ((data: string) => string) | string;
|
||||
import?: ((data: string) => string) | string;
|
||||
|
||||
/**
|
||||
* How to export this Tool to make other Block.
|
||||
|
@ -22,5 +22,5 @@ export interface ConversionConfig {
|
|||
* 1. String — which property of saved Tool data should be used as exported string.
|
||||
* 2. Function — accepts saved Tool data and create a string to export
|
||||
*/
|
||||
export: ((data: BlockToolData) => string) | string;
|
||||
export?: ((data: BlockToolData) => string) | string;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue