Skip to content

Commit 23858e0

Browse files
authored
fix(conversion): restore caret after conversion though the Inline Toolbar and API (#2699)
* fix caret loosing after caret * Refactor convert method to return Promise in Blocks API * changelog upd * Fix missing semicolon in blocks.cy.ts and BlockTunes.cy.ts * add test for inline toolbar conversion * Fix missing semicolon in InlineToolbar.cy.ts * add test for toolbox shortcut * api caret.setToBlock now can accept block api or index or id * eslint fix * Refactor test descriptions in caret.cy.ts * rm tsconfig change * lint * lint * Update CHANGELOG.md
1 parent 5eafda5 commit 23858e0

File tree

14 files changed

+326
-47
lines changed

14 files changed

+326
-47
lines changed

docs/CHANGELOG.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@
22

33
### 2.30.0
44

5-
`New` – Block Tunes now supports nesting items
6-
`New` – Block Tunes now supports separator items
7-
`New` – "Convert to" control is now also available in Block Tunes
5+
- `New` – Block Tunes now supports nesting items
6+
- `New` – Block Tunes now supports separator items
7+
- `New` – "Convert to" control is now also available in Block Tunes
88
- `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig)
99
- `Fix``onChange` will be called when removing the entire text within a descendant element of a block.
1010
- `Fix` - Unexpected new line on Enter press with selected block without caret
1111
- `Fix` - Search input autofocus loosing after Block Tunes opening
1212
- `Fix` - Block removing while Enter press on Block Tunes
13-
`Fix` – Unwanted scroll on first typing on iOS devices
13+
- `Fix` – Unwanted scroll on first typing on iOS devices
1414
- `Fix` - Unwanted soft line break on Enter press after period and space (". |") on iOS devices
1515
- `Fix` - Caret lost after block conversion on mobile devices.
16+
- `Improvement` - The API `blocks.convert()` now returns the new block API
17+
- `Improvement` - The API `caret.setToBlock()` now can accept either BlockAPI or block index or block id
1618

1719
### 2.29.1
1820

src/components/modules/api/blocks.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api';
1+
import type { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api';
22
import { BlockToolData, OutputBlockData, OutputData, ToolConfig } from '../../../../types';
33
import * as _ from './../../utils';
44
import BlockAPI from '../../block/api';
@@ -327,7 +327,7 @@ export default class BlocksAPI extends Module {
327327
* @param dataOverrides - optional data overrides for the new block
328328
* @throws Error if conversion is not possible
329329
*/
330-
private convert = (id: string, newType: string, dataOverrides?: BlockToolData): void => {
330+
private convert = async (id: string, newType: string, dataOverrides?: BlockToolData): Promise<BlockAPIInterface> => {
331331
const { BlockManager, Tools } = this.Editor;
332332
const blockToConvert = BlockManager.getBlockById(id);
333333

@@ -346,7 +346,9 @@ export default class BlocksAPI extends Module {
346346
const targetBlockConvertable = targetBlockTool.conversionConfig?.import !== undefined;
347347

348348
if (originalBlockConvertable && targetBlockConvertable) {
349-
BlockManager.convert(blockToConvert, newType, dataOverrides);
349+
const newBlock = await BlockManager.convert(blockToConvert, newType, dataOverrides);
350+
351+
return new BlockAPI(newBlock);
350352
} else {
351353
const unsupportedBlockTypes = [
352354
!originalBlockConvertable ? capitalize(blockToConvert.name) : false,

src/components/modules/api/caret.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Caret } from '../../../../types/api';
1+
import { BlockAPI, Caret } from '../../../../types/api';
22
import Module from '../../__module';
3+
import { resolveBlock } from '../../utils/api';
34

45
/**
56
* @class CaretAPI
@@ -96,21 +97,23 @@ export default class CaretAPI extends Module {
9697
/**
9798
* Sets caret to the Block by passed index
9899
*
99-
* @param {number} index - index of Block where to set caret
100-
* @param {string} position - position where to set caret
101-
* @param {number} offset - caret offset
100+
* @param blockOrIdOrIndex - either BlockAPI or Block id or Block index
101+
* @param position - position where to set caret
102+
* @param offset - caret offset
102103
* @returns {boolean}
103104
*/
104105
private setToBlock = (
105-
index: number,
106+
blockOrIdOrIndex: BlockAPI | BlockAPI['id'] | number,
106107
position: string = this.Editor.Caret.positions.DEFAULT,
107108
offset = 0
108109
): boolean => {
109-
if (!this.Editor.BlockManager.blocks[index]) {
110+
const block = resolveBlock(blockOrIdOrIndex, this.Editor);
111+
112+
if (block === undefined) {
110113
return false;
111114
}
112115

113-
this.Editor.Caret.setToBlock(this.Editor.BlockManager.blocks[index], position, offset);
116+
this.Editor.Caret.setToBlock(block, position, offset);
114117

115118
return true;
116119
};

src/components/modules/toolbar/conversion.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,14 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
183183
public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
184184
const { BlockManager, BlockSelection, InlineToolbar, Caret } = this.Editor;
185185

186-
BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides);
186+
const newBlock = await BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides);
187187

188188
BlockSelection.clearSelection();
189189

190190
this.close();
191191
InlineToolbar.close();
192192

193-
window.requestAnimationFrame(() => {
194-
Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END);
195-
});
193+
Caret.setToBlock(newBlock, Caret.positions.END);
196194
}
197195

198196
/**

src/components/modules/toolbar/inline.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
427427

428428
this.nodes.togglerAndButtonsWrapper.appendChild(this.nodes.conversionToggler);
429429

430+
if (import.meta.env.MODE === 'test') {
431+
this.nodes.conversionToggler.setAttribute('data-cy', 'conversion-toggler');
432+
}
433+
430434
this.listeners.on(this.nodes.conversionToggler, 'click', () => {
431435
this.Editor.ConversionToolbar.toggle((conversionToolbarOpened) => {
432436
/**

src/components/ui/toolbox.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
356356
Shortcuts.add({
357357
name: shortcut,
358358
on: this.api.ui.nodes.redactor,
359-
handler: (event: KeyboardEvent) => {
359+
handler: async (event: KeyboardEvent) => {
360360
event.preventDefault();
361361

362362
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
@@ -368,11 +368,9 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
368368
*/
369369
if (currentBlock) {
370370
try {
371-
this.api.blocks.convert(currentBlock.id, toolName);
371+
const newBlock = await this.api.blocks.convert(currentBlock.id, toolName);
372372

373-
window.requestAnimationFrame(() => {
374-
this.api.caret.setToBlock(currentBlockIndex, 'end');
375-
});
373+
this.api.caret.setToBlock(newBlock, 'end');
376374

377375
return;
378376
} catch (error) {}

src/components/utils/api.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { BlockAPI } from '../../../types/api/block';
2+
import { EditorModules } from '../../types-internal/editor-modules';
3+
import Block from '../block';
4+
5+
/**
6+
* Returns Block instance by passed Block index or Block id
7+
*
8+
* @param attribute - either BlockAPI or Block id or Block index
9+
* @param editor - Editor instance
10+
*/
11+
export function resolveBlock(attribute: BlockAPI | BlockAPI['id'] | number, editor: EditorModules): Block | undefined {
12+
if (typeof attribute === 'number') {
13+
return editor.BlockManager.getBlockByIndex(attribute);
14+
}
15+
16+
if (typeof attribute === 'string') {
17+
return editor.BlockManager.getBlockById(attribute);
18+
}
19+
20+
return editor.BlockManager.getBlockById(attribute.id);
21+
}

test/cypress/tests/api/blocks.cy.ts

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type EditorJS from '../../../../types/index';
2-
import { ConversionConfig, ToolboxConfig } from '../../../../types';
2+
import type { ConversionConfig, ToolboxConfig } from '../../../../types';
33
import ToolMock from '../../fixtures/tools/ToolMock';
44

55
/**
@@ -202,7 +202,7 @@ describe('api.blocks', () => {
202202
});
203203

204204
describe('.convert()', function () {
205-
it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import"', function () {
205+
it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import". Should return BlockAPI as well.', function () {
206206
/**
207207
* Mock of Tool with conversionConfig
208208
*/
@@ -246,20 +246,28 @@ describe('api.blocks', () => {
246246
existingBlock,
247247
],
248248
},
249-
}).then((editor) => {
249+
}).then(async (editor) => {
250250
const { convert } = editor.blocks;
251251

252-
convert(existingBlock.id, 'convertableTool');
252+
const returnValue = await convert(existingBlock.id, 'convertableTool');
253253

254254
// wait for block to be converted
255-
cy.wait(100).then(() => {
255+
cy.wait(100).then(async () => {
256256
/**
257257
* Check that block was converted
258258
*/
259-
editor.save().then(( { blocks }) => {
260-
expect(blocks.length).to.eq(1);
261-
expect(blocks[0].type).to.eq('convertableTool');
262-
expect(blocks[0].data.text).to.eq(existingBlock.data.text);
259+
const { blocks } = await editor.save();
260+
261+
expect(blocks.length).to.eq(1);
262+
expect(blocks[0].type).to.eq('convertableTool');
263+
expect(blocks[0].data.text).to.eq(existingBlock.data.text);
264+
265+
/**
266+
* Check that returned value is BlockAPI
267+
*/
268+
expect(returnValue).to.containSubset({
269+
name: 'convertableTool',
270+
id: blocks[0].id,
263271
});
264272
});
265273
});
@@ -274,9 +282,10 @@ describe('api.blocks', () => {
274282
const fakeId = 'WRNG_ID';
275283
const { convert } = editor.blocks;
276284

277-
const exec = (): void => convert(fakeId, 'convertableTool');
278-
279-
expect(exec).to.throw(`Block with id "${fakeId}" not found`);
285+
return convert(fakeId, 'convertableTool')
286+
.catch((error) => {
287+
expect(error.message).to.be.eq(`Block with id "${fakeId}" not found`);
288+
});
280289
});
281290
});
282291

@@ -302,9 +311,10 @@ describe('api.blocks', () => {
302311
const nonexistingToolName = 'WRNG_TOOL_NAME';
303312
const { convert } = editor.blocks;
304313

305-
const exec = (): void => convert(existingBlock.id, nonexistingToolName);
306-
307-
expect(exec).to.throw(`Block Tool with type "${nonexistingToolName}" not found`);
314+
return convert(existingBlock.id, nonexistingToolName)
315+
.catch((error) => {
316+
expect(error.message).to.be.eq(`Block Tool with type "${nonexistingToolName}" not found`);
317+
});
308318
});
309319
});
310320

@@ -340,9 +350,10 @@ describe('api.blocks', () => {
340350
*/
341351
const { convert } = editor.blocks;
342352

343-
const exec = (): void => convert(existingBlock.id, 'nonConvertableTool');
344-
345-
expect(exec).to.throw(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`);
353+
return convert(existingBlock.id, 'nonConvertableTool')
354+
.catch((error) => {
355+
expect(error.message).to.be.eq(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`);
356+
});
346357
});
347358
});
348359
});

test/cypress/tests/api/caret.cy.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import EditorJS from '../../../../types';
2+
3+
/**
4+
* Test cases for Caret API
5+
*/
6+
describe('Caret API', () => {
7+
const paragraphDataMock = {
8+
id: 'bwnFX5LoX7',
9+
type: 'paragraph',
10+
data: {
11+
text: 'The first block content mock.',
12+
},
13+
};
14+
15+
describe('.setToBlock()', () => {
16+
/**
17+
* The arrange part of the following tests are the same:
18+
* - create an editor
19+
* - move caret out of the block by default
20+
*/
21+
beforeEach(() => {
22+
cy.createEditor({
23+
data: {
24+
blocks: [
25+
paragraphDataMock,
26+
],
27+
},
28+
}).as('editorInstance');
29+
30+
/**
31+
* Blur caret from the block before setting via api
32+
*/
33+
cy.get('[data-cy=editorjs]')
34+
.click();
35+
});
36+
37+
it('should set caret to a block (and return true) if block index is passed as argument', () => {
38+
cy.get<EditorJS>('@editorInstance')
39+
.then(async (editor) => {
40+
const returnedValue = editor.caret.setToBlock(0);
41+
42+
/**
43+
* Check that caret belongs block
44+
*/
45+
cy.window()
46+
.then((window) => {
47+
const selection = window.getSelection();
48+
const range = selection.getRangeAt(0);
49+
50+
cy.get('[data-cy=editorjs]')
51+
.find('.ce-block')
52+
.first()
53+
.should(($block) => {
54+
expect($block[0].contains(range.startContainer)).to.be.true;
55+
});
56+
});
57+
58+
expect(returnedValue).to.be.true;
59+
});
60+
});
61+
62+
it('should set caret to a block (and return true) if block id is passed as argument', () => {
63+
cy.get<EditorJS>('@editorInstance')
64+
.then(async (editor) => {
65+
const returnedValue = editor.caret.setToBlock(paragraphDataMock.id);
66+
67+
/**
68+
* Check that caret belongs block
69+
*/
70+
cy.window()
71+
.then((window) => {
72+
const selection = window.getSelection();
73+
const range = selection.getRangeAt(0);
74+
75+
cy.get('[data-cy=editorjs]')
76+
.find('.ce-block')
77+
.first()
78+
.should(($block) => {
79+
expect($block[0].contains(range.startContainer)).to.be.true;
80+
});
81+
});
82+
83+
expect(returnedValue).to.be.true;
84+
});
85+
});
86+
87+
it('should set caret to a block (and return true) if Block API is passed as argument', () => {
88+
cy.get<EditorJS>('@editorInstance')
89+
.then(async (editor) => {
90+
const block = editor.blocks.getById(paragraphDataMock.id);
91+
const returnedValue = editor.caret.setToBlock(block);
92+
93+
/**
94+
* Check that caret belongs block
95+
*/
96+
cy.window()
97+
.then((window) => {
98+
const selection = window.getSelection();
99+
const range = selection.getRangeAt(0);
100+
101+
cy.get('[data-cy=editorjs]')
102+
.find('.ce-block')
103+
.first()
104+
.should(($block) => {
105+
expect($block[0].contains(range.startContainer)).to.be.true;
106+
});
107+
});
108+
109+
expect(returnedValue).to.be.true;
110+
});
111+
});
112+
});
113+
});

0 commit comments

Comments
 (0)