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 (
      1. ) - * @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 (
      2. ) 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"