Skip to content

Commit

Permalink
feat(shortcuts): convert block by tools shortcut (#2419)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
neSpecc authored Jul 20, 2023
1 parent 41dc652 commit 0223209
Show file tree
Hide file tree
Showing 15 changed files with 604 additions and 116 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"colspan",
"contenteditable",
"contentless",
"Convertable",
"cssnano",
"cssnext",
"Debouncer",
Expand Down
3 changes: 3 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions src/components/block/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*
Expand Down
40 changes: 40 additions & 0 deletions src/components/modules/api/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -33,6 +34,7 @@ export default class BlocksAPI extends Module {
insert: this.insert,
update: this.update,
composeBlockData: this.composeBlockData,
convert: this.convert,
};
}

Expand Down Expand Up @@ -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"`);
}
};
}
80 changes: 68 additions & 12 deletions src/components/modules/blockManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
}
Expand Down Expand Up @@ -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
Expand Down
100 changes: 20 additions & 80 deletions src/components/modules/toolbar/conversion.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;

/**
* 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;
const { BlockManager, BlockSelection, InlineToolbar, Caret } = this.Editor;

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);
}
BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, 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);
});
}

/**
Expand All @@ -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)
);
});
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/components/tools/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}

Expand Down
20 changes: 20 additions & 0 deletions src/components/ui/toolbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
});
Expand Down
Loading

0 comments on commit 0223209

Please sign in to comment.