Compare commits

...

8 commits

Author SHA1 Message Date
Peter Savchenko 29d68ecb47
fix(block-events): caret losing after backspace after nested list (#2723)
* feat: Fix caret loss after Backspace at the start of block when previous block is not convertible

* fix create shadow caret

* fix: remove unnecessary blank line in blockEvents.ts

* fix: pass event object to slashPressed method in blockEvents.ts

* fix eslint
2024-05-23 20:06:33 +03:00
Tatiana Fomina d18eeb5dc8
feat(popover): Add hint support (#2711)
* Add custom item

* Remove customcontent parameter from popover

* Tests

* Cleanup

* Cleanup

* Lint

* Cleanup

* Rename custom to html, add enum with item types

* Fix tests

* Support hint

* Rename hint content to hint

* Align hint left

* Move types and exports

* Update changelog

* Cleanup

* Add todos

* Change the way hint is disabled for mobile

* Get rid of buildItems override

* Update comment
2024-05-16 15:26:25 +03:00
Tatiana Fomina 50f43bb35d
Change cypress preprocessor (#2712) 2024-05-04 21:23:36 +03:00
Tatiana Fomina f78972ee09
feat(popover): custom content becomes a popover item (#2707)
* Add custom item

* Remove customcontent parameter from popover

* Tests

* Cleanup

* Cleanup

* Lint

* Cleanup

* Rename custom to html, add enum with item types

* Fix tests

* Add order test

* Update jsdoc

* Update changelog

* Fix issue with html item not hiding on search

* Fix flipper issue

* Update changelog
2024-05-04 15:35:36 +00: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
40 changed files with 1079 additions and 251 deletions

View file

@ -12,6 +12,8 @@ export default defineConfig({
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
on('file:preprocessor', require('cypress-vite')(config));
/**
* Plugin for cypress that adds better terminal output for easier debugging.
* Prints cy commands, browser console logs, cy.request and cy.intercept data. Great for your pipelines.

View file

@ -2,17 +2,23 @@
### 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
- `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 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.
- `Fix` - Caret lost after Backspace at the start of block when previoius block is not convertable
- `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
- `New` *Menu Config* New item type HTML
`Refactoring` Switched to Vite as Cypress bundler
`New` *Menu Config* Default and HTML items now support hints
### 2.29.1

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.30.0-rc.7",
"version": "2.30.0-rc.10",
"description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editorjs.umd.js",
"module": "dist/editorjs.mjs",
@ -56,6 +56,7 @@
"cypress-intellij-reporter": "^0.0.7",
"cypress-plugin-tab": "^1.0.5",
"cypress-terminal-report": "^5.3.2",
"cypress-vite": "^1.5.0",
"eslint": "^8.37.0",
"eslint-config-codex": "^1.7.1",
"eslint-plugin-chai-friendly": "^0.7.2",

View file

@ -21,11 +21,12 @@ 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 { TunesMenuConfig, TunesMenuConfigItem } from '../../../types/tools';
import { TunesMenuConfigItem } from '../../../types/tools';
import { isMutationBelongsToElement } from '../utils/mutations';
import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
import { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
import { convertBlockDataToString, isSameBlockData } from '../utils/blocks';
import { PopoverItemType } from '../utils/popover';
/**
* Interface describes Block class constructor argument
@ -610,29 +611,28 @@ export default class Block extends EventsDispatcher<BlockEvents> {
}
/**
* Returns data to render in tunes menu.
* 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
* Returns data to render in Block Tunes menu.
* Splits block tunes into 2 groups: block specific tunes and common tunes
*/
public getTunes(): {
toolTunes: PopoverItemParams[];
commonTunes: PopoverItemParams[];
customHtmlTunes: HTMLElement
} {
const customHtmlTunesContainer = document.createElement('div');
const toolTunesPopoverParams: 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);
if ($.isElement(tunesDefinedInTool)) {
toolTunesPopoverParams.push({
type: PopoverItemType.Html,
element: tunesDefinedInTool,
});
} else if (Array.isArray(tunesDefinedInTool)) {
toolTunesPopoverParams.push(...tunesDefinedInTool);
} else {
toolTunesPopoverParams.push(tunesDefinedInTool);
}
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
@ -643,28 +643,24 @@ export default class Block extends EventsDispatcher<BlockEvents> {
/** 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);
if ($.isElement(tuneConfig)) {
commonTunesPopoverParams.push({
type: PopoverItemType.Html,
element: tuneConfig,
});
} else if (Array.isArray(tuneConfig)) {
commonTunesPopoverParams.push(...tuneConfig);
} else {
commonTunesPopoverParams.push(tuneConfig);
}
});
return {
toolTunes: toolTunesPopoverParams,
commonTunes: commonTunesPopoverParams,
customHtmlTunes: customHtmlTunesContainer,
};
}
/**
* Update current input index with selection anchor node
*/
@ -750,25 +746,6 @@ 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

@ -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

@ -60,7 +60,7 @@ export default class BlockEvents extends Module {
* @todo probably using "beforeInput" event would be better here
*/
if (event.key === '/' && !event.ctrlKey && !event.metaKey) {
this.slashPressed();
this.slashPressed(event);
}
/**
@ -233,8 +233,10 @@ export default class BlockEvents extends Module {
/**
* '/' keydown inside a Block
*
* @param event - keydown
*/
private slashPressed(): void {
private slashPressed(event: KeyboardEvent): void {
const currentBlock = this.Editor.BlockManager.currentBlock;
const canOpenToolbox = currentBlock.isEmpty;
@ -249,6 +251,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();
}
@ -388,7 +397,7 @@ export default class BlockEvents extends Module {
return;
}
const bothBlocksMergeable = areBlocksMergeable(currentBlock, previousBlock);
const bothBlocksMergeable = areBlocksMergeable(previousBlock, currentBlock);
/**
* If Blocks could be merged, do it
@ -492,7 +501,7 @@ export default class BlockEvents extends Module {
private mergeBlocks(targetBlock: Block, blockToMerge: Block): void {
const { BlockManager, Caret, Toolbar } = this.Editor;
Caret.createShadow(targetBlock.pluginsContent);
Caret.createShadow(targetBlock.lastInput);
BlockManager
.mergeBlocks(targetBlock, blockToMerge)

View file

@ -7,7 +7,7 @@ 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, PopoverItemParams, PopoverItemDefaultParams } from '../../utils/popover';
import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemParams, PopoverItemDefaultParams, PopoverItemType } from '../../utils/popover';
import { PopoverEvent } from '../../utils/popover/popover.types';
import { isMobileScreen } from '../../utils';
import { EditorMobileLayoutToggled } from '../../events';
@ -124,7 +124,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.Editor.BlockSelection.clearCache();
/** Get tool's settings data */
const { toolTunes, commonTunes, customHtmlTunes } = targetBlock.getTunes();
const { toolTunes, commonTunes } = targetBlock.getTunes();
/** Tell to subscribers that block settings is opened */
this.eventsDispatcher.emit(this.events.opened);
@ -134,8 +134,6 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.popover = new PopoverClass({
searchable: true,
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'),
@ -212,7 +210,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
if (toolTunes !== undefined && toolTunes.length > 0) {
items.push(...toolTunes);
items.push({
type: 'separator',
type: PopoverItemType.Separator,
});
}
@ -227,7 +225,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
},
});
items.push({
type: 'separator',
type: PopoverItemType.Separator,
});
}
@ -314,28 +312,13 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.close();
};
/**
* Returns list of buttons and inputs inside specified container
*
* @param container - container to query controls inside of
*/
private getControls(container: HTMLElement): HTMLElement[] {
const { StylesAPI } = this.Editor;
/** Query buttons and inputs inside tunes html */
const controls = container.querySelectorAll<HTMLElement>(
`.${StylesAPI.classes.settingsButton}, ${$.allInputsSelector}`
);
return Array.from(controls);
}
/**
* Resolves aliases in tunes menu items
*
* @param item - item with resolved aliases
*/
private resolveTuneAliases(item: TunesMenuConfigItem): PopoverItemParams {
if (item.type === 'separator') {
if (item.type === PopoverItemType.Separator || item.type === PopoverItemType.Html) {
return item;
}
const result = resolveAliases(item, { label: 'title' });

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

@ -3,7 +3,7 @@ import { BlockToolAPI } from '../block';
import Shortcuts from '../utils/shortcuts';
import BlockTool from '../tools/block';
import ToolsCollection from '../tools/collection';
import { API, BlockToolData, ToolboxConfigEntry, PopoverItem, BlockAPI } from '../../../types';
import { API, BlockToolData, ToolboxConfigEntry, PopoverItemParams, BlockAPI } from '../../../types';
import EventsDispatcher from '../utils/events';
import I18n from '../i18n';
import { I18nInternalNS } from '../i18n/namespace-internal';
@ -303,11 +303,11 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
* Returns list of items that will be displayed in toolbox
*/
@_.cacheable
private get toolboxItemsToBeDisplayed(): PopoverItem[] {
private get toolboxItemsToBeDisplayed(): PopoverItemParams[] {
/**
* Maps tool data to popover item structure
*/
const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockTool): PopoverItem => {
const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockTool): PopoverItemParams => {
return {
icon: toolboxItem.icon,
title: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)),
@ -320,7 +320,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
};
return this.toolsToBeDisplayed
.reduce<PopoverItem[]>((result, tool) => {
.reduce<PopoverItemParams[]>((result, tool) => {
if (Array.isArray(tool.toolbox)) {
tool.toolbox.forEach(item => {
result.push(toPopoverItem(item, tool));
@ -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

@ -0,0 +1,16 @@
import { bem } from '../../../bem';
/**
* Hint block CSS class constructor
*/
const className = bem('ce-hint');
/**
* CSS class names to be used in hint class
*/
export const css = {
root: className(),
alignedLeft: className(null, 'align-left'),
title: className('title'),
description: className('description'),
};

View file

@ -0,0 +1,10 @@
.ce-hint {
&--align-left {
text-align: left;
}
&__description {
opacity: 0.6;
margin-top: 3px;
}
}

View file

@ -0,0 +1,46 @@
import Dom from '../../../../dom';
import { css } from './hint.const';
import { HintParams } from './hint.types';
import './hint.css';
/**
* Represents the hint content component
*/
export class Hint {
/**
* Html element used to display hint content on screen
*/
private nodes: {
root: HTMLElement;
title: HTMLElement;
description?: HTMLElement;
};
/**
* Constructs the hint content instance
*
* @param params - hint content parameters
*/
constructor(params: HintParams) {
this.nodes = {
root: Dom.make('div', [css.root, css.alignedLeft]),
title: Dom.make('div', css.title, { textContent: params.title }),
};
this.nodes.root.appendChild(this.nodes.title);
if (params.description !== undefined) {
this.nodes.description = Dom.make('div', css.description, { textContent: params.description });
this.nodes.root.appendChild(this.nodes.description);
}
}
/**
* Returns the root element of the hint content
*/
public getElement(): HTMLElement {
return this.nodes.root;
}
}

View file

@ -0,0 +1,19 @@
/**
* Hint parameters
*/
export interface HintParams {
/**
* Title of the hint
*/
title: string;
/**
* Secondary text to be displayed below the title
*/
description?: string;
}
/**
* Possible hint positions
*/
export type HintPosition = 'top' | 'bottom' | 'left' | 'right';

View file

@ -0,0 +1,2 @@
export * from './hint';
export * from './hint.types';

View file

@ -2,7 +2,9 @@ import Dom from '../../../../../dom';
import { IconDotCircle, IconChevronRight } from '@codexteam/icons';
import {
PopoverItemDefaultParams as PopoverItemDefaultParams,
PopoverItemParams as PopoverItemParams
PopoverItemParams as PopoverItemParams,
PopoverItemRenderParamsMap,
PopoverItemType
} from '../popover-item.types';
import { PopoverItem } from '../popover-item';
import { css } from './popover-item-default.const';
@ -11,8 +13,9 @@ import { css } from './popover-item-default.const';
* Represents sigle popover item node
*
* @todo move nodes initialization to constructor
* @todo replace multiple make() usages with constructing separate instaces
* @todo replace multiple make() usages with constructing separate instances
* @todo split regular popover item and popover item with confirmation to separate classes
* @todo display icon on the right side of the item for rtl languages
*/
export class PopoverItemDefault extends PopoverItem {
/**
@ -72,10 +75,6 @@ export class PopoverItemDefault extends PopoverItem {
icon: null,
};
/**
* Popover item params
*/
private params: PopoverItemDefaultParams;
/**
* If item is in confirmation state, stores confirmation params such as icon, label, onActivate callback and so on
@ -86,12 +85,13 @@ export class PopoverItemDefault extends PopoverItem {
* Constructs popover item instance
*
* @param params - popover item construction params
* @param renderParams - popover item render params.
* The parameters that are not set by user via popover api but rather depend on technical implementation
*/
constructor(params: PopoverItemDefaultParams) {
constructor(private readonly params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]) {
super();
this.params = params;
this.nodes.root = this.make(params);
this.nodes.root = this.make(params, renderParams);
}
/**
@ -159,8 +159,9 @@ export class PopoverItemDefault extends PopoverItem {
* Constructs HTML element corresponding to popover item params
*
* @param params - item construction params
* @param renderParams - popover item render params
*/
private make(params: PopoverItemDefaultParams): HTMLElement {
private make(params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]): HTMLElement {
const el = Dom.make('div', css.container);
if (params.name) {
@ -197,6 +198,13 @@ export class PopoverItemDefault extends PopoverItem {
el.classList.add(css.disabled);
}
if (params.hint !== undefined && renderParams?.hint?.enabled !== false) {
this.addHint(el, {
...params.hint,
position: renderParams?.hint?.position || 'right',
});
}
return el;
}

View file

@ -0,0 +1,14 @@
import { bem } from '../../../../bem';
/**
* Popover item block CSS class constructor
*/
const className = bem('ce-popover-item-html');
/**
* CSS class names to be used in popover item class
*/
export const css = {
root: className(),
hidden: className(null, 'hidden'),
};

View file

@ -0,0 +1,66 @@
import { PopoverItem } from '../popover-item';
import { PopoverItemHtmlParams, PopoverItemRenderParamsMap, PopoverItemType } from '../popover-item.types';
import { css } from './popover-item-html.const';
import Dom from '../../../../../dom';
/**
* Represents popover item with custom html content
*/
export class PopoverItemHtml extends PopoverItem {
/**
* Item html elements
*/
private nodes: { root: HTMLElement };
/**
* Constructs the instance
*
* @param params instance parameters
* @param renderParams popover item render params.
* The parameters that are not set by user via popover api but rather depend on technical implementation
*/
constructor(params: PopoverItemHtmlParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Html]) {
super();
this.nodes = {
root: Dom.make('div', css.root),
};
this.nodes.root.appendChild(params.element);
if (params.hint !== undefined && renderParams?.hint?.enabled !== false) {
this.addHint(this.nodes.root, {
...params.hint,
position: renderParams?.hint?.position || 'right',
});
}
}
/**
* Returns popover item root element
*/
public getElement(): HTMLElement {
return this.nodes.root;
}
/**
* Toggles item hidden state
*
* @param isHidden - true if item should be hidden
*/
public toggleHidden(isHidden: boolean): void {
this.nodes.root?.classList.toggle(css.hidden, isHidden);
}
/**
* Returns list of buttons and inputs inside custom content
*/
public getControls(): HTMLElement[] {
/** Query buttons and inputs inside custom html */
const controls = this.nodes.root.querySelectorAll<HTMLElement>(
`button, ${Dom.allInputsSelector}`
);
return Array.from(controls);
}
}

View file

@ -1,7 +1,25 @@
import * as tooltip from '../../../../utils/tooltip';
import { type HintPosition, Hint } from '../hint';
/**
* Popover item abstract class
*/
export abstract class PopoverItem {
/**
* Adds hint to the item element if hint data is provided
*
* @param itemElement - popover item root element to add hint to
* @param hintData - hint data
*/
protected addHint(itemElement: HTMLElement, hintData: { title: string, description?: string; position: HintPosition }): void {
const content = new Hint(hintData);
tooltip.onHover(itemElement, content.getElement(), {
placement: hintData.position,
hidingDelay: 100,
});
}
/**
* Returns popover item root element
*/

View file

@ -1,3 +1,18 @@
import { HintParams, HintPosition } from '../hint';
/**
* Popover item types
*/
export enum PopoverItemType {
/** Regular item with icon, title and other properties */
Default = 'default',
/** Gray line used to separate items from each other */
Separator = 'separator',
/** Item with custom html content */
Html = 'html'
}
/**
* Represents popover item separator.
@ -7,7 +22,27 @@ export interface PopoverItemSeparatorParams {
/**
* Item type
*/
type: 'separator'
type: PopoverItemType.Separator
}
/**
* Represents popover item with custom html content
*/
export interface PopoverItemHtmlParams {
/**
* Item type
*/
type: PopoverItemType.Html;
/**
* Custom html content to be displayed in the popover
*/
element: HTMLElement;
/**
* Hint data to be displayed on item hover
*/
hint?: HintParams;
}
/**
@ -17,7 +52,7 @@ interface PopoverItemDefaultBaseParams {
/**
* Item type
*/
type?: 'default';
type?: PopoverItemType.Default;
/**
* Displayed text
@ -61,6 +96,11 @@ interface PopoverItemDefaultBaseParams {
* In case of string, works like radio buttons group and highlights as inactive any other item that has same toggle key value.
*/
toggle?: boolean | string;
/**
* Hint data to be displayed on item hover
*/
hint?: HintParams;
}
/**
@ -89,7 +129,6 @@ export interface PopoverItemWithoutConfirmationParams extends PopoverItemDefault
* @param event - event that initiated item activation
*/
onActivate: (item: PopoverItemParams, event?: PointerEvent) => void;
}
@ -119,5 +158,33 @@ export type PopoverItemDefaultParams =
/**
* Represents single popover item
*/
export type PopoverItemParams = PopoverItemDefaultParams | PopoverItemSeparatorParams;
export type PopoverItemParams =
PopoverItemDefaultParams |
PopoverItemSeparatorParams |
PopoverItemHtmlParams;
/**
* Popover item render params.
* The parameters that are not set by user via popover api but rather depend on technical implementation
*/
export type PopoverItemRenderParamsMap = {
[key in PopoverItemType.Default | PopoverItemType.Html]?: {
/**
* Hint render params
*/
hint?: {
/**
* Hint position relative to the item
*/
position?: HintPosition;
/**
* If false, hint will not be rendered.
* True by default.
* Used to disable hints on mobile popover
*/
enabled: boolean;
}
};
};

View file

@ -1,4 +1,4 @@
import { PopoverItem, PopoverItemDefault, PopoverItemSeparator } from './components/popover-item';
import { PopoverItem, PopoverItemDefault, PopoverItemRenderParamsMap, PopoverItemSeparator, PopoverItemType } from './components/popover-item';
import Dom from '../../dom';
import { SearchInput, SearchInputEvent, SearchableItem } from './components/search-input';
import EventsDispatcher from '../events';
@ -6,6 +6,7 @@ import Listeners from '../listeners';
import { PopoverEventMap, PopoverMessages, PopoverParams, PopoverEvent, PopoverNodes } from './popover.types';
import { css } from './popover.const';
import { PopoverItemParams } from './components/popover-item';
import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html';
/**
* Class responsible for rendering popover and handling its behaviour
@ -27,10 +28,9 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
protected nodes: Nodes;
/**
* List of usual interactive popover items that can be clicked, hovered, etc.
* (excluding separators)
* List of default popover items that are searchable and may have confirmation state
*/
protected get itemsInteractive(): PopoverItemDefault[] {
protected get itemsDefault(): PopoverItemDefault[] {
return this.items.filter(item => item instanceof PopoverItemDefault) as PopoverItemDefault[];
}
@ -39,6 +39,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
*/
protected search: SearchInput | undefined;
/**
* Messages that will be displayed in popover
*/
@ -51,8 +52,13 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
* Constructs the instance
*
* @param params - popover construction params
* @param itemsRenderParams - popover item render params.
* The parameters that are not set by user via popover api but rather depend on technical implementation
*/
constructor(protected readonly params: PopoverParams) {
constructor(
protected readonly params: PopoverParams,
protected readonly itemsRenderParams: PopoverItemRenderParamsMap = {}
) {
super();
this.items = this.buildItems(params.items);
@ -97,10 +103,6 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
this.nodes.popover.appendChild(this.nodes.popoverContainer);
if (params.customContent) {
this.addCustomContent(params.customContent);
}
if (params.searchable) {
this.addSearch();
}
@ -131,7 +133,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
this.nodes.popover.classList.remove(css.popoverOpened);
this.nodes.popover.classList.remove(css.popoverOpenTop);
this.itemsInteractive.forEach(item => item.reset());
this.itemsDefault.forEach(item => item.reset());
if (this.search !== undefined) {
this.search.clear();
@ -155,10 +157,12 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
protected buildItems(items: PopoverItemParams[]): Array<PopoverItem> {
return items.map(item => {
switch (item.type) {
case 'separator':
case PopoverItemType.Separator:
return new PopoverItemSeparator();
case PopoverItemType.Html:
return new PopoverItemHtml(item, this.itemsRenderParams[PopoverItemType.Html]);
default:
return new PopoverItemDefault(item);
return new PopoverItemDefault(item, this.itemsRenderParams[PopoverItemType.Default]);
}
});
}
@ -169,7 +173,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
* @param event - event to retrieve popover item from
*/
protected getTargetItem(event: Event): PopoverItemDefault | undefined {
return this.itemsInteractive.find(el => {
return this.itemsDefault.find(el => {
const itemEl = el.getElement();
if (itemEl === null) {
@ -197,14 +201,13 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
if (item instanceof PopoverItemDefault) {
isHidden = !data.items.includes(item);
} else if (item instanceof PopoverItemSeparator) {
} else if (item instanceof PopoverItemSeparator || item instanceof PopoverItemHtml) {
/** Should hide separators if nothing found message displayed or if there is some search query applied */
isHidden = isNothingFound || !isEmptyQuery;
}
item.toggleHidden(isHidden);
});
this.toggleNothingFoundMessage(isNothingFound);
this.toggleCustomContent(isEmptyQuery);
};
/**
@ -212,7 +215,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
*/
private addSearch(): void {
this.search = new SearchInput({
items: this.itemsInteractive,
items: this.itemsDefault,
placeholder: this.messages.search,
});
@ -225,17 +228,6 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
this.nodes.popoverContainer.insertBefore(searchElement, this.nodes.popoverContainer.firstChild);
}
/**
* Adds custom html content to the popover
*
* @param content - html content to append
*/
private addCustomContent(content: HTMLElement): void {
this.nodes.customContent = content;
this.nodes.customContent.classList.add(css.customContent);
this.nodes.popoverContainer.insertBefore(content, this.nodes.popoverContainer.firstChild);
}
/**
* Handles clicks inside popover
*
@ -259,7 +251,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
}
/** Cleanup other items state */
this.itemsInteractive.filter(x => x !== item).forEach(x => x.reset());
this.itemsDefault.filter(x => x !== item).forEach(x => x.reset());
item.handleClick();
@ -279,15 +271,6 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
this.nodes.nothingFoundMessage.classList.toggle(css.nothingFoundMessageDisplayed, isDisplayed);
}
/**
* Toggles custom content visibility
*
* @param isDisplayed - true if custom content should be displayed
*/
private toggleCustomContent(isDisplayed: boolean): void {
this.nodes.customContent?.classList.toggle(css.customContentHidden, isDisplayed);
}
/**
* - Toggles item active state, if clicked popover item has property 'toggle' set to true.
*
@ -302,7 +285,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
}
if (typeof clickedItem.toggle === 'string') {
const itemsInToggleGroup = this.itemsInteractive.filter(item => item.toggle === clickedItem.toggle);
const itemsInToggleGroup = this.itemsDefault.filter(item => item.toggle === clickedItem.toggle);
/** If there's only one item in toggle group, toggle it */
if (itemsInToggleGroup.length === 1) {

View file

@ -7,10 +7,13 @@ import { css } from './popover.const';
import { SearchInputEvent, SearchableItem } from './components/search-input';
import { cacheable } from '../../utils';
import { PopoverItemDefault } from './components/popover-item';
import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html';
/**
* Desktop popover.
* On desktop devices popover behaves like a floating element. Nested popover appears at right or left side.
*
* @todo support rtl for nested popovers and search
*/
export class PopoverDesktop extends PopoverAbstract {
/**
@ -18,11 +21,6 @@ export class PopoverDesktop extends PopoverAbstract {
*/
public flipper: Flipper;
/**
* List of html elements inside custom content area that should be available for keyboard navigation
*/
private customContentFlippableItems: HTMLElement[] | undefined;
/**
* Reference to nested popover if exists.
* Undefined by default, PopoverDesktop when exists and null after destroyed.
@ -63,10 +61,6 @@ export class PopoverDesktop extends PopoverAbstract {
this.nodes.popover.classList.add(css.popoverNested);
}
if (params.customContentFlippableItems) {
this.customContentFlippableItems = params.customContentFlippableItems;
}
if (params.scopeElement !== undefined) {
this.scopeElement = params.scopeElement;
}
@ -148,10 +142,10 @@ export class PopoverDesktop extends PopoverAbstract {
public hide(): void {
super.hide();
this.flipper.deactivate();
this.destroyNestedPopoverIfExists();
this.flipper.deactivate();
this.previouslyHoveredItem = null;
}
@ -283,23 +277,28 @@ export class PopoverDesktop extends PopoverAbstract {
/**
* Returns list of elements available for keyboard navigation.
* Contains both usual popover items elements and custom html content.
*/
private get flippableElements(): HTMLElement[] {
const popoverItemsElements = this.itemsInteractive.map(item => item.getElement());
const customContentControlsElements = this.customContentFlippableItems || [];
const result = this.items
.map(item => {
if (item instanceof PopoverItemDefault) {
return item.getElement();
}
if (item instanceof PopoverItemHtml) {
return item.getControls();
}
})
.flat()
.filter(item => item !== undefined && item !== null);
/**
* Combine elements inside custom content area with popover items elements
*/
return customContentControlsElements.concat(popoverItemsElements as HTMLElement[]);
return result as HTMLElement[];
}
/**
* Called on flipper navigation
*/
private onFlip = (): void => {
const focusedItem = this.itemsInteractive.find(item => item.isFocused);
const focusedItem = this.itemsDefault.find(item => item.isFocused);
focusedItem?.onFocus();
};

View file

@ -3,10 +3,11 @@ import ScrollLocker from '../scroll-locker';
import { PopoverHeader } from './components/popover-header';
import { PopoverStatesHistory } from './utils/popover-states-history';
import { PopoverMobileNodes, PopoverParams } from './popover.types';
import { PopoverItemDefault, PopoverItemParams } from './components/popover-item';
import { PopoverItemDefault, PopoverItemParams, PopoverItemType } from './components/popover-item';
import { css } from './popover.const';
import Dom from '../../dom';
/**
* Mobile Popover.
* On mobile devices Popover behaves like a fixed panel at the bottom of screen. Nested item appears like "pages" with the "back" button
@ -41,7 +42,13 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
* @param params - popover params
*/
constructor(params: PopoverParams) {
super(params);
super(params, {
[PopoverItemType.Default]: {
hint: {
enabled: false,
},
},
});
this.nodes.overlay = Dom.make('div', [css.overlay, css.overlayHidden]);
this.nodes.popover.insertBefore(this.nodes.overlay, this.nodes.popover.firstChild);
@ -112,8 +119,8 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
/**
* Removes rendered popover items and header and displays new ones
*
* @param title - new popover header text
* @param items - new popover items
* @param title - new popover header text
*/
private updateItemsAndHeader(items: PopoverItemParams[], title?: string ): void {
/** Re-render header */

View file

@ -17,8 +17,6 @@ export const css = {
search: className('search'),
nothingFoundMessage: className('nothing-found-message'),
nothingFoundMessageDisplayed: className('nothing-found-message', 'displayed'),
customContent: className('custom-content'),
customContentHidden: className('custom-content', 'hidden'),
items: className('items'),
overlay: className('overlay'),
overlayHidden: className('overlay', 'hidden'),

View file

@ -15,16 +15,6 @@ export interface PopoverParams {
*/
scopeElement?: HTMLElement;
/**
* Arbitrary html element to be inserted before items list
*/
customContent?: HTMLElement;
/**
* List of html elements inside custom content area that should be available for keyboard navigation
*/
customContentFlippableItems?: HTMLElement[];
/**
* True if popover should contain search field
*/
@ -92,9 +82,6 @@ export interface PopoverNodes {
/** Popover items wrapper */
items: HTMLElement;
/** Custom html content area */
customContent: HTMLElement | undefined;
}
/**

View file

@ -130,7 +130,7 @@
}
}
&__search, &__custom-content:not(:empty) {
&__search {
margin-bottom: 5px;
}
@ -151,18 +151,6 @@
}
}
&__custom-content:not(:empty) {
padding: 4px;
@media (--not-mobile) {
padding: 0;
}
}
&__custom-content--hidden {
display: none;
}
&--nested {
.ce-popover__container {
/* Variable --nesting-level is set via js in showNestedPopoverForItem() method */
@ -210,6 +198,12 @@
}
}
.ce-popover-item-html {
&--hidden {
display: none;
}
}
.ce-popover-item {
--border-radius: 6px;
border-radius: var(--border-radius);

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,7 @@
import type EditorJS from '../../../../../types/index';
import Chainable = Cypress.Chainable;
import { SimpleHeader } from '../../../fixtures/tools/SimpleHeader';
import type { ConversionConfig } from '../../../../../types/index';
/**
@ -425,11 +426,11 @@ describe('Backspace keydown', function () {
.should('not.have.class', 'ce-toolbar--opened');
});
it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable. Also, should close the Toolbox.', function () {
it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable (target Bock is lack of merge() and conversionConfig). Also, should close the Toolbox.', function () {
/**
* Mock of tool without merge method
* Mock of tool without merge() method
*/
class ExampleOfUnmergeableTool {
class UnmergeableToolWithoutConversionConfig {
/**
* Render method mock
*/
@ -452,7 +453,90 @@ describe('Backspace keydown', function () {
cy.createEditor({
tools: {
code: ExampleOfUnmergeableTool,
code: UnmergeableToolWithoutConversionConfig,
},
data: {
blocks: [
{
type: 'code',
data: {},
},
{
type: 'paragraph',
data: {
text: 'Second block',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{home}')
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('[data-cy=unmergeable-tool]')
.as('firstBlock');
/**
* Caret is set to the previous Block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('@firstBlock').should(($div) => {
expect($div[0].contains(range.startContainer)).to.be.true;
});
});
});
it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable (target Bock is lack of merge() but has the conversionConfig). Also, should close the Toolbox.', function () {
/**
* Mock of tool without merge() method
*/
class UnmergeableToolWithConversionConfig {
/**
* Render method mock
*/
public render(): HTMLElement {
const container = document.createElement('div');
container.dataset.cy = 'unmergeable-tool';
container.contentEditable = 'true';
container.innerHTML = 'Unmergeable not empty tool';
return container;
}
/**
* Saving logic is not necessary for this test
*/
public save(): { key: string } {
return {
key: 'value',
};
}
/**
* Mock of the conversionConfig
*/
public static get conversionConfig(): ConversionConfig {
return {
export: 'key',
import: 'key',
};
}
}
cy.createEditor({
tools: {
code: UnmergeableToolWithConversionConfig,
},
data: {
blocks: [

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,6 +1,7 @@
import { selectionChangeDebounceTimeout } from '../../../../src/components/constants';
import Header from '@editorjs/header';
import { ToolboxConfig } from '../../../../types';
import { TunesMenuConfig } from '../../../../types/tools';
describe('BlockTunes', function () {
@ -287,5 +288,154 @@ describe('BlockTunes', function () {
.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;
});
});
});
});
describe('Tunes order', () => {
it('should display block specific tunes before common tunes', () => {
/**
* Tool with several toolbox entries configured
*/
class TestTool {
/**
* TestTool contains several toolbox options
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Title 1',
icon: 'Icon1',
data: {
level: 1,
},
},
];
}
/**
* Tool can render itself
*/
public render(): HTMLDivElement {
const div = document.createElement('div');
div.innerText = 'Some text';
return div;
}
/**
*
*/
public renderSettings(): TunesMenuConfig {
return {
icon: 'Icon',
title: 'Tune',
};
}
/**
* 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();
/** Check there are more than 1 tune */
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.should('have.length.above', 1);
/** Check the first tune is tool specific tune */
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item:first-child')
.contains('Tune')
.should('exist');
});
});
});

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 { PopoverDesktop as Popover } from '../../../../src/components/utils/popover';
import { PopoverDesktop as Popover, PopoverItemType } from '../../../../src/components/utils/popover';
import { PopoverItemParams } from '../../../../types';
import { TunesMenuConfig } from '../../../../types/tools';
@ -115,7 +115,7 @@ describe('Popover', () => {
.should('have.class', 'ce-popover-item--disabled')
.click()
.then(() => {
if (items[0].type !== 'default') {
if (items[0].type !== PopoverItemType.Default) {
return;
}
// Check onActivate callback has never been called
@ -244,22 +244,149 @@ describe('Popover', () => {
});
});
it('should render custom html content', () => {
const customHtml = document.createElement('div');
it('should display item with custom html', () => {
/**
* Block Tune with html as return type of render() method
*/
class TestTune {
public static isTune = true;
customHtml.setAttribute('data-cy-name', 'customContent');
customHtml.innerText = 'custom html content';
const popover = new Popover({
customContent: customHtml,
items: [],
/** Tune control displayed in block tunes popover */
public render(): HTMLElement {
const button = document.createElement('button');
button.classList.add('ce-settings__button');
button.innerText = 'Tune';
return button;
}
}
/** Create editor instance */
cy.createEditor({
tools: {
testTool: TestTune,
},
tunes: [ 'testTool' ],
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello',
},
},
],
},
});
cy.document().then(doc => {
doc.body.append(popover.getElement());
/** Open block tunes menu */
cy.get('[data-cy=editorjs]')
.get('.cdx-block')
.click();
/* Check custom content exists in the popover */
cy.get('[data-cy-name=customContent]');
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
/** Check item with custom html content is displayed */
cy.get('[data-cy=editorjs]')
.get('.ce-popover .ce-popover-item-html')
.contains('Tune')
.should('be.visible');
});
it('should support flipping between custom content items', () => {
/**
* Block Tune with html as return type of render() method
*/
class TestTune1 {
public static isTune = true;
/** Tune control displayed in block tunes popover */
public render(): HTMLElement {
const button = document.createElement('button');
button.classList.add('ce-settings__button');
button.innerText = 'Tune1';
return button;
}
}
/**
* Block Tune with html as return type of render() method
*/
class TestTune2 {
public static isTune = true;
/** Tune control displayed in block tunes popover */
public render(): HTMLElement {
const button = document.createElement('button');
button.classList.add('ce-settings__button');
button.innerText = 'Tune2';
return button;
}
}
/** Create editor instance */
cy.createEditor({
tools: {
testTool1: TestTune1,
testTool2: TestTune2,
},
tunes: ['testTool1', 'testTool2'],
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello',
},
},
],
},
});
/** Open block tunes menu */
cy.get('[data-cy=editorjs]')
.get('.cdx-block')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
/** Press Tab */
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check the first custom html item is focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover .ce-popover-item-html .ce-settings__button')
.contains('Tune1')
.should('have.class', 'ce-popover-item--focused');
/** Press Tab */
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check the second custom html item is focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover .ce-popover-item-html .ce-settings__button')
.contains('Tune2')
.should('have.class', 'ce-popover-item--focused');
/** Press Tab */
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check that default popover item got focused */
cy.get('[data-cy=editorjs]')
.get('[data-item-name=move-up]')
.should('have.class', 'ce-popover-item--focused');
});
it('should display nested popover (desktop)', () => {
@ -454,7 +581,6 @@ describe('Popover', () => {
/** Tool data displayed in block tunes popover */
public render(): TunesMenuConfig {
return {
// @ts-expect-error type is not specified on purpose to test the back compatibility
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune',
@ -464,7 +590,6 @@ describe('Popover', () => {
}
}
/** Create editor instance */
cy.createEditor({
tools: {
@ -515,7 +640,7 @@ describe('Popover', () => {
name: 'test-item',
},
{
type: 'separator',
type: PopoverItemType.Separator,
},
];
}
@ -577,7 +702,7 @@ describe('Popover', () => {
name: 'test-item-1',
},
{
type: 'separator',
type: PopoverItemType.Separator,
},
{
onActivate: (): void => {},
@ -664,7 +789,7 @@ describe('Popover', () => {
name: 'test-item-1',
},
{
type: 'separator',
type: PopoverItemType.Separator,
},
{
onActivate: (): void => {},

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

View file

@ -1,6 +1,6 @@
import { ToolConfig } from './tool-config';
import { ToolConstructable, BlockToolData } from './index';
import { PopoverItemDefaultParams, PopoverItemSeparatorParams, PopoverItemParams } from '../configs';
import { PopoverItemDefaultParams, PopoverItemSeparatorParams, PopoverItemHtmlParams } from '../configs';
/**
* Tool may specify its toolbox configuration
@ -57,10 +57,15 @@ export type TunesMenuConfigDefaultItem = PopoverItemDefaultParams & {
*/
export type TunesMenuConfigSeparatorItem = PopoverItemSeparatorParams;
/**
* Represents single Tunes Menu item with custom HTML contect
*/
export type TunesMenuConfigHtmlItem = PopoverItemHtmlParams;
/**
* Union of all Tunes Menu item types
*/
export type TunesMenuConfigItem = TunesMenuConfigDefaultItem | TunesMenuConfigSeparatorItem;
export type TunesMenuConfigItem = TunesMenuConfigDefaultItem | TunesMenuConfigSeparatorItem | TunesMenuConfigHtmlItem;
/**
* Tool may specify its tunes configuration

View file

@ -1424,6 +1424,21 @@ chokidar@3.5.3:
optionalDependencies:
fsevents "~2.3.2"
chokidar@^3.5.3:
version "3.6.0"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
dependencies:
anymatch "~3.1.2"
braces "~3.0.2"
glob-parent "~5.1.2"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.6.0"
optionalDependencies:
fsevents "~2.3.2"
ci-info@^3.2.0:
version "3.9.0"
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4"
@ -1689,6 +1704,14 @@ cypress-terminal-report@^5.3.2:
semver "^7.3.5"
tv4 "^1.3.0"
cypress-vite@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/cypress-vite/-/cypress-vite-1.5.0.tgz#471ecc1175c7ab51b3b132c595dc3c7e222fe944"
integrity sha512-vvTMqJZgI3sN2ylQTi4OQh8LRRjSrfrIdkQD5fOj+EC/e9oHkxS96lif1SyDF1PwailG1tnpJE+VpN6+AwO/rg==
dependencies:
chokidar "^3.5.3"
debug "^4.3.4"
cypress@^13.7.1:
version "13.7.1"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.7.1.tgz#d1208eb04efd46ef52a30480a5da71a03373261a"