Compare commits

...

12 commits

Author SHA1 Message Date
Danilo da Silveira Figueira 0a609a9e46
Merge 07d2fc0db4 into bd1de56ef3 2024-05-01 21:53:56 -03:00
github-actions[bot] bd1de56ef3
Bump version (#2705)
Co-authored-by: github-actions <action@github.com>
2024-05-01 21:00:22 +03:00
Peter Savchenko 8276daa5ca
fix changelog (#2704) 2024-05-01 20:59:33 +03:00
github-actions[bot] 238c909016
Bump version (#2701)
Co-authored-by: github-actions <action@github.com>
2024-04-29 22:28:45 +03:00
Peter Savchenko 23858e0025
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
2024-04-29 22:24:31 +03:00
github-actions[bot] 5eafda5ec4
Bump version (#2698)
Co-authored-by: github-actions <action@github.com>
2024-04-27 21:22:12 +03:00
Peter Savchenko efa0a34f8e
fix caret loosing after caret (#2697) 2024-04-27 21:19:12 +03:00
Peter Savchenko c48fca1be3
fix ios shift (#2696) 2024-04-27 21:09:16 +03:00
Peter Savchenko 1028577521
fix(scroll): acidental scroll to top on iOS devices (#2695)
* fix scroll on ios typing

* Update tsconfig.json

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update package.json

* Fix popover hide method to use isHidden flag
2024-04-27 21:04:26 +03:00
github-actions[bot] 844272656e
Bump version (#2694)
Co-authored-by: github-actions <action@github.com>
2024-04-27 16:59:52 +03:00
Tatiana Fomina 7821e35302
feat(block tunes): Conversion Menu in Block Tunes (#2692)
* Support delimiter

* Rename types, move types to popover-item folder

* Fix ts errors

* Add tests

* Review fixes

* Review fixes 2

* Fix delimiter while search

* Fix flipper issue

* Fix block tunes types

* Fix types

* tmp

* Fixes

* Make search input emit event

* Fix types

* Rename delimiter to separator

* Update chengelog

* Add convert to to block tunes

* i18n

* Lint

* Fix tests

* Fix tests 2

* Tests

* Add caching

* Rename

* Fix for miltiple toolbox entries

* Update changelog

* Update changelog

* Fix popover test

* Fix flipper tests

* Fix popover tests

* Remove type: 'default'

* Create isSameBlockData util

* Add testcase
2024-04-27 16:57:52 +03:00
Danilo da Silveira Figueira 07d2fc0db4 fix: Cross block selection gets wrong when pointer leaves the window area
The mouse events doesnt work when the mouse get outs of the editor window.
Related to issue #2167
2024-02-21 00:18:07 -03:00
28 changed files with 774 additions and 116 deletions

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.30.0-rc.4",
"version": "2.30.0-rc.9",
"description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editorjs.umd.js",
"module": "dist/editorjs.mjs",

View file

@ -21,11 +21,11 @@ import BlockTune from '../tools/tune';
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
import ToolsCollection from '../tools/collection';
import EventsDispatcher from '../utils/events';
import { TunesMenuConfigItem } from '../../../types/tools';
import { TunesMenuConfig, 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';
import { convertBlockDataToString, isSameBlockData } from '../utils/blocks';
/**
* Interface describes Block class constructor argument
@ -229,7 +229,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
tunesData,
}: BlockConstructorOptions, eventBus?: EventsDispatcher<EditorEventMap>) {
super();
this.name = tool.name;
this.id = id;
this.settings = tool.settings;
@ -612,34 +611,60 @@ export default class Block extends EventsDispatcher<BlockEvents> {
/**
* Returns data to render in tunes menu.
* Splits block tunes settings into 2 groups: popover items and custom html.
* Splits block tunes into 3 groups: block specific tunes, common tunes
* and custom html that is produced by combining tunes html from both previous groups
*/
public getTunes(): [PopoverItemParams[], HTMLElement] {
public getTunes(): {
toolTunes: PopoverItemParams[];
commonTunes: PopoverItemParams[];
customHtmlTunes: HTMLElement
} {
const customHtmlTunesContainer = document.createElement('div');
const tunesItems: TunesMenuConfigItem[] = [];
const commonTunesPopoverParams: TunesMenuConfigItem[] = [];
/** Tool's tunes: may be defined as return value of optional renderSettings method */
const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : [];
/** Separate custom html from Popover items params for tool's tunes */
const {
items: toolTunesPopoverParams,
htmlElement: toolTunesHtmlElement,
} = this.getTunesDataSegregated(tunesDefinedInTool);
if (toolTunesHtmlElement !== undefined) {
customHtmlTunesContainer.appendChild(toolTunesHtmlElement);
}
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
const commonTunes = [
...this.tunesInstances.values(),
...this.defaultTunesInstances.values(),
].map(tuneInstance => tuneInstance.render());
[tunesDefinedInTool, commonTunes].flat().forEach(rendered => {
if ($.isElement(rendered)) {
customHtmlTunesContainer.appendChild(rendered);
} else if (Array.isArray(rendered)) {
tunesItems.push(...rendered);
} else {
tunesItems.push(rendered);
/** Separate custom html from Popover items params for common tunes */
commonTunes.forEach(tuneConfig => {
const {
items,
htmlElement,
} = this.getTunesDataSegregated(tuneConfig);
if (htmlElement !== undefined) {
customHtmlTunesContainer.appendChild(htmlElement);
}
if (items !== undefined) {
commonTunesPopoverParams.push(...items);
}
});
return [tunesItems, customHtmlTunesContainer];
return {
toolTunes: toolTunesPopoverParams,
commonTunes: commonTunesPopoverParams,
customHtmlTunes: customHtmlTunesContainer,
};
}
/**
* Update current input index with selection anchor node
*/
@ -711,11 +736,8 @@ export default class Block extends EventsDispatcher<BlockEvents> {
const blockData = await this.data;
const toolboxItems = toolboxSettings;
return toolboxItems.find((item) => {
return Object.entries(item.data)
.some(([propName, propValue]) => {
return blockData[propName] && _.equals(blockData[propName], propValue);
});
return toolboxItems?.find((item) => {
return isSameBlockData(item.data, blockData);
});
}
@ -728,6 +750,25 @@ export default class Block extends EventsDispatcher<BlockEvents> {
return convertBlockDataToString(blockData, this.tool.conversionConfig);
}
/**
* Determines if tool's tunes settings are custom html or popover params and separates one from another by putting to different object fields
*
* @param tunes - tool's tunes config
*/
private getTunesDataSegregated(tunes: HTMLElement | TunesMenuConfig): { htmlElement?: HTMLElement; items: PopoverItemParams[] } {
const result = { } as { htmlElement?: HTMLElement; items: PopoverItemParams[] };
if ($.isElement(tunes)) {
result.htmlElement = tunes as HTMLElement;
} else if (Array.isArray(tunes)) {
result.items = tunes as PopoverItemParams[];
} else {
result.items = [ tunes ];
}
return result;
}
/**
* Make default Block wrappers and put Tool`s content there
*

View file

@ -18,7 +18,8 @@
},
"popover": {
"Filter": "",
"Nothing found": ""
"Nothing found": "",
"Convert to": ""
}
},
"toolNames": {

View file

@ -1,4 +1,4 @@
import { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api';
import type { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api';
import { BlockToolData, OutputBlockData, OutputData, ToolConfig } from '../../../../types';
import * as _ from './../../utils';
import BlockAPI from '../../block/api';
@ -327,7 +327,7 @@ export default class BlocksAPI extends Module {
* @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 => {
private convert = async (id: string, newType: string, dataOverrides?: BlockToolData): Promise<BlockAPIInterface> => {
const { BlockManager, Tools } = this.Editor;
const blockToConvert = BlockManager.getBlockById(id);
@ -346,7 +346,9 @@ export default class BlocksAPI extends Module {
const targetBlockConvertable = targetBlockTool.conversionConfig?.import !== undefined;
if (originalBlockConvertable && targetBlockConvertable) {
BlockManager.convert(blockToConvert, newType, dataOverrides);
const newBlock = await BlockManager.convert(blockToConvert, newType, dataOverrides);
return new BlockAPI(newBlock);
} else {
const unsupportedBlockTypes = [
!originalBlockConvertable ? capitalize(blockToConvert.name) : false,

View file

@ -1,5 +1,6 @@
import { Caret } from '../../../../types/api';
import { BlockAPI, Caret } from '../../../../types/api';
import Module from '../../__module';
import { resolveBlock } from '../../utils/api';
/**
* @class CaretAPI
@ -96,21 +97,23 @@ export default class CaretAPI extends Module {
/**
* Sets caret to the Block by passed index
*
* @param {number} index - index of Block where to set caret
* @param {string} position - position where to set caret
* @param {number} offset - caret offset
* @param blockOrIdOrIndex - either BlockAPI or Block id or Block index
* @param position - position where to set caret
* @param offset - caret offset
* @returns {boolean}
*/
private setToBlock = (
index: number,
blockOrIdOrIndex: BlockAPI | BlockAPI['id'] | number,
position: string = this.Editor.Caret.positions.DEFAULT,
offset = 0
): boolean => {
if (!this.Editor.BlockManager.blocks[index]) {
const block = resolveBlock(blockOrIdOrIndex, this.Editor);
if (block === undefined) {
return false;
}
this.Editor.Caret.setToBlock(this.Editor.BlockManager.blocks[index], position, offset);
this.Editor.Caret.setToBlock(block, position, offset);
return true;
};

View file

@ -249,6 +249,13 @@ export default class BlockEvents extends Module {
return;
}
/**
* The Toolbox will be opened with immediate focus on the Search input,
* and '/' will be added in the search input by default we need to prevent it and add '/' manually
*/
event.preventDefault();
this.Editor.Caret.insertContentAtCaretPosition('/');
this.activateToolbox();
}
@ -279,8 +286,12 @@ export default class BlockEvents extends Module {
/**
* Allow to create line breaks by Shift+Enter
*
* Note. On iOS devices, Safari automatically treats enter after a period+space (". |") as Shift+Enter
* (it used for capitalizing of the first letter of the next sentence)
* We don't need to lead soft line break in this case new block should be created
*/
if (event.shiftKey) {
if (event.shiftKey && !_.isIosDevice) {
return;
}

View file

@ -370,10 +370,10 @@ export default class BlockManager extends Module {
* @param newTool - new Tool name
* @param data - new Tool data
*/
public replace(block: Block, newTool: string, data: BlockToolData): void {
public replace(block: Block, newTool: string, data: BlockToolData): Block {
const blockIndex = this.getBlockIndex(block);
this.insert({
return this.insert({
tool: newTool,
data,
index: blockIndex,
@ -821,7 +821,7 @@ export default class BlockManager extends Module {
* @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> {
public async convert(blockToConvert: Block, targetToolName: string, blockDataOverrides?: BlockToolData): Promise<Block> {
/**
* At first, we get current Block data
*/
@ -866,7 +866,7 @@ export default class BlockManager extends Module {
newBlockData = Object.assign(newBlockData, blockDataOverrides);
}
this.replace(blockToConvert, replacingTool.name, newBlockData);
return this.replace(blockToConvert, replacingTool.name, newBlockData);
}
/**

View file

@ -185,14 +185,14 @@ export default class RectangleSelection extends Module {
this.processMouseDown(mouseEvent);
}, false);
this.listeners.on(document.body, 'mousemove', _.throttle((mouseEvent: MouseEvent) => {
this.listeners.on(window, 'mousemove', _.throttle((mouseEvent: MouseEvent) => {
this.processMouseMove(mouseEvent);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 10), {
passive: true,
});
this.listeners.on(document.body, 'mouseleave', () => {
this.listeners.on(window, 'mouseleave', () => {
this.processMouseLeave();
});
@ -203,7 +203,7 @@ export default class RectangleSelection extends Module {
passive: true,
});
this.listeners.on(document.body, 'mouseup', () => {
this.listeners.on(window, 'mouseup', () => {
this.processMouseUp();
}, false);
}

View file

@ -7,10 +7,13 @@ import { I18nInternalNS } from '../../i18n/namespace-internal';
import Flipper from '../../flipper';
import { TunesMenuConfigItem } from '../../../../types/tools';
import { resolveAliases } from '../../utils/resolve-aliases';
import { type Popover, PopoverDesktop, PopoverMobile } from '../../utils/popover';
import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemParams, PopoverItemDefaultParams } from '../../utils/popover';
import { PopoverEvent } from '../../utils/popover/popover.types';
import { isMobileScreen } from '../../utils';
import { EditorMobileLayoutToggled } from '../../events';
import * as _ from '../../utils';
import { IconReplace } from '@codexteam/icons';
import { isSameBlockData } from '../../utils/blocks';
/**
* HTML Elements that used for BlockSettings
@ -105,7 +108,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*
* @param targetBlock - near which Block we should open BlockSettings
*/
public open(targetBlock: Block = this.Editor.BlockManager.currentBlock): void {
public async open(targetBlock: Block = this.Editor.BlockManager.currentBlock): Promise<void> {
this.opened = true;
/**
@ -120,10 +123,8 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.Editor.BlockSelection.selectBlock(targetBlock);
this.Editor.BlockSelection.clearCache();
/**
* Fill Tool's settings
*/
const [tunesItems, customHtmlTunesContainer] = targetBlock.getTunes();
/** Get tool's settings data */
const { toolTunes, commonTunes, customHtmlTunes } = targetBlock.getTunes();
/** Tell to subscribers that block settings is opened */
this.eventsDispatcher.emit(this.events.opened);
@ -132,9 +133,9 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.popover = new PopoverClass({
searchable: true,
items: tunesItems.map(tune => this.resolveTuneAliases(tune)),
customContent: customHtmlTunesContainer,
customContentFlippableItems: this.getControls(customHtmlTunesContainer),
items: await this.getTunesItems(targetBlock, commonTunes, toolTunes),
customContent: customHtmlTunes,
customContentFlippableItems: this.getControls(customHtmlTunes),
scopeElement: this.Editor.API.methods.ui.nodes.redactor,
messages: {
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
@ -197,6 +198,115 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
}
};
/**
* Returns list of items to be displayed in block tunes menu.
* Merges tool specific tunes, conversion menu and common tunes in one list in predefined order
*
* @param currentBlock block we are about to open block tunes for
* @param commonTunes common tunes
* @param toolTunes - tool specific tunes
*/
private async getTunesItems(currentBlock: Block, commonTunes: TunesMenuConfigItem[], toolTunes?: TunesMenuConfigItem[]): Promise<PopoverItemParams[]> {
const items = [] as TunesMenuConfigItem[];
if (toolTunes !== undefined && toolTunes.length > 0) {
items.push(...toolTunes);
items.push({
type: 'separator',
});
}
const convertToItems = await this.getConvertToItems(currentBlock);
if (convertToItems.length > 0) {
items.push({
icon: IconReplace,
title: I18n.ui(I18nInternalNS.ui.popover, 'Convert to'),
children: {
items: convertToItems,
},
});
items.push({
type: 'separator',
});
}
items.push(...commonTunes);
return items.map(tune => this.resolveTuneAliases(tune));
}
/**
* Returns list of all available conversion menu items
*
* @param currentBlock - block we are about to open block tunes for
*/
private async getConvertToItems(currentBlock: Block): Promise<PopoverItemDefaultParams[]> {
const conversionEntries = Array.from(this.Editor.Tools.blockTools.entries());
const resultItems: PopoverItemDefaultParams[] = [];
const blockData = await currentBlock.data;
conversionEntries.forEach(([toolName, tool]) => {
const conversionConfig = tool.conversionConfig;
/**
* Skip tools without «import» rule specified
*/
if (!conversionConfig || !conversionConfig.import) {
return;
}
tool.toolbox?.forEach((toolboxItem) => {
/**
* Skip tools that don't pass 'toolbox' property
*/
if (_.isEmpty(toolboxItem) || !toolboxItem.icon) {
return;
}
let shouldSkip = false;
if (toolboxItem.data !== undefined) {
/**
* When a tool has several toolbox entries, we need to make sure we do not add
* toolbox item with the same data to the resulting array. This helps exclude duplicates
*/
const hasSameData = isSameBlockData(toolboxItem.data, blockData);
shouldSkip = hasSameData;
} else {
shouldSkip = toolName === currentBlock.name;
}
if (shouldSkip) {
return;
}
resultItems.push({
icon: toolboxItem.icon,
title: toolboxItem.title,
name: toolName,
onActivate: async () => {
const { BlockManager, BlockSelection, Caret } = this.Editor;
const newBlock = await BlockManager.convert(this.Editor.BlockManager.currentBlock, toolName, toolboxItem.data);
BlockSelection.clearSelection();
this.close();
Caret.setToBlock(newBlock, Caret.positions.END);
},
});
});
});
return resultItems;
}
/**
* Handles popover close event
*/
@ -224,7 +334,10 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*
* @param item - item with resolved aliases
*/
private resolveTuneAliases(item: TunesMenuConfigItem): TunesMenuConfigItem {
private resolveTuneAliases(item: TunesMenuConfigItem): PopoverItemParams {
if (item.type === 'separator') {
return item;
}
const result = resolveAliases(item, { label: 'title' });
if (item.confirmation) {

View file

@ -183,16 +183,14 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
const { BlockManager, BlockSelection, InlineToolbar, Caret } = this.Editor;
BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides);
const newBlock = await BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides);
BlockSelection.clearSelection();
this.close();
InlineToolbar.close();
window.requestAnimationFrame(() => {
Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END);
});
Caret.setToBlock(newBlock, Caret.positions.END);
}
/**

View file

@ -427,6 +427,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.nodes.togglerAndButtonsWrapper.appendChild(this.nodes.conversionToggler);
if (import.meta.env.MODE === 'test') {
this.nodes.conversionToggler.setAttribute('data-cy', 'conversion-toggler');
}
this.listeners.on(this.nodes.conversionToggler, 'click', () => {
this.Editor.ConversionToolbar.toggle((conversionToolbarOpened) => {
/**

View file

@ -356,7 +356,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
Shortcuts.add({
name: shortcut,
on: this.api.ui.nodes.redactor,
handler: (event: KeyboardEvent) => {
handler: async (event: KeyboardEvent) => {
event.preventDefault();
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
@ -368,11 +368,9 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
*/
if (currentBlock) {
try {
this.api.blocks.convert(currentBlock.id, toolName);
const newBlock = await this.api.blocks.convert(currentBlock.id, toolName);
window.requestAnimationFrame(() => {
this.api.caret.setToBlock(currentBlockIndex, 'end');
});
this.api.caret.setToBlock(newBlock, 'end');
return;
} catch (error) {}

View file

@ -0,0 +1,21 @@
import type { BlockAPI } from '../../../types/api/block';
import { EditorModules } from '../../types-internal/editor-modules';
import Block from '../block';
/**
* Returns Block instance by passed Block index or Block id
*
* @param attribute - either BlockAPI or Block id or Block index
* @param editor - Editor instance
*/
export function resolveBlock(attribute: BlockAPI | BlockAPI['id'] | number, editor: EditorModules): Block | undefined {
if (typeof attribute === 'number') {
return editor.BlockManager.getBlockByIndex(attribute);
}
if (typeof attribute === 'string') {
return editor.BlockManager.getBlockById(attribute);
}
return editor.BlockManager.getBlockById(attribute.id);
}

View file

@ -1,7 +1,8 @@
import type { ConversionConfig } from '../../../types/configs/conversion-config';
import type { BlockToolData } from '../../../types/tools/block-tool-data';
import type Block from '../block';
import { isFunction, isString, log } from '../utils';
import { isFunction, isString, log, equals } from '../utils';
/**
* Check if block has valid conversion config for export or import.
@ -19,6 +20,18 @@ export function isBlockConvertable(block: Block, direction: 'export' | 'import')
return isFunction(conversionProp) || isString(conversionProp);
}
/**
* Checks that all the properties of the first block data exist in second block data with the same values.
*
* @param data1 first block data
* @param data2 second block data
*/
export function isSameBlockData(data1: BlockToolData, data2: BlockToolData): boolean {
return Object.entries(data1).some((([propName, propValue]) => {
return data2[propName] && equals(data2[propName], propValue);
}));
}
/**
* Check if two blocks could be merged.
*

View file

@ -17,7 +17,7 @@ interface PopoverItemDefaultBaseParams {
/**
* Item type
*/
type: 'default';
type?: 'default';
/**
* Displayed text

View file

@ -30,6 +30,11 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
*/
private history = new PopoverStatesHistory();
/**
* Flag that indicates if popover is hidden
*/
private isHidden = true;
/**
* Construct the instance
*
@ -58,18 +63,26 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
super.show();
this.scrollLocker.lock();
this.isHidden = false;
}
/**
* Closes popover
*/
public hide(): void {
if (this.isHidden) {
return;
}
super.hide();
this.nodes.overlay.classList.add(css.overlayHidden);
this.scrollLocker.unlock();
this.history.reset();
this.isHidden = true;
}
/**

View file

@ -15,7 +15,7 @@ export default class ScrollLocker {
/**
* Stores scroll position, used for hard scroll lock
*/
private scrollPosition: null|number;
private scrollPosition: null | number = null;
/**
* Locks body element scroll

View file

@ -1,5 +1,5 @@
import type EditorJS from '../../../../types/index';
import { ConversionConfig, ToolboxConfig } from '../../../../types';
import type { ConversionConfig, ToolboxConfig } from '../../../../types';
import ToolMock from '../../fixtures/tools/ToolMock';
/**
@ -202,7 +202,7 @@ describe('api.blocks', () => {
});
describe('.convert()', function () {
it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import"', function () {
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 () {
/**
* Mock of Tool with conversionConfig
*/
@ -246,20 +246,28 @@ describe('api.blocks', () => {
existingBlock,
],
},
}).then((editor) => {
}).then(async (editor) => {
const { convert } = editor.blocks;
convert(existingBlock.id, 'convertableTool');
const returnValue = await convert(existingBlock.id, 'convertableTool');
// wait for block to be converted
cy.wait(100).then(() => {
cy.wait(100).then(async () => {
/**
* Check that block was converted
*/
editor.save().then(( { blocks }) => {
expect(blocks.length).to.eq(1);
expect(blocks[0].type).to.eq('convertableTool');
expect(blocks[0].data.text).to.eq(existingBlock.data.text);
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1);
expect(blocks[0].type).to.eq('convertableTool');
expect(blocks[0].data.text).to.eq(existingBlock.data.text);
/**
* Check that returned value is BlockAPI
*/
expect(returnValue).to.containSubset({
name: 'convertableTool',
id: blocks[0].id,
});
});
});
@ -274,9 +282,10 @@ describe('api.blocks', () => {
const fakeId = 'WRNG_ID';
const { convert } = editor.blocks;
const exec = (): void => convert(fakeId, 'convertableTool');
expect(exec).to.throw(`Block with id "${fakeId}" not found`);
return convert(fakeId, 'convertableTool')
.catch((error) => {
expect(error.message).to.be.eq(`Block with id "${fakeId}" not found`);
});
});
});
@ -302,9 +311,10 @@ describe('api.blocks', () => {
const nonexistingToolName = 'WRNG_TOOL_NAME';
const { convert } = editor.blocks;
const exec = (): void => convert(existingBlock.id, nonexistingToolName);
expect(exec).to.throw(`Block Tool with type "${nonexistingToolName}" not found`);
return convert(existingBlock.id, nonexistingToolName)
.catch((error) => {
expect(error.message).to.be.eq(`Block Tool with type "${nonexistingToolName}" not found`);
});
});
});
@ -340,9 +350,10 @@ describe('api.blocks', () => {
*/
const { convert } = editor.blocks;
const exec = (): void => convert(existingBlock.id, 'nonConvertableTool');
expect(exec).to.throw(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`);
return convert(existingBlock.id, 'nonConvertableTool')
.catch((error) => {
expect(error.message).to.be.eq(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`);
});
});
});
});

View file

@ -0,0 +1,113 @@
import EditorJS from '../../../../types';
/**
* Test cases for Caret API
*/
describe('Caret API', () => {
const paragraphDataMock = {
id: 'bwnFX5LoX7',
type: 'paragraph',
data: {
text: 'The first block content mock.',
},
};
describe('.setToBlock()', () => {
/**
* The arrange part of the following tests are the same:
* - create an editor
* - move caret out of the block by default
*/
beforeEach(() => {
cy.createEditor({
data: {
blocks: [
paragraphDataMock,
],
},
}).as('editorInstance');
/**
* Blur caret from the block before setting via api
*/
cy.get('[data-cy=editorjs]')
.click();
});
it('should set caret to a block (and return true) if block index is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const returnedValue = editor.caret.setToBlock(0);
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
expect(returnedValue).to.be.true;
});
});
it('should set caret to a block (and return true) if block id is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const returnedValue = editor.caret.setToBlock(paragraphDataMock.id);
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
expect(returnedValue).to.be.true;
});
});
it('should set caret to a block (and return true) if Block API is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const block = editor.blocks.getById(paragraphDataMock.id);
const returnedValue = editor.caret.setToBlock(block);
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
expect(returnedValue).to.be.true;
});
});
});
});

View file

@ -1,6 +1,6 @@
describe('Slash keydown', function () {
describe('pressed in empty block', function () {
it('should open Toolbox', () => {
it('should add "/" in a block and open Toolbox', () => {
cy.createEditor({
data: {
blocks: [
@ -19,6 +19,14 @@ describe('Slash keydown', function () {
.click()
.type('/');
/**
* Block content should contain slash
*/
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.invoke('text')
.should('eq', '/');
cy.get('[data-cy="toolbox"] .ce-popover__container')
.should('be.visible');
});

View file

@ -1,3 +1,5 @@
import Header from '@editorjs/header';
describe('Inline Toolbar', () => {
it('should appear aligned with left coord of selection rect', () => {
cy.createEditor({
@ -73,4 +75,56 @@ describe('Inline Toolbar', () => {
});
});
});
describe('Conversion toolbar', () => {
it('should restore caret after converting of a block', () => {
cy.createEditor({
tools: {
header: {
class: Header,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Some text',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.selectText('Some text');
cy.get('[data-cy=conversion-toggler]')
.click();
cy.get('[data-cy=editorjs]')
.find('.ce-conversion-tool[data-tool=header]')
.click();
cy.get('[data-cy=editorjs]')
.find('.ce-header')
.should('have.text', 'Some text');
cy.window()
.then((window) => {
const selection = window.getSelection();
expect(selection.rangeCount).to.be.equal(1);
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-header')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
});
});
});

View file

@ -1,4 +1,7 @@
import { selectionChangeDebounceTimeout } from '../../../../src/components/constants';
import Header from '@editorjs/header';
import { ToolboxConfig } from '../../../../types';
describe('BlockTunes', function () {
describe('Search', () => {
@ -104,4 +107,241 @@ describe('BlockTunes', function () {
.should('have.class', 'ce-block--selected');
});
});
describe('Convert to', () => {
it('should display Convert to inside Block Tunes', () => {
cy.createEditor({
tools: {
header: Header,
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Some text',
},
},
],
},
});
/** Open block tunes menu */
cy.get('[data-cy=editorjs]')
.get('.cdx-block')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
/** Check "Convert to" option is present */
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.contains('Convert to')
.should('exist');
/** Click "Convert to" option*/
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.contains('Convert to')
.click();
/** Check nected popover with "Heading" option is present */
cy.get('[data-cy=editorjs]')
.get('.ce-popover--nested [data-item-name=header]')
.should('exist');
});
it('should not display Convert to inside Block Tunes if there is nothing to convert to', () => {
/** Editor instance with single default tool */
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Some text',
},
},
],
},
});
/** Open block tunes menu */
cy.get('[data-cy=editorjs]')
.get('.cdx-block')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
/** Check "Convert to" option is not present */
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.contains('Convert to')
.should('not.exist');
});
it('should not display tool with the same data in "Convert to" menu', () => {
/**
* Tool with several toolbox entries configured
*/
class TestTool {
/**
* Tool is convertable
*/
public static get conversionConfig(): { import: string } {
return {
import: 'text',
};
}
/**
* TestTool contains several toolbox options
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Title 1',
icon: 'Icon1',
data: {
level: 1,
},
},
{
title: 'Title 2',
icon: 'Icon2',
data: {
level: 2,
},
},
];
}
/**
* Tool can render itself
*/
public render(): HTMLDivElement {
const div = document.createElement('div');
div.innerText = 'Some text';
return div;
}
/**
* Tool can save it's data
*/
public save(): { text: string; level: number } {
return {
text: 'Some text',
level: 1,
};
}
}
/** Editor instance with TestTool installed and one block of TestTool type */
cy.createEditor({
tools: {
testTool: TestTool,
},
data: {
blocks: [
{
type: 'testTool',
data: {
text: 'Some text',
level: 1,
},
},
],
},
});
/** Open block tunes menu */
cy.get('[data-cy=editorjs]')
.get('.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
/** Open "Convert to" menu */
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.contains('Convert to')
.click();
/** Check TestTool option with SAME data is NOT present */
cy.get('[data-cy=editorjs]')
.get('.ce-popover--nested [data-item-name=testTool]')
.contains('Title 1')
.should('not.exist');
/** Check TestTool option with DIFFERENT data IS present */
cy.get('[data-cy=editorjs]')
.get('.ce-popover--nested [data-item-name=testTool]')
.contains('Title 2')
.should('exist');
});
it('should convert block to another type and set caret to the new block', () => {
cy.createEditor({
tools: {
header: Header,
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Some text',
},
},
],
},
});
/** Open block tunes menu */
cy.get('[data-cy=editorjs]')
.get('.cdx-block')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
/** Click "Convert to" option*/
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.contains('Convert to')
.click();
/** Click "Heading" option */
cy.get('[data-cy=editorjs]')
.get('.ce-popover--nested [data-item-name=header]')
.click();
/** Check the block was converted to the second option */
cy.get('[data-cy=editorjs]')
.get('.ce-header')
.should('have.text', 'Some text');
/** Check that caret set to the end of the new block */
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-header')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
});
});
});

View file

@ -4,7 +4,7 @@ import ToolMock from '../../fixtures/tools/ToolMock';
describe('Toolbox', function () {
describe('Shortcuts', function () {
it('should covert current Block to the Shortcuts\'s Block if both tools provides a "conversionConfig" ', function () {
it('should convert current Block to the Shortcuts\'s Block if both tools provides a "conversionConfig". Caret should be restored after conversion.', function () {
/**
* Mock of Tool with conversionConfig
*/
@ -54,6 +54,21 @@ describe('Toolbox', function () {
expect(blocks.length).to.eq(1);
expect(blocks[0].type).to.eq('convertableTool');
expect(blocks[0].data.text).to.eq('Some text');
/**
* Check that caret belongs to the new block after conversion
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find(`.ce-block[data-id=${blocks[0].id}]`)
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
});
});

View file

@ -1,4 +1,4 @@
import { PopoverItem } from '../../../../types/index.js';
import { PopoverItemParams } from '../../../../types/index.js';
/**
* Mock of some Block Tool
@ -26,7 +26,7 @@ class SomePlugin {
/**
* Used to display our tool in the Toolbox
*/
public static get toolbox(): PopoverItem {
public static get toolbox(): PopoverItemParams {
return {
icon: '₷',
title: 'Some tool',
@ -34,6 +34,15 @@ class SomePlugin {
onActivate: (): void => {},
};
}
/**
* Extracts data from the plugin's UI
*/
public save(): {data: string} {
return {
data: '123',
};
}
}
describe('Flipper', () => {
@ -71,15 +80,16 @@ describe('Flipper', () => {
cy.get('[data-cy=editorjs]')
.get('.cdx-some-plugin')
// Open tunes menu
.trigger('keydown', { code: 'Slash', ctrlKey: true })
.trigger('keydown', { code: 'Slash',
ctrlKey: true })
// Navigate to delete button (the second button)
.trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE })
.trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE });
/**
* Check whether we focus the Delete Tune or not
* Check whether we focus the Move Up Tune or not
*/
cy.get('[data-item-name="delete"]')
cy.get('[data-item-name="move-up"]')
.should('have.class', 'ce-popover-item--focused');
cy.get('[data-cy=editorjs]')

View file

@ -16,7 +16,6 @@ describe('Popover', () => {
* (Inside popover null value is set to confirmation property, so, object becomes unavailable otherwise)
*/
const confirmation: PopoverItemParams = {
type: 'default',
icon: confirmActionIcon,
title: confirmActionTitle,
onActivate: cy.stub(),
@ -24,7 +23,6 @@ describe('Popover', () => {
const items: PopoverItemParams[] = [
{
type: 'default',
icon: actionIcon,
title: actionTitle,
name: 'testItem',
@ -73,7 +71,6 @@ describe('Popover', () => {
it('should render the items with true isActive property value as active', () => {
const items = [
{
type: 'default',
icon: 'Icon',
title: 'Title',
isActive: true,
@ -98,7 +95,6 @@ describe('Popover', () => {
it('should not execute item\'s onActivate callback if the item is disabled', () => {
const items: PopoverItemParams[] = [
{
type: 'default',
icon: 'Icon',
title: 'Title',
isDisabled: true,
@ -131,7 +127,6 @@ describe('Popover', () => {
it('should close once item with closeOnActivate property set to true is activated', () => {
const items = [
{
type: 'default',
icon: 'Icon',
title: 'Title',
closeOnActivate: true,
@ -159,7 +154,6 @@ describe('Popover', () => {
it('should highlight as active the item with toggle property set to true once activated', () => {
const items = [
{
type: 'default',
icon: 'Icon',
title: 'Title',
toggle: true,
@ -184,7 +178,6 @@ describe('Popover', () => {
it('should perform radiobutton-like behavior among the items that have toggle property value set to the same string value', () => {
const items = [
{
type: 'default',
icon: 'Icon 1',
title: 'Title 1',
toggle: 'group-name',
@ -193,7 +186,6 @@ describe('Popover', () => {
onActivate: (): void => {},
},
{
type: 'default',
icon: 'Icon 2',
title: 'Title 2',
toggle: 'group-name',
@ -231,7 +223,6 @@ describe('Popover', () => {
it('should toggle item if it is the only item in toggle group', () => {
const items = [
{
type: 'default',
icon: 'Icon',
title: 'Title',
toggle: 'key',
@ -279,7 +270,6 @@ describe('Popover', () => {
/** Tool data displayed in block tunes popover */
public render(): TunesMenuConfig {
return {
type: 'default',
icon: 'Icon',
title: 'Title',
toggle: 'key',
@ -287,7 +277,6 @@ describe('Popover', () => {
children: {
items: [
{
type: 'default',
icon: 'Icon',
title: 'Title',
name: 'nested-test-item',
@ -357,7 +346,6 @@ describe('Popover', () => {
/** Tool data displayed in block tunes popover */
public render(): TunesMenuConfig {
return {
type: 'default',
icon: 'Icon',
title: 'Tune',
toggle: 'key',
@ -365,7 +353,6 @@ describe('Popover', () => {
children: {
items: [
{
type: 'default',
icon: 'Icon',
title: 'Title',
name: 'nested-test-item',
@ -521,7 +508,6 @@ describe('Popover', () => {
public render(): TunesMenuConfig {
return [
{
type: 'default',
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune',
@ -585,7 +571,6 @@ describe('Popover', () => {
public render(): TunesMenuConfig {
return [
{
type: 'default',
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune 1',
@ -595,7 +580,6 @@ describe('Popover', () => {
type: 'separator',
},
{
type: 'default',
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune 2',
@ -633,7 +617,8 @@ describe('Popover', () => {
.click();
/** Press Tab */
cy.tab();
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check first item is focused */
cy.get('[data-cy=editorjs]')
@ -648,7 +633,8 @@ describe('Popover', () => {
.should('not.exist');
/** Press Tab */
cy.tab();
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check first item is not focused */
cy.get('[data-cy=editorjs]')
@ -672,7 +658,6 @@ describe('Popover', () => {
public render(): TunesMenuConfig {
return [
{
type: 'default',
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune 1',
@ -682,7 +667,6 @@ describe('Popover', () => {
type: 'separator',
},
{
type: 'default',
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune 2',

View file

@ -147,5 +147,5 @@ export interface Blocks {
*
* @throws Error if conversion is not possible
*/
convert(id: string, newType: string, dataOverrides?: BlockToolData): void;
convert(id: string, newType: string, dataOverrides?: BlockToolData): Promise<BlockAPI>;
}

10
types/api/caret.d.ts vendored
View file

@ -1,3 +1,5 @@
import { BlockAPI } from "./block";
/**
* Describes Editor`s caret API
*/
@ -46,13 +48,13 @@ export interface Caret {
/**
* Sets caret to the Block by passed index
*
* @param {number} index - index of Block where to set caret
* @param {string} position - position where to set caret
* @param {number} offset - caret offset
* @param blockOrIdOrIndex - BlockAPI or Block id or Block index
* @param position - position where to set caret
* @param offset - caret offset
*
* @return {boolean}
*/
setToBlock(index: number, position?: 'end'|'start'|'default', offset?: number): boolean;
setToBlock(blockOrIdOrIndex: BlockAPI | BlockAPI['id'] | number, position?: 'end'|'start'|'default', offset?: number): boolean;
/**
* Sets caret to the Editor