Skip to content

Commit 0223209

Browse files
authored
feat(shortcuts): convert block by tools shortcut (codex-team#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
1 parent 41dc652 commit 0223209

File tree

15 files changed

+604
-116
lines changed

15 files changed

+604
-116
lines changed

.vscode/settings.json

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"colspan",
99
"contenteditable",
1010
"contentless",
11+
"Convertable",
1112
"cssnano",
1213
"cssnext",
1314
"Debouncer",

docs/CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
### 2.28.0
44

55
- `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.
6+
- `New` - The `.convert(blockId, newType)` API method added
67
- `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.
78
- `Improvement` - The Delete keydown will work like a Backspace when several Blocks are selected.
89
- `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.
10+
- `Improvement` - Tools shortcuts could be used to convert one Block to another.
11+
- `Improvement` - Tools shortcuts displayed in the Conversion Toolbar
912

1013
### 2.27.2
1114

src/components/block/index.ts

+10
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { TunesMenuConfigItem } from '../../../types/tools';
2525
import { isMutationBelongsToElement } from '../utils/mutations';
2626
import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
2727
import { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
28+
import { convertBlockDataToString } from '../utils/blocks';
2829

2930
/**
3031
* Interface describes Block class constructor argument
@@ -723,6 +724,15 @@ export default class Block extends EventsDispatcher<BlockEvents> {
723724
});
724725
}
725726

727+
/**
728+
* Exports Block data as string using conversion config
729+
*/
730+
public async exportDataAsString(): Promise<string> {
731+
const blockData = await this.data;
732+
733+
return convertBlockDataToString(blockData, this.tool.conversionConfig);
734+
}
735+
726736
/**
727737
* Make default Block wrappers and put Tool`s content there
728738
*

src/components/modules/api/blocks.ts

+40
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as _ from './../../utils';
44
import BlockAPI from '../../block/api';
55
import Module from '../../__module';
66
import Block from '../../block';
7+
import { capitalize } from './../../utils';
78

89
/**
910
* @class BlocksAPI
@@ -33,6 +34,7 @@ export default class BlocksAPI extends Module {
3334
insert: this.insert,
3435
update: this.update,
3536
composeBlockData: this.composeBlockData,
37+
convert: this.convert,
3638
};
3739
}
3840

@@ -311,4 +313,42 @@ export default class BlocksAPI extends Module {
311313
tunes: block.tunes,
312314
});
313315
};
316+
317+
/**
318+
* Converts block to another type. Both blocks should provide the conversionConfig.
319+
*
320+
* @param id - id of the existing block to convert. Should provide 'conversionConfig.export' method
321+
* @param newType - new block type. Should provide 'conversionConfig.import' method
322+
* @param dataOverrides - optional data overrides for the new block
323+
* @throws Error if conversion is not possible
324+
*/
325+
private convert = (id: string, newType: string, dataOverrides?: BlockToolData): void => {
326+
const { BlockManager, Tools } = this.Editor;
327+
const blockToConvert = BlockManager.getBlockById(id);
328+
329+
if (!blockToConvert) {
330+
throw new Error(`Block with id "${id}" not found`);
331+
}
332+
333+
const originalBlockTool = Tools.blockTools.get(blockToConvert.name);
334+
const targetBlockTool = Tools.blockTools.get(newType);
335+
336+
if (!targetBlockTool) {
337+
throw new Error(`Block Tool with type "${newType}" not found`);
338+
}
339+
340+
const originalBlockConvertable = originalBlockTool?.conversionConfig?.export !== undefined;
341+
const targetBlockConvertable = targetBlockTool.conversionConfig?.import !== undefined;
342+
343+
if (originalBlockConvertable && targetBlockConvertable) {
344+
BlockManager.convert(blockToConvert, newType, dataOverrides);
345+
} else {
346+
const unsupportedBlockTypes = [
347+
!originalBlockConvertable ? capitalize(blockToConvert.name) : false,
348+
!targetBlockConvertable ? capitalize(newType) : false,
349+
].filter(Boolean).join(' and ');
350+
351+
throw new Error(`Conversion from "${blockToConvert.name}" to "${newType}" is not possible. ${unsupportedBlockTypes} tool(s) should provide a "conversionConfig"`);
352+
}
353+
};
314354
}

src/components/modules/blockManager.ts

+68-12
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded';
1818
import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved';
1919
import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged';
2020
import { BlockChanged } from '../events';
21+
import { clean } from '../utils/sanitizer';
22+
import { convertStringToBlockData } from '../utils/blocks';
2123

2224
/**
2325
* @typedef {BlockManager} BlockManager
@@ -319,21 +321,19 @@ export default class BlockManager extends Module {
319321
}
320322

321323
/**
322-
* Replace current working block
324+
* Replace passed Block with the new one with specified Tool and data
323325
*
324-
* @param {object} options - replace options
325-
* @param {string} options.tool — plugin name
326-
* @param {BlockToolData} options.data — plugin data
327-
* @returns {Block}
326+
* @param block - block to replace
327+
* @param newTool - new Tool name
328+
* @param data - new Tool data
328329
*/
329-
public replace({
330-
tool = this.config.defaultBlock,
331-
data = {},
332-
}): Block {
333-
return this.insert({
334-
tool,
330+
public replace(block: Block, newTool: string, data: BlockToolData): void {
331+
const blockIndex = this.getBlockIndex(block);
332+
333+
this.insert({
334+
tool: newTool,
335335
data,
336-
index: this.currentBlockIndex,
336+
index: blockIndex,
337337
replace: true,
338338
});
339339
}
@@ -732,6 +732,62 @@ export default class BlockManager extends Module {
732732
});
733733
}
734734

735+
/**
736+
* Converts passed Block to the new Tool
737+
* Uses Conversion Config
738+
*
739+
* @param blockToConvert - Block that should be converted
740+
* @param targetToolName - name of the Tool to convert to
741+
* @param blockDataOverrides - optional new Block data overrides
742+
*/
743+
public async convert(blockToConvert: Block, targetToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
744+
/**
745+
* At first, we get current Block data
746+
*/
747+
const savedBlock = await blockToConvert.save();
748+
749+
if (!savedBlock) {
750+
throw new Error('Could not convert Block. Failed to extract original Block data.');
751+
}
752+
753+
/**
754+
* Getting a class of the replacing Tool
755+
*/
756+
const replacingTool = this.Editor.Tools.blockTools.get(targetToolName);
757+
758+
if (!replacingTool) {
759+
throw new Error(`Could not convert Block. Tool «${targetToolName}» not found.`);
760+
}
761+
762+
/**
763+
* Using Conversion Config "export" we get a stringified version of the Block data
764+
*/
765+
const exportedData = await blockToConvert.exportDataAsString();
766+
767+
/**
768+
* Clean exported data with replacing sanitizer config
769+
*/
770+
const cleanData: string = clean(
771+
exportedData,
772+
replacingTool.sanitizeConfig
773+
);
774+
775+
/**
776+
* Now using Conversion Config "import" we compose a new Block data
777+
*/
778+
let newBlockData = convertStringToBlockData(cleanData, replacingTool.conversionConfig);
779+
780+
/**
781+
* Optional data overrides.
782+
* Used for example, by the Multiple Toolbox Items feature, where a single Tool provides several Toolbox items with "data" overrides
783+
*/
784+
if (blockDataOverrides) {
785+
newBlockData = Object.assign(newBlockData, blockDataOverrides);
786+
}
787+
788+
this.replace(blockToConvert, replacingTool.name, newBlockData);
789+
}
790+
735791
/**
736792
* Sets current Block Index -1 which means unknown
737793
* and clear highlights

src/components/modules/toolbar/conversion.ts

+20-80
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import Module from '../../__module';
22
import $ from '../../dom';
33
import * as _ from '../../utils';
4-
import { SavedData } from '../../../../types/data-formats';
54
import Flipper from '../../flipper';
65
import I18n from '../../i18n';
76
import { I18nInternalNS } from '../../i18n/namespace-internal';
8-
import { clean } from '../../utils/sanitizer';
97
import { ToolboxConfigEntry, BlockToolData } from '../../../../types';
108

119
/**
@@ -34,6 +32,7 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
3432
conversionTool: 'ce-conversion-tool',
3533
conversionToolHidden: 'ce-conversion-tool--hidden',
3634
conversionToolIcon: 'ce-conversion-tool__icon',
35+
conversionToolSecondaryLabel: 'ce-conversion-tool__secondary-label',
3736

3837
conversionToolFocused: 'ce-conversion-tool--focused',
3938
conversionToolActive: 'ce-conversion-tool--active',
@@ -179,90 +178,21 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
179178
* For that Tools must provide import/export methods
180179
*
181180
* @param {string} replacingToolName - name of Tool which replaces current
182-
* @param blockDataOverrides - Block data overrides. Could be passed in case if Multiple Toolbox items specified
181+
* @param blockDataOverrides - If this conversion fired by the one of multiple Toolbox items, extend converted data with this item's "data" overrides
183182
*/
184183
public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
185-
/**
186-
* At first, we get current Block data
187-
*/
188-
const currentBlockTool = this.Editor.BlockManager.currentBlock.tool;
189-
const savedBlock = await this.Editor.BlockManager.currentBlock.save() as SavedData;
190-
const blockData = savedBlock.data;
191-
192-
/**
193-
* Getting a class of replacing Tool
194-
*/
195-
const replacingTool = this.Editor.Tools.blockTools.get(replacingToolName);
196-
197-
/**
198-
* Export property can be:
199-
* 1) Function — Tool defines which data to return
200-
* 2) String — the name of saved property
201-
*
202-
* In both cases returning value must be a string
203-
*/
204-
let exportData = '';
205-
const exportProp = currentBlockTool.conversionConfig.export;
206-
207-
if (_.isFunction(exportProp)) {
208-
exportData = exportProp(blockData);
209-
} else if (_.isString(exportProp)) {
210-
exportData = blockData[exportProp];
211-
} else {
212-
_.log('Conversion «export» property must be a string or function. ' +
213-
'String means key of saved data object to export. Function should export processed string to export.');
214-
215-
return;
216-
}
217-
218-
/**
219-
* Clean exported data with replacing sanitizer config
220-
*/
221-
const cleaned: string = clean(
222-
exportData,
223-
replacingTool.sanitizeConfig
224-
);
225-
226-
/**
227-
* «import» property can be Function or String
228-
* function — accept imported string and compose tool data object
229-
* string — the name of data field to import
230-
*/
231-
let newBlockData = {};
232-
const importProp = replacingTool.conversionConfig.import;
184+
const { BlockManager, BlockSelection, InlineToolbar, Caret } = this.Editor;
233185

234-
if (_.isFunction(importProp)) {
235-
newBlockData = importProp(cleaned);
236-
} else if (_.isString(importProp)) {
237-
newBlockData[importProp] = cleaned;
238-
} else {
239-
_.log('Conversion «import» property must be a string or function. ' +
240-
'String means key of tool data to import. Function accepts a imported string and return composed tool data.');
241-
242-
return;
243-
}
244-
245-
/**
246-
* If this conversion fired by the one of multiple Toolbox items,
247-
* extend converted data with this item's "data" overrides
248-
*/
249-
if (blockDataOverrides) {
250-
newBlockData = Object.assign(newBlockData, blockDataOverrides);
251-
}
186+
BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides);
252187

253-
this.Editor.BlockManager.replace({
254-
tool: replacingToolName,
255-
data: newBlockData,
256-
});
257-
this.Editor.BlockSelection.clearSelection();
188+
BlockSelection.clearSelection();
258189

259190
this.close();
260-
this.Editor.InlineToolbar.close();
191+
InlineToolbar.close();
261192

262-
_.delay(() => {
263-
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
264-
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
265-
}, 10)();
193+
window.requestAnimationFrame(() => {
194+
Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END);
195+
});
266196
}
267197

268198
/**
@@ -283,7 +213,7 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
283213
if (!conversionConfig || !conversionConfig.import) {
284214
return;
285215
}
286-
tool.toolbox.forEach((toolboxItem) =>
216+
tool.toolbox?.forEach((toolboxItem) =>
287217
this.addToolIfValid(name, toolboxItem)
288218
);
289219
});
@@ -322,6 +252,16 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
322252
$.append(tool, icon);
323253
$.append(tool, $.text(I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(toolName))));
324254

255+
const shortcut = this.Editor.Tools.blockTools.get(toolName)?.shortcut;
256+
257+
if (shortcut) {
258+
const shortcutEl = $.make('span', ConversionToolbar.CSS.conversionToolSecondaryLabel, {
259+
innerText: _.beautifyShortcut(shortcut),
260+
});
261+
262+
$.append(tool, shortcutEl);
263+
}
264+
325265
$.append(this.nodes.tools, tool);
326266
this.tools.push({
327267
name: toolName,

src/components/tools/block.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export default class BlockTool extends BaseTool<IBlockTool> {
136136
/**
137137
* Returns Tool conversion configuration
138138
*/
139-
public get conversionConfig(): ConversionConfig {
139+
public get conversionConfig(): ConversionConfig | undefined {
140140
return this.constructable[InternalBlockToolSettings.ConversionConfig];
141141
}
142142

src/components/ui/toolbox.ts

+20
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,26 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
307307
on: this.api.ui.nodes.redactor,
308308
handler: (event: KeyboardEvent) => {
309309
event.preventDefault();
310+
311+
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
312+
const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex);
313+
314+
/**
315+
* Try to convert current Block to shortcut's tool
316+
* If conversion is not possible, insert a new Block below
317+
*/
318+
if (currentBlock) {
319+
try {
320+
this.api.blocks.convert(currentBlock.id, toolName);
321+
322+
window.requestAnimationFrame(() => {
323+
this.api.caret.setToBlock(currentBlockIndex, 'end');
324+
});
325+
326+
return;
327+
} catch (error) {}
328+
}
329+
310330
this.insertNewBlock(toolName);
311331
},
312332
});

0 commit comments

Comments
 (0)