+
+
+
+
+
+
+
+
+
+
+
diff --git a/postcss.config.js b/postcss.config.js
index 8b80aeed..1984c1d8 100644
--- a/postcss.config.js
+++ b/postcss.config.js
@@ -1,7 +1,9 @@
+// eslint-disable-next-line no-undef
module.exports = {
- plugins: [
- require('postcss-nested-ancestors'),
- require('postcss-nested'),
- ],
- };
-
\ No newline at end of file
+ plugins: [
+ // eslint-disable-next-line no-undef
+ require('postcss-nested-ancestors'),
+ // eslint-disable-next-line no-undef
+ require('postcss-nested'),
+ ],
+};
diff --git a/src/ListRenderer/ChecklistRenderer.ts b/src/ListRenderer/ChecklistRenderer.ts
new file mode 100644
index 00000000..34a13517
--- /dev/null
+++ b/src/ListRenderer/ChecklistRenderer.ts
@@ -0,0 +1,197 @@
+import { IconCheck } from '@codexteam/icons';
+import type { ChecklistItemMeta } from '../types/ItemMeta';
+import type { ListConfig } from '../types/ListParams';
+import { isEmpty, make } from '@editorjs/dom';
+import { DefaultListCssClasses } from './ListRenderer';
+import type { ListCssClasses, ListRendererInterface } from './ListRenderer';
+import { CssPrefix } from '../styles/CssPrefix';
+
+/**
+ * Interface that represents all list used only in unordered list rendering
+ */
+interface ChecklistCssClasses extends ListCssClasses {
+ /**
+ * CSS class of the checklist
+ */
+ checklist: string;
+
+ /**
+ * CSS class of the checked checkbox
+ */
+ itemChecked: string;
+
+ /**
+ * CSS class for the special hover behavior of the checkboc
+ */
+ noHover: string;
+
+ /**
+ * CSS class of the checkbox
+ */
+ checkbox: string;
+
+ /**
+ * CSS class of the checkbox container
+ */
+ checkboxContainer: string;
+}
+
+/**
+ * Class that is responsible for checklist rendering
+ */
+export class CheckListRenderer implements ListRendererInterface {
+ /**
+ * Tool's configuration
+ */
+ protected config?: ListConfig;
+
+ /**
+ * Is Editorjs List Tool read-only option
+ */
+ private readOnly: boolean;
+
+ /**
+ * Getter for all CSS classes used in unordered list rendering
+ */
+ private static get CSS(): ChecklistCssClasses {
+ return {
+ ...DefaultListCssClasses,
+ checklist: `${CssPrefix}-checklist`,
+ itemChecked: `${CssPrefix}__checkbox--checked`,
+ noHover: `${CssPrefix}__checkbox--no-hover`,
+ checkbox: `${CssPrefix}__checkbox-check`,
+ checkboxContainer: `${CssPrefix}__checkbox`,
+ };
+ }
+
+ /**
+ * Assign passed readonly mode and config to relevant class properties
+ * @param readonly - read-only mode flag
+ * @param config - user config for Tool
+ */
+ constructor(readonly: boolean, config?: ListConfig) {
+ this.config = config;
+ this.readOnly = readonly;
+ }
+
+ /**
+ * Renders ul wrapper for list
+ * @param isRoot - boolean variable that represents level of the wrappre (root or childList)
+ * @returns - created html ul element
+ */
+ public renderWrapper(isRoot: boolean): HTMLUListElement {
+ let wrapperElement: HTMLUListElement;
+
+ /**
+ * Check if it's root level
+ */
+ if (isRoot === true) {
+ wrapperElement = make('ul', [CheckListRenderer.CSS.wrapper, CheckListRenderer.CSS.checklist]) as HTMLUListElement;
+
+ /**
+ * Delegate clicks from wrapper to items
+ */
+ wrapperElement.addEventListener('click', (event) => {
+ const target = event.target as Element | null;
+
+ if (target) {
+ const checkbox = target.closest(`.${CheckListRenderer.CSS.checkboxContainer}`);
+
+ if (checkbox && checkbox.contains(target)) {
+ this.toggleCheckbox(checkbox);
+ }
+ }
+ });
+ } else {
+ wrapperElement = make('ul', [CheckListRenderer.CSS.checklist, CheckListRenderer.CSS.itemChildren]) as HTMLUListElement;
+ }
+
+ return wrapperElement;
+ }
+
+ /**
+ * Redners list item element
+ * @param content - content used in list item rendering
+ * @param meta - meta of the list item used in rendering of the checklist
+ * @returns - created html list item element
+ */
+ public renderItem(content: string, meta: ChecklistItemMeta): HTMLLIElement {
+ const itemWrapper = make('li', [CheckListRenderer.CSS.item, CheckListRenderer.CSS.item]);
+ const itemContent = make('div', CheckListRenderer.CSS.itemContent, {
+ innerHTML: content,
+ contentEditable: (!this.readOnly).toString(),
+ });
+
+ const checkbox = make('span', CheckListRenderer.CSS.checkbox);
+ const checkboxContainer = make('div', CheckListRenderer.CSS.checkboxContainer);
+
+ if (meta.checked === true) {
+ checkboxContainer.classList.add(CheckListRenderer.CSS.itemChecked);
+ }
+
+ checkbox.innerHTML = IconCheck;
+ checkboxContainer.appendChild(checkbox);
+
+ itemWrapper.appendChild(checkboxContainer);
+ itemWrapper.appendChild(itemContent);
+
+ return itemWrapper as HTMLLIElement;
+ }
+
+ /**
+ * Return the item content
+ * @param item - item wrapper (
)
+ * @returns - item content string
+ */
+ public getItemContent(item: Element): string {
+ const contentNode = item.querySelector(`.${CheckListRenderer.CSS.itemContent}`);
+
+ if (!contentNode) {
+ return '';
+ }
+
+ if (isEmpty(contentNode)) {
+ return '';
+ }
+
+ return contentNode.innerHTML;
+ }
+
+ /**
+ * Return meta object of certain element
+ * @param item - will be returned meta information of this item
+ * @returns Item meta object
+ */
+ public getItemMeta(item: Element): ChecklistItemMeta {
+ const checkbox = item.querySelector(`.${CheckListRenderer.CSS.checkboxContainer}`);
+
+ return {
+ checked: checkbox ? checkbox.classList.contains(CheckListRenderer.CSS.itemChecked) : false,
+ };
+ }
+
+ /**
+ * Returns default item meta used on creation of the new item
+ */
+ public composeDefaultMeta(): ChecklistItemMeta {
+ return { checked: false };
+ }
+
+ /**
+ * Toggle checklist item state
+ * @param checkbox - checkbox element to be toggled
+ */
+ private toggleCheckbox(checkbox: Element): void {
+ checkbox.classList.toggle(CheckListRenderer.CSS.itemChecked);
+ checkbox.classList.add(CheckListRenderer.CSS.noHover);
+ checkbox.addEventListener('mouseleave', () => this.removeSpecialHoverBehavior(checkbox), { once: true });
+ }
+
+ /**
+ * Removes class responsible for special hover behavior on an item
+ * @param el - item wrapper
+ */
+ private removeSpecialHoverBehavior(el: Element): void {
+ el.classList.remove(CheckListRenderer.CSS.noHover);
+ }
+}
diff --git a/src/ListRenderer/ListRenderer.ts b/src/ListRenderer/ListRenderer.ts
new file mode 100644
index 00000000..711a7758
--- /dev/null
+++ b/src/ListRenderer/ListRenderer.ts
@@ -0,0 +1,73 @@
+import { CssPrefix } from '../styles/CssPrefix';
+/**
+ * CSS classes for the List Tool
+ */
+export const DefaultListCssClasses = {
+ wrapper: CssPrefix,
+ item: `${CssPrefix}__item`,
+ itemContent: `${CssPrefix}__item-content`,
+ itemChildren: `${CssPrefix}__item-children`,
+};
+
+/**
+ * Interface that represents default list css classes
+ */
+export interface ListCssClasses {
+ /**
+ * CSS class of the whole list wrapper
+ */
+ wrapper: string;
+
+ /**
+ * CSS class of the list item
+ */
+ item: string;
+
+ /**
+ * CSS class of the list item content element
+ */
+ itemContent: string;
+
+ /**
+ * CSS class of the children item wrapper
+ */
+ itemChildren: string;
+}
+
+/**
+ * Interface that represents all list renderer classes
+ */
+export interface ListRendererInterface {
+ /**
+ * Renders wrapper for list
+ * @param isRoot - boolean variable that represents level of the wrappre (root or childList)
+ * @returns - created html ul element
+ */
+ renderWrapper: (isRoot: boolean) => HTMLElement;
+
+ /**
+ * Redners list item element
+ * @param content - content of the list item
+ * @returns - created html list item element
+ */
+ renderItem: (content: string, meta: ItemMeta) => HTMLElement;
+
+ /**
+ * Return the item content
+ * @param {Element} item - item wrapper (
)
+ * @returns {string}
+ */
+ getItemContent: (item: Element) => string;
+
+ /**
+ * Return meta object of certain element
+ * @param {Element} item - item of the list to get meta from
+ * @returns {ItemMeta} Item meta object
+ */
+ getItemMeta: (item: Element) => ItemMeta;
+
+ /**
+ * Returns default item meta used on creation of the new item
+ */
+ composeDefaultMeta: () => ItemMeta;
+};
diff --git a/src/ListRenderer/OrderedListRenderer.ts b/src/ListRenderer/OrderedListRenderer.ts
new file mode 100644
index 00000000..7d7ec28e
--- /dev/null
+++ b/src/ListRenderer/OrderedListRenderer.ts
@@ -0,0 +1,123 @@
+import type { OrderedListItemMeta } from '../types/ItemMeta';
+import type { ListConfig } from '../types/ListParams';
+import { isEmpty, make } from '@editorjs/dom';
+import { DefaultListCssClasses } from './ListRenderer';
+import type { ListCssClasses, ListRendererInterface } from './ListRenderer';
+import { CssPrefix } from '../styles/CssPrefix';
+
+/**
+ * Interface that represents all list used only in unordered list rendering
+ */
+interface OrderedListCssClasses extends ListCssClasses {
+ /**
+ * CSS class of the ordered list
+ */
+ orderedList: string;
+}
+
+/**
+ * Class that is responsible for ordered list rendering
+ */
+export class OrderedListRenderer implements ListRendererInterface {
+ /**
+ * Tool's configuration
+ */
+ protected config?: ListConfig;
+
+ /**
+ * Is Editorjs List Tool read-only option
+ */
+ private readOnly: boolean;
+
+ /**
+ * Getter for all CSS classes used in unordered list rendering
+ */
+ private static get CSS(): OrderedListCssClasses {
+ return {
+ ...DefaultListCssClasses,
+ orderedList: `${CssPrefix}-ordered`,
+ };
+ }
+
+ /**
+ * Assign passed readonly mode and config to relevant class properties
+ * @param readonly - read-only mode flag
+ * @param config - user config for Tool
+ */
+ constructor(readonly: boolean, config?: ListConfig) {
+ this.config = config;
+ this.readOnly = readonly;
+ }
+
+ /**
+ * Renders ol wrapper for list
+ * @param isRoot - boolean variable that represents level of the wrappre (root or childList)
+ * @returns - created html ol element
+ */
+ public renderWrapper(isRoot: boolean): HTMLOListElement {
+ let wrapperElement: HTMLOListElement;
+
+ /**
+ * Check if it's root level
+ */
+ if (isRoot === true) {
+ wrapperElement = make('ol', [OrderedListRenderer.CSS.wrapper, OrderedListRenderer.CSS.orderedList]) as HTMLOListElement;
+ } else {
+ wrapperElement = make('ol', [OrderedListRenderer.CSS.orderedList, OrderedListRenderer.CSS.itemChildren]) as HTMLOListElement;
+ }
+
+ return wrapperElement;
+ }
+
+ /**
+ * Redners list item element
+ * @param content - content used in list item rendering
+ * @param _meta - meta of the list item unused in rendering of the ordered list
+ * @returns - created html list item element
+ */
+ public renderItem(content: string, _meta: OrderedListItemMeta): HTMLLIElement {
+ const itemWrapper = make('li', OrderedListRenderer.CSS.item);
+ const itemContent = make('div', OrderedListRenderer.CSS.itemContent, {
+ innerHTML: content,
+ contentEditable: (!this.readOnly).toString(),
+ });
+
+ itemWrapper.appendChild(itemContent);
+
+ return itemWrapper as HTMLLIElement;
+ }
+
+ /**
+ * Return the item content
+ * @param item - item wrapper (
)
+ * @returns - item content string
+ */
+ public getItemContent(item: Element): string {
+ const contentNode = item.querySelector(`.${OrderedListRenderer.CSS.itemContent}`);
+
+ if (!contentNode) {
+ return '';
+ }
+
+ if (isEmpty(contentNode)) {
+ return '';
+ }
+
+ return contentNode.innerHTML;
+ }
+
+ /**
+ * Returns item meta, for ordered list
+ * @returns item meta object
+ */
+ public getItemMeta(): OrderedListItemMeta {
+ return {};
+ }
+
+ /**
+ * Returns default item meta used on creation of the new item
+ */
+ public composeDefaultMeta(): OrderedListItemMeta {
+ return {};
+ }
+}
diff --git a/src/ListRenderer/UnorderedListRenderer.ts b/src/ListRenderer/UnorderedListRenderer.ts
new file mode 100644
index 00000000..f50f36a8
--- /dev/null
+++ b/src/ListRenderer/UnorderedListRenderer.ts
@@ -0,0 +1,123 @@
+import type { UnorderedListItemMeta } from '../types/ItemMeta';
+import type { ListConfig } from '../types/ListParams';
+import { make, isEmpty } from '@editorjs/dom';
+import { DefaultListCssClasses } from './ListRenderer';
+import type { ListCssClasses, ListRendererInterface } from './ListRenderer';
+import { CssPrefix } from '../styles/CssPrefix';
+
+/**
+ * Interface that represents all list used only in unordered list rendering
+ */
+interface UnoderedListCssClasses extends ListCssClasses {
+ /**
+ * CSS class of the unordered list
+ */
+ unorderedList: string;
+}
+
+/**
+ * Class that is responsible for unordered list rendering
+ */
+export class UnorderedListRenderer implements ListRendererInterface {
+ /**
+ * Tool's configuration
+ */
+ protected config?: ListConfig;
+
+ /**
+ * Is Editorjs List Tool read-only option
+ */
+ private readOnly: boolean;
+
+ /**
+ * Getter for all CSS classes used in unordered list rendering
+ */
+ private static get CSS(): UnoderedListCssClasses {
+ return {
+ ...DefaultListCssClasses,
+ unorderedList: `${CssPrefix}-unordered`,
+ };
+ }
+
+ /**
+ * Assign passed readonly mode and config to relevant class properties
+ * @param readonly - read-only mode flag
+ * @param config - user config for Tool
+ */
+ constructor(readonly: boolean, config?: ListConfig) {
+ this.config = config;
+ this.readOnly = readonly;
+ }
+
+ /**
+ * Renders ol wrapper for list
+ * @param isRoot - boolean variable that represents level of the wrappre (root or childList)
+ * @returns - created html ul element
+ */
+ public renderWrapper(isRoot: boolean): HTMLUListElement {
+ let wrapperElement: HTMLUListElement;
+
+ /**
+ * Check if it's root level
+ */
+ if (isRoot === true) {
+ wrapperElement = make('ul', [UnorderedListRenderer.CSS.wrapper, UnorderedListRenderer.CSS.unorderedList]) as HTMLUListElement;
+ } else {
+ wrapperElement = make('ul', [UnorderedListRenderer.CSS.unorderedList, UnorderedListRenderer.CSS.itemChildren]) as HTMLUListElement;
+ }
+
+ return wrapperElement;
+ }
+
+ /**
+ * Redners list item element
+ * @param content - content used in list item rendering
+ * @param _meta - meta of the list item unused in rendering of the unordered list
+ * @returns - created html list item element
+ */
+ public renderItem(content: string, _meta: UnorderedListItemMeta): HTMLLIElement {
+ const itemWrapper = make('li', UnorderedListRenderer.CSS.item);
+ const itemContent = make('div', UnorderedListRenderer.CSS.itemContent, {
+ innerHTML: content,
+ contentEditable: (!this.readOnly).toString(),
+ });
+
+ itemWrapper.appendChild(itemContent);
+
+ return itemWrapper as HTMLLIElement;
+ }
+
+ /**
+ * Return the item content
+ * @param item - item wrapper (
)
+ * @returns - item content string
+ */
+ public getItemContent(item: Element): string {
+ const contentNode = item.querySelector(`.${UnorderedListRenderer.CSS.itemContent}`);
+
+ if (!contentNode) {
+ return '';
+ }
+
+ if (isEmpty(contentNode)) {
+ return '';
+ }
+
+ return contentNode.innerHTML;
+ }
+
+ /**
+ * Returns item meta, for unordered list
+ * @returns Item meta object
+ */
+ public getItemMeta(): UnorderedListItemMeta {
+ return {};
+ }
+
+ /**
+ * Returns default item meta used on creation of the new item
+ */
+ public composeDefaultMeta(): UnorderedListItemMeta {
+ return {};
+ }
+}
diff --git a/src/ListRenderer/index.ts b/src/ListRenderer/index.ts
new file mode 100644
index 00000000..fca9b116
--- /dev/null
+++ b/src/ListRenderer/index.ts
@@ -0,0 +1,6 @@
+import { CheckListRenderer } from './ChecklistRenderer';
+import { OrderedListRenderer } from './OrderedListRenderer';
+import { UnorderedListRenderer } from './UnorderedListRenderer';
+import { DefaultListCssClasses } from './ListRenderer';
+
+export { CheckListRenderer, OrderedListRenderer, UnorderedListRenderer, DefaultListCssClasses };
diff --git a/src/ListTabulator/index.ts b/src/ListTabulator/index.ts
new file mode 100644
index 00000000..35f0b6fe
--- /dev/null
+++ b/src/ListTabulator/index.ts
@@ -0,0 +1,1170 @@
+import { OrderedListRenderer } from '../ListRenderer/OrderedListRenderer';
+import { UnorderedListRenderer } from '../ListRenderer/UnorderedListRenderer';
+import type { ListConfig, ListData, ListDataStyle } from '../types/ListParams';
+import type { ListItem } from '../types/ListParams';
+import type { ItemElement, ItemChildWrapperElement } from '../types/Elements';
+import { isHtmlElement } from '../utils/type-guards';
+import { getContenteditableSlice, getCaretNodeAndOffset, isCaretAtStartOfInput } from '@editorjs/caret';
+import { DefaultListCssClasses } from '../ListRenderer';
+import type { PasteEvent } from '../types';
+import type { API, BlockAPI, PasteConfig } from '@editorjs/editorjs';
+import type { ListParams } from '..';
+import type { ChecklistItemMeta, ItemMeta, OrderedListItemMeta, UnorderedListItemMeta } from '../types/ItemMeta';
+import type { ListRenderer } from '../types/ListRenderer';
+import { getSiblings } from '../utils/getSiblings';
+import { getChildItems } from '../utils/getChildItems';
+import { isLastItem } from '../utils/isLastItem';
+import { itemHasSublist } from '../utils/itemHasSublist';
+import { getItemChildWrapper } from '../utils/getItemChildWrapper';
+import { removeChildWrapperIfEmpty } from '../utils/removeChildWrapperIfEmpty';
+import { getItemContentElement } from '../utils/getItemContentElement';
+import { focusItem } from '../utils/focusItem';
+import type { OlCounterType } from '../types/OlCounterType';
+
+/**
+ * Class that is responsible for list tabulation
+ */
+export default class ListTabulator {
+ /**
+ * The Editor.js API
+ */
+ private api: API;
+
+ /**
+ * Is Editorjs List Tool read-only option
+ */
+ private readOnly: boolean;
+
+ /**
+ * Tool's configuration
+ */
+ private config?: ListConfig;
+
+ /**
+ * Full content of the list
+ */
+ private data: ListData;
+
+ /**
+ * Editor block api
+ */
+ private block: BlockAPI;
+
+ /**
+ * Rendered list of items
+ */
+ private renderer: Renderer;
+
+ /**
+ * Wrapper of the whole list
+ */
+ private listWrapper: ItemChildWrapperElement | undefined;
+
+ /**
+ * Getter method to get current item
+ * @returns current list item or null if caret position is not undefined
+ */
+ private get currentItem(): ItemElement | null {
+ const selection = window.getSelection();
+
+ if (!selection) {
+ return null;
+ }
+
+ let currentNode = selection.anchorNode;
+
+ if (!currentNode) {
+ return null;
+ }
+
+ if (!isHtmlElement(currentNode)) {
+ currentNode = currentNode.parentNode;
+ }
+ if (!currentNode) {
+ return null;
+ }
+ if (!isHtmlElement(currentNode)) {
+ return null;
+ }
+
+ return currentNode.closest(`.${DefaultListCssClasses.item}`);
+ }
+
+ /**
+ * Method that returns nesting level of the current item, null if there is no selection
+ */
+ private get currentItemLevel(): number | null {
+ const currentItem = this.currentItem;
+
+ if (currentItem === null) {
+ return null;
+ }
+
+ let parentNode = currentItem.parentNode;
+
+ let levelCounter = 0;
+
+ while (parentNode !== null && parentNode !== this.listWrapper) {
+ if (isHtmlElement(parentNode) && parentNode.classList.contains(DefaultListCssClasses.item)) {
+ levelCounter += 1;
+ }
+
+ parentNode = parentNode.parentNode;
+ }
+
+ /**
+ * Level counter is number of the parent element, so it should be increased by one
+ */
+ return levelCounter + 1;
+ }
+
+ /**
+ * Assign all passed params and renderer to relevant class properties
+ * @param params - tool constructor options
+ * @param params.data - previously saved data
+ * @param params.config - user config for Tool
+ * @param params.api - Editor.js API
+ * @param params.readOnly - read-only mode flag
+ * @param renderer - renderer instance initialized in tool class
+ */
+ constructor({ data, config, api, readOnly, block }: ListParams, renderer: Renderer) {
+ this.config = config;
+ this.data = data as ListData;
+ this.readOnly = readOnly;
+ this.api = api;
+ this.block = block;
+
+ this.renderer = renderer;
+ }
+
+ /**
+ * Function that is responsible for rendering list with contents
+ * @returns Filled with content wrapper element of the list
+ */
+ public render(): ItemChildWrapperElement {
+ this.listWrapper = this.renderer.renderWrapper(true);
+
+ // fill with data
+ if (this.data.items.length) {
+ this.appendItems(this.data.items, this.listWrapper);
+ } else {
+ this.appendItems(
+ [
+ {
+ content: '',
+ meta: {},
+ items: [],
+ },
+ ],
+ this.listWrapper
+ );
+ }
+
+ if (!this.readOnly) {
+ // detect keydown on the last item to escape List
+ this.listWrapper.addEventListener(
+ 'keydown',
+ (event) => {
+ switch (event.key) {
+ case 'Enter':
+ this.enterPressed(event);
+ break;
+ case 'Backspace':
+ this.backspace(event);
+ break;
+ case 'Tab':
+ if (event.shiftKey) {
+ this.shiftTab(event);
+ } else {
+ this.addTab(event);
+ }
+ break;
+ }
+ },
+ false
+ );
+ }
+
+ /**
+ * Set start property value from initial data
+ */
+ if ('start' in this.data.meta && this.data.meta.start !== undefined) {
+ this.changeStartWith(this.data.meta.start);
+ }
+
+ /**
+ * Set counterType value from initial data
+ */
+ if ('counterType' in this.data.meta && this.data.meta.counterType !== undefined) {
+ this.changeCounters(this.data.meta.counterType);
+ }
+
+ return this.listWrapper;
+ }
+
+ /**
+ * Function that is responsible for list content saving
+ * @param wrapper - optional argument wrapper
+ * @returns whole list saved data if wrapper not passes, otherwise will return data of the passed wrapper
+ */
+ public save(wrapper?: ItemChildWrapperElement): ListData {
+ const listWrapper = wrapper ?? this.listWrapper;
+
+ /**
+ * The method for recursive collecting of the child items
+ * @param parent - where to find items
+ */
+ const getItems = (parent: ItemChildWrapperElement): ListItem[] => {
+ const children = getChildItems(parent);
+
+ return children.map((el) => {
+ const subItemsWrapper = getItemChildWrapper(el);
+ const content = this.renderer.getItemContent(el);
+ const meta = this.renderer.getItemMeta(el);
+ const subItems = subItemsWrapper ? getItems(subItemsWrapper) : [];
+
+ return {
+ content,
+ meta,
+ items: subItems,
+ };
+ });
+ };
+
+ const composedListItems = listWrapper ? getItems(listWrapper) : [];
+
+ let dataToSave: ListData = {
+ style: this.data.style,
+ meta: {} as ItemMeta,
+ items: composedListItems,
+ };
+
+ if (this.data.style === 'ordered') {
+ dataToSave.meta = {
+ start: (this.data.meta as OrderedListItemMeta).start,
+ counterType: (this.data.meta as OrderedListItemMeta).counterType,
+ };
+ }
+
+ return dataToSave;
+ }
+
+ /**
+ * On paste sanitzation config. Allow only tags that are allowed in the Tool.
+ * @returns - config that determines tags supposted by paste handler
+ * @todo - refactor and move to list instance
+ */
+ public static get pasteConfig(): PasteConfig {
+ return {
+ tags: ['OL', 'UL', 'LI'],
+ };
+ }
+
+ /**
+ * Method that specified hot to merge two List blocks.
+ * Called by Editor.js by backspace at the beginning of the Block
+ *
+ * Content of the first item of the next List would be merged with deepest item in current list
+ * Other items of the next List would be appended to the current list without any changes in nesting levels
+ * @param data - data of the second list to be merged with current
+ */
+ public merge(data: ListData): void {
+ /**
+ * Get list of all levels children of the previous item
+ */
+ const items = this.block.holder.querySelectorAll(`.${DefaultListCssClasses.item}`);
+
+ const deepestBlockItem = items[items.length - 1];
+ const deepestBlockItemContentElement = getItemContentElement(deepestBlockItem);
+
+ if (deepestBlockItem === null || deepestBlockItemContentElement === null) {
+ return;
+ }
+
+ /**
+ * Insert trailing html to the deepest block item content
+ */
+ deepestBlockItemContentElement.insertAdjacentHTML('beforeend', data.items[0].content);
+
+ if (this.listWrapper === undefined) {
+ return;
+ }
+
+ const firstLevelItems = getChildItems(this.listWrapper);
+
+ if (firstLevelItems.length === 0) {
+ return;
+ }
+
+ /**
+ * Get last item of the first level of the list
+ */
+ const lastFirstLevelItem = firstLevelItems[firstLevelItems.length - 1];
+
+ /**
+ * Get child items wrapper of the last item
+ */
+ let lastFirstLevelItemChildWrapper = getItemChildWrapper(lastFirstLevelItem);
+
+ /**
+ * Get first item of the list to be merged with current one
+ */
+ const firstItem = data.items.shift();
+
+ /**
+ * Check that first item exists
+ */
+ if (firstItem === undefined) {
+ return;
+ }
+
+ /**
+ * Append child items of the first element
+ */
+ if (firstItem.items.length !== 0) {
+ /**
+ * Render child wrapper of the last item if it does not exist
+ */
+ if (lastFirstLevelItemChildWrapper === null) {
+ lastFirstLevelItemChildWrapper = this.renderer.renderWrapper(false);
+ }
+
+ this.appendItems(firstItem.items, lastFirstLevelItemChildWrapper);
+ }
+
+ if (data.items.length > 0) {
+ this.appendItems(data.items, this.listWrapper);
+ }
+ }
+
+ /**
+ * On paste callback that is fired from Editor.
+ * @param event - event with pasted data
+ * @todo - refactor and move to list instance
+ */
+ public onPaste(event: PasteEvent): void {
+ const list = event.detail.data;
+
+ this.data = this.pasteHandler(list);
+
+ // render new list
+ const oldView = this.listWrapper;
+
+ if (oldView && oldView.parentNode) {
+ oldView.parentNode.replaceChild(this.render(), oldView);
+ }
+ }
+
+ /**
+ * Handle UL, OL and LI tags paste and returns List data
+ * @param element - html element that contains whole list
+ * @todo - refactor and move to list instance
+ */
+ public pasteHandler(element: PasteEvent['detail']['data']): ListData {
+ const { tagName: tag } = element;
+ let style: ListDataStyle = 'unordered';
+ let tagToSearch: string;
+
+ // set list style and tag to search.
+ switch (tag) {
+ case 'OL':
+ style = 'ordered';
+ tagToSearch = 'ol';
+ break;
+ case 'UL':
+ case 'LI':
+ style = 'unordered';
+ tagToSearch = 'ul';
+ }
+
+ const data: ListData = {
+ style,
+ meta: {} as ItemMeta,
+ items: [],
+ };
+
+ /**
+ * Set default ordered list atributes if style is ordered
+ */
+ if (style === 'ordered') {
+ (this.data.meta as OrderedListItemMeta).counterType = 'numeric';
+ (this.data.meta as OrderedListItemMeta).start = 1;
+ }
+
+ // get pasted items from the html.
+ const getPastedItems = (parent: Element): ListItem[] => {
+ // get first level li elements.
+ const children = Array.from(parent.querySelectorAll(`:scope > li`));
+
+ return children.map((child) => {
+ // get subitems if they exist.
+ const subItemsWrapper = child.querySelector(`:scope > ${tagToSearch}`);
+ // get subitems.
+ const subItems = subItemsWrapper ? getPastedItems(subItemsWrapper) : [];
+ // get text content of the li element.
+ const content = child.firstChild?.textContent ?? '';
+
+ return {
+ content,
+ meta: {},
+ items: subItems,
+ };
+ });
+ };
+
+ // get pasted items.
+ data.items = getPastedItems(element);
+
+ return data;
+ }
+
+ /**
+ * Changes ordered list start property value
+ * @param index - new value of the start property
+ */
+ public changeStartWith(index: number): void {
+ this.listWrapper!.style.setProperty('counter-reset', `item ${index - 1}`);
+
+ (this.data.meta as OrderedListItemMeta).start = index;
+ }
+
+ /**
+ * Changes ordered list counterType property value
+ * @param counterType - new value of the counterType value
+ */
+ public changeCounters(counterType: OlCounterType): void {
+ this.listWrapper!.style.setProperty('--list-counter-type', counterType);
+
+ (this.data.meta as OrderedListItemMeta).counterType = counterType;
+ }
+
+ /**
+ * Handles Enter keypress
+ * @param event - keydown
+ */
+ private enterPressed(event: KeyboardEvent): void {
+ const currentItem = this.currentItem;
+
+ /**
+ * Prevent editor.js behaviour
+ */
+ event.stopPropagation();
+
+ /**
+ * Prevent browser behaviour
+ */
+ event.preventDefault();
+
+ /**
+ * Prevent duplicated event in Chinese, Japanese and Korean languages
+ */
+ if (event.isComposing) {
+ return;
+ }
+ if (currentItem === null) {
+ return;
+ }
+
+ const isEmpty = this.renderer?.getItemContent(currentItem).trim().length === 0;
+ const isFirstLevelItem = currentItem.parentNode === this.listWrapper;
+ const isFirstItem = currentItem.previousElementSibling === null;
+
+ const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
+
+ /**
+ * On Enter in the last empty item, get out of list
+ */
+ if (isFirstLevelItem && isEmpty) {
+ if (isLastItem(currentItem) && !itemHasSublist(currentItem)) {
+ /**
+ * If current item is first and last item of the list, then empty list should be deleted after deletion of the item
+ */
+ if (isFirstItem) {
+ this.convertItemToDefaultBlock(currentBlockIndex, true);
+ } else {
+ /**
+ * If there are other items in the list, just remove current item and get out of the list
+ */
+ this.convertItemToDefaultBlock();
+ }
+
+ return;
+ } else {
+ /**
+ * If enter is pressed in the сenter of the list item we should split it
+ */
+ this.splitList(currentItem);
+
+ return;
+ }
+ } else if (isEmpty) {
+ /**
+ * If currnet item is empty and is in the middle of the list
+ * And if current item is not on the first level
+ * Then unshift current item
+ */
+ this.unshiftItem(currentItem);
+
+ return;
+ } else {
+ /**
+ * If current item is not empty than split current item
+ */
+ this.splitItem(currentItem);
+ }
+ }
+
+ /**
+ * Handle backspace
+ * @param event - keydown
+ */
+ private backspace(event: KeyboardEvent): void {
+ const currentItem = this.currentItem;
+
+ if (currentItem === null) {
+ return;
+ }
+
+ /**
+ * Caret is not at start of the item
+ * Then backspace button should remove letter as usual
+ */
+ if (!isCaretAtStartOfInput(currentItem)) {
+ return;
+ }
+
+ /**
+ * Prevent Editor.js backspace handling
+ */
+ event.stopPropagation();
+
+ /**
+ * First item of the list should become paragraph on backspace
+ */
+ if (currentItem.parentNode === this.listWrapper && currentItem.previousElementSibling === null) {
+ /**
+ * If current item is first item of the list, then we need to merge first item content with previous block
+ */
+ this.convertFirstItemToDefaultBlock();
+
+ return;
+ }
+
+ /**
+ * Prevent default backspace behaviour
+ */
+ event.preventDefault();
+
+ this.mergeItemWithPrevious(currentItem);
+ }
+
+ /**
+ * Reduce indentation for current item
+ * @param event - keydown
+ */
+ private shiftTab(event: KeyboardEvent): void {
+ /**
+ * Prevent editor.js behaviour
+ */
+ event.stopPropagation();
+
+ /**
+ * Prevent browser tab behaviour
+ */
+ event.preventDefault();
+
+ /**
+ * Check that current item exists
+ */
+ if (this.currentItem === null) {
+ return;
+ }
+
+ /**
+ * Move item from current list to parent list
+ */
+ this.unshiftItem(this.currentItem);
+ }
+
+ /**
+ * Decrease indentation of the passed item
+ * @param item - list item to be unshifted
+ */
+ private unshiftItem(item: ItemElement): void {
+ if (!item.parentNode) {
+ return;
+ }
+ if (!isHtmlElement(item.parentNode)) {
+ return;
+ }
+
+ const parentItem = item.parentNode.closest(`.${DefaultListCssClasses.item}`);
+
+ /**
+ * If item in the first-level list then no need to do anything
+ */
+ if (!parentItem) {
+ return;
+ }
+
+ let currentItemChildWrapper = getItemChildWrapper(item);
+
+ if (item.parentElement === null) {
+ return;
+ }
+
+ const siblings = getSiblings(item);
+
+ /**
+ * If item has any siblings, they should be appended to item child wrapper
+ */
+ if (siblings !== null) {
+ /**
+ * Render child wrapper if it does no exist
+ */
+ if (currentItemChildWrapper === null) {
+ currentItemChildWrapper = this.renderer.renderWrapper(false);
+ }
+
+ /**
+ * Append siblings to item child wrapper
+ */
+ siblings.forEach((sibling) => {
+ currentItemChildWrapper!.appendChild(sibling);
+ });
+
+ item.appendChild(currentItemChildWrapper);
+ }
+
+ parentItem.after(item);
+
+ focusItem(item, false);
+
+ /**
+ * If parent item has empty child wrapper after unshifting of the current item, then we need to remove child wrapper
+ * This case could be reached if the only child item of the parent was unshifted
+ */
+ removeChildWrapperIfEmpty(parentItem);
+ }
+
+ /**
+ * Method that is used for list splitting and moving trailing items to the new separated list
+ * @param item - current item html element
+ */
+ private splitList(item: ItemElement): void {
+ const currentItemChildrenList = getChildItems(item);
+
+ /**
+ * Get current list block index
+ */
+ const currentBlock = this.block;
+
+ const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
+
+ /**
+ * First child item should be unshifted because separated list should start
+ * with item with first nesting level
+ */
+ if (currentItemChildrenList.length !== 0) {
+ const firstChildItem = currentItemChildrenList[0];
+
+ this.unshiftItem(firstChildItem);
+
+ /**
+ * If first child item was been unshifted, that caret would be set to the end of the first child item
+ * Then we should set caret to the actual current item
+ */
+ focusItem(item, false);
+ }
+
+ /**
+ * If item is first item of the list, we should just get out of the list
+ * It means, that we would not split on two lists, if one of them would be empty
+ */
+ if (item.previousElementSibling === null && item.parentNode === this.listWrapper) {
+ this.convertItemToDefaultBlock(currentBlockIndex);
+
+ return;
+ }
+
+ /**
+ * Get trailing siblings of the current item
+ */
+ const newListItems = getSiblings(item);
+
+ if (newListItems === null) {
+ return;
+ }
+
+ /**
+ * Render new wrapper for list that would be separated
+ */
+ const newListWrapper = this.renderer.renderWrapper(true);
+
+ /**
+ * Append new list wrapper with trailing elements
+ */
+ newListItems.forEach((newListItem) => {
+ newListWrapper.appendChild(newListItem);
+ });
+
+ const newListContent = this.save(newListWrapper);
+
+ (newListContent.meta as OrderedListItemMeta).start = this.data.style == 'ordered' ? 1 : undefined;
+
+ /**
+ * Insert separated list with trailing items
+ */
+ this.api.blocks.insert(currentBlock?.name, newListContent, this.config, currentBlockIndex + 1);
+
+ /**
+ * Insert paragraph
+ */
+ this.convertItemToDefaultBlock(currentBlockIndex + 1);
+
+ /**
+ * Remove temporary new list wrapper used for content save
+ */
+ newListWrapper.remove();
+ }
+
+ /**
+ * Method that is used for splitting item content and moving trailing content to the new sibling item
+ * @param currentItem - current item html element
+ */
+ private splitItem(currentItem: ItemElement): void {
+ const [currentNode, offset] = getCaretNodeAndOffset();
+
+ if (currentNode === null) {
+ return;
+ }
+
+ const currentItemContent = getItemContentElement(currentItem);
+
+ let endingHTML: string;
+
+ /**
+ * If current item has no content, we should pass an empty string to the next created list item
+ */
+ if (currentItemContent === null) {
+ endingHTML = '';
+ } else {
+ /**
+ * On other Enters, get content from caret till the end of the block
+ * And move it to the new item
+ */
+ endingHTML = getContenteditableSlice(currentItemContent, currentNode, offset, 'right', true);
+ }
+
+ const itemChildren = getItemChildWrapper(currentItem);
+ /**
+ * Create the new list item
+ */
+ const itemEl = this.renderItem(endingHTML);
+
+ /**
+ * Move new item after current
+ */
+ currentItem?.after(itemEl);
+
+ /**
+ * If current item has children, move them to the new item
+ */
+ if (itemChildren) {
+ itemEl.appendChild(itemChildren);
+ }
+
+ focusItem(itemEl);
+ }
+
+ /**
+ * Method that is used for merging current item with previous one
+ * Content of the current item would be appended to the previous item
+ * Current item children would not change nesting level
+ * @param item - current item html element
+ */
+ private mergeItemWithPrevious(item: ItemElement): void {
+ const previousItem = item.previousElementSibling;
+
+ const currentItemParentNode = item.parentNode;
+
+ /**
+ * Check that parent node of the current element exists
+ */
+ if (currentItemParentNode === null) {
+ return;
+ }
+ if (!isHtmlElement(currentItemParentNode)) {
+ return;
+ }
+
+ const parentItem = currentItemParentNode.closest(`.${DefaultListCssClasses.item}`);
+
+ /**
+ * Check that current item has any previous siblings to be merged with
+ */
+ if (!previousItem && !parentItem) {
+ return;
+ }
+
+ /**
+ * Make sure previousItem is an HTMLElement
+ */
+ if (previousItem && !isHtmlElement(previousItem)) {
+ return;
+ }
+
+ /**
+ * Lets compute the item which will be merged with current item text
+ */
+ let targetItem: ItemElement | null;
+
+ /**
+ * If there is a previous item then we get a deepest item in its sublists
+ *
+ * Otherwise we will use the parent item
+ */
+ if (previousItem) {
+ /**
+ * Get list of all levels children of the previous item
+ */
+ const childrenOfPreviousItem = getChildItems(previousItem, false);
+
+ /**
+ * Target item would be deepest child of the previous item or previous item itself
+ */
+ if (childrenOfPreviousItem.length !== 0 && childrenOfPreviousItem.length !== 0) {
+ targetItem = childrenOfPreviousItem[childrenOfPreviousItem.length - 1];
+ } else {
+ targetItem = previousItem;
+ }
+ } else {
+ targetItem = parentItem;
+ }
+
+ /**
+ * Get current item content
+ */
+ const currentItemContent = this.renderer.getItemContent(item);
+
+ /**
+ * Get the target item content element
+ */
+ if (!targetItem) {
+ return;
+ }
+
+ /**
+ * Set caret to the end of the target item
+ */
+ focusItem(targetItem, false);
+
+ /**
+ * Get target item content element
+ */
+ const targetItemContentElement = getItemContentElement(targetItem);
+
+ /**
+ * Set a new place for caret
+ */
+ if (targetItemContentElement === null) {
+ return;
+ }
+
+ /**
+ * Update target item content by merging with current item html content
+ */
+ targetItemContentElement.insertAdjacentHTML('beforeend', currentItemContent);
+
+ /**
+ * Get child list of the currentItem
+ */
+ const currentItemChildrenList = getChildItems(item);
+
+ /**
+ * If item has no children, just remove item
+ * Else children of the item should be prepended to the target item child list
+ */
+ if (currentItemChildrenList.length === 0) {
+ /**
+ * Remove current item element
+ */
+ item.remove();
+
+ /**
+ * If target item has empty child wrapper after merge, we need to remove child wrapper
+ * This case could be reached if the only child item of the target was merged with target
+ */
+ removeChildWrapperIfEmpty(targetItem);
+
+ return;
+ }
+
+ /**
+ * Get target for child list of the currentItem
+ * Note that previous item and parent item could not be null at the same time
+ * This case is checked before
+ */
+ const targetForChildItems = previousItem ? previousItem : parentItem!;
+
+ const targetChildWrapper = getItemChildWrapper(targetForChildItems) ?? this.renderer.renderWrapper(false);
+
+ /**
+ * Add child current item children to the target childWrapper
+ */
+ if (previousItem) {
+ currentItemChildrenList.forEach((childItem) => {
+ targetChildWrapper.appendChild(childItem);
+ });
+ } else {
+ currentItemChildrenList.forEach((childItem) => {
+ targetChildWrapper.prepend(childItem);
+ });
+ }
+
+ /**
+ * If we created new wrapper, then append childWrapper to the target item
+ */
+ if (getItemChildWrapper(targetForChildItems) === null) {
+ targetItem.appendChild(targetChildWrapper);
+ }
+
+ /**
+ * Remove current item element
+ */
+ item.remove();
+ }
+
+ /**
+ * Add indentation to current item
+ * @param event - keydown
+ */
+ private addTab(event: KeyboardEvent): void {
+ /**
+ * Prevent editor.js behaviour
+ */
+ event.stopPropagation();
+
+ /**
+ * Prevent browser tab behaviour
+ */
+ event.preventDefault();
+
+ const currentItem = this.currentItem;
+
+ if (!currentItem) {
+ return;
+ }
+
+ /**
+ * Check that maxLevel specified in config
+ */
+ if (this.config?.maxLevel !== undefined) {
+ const currentItemLevel = this.currentItemLevel;
+
+ /**
+ * Check that current item is not in the maximum nesting level
+ */
+ if (currentItemLevel !== null && currentItemLevel === this.config.maxLevel) {
+ return;
+ }
+ }
+
+ /**
+ * Check that the item has potential parent
+ * Previous sibling is potential parent in case of adding tab
+ * After adding tab current item would be moved to the previous sibling's child list
+ */
+ const prevItem = currentItem.previousSibling;
+
+ if (prevItem === null) {
+ return;
+ }
+ if (!isHtmlElement(prevItem)) {
+ return;
+ }
+
+ const prevItemChildrenList = getItemChildWrapper(prevItem);
+
+ /**
+ * If prev item has child items, just append current to them
+ * Else render new child wrapper for previous item
+ */
+ if (prevItemChildrenList) {
+ /**
+ * Previous item would be appended with current item and it's sublists
+ * After that sublists would be moved one level back
+ */
+ prevItemChildrenList.appendChild(currentItem);
+
+ /**
+ * Get all current item child to be moved to previous nesting level
+ */
+ const currentItemChildrenList = getChildItems(currentItem);
+
+ /**
+ * Move current item sublists one level back
+ */
+ currentItemChildrenList.forEach((child) => {
+ prevItemChildrenList.appendChild(child);
+ });
+ } else {
+ const prevItemChildrenListWrapper = this.renderer.renderWrapper(false);
+
+ /**
+ * Previous item would be appended with current item and it's sublists
+ * After that sublists would be moved one level back
+ */
+ prevItemChildrenListWrapper.appendChild(currentItem);
+
+ /**
+ * Get all current item child to be moved to previous nesting level
+ */
+ const currentItemChildrenList = getChildItems(currentItem);
+
+ /**
+ * Move current item sublists one level back
+ */
+ currentItemChildrenList.forEach((child) => {
+ prevItemChildrenListWrapper.appendChild(child);
+ });
+
+ prevItem.appendChild(prevItemChildrenListWrapper);
+ }
+
+ /**
+ * Remove child wrapper of the current item if it is empty after adding the tab
+ * This case would be reached, because after adding tab current item will have same nesting level with children
+ * So its child wrapper would be empty
+ */
+ removeChildWrapperIfEmpty(currentItem);
+
+ focusItem(currentItem, false);
+ }
+
+ /**
+ * Convert current item to default block with passed index
+ * @param newBloxkIndex - optional parameter represents index, where would be inseted default block
+ * @param removeList - optional parameter, that represents condition, if List should be removed
+ */
+ private convertItemToDefaultBlock(newBloxkIndex?: number, removeList?: boolean): void {
+ let newBlock;
+
+ const currentItem = this.currentItem;
+
+ const currentItemContent = currentItem !== null ? this.renderer.getItemContent(currentItem) : '';
+
+ if (removeList === true) {
+ this.api.blocks.delete();
+ }
+
+ /**
+ * Check that index have passed
+ */
+ if (newBloxkIndex !== undefined) {
+ newBlock = this.api.blocks.insert(undefined, { text: currentItemContent }, undefined, newBloxkIndex);
+ } else {
+ newBlock = this.api.blocks.insert();
+ }
+
+ currentItem?.remove();
+ this.api.caret.setToBlock(newBlock, 'start');
+ }
+
+ /**
+ * Convert first item of the list to default block
+ * This method could be called when backspace button pressed at start of the first item of the list
+ * First item of the list would be converted to the paragraph and first item children would be unshifted
+ */
+ private convertFirstItemToDefaultBlock(): void {
+ const currentItem = this.currentItem;
+
+ if (currentItem === null) {
+ return;
+ }
+
+ const currentItemChildren = getChildItems(currentItem);
+
+ /**
+ * Check that current item have at least one child
+ * If current item have no children, we can guarantee,
+ * that after deletion of the first item of the list, children would not be removed
+ */
+ if (currentItemChildren.length !== 0) {
+ const firstChildItem = currentItemChildren[0];
+
+ /**
+ * Unshift first child item, to guarantee, that after deletion of the first item
+ * list will start with first level of nesting
+ */
+ this.unshiftItem(firstChildItem);
+
+ /**
+ * Set focus back to the current item after unshifting child
+ */
+ focusItem(currentItem);
+ }
+
+ /**
+ * Get all first level items of the list
+ */
+ const currentItemSiblings = getSiblings(currentItem);
+
+ const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
+
+ /**
+ * If current item has no siblings, than List is empty, and it should be deleted
+ */
+ const removeList = currentItemSiblings === null;
+
+ this.convertItemToDefaultBlock(currentBlockIndex, removeList);
+ }
+
+ /**
+ * Method that calls render function of the renderer with a necessary item meta cast
+ * @param itemContent - content to be rendered in new item
+ * @param meta - meta used in list item rendering
+ * @returns html element of the rendered item
+ */
+ private renderItem(itemContent: ListItem['content'], meta?: ListItem['meta']): ItemElement {
+ const itemMeta = meta ?? this.renderer.composeDefaultMeta();
+
+ switch (true) {
+ case this.renderer instanceof OrderedListRenderer:
+ return this.renderer.renderItem(itemContent, itemMeta as OrderedListItemMeta);
+
+ case this.renderer instanceof UnorderedListRenderer:
+ return this.renderer.renderItem(itemContent, itemMeta as UnorderedListItemMeta);
+
+ default:
+ return this.renderer.renderItem(itemContent, itemMeta as ChecklistItemMeta);
+ }
+ }
+
+ /**
+ * Renders children list
+ * @param items - list data used in item rendering
+ * @param parentElement - where to append passed items
+ */
+ private appendItems(items: ListItem[], parentElement: Element): void {
+ items.forEach((item) => {
+ const itemEl = this.renderItem(item.content, item.meta);
+
+ parentElement.appendChild(itemEl);
+
+ /**
+ * Check if there are child items
+ */
+ if (item.items.length) {
+ const sublistWrapper = this.renderer?.renderWrapper(false);
+
+ /**
+ * Recursively render child items
+ */
+ this.appendItems(item.items, sublistWrapper);
+
+ itemEl.appendChild(sublistWrapper);
+ }
+ });
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 2a15f7ad..f2281772 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,110 +1,47 @@
-import type { API, PasteConfig, ToolboxConfig } from '@editorjs/editorjs';
-import type { PasteEvent } from './types';
+import type { API, BlockAPI, PasteConfig, ToolboxConfig } from '@editorjs/editorjs';
import type {
BlockToolConstructorOptions,
- TunesMenuConfig,
+ MenuConfigItem,
+ ToolConfig
} from '@editorjs/editorjs/types/tools';
-
-import { isHtmlElement } from './utils/type-guards';
-
-import * as Dom from './utils/dom';
-import Caret from './utils/caret';
-import { IconListBulleted, IconListNumbered } from '@codexteam/icons';
+import { IconListBulleted, IconListNumbered, IconChecklist } from '@codexteam/icons';
+import type { ListConfig, ListData, ListDataStyle, ListItem, OldListData } from './types/ListParams';
+import ListTabulator from './ListTabulator';
+import { CheckListRenderer, OrderedListRenderer, UnorderedListRenderer } from './ListRenderer';
+import type { ListRenderer } from './types/ListRenderer';
+import { renderToolboxInput } from './utils/renderToolboxInput';
+import { type OlCounterType, OlCounterTypesMap } from './types/OlCounterType';
/**
* Build styles
*/
-import './../styles/index.pcss';
-
-/**
- * list style to make list as ordered or unordered
- */
-type ListDataStyle = 'ordered' | 'unordered';
-
-/**
- * Output data
- */
-interface ListData {
- /**
- * list type 'ordered' or 'unordered'
- */
- style: ListDataStyle;
- /**
- * list of first-level elements
- */
- items: ListItem[];
-}
-
-/**
- * List item within the output data
- */
-interface ListItem {
- /**
- * list item text content
- */
- content: string;
- /**
- * sublist items
- */
- items: ListItem[];
-}
-
-/**
- * Tool's configuration
- */
-interface NestedListConfig {
- /**
- * default list style: ordered or unordered
- * default is unordered
- */
- defaultStyle?: ListDataStyle;
-}
-
-/**
- * Constructor Params for Nested List Tool, use to pass initial data and settings
- */
-export type NestedListParams = BlockToolConstructorOptions<
- ListData,
- NestedListConfig
->;
+import './styles/list.pcss';
+import './styles/input.pcss';
+import stripNumbers from './utils/stripNumbers';
+import normalizeData from './utils/normalizeData';
+import type { PasteEvent } from './types';
+import type { OrderedListItemMeta } from './types/ItemMeta';
/**
- * CSS classes for the Nested List Tool
+ * Constructor Params for Editorjs List Tool, use to pass initial data and settings
*/
-interface NestedListCssClasses {
- baseBlock: string;
- wrapper: string;
- wrapperOrdered: string;
- wrapperUnordered: string;
- item: string;
- itemBody: string;
- itemContent: string;
- itemChildren: string;
- settingsWrapper: string;
- settingsButton: string;
- settingsButtonActive: string;
-}
+export type ListParams = BlockToolConstructorOptions;
/**
- * NestedList Tool for EditorJS
+ * Default class of the component used in editor
*/
-export default class NestedList {
+export default class EditorjsList {
/**
* Notify core that read-only mode is supported
- *
- * @returns {boolean}
*/
- static get isReadOnlySupported(): boolean {
+ public static get isReadOnlySupported(): boolean {
return true;
}
/**
* Allow to use native Enter behaviour
- *
- * @returns {boolean}
- * @public
*/
- static get enableLineBreaks(): boolean {
+ public static get enableLineBreaks(): boolean {
return true;
}
@@ -112,1002 +49,405 @@ export default class NestedList {
* Get Tool toolbox settings
* icon - Tool icon's SVG
* title - title to show in toolbox
- *
- * @returns {ToolboxConfig}
- */
- static get toolbox(): ToolboxConfig {
- return {
- icon: IconListNumbered,
- title: 'List',
- };
- }
-
- /**
- * The Editor.js API
*/
- private api: API;
-
- /**
- * Is NestedList Tool read-only
- */
- private readOnly: boolean;
-
- /**
- * Tool's configuration
- */
- private config?: NestedListConfig;
-
- /**
- * Default list style
- */
- private defaultListStyle?: NestedListConfig['defaultStyle'];
-
- /**
- * Corresponds to UiNodes type from Editor.js but with wrapper being nullable
- */
- private nodes: { wrapper: HTMLElement | null };
-
- /**
- * Tool's data
- */
- private data: ListData;
-
- /**
- * Caret helper
- */
- private caret: Caret;
-
- /**
- * Render plugin`s main Element and fill it with saved data
- *
- * @param {object} params - tool constructor options
- * @param {ListData} params.data - previously saved data
- * @param {object} params.config - user config for Tool
- * @param {object} params.api - Editor.js API
- * @param {boolean} params.readOnly - read-only mode flag
- */
- constructor({ data, config, api, readOnly }: NestedListParams) {
- /**
- * HTML nodes used in tool
- */
- this.nodes = {
- wrapper: null,
- };
-
- this.api = api;
- this.readOnly = readOnly;
- this.config = config;
-
- /**
- * Set the default list style from the config.
- */
- this.defaultListStyle =
- this.config?.defaultStyle === 'ordered' ? 'ordered' : 'unordered';
-
- const initialData = {
- style: this.defaultListStyle,
- items: [],
- };
- this.data = data && Object.keys(data).length ? data : initialData;
-
- /**
- * Instantiate caret helper
- */
- this.caret = new Caret();
- }
-
- /**
- * Returns list tag with items
- *
- * @returns {Element}
- * @public
- */
- render(): Element {
- this.nodes.wrapper = this.makeListWrapper(this.data.style, [
- this.CSS.baseBlock,
- ]);
-
- // fill with data
- if (this.data.items.length) {
- this.appendItems(this.data.items, this.nodes.wrapper);
- } else {
- this.appendItems(
- [
- {
- content: '',
- items: [],
- },
- ],
- this.nodes.wrapper
- );
- }
-
- if (!this.readOnly) {
- // detect keydown on the last item to escape List
- this.nodes.wrapper.addEventListener(
- 'keydown',
- (event) => {
- switch (event.key) {
- case 'Enter':
- this.enterPressed(event);
- break;
- case 'Backspace':
- this.backspace(event);
- break;
- case 'Tab':
- if (event.shiftKey) {
- this.shiftTab(event);
- } else {
- this.addTab(event);
- }
- break;
- }
- },
- false
- );
- }
-
- return this.nodes.wrapper;
- }
-
- /**
- * Creates Block Tune allowing to change the list style
- *
- * @public
- * @returns {Array}
- */
- renderSettings(): TunesMenuConfig {
- const tunes = [
+ public static get toolbox(): ToolboxConfig {
+ return [
{
- name: 'unordered' as const,
- label: this.api.i18n.t('Unordered'),
icon: IconListBulleted,
+ title: 'Unordered List',
+ data: {
+ style: 'unordered',
+ },
},
{
- name: 'ordered' as const,
- label: this.api.i18n.t('Ordered'),
icon: IconListNumbered,
+ title: 'Ordered List',
+ data: {
+ style: 'ordered',
+ },
},
- ];
-
- return tunes.map((tune) => ({
- name: tune.name,
- icon: tune.icon,
- label: tune.label,
- isActive: this.data.style === tune.name,
- closeOnActivate: true,
- onActivate: () => {
- this.listStyle = tune.name;
+ {
+ icon: IconChecklist,
+ title: 'Checklist',
+ data: {
+ style: 'checklist',
+ },
},
- }));
+ ];
}
/**
* On paste sanitzation config. Allow only tags that are allowed in the Tool.
- *
- * @returns {PasteConfig} - paste config.
+ * @returns - paste config object used in editor
*/
- static get pasteConfig(): PasteConfig {
+ public static get pasteConfig(): PasteConfig {
return {
tags: ['OL', 'UL', 'LI'],
};
}
/**
- * On paste callback that is fired from Editor.
- *
- * @param {PasteEvent} event - event with pasted data
- */
- onPaste(event: PasteEvent): void {
- const list = event.detail.data;
-
- this.data = this.pasteHandler(list);
-
- // render new list
- const oldView = this.nodes.wrapper;
-
- if (oldView && oldView.parentNode) {
- oldView.parentNode.replaceChild(this.render(), oldView);
- }
- }
-
- /**
- * Handle UL, OL and LI tags paste and returns List data
- *
- * @param {HTMLUListElement|HTMLOListElement|HTMLLIElement} element
- * @returns {ListData}
+ * Convert from text to list with import and export list to text
*/
- pasteHandler(element: PasteEvent['detail']['data']): ListData {
- const { tagName: tag } = element;
- let style: ListDataStyle = 'unordered';
- let tagToSearch: string;
-
- // set list style and tag to search.
- switch (tag) {
- case 'OL':
- style = 'ordered';
- tagToSearch = 'ol';
- break;
- case 'UL':
- case 'LI':
- style = 'unordered';
- tagToSearch = 'ul';
- }
-
- const data: ListData = {
- style,
- items: [],
- };
-
- // get pasted items from the html.
- const getPastedItems = (parent: Element): ListItem[] => {
- // get first level li elements.
- const children = Array.from(parent.querySelectorAll(`:scope > li`));
-
- return children.map((child) => {
- // get subitems if they exist.
- const subItemsWrapper = child.querySelector(`:scope > ${tagToSearch}`);
- // get subitems.
- const subItems = subItemsWrapper ? getPastedItems(subItemsWrapper) : [];
- // get text content of the li element.
- const content = child?.firstChild?.textContent || '';
+ public static get conversionConfig(): {
+ /**
+ * Method that is responsible for conversion from data to string
+ * @param data - current list data
+ * @returns - contents string formed from list data
+ */
+ export: (data: ListData) => string;
+ /**
+ * Method that is responsible for conversion from string to data
+ * @param content - contents string
+ * @returns - list data formed from contents string
+ */
+ import: (content: string, config: ToolConfig) => ListData;
+ } {
+ return {
+ export: (data) => {
+ return EditorjsList.joinRecursive(data);
+ },
+ import: (content, config) => {
return {
- content,
- items: subItems,
+ meta: {},
+ items: [
+ {
+ content,
+ meta: {},
+ items: [],
+ },
+ ],
+ style: config?.defaultStyle !== undefined ? config.defaultStyle : 'unordered',
};
- });
+ },
};
-
- // get pasted items.
- data.items = getPastedItems(element);
-
- return data;
}
/**
- * Renders children list
- *
- * @param {ListItem[]} items - items data to append
- * @param {Element} parentItem - where to append
- * @returns {void}
+ * Get list style name
*/
- appendItems(items: ListItem[], parentItem: Element): void {
- items.forEach((item) => {
- const itemEl = this.createItem(item.content, item.items);
-
- parentItem.appendChild(itemEl);
- });
+ private get listStyle(): ListDataStyle {
+ return this.data.style || this.defaultListStyle;
}
/**
- * Renders the single item
- *
- * @param {string} content - item content to render
- * @param {ListItem[]} [items] - children
- * @returns {Element}
+ * Set list style
+ * @param style - new style to set
*/
- createItem(content: string, items: ListItem[] = []): Element {
- const itemWrapper = Dom.make('li', this.CSS.item);
- const itemBody = Dom.make('div', this.CSS.itemBody);
- const itemContent = Dom.make('div', this.CSS.itemContent, {
- innerHTML: content,
- contentEditable: (!this.readOnly).toString(),
- });
+ private set listStyle(style: ListDataStyle) {
+ this.data.style = style;
- itemBody.appendChild(itemContent);
- itemWrapper.appendChild(itemBody);
+ this.changeTabulatorByStyle();
/**
- * Append children if we have some
+ * Create new list element
*/
- if (items && items.length > 0) {
- this.addChildrenList(itemWrapper, items);
- }
+ const newListElement = this.list!.render();
- return itemWrapper;
+ this.listElement?.replaceWith(newListElement);
+
+ this.listElement = newListElement;
}
/**
- * Extracts tool's data from the DOM
- *
- * @returns {ListData}
+ * The Editor.js API
*/
- save(): ListData {
- /**
- * The method for recursive collecting of the child items
- *
- * @param {Element} parent - where to find items
- * @returns {ListItem[]}
- */
- const getItems = (parent: Element): ListItem[] => {
- const children = Array.from(
- parent.querySelectorAll(`:scope > .${this.CSS.item}`)
- );
-
- return children.map((el) => {
- const subItemsWrapper = el.querySelector(`.${this.CSS.itemChildren}`);
- const content = this.getItemContent(el);
- const subItems = subItemsWrapper ? getItems(subItemsWrapper) : [];
-
- return {
- content,
- items: subItems,
- };
- });
- };
-
- return {
- style: this.data.style,
- items: this.nodes.wrapper ? getItems(this.nodes.wrapper) : [],
- };
- }
+ private api: API;
/**
- * Append children list to passed item
- *
- * @param {Element} parentItem - item that should contain passed sub-items
- * @param {ListItem[]} items - sub items to append
+ * Is Ediotrjs List Tool read-only
*/
- addChildrenList(parentItem: Element, items: ListItem[]): void {
- const itemBody = parentItem.querySelector(`.${this.CSS.itemBody}`);
- const sublistWrapper = this.makeListWrapper(undefined, [
- this.CSS.itemChildren,
- ]);
-
- this.appendItems(items, sublistWrapper);
-
- if (!itemBody) {
- return;
- }
-
- itemBody.appendChild(sublistWrapper);
- }
+ private readOnly: boolean;
/**
- * Creates main
or tag depended on style
- *
- * @param {string} [style] - 'ordered' or 'unordered'
- * @param {string[]} [classes] - additional classes to append
- * @returns {HTMLOListElement|HTMLUListElement}
+ * Tool's configuration
*/
- makeListWrapper(
- style: string = this.listStyle,
- classes: string[] = []
- ): HTMLOListElement | HTMLUListElement {
- const tag = style === 'ordered' ? 'ol' : 'ul';
- const styleClass =
- style === 'ordered' ? this.CSS.wrapperOrdered : this.CSS.wrapperUnordered;
-
- classes.push(styleClass);
-
- // since tag is either 'ol' or 'ul' we can safely cast it to HTMLOListElement | HTMLUListElement
- return Dom.make(tag, [this.CSS.wrapper, ...classes]) as
- | HTMLOListElement
- | HTMLUListElement;
- }
+ private config: ListConfig | undefined;
/**
- * Styles
- *
- * @returns {NestedListCssClasses} - CSS classes names by keys
- * @private
+ * Default list style formes as passed default list style from config or 'ordered' as default
*/
- get CSS(): NestedListCssClasses {
- return {
- baseBlock: this.api.styles.block,
- wrapper: 'cdx-nested-list',
- wrapperOrdered: 'cdx-nested-list--ordered',
- wrapperUnordered: 'cdx-nested-list--unordered',
- item: 'cdx-nested-list__item',
- itemBody: 'cdx-nested-list__item-body',
- itemContent: 'cdx-nested-list__item-content',
- itemChildren: 'cdx-nested-list__item-children',
- settingsWrapper: 'cdx-nested-list__settings',
- settingsButton: this.api.styles.settingsButton,
- settingsButtonActive: this.api.styles.settingsButtonActive,
- };
- }
+ private defaultListStyle?: ListConfig['defaultStyle'];
/**
- * Get list style name
- *
- * @returns {string}
+ * Tool's data
*/
- get listStyle(): string {
- return this.data.style || this.defaultListStyle;
- }
+ private data: ListData;
/**
- * Set list style
- *
- * @param {ListDataStyle} style - new style to set
+ * Editor block api
*/
- set listStyle(style: ListDataStyle) {
- if (!this.nodes) {
- return;
- }
- if (!this.nodes.wrapper) {
- return;
- }
- /**
- * Get lists elements
- *
- * @type {Element[]}
- */
- const lists: Element[] = Array.from(
- this.nodes.wrapper.querySelectorAll(`.${this.CSS.wrapper}`)
- );
-
- /**
- * Add main wrapper to the list
- */
- lists.push(this.nodes.wrapper);
-
- /**
- * For each list we need to update classes
- */
- lists.forEach((list) => {
- list.classList.toggle(this.CSS.wrapperUnordered, style === 'unordered');
- list.classList.toggle(this.CSS.wrapperOrdered, style === 'ordered');
- });
-
- /**
- * Update the style in data
- *
- * @type {ListDataStyle}
- */
- this.data.style = style;
- }
+ private block: BlockAPI;
/**
- * Returns current List item by the caret position
- *
- * @returns {Element}
+ * Class that is responsible for complete list rendering and saving
*/
- get currentItem(): Element | null {
- const selection = window.getSelection();
-
- if (!selection) {
- return null;
- }
- let currentNode = selection.anchorNode;
-
- if (!currentNode) {
- return null;
- }
-
- if (!isHtmlElement(currentNode)) {
- currentNode = currentNode.parentNode;
- }
- if (!currentNode) {
- return null;
- }
- if (!isHtmlElement(currentNode)) {
- return null;
- }
-
- return currentNode.closest(`.${this.CSS.item}`);
- }
+ private list: ListTabulator | undefined;
/**
- * Handles Enter keypress
- *
- * @param {KeyboardEvent} event - keydown
- * @returns {void}
+ * Main constant wrapper of the whole list
*/
- enterPressed(event: KeyboardEvent): void {
- const currentItem = this.currentItem;
-
- /**
- * Prevent editor.js behaviour
- */
- event.stopPropagation();
+ private listElement: HTMLElement | undefined;
- /**
- * Prevent browser behaviour
- */
- event.preventDefault();
-
- /**
- * Prevent duplicated event in Chinese, Japanese and Korean languages
- */
- if (event.isComposing) {
- return;
- }
-
- /**
- * On Enter in the last empty item, get out of list
- */
- const isEmpty = currentItem
- ? this.getItemContent(currentItem).trim().length === 0
- : true;
- const isFirstLevelItem = currentItem?.parentNode === this.nodes.wrapper;
- const isLastItem = currentItem?.nextElementSibling === null;
-
- if (isFirstLevelItem && isLastItem && isEmpty) {
- this.getOutOfList();
-
- return;
- } else if (isLastItem && isEmpty) {
- this.unshiftItem();
-
- return;
- }
+ /**
+ * Render plugin`s main Element and fill it with saved data
+ * @param params - tool constructor options
+ * @param params.data - previously saved data
+ * @param params.config - user config for Tool
+ * @param params.api - Editor.js API
+ * @param params.readOnly - read-only mode flag
+ */
+ constructor({ data, config, api, readOnly, block }: ListParams) {
+ this.api = api;
+ this.readOnly = readOnly;
+ this.config = config;
+ this.block = block;
/**
- * On other Enters, get content from caret till the end of the block
- * And move it to the new item
+ * Set the default list style from the config or presetted 'unordered'.
*/
- const endingFragment = Caret.extractFragmentFromCaretPositionTillTheEnd();
- if (!endingFragment) {
- return;
- }
- const endingHTML = Dom.fragmentToString(endingFragment);
- const itemChildren = currentItem?.querySelector(
- `.${this.CSS.itemChildren}`
- );
+ this.defaultListStyle = this.config?.defaultStyle || 'unordered';
- /**
- * Create the new list item
- */
- const itemEl = this.createItem(endingHTML, undefined);
+ const initialData = {
+ style: this.defaultListStyle,
+ meta: {},
+ items: [],
+ };
- /**
- * Check if child items exist
- *
- * @type {boolean}
- */
- const childrenExist =
- itemChildren &&
- Array.from(itemChildren.querySelectorAll(`.${this.CSS.item}`)).length > 0;
+ this.data = Object.keys(data).length ? normalizeData(data) : initialData;
/**
- * If item has children, prepend to them
- * Otherwise, insert the new item after current
+ * Assign default value of the property for the ordered list
*/
- if (childrenExist) {
- itemChildren.prepend(itemEl);
- } else {
- currentItem?.after(itemEl);
+ if (this.listStyle === 'ordered' && (this.data.meta as OrderedListItemMeta).counterType === undefined) {
+ (this.data.meta as OrderedListItemMeta).counterType = 'numeric';
}
- this.focusItem(itemEl);
+ this.changeTabulatorByStyle();
}
/**
- * Decrease indentation of the current item
- *
- * @returns {void}
+ * Convert from list to text for conversionConfig
+ * @param data - current data of the list
+ * @returns - string of the recursively merged contents of the items of the list
*/
- unshiftItem(): void {
- const currentItem = this.currentItem;
- if (!currentItem) {
- return;
- }
- if (!currentItem.parentNode) {
- return;
- }
- if (!isHtmlElement(currentItem.parentNode)) {
- return;
- }
-
- const parentItem = currentItem.parentNode.closest(`.${this.CSS.item}`);
-
- /**
- * If item in the first-level list then no need to do anything
- */
- if (!parentItem) {
- return;
- }
-
- this.caret.save();
-
- parentItem.after(currentItem);
-
- this.caret.restore();
-
- /**
- * If previous parent's children list is now empty, remove it.
- */
- const prevParentChildrenList = parentItem.querySelector(
- `.${this.CSS.itemChildren}`
- );
- if (!prevParentChildrenList) {
- return;
- }
- const isPrevParentChildrenEmpty =
- prevParentChildrenList.children.length === 0;
-
- if (isPrevParentChildrenEmpty) {
- prevParentChildrenList.remove();
- }
+ private static joinRecursive(data: ListData | ListItem): string {
+ return data.items
+ .map(item => `${item.content} ${EditorjsList.joinRecursive(item)}`)
+ .join('');
}
/**
- * Return the item content
- *
- * @param {Element} item - item wrapper (
)
- * @returns {string}
+ * Function that is responsible for content rendering
+ * @returns rendered list wrapper with all contents
*/
- getItemContent(item: Element): string {
- const contentNode = item.querySelector(`.${this.CSS.itemContent}`);
- if (!contentNode) {
- return '';
- }
-
- if (Dom.isEmpty(contentNode)) {
- return '';
- }
+ public render(): HTMLElement {
+ this.listElement = this.list!.render();
- return contentNode.innerHTML;
+ return this.listElement;
}
/**
- * Sets focus to the item's content
- *
- * @param {Element} item - item (
) to select
- * @param {boolean} atStart - where to set focus: at the start or at the end
- * @returns {void}
+ * Function that is responsible for content saving
+ * @returns formatted content used in editor
*/
- focusItem(item: Element, atStart: boolean = true): void {
- const itemContent = item.querySelector(
- `.${this.CSS.itemContent}`
- );
- if (!itemContent) {
- return;
- }
+ public save(): ListData {
+ this.data = this.list!.save();
- Caret.focus(itemContent, atStart);
+ return this.data;
}
/**
- * Get out from List Tool by Enter on the empty last item
- *
- * @returns {void}
+ * Function that is responsible for mergind two lists into one
+ * @param data - data of the next standing list, that should be merged with current
*/
- getOutOfList(): void {
- this.currentItem?.remove();
-
- this.api.blocks.insert();
- this.api.caret.setToBlock(this.api.blocks.getCurrentBlockIndex());
+ public merge(data: ListData): void {
+ this.list!.merge(data);
}
/**
- * Handle backspace
- *
- * @param {KeyboardEvent} event - keydown
+ * Creates Block Tune allowing to change the list style
+ * @returns array of tune configs
*/
- backspace(event: KeyboardEvent): void {
- /**
- * Caret is not at start of the item
- * Then backspace button should remove letter as usual
- */
- if (!Caret.isAtStart()) {
- return;
- }
-
- /**
- * Prevent default backspace behaviour
- */
- event.preventDefault();
-
- const currentItem = this.currentItem;
- if (!currentItem) {
- return;
- }
- const previousItem = currentItem.previousSibling;
- if (!currentItem.parentNode) {
- return;
- }
- if (!isHtmlElement(currentItem.parentNode)) {
- return;
- }
- const parentItem = currentItem.parentNode.closest(`.${this.CSS.item}`);
-
- /**
- * Do nothing with the first item in the first-level list.
- * No previous sibling means that this is the first item in the list.
- * No parent item means that this is a first-level list.
- *
- * Before:
- * 1. |Hello
- * 2. World!
- *
- * After:
- * 1. |Hello
- * 2. World!
- *
- * If it this item and the while list is empty then editor.js should
- * process this behaviour and remove the block completely
- *
- * Before:
- * 1. |
- *
- * After: block has been removed
- *
- */
- if (!previousItem && !parentItem) {
- return;
- }
-
- // make sure previousItem is an HTMLElement
- if (previousItem && !isHtmlElement(previousItem)) {
- return;
- }
-
- /**
- * Prevent editor.js behaviour
- */
- event.stopPropagation();
-
- /**
- * Lets compute the item which will be merged with current item text
- */
- let targetItem: Element | null;
-
- /**
- * If there is a previous item then we get a deepest item in its sublists
- *
- * Otherwise we will use the parent item
- */
- if (previousItem) {
- const childrenOfPreviousItem = previousItem.querySelectorAll(
- `.${this.CSS.item}`
- );
-
- targetItem = Array.from(childrenOfPreviousItem).pop() || previousItem;
- } else {
- targetItem = parentItem;
- }
-
- /**
- * Get content from caret till the end of the block to move it to the new item
- */
- const endingFragment = Caret.extractFragmentFromCaretPositionTillTheEnd();
- if (!endingFragment) {
- return;
- }
- const endingHTML = Dom.fragmentToString(endingFragment);
-
- /**
- * Get the target item content element
- */
- if (!targetItem) {
- return;
- }
- const targetItemContent = targetItem.querySelector(
- `.${this.CSS.itemContent}`
- );
-
- /**
- * Set a new place for caret
- */
- if (!targetItemContent) {
- return;
- }
- Caret.focus(targetItemContent, false);
-
- /**
- * Save the caret position
- */
- this.caret.save();
-
- /**
- * Update target item content by merging with current item html content
- */
- targetItemContent.insertAdjacentHTML('beforeend', endingHTML);
-
- /**
- * Get the sublist first-level items for current item
- */
- let currentItemSublistItems: NodeListOf | Element[] =
- currentItem.querySelectorAll(
- `.${this.CSS.itemChildren} > .${this.CSS.item}`
- );
+ public renderSettings(): MenuConfigItem[] {
+ const defaultTunes: MenuConfigItem[] = [
+ {
+ label: this.api.i18n.t('Unordered'),
+ icon: IconListBulleted,
+ closeOnActivate: true,
+ isActive: this.listStyle == 'unordered',
+ onActivate: () => {
+ this.listStyle = 'unordered';
+ },
+ },
+ {
+ label: this.api.i18n.t('Ordered'),
+ icon: IconListNumbered,
+ closeOnActivate: true,
+ isActive: this.listStyle == 'ordered',
+ onActivate: () => {
+ this.listStyle = 'ordered';
+ },
+ },
+ {
+ label: this.api.i18n.t('Checklist'),
+ icon: IconChecklist,
+ closeOnActivate: true,
+ isActive: this.listStyle == 'checklist',
+ onActivate: () => {
+ this.listStyle = 'checklist';
+ },
+ },
+ ];
- /**
- * Create an array from current item sublist items
- */
- currentItemSublistItems = Array.from(currentItemSublistItems);
+ if (this.listStyle === 'ordered') {
+ const startWithElement = renderToolboxInput(
+ (index: string) => this.changeStartWith(Number(index)),
+ {
+ value: String((this.data.meta as OrderedListItemMeta).start ?? 1),
+ placeholder: '',
+ attributes: {
+ required: 'true',
+ },
+ sanitize: input => stripNumbers(input),
+ });
+
+ const orderedListTunes: MenuConfigItem[] = [
+ {
+ label: this.api.i18n.t('Start with'),
+ children: {
+ items: [
+ {
+ element: startWithElement,
+ // @ts-expect-error ts(2820) can not use PopoverItem enum from editor.js types
+ type: 'html',
+ },
+ ],
+ },
+ },
+ ];
- /**
- * Filter items for sublist first-level
- * No need to move deeper items
- */
- currentItemSublistItems = currentItemSublistItems.filter((node) => {
- // make sure node.parentNode is an HTMLElement
- if (!node.parentNode) {
- return false;
- }
- if (!isHtmlElement(node.parentNode)) {
- return false;
- }
- return node.parentNode.closest(`.${this.CSS.item}`) === currentItem;
- });
+ const orderedListCountersTunes: MenuConfigItem = {
+ label: this.api.i18n.t('Counters type'),
+ children: {
+ items: [],
+ },
+ };
- /**
- * Reverse the array to insert items
- */
- currentItemSublistItems.reverse().forEach((item) => {
/**
- * Check if we need to save the indent for current item children
- *
- * If this is the first item in the list then place its children to the same level as currentItem.
- * Same as shift+tab for all of these children.
- *
- * If there is a previous sibling then place children right after target item
+ * For each counter type in OlCounterType create toolbox item
*/
- if (!previousItem) {
- /**
- * The first item in the list
- *
- * Before:
- * 1. Hello
- * 1.1. |My
- * 1.1.1. Wonderful
- * 1.1.2. World
- *
- * After:
- * 1. Hello|My
- * 1.1. Wonderful
- * 1.2. World
- */
- currentItem.after(item);
- } else {
- /**
- * Not the first item
- *
- * Before:
- * 1. Hello
- * 1.1. My
- * 1.2. |Dear
- * 1.2.1. Wonderful
- * 1.2.2. World
- *
- * After:
- * 1. Hello
- * 1.1. My|Dear
- * 1.2. Wonderful
- * 1.3. World
- */
- targetItem.after(item);
- }
- });
- /**
- * Remove current item element
- */
- currentItem.remove();
+ OlCounterTypesMap.forEach((_, counterType: string) => {
+ orderedListCountersTunes.children.items!.push({
+ title: this.api.i18n.t(counterType),
+ isActive: (this.data.meta as OrderedListItemMeta).counterType === OlCounterTypesMap.get(counterType),
+ closeOnActivate: true,
+ onActivate: () => {
+ this.changeCounters(OlCounterTypesMap.get(counterType) as OlCounterType);
+ },
+ });
+ });
- /**
- * Restore the caret position
- */
- this.caret.restore();
+ defaultTunes.push(...orderedListTunes, orderedListCountersTunes);
+ }
+
+ return defaultTunes;
}
/**
- * Add indentation to current item
- *
- * @param {KeyboardEvent} event - keydown
+ * On paste callback that is fired from Editor.
+ * @param event - event with pasted data
*/
- addTab(event: KeyboardEvent): void {
- /**
- * Prevent editor.js behaviour
- */
- event.stopPropagation();
+ public onPaste(event: PasteEvent): void {
+ const { tagName: tag } = event.detail.data;
- /**
- * Prevent browser tab behaviour
- */
- event.preventDefault();
-
- const currentItem = this.currentItem;
- if (!currentItem) {
- return;
- }
- const prevItem = currentItem.previousSibling;
- if (!prevItem) {
- return;
- }
- if (!isHtmlElement(prevItem)) {
- return;
- }
- const isFirstChild = !prevItem;
-
- /**
- * In the first item we should not handle Tabs (because there is no parent item above)
- */
- if (isFirstChild) {
- return;
+ switch (tag) {
+ case 'OL':
+ this.listStyle = 'ordered';
+ break;
+ case 'UL':
+ case 'LI':
+ this.listStyle = 'unordered';
}
- const prevItemChildrenList = prevItem.querySelector(
- `.${this.CSS.itemChildren}`
- );
-
- this.caret.save();
-
- /**
- * If prev item has child items, just append current to them
- */
- if (prevItemChildrenList) {
- prevItemChildrenList.appendChild(currentItem);
- } else {
- /**
- * If prev item has no child items
- * - Create and append children wrapper to the previous item
- * - Append current item to it
- */
- const sublistWrapper = this.makeListWrapper(undefined, [
- this.CSS.itemChildren,
- ]);
- const prevItemBody = prevItem.querySelector(`.${this.CSS.itemBody}`);
+ this.list!.onPaste(event);
+ }
- sublistWrapper.appendChild(currentItem);
- prevItemBody?.appendChild(sublistWrapper);
- }
+ /**
+ * Handle UL, OL and LI tags paste and returns List data
+ * @param element - html element that contains whole list
+ */
+ public pasteHandler(element: PasteEvent['detail']['data']): ListData {
+ const data = this.list!.pasteHandler(element);
- this.caret.restore();
+ return data;
}
/**
- * Reduce indentation for current item
- *
- * @param {KeyboardEvent} event - keydown
- * @returns {void}
+ * Changes ordered list counterType property value
+ * @param counterType - new value of the counterType value
*/
- shiftTab(event: KeyboardEvent): void {
- /**
- * Prevent editor.js behaviour
- */
- event.stopPropagation();
-
- /**
- * Prevent browser tab behaviour
- */
- event.preventDefault();
+ private changeCounters(counterType: OlCounterType): void {
+ this.list?.changeCounters(counterType);
- /**
- * Move item from current list to parent list
- */
- this.unshiftItem();
+ (this.data.meta as OrderedListItemMeta).counterType = counterType;
}
/**
- * Convert from list to text for conversionConfig
- *
- * @param {ListData} data
- * @returns {string}
+ * Changes ordered list start property value
+ * @param index - new value of the start property
*/
- static joinRecursive(data: ListData | ListItem): string {
- return data.items
- .map((item) => `${item.content} ${NestedList.joinRecursive(item)}`)
- .join('');
+ private changeStartWith(index: number): void {
+ this.list?.changeStartWith(index);
+
+ (this.data.meta as OrderedListItemMeta).start = index;
}
/**
- * Convert from text to list with import and export list to text
+ * This method allows changing tabulator respectfully to passed style
*/
- static get conversionConfig(): {
- export: (data: ListData) => string;
- import: (content: string) => ListData;
- } {
- return {
- export: (data) => {
- return NestedList.joinRecursive(data);
- },
- import: (content) => {
- return {
- items: [
- {
- content,
- items: [],
- },
- ],
- style: 'unordered',
- };
- },
- };
+ private changeTabulatorByStyle(): void {
+ switch (this.listStyle) {
+ case 'ordered':
+ this.list = new ListTabulator({
+ data: this.data,
+ readOnly: this.readOnly,
+ api: this.api,
+ config: this.config,
+ block: this.block,
+ },
+ new OrderedListRenderer(this.readOnly, this.config)
+ );
+
+ break;
+
+ case 'unordered':
+ this.list = new ListTabulator({
+ data: this.data,
+ readOnly: this.readOnly,
+ api: this.api,
+ config: this.config,
+ block: this.block,
+ },
+ new UnorderedListRenderer(this.readOnly, this.config)
+ );
+
+ break;
+
+ case 'checklist':
+ this.list = new ListTabulator({
+ data: this.data,
+ readOnly: this.readOnly,
+ api: this.api,
+ config: this.config,
+ block: this.block,
+ },
+ new CheckListRenderer(this.readOnly, this.config)
+ );
+
+ break;
+ }
}
}
diff --git a/src/styles/CssPrefix.ts b/src/styles/CssPrefix.ts
new file mode 100644
index 00000000..e1564c20
--- /dev/null
+++ b/src/styles/CssPrefix.ts
@@ -0,0 +1,4 @@
+/**
+ * Default css prefix for list
+ */
+export const CssPrefix = 'cdx-list';
diff --git a/src/styles/input.pcss b/src/styles/input.pcss
new file mode 100644
index 00000000..cc1ec08e
--- /dev/null
+++ b/src/styles/input.pcss
@@ -0,0 +1,36 @@
+.cdx-list-start-with-field {
+ background: #F8F8F8;
+ border: 1px solid rgba(226,226,229,0.20);
+ border-radius: 6px;
+ padding: 2px;
+ display: grid;
+ grid-template-columns: auto auto 1fr;
+ grid-template-rows: auto;
+
+ &--invalid {
+ background: #FFECED;
+ border: 1px solid #E13F3F;
+ }
+
+ &--invalid &__input {
+ color: #E13F3F;
+ }
+
+ &__input {
+ font-size: 14px;
+ outline: none;
+ font-weight: 500;
+ font-family: inherit;
+ border: 0;
+ background: transparent;
+ margin: 0;
+ padding: 0;
+ line-height: 22px;
+ min-width: calc(100% - var(--toolbox-buttons-size) - var(--icon-margin-right));
+
+ &::placeholder {
+ color: var(--grayText);
+ font-weight: 500;
+ }
+ }
+}
diff --git a/src/styles/list.pcss b/src/styles/list.pcss
new file mode 100644
index 00000000..b1d25b3b
--- /dev/null
+++ b/src/styles/list.pcss
@@ -0,0 +1,164 @@
+.cdx-list {
+ margin: 0;
+ padding: 0;
+ outline: none;
+ display: grid;
+ counter-reset: item;
+ gap: var(--spacing-s);
+ padding: var(--spacing-xs);
+ --spacing-s: 8px;
+ --spacing-xs: 6px;
+ --list-counter-type: numeric;
+ --radius-border: 5px;
+ --checkbox-background: #fff;
+ --color-border: #C9C9C9;
+ --color-bg-checked: #369FFF;
+ --line-height: 1.45em;
+ --color-bg-checked-hover: #0059AB;
+ --color-tick: #fff;
+ --size-checkbox: 1.2em;
+
+ &__item {
+ line-height: var(--line-height);
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto auto;
+ grid-template-areas:
+ "checkbox content"
+ ". child";
+
+ &-children {
+ display: grid;
+ grid-area: child;
+ gap: var(--spacing-s);
+ padding-top: var(--spacing-s);
+ }
+
+ [contenteditable]{
+ outline: none;
+ }
+
+ &-content {
+ word-break: break-word;
+ white-space: pre-wrap;
+ grid-area: content;
+ padding-left: var(--spacing-s);
+ }
+
+ &::before {
+ counter-increment: item;
+
+ white-space: nowrap;
+ }
+ }
+
+ &-ordered &__item::before {
+ content: counters(item, ".", var(--list-counter-type)) ".";
+ }
+
+ &-ordered {
+ counter-reset: item;
+ }
+
+ &-unordered &__item::before {
+ content: "•";
+ }
+
+ &-checklist &__item::before {
+ content: "";
+ }
+
+ &__settings {
+ .cdx-settings-button {
+ width: 50%;
+ }
+ }
+
+ &__checkbox {
+ padding-top: calc((var(--line-height) - var(--size-checkbox)) / 2);
+ grid-area: checkbox;
+ width: var(--size-checkbox);
+ height: var(--size-checkbox);
+ display: flex;
+ cursor: pointer;
+
+ svg {
+ opacity: 0;
+ height: var(--size-checkbox);
+ width: var(--size-checkbox);
+ left: -1px;
+ top: -1px;
+ position: absolute;
+ }
+
+ @media (hover: hover) {
+ &:not(&--no-hover):hover {
+ ^&-check {
+ svg {
+ opacity: 1;
+ }
+ }
+ }
+ }
+
+ &--checked {
+ line-height: var(--line-height);
+
+ @media (hover: hover) {
+ &:not(&--no-hover):hover {
+ .cdx-checklist__checkbox-check {
+ background: var(--color-bg-checked-hover);
+ border-color: var(--color-bg-checked-hover);
+ }
+ }
+ }
+
+
+ ^&-check {
+ background: var(--color-bg-checked);
+ border-color: var(--color-bg-checked);
+
+ svg {
+ opacity: 1;
+
+ path {
+ stroke: var(--color-tick);
+ }
+ }
+
+ &::before {
+ opacity: 0;
+ visibility: visible;
+ transform: scale(2.5);
+ }
+ }
+ }
+ &-check {
+ cursor: pointer;
+ display: inline-block;
+ position: relative;
+ margin: 0 auto;
+ width: var(--size-checkbox);
+ height: var(--size-checkbox);
+ box-sizing: border-box;
+ border-radius: var(--radius-border);
+ border: 1px solid var(--color-border);
+ background: var(--checkbox-background);
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ border-radius: 100%;
+ background-color: var(--color-bg-checked);
+ visibility: hidden;
+ pointer-events: none;
+ transform: scale(1);
+ transition: transform 400ms ease-out, opacity 400ms;
+ }
+ }
+ }
+}
diff --git a/src/types/Elements.ts b/src/types/Elements.ts
new file mode 100644
index 00000000..de1225cb
--- /dev/null
+++ b/src/types/Elements.ts
@@ -0,0 +1,14 @@
+/**
+ * Type that represents the list item
+ */
+export type ItemElement = HTMLElement;
+
+/**
+ * Type that represents children wrapper of the list item
+ */
+export type ItemChildWrapperElement = HTMLElement;
+
+/**
+ * Type that represents content element of the item
+ */
+export type ItemContentElement = HTMLElement;
diff --git a/src/types/ItemMeta.ts b/src/types/ItemMeta.ts
new file mode 100644
index 00000000..c5452953
--- /dev/null
+++ b/src/types/ItemMeta.ts
@@ -0,0 +1,40 @@
+import type { OlCounterType } from './OlCounterType';
+
+/**
+ * Meta information of each list item
+ */
+export interface ItemMetaBase {}
+
+/**
+ * Meta information of checklist item
+ */
+export interface ChecklistItemMeta extends ItemMetaBase {
+ /**
+ * State of the checkbox of the item
+ */
+ checked: boolean;
+}
+
+/**
+ * Meta information of ordered list item
+ */
+export interface OrderedListItemMeta extends ItemMetaBase {
+ /**
+ * If passed, ordered list counters will start with this index
+ */
+ start?: number;
+ /**
+ * Counters type used only in ordered list
+ */
+ counterType?: OlCounterType;
+}
+
+/**
+ * Meta information of unordered list item
+ */
+export interface UnorderedListItemMeta extends ItemMetaBase {}
+
+/**
+ * Type that represents all available meta objects for list item
+ */
+export type ItemMeta = ChecklistItemMeta | OrderedListItemMeta | UnorderedListItemMeta;
diff --git a/src/types/ListParams.ts b/src/types/ListParams.ts
new file mode 100644
index 00000000..e377b15f
--- /dev/null
+++ b/src/types/ListParams.ts
@@ -0,0 +1,90 @@
+import type { ItemMeta } from './ItemMeta';
+
+/**
+ * list style to make list as ordered or unordered
+ */
+export type ListDataStyle = 'ordered' | 'unordered' | 'checklist';
+
+/**
+ * Interface that represents data of the List tool
+ */
+export type ListData = Omit & {
+ /**
+ * Style of the list tool
+ */
+ style: ListDataStyle;
+};
+
+/**
+ * Interface that represents data of the List tool
+ */
+export interface OldListData {
+ /**
+ * Style of the List tool
+ */
+ style: 'ordered' | 'unordered';
+ /**
+ * Array of items of the List tool
+ */
+ items: string[];
+}
+
+/**
+ * Interface that represents old checklist data format
+ */
+export interface OldChecklistData {
+ /**
+ * Checklist items
+ */
+ items: OldChecklistItem[];
+}
+
+/**
+ * Interface that represents old checklist item format
+ */
+interface OldChecklistItem {
+ /**
+ * Text of the checklist item
+ */
+ text: string;
+ /**
+ * Checked state of the checklist item
+ */
+ checked: boolean;
+}
+
+/**
+ * List item within the output data
+ */
+export interface ListItem {
+ /**
+ * list item text content
+ */
+ content: string;
+
+ /**
+ * Meta information of each list item
+ */
+ meta: ItemMeta;
+
+ /**
+ * sublist items
+ */
+ items: ListItem[];
+}
+
+/**
+ * Tool's configuration
+ */
+export interface ListConfig {
+ /**
+ * default list style: ordered or unordered
+ * default is unordered
+ */
+ defaultStyle?: ListDataStyle;
+ /**
+ * Max level of the nesting in list
+ * If nesting is not needed, it could be set to 1
+ */
+ maxLevel?: number;
+}
diff --git a/src/types/ListRenderer.ts b/src/types/ListRenderer.ts
new file mode 100644
index 00000000..3a7fc11a
--- /dev/null
+++ b/src/types/ListRenderer.ts
@@ -0,0 +1,6 @@
+import type { CheckListRenderer, OrderedListRenderer, UnorderedListRenderer } from '../ListRenderer';
+
+/**
+ * Type that represents all possible list renderer types
+ */
+export type ListRenderer = CheckListRenderer | OrderedListRenderer | UnorderedListRenderer;
diff --git a/src/types/OlCounterType.ts b/src/types/OlCounterType.ts
new file mode 100644
index 00000000..b39e2ee4
--- /dev/null
+++ b/src/types/OlCounterType.ts
@@ -0,0 +1,31 @@
+export type OlCounterType = 'numeric' | 'upper-roman' | 'lower-roman' | 'upper-alpha' | 'lower-alpha';
+
+/**
+ * Enum that represents all of the supported styles of the counters for ordered list
+ */
+export const OlCounterTypesMap = new Map([
+ /**
+ * Value that represents default arabic numbers for counters
+ */
+ ['Numeric', 'numeric'],
+
+ /**
+ * Value that represents lower roman numbers for counteres
+ */
+ ['Lower Roman', 'lower-roman'],
+
+ /**
+ * Value that represents upper roman numbers for counters
+ */
+ ['Upper Roman', 'upper-roman'],
+
+ /**
+ * Value that represents lower alpha characters for counters
+ */
+ ['Lower Alpha', 'lower-alpha'],
+
+ /**
+ * Value that represents upper alpha characters for counters
+ */
+ ['Upper Alpha', 'upper-alpha'],
+]);
diff --git a/src/types/icons.d.ts b/src/types/icons.d.ts
deleted file mode 100644
index 79bafd56..00000000
--- a/src/types/icons.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// temporary fix for the missing types
-declare module '@codexteam/icons' {
- export const IconListBulleted: string;
- export const IconListNumbered: string;
-}
diff --git a/src/types/index.ts b/src/types/index.ts
index db3eb420..3ac5fde7 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -6,6 +6,9 @@ export interface PasteEvent extends CustomEvent {
* Pasted element
*/
detail: {
+ /**
+ * Supported elements fir the paste event
+ */
data: HTMLUListElement | HTMLOListElement | HTMLLIElement;
};
}
diff --git a/src/utils/caret.ts b/src/utils/caret.ts
deleted file mode 100644
index f26d1a33..00000000
--- a/src/utils/caret.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-import * as dom from './dom';
-import { isHtmlElement } from './type-guards';
-
-/**
- * Helper for working with caret
- */
-export default class Caret {
- /**
- * The for caret saving/restoring
- */
- savedFakeCaret: HTMLElement | undefined;
-
- /**
- * Store internal properties
- */
- constructor() {
- /**
- * The hidden for caret saving/restoring
- */
- this.savedFakeCaret = undefined;
- }
-
- /**
- * Saves caret position using hidden
- *
- * @returns {void}
- */
- save(): void {
- const range = Caret.range;
- const cursor = dom.make('span');
-
- cursor.hidden = true;
-
- if (!range) {
- return;
- }
- range.insertNode(cursor);
-
- this.savedFakeCaret = cursor;
- }
-
- /**
- * Restores the caret position saved by the save() method
- *
- * @returns {void}
- */
- restore(): void {
- if (!this.savedFakeCaret) {
- return;
- }
-
- const sel = window.getSelection();
- if (!sel) {
- return;
- }
-
- const range = new Range();
-
- range.setStartAfter(this.savedFakeCaret);
- range.setEndAfter(this.savedFakeCaret);
-
- sel.removeAllRanges();
- sel.addRange(range);
-
- /**
- * A little timeout uses to allow browser to set caret after element before we remove it.
- */
- setTimeout(() => {
- this.savedFakeCaret?.remove();
- }, 150);
- }
-
- /**
- * Returns the first range
- *
- * @returns {Range|null}
- */
- static get range(): Range | null {
- const selection = window.getSelection();
-
- return selection && selection.rangeCount ? selection.getRangeAt(0) : null;
- }
-
- /**
- * Extract content fragment from Caret position to the end of contenteditable element
- *
- * @returns {DocumentFragment|void}
- */
- static extractFragmentFromCaretPositionTillTheEnd(): DocumentFragment | void {
- const selection = window.getSelection();
-
- if (!selection) {
- return;
- }
-
- if (!selection.rangeCount) {
- return;
- }
-
- const selectRange = selection.getRangeAt(0);
- let startNode = selectRange.startContainer;
-
- /**
- * selectRange.startContainer can point to the Text node which has no .closest() method
- */
- if (startNode.nodeType !== Node.ELEMENT_NODE) {
- if (!startNode.parentNode) {
- return;
- }
- startNode = startNode.parentNode;
- }
-
- // if startNode is not htmlelement return
- if (!isHtmlElement(startNode)) {
- return;
- }
-
- const currentBlockInput = startNode.closest('[contenteditable]');
-
- if (!currentBlockInput) {
- return;
- }
-
- selectRange.deleteContents();
-
- const range = selectRange.cloneRange();
-
- range.selectNodeContents(currentBlockInput);
- range.setStart(selectRange.endContainer, selectRange.endOffset);
-
- return range.extractContents();
- }
-
- /**
- * Set focus to contenteditable or native input element
- *
- * @param {HTMLElement} element - element where to set focus
- * @param {boolean} atStart - where to set focus: at the start or at the end
- * @returns {void}
- */
- static focus(element: HTMLElement, atStart: boolean = true): void {
- const range = document.createRange();
- const selection = window.getSelection();
- if (!selection) {
- return;
- }
-
- range.selectNodeContents(element);
- range.collapse(atStart);
-
- selection.removeAllRanges();
- selection.addRange(range);
- }
-
- /**
- * Check if the caret placed at the start of the contenteditable element
- *
- * @returns {boolean}
- */
- static isAtStart(): boolean {
- const selection = window.getSelection();
-
- if (!selection) {
- return false;
- }
-
- if (selection.focusOffset > 0) {
- return false;
- }
-
- const focusNode = selection.focusNode;
-
- if (!focusNode) {
- return false;
- }
-
- // if focusNode is not htmlelement return false
- if (!isHtmlElement(focusNode)) {
- return false;
- }
-
- /**
- * In case of
- *
- *
<-- first (and deepest) node is
- * |adaddad <-- focus node
- *
- */
- const leftSiblings = Caret.getHigherLevelSiblings(focusNode, 'left');
-
- const nothingAtLeft = leftSiblings.every((node) => {
- return dom.isEmpty(node);
- });
-
- return nothingAtLeft;
- }
-
- /**
- * Get all first-level (first child of [contenteditabel]) siblings from passed node
- * Then you can check it for emptiness
- *
- * @example
- *