diff --git a/index.html b/index.html
new file mode 100644
index 00000000..8e083bd1
--- /dev/null
+++ b/index.html
@@ -0,0 +1,232 @@
+
+
+
+
+ Editor.js 🤩🧦🤨 example
+
+
+
+
+
+
+
+
+
+
+
+
+ editor.save()
+
+
+
+ Readonly:
+
+ Off
+
+
+ toggle
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index cb9559b7..25e7e233 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,6 @@
"vite-plugin-dts": "^3.9.1"
},
"dependencies": {
- "@codexteam/icons": "^0.0.2"
+ "@codexteam/icons": "^0.3.2"
}
}
diff --git a/src/ListRenderer/ChecklistRenderer.ts b/src/ListRenderer/ChecklistRenderer.ts
new file mode 100644
index 00000000..c64b12a7
--- /dev/null
+++ b/src/ListRenderer/ChecklistRenderer.ts
@@ -0,0 +1,163 @@
+import { IconCheck } from '@codexteam/icons'
+import type { ChecklistItemMeta } from "../types/ItemMeta";
+import { NestedListConfig } from "../types/ListParams";
+import * as Dom from '../utils/Dom';
+import { ListRendererInterface, DefaultListCssClasses, CssPrefix } from './ListRenderer';
+import type { ListCssClasses } from './ListRenderer';
+
+interface ChecklistCssClasses extends ListCssClasses {
+ checklist: string;
+ itemChecked: string;
+ noHover: string;
+ checkbox: string;
+ checkboxContainer: string;
+}
+
+/**
+ * Class that is responsible for checklist rendering
+ */
+export class CheckListRenderer implements ListRendererInterface {
+ /**
+ * Tool's configuration
+ */
+ protected config?: NestedListConfig;
+
+ /**
+ * Is NestedList Tool read-only option
+ */
+ private readOnly: boolean;
+
+ 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`
+ }
+ }
+
+ constructor(readonly: boolean, config?: NestedListConfig) {
+ this.config = config;
+ this.readOnly = readonly;
+ }
+
+ /**
+ * Renders ul wrapper for list
+ * @param level - level of nesting (0 for the rool level)
+ * @returns - created html ul element
+ */
+ renderWrapper(level: number): HTMLUListElement {
+ let wrapperElement: HTMLUListElement;
+
+ /**
+ * Check if it's root level
+ */
+ if (level === 0) {
+ wrapperElement = Dom.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;
+ if (target){
+ const checkbox = target.closest(`.${CheckListRenderer.CSS.checkboxContainer}`);
+
+ if (checkbox && checkbox.contains(target)) {
+ this.toggleCheckbox(checkbox);
+ }
+ }
+ });
+ } else {
+ wrapperElement = Dom.make('ul', [CheckListRenderer.CSS.checklist, CheckListRenderer.CSS.itemChildren]) as HTMLUListElement;
+ }
+
+ return wrapperElement;
+ }
+
+ /**
+ * Redners list item element
+ * @param content - content of the list item
+ * @returns - created html list item element
+ */
+ renderItem(content: string, meta: ChecklistItemMeta ): HTMLLIElement {
+ const itemWrapper = Dom.make('li', [CheckListRenderer.CSS.item, CheckListRenderer.CSS.item]);
+ const itemContent = Dom.make('div', CheckListRenderer.CSS.itemContent, {
+ innerHTML: content,
+ contentEditable: (!this.readOnly).toString(),
+ });
+
+ const checkbox = Dom.make('span', CheckListRenderer.CSS.checkbox);
+ const checkboxContainer = Dom.make('div', CheckListRenderer.CSS.checkboxContainer);
+
+ if (meta && 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 {Element} item - item wrapper ()
+ * @returns {string}
+ */
+ getItemContent(item: Element): string {
+ const contentNode = item.querySelector(`.${CheckListRenderer.CSS.itemContent}`);
+ if (!contentNode) {
+ return '';
+ }
+
+ if (Dom.isEmpty(contentNode)) {
+ return '';
+ }
+
+ return contentNode.innerHTML;
+ }
+
+ /**
+ * 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): ChecklistItemMeta {
+ const checkbox = item.querySelector(`.${CheckListRenderer.CSS.checkboxContainer}`);
+
+ return {
+ checked: checkbox ? checkbox.classList.contains(CheckListRenderer.CSS.itemChecked) : false,
+ }
+ }
+
+ /**
+ * Toggle checklist item state
+ *
+ * @private
+ * @param {MouseEvent} event - click
+ * @returns {void}
+ */
+ 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
+ *
+ * @private
+ * @param {Element} el - item wrapper
+ * @returns {Element}
+ */
+ private removeSpecialHoverBehavior(el: Element) {
+ el.classList.remove(CheckListRenderer.CSS.noHover);
+ }
+}
+
diff --git a/src/ListRenderer/ListRenderer.ts b/src/ListRenderer/ListRenderer.ts
new file mode 100644
index 00000000..e5f9bcda
--- /dev/null
+++ b/src/ListRenderer/ListRenderer.ts
@@ -0,0 +1,58 @@
+
+/**
+ * Default css prefix for list
+ */
+export const CssPrefix = 'cdx-list';
+
+/**
+ * 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 {
+ wrapper: string;
+ item: string;
+ itemContent: string;
+ itemChildren: string;
+}
+
+/**
+ * Interface that represents all list renderer classes
+ */
+export interface ListRendererInterface {
+ /**
+ * Renders wrapper for list
+ * @param level - level of nesting (0 for the rool level)
+ * @returns - created html ul element
+ */
+ renderWrapper: (level: number) => 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;
+};
diff --git a/src/ListRenderer/OrderedListRenderer.ts b/src/ListRenderer/OrderedListRenderer.ts
new file mode 100644
index 00000000..e14f0c22
--- /dev/null
+++ b/src/ListRenderer/OrderedListRenderer.ts
@@ -0,0 +1,102 @@
+import type { OrderedListItemMeta } from "../types/ItemMeta";
+import { NestedListConfig } from "../types/ListParams";
+import * as Dom from '../utils/Dom';
+import { ListRendererInterface, DefaultListCssClasses, CssPrefix } from './ListRenderer';
+import type { ListCssClasses } from './ListRenderer';
+
+/**
+ * CSS classes for the Ordered list
+ */
+interface OrderedListCssClasses extends ListCssClasses {
+ orderedList: string;
+}
+
+/**
+ * Class that is responsible for ordered list rendering
+ */
+export class OrderedListRenderer implements ListRendererInterface {
+ /**
+ * Tool's configuration
+ */
+ protected config?: NestedListConfig;
+
+ /**
+ * Is NestedList Tool read-only option
+ */
+ private readOnly: boolean;
+
+ static get CSS(): OrderedListCssClasses {
+ return {
+ ...DefaultListCssClasses,
+ orderedList: `${CssPrefix}-ordered`,
+ }
+ }
+
+ constructor(readonly: boolean, config?: NestedListConfig) {
+ this.config = config;
+ this.readOnly = readonly;
+ }
+
+ /**
+ * Renders ol wrapper for list
+ * @param level - level of nesting (0 for the rool level)
+ * @returns - created html ol element
+ */
+ renderWrapper(level: number): HTMLOListElement {
+ let wrapperElement: HTMLOListElement;
+
+ /**
+ * Check if it's root level
+ */
+ if (level === 0) {
+ wrapperElement = Dom.make('ol', [OrderedListRenderer.CSS.wrapper, OrderedListRenderer.CSS.orderedList]) as HTMLOListElement;
+ } else {
+ wrapperElement = Dom.make('ol', [OrderedListRenderer.CSS.orderedList, OrderedListRenderer.CSS.itemChildren]) as HTMLOListElement;
+ }
+
+ return wrapperElement;
+ }
+ /**
+ * Redners list item element
+ * @param content - content of the list item
+ * @returns - created html list item element
+ */
+ renderItem(content: string, meta: OrderedListItemMeta): HTMLLIElement {
+ const itemWrapper = Dom.make('li', OrderedListRenderer.CSS.item);
+ const itemContent = Dom.make('div', OrderedListRenderer.CSS.itemContent, {
+ innerHTML: content,
+ contentEditable: (!this.readOnly).toString(),
+ });
+
+ itemWrapper.appendChild(itemContent);
+
+ return itemWrapper as HTMLLIElement;
+ }
+
+ /**
+ * Return the item content
+ *
+ * @param {Element} item - item wrapper ()
+ * @returns {string}
+ */
+ getItemContent(item: Element): string {
+ const contentNode = item.querySelector(`.${OrderedListRenderer.CSS.itemContent}`);
+ if (!contentNode) {
+ return '';
+ }
+
+ if (Dom.isEmpty(contentNode)) {
+ return '';
+ }
+
+ return contentNode.innerHTML;
+ }
+
+ /**
+ * Returns item meta, for ordered list
+ * @returns Item meta object
+ */
+ getItemMeta(): OrderedListItemMeta {
+ return {}
+ }
+}
diff --git a/src/ListRenderer/UnorderedListRenderer.ts b/src/ListRenderer/UnorderedListRenderer.ts
new file mode 100644
index 00000000..b0bbe942
--- /dev/null
+++ b/src/ListRenderer/UnorderedListRenderer.ts
@@ -0,0 +1,103 @@
+import type { UnorderedListItemMeta } from "../types/ItemMeta";
+import { NestedListConfig } from "../types/ListParams";
+import * as Dom from '../utils/Dom';
+import { ListRendererInterface, DefaultListCssClasses, CssPrefix } from './ListRenderer';
+import type { ListCssClasses } from './ListRenderer';
+
+interface UnoderedListCssClasses extends ListCssClasses {
+ unorderedList: string;
+}
+
+/**
+ * Class that is responsible for unordered list rendering
+ */
+export class UnorderedListRenderer implements ListRendererInterface {
+ /**
+ * Tool's configuration
+ */
+ protected config?: NestedListConfig;
+
+ /**
+ * Is NestedList Tool read-only option
+ */
+ private readOnly: boolean;
+
+ /**
+ * Getter for all CSS classes used in unordered list rendering
+ */
+ static get CSS(): UnoderedListCssClasses {
+ return {
+ ...DefaultListCssClasses,
+ unorderedList: `${CssPrefix}-unordered`,
+ }
+ }
+
+ constructor(readonly: boolean, config?: NestedListConfig) {
+ this.config = config;
+ this.readOnly = readonly;
+ }
+
+ /**
+ * Renders ol wrapper for list
+ * @param level - level of nesting (0 for the rool level)
+ * @returns - created html ul element
+ */
+ renderWrapper(level: number): HTMLUListElement {
+ let wrapperElement: HTMLUListElement;
+
+ /**
+ * Check if it's root level
+ */
+ if (level === 0) {
+ wrapperElement = Dom.make('ul', [UnorderedListRenderer.CSS.wrapper, UnorderedListRenderer.CSS.unorderedList]) as HTMLUListElement;
+ } else {
+ wrapperElement = Dom.make('ul', [UnorderedListRenderer.CSS.unorderedList, UnorderedListRenderer.CSS.itemChildren]) as HTMLUListElement;
+ }
+
+ return wrapperElement;
+ }
+
+ /**
+ * Redners list item element
+ * @param content - content of the list item
+ * @returns - created html list item element
+ */
+ renderItem(content: string, meta: UnorderedListItemMeta): HTMLLIElement {
+ const itemWrapper = Dom.make('li', UnorderedListRenderer.CSS.item);
+ const itemContent = Dom.make('div', UnorderedListRenderer.CSS.itemContent, {
+ innerHTML: content,
+ contentEditable: (!this.readOnly).toString(),
+ });
+
+ itemWrapper.appendChild(itemContent);
+
+ return itemWrapper as HTMLLIElement;
+ }
+
+ /**
+ * Return the item content
+ *
+ * @param {Element} item - item wrapper ()
+ * @returns {string}
+ */
+ getItemContent(item: Element): string {
+ const contentNode = item.querySelector(`.${UnorderedListRenderer.CSS.itemContent}`);
+ if (!contentNode) {
+ return '';
+ }
+
+ if (Dom.isEmpty(contentNode)) {
+ return '';
+ }
+
+ return contentNode.innerHTML;
+ }
+
+ /**
+ * Returns item meta, for unordered list
+ * @returns Item meta object
+ */
+ getItemMeta(): UnorderedListItemMeta {
+ return {}
+ }
+}
diff --git a/src/ListRenderer/index.ts b/src/ListRenderer/index.ts
new file mode 100644
index 00000000..477fb0ac
--- /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, CssPrefix } from './ListRenderer';
+
+export { CheckListRenderer, OrderedListRenderer, UnorderedListRenderer, DefaultListCssClasses, CssPrefix };
diff --git a/src/ListTabulator/index.ts b/src/ListTabulator/index.ts
new file mode 100644
index 00000000..4cbe8dff
--- /dev/null
+++ b/src/ListTabulator/index.ts
@@ -0,0 +1,827 @@
+import { CheckListRenderer } from "../ListRenderer/ChecklistRenderer";
+import { OrderedListRenderer } from "../ListRenderer/OrderedListRenderer";
+import { UnorderedListRenderer } from "../ListRenderer/UnorderedListRenderer";
+import { NestedListConfig, ListData, ListDataStyle } from "../types/ListParams"
+import { ListItem } from "../types/ListParams";
+import { isHtmlElement } from '../utils/type-guards';
+import Caret from '../utils/Caret';
+import { DefaultListCssClasses } from "../ListRenderer";
+import * as Dom from '../utils/Dom'
+import type { PasteEvent } from '../types';
+import type { API, PasteConfig } from '@editorjs/editorjs';
+import { ListParams } from "..";
+import { ChecklistItemMeta, OrderedListItemMeta, UnorderedListItemMeta } from "../types/ItemMeta";
+
+type ListRendererTypes = OrderedListRenderer | UnorderedListRenderer | CheckListRenderer;
+
+/**
+ * Class that is responsible for list tabulation
+ */
+export default class ListTabulator {
+ /**
+ * The Editor.js API
+ */
+ private api: API;
+
+ /**
+ * Caret helper
+ */
+ private caret: Caret;
+
+ /**
+ * Is NestedList Tool read-only option
+ */
+ private readOnly: boolean;
+
+ /**
+ * Tool's configuration
+ */
+ private config?: NestedListConfig;
+
+ /**
+ * Full content of the list
+ */
+ private data: ListData;
+
+ /**
+ * Current level of nesting for dynamyc updates
+ */
+ private currentLevel: number;
+
+ /**
+ * Style of the nested list
+ */
+ style: ListDataStyle;
+
+ /**
+ * Rendered list of items
+ */
+ list: ListRendererTypes | undefined;
+
+ /**
+ * Wrapper of the whole list
+ */
+ listWrapper: HTMLElement | undefined;
+
+ /**
+ * Returns current List item by the caret position
+ *
+ * @returns {Element}
+ */
+ 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(DefaultListCssClasses.item);
+ }
+
+ constructor({data, config, api, readOnly}: ListParams, style: ListDataStyle) {
+ this.config = config;
+ this.data = data;
+ this.style = style;
+ this.readOnly = readOnly;
+ this.api = api;
+ this.currentLevel = 0;
+
+ /**
+ * Instantiate caret helper
+ */
+ this.caret = new Caret();
+ }
+
+ /**
+ * Function that is responsible for rendering nested list with contents
+ * @returns Filled with content wrapper element of the list
+ */
+ render() {
+ switch (this.style) {
+ case 'ordered':
+ this.list = new OrderedListRenderer(this.readOnly, this.config);
+ break
+ case 'unordered':
+ this.list = new UnorderedListRenderer(this.readOnly, this.config);
+ break
+ case 'checklist':
+ this.list = new CheckListRenderer(this.readOnly, this.config);
+ break
+ }
+
+ this.listWrapper = this.list.renderWrapper(this.currentLevel);
+
+ // 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
+ );
+ }
+
+ return this.listWrapper;
+ }
+
+ /**
+ * Renders children list
+ *
+ * @param list - initialized ListRenderer instance
+ * @param {ListItem[]} items - items data to append
+ * @param {Element} parentItem - where to append
+ * @returns {void}
+ */
+ appendItems(items: ListItem[], parentItem: Element): void {
+ /**
+ * Update current nesting level
+ */
+ this.currentLevel += 1;
+
+ if (this.list !== undefined) {
+ items.forEach((item) => {
+ let itemEl: Element;
+
+ if (this.list instanceof OrderedListRenderer) {
+ itemEl = this.list!.renderItem(item.content, item.meta as OrderedListItemMeta);
+ }
+ else if (this.list instanceof UnorderedListRenderer) {
+ itemEl = this.list!.renderItem(item.content, item.meta as UnorderedListItemMeta);
+ }
+ else {
+ itemEl = this.list!.renderItem(item.content, item.meta as ChecklistItemMeta);
+ }
+
+ parentItem.appendChild(itemEl);
+
+ /**
+ * Check if there are child items
+ */
+ if (item.items.length) {
+ const sublistWrapper = this.list?.renderWrapper(this.currentLevel);
+
+ /**
+ * Recursively render child items, it will increase currentLevel varible
+ * after filling level with items we will need to decrease currentLevel
+ */
+ this.appendItems(item.items, sublistWrapper!);
+ this.currentLevel -= 1;
+
+ if (itemEl) {
+ itemEl.appendChild(sublistWrapper!);
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * Function that is responsible for list content saving
+ * @returns saved list data
+ */
+ 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 > .${DefaultListCssClasses.item}`)
+ );
+
+ return children.map((el) => {
+ const subItemsWrapper = el.querySelector(`.${DefaultListCssClasses.itemChildren}`);
+ const content = this.list!.getItemContent(el);
+ const meta = this.list!.getItemMeta(el);
+ const subItems = subItemsWrapper ? getItems(subItemsWrapper) : [];
+
+ return {
+ content,
+ meta,
+ items: subItems,
+ };
+ });
+ };
+
+ return {
+ style: this.data.style,
+ items: this.listWrapper ? getItems(this.listWrapper) : [],
+ };
+ }
+
+ /**
+ * On paste sanitzation config. Allow only tags that are allowed in the Tool.
+ *
+ * @returns {PasteConfig} - paste config.
+ */
+ 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.listWrapper;
+
+ 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}
+ */
+ 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 || '';
+
+ return {
+ content,
+ meta: {},
+ items: subItems,
+ };
+ });
+ };
+
+ // get pasted items.
+ data.items = getPastedItems(element);
+
+ return data;
+ }
+
+ /**
+ * Handles Enter keypress
+ *
+ * @param {KeyboardEvent} event - keydown
+ * @returns {void}
+ */
+ 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;
+ }
+
+ /**
+ * On Enter in the last empty item, get out of list
+ */
+ const isEmpty = currentItem
+ ? this.list?.getItemContent(currentItem).trim().length === 0
+ : true;
+ const isFirstLevelItem = currentItem?.parentNode === this.listWrapper;
+ const isLastItem = currentItem?.nextElementSibling === null;
+
+ if (isFirstLevelItem && isLastItem && isEmpty) {
+ this.getOutOfList();
+
+ return;
+ } else if (isLastItem && isEmpty) {
+ this.unshiftItem();
+
+ return;
+ }
+
+ /**
+ * On other Enters, get content from caret till the end of the block
+ * And move it to the new item
+ */
+ const endingFragment = Caret.extractFragmentFromCaretPositionTillTheEnd();
+ if (!endingFragment) {
+ return;
+ }
+ const endingHTML = Dom.fragmentToString(endingFragment);
+ const itemChildren = currentItem?.querySelector(
+ `.${DefaultListCssClasses.itemChildren}`
+ );
+
+ /**
+ * Create the new list item
+ */
+ const itemEl = this.list!.renderItem(endingHTML, { checked: false });
+
+ /**
+ * Check if child items exist
+ *
+ * @type {boolean}
+ */
+ const childrenExist =
+ itemChildren &&
+ Array.from(itemChildren.querySelectorAll(`.${DefaultListCssClasses.item}`)).length > 0;
+
+ /**
+ * If item has children, prepend to them
+ * Otherwise, insert the new item after current
+ */
+ if (childrenExist) {
+ itemChildren.prepend(itemEl);
+ } else {
+ currentItem?.after(itemEl);
+ }
+
+ this.focusItem(itemEl);
+ }
+
+ /**
+ * Handle backspace
+ *
+ * @param {KeyboardEvent} event - keydown
+ */
+ 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(`.${DefaultListCssClasses.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(
+ `.${DefaultListCssClasses.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(
+ `.${DefaultListCssClasses.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(
+ `.${DefaultListCssClasses.itemChildren} > .${DefaultListCssClasses.item}`
+ );
+
+ /**
+ * Create an array from current item sublist items
+ */
+ currentItemSublistItems = Array.from(currentItemSublistItems);
+
+ /**
+ * 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(`.${DefaultListCssClasses.item}`) === currentItem;
+ });
+
+ /**
+ * 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
+ */
+ 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();
+
+ /**
+ * Restore the caret position
+ */
+ this.caret.restore();
+ }
+
+
+ /**
+ * Reduce indentation for current item
+ *
+ * @param {KeyboardEvent} event - keydown
+ * @returns {void}
+ */
+ shiftTab(event: KeyboardEvent): void {
+ /**
+ * Prevent editor.js behaviour
+ */
+ event.stopPropagation();
+
+ /**
+ * Prevent browser tab behaviour
+ */
+ event.preventDefault();
+
+ /**
+ * Move item from current list to parent list
+ */
+ this.unshiftItem();
+ }
+
+
+ /**
+ * Decrease indentation of the current item
+ *
+ * @returns {void}
+ */
+ unshiftItem(): void {
+ const currentItem = this.currentItem;
+ if (!currentItem) {
+ return;
+ }
+ if (!currentItem.parentNode) {
+ return;
+ }
+ if (!isHtmlElement(currentItem.parentNode)) {
+ return;
+ }
+
+ const parentItem = currentItem.parentNode.closest(`.${DefaultListCssClasses.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(
+ `.${DefaultListCssClasses.itemChildren}`
+ );
+ if (!prevParentChildrenList) {
+ return;
+ }
+ const isPrevParentChildrenEmpty =
+ prevParentChildrenList.children.length === 0;
+
+ if (isPrevParentChildrenEmpty) {
+ prevParentChildrenList.remove();
+ }
+ }
+
+
+ /**
+ * Add indentation to current item
+ *
+ * @param {KeyboardEvent} event - keydown
+ */
+ addTab(event: KeyboardEvent): void {
+ /**
+ * Prevent editor.js behaviour
+ */
+ event.stopPropagation();
+
+ /**
+ * Prevent browser tab behaviour
+ */
+ event.preventDefault();
+
+ const currentItem = this.currentItem;
+
+ if (!currentItem) {
+ return;
+ }
+ const prevItem = currentItem.previousSibling;
+ if (!prevItem) {
+ return;
+ }
+ if (!isHtmlElement(prevItem)) {
+ return;
+ }
+ if (currentItem.querySelector(`.${DefaultListCssClasses.itemChildren}`) !== null) {
+ return;
+ }
+ const isFirstChild = !prevItem;
+
+ /**
+ * In the first item we should not handle Tabs (because there is no parent item above)
+ */
+ if (isFirstChild) {
+ return;
+ }
+
+ const prevItemChildrenList = prevItem.querySelector(
+ `.${DefaultListCssClasses.itemChildren}`
+ );
+
+ this.caret.save();
+
+ /**
+ * If prev item has child items, just append current to them
+ */
+ if (prevItemChildrenList) {
+ /**
+ * CurrentItem would not be removed soon (it should be cleared content and checkbox would be removed)
+ * after that elements with child items would be moveable too
+ */
+ currentItem.remove();
+ const newSublistItem = this.list!.renderItem(this.list!.getItemContent(currentItem), {checked: false});
+ prevItemChildrenList.appendChild(newSublistItem);
+ } else {
+ /**
+ * CurrentItem would not be removed soon (it should be cleared content and checkbox would be removed)
+ * after that elements with child items would be moveable too
+ */
+ currentItem.remove();
+ /**
+ * If prev item has no child items
+ * - Create and append children wrapper to the previous item
+ * - Append current item to it
+ */
+ const sublistWrapper = this.list!.renderWrapper(1);
+ const newSublistItem = this.list!.renderItem(this.list!.getItemContent(currentItem), {checked: false});
+
+ sublistWrapper.appendChild(newSublistItem);
+
+ console.log(prevItem, sublistWrapper)
+
+ prevItem?.appendChild(sublistWrapper);
+ }
+
+ this.caret.restore();
+ }
+
+ /**
+ * 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}
+ */
+ focusItem(item: Element, atStart: boolean = true): void {
+ const itemContent = item.querySelector(
+ `.${DefaultListCssClasses.itemContent}`
+ );
+ if (!itemContent) {
+ return;
+ }
+
+ Caret.focus(itemContent, atStart);
+ }
+
+ /**
+ * Get out from List Tool by Enter on the empty last item
+ *
+ * @returns {void}
+ */
+ getOutOfList(): void {
+ this.currentItem?.remove();
+
+ this.api.blocks.insert();
+ this.api.caret.setToBlock(this.api.blocks.getCurrentBlockIndex());
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 2a15f7ad..ba0d4f12 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,92 +1,24 @@
import type { API, PasteConfig, ToolboxConfig } from '@editorjs/editorjs';
-import type { PasteEvent } from './types';
import type {
BlockToolConstructorOptions,
TunesMenuConfig,
} 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 { NestedListConfig, ListData, ListDataStyle, ListItem } from './types/ListParams';
+import ListTabulator from './ListTabulator';
/**
* 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
->;
-
-/**
- * CSS classes for the Nested List Tool
- */
-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 {
/**
@@ -122,6 +54,43 @@ export default class NestedList {
};
}
+ /**
+ * Get list style name
+ *
+ * @returns {string}
+ */
+ get listStyle(): ListDataStyle {
+ return this.data.style || this.defaultListStyle;
+ }
+
+ /**
+ * Set list style
+ *
+ * @param {ListDataStyle} style - new style to set
+ */
+ set listStyle(style: ListDataStyle) {
+ this.data.style = style;
+
+ /**
+ * Create new instance of list
+ */
+ this.list = new ListTabulator(
+ {
+ data: this.data,
+ api: this.api,
+ readOnly: this.readOnly,
+ config: this.config,
+ },
+ this.listStyle
+ );
+
+ const newListElement = this.list.render()
+
+ this.listElement?.replaceWith(newListElement);
+
+ this.listElement = newListElement;
+ }
+
/**
* The Editor.js API
*/
@@ -143,19 +112,20 @@ export default class NestedList {
private defaultListStyle?: NestedListConfig['defaultStyle'];
/**
- * Corresponds to UiNodes type from Editor.js but with wrapper being nullable
+ * Tool's data
*/
- private nodes: { wrapper: HTMLElement | null };
+ private data: ListData;
/**
- * Tool's data
+ * Class that is responsible for list complete list rendering and saving
*/
- private data: ListData;
+ list: ListTabulator | undefined;
/**
- * Caret helper
+ * Main constant wrapper of the whole list
*/
- private caret: Caret;
+ listElement: HTMLElement | undefined;
+
/**
* Render plugin`s main Element and fill it with saved data
@@ -166,95 +136,58 @@ export default class NestedList {
* @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,
- };
-
+ constructor({ data, config, api, readOnly }: ListParams) {
this.api = api;
this.readOnly = readOnly;
this.config = config;
/**
- * Set the default list style from the config.
+ * Set the default list style from the config or presetted 'ordered'.
*/
- this.defaultListStyle =
- this.config?.defaultStyle === 'ordered' ? 'ordered' : 'unordered';
+ this.defaultListStyle = this.config?.defaultStyle || 'ordered';
const initialData = {
style: this.defaultListStyle,
items: [],
};
- this.data = data && Object.keys(data).length ? data : initialData;
- /**
- * Instantiate caret helper
- */
- this.caret = new Caret();
+ this.data = data && Object.keys(data).length ? data : initialData;
}
/**
- * Returns list tag with items
- *
- * @returns {Element}
- * @public
+ * Function that is responsible for content rendering
+ * @returns rendered list wrapper with all contents
*/
- render(): Element {
- this.nodes.wrapper = this.makeListWrapper(this.data.style, [
- this.CSS.baseBlock,
- ]);
+ render() {
+ this.list = new ListTabulator({
+ data: this.data,
+ readOnly: this.readOnly,
+ api: this.api,
+ config: this.config,
+ },
+ this.listStyle
+ );
- // fill with data
- if (this.data.items.length) {
- this.appendItems(this.data.items, this.nodes.wrapper);
- } else {
- this.appendItems(
- [
- {
- content: '',
- items: [],
- },
- ],
- this.nodes.wrapper
- );
- }
+ this.listElement = this.list.render();
- 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.listElement;
+ }
- return this.nodes.wrapper;
+ /**
+ * Function that is responsible for content saving
+ * @returns formatted content used in editor
+ */
+ save() {
+ this.data = this.list!.save();
+
+ return this.data
}
/**
* Creates Block Tune allowing to change the list style
*
* @public
- * @returns {Array}
+ * @returns {Array} array of tune configs
*/
renderSettings(): TunesMenuConfig {
const tunes = [
@@ -268,6 +201,11 @@ export default class NestedList {
label: this.api.i18n.t('Ordered'),
icon: IconListNumbered,
},
+ {
+ name: 'checklist' as const,
+ label: this.api.i18n.t('Checklist'),
+ icon: IconChecklist,
+ }
];
return tunes.map((tune) => ({
@@ -293,787 +231,6 @@ export default class NestedList {
};
}
- /**
- * 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}
- */
- 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 || '';
-
- return {
- content,
- items: subItems,
- };
- });
- };
-
- // 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}
- */
- appendItems(items: ListItem[], parentItem: Element): void {
- items.forEach((item) => {
- const itemEl = this.createItem(item.content, item.items);
-
- parentItem.appendChild(itemEl);
- });
- }
-
- /**
- * Renders the single item
- *
- * @param {string} content - item content to render
- * @param {ListItem[]} [items] - children
- * @returns {Element}
- */
- 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(),
- });
-
- itemBody.appendChild(itemContent);
- itemWrapper.appendChild(itemBody);
-
- /**
- * Append children if we have some
- */
- if (items && items.length > 0) {
- this.addChildrenList(itemWrapper, items);
- }
-
- return itemWrapper;
- }
-
- /**
- * Extracts tool's data from the DOM
- *
- * @returns {ListData}
- */
- 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) : [],
- };
- }
-
- /**
- * Append children list to passed item
- *
- * @param {Element} parentItem - item that should contain passed sub-items
- * @param {ListItem[]} items - sub items to append
- */
- 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);
- }
-
- /**
- * Creates main or tag depended on style
- *
- * @param {string} [style] - 'ordered' or 'unordered'
- * @param {string[]} [classes] - additional classes to append
- * @returns {HTMLOListElement|HTMLUListElement}
- */
- 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;
- }
-
- /**
- * Styles
- *
- * @returns {NestedListCssClasses} - CSS classes names by keys
- * @private
- */
- 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,
- };
- }
-
- /**
- * Get list style name
- *
- * @returns {string}
- */
- get listStyle(): string {
- return this.data.style || this.defaultListStyle;
- }
-
- /**
- * Set list style
- *
- * @param {ListDataStyle} style - new style to set
- */
- 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;
- }
-
- /**
- * Returns current List item by the caret position
- *
- * @returns {Element}
- */
- 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}`);
- }
-
- /**
- * Handles Enter keypress
- *
- * @param {KeyboardEvent} event - keydown
- * @returns {void}
- */
- 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;
- }
-
- /**
- * 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;
- }
-
- /**
- * On other Enters, get content from caret till the end of the block
- * And move it to the new item
- */
- const endingFragment = Caret.extractFragmentFromCaretPositionTillTheEnd();
- if (!endingFragment) {
- return;
- }
- const endingHTML = Dom.fragmentToString(endingFragment);
- const itemChildren = currentItem?.querySelector(
- `.${this.CSS.itemChildren}`
- );
-
- /**
- * Create the new list item
- */
- const itemEl = this.createItem(endingHTML, undefined);
-
- /**
- * Check if child items exist
- *
- * @type {boolean}
- */
- const childrenExist =
- itemChildren &&
- Array.from(itemChildren.querySelectorAll(`.${this.CSS.item}`)).length > 0;
-
- /**
- * If item has children, prepend to them
- * Otherwise, insert the new item after current
- */
- if (childrenExist) {
- itemChildren.prepend(itemEl);
- } else {
- currentItem?.after(itemEl);
- }
-
- this.focusItem(itemEl);
- }
-
- /**
- * Decrease indentation of the current item
- *
- * @returns {void}
- */
- 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();
- }
- }
-
- /**
- * Return the item content
- *
- * @param {Element} item - item wrapper (- )
- * @returns {string}
- */
- getItemContent(item: Element): string {
- const contentNode = item.querySelector(`.${this.CSS.itemContent}`);
- if (!contentNode) {
- return '';
- }
-
- if (Dom.isEmpty(contentNode)) {
- return '';
- }
-
- return contentNode.innerHTML;
- }
-
- /**
- * 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}
- */
- focusItem(item: Element, atStart: boolean = true): void {
- const itemContent = item.querySelector(
- `.${this.CSS.itemContent}`
- );
- if (!itemContent) {
- return;
- }
-
- Caret.focus(itemContent, atStart);
- }
-
- /**
- * Get out from List Tool by Enter on the empty last item
- *
- * @returns {void}
- */
- getOutOfList(): void {
- this.currentItem?.remove();
-
- this.api.blocks.insert();
- this.api.caret.setToBlock(this.api.blocks.getCurrentBlockIndex());
- }
-
- /**
- * Handle backspace
- *
- * @param {KeyboardEvent} event - keydown
- */
- 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}`
- );
-
- /**
- * Create an array from current item sublist items
- */
- currentItemSublistItems = Array.from(currentItemSublistItems);
-
- /**
- * 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;
- });
-
- /**
- * 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
- */
- 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();
-
- /**
- * Restore the caret position
- */
- this.caret.restore();
- }
-
- /**
- * Add indentation to current item
- *
- * @param {KeyboardEvent} event - keydown
- */
- addTab(event: KeyboardEvent): void {
- /**
- * Prevent editor.js behaviour
- */
- event.stopPropagation();
-
- /**
- * 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;
- }
-
- 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}`);
-
- sublistWrapper.appendChild(currentItem);
- prevItemBody?.appendChild(sublistWrapper);
- }
-
- this.caret.restore();
- }
-
- /**
- * Reduce indentation for current item
- *
- * @param {KeyboardEvent} event - keydown
- * @returns {void}
- */
- shiftTab(event: KeyboardEvent): void {
- /**
- * Prevent editor.js behaviour
- */
- event.stopPropagation();
-
- /**
- * Prevent browser tab behaviour
- */
- event.preventDefault();
-
- /**
- * Move item from current list to parent list
- */
- this.unshiftItem();
- }
-
/**
* Convert from list to text for conversionConfig
*
@@ -1102,6 +259,7 @@ export default class NestedList {
items: [
{
content,
+ meta: {},
items: [],
},
],
diff --git a/src/types/ItemMeta.ts b/src/types/ItemMeta.ts
new file mode 100644
index 00000000..fd78f025
--- /dev/null
+++ b/src/types/ItemMeta.ts
@@ -0,0 +1,24 @@
+/**
+ * Meta information of each list item
+ */
+export interface ItemMeta {};
+
+/**
+ * Meta information of checklist item
+ */
+export interface ChecklistItemMeta extends ItemMeta {
+ /**
+ * State of the checkbox of the item
+ */
+ checked: boolean;
+};
+
+/**
+ * Meta information of ordered list item
+ */
+export interface OrderedListItemMeta extends ItemMeta {};
+
+/**
+ * Meta information of unordered list item
+ */
+export interface UnorderedListItemMeta extends ItemMeta {};
diff --git a/src/types/ListParams.ts b/src/types/ListParams.ts
new file mode 100644
index 00000000..9316df25
--- /dev/null
+++ b/src/types/ListParams.ts
@@ -0,0 +1,51 @@
+import { ChecklistItemMeta, OrderedListItemMeta, UnorderedListItemMeta } from "./ItemMeta";
+
+/**
+ * list style to make list as ordered or unordered
+ */
+export type ListDataStyle = 'ordered' | 'unordered' | 'checklist';
+
+/**
+ * Output data
+ */
+export interface ListData {
+ /**
+ * list type 'ordered' or 'unordered'
+ */
+ style: ListDataStyle;
+ /**
+ * list of first-level elements
+ */
+ items: ListItem[];
+}
+
+/**
+ * List item within the output data
+ */
+export interface ListItem {
+ /**
+ * list item text content
+ */
+ content: string;
+
+ /**
+ * Meta information of each list item
+ */
+ meta: OrderedListItemMeta | UnorderedListItemMeta | ChecklistItemMeta;
+
+ /**
+ * sublist items
+ */
+ items: ListItem[];
+}
+
+/**
+ * Tool's configuration
+ */
+export interface NestedListConfig {
+ /**
+ * default list style: ordered or unordered
+ * default is unordered
+ */
+ defaultStyle?: ListDataStyle;
+}
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/utils/caret.ts b/src/utils/Caret.ts
similarity index 99%
rename from src/utils/caret.ts
rename to src/utils/Caret.ts
index f26d1a33..69e982f0 100644
--- a/src/utils/caret.ts
+++ b/src/utils/Caret.ts
@@ -1,4 +1,4 @@
-import * as dom from './dom';
+import * as dom from './Dom';
import { isHtmlElement } from './type-guards';
/**
diff --git a/src/utils/dom.ts b/src/utils/Dom.ts
similarity index 100%
rename from src/utils/dom.ts
rename to src/utils/Dom.ts
diff --git a/styles/index.pcss b/styles/index.pcss
index d6edb682..832a22ee 100644
--- a/styles/index.pcss
+++ b/styles/index.pcss
@@ -1,35 +1,43 @@
-.cdx-nested-list {
+.cdx-list {
margin: 0;
padding: 0;
outline: none;
counter-reset: item;
list-style: none;
+ --radius-border: 5px;
+ --checkbox-background: #fff;
+ --color-border: #C9C9C9;
+ --color-bg-checked: #369FFF;
+ --line-height: 1.6em;
+ --color-bg-checked-hover: #0059AB;
+ --color-tick: #fff;
+ --size-checkbox: 1.2em;
&__item {
- line-height: 1.6em;
- display: flex;
+ line-height: var(--line-height);
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto auto;
+ grid-template-areas:
+ "checkbox content"
+ ". child";
margin: 2px 0;
- [contenteditable]{
- outline: none;
- }
-
- &-body {
- flex-grow: 2;
+ &-children {
+ grid-area: child;
}
- &-content,
- &-children {
- flex-basis: 100%;
+ [contenteditable]{
+ outline: none;
}
&-content {
+ margin-left: 8px;
word-break: break-word;
white-space: pre-wrap;
+ grid-area: content;
}
- &-children {}
-
&::before {
counter-increment: item;
margin-right: 5px;
@@ -37,19 +45,113 @@
}
}
- &--ordered > &__item::before {
- content: counters(item, ".") ". ";
+ &-ordered &__item::before {
+ content: counters(item, ".") ".";
+ }
+
+ &-ordered {
+ counter-reset: item;
}
- &--unordered > &__item::before {
+ &-unordered &__item::before {
content: "•";
}
- &__settings {
- display: flex;
+ &-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/vite.config.js b/vite.config.js
index 64b79224..4a6bc518 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -20,5 +20,9 @@ export default {
VERSION: JSON.stringify(VERSION),
},
+ server: {
+ port: 3303,
+ open: true,
+ },
plugins: [cssInjectedByJsPlugin(), dts()],
};
diff --git a/yarn.lock b/yarn.lock
index 7d6a7796..95902792 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -25,10 +25,10 @@
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.7.tgz#9a5226f92f0c5c8ead550b750f5608e766c8ce85"
integrity sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==
-"@codexteam/icons@^0.0.2":
- version "0.0.2"
- resolved "https://registry.yarnpkg.com/@codexteam/icons/-/icons-0.0.2.tgz#9183996a38b75a93506890373a015e3a2a369264"
- integrity sha512-KdeKj3TwaTHqM3IXd5YjeJP39PBUZTb+dtHjGlf5+b0VgsxYD4qzsZkb11lzopZbAuDsHaZJmAYQ8LFligIT6Q==
+"@codexteam/icons@^0.3.2":
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/@codexteam/icons/-/icons-0.3.2.tgz#b7aed0ba7b344e07953101f5476cded570d4f150"
+ integrity sha512-P1ep2fHoy0tv4wx85eic+uee5plDnZQ1Qa6gDfv7eHPkCXorMtVqJhzMb75o1izogh6G7380PqmFDXV3bW3Pig==
"@editorjs/editorjs@^2.29.1":
version "2.29.1"