Skip to content

Commit

Permalink
feat: support merging multiple blocks
Browse files Browse the repository at this point in the history
  • Loading branch information
evangillogley committed Apr 5, 2021
1 parent 2d89105 commit 4d9cff9
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 16 deletions.
16 changes: 12 additions & 4 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,12 +388,13 @@ static get sanitize() {

## Conversion config <a name="conversion-config"></a>

Editor.js has a Conversion Toolbar that allows user to convert one Block to another.
Editor.js has a Conversion Toolbar that allows user to convert one or multiple Blocks to another.

![](https://capella.pics/6c1f708b-a30c-4ffd-a427-5b59a1a472e0.jpg)

1. You can add ability to your Tool to be converted. Specify «export» property of `conversionConfig`.
2. You can add ability to convert other Tools to your Tool. Specify «import» property of `conversionConfig`.
1. You can add the ability to your Tool to be converted. Specify «export» property of `conversionConfig`.
2. You can add the ability to convert other Tools to your Tool. Specify «import» property of `conversionConfig`.
3. You can optionally add the ability to handle how multiple blocks are merged into a single block. Specify «mergeImport» property of `conversionConfig`.

Conversion Toolbar will be shown only near Blocks that specified an «export» rule, when user selected almost all block's content.
This Toolbar will contain only Tools that specified an «import» rule.
Expand Down Expand Up @@ -436,7 +437,7 @@ class ListTool {
constructor(){
this.data = {
items: [
'Fisrt item',
'First item',
'Second item',
'Third item'
],
Expand All @@ -446,6 +447,13 @@ class ListTool {

static get conversionConfig() {
return {
imports: (data) => ({
items: [data],
}),
mergeImport: (dataArray) => ({
items: dataArray,
type: 'ordered'
}),
export: (data) => {
return data.items.join('.'); // in this example, all list items will be concatenated to an export string
},
Expand Down
16 changes: 15 additions & 1 deletion src/components/modules/crossBlockSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ export default class CrossBlockSelection extends Module {
return;
}

const { BlockManager } = this.Editor;
const { BlockManager, ConversionToolbar } = this.Editor;

this.firstSelectedBlock = BlockManager.getBlock(event.target as HTMLElement);
this.lastSelectedBlock = this.firstSelectedBlock;

// close multiple block conversion tool bar on mouse down
ConversionToolbar.close();

this.listeners.on(document, 'mouseover', this.onMouseOver);
this.listeners.on(document, 'mouseup', this.onMouseUp);
}
Expand Down Expand Up @@ -171,9 +174,20 @@ export default class CrossBlockSelection extends Module {

/**
* Mouse up event handler.
* Opens Conversion toolbar if more than 1 block selected
* Removes the listeners
*/
private onMouseUp = (): void => {
const { BlockSelection, InlineToolbar, ConversionToolbar } = this.Editor;
const selectedBlocks = BlockSelection.selectedBlocks;

if (selectedBlocks.length > 1) {
const secondlastBlock = selectedBlocks[selectedBlocks.length - 2];

InlineToolbar.move(secondlastBlock.holder.getBoundingClientRect());
ConversionToolbar.open(true);
}

this.listeners.off(document, 'mouseover', this.onMouseOver);
this.listeners.off(document, 'mouseup', this.onMouseUp);
}
Expand Down
173 changes: 165 additions & 8 deletions src/components/modules/toolbar/conversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SavedData } from '../../../../types/data-formats';
import Flipper from '../../flipper';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import Block from '../../block';

/**
* HTML Elements used for ConversionToolbar
Expand Down Expand Up @@ -37,6 +38,13 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
};
}

/**
* Last selection of multiple blocks
*
* @type {Block[]}
*/
public selectedBlocks: Block[] = [];

/**
* Conversion Toolbar open/close state
*
Expand Down Expand Up @@ -125,11 +133,27 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {

/**
* Shows Conversion Toolbar
*
* @param hasMultipleBlocks - boolean for multiblock conversion
*/
public open(): void {
this.filterTools();
public open(hasMultipleBlocks?: boolean): void {
// filter tools only for one to one conversions
this.filterTools(hasMultipleBlocks);

this.opened = true;

// check if toolbar needs to be displayed
// @todo detach or recreate conversion toolbar as independent instance
const inlineToolClass = this.Editor.InlineToolbar.CSS.inlineToolbar;

if (hasMultipleBlocks && this.nodes.wrapper.parentElement.classList.contains(inlineToolClass)) {
// store blocks for merge later
this.selectedBlocks = this.Editor.BlockSelection.selectedBlocks;
this.nodes.wrapper.parentElement.classList.add(this.Editor.InlineToolbar.CSS.inlineToolbarVisibleChildren);
} else {
this.selectedBlocks = [];
}

this.nodes.wrapper.classList.add(ConversionToolbar.CSS.conversionToolbarShowed);

/**
Expand Down Expand Up @@ -178,13 +202,15 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
*/
public async replaceWithBlock(replacingToolName: string): Promise<void> {
/**
* At first, we get current Block data
* At first, we get current Block data with one selection
*
* @type {BlockToolConstructable}
*/
const currentBlockTool = this.Editor.BlockManager.currentBlock.tool;
const currentBlockName = this.Editor.BlockManager.currentBlock.name;
const savedBlock = await this.Editor.BlockManager.currentBlock.save() as SavedData;
const currentBlock = this.Editor.BlockManager.currentBlock;

const currentBlockTool = currentBlock.tool;
const currentBlockName = currentBlock.name;
const savedBlock = await currentBlock.save() as SavedData;
const blockData = savedBlock.data;

/**
Expand Down Expand Up @@ -264,6 +290,130 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
}, 10)();
}

/**
* Merges many Blocks into one
* For that Tools must provide import/export methods and can provide an optional mergeImport method
*
* @param {string} replacingToolName - name of Tool which replaces current
*/
public async mergeBlocks(replacingToolName: string): Promise<void> {
/**
* At first, we get all the selected blocks
*
* @type {BlockToolConstructable}
*/
const selectedBlocks = [ ...this.selectedBlocks ];
const blocksData = await Promise.all(selectedBlocks.map(async (block) => {
const savedBlock = await block.save();

return savedBlock && savedBlock.data;
}));

/**
* Getting a class of replacing Tool
*
* @type {BlockToolConstructable}
*/
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
*/
const exportData = [];

blocksData.forEach((data, index) => {
const currentBlock = selectedBlocks[index];
const currentBlockTool = currentBlock.tool;
const exportProp = currentBlockTool?.conversionConfig?.export;

if (_.isFunction(exportProp)) {
exportData.push(exportProp(data));
} else if (_.isString(exportProp)) {
exportData.push(data[exportProp]);
} else {
_.log(`Conversion «export» property not defined on ${currentBlock.tool.toString()}`);
}
});

/**
* Clean exported data with replacing sanitizer config
*/
const cleaned: string[] = exportData.map(data => this.Editor.Sanitizer.clean(
data,
replacingTool.sanitizeConfig
));

let newBlockData = {};
const conversionConfig = replacingTool.conversionConfig;

/**
* «mergeImport» property can be a Function
* function — accept imported strings and compose tool data object
*/
if (conversionConfig && conversionConfig.mergeImport) {
const mergeImport = conversionConfig.mergeImport;

if (_.isFunction(mergeImport)) {
newBlockData = mergeImport(cleaned, blocksData);
} else {
_.log('Conversion «mergeImport» property must be a function. ' +
'Function accepts a imported string array and return composed tool data.');

return;
}

/**
* «import» property can be Function or String
* function — accept imported string and compose tool data object
* string — the name of data field to import
*/
} else if (conversionConfig && conversionConfig.import) {
const importProp = conversionConfig.import;
const joinedCleaned = cleaned.join(' ');

if (_.isFunction(importProp)) {
newBlockData = importProp(joinedCleaned);
} else if (_.isString(importProp)) {
newBlockData[importProp] = joinedCleaned;
} 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;
}
}

const { BlockManager } = this.Editor;
const [first, ...deletedBlocks] = selectedBlocks;

const index = BlockManager.blocks.indexOf(first);

const block = this.Editor.BlockManager.insert({
tool: replacingToolName,
data: newBlockData,
replace: true,
index,
});

deletedBlocks.forEach((deletedBlock) => {
const deletedIndex = BlockManager.blocks.indexOf(deletedBlock);

this.Editor.BlockManager.removeBlock(deletedIndex);
});

this.Editor.BlockSelection.clearSelection();
this.close();
this.Editor.InlineToolbar.close();

_.delay(() => {
this.Editor.Caret.setToBlock(block);
}, 10)();
}

/**
* Iterates existing Tools and inserts to the ConversionToolbar
* if tools have ability to import
Expand Down Expand Up @@ -316,22 +466,29 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
this.tools[toolName] = tool;

this.listeners.on(tool, 'click', async () => {
if (this.selectedBlocks.length > 1) {
this.mergeBlocks(toolName);

return;
}
await this.replaceWithBlock(toolName);
});
}

/**
* Hide current Tool and show others
*
* @param {boolean} hasMultipleBlocks - show all if has multiple blocks
*/
private filterTools(): void {
private filterTools(hasMultipleBlocks: boolean): void {
const { currentBlock } = this.Editor.BlockManager;

/**
* Show previously hided
*/
Object.entries(this.tools).forEach(([name, button]) => {
button.hidden = false;
button.classList.toggle(ConversionToolbar.CSS.conversionToolHidden, name === currentBlock.name);
button.classList.toggle(ConversionToolbar.CSS.conversionToolHidden, hasMultipleBlocks ? false : name === currentBlock.name);
});
}

Expand Down
7 changes: 5 additions & 2 deletions src/components/modules/toolbar/inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
public CSS = {
inlineToolbar: 'ce-inline-toolbar',
inlineToolbarVisibleChildren: 'ce-inline-toolbar--visible-children',
inlineToolbarShowed: 'ce-inline-toolbar--showed',
inlineToolbarLeftOriented: 'ce-inline-toolbar--left-oriented',
inlineToolbarRightOriented: 'ce-inline-toolbar--right-oriented',
Expand Down Expand Up @@ -134,9 +135,11 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {

/**
* Move Toolbar to the selected text
*
* @param rect
*/
public move(): void {
const selectionRect = SelectionUtils.rect as DOMRect;
public move(rect?: DOMRect): void {
const selectionRect = rect || SelectionUtils.rect as DOMRect;
const wrapperOffset = this.Editor.UI.nodes.wrapper.getBoundingClientRect();
const newCoords = {
x: selectionRect.x - wrapperOffset.left,
Expand Down
4 changes: 4 additions & 0 deletions src/styles/inline-toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
top: 0;
left: 0;

&--visible-children {
opacity: 1;
}

&--showed {
opacity: 1;
visibility: visible;
Expand Down
9 changes: 8 additions & 1 deletion types/configs/conversion-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ 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) => BlockToolData) | string;

/**
* How to merge multiple blocks into this Tool.
*
* Function — optional method that accepts importing strings from other blocks and composes Tool data to render.
*/
mergeImport?: ((data: string[], selectedBlocks: BlockToolData[]) => BlockToolData);

/**
* How to export this Tool to make other Block.
Expand Down

0 comments on commit 4d9cff9

Please sign in to comment.