Skip to content

Commit 1320b04

Browse files
feat(merge): blocks of different types can be merged (#2671)
* feature: possibilities to merge blocks of different types * fix: remove scope change * feat: use convert config instead of defined property * chore:: use built-in function for type check * fix: remove console.log * chore: remove styling added by mistakes * test: add testing for different blocks types merging * fix: remove unused import * fix: remove type argument * fix: use existing functions for data export * chore: update changelog * fix: re put await * fix: remove unnecessary check * fix: typo in test name * fix: re-add condition for merge * test: add caret position test * fix caret issues, add sanitize * make if-else statement more clear * upgrade cypress * Update cypress.yml * upd cypress to 13 * make sanitize test simpler * patch rc version --------- Co-authored-by: GuillaumeOnepilot <guillaume@onepilot.co> Co-authored-by: Guillaume Leon <97881811+GuillaumeOnepilot@users.noreply.github.com>
1 parent b355f16 commit 1320b04

File tree

12 files changed

+571
-140
lines changed

12 files changed

+571
-140
lines changed

.github/workflows/cypress.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ jobs:
1212
steps:
1313
- uses: actions/setup-node@v3
1414
with:
15-
node-version: 16
16-
- uses: actions/checkout@v3
17-
- uses: cypress-io/github-action@v5
15+
node-version: 18
16+
- uses: actions/checkout@v4
17+
- uses: cypress-io/github-action@v6
1818
with:
1919
config: video=false
2020
browser: ${{ matrix.browser }}

docs/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
### 2.30.0
44

5+
- `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig)
56
- `Fix``onChange` will be called when removing the entire text within a descendant element of a block.
67
- `Fix` - Unexpected new line on Enter press with selected block without caret
78
- `Fix` - Search input autofocus loosing after Block Tunes opening

package.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@editorjs/editorjs",
3-
"version": "2.30.0-rc.1",
3+
"version": "2.30.0-rc.2",
44
"description": "Editor.js — Native JS, based on API and Open Source",
55
"main": "dist/editorjs.umd.js",
66
"module": "dist/editorjs.mjs",
@@ -45,14 +45,14 @@
4545
"@editorjs/code": "^2.7.0",
4646
"@editorjs/delimiter": "^1.2.0",
4747
"@editorjs/header": "^2.7.0",
48-
"@editorjs/paragraph": "^2.11.3",
48+
"@editorjs/paragraph": "^2.11.4",
4949
"@editorjs/simple-image": "^1.4.1",
5050
"@types/node": "^18.15.11",
5151
"chai-subset": "^1.6.0",
5252
"codex-notifier": "^1.1.2",
5353
"codex-tooltip": "^1.0.5",
5454
"core-js": "3.30.0",
55-
"cypress": "^12.9.0",
55+
"cypress": "^13.7.1",
5656
"cypress-intellij-reporter": "^0.0.7",
5757
"cypress-plugin-tab": "^1.0.5",
5858
"cypress-terminal-report": "^5.3.2",

src/components/block/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
550550
*
551551
* @returns {object}
552552
*/
553-
public async save(): Promise<void | SavedData> {
553+
public async save(): Promise<undefined | SavedData> {
554554
const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement);
555555
const tunesData: { [name: string]: BlockTuneData } = this.unavailableTunesData;
556556

src/components/modules/blockEvents.ts

+3-6
Original file line numberDiff line numberDiff line change
@@ -493,12 +493,9 @@ export default class BlockEvents extends Module {
493493
BlockManager
494494
.mergeBlocks(targetBlock, blockToMerge)
495495
.then(() => {
496-
window.requestAnimationFrame(() => {
497-
/** Restore caret position after merge */
498-
Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement);
499-
targetBlock.pluginsContent.normalize();
500-
Toolbar.close();
501-
});
496+
/** Restore caret position after merge */
497+
Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement);
498+
Toolbar.close();
502499
});
503500
}
504501

src/components/modules/blockManager.ts

+34-6
Original file line numberDiff line numberDiff line change
@@ -18,8 +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';
21+
import { clean, sanitizeBlocks } from '../utils/sanitizer';
22+
import { convertStringToBlockData, isBlockConvertable } from '../utils/blocks';
2323
import PromiseQueue from '../utils/promise-queue';
2424

2525
/**
@@ -69,7 +69,7 @@ export default class BlockManager extends Module {
6969
*
7070
* @returns {Block}
7171
*/
72-
public get currentBlock(): Block {
72+
public get currentBlock(): Block | undefined {
7373
return this._blocks[this.currentBlockIndex];
7474
}
7575

@@ -471,12 +471,40 @@ export default class BlockManager extends Module {
471471
* @returns {Promise} - the sequence that can be continued
472472
*/
473473
public async mergeBlocks(targetBlock: Block, blockToMerge: Block): Promise<void> {
474-
const blockToMergeData = await blockToMerge.data;
474+
let blockToMergeData: BlockToolData | undefined;
475475

476-
if (!_.isEmpty(blockToMergeData)) {
477-
await targetBlock.mergeWith(blockToMergeData);
476+
/**
477+
* We can merge:
478+
* 1) Blocks with the same Tool if tool provides merge method
479+
*/
480+
if (targetBlock.name === blockToMerge.name && targetBlock.mergeable) {
481+
const blockToMergeDataRaw = await blockToMerge.data;
482+
483+
if (_.isEmpty(blockToMergeDataRaw)) {
484+
console.error('Could not merge Block. Failed to extract original Block data.');
485+
486+
return;
487+
}
488+
489+
const [ cleanData ] = sanitizeBlocks([ blockToMergeDataRaw ], targetBlock.tool.sanitizeConfig);
490+
491+
blockToMergeData = cleanData;
492+
493+
/**
494+
* 2) Blocks with different Tools if they provides conversionConfig
495+
*/
496+
} else if (targetBlock.mergeable && isBlockConvertable(blockToMerge, 'export') && isBlockConvertable(targetBlock, 'import')) {
497+
const blockToMergeDataStringified = await blockToMerge.exportDataAsString();
498+
const cleanData = clean(blockToMergeDataStringified, targetBlock.tool.sanitizeConfig);
499+
500+
blockToMergeData = convertStringToBlockData(cleanData, targetBlock.tool.conversionConfig);
501+
}
502+
503+
if (blockToMergeData === undefined) {
504+
return;
478505
}
479506

507+
await targetBlock.mergeWith(blockToMergeData);
480508
this.removeBlock(blockToMerge);
481509
this.currentBlockIndex = this._blocks.indexOf(targetBlock);
482510
}

src/components/modules/caret.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export default class Caret extends Module {
5050
/**
5151
* If Block does not contain inputs, treat caret as "at start"
5252
*/
53-
if (!currentBlock.focusable) {
53+
if (!currentBlock?.focusable) {
5454
return true;
5555
}
5656

src/components/utils/blocks.ts

+37-1
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,54 @@ import type { BlockToolData } from '../../../types/tools/block-tool-data';
33
import type Block from '../block';
44
import { isFunction, isString, log } from '../utils';
55

6+
/**
7+
* Check if block has valid conversion config for export or import.
8+
*
9+
* @param block - block to check
10+
* @param direction - export for block to merge from, import for block to merge to
11+
*/
12+
export function isBlockConvertable(block: Block, direction: 'export' | 'import'): boolean {
13+
if (!block.tool.conversionConfig) {
14+
return false;
15+
}
16+
17+
const conversionProp = block.tool.conversionConfig[direction];
18+
19+
return isFunction(conversionProp) || isString(conversionProp);
20+
}
21+
622
/**
723
* Check if two blocks could be merged.
824
*
925
* We can merge two blocks if:
1026
* - they have the same type
1127
* - they have a merge function (.mergeable = true)
28+
* - If they have valid conversions config
1229
*
1330
* @param targetBlock - block to merge to
1431
* @param blockToMerge - block to merge from
1532
*/
1633
export function areBlocksMergeable(targetBlock: Block, blockToMerge: Block): boolean {
17-
return targetBlock.mergeable && targetBlock.name === blockToMerge.name;
34+
/**
35+
* If target block has not 'merge' method, we can't merge blocks.
36+
*
37+
* Technically we can (through the conversion) but it will lead a target block delete and recreation, which is unexpected behavior.
38+
*/
39+
if (!targetBlock.mergeable) {
40+
return false;
41+
}
42+
43+
/**
44+
* Tool knows how to merge own data format
45+
*/
46+
if (targetBlock.name === blockToMerge.name) {
47+
return true;
48+
}
49+
50+
/**
51+
* We can merge blocks if they have valid conversion config
52+
*/
53+
return isBlockConvertable(blockToMerge, 'export') && isBlockConvertable(targetBlock, 'import');
1854
}
1955

2056
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {
2+
BaseTool,
3+
BlockToolConstructorOptions,
4+
BlockToolData,
5+
ConversionConfig
6+
} from '../../../../types';
7+
8+
/**
9+
* Simplified Header for testing
10+
*/
11+
export class SimpleHeader implements BaseTool {
12+
private _data: BlockToolData;
13+
private element: HTMLHeadingElement;
14+
15+
/**
16+
*
17+
* @param options - constructor options
18+
*/
19+
constructor({ data }: BlockToolConstructorOptions) {
20+
this._data = data;
21+
}
22+
23+
/**
24+
* Return Tool's view
25+
*
26+
* @returns {HTMLHeadingElement}
27+
* @public
28+
*/
29+
public render(): HTMLHeadingElement {
30+
this.element = document.createElement('h1');
31+
32+
this.element.contentEditable = 'true';
33+
this.element.innerHTML = this._data.text;
34+
35+
return this.element;
36+
}
37+
38+
/**
39+
* @param data - saved data to merger with current block
40+
*/
41+
public merge(data: BlockToolData): void {
42+
this.data = {
43+
text: this.data.text + data.text,
44+
level: this.data.level,
45+
};
46+
}
47+
48+
/**
49+
* Extract Tool's data from the view
50+
*
51+
* @param toolsContent - Text tools rendered view
52+
*/
53+
public save(toolsContent: HTMLHeadingElement): BlockToolData {
54+
return {
55+
text: toolsContent.innerHTML,
56+
level: 1,
57+
};
58+
}
59+
60+
/**
61+
* Allow Header to be converted to/from other blocks
62+
*/
63+
public static get conversionConfig(): ConversionConfig {
64+
return {
65+
export: 'text', // use 'text' property for other blocks
66+
import: 'text', // fill 'text' property from other block's export string
67+
};
68+
}
69+
70+
/**
71+
* Data getter
72+
*/
73+
private get data(): BlockToolData {
74+
this._data.text = this.element.innerHTML;
75+
this._data.level = 1;
76+
77+
return this._data;
78+
}
79+
80+
/**
81+
* Data setter
82+
*/
83+
private set data(data: BlockToolData) {
84+
this._data = data;
85+
86+
if (data.text !== undefined) {
87+
this.element.innerHTML = this._data.text || '';
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)