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:
Peter Savchenko 2023-07-20 23:27:18 +03:00 committed by GitHub
parent 41dc65274d
commit 022320940e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 604 additions and 116 deletions

View file

@ -8,6 +8,7 @@
"colspan",
"contenteditable",
"contentless",
"Convertable",
"cssnano",
"cssnext",
"Debouncer",

View file

@ -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

View file

@ -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
*

View file

@ -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"`);
}
};
}

View file

@ -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

View file

@ -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,

View file

@ -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];
}

View file

@ -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);
},
});

View file

@ -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 {};
}
}

View file

@ -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;
}
}
}

View 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,
};
}
}

View file

@ -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"`);
});
});
});
});

View 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
View file

@ -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;
}

View file

@ -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;
}