diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 137de7ae..00000000 --- a/.eslintrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "globals": { - "HTMLOListElement": true, - "HTMLUListElement": true - }, - "parserOptions": { - "sourceType": "module", - "ecmaVersion": 2020 - }, - "parser": "@typescript-eslint/parser" -} diff --git a/README.md b/README.md index e6b0b9a5..3b7cd537 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,50 @@ ![](https://badgen.net/badge/Editor.js/v2.19.2/blue) -# Nested List Tool for Editor.js +# Editorjs List Tool -Multi-leveled lists for the [Editor.js](https://editorjs.io). +- 🤩 Part of [Editor.js](https://editorjs.io/) ecosystem. +- 📂 Nesting. +- 🔥 Ordered and Unordered lists. +- ✅ Checklists. +- 🔢 Customizable start number. +- 🏛️ Customizable counter type (e.g. `lower-roman`). +- 🪜 Max nesting level configuration. +- 📝 Compatible with [List](https://github.com/editor-js/list) and [Checklist](https://github.com/editor-js/checklist). -Use `Tab` and `Shift+Tab` keys to create or remove sublist with a padding. +![](assets/demo.gif) -![](assets/example.gif) +Use `Tab` and `Shift+Tab` keys to create or remove sublist with a padding. ## Installation Get the package ```shell -yarn add @editorjs/nested-list +yarn add @editorjs/list ``` Include module at your application ```javascript -import NestedList from '@editorjs/nested-list'; +import List from '@editorjs/list'; ``` -Optionally, you can load this tool from CDN [JsDelivr CDN](https://cdn.jsdelivr.net/npm/@editorjs/nested-list@latest) +Optionally, you can load this tool from CDN [JsDelivr CDN](https://cdn.jsdelivr.net/npm/@editorjs/list@latest) ## Usage -Add the NestedList Tool to the `tools` property of the Editor.js initial config. +Add the List Tool to the `tools` property of the Editor.js initial config. ```javascript import EditorJS from '@editorjs/editorjs'; -import NestedList from '@editorjs/nested-list'; +import List from '@editorjs/list'; var editor = EditorJS({ // ... tools: { ... list: { - class: NestedList, + class: List, inlineToolbar: true, config: { defaultStyle: 'unordered' @@ -51,58 +58,115 @@ var editor = EditorJS({ | Field | Type | Description | |--------------|----------|----------------------------------------------------------------| -| defaultStyle | `string` | default list style: `ordered` or `unordered`, default is `unordered` | +| defaultStyle | `string` | default list style: `ordered`, `unordered` or `checklist`, default is `unordered` | +| maxLevel | `number` | maximum level of the list nesting, could be set to `1` to disable nesting, unlimited by default | -## Tool's settings +## Output data -![](assets/bf5a42e4-1350-499d-a728-493b0fcaeda4.jpg) +| Field | Type | Description | +| ----------------- | --------- | ------------------------------------------------------------------------------------------------------------------------- | +| style | `string` | list will be rendered with this style: `ordered`, `unordered` or `checklist`, default is `defaultStyle` from tool config | +| meta | `ItemMeta`| Item meta based on the list style | +| items | `Item[]` | the array of list's items | -You can choose list`s type. +Object `Item`: -## Output data +| Field | Type | Description | +| ------- | ---------- | --------------------------- | +| content | `string` | item's string content | +| meta | `ItemMeta` | meta information about item | +| items | `Item[]` | the array of list's items | -| Field | Type | Description | -| ----- | --------- | ---------------------------------------- | -| style | `string` | type of a list: `ordered` or `unordered` | -| items | `Item[]` | the array of list's items | +Object `ItemMeta` for Checklist: -Object `Item`: +| Field | Type | Description | +| ------- | --------- | ------------------------- | +| checked | `boolean` | state of the checkbox | + +Object `ItemMeta` for Ordered list | Field | Type | Description | | ------- | --------- | ------------------------- | -| content | `string` | item's string content | -| items | `Item[]` | the array of list's items | +| start | `number` | number for list to start with, default is 1 | +| counterType | `string` | counter type for list, it could be `numeric`, `lower-roman`, `upper-roman`, `lower-alpha`, `upper-alpha`, default is `numeric` | + + +Object `ItemMeta` for Unordered list would be empty. +## Example of the content for `Unordered List` ```json { - "type" : "list", - "data" : { - "style" : "unordered", - "items" : [ - { - "content": "Apples", - "items": [ - { - "content": "Red", - "items": [] - }, - { - "content": "Green", - "items": [] - }, - ] - }, - { - "content": "Bananas", - "items": [ - { - "content": "Yellow", - "items": [] - }, - ] + "type" : "list", + "data" : { + "style": "unordered", + "items": [ + { + "content": "Apples", + "meta": {}, + "items": [ + { + "content": "Red", + "meta": {}, + "items": [] + }, + ] + }, + ] + } +}, +``` + +## Example of the content for `Ordered List` +```json +{ + "type" : "list", + "data" : { + "style": "ordered", + "meta": { + "start": 2, + "counterType": "upper-roman", + }, + "items" : [ + { + "content": "Apples", + "meta": {}, + "items": [ + { + "content": "Red", + "meta": {}, + "items": [] + }, + ] + }, + ] + } +}, +``` + +## Example of the content for `Checklist` +```json +{ + "type" : "list", + "data" : { + "style": "checklist", + "items" : [ + { + "content": "Apples", + "meta": { + "checked": false + }, + "items": [ + { + "content": "Red", + "meta": { + "checked": true }, + "items": [] + }, ] - } + }, + ] + } }, ``` diff --git a/assets/bf5a42e4-1350-499d-a728-493b0fcaeda4.jpg b/assets/bf5a42e4-1350-499d-a728-493b0fcaeda4.jpg deleted file mode 100644 index 44405511..00000000 Binary files a/assets/bf5a42e4-1350-499d-a728-493b0fcaeda4.jpg and /dev/null differ diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 00000000..7e01501f Binary files /dev/null and b/assets/demo.gif differ diff --git a/assets/example.gif b/assets/example.gif deleted file mode 100644 index 0d849790..00000000 Binary files a/assets/example.gif and /dev/null differ diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..16eebf9c --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,62 @@ +import CodeX from 'eslint-config-codex'; +import { plugin as TsPlugin, parser as TsParser } from 'typescript-eslint'; + +export default [ + ...CodeX, + + /** + * Redefine language options and some of the rules of CodeX eslint config for javascript config + */ + { + files: ['vite.config.js', 'eslint.config.mjs', 'postcss.config.js', '**/json-preview.js'], + languageOptions: { + parserOptions: { + project: './tsconfig.eslint.json', + ecmaVersion: 2022, + sourceType: 'module', + }, + }, + rules: { + 'n/no-extraneous-import': ['error', { + allowModules: ['typescript-eslint'], + }], + }, + }, + + /** + * Redefine language oprions and some of the rules of the CodeX eslint config for typescript config + */ + { + name: 'editorjs-list', + ignores: ['vite.config.js', 'eslint.config.mjs', 'postcss.config.js', '**/json-preview.js'], + plugins: { + '@typescript-eslint': TsPlugin, + }, + + /** + * This are the options for typescript files + */ + languageOptions: { + parser: TsParser, + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: './', + sourceType: 'module', // Allows for the use of imports + }, + }, + + rules: { + 'n/no-missing-import': ['off'], + 'n/no-unpublished-import': ['error', { + allowModules: ['eslint-config-codex'], + ignoreTypeImport: true, + }], + 'n/no-unsupported-features/node-builtins': ['error', { + version: '>=22.1.0', + }], + '@typescript-eslint/no-empty-object-type': ['error', { + allowInterfaces: 'always', + }], + }, + }, +]; diff --git a/example/assets/json-preview.js b/example/assets/json-preview.js deleted file mode 100644 index 24600cb7..00000000 --- a/example/assets/json-preview.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Module to compose output JSON preview - */ -const cPreview = (function (module) { - /** - * Shows JSON in pretty preview - * @param {object} output - what to show - * @param {Element} holder - where to show - */ - module.show = function(output, holder) { - /** Make JSON pretty */ - output = JSON.stringify( output, null, 4 ); - /** Encode HTML entities */ - output = encodeHTMLEntities( output ); - /** Stylize! */ - output = stylize( output ); - holder.innerHTML = output; - }; - - /** - * Converts '>', '<', '&' symbols to entities - */ - function encodeHTMLEntities(string) { - return string.replace(/&/g, '&').replace(//g, '>'); - } - - /** - * Some styling magic - */ - function stylize(string) { - /** Stylize JSON keys */ - string = string.replace( /"(\w+)"\s?:/g, '"$1" :'); - /** Stylize tool names */ - string = string.replace( /"(paragraph|quote|list|header|link|code|image|delimiter|raw|checklist|table|embed|warning)"/g, '"$1"'); - /** Stylize HTML tags */ - string = string.replace( /(<[\/a-z]+(>)?)/gi, '$1' ); - /** Stylize strings */ - string = string.replace( /"([^"]+)"/gi, '"$1"' ); - /** Boolean/Null */ - string = string.replace( /\b(true|false|null)\b/gi, '$1' ); - return string; - } - - return module; -})({}); diff --git a/example/example.html b/example/example.html deleted file mode 100644 index 5410d64c..00000000 --- a/example/example.html +++ /dev/null @@ -1,402 +0,0 @@ - - - - - Editor.js 🤩🧦🤨 example - - - - - - -
-
- - - -
-
-
- -
- editor.save() -
- -
- Readonly: - - Off - -
- toggle -
-
-
-
-

-
-      
-    
-
- - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/package.json b/package.json index cb9559b7..d9057c7d 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,25 @@ { - "name": "@editorjs/nested-list", - "version": "1.4.3", + "name": "@editorjs/list", + "version": "2.0.0", "keywords": [ "codex editor", - "nested-list", + "list", "editor.js", "editorjs" ], - "description": "Nested list Tool for EditorJS", - "repository": "https://github.com/editor-js/nested-list.git", + "description": "List Tool for EditorJS", + "repository": "https://github.com/editor-js/list.git", "author": "CodeX ", "license": "MIT", "files": [ "dist" ], - "main": "./dist/nested-list.umd.js", - "module": "./dist/nested-list.mjs", + "main": "./dist/editorjs-list.umd.js", + "module": "./dist/editorjs-list.mjs", "exports": { ".": { - "import": "./dist/nested-list.mjs", - "require": "./dist/nested-list.umd.js", + "import": "./dist/editorjs-list.mjs", + "require": "./dist/editorjs-list.umd.js", "types": "./dist/index.d.ts" } }, @@ -27,16 +27,18 @@ "scripts": { "dev": "vite", "build": "vite build", - "lint": "eslint src/ --quiet --ext .ts", - "lint:errors": "eslint src/ --quiet", - "lint:fix": "eslint src/ --fix" + "lint": "eslint", + "lint:fix": "eslint --fix" }, "devDependencies": { - "@editorjs/editorjs": "^2.29.1", + "@editorjs/editorjs": "^2.31.0-rc.2", "@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/parser": "^7.13.1", - "eslint": "^7.22.0", - "eslint-loader": "^4.0.2", + "@editorjs/caret": "^1.0.3", + "@editorjs/dom": "^1.0.1", + "eslint": "^9.2.0", + "eslint-config-codex": "^2.0.0", + "eslint-import-resolver-alias": "1.1.2", "postcss-nested": "^5.0.3", "postcss-nested-ancestors": "^2.0.0", "typescript": "^5.4.5", @@ -45,6 +47,6 @@ "vite-plugin-dts": "^3.9.1" }, "dependencies": { - "@codexteam/icons": "^0.0.2" + "@codexteam/icons": "^0.3.2" } } diff --git a/example/assets/codex2x.png b/playground/assets/codex2x.png similarity index 100% rename from example/assets/codex2x.png rename to playground/assets/codex2x.png diff --git a/example/assets/demo.css b/playground/assets/demo.css similarity index 100% rename from example/assets/demo.css rename to playground/assets/demo.css diff --git a/playground/assets/json-preview.js b/playground/assets/json-preview.js new file mode 100644 index 00000000..fefaa998 --- /dev/null +++ b/playground/assets/json-preview.js @@ -0,0 +1,55 @@ +/** + * Module to compose output JSON preview + */ +// eslint-disable-next-line no-unused-vars +const cPreview = (function (module) { + /** + * Shows JSON in pretty preview + * + * @param {object} output - what to show + * @param {Element} holder - where to show + */ + module.show = function (output, holder) { + /** Make JSON pretty */ + output = JSON.stringify(output, null, 4); + /** Encode HTML entities */ + output = encodeHTMLEntities(output); + /** Stylize! */ + output = stylize(output); + holder.innerHTML = output; + }; + + /** + * Converts '>', '<', '&' symbols to entities + * + * @param {string} string - in passed string all html symbols would be converted to the entities + * @returns {string} string with encoded html + */ + function encodeHTMLEntities(string) { + return string.replace(/&/g, '&').replace(//g, '>'); + } + + /** + * Some styling magic + * + * @param {string} string - passed string will be converted to stylized + * @returns {string} - passed string converted with stylized html elements + */ + function stylize(string) { + /** Stylize JSON keys */ + string = string.replace(/"(\w+)"\s?:/g, '"$1" :'); + /** Stylize tool names */ + string = string.replace(/"(paragraph|quote|list|header|link|code|image|delimiter|raw|checklist|table|embed|warning)"/g, '"$1"'); + /** Stylize HTML tags */ + string = string.replace(/(<[/a-z]+(>)?)/gi, '$1'); + /** Stylize strings */ + string = string.replace(/"([^"]+)"/gi, '"$1"'); + /** Boolean/Null */ + string = string.replace(/\b(true|false|null)\b/gi, '$1'); + + return string; + } + + return module; +})({}); diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 00000000..c33e7015 --- /dev/null +++ b/playground/index.html @@ -0,0 +1,274 @@ + + + + + Editor.js 🤩🧦🤨 example + + + + + + +
+ +
+
+ +
+ editor.save() +
+ +
+ Readonly: + + Off + +
+ toggle +
+
+
+
+

+
+      
+    
+
+ + + + + + + + + + + diff --git a/postcss.config.js b/postcss.config.js index 8b80aeed..1984c1d8 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,7 +1,9 @@ +// eslint-disable-next-line no-undef module.exports = { - plugins: [ - require('postcss-nested-ancestors'), - require('postcss-nested'), - ], - }; - \ No newline at end of file + plugins: [ + // eslint-disable-next-line no-undef + require('postcss-nested-ancestors'), + // eslint-disable-next-line no-undef + require('postcss-nested'), + ], +}; diff --git a/src/ListRenderer/ChecklistRenderer.ts b/src/ListRenderer/ChecklistRenderer.ts new file mode 100644 index 00000000..34a13517 --- /dev/null +++ b/src/ListRenderer/ChecklistRenderer.ts @@ -0,0 +1,197 @@ +import { IconCheck } from '@codexteam/icons'; +import type { ChecklistItemMeta } from '../types/ItemMeta'; +import type { ListConfig } from '../types/ListParams'; +import { isEmpty, make } from '@editorjs/dom'; +import { DefaultListCssClasses } from './ListRenderer'; +import type { ListCssClasses, ListRendererInterface } from './ListRenderer'; +import { CssPrefix } from '../styles/CssPrefix'; + +/** + * Interface that represents all list used only in unordered list rendering + */ +interface ChecklistCssClasses extends ListCssClasses { + /** + * CSS class of the checklist + */ + checklist: string; + + /** + * CSS class of the checked checkbox + */ + itemChecked: string; + + /** + * CSS class for the special hover behavior of the checkboc + */ + noHover: string; + + /** + * CSS class of the checkbox + */ + checkbox: string; + + /** + * CSS class of the checkbox container + */ + checkboxContainer: string; +} + +/** + * Class that is responsible for checklist rendering + */ +export class CheckListRenderer implements ListRendererInterface { + /** + * Tool's configuration + */ + protected config?: ListConfig; + + /** + * Is Editorjs List Tool read-only option + */ + private readOnly: boolean; + + /** + * Getter for all CSS classes used in unordered list rendering + */ + private static get CSS(): ChecklistCssClasses { + return { + ...DefaultListCssClasses, + checklist: `${CssPrefix}-checklist`, + itemChecked: `${CssPrefix}__checkbox--checked`, + noHover: `${CssPrefix}__checkbox--no-hover`, + checkbox: `${CssPrefix}__checkbox-check`, + checkboxContainer: `${CssPrefix}__checkbox`, + }; + } + + /** + * Assign passed readonly mode and config to relevant class properties + * @param readonly - read-only mode flag + * @param config - user config for Tool + */ + constructor(readonly: boolean, config?: ListConfig) { + this.config = config; + this.readOnly = readonly; + } + + /** + * Renders ul wrapper for list + * @param isRoot - boolean variable that represents level of the wrappre (root or childList) + * @returns - created html ul element + */ + public renderWrapper(isRoot: boolean): HTMLUListElement { + let wrapperElement: HTMLUListElement; + + /** + * Check if it's root level + */ + if (isRoot === true) { + wrapperElement = make('ul', [CheckListRenderer.CSS.wrapper, CheckListRenderer.CSS.checklist]) as HTMLUListElement; + + /** + * Delegate clicks from wrapper to items + */ + wrapperElement.addEventListener('click', (event) => { + const target = event.target as Element | null; + + if (target) { + const checkbox = target.closest(`.${CheckListRenderer.CSS.checkboxContainer}`); + + if (checkbox && checkbox.contains(target)) { + this.toggleCheckbox(checkbox); + } + } + }); + } else { + wrapperElement = make('ul', [CheckListRenderer.CSS.checklist, CheckListRenderer.CSS.itemChildren]) as HTMLUListElement; + } + + return wrapperElement; + } + + /** + * Redners list item element + * @param content - content used in list item rendering + * @param meta - meta of the list item used in rendering of the checklist + * @returns - created html list item element + */ + public renderItem(content: string, meta: ChecklistItemMeta): HTMLLIElement { + const itemWrapper = make('li', [CheckListRenderer.CSS.item, CheckListRenderer.CSS.item]); + const itemContent = make('div', CheckListRenderer.CSS.itemContent, { + innerHTML: content, + contentEditable: (!this.readOnly).toString(), + }); + + const checkbox = make('span', CheckListRenderer.CSS.checkbox); + const checkboxContainer = make('div', CheckListRenderer.CSS.checkboxContainer); + + if (meta.checked === true) { + checkboxContainer.classList.add(CheckListRenderer.CSS.itemChecked); + } + + checkbox.innerHTML = IconCheck; + checkboxContainer.appendChild(checkbox); + + itemWrapper.appendChild(checkboxContainer); + itemWrapper.appendChild(itemContent); + + return itemWrapper as HTMLLIElement; + } + + /** + * Return the item content + * @param item - item wrapper (
  • ) + * @returns - item content string + */ + public getItemContent(item: Element): string { + const contentNode = item.querySelector(`.${CheckListRenderer.CSS.itemContent}`); + + if (!contentNode) { + return ''; + } + + if (isEmpty(contentNode)) { + return ''; + } + + return contentNode.innerHTML; + } + + /** + * Return meta object of certain element + * @param item - will be returned meta information of this item + * @returns Item meta object + */ + public getItemMeta(item: Element): ChecklistItemMeta { + const checkbox = item.querySelector(`.${CheckListRenderer.CSS.checkboxContainer}`); + + return { + checked: checkbox ? checkbox.classList.contains(CheckListRenderer.CSS.itemChecked) : false, + }; + } + + /** + * Returns default item meta used on creation of the new item + */ + public composeDefaultMeta(): ChecklistItemMeta { + return { checked: false }; + } + + /** + * Toggle checklist item state + * @param checkbox - checkbox element to be toggled + */ + private toggleCheckbox(checkbox: Element): void { + checkbox.classList.toggle(CheckListRenderer.CSS.itemChecked); + checkbox.classList.add(CheckListRenderer.CSS.noHover); + checkbox.addEventListener('mouseleave', () => this.removeSpecialHoverBehavior(checkbox), { once: true }); + } + + /** + * Removes class responsible for special hover behavior on an item + * @param el - item wrapper + */ + private removeSpecialHoverBehavior(el: Element): void { + el.classList.remove(CheckListRenderer.CSS.noHover); + } +} diff --git a/src/ListRenderer/ListRenderer.ts b/src/ListRenderer/ListRenderer.ts new file mode 100644 index 00000000..711a7758 --- /dev/null +++ b/src/ListRenderer/ListRenderer.ts @@ -0,0 +1,73 @@ +import { CssPrefix } from '../styles/CssPrefix'; +/** + * CSS classes for the List Tool + */ +export const DefaultListCssClasses = { + wrapper: CssPrefix, + item: `${CssPrefix}__item`, + itemContent: `${CssPrefix}__item-content`, + itemChildren: `${CssPrefix}__item-children`, +}; + +/** + * Interface that represents default list css classes + */ +export interface ListCssClasses { + /** + * CSS class of the whole list wrapper + */ + wrapper: string; + + /** + * CSS class of the list item + */ + item: string; + + /** + * CSS class of the list item content element + */ + itemContent: string; + + /** + * CSS class of the children item wrapper + */ + itemChildren: string; +} + +/** + * Interface that represents all list renderer classes + */ +export interface ListRendererInterface { + /** + * Renders wrapper for list + * @param isRoot - boolean variable that represents level of the wrappre (root or childList) + * @returns - created html ul element + */ + renderWrapper: (isRoot: boolean) => HTMLElement; + + /** + * Redners list item element + * @param content - content of the list item + * @returns - created html list item element + */ + renderItem: (content: string, meta: ItemMeta) => HTMLElement; + + /** + * Return the item content + * @param {Element} item - item wrapper (
  • ) + * @returns {string} + */ + getItemContent: (item: Element) => string; + + /** + * Return meta object of certain element + * @param {Element} item - item of the list to get meta from + * @returns {ItemMeta} Item meta object + */ + getItemMeta: (item: Element) => ItemMeta; + + /** + * Returns default item meta used on creation of the new item + */ + composeDefaultMeta: () => ItemMeta; +}; diff --git a/src/ListRenderer/OrderedListRenderer.ts b/src/ListRenderer/OrderedListRenderer.ts new file mode 100644 index 00000000..7d7ec28e --- /dev/null +++ b/src/ListRenderer/OrderedListRenderer.ts @@ -0,0 +1,123 @@ +import type { OrderedListItemMeta } from '../types/ItemMeta'; +import type { ListConfig } from '../types/ListParams'; +import { isEmpty, make } from '@editorjs/dom'; +import { DefaultListCssClasses } from './ListRenderer'; +import type { ListCssClasses, ListRendererInterface } from './ListRenderer'; +import { CssPrefix } from '../styles/CssPrefix'; + +/** + * Interface that represents all list used only in unordered list rendering + */ +interface OrderedListCssClasses extends ListCssClasses { + /** + * CSS class of the ordered list + */ + orderedList: string; +} + +/** + * Class that is responsible for ordered list rendering + */ +export class OrderedListRenderer implements ListRendererInterface { + /** + * Tool's configuration + */ + protected config?: ListConfig; + + /** + * Is Editorjs List Tool read-only option + */ + private readOnly: boolean; + + /** + * Getter for all CSS classes used in unordered list rendering + */ + private static get CSS(): OrderedListCssClasses { + return { + ...DefaultListCssClasses, + orderedList: `${CssPrefix}-ordered`, + }; + } + + /** + * Assign passed readonly mode and config to relevant class properties + * @param readonly - read-only mode flag + * @param config - user config for Tool + */ + constructor(readonly: boolean, config?: ListConfig) { + this.config = config; + this.readOnly = readonly; + } + + /** + * Renders ol wrapper for list + * @param isRoot - boolean variable that represents level of the wrappre (root or childList) + * @returns - created html ol element + */ + public renderWrapper(isRoot: boolean): HTMLOListElement { + let wrapperElement: HTMLOListElement; + + /** + * Check if it's root level + */ + if (isRoot === true) { + wrapperElement = make('ol', [OrderedListRenderer.CSS.wrapper, OrderedListRenderer.CSS.orderedList]) as HTMLOListElement; + } else { + wrapperElement = make('ol', [OrderedListRenderer.CSS.orderedList, OrderedListRenderer.CSS.itemChildren]) as HTMLOListElement; + } + + return wrapperElement; + } + + /** + * Redners list item element + * @param content - content used in list item rendering + * @param _meta - meta of the list item unused in rendering of the ordered list + * @returns - created html list item element + */ + public renderItem(content: string, _meta: OrderedListItemMeta): HTMLLIElement { + const itemWrapper = make('li', OrderedListRenderer.CSS.item); + const itemContent = make('div', OrderedListRenderer.CSS.itemContent, { + innerHTML: content, + contentEditable: (!this.readOnly).toString(), + }); + + itemWrapper.appendChild(itemContent); + + return itemWrapper as HTMLLIElement; + } + + /** + * Return the item content + * @param item - item wrapper (
  • ) + * @returns - item content string + */ + public getItemContent(item: Element): string { + const contentNode = item.querySelector(`.${OrderedListRenderer.CSS.itemContent}`); + + if (!contentNode) { + return ''; + } + + if (isEmpty(contentNode)) { + return ''; + } + + return contentNode.innerHTML; + } + + /** + * Returns item meta, for ordered list + * @returns item meta object + */ + public getItemMeta(): OrderedListItemMeta { + return {}; + } + + /** + * Returns default item meta used on creation of the new item + */ + public composeDefaultMeta(): OrderedListItemMeta { + return {}; + } +} diff --git a/src/ListRenderer/UnorderedListRenderer.ts b/src/ListRenderer/UnorderedListRenderer.ts new file mode 100644 index 00000000..f50f36a8 --- /dev/null +++ b/src/ListRenderer/UnorderedListRenderer.ts @@ -0,0 +1,123 @@ +import type { UnorderedListItemMeta } from '../types/ItemMeta'; +import type { ListConfig } from '../types/ListParams'; +import { make, isEmpty } from '@editorjs/dom'; +import { DefaultListCssClasses } from './ListRenderer'; +import type { ListCssClasses, ListRendererInterface } from './ListRenderer'; +import { CssPrefix } from '../styles/CssPrefix'; + +/** + * Interface that represents all list used only in unordered list rendering + */ +interface UnoderedListCssClasses extends ListCssClasses { + /** + * CSS class of the unordered list + */ + unorderedList: string; +} + +/** + * Class that is responsible for unordered list rendering + */ +export class UnorderedListRenderer implements ListRendererInterface { + /** + * Tool's configuration + */ + protected config?: ListConfig; + + /** + * Is Editorjs List Tool read-only option + */ + private readOnly: boolean; + + /** + * Getter for all CSS classes used in unordered list rendering + */ + private static get CSS(): UnoderedListCssClasses { + return { + ...DefaultListCssClasses, + unorderedList: `${CssPrefix}-unordered`, + }; + } + + /** + * Assign passed readonly mode and config to relevant class properties + * @param readonly - read-only mode flag + * @param config - user config for Tool + */ + constructor(readonly: boolean, config?: ListConfig) { + this.config = config; + this.readOnly = readonly; + } + + /** + * Renders ol wrapper for list + * @param isRoot - boolean variable that represents level of the wrappre (root or childList) + * @returns - created html ul element + */ + public renderWrapper(isRoot: boolean): HTMLUListElement { + let wrapperElement: HTMLUListElement; + + /** + * Check if it's root level + */ + if (isRoot === true) { + wrapperElement = make('ul', [UnorderedListRenderer.CSS.wrapper, UnorderedListRenderer.CSS.unorderedList]) as HTMLUListElement; + } else { + wrapperElement = make('ul', [UnorderedListRenderer.CSS.unorderedList, UnorderedListRenderer.CSS.itemChildren]) as HTMLUListElement; + } + + return wrapperElement; + } + + /** + * Redners list item element + * @param content - content used in list item rendering + * @param _meta - meta of the list item unused in rendering of the unordered list + * @returns - created html list item element + */ + public renderItem(content: string, _meta: UnorderedListItemMeta): HTMLLIElement { + const itemWrapper = make('li', UnorderedListRenderer.CSS.item); + const itemContent = make('div', UnorderedListRenderer.CSS.itemContent, { + innerHTML: content, + contentEditable: (!this.readOnly).toString(), + }); + + itemWrapper.appendChild(itemContent); + + return itemWrapper as HTMLLIElement; + } + + /** + * Return the item content + * @param item - item wrapper (
  • ) + * @returns - item content string + */ + public getItemContent(item: Element): string { + const contentNode = item.querySelector(`.${UnorderedListRenderer.CSS.itemContent}`); + + if (!contentNode) { + return ''; + } + + if (isEmpty(contentNode)) { + return ''; + } + + return contentNode.innerHTML; + } + + /** + * Returns item meta, for unordered list + * @returns Item meta object + */ + public getItemMeta(): UnorderedListItemMeta { + return {}; + } + + /** + * Returns default item meta used on creation of the new item + */ + public composeDefaultMeta(): UnorderedListItemMeta { + return {}; + } +} diff --git a/src/ListRenderer/index.ts b/src/ListRenderer/index.ts new file mode 100644 index 00000000..fca9b116 --- /dev/null +++ b/src/ListRenderer/index.ts @@ -0,0 +1,6 @@ +import { CheckListRenderer } from './ChecklistRenderer'; +import { OrderedListRenderer } from './OrderedListRenderer'; +import { UnorderedListRenderer } from './UnorderedListRenderer'; +import { DefaultListCssClasses } from './ListRenderer'; + +export { CheckListRenderer, OrderedListRenderer, UnorderedListRenderer, DefaultListCssClasses }; diff --git a/src/ListTabulator/index.ts b/src/ListTabulator/index.ts new file mode 100644 index 00000000..35f0b6fe --- /dev/null +++ b/src/ListTabulator/index.ts @@ -0,0 +1,1170 @@ +import { OrderedListRenderer } from '../ListRenderer/OrderedListRenderer'; +import { UnorderedListRenderer } from '../ListRenderer/UnorderedListRenderer'; +import type { ListConfig, ListData, ListDataStyle } from '../types/ListParams'; +import type { ListItem } from '../types/ListParams'; +import type { ItemElement, ItemChildWrapperElement } from '../types/Elements'; +import { isHtmlElement } from '../utils/type-guards'; +import { getContenteditableSlice, getCaretNodeAndOffset, isCaretAtStartOfInput } from '@editorjs/caret'; +import { DefaultListCssClasses } from '../ListRenderer'; +import type { PasteEvent } from '../types'; +import type { API, BlockAPI, PasteConfig } from '@editorjs/editorjs'; +import type { ListParams } from '..'; +import type { ChecklistItemMeta, ItemMeta, OrderedListItemMeta, UnorderedListItemMeta } from '../types/ItemMeta'; +import type { ListRenderer } from '../types/ListRenderer'; +import { getSiblings } from '../utils/getSiblings'; +import { getChildItems } from '../utils/getChildItems'; +import { isLastItem } from '../utils/isLastItem'; +import { itemHasSublist } from '../utils/itemHasSublist'; +import { getItemChildWrapper } from '../utils/getItemChildWrapper'; +import { removeChildWrapperIfEmpty } from '../utils/removeChildWrapperIfEmpty'; +import { getItemContentElement } from '../utils/getItemContentElement'; +import { focusItem } from '../utils/focusItem'; +import type { OlCounterType } from '../types/OlCounterType'; + +/** + * Class that is responsible for list tabulation + */ +export default class ListTabulator { + /** + * The Editor.js API + */ + private api: API; + + /** + * Is Editorjs List Tool read-only option + */ + private readOnly: boolean; + + /** + * Tool's configuration + */ + private config?: ListConfig; + + /** + * Full content of the list + */ + private data: ListData; + + /** + * Editor block api + */ + private block: BlockAPI; + + /** + * Rendered list of items + */ + private renderer: Renderer; + + /** + * Wrapper of the whole list + */ + private listWrapper: ItemChildWrapperElement | undefined; + + /** + * Getter method to get current item + * @returns current list item or null if caret position is not undefined + */ + private get currentItem(): ItemElement | null { + const selection = window.getSelection(); + + if (!selection) { + return null; + } + + let currentNode = selection.anchorNode; + + if (!currentNode) { + return null; + } + + if (!isHtmlElement(currentNode)) { + currentNode = currentNode.parentNode; + } + if (!currentNode) { + return null; + } + if (!isHtmlElement(currentNode)) { + return null; + } + + return currentNode.closest(`.${DefaultListCssClasses.item}`); + } + + /** + * Method that returns nesting level of the current item, null if there is no selection + */ + private get currentItemLevel(): number | null { + const currentItem = this.currentItem; + + if (currentItem === null) { + return null; + } + + let parentNode = currentItem.parentNode; + + let levelCounter = 0; + + while (parentNode !== null && parentNode !== this.listWrapper) { + if (isHtmlElement(parentNode) && parentNode.classList.contains(DefaultListCssClasses.item)) { + levelCounter += 1; + } + + parentNode = parentNode.parentNode; + } + + /** + * Level counter is number of the parent element, so it should be increased by one + */ + return levelCounter + 1; + } + + /** + * Assign all passed params and renderer to relevant class properties + * @param params - tool constructor options + * @param params.data - previously saved data + * @param params.config - user config for Tool + * @param params.api - Editor.js API + * @param params.readOnly - read-only mode flag + * @param renderer - renderer instance initialized in tool class + */ + constructor({ data, config, api, readOnly, block }: ListParams, renderer: Renderer) { + this.config = config; + this.data = data as ListData; + this.readOnly = readOnly; + this.api = api; + this.block = block; + + this.renderer = renderer; + } + + /** + * Function that is responsible for rendering list with contents + * @returns Filled with content wrapper element of the list + */ + public render(): ItemChildWrapperElement { + this.listWrapper = this.renderer.renderWrapper(true); + + // fill with data + if (this.data.items.length) { + this.appendItems(this.data.items, this.listWrapper); + } else { + this.appendItems( + [ + { + content: '', + meta: {}, + items: [], + }, + ], + this.listWrapper + ); + } + + if (!this.readOnly) { + // detect keydown on the last item to escape List + this.listWrapper.addEventListener( + 'keydown', + (event) => { + switch (event.key) { + case 'Enter': + this.enterPressed(event); + break; + case 'Backspace': + this.backspace(event); + break; + case 'Tab': + if (event.shiftKey) { + this.shiftTab(event); + } else { + this.addTab(event); + } + break; + } + }, + false + ); + } + + /** + * Set start property value from initial data + */ + if ('start' in this.data.meta && this.data.meta.start !== undefined) { + this.changeStartWith(this.data.meta.start); + } + + /** + * Set counterType value from initial data + */ + if ('counterType' in this.data.meta && this.data.meta.counterType !== undefined) { + this.changeCounters(this.data.meta.counterType); + } + + return this.listWrapper; + } + + /** + * Function that is responsible for list content saving + * @param wrapper - optional argument wrapper + * @returns whole list saved data if wrapper not passes, otherwise will return data of the passed wrapper + */ + public save(wrapper?: ItemChildWrapperElement): ListData { + const listWrapper = wrapper ?? this.listWrapper; + + /** + * The method for recursive collecting of the child items + * @param parent - where to find items + */ + const getItems = (parent: ItemChildWrapperElement): ListItem[] => { + const children = getChildItems(parent); + + return children.map((el) => { + const subItemsWrapper = getItemChildWrapper(el); + const content = this.renderer.getItemContent(el); + const meta = this.renderer.getItemMeta(el); + const subItems = subItemsWrapper ? getItems(subItemsWrapper) : []; + + return { + content, + meta, + items: subItems, + }; + }); + }; + + const composedListItems = listWrapper ? getItems(listWrapper) : []; + + let dataToSave: ListData = { + style: this.data.style, + meta: {} as ItemMeta, + items: composedListItems, + }; + + if (this.data.style === 'ordered') { + dataToSave.meta = { + start: (this.data.meta as OrderedListItemMeta).start, + counterType: (this.data.meta as OrderedListItemMeta).counterType, + }; + } + + return dataToSave; + } + + /** + * On paste sanitzation config. Allow only tags that are allowed in the Tool. + * @returns - config that determines tags supposted by paste handler + * @todo - refactor and move to list instance + */ + public static get pasteConfig(): PasteConfig { + return { + tags: ['OL', 'UL', 'LI'], + }; + } + + /** + * Method that specified hot to merge two List blocks. + * Called by Editor.js by backspace at the beginning of the Block + * + * Content of the first item of the next List would be merged with deepest item in current list + * Other items of the next List would be appended to the current list without any changes in nesting levels + * @param data - data of the second list to be merged with current + */ + public merge(data: ListData): void { + /** + * Get list of all levels children of the previous item + */ + const items = this.block.holder.querySelectorAll(`.${DefaultListCssClasses.item}`); + + const deepestBlockItem = items[items.length - 1]; + const deepestBlockItemContentElement = getItemContentElement(deepestBlockItem); + + if (deepestBlockItem === null || deepestBlockItemContentElement === null) { + return; + } + + /** + * Insert trailing html to the deepest block item content + */ + deepestBlockItemContentElement.insertAdjacentHTML('beforeend', data.items[0].content); + + if (this.listWrapper === undefined) { + return; + } + + const firstLevelItems = getChildItems(this.listWrapper); + + if (firstLevelItems.length === 0) { + return; + } + + /** + * Get last item of the first level of the list + */ + const lastFirstLevelItem = firstLevelItems[firstLevelItems.length - 1]; + + /** + * Get child items wrapper of the last item + */ + let lastFirstLevelItemChildWrapper = getItemChildWrapper(lastFirstLevelItem); + + /** + * Get first item of the list to be merged with current one + */ + const firstItem = data.items.shift(); + + /** + * Check that first item exists + */ + if (firstItem === undefined) { + return; + } + + /** + * Append child items of the first element + */ + if (firstItem.items.length !== 0) { + /** + * Render child wrapper of the last item if it does not exist + */ + if (lastFirstLevelItemChildWrapper === null) { + lastFirstLevelItemChildWrapper = this.renderer.renderWrapper(false); + } + + this.appendItems(firstItem.items, lastFirstLevelItemChildWrapper); + } + + if (data.items.length > 0) { + this.appendItems(data.items, this.listWrapper); + } + } + + /** + * On paste callback that is fired from Editor. + * @param event - event with pasted data + * @todo - refactor and move to list instance + */ + public onPaste(event: PasteEvent): void { + const list = event.detail.data; + + this.data = this.pasteHandler(list); + + // render new list + const oldView = this.listWrapper; + + if (oldView && oldView.parentNode) { + oldView.parentNode.replaceChild(this.render(), oldView); + } + } + + /** + * Handle UL, OL and LI tags paste and returns List data + * @param element - html element that contains whole list + * @todo - refactor and move to list instance + */ + public pasteHandler(element: PasteEvent['detail']['data']): ListData { + const { tagName: tag } = element; + let style: ListDataStyle = 'unordered'; + let tagToSearch: string; + + // set list style and tag to search. + switch (tag) { + case 'OL': + style = 'ordered'; + tagToSearch = 'ol'; + break; + case 'UL': + case 'LI': + style = 'unordered'; + tagToSearch = 'ul'; + } + + const data: ListData = { + style, + meta: {} as ItemMeta, + items: [], + }; + + /** + * Set default ordered list atributes if style is ordered + */ + if (style === 'ordered') { + (this.data.meta as OrderedListItemMeta).counterType = 'numeric'; + (this.data.meta as OrderedListItemMeta).start = 1; + } + + // get pasted items from the html. + const getPastedItems = (parent: Element): ListItem[] => { + // get first level li elements. + const children = Array.from(parent.querySelectorAll(`:scope > li`)); + + return children.map((child) => { + // get subitems if they exist. + const subItemsWrapper = child.querySelector(`:scope > ${tagToSearch}`); + // get subitems. + const subItems = subItemsWrapper ? getPastedItems(subItemsWrapper) : []; + // get text content of the li element. + const content = child.firstChild?.textContent ?? ''; + + return { + content, + meta: {}, + items: subItems, + }; + }); + }; + + // get pasted items. + data.items = getPastedItems(element); + + return data; + } + + /** + * Changes ordered list start property value + * @param index - new value of the start property + */ + public changeStartWith(index: number): void { + this.listWrapper!.style.setProperty('counter-reset', `item ${index - 1}`); + + (this.data.meta as OrderedListItemMeta).start = index; + } + + /** + * Changes ordered list counterType property value + * @param counterType - new value of the counterType value + */ + public changeCounters(counterType: OlCounterType): void { + this.listWrapper!.style.setProperty('--list-counter-type', counterType); + + (this.data.meta as OrderedListItemMeta).counterType = counterType; + } + + /** + * Handles Enter keypress + * @param event - keydown + */ + private enterPressed(event: KeyboardEvent): void { + const currentItem = this.currentItem; + + /** + * Prevent editor.js behaviour + */ + event.stopPropagation(); + + /** + * Prevent browser behaviour + */ + event.preventDefault(); + + /** + * Prevent duplicated event in Chinese, Japanese and Korean languages + */ + if (event.isComposing) { + return; + } + if (currentItem === null) { + return; + } + + const isEmpty = this.renderer?.getItemContent(currentItem).trim().length === 0; + const isFirstLevelItem = currentItem.parentNode === this.listWrapper; + const isFirstItem = currentItem.previousElementSibling === null; + + const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); + + /** + * On Enter in the last empty item, get out of list + */ + if (isFirstLevelItem && isEmpty) { + if (isLastItem(currentItem) && !itemHasSublist(currentItem)) { + /** + * If current item is first and last item of the list, then empty list should be deleted after deletion of the item + */ + if (isFirstItem) { + this.convertItemToDefaultBlock(currentBlockIndex, true); + } else { + /** + * If there are other items in the list, just remove current item and get out of the list + */ + this.convertItemToDefaultBlock(); + } + + return; + } else { + /** + * If enter is pressed in the сenter of the list item we should split it + */ + this.splitList(currentItem); + + return; + } + } else if (isEmpty) { + /** + * If currnet item is empty and is in the middle of the list + * And if current item is not on the first level + * Then unshift current item + */ + this.unshiftItem(currentItem); + + return; + } else { + /** + * If current item is not empty than split current item + */ + this.splitItem(currentItem); + } + } + + /** + * Handle backspace + * @param event - keydown + */ + private backspace(event: KeyboardEvent): void { + const currentItem = this.currentItem; + + if (currentItem === null) { + return; + } + + /** + * Caret is not at start of the item + * Then backspace button should remove letter as usual + */ + if (!isCaretAtStartOfInput(currentItem)) { + return; + } + + /** + * Prevent Editor.js backspace handling + */ + event.stopPropagation(); + + /** + * First item of the list should become paragraph on backspace + */ + if (currentItem.parentNode === this.listWrapper && currentItem.previousElementSibling === null) { + /** + * If current item is first item of the list, then we need to merge first item content with previous block + */ + this.convertFirstItemToDefaultBlock(); + + return; + } + + /** + * Prevent default backspace behaviour + */ + event.preventDefault(); + + this.mergeItemWithPrevious(currentItem); + } + + /** + * Reduce indentation for current item + * @param event - keydown + */ + private shiftTab(event: KeyboardEvent): void { + /** + * Prevent editor.js behaviour + */ + event.stopPropagation(); + + /** + * Prevent browser tab behaviour + */ + event.preventDefault(); + + /** + * Check that current item exists + */ + if (this.currentItem === null) { + return; + } + + /** + * Move item from current list to parent list + */ + this.unshiftItem(this.currentItem); + } + + /** + * Decrease indentation of the passed item + * @param item - list item to be unshifted + */ + private unshiftItem(item: ItemElement): void { + if (!item.parentNode) { + return; + } + if (!isHtmlElement(item.parentNode)) { + return; + } + + const parentItem = item.parentNode.closest(`.${DefaultListCssClasses.item}`); + + /** + * If item in the first-level list then no need to do anything + */ + if (!parentItem) { + return; + } + + let currentItemChildWrapper = getItemChildWrapper(item); + + if (item.parentElement === null) { + return; + } + + const siblings = getSiblings(item); + + /** + * If item has any siblings, they should be appended to item child wrapper + */ + if (siblings !== null) { + /** + * Render child wrapper if it does no exist + */ + if (currentItemChildWrapper === null) { + currentItemChildWrapper = this.renderer.renderWrapper(false); + } + + /** + * Append siblings to item child wrapper + */ + siblings.forEach((sibling) => { + currentItemChildWrapper!.appendChild(sibling); + }); + + item.appendChild(currentItemChildWrapper); + } + + parentItem.after(item); + + focusItem(item, false); + + /** + * If parent item has empty child wrapper after unshifting of the current item, then we need to remove child wrapper + * This case could be reached if the only child item of the parent was unshifted + */ + removeChildWrapperIfEmpty(parentItem); + } + + /** + * Method that is used for list splitting and moving trailing items to the new separated list + * @param item - current item html element + */ + private splitList(item: ItemElement): void { + const currentItemChildrenList = getChildItems(item); + + /** + * Get current list block index + */ + const currentBlock = this.block; + + const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); + + /** + * First child item should be unshifted because separated list should start + * with item with first nesting level + */ + if (currentItemChildrenList.length !== 0) { + const firstChildItem = currentItemChildrenList[0]; + + this.unshiftItem(firstChildItem); + + /** + * If first child item was been unshifted, that caret would be set to the end of the first child item + * Then we should set caret to the actual current item + */ + focusItem(item, false); + } + + /** + * If item is first item of the list, we should just get out of the list + * It means, that we would not split on two lists, if one of them would be empty + */ + if (item.previousElementSibling === null && item.parentNode === this.listWrapper) { + this.convertItemToDefaultBlock(currentBlockIndex); + + return; + } + + /** + * Get trailing siblings of the current item + */ + const newListItems = getSiblings(item); + + if (newListItems === null) { + return; + } + + /** + * Render new wrapper for list that would be separated + */ + const newListWrapper = this.renderer.renderWrapper(true); + + /** + * Append new list wrapper with trailing elements + */ + newListItems.forEach((newListItem) => { + newListWrapper.appendChild(newListItem); + }); + + const newListContent = this.save(newListWrapper); + + (newListContent.meta as OrderedListItemMeta).start = this.data.style == 'ordered' ? 1 : undefined; + + /** + * Insert separated list with trailing items + */ + this.api.blocks.insert(currentBlock?.name, newListContent, this.config, currentBlockIndex + 1); + + /** + * Insert paragraph + */ + this.convertItemToDefaultBlock(currentBlockIndex + 1); + + /** + * Remove temporary new list wrapper used for content save + */ + newListWrapper.remove(); + } + + /** + * Method that is used for splitting item content and moving trailing content to the new sibling item + * @param currentItem - current item html element + */ + private splitItem(currentItem: ItemElement): void { + const [currentNode, offset] = getCaretNodeAndOffset(); + + if (currentNode === null) { + return; + } + + const currentItemContent = getItemContentElement(currentItem); + + let endingHTML: string; + + /** + * If current item has no content, we should pass an empty string to the next created list item + */ + if (currentItemContent === null) { + endingHTML = ''; + } else { + /** + * On other Enters, get content from caret till the end of the block + * And move it to the new item + */ + endingHTML = getContenteditableSlice(currentItemContent, currentNode, offset, 'right', true); + } + + const itemChildren = getItemChildWrapper(currentItem); + /** + * Create the new list item + */ + const itemEl = this.renderItem(endingHTML); + + /** + * Move new item after current + */ + currentItem?.after(itemEl); + + /** + * If current item has children, move them to the new item + */ + if (itemChildren) { + itemEl.appendChild(itemChildren); + } + + focusItem(itemEl); + } + + /** + * Method that is used for merging current item with previous one + * Content of the current item would be appended to the previous item + * Current item children would not change nesting level + * @param item - current item html element + */ + private mergeItemWithPrevious(item: ItemElement): void { + const previousItem = item.previousElementSibling; + + const currentItemParentNode = item.parentNode; + + /** + * Check that parent node of the current element exists + */ + if (currentItemParentNode === null) { + return; + } + if (!isHtmlElement(currentItemParentNode)) { + return; + } + + const parentItem = currentItemParentNode.closest(`.${DefaultListCssClasses.item}`); + + /** + * Check that current item has any previous siblings to be merged with + */ + if (!previousItem && !parentItem) { + return; + } + + /** + * Make sure previousItem is an HTMLElement + */ + if (previousItem && !isHtmlElement(previousItem)) { + return; + } + + /** + * Lets compute the item which will be merged with current item text + */ + let targetItem: ItemElement | null; + + /** + * If there is a previous item then we get a deepest item in its sublists + * + * Otherwise we will use the parent item + */ + if (previousItem) { + /** + * Get list of all levels children of the previous item + */ + const childrenOfPreviousItem = getChildItems(previousItem, false); + + /** + * Target item would be deepest child of the previous item or previous item itself + */ + if (childrenOfPreviousItem.length !== 0 && childrenOfPreviousItem.length !== 0) { + targetItem = childrenOfPreviousItem[childrenOfPreviousItem.length - 1]; + } else { + targetItem = previousItem; + } + } else { + targetItem = parentItem; + } + + /** + * Get current item content + */ + const currentItemContent = this.renderer.getItemContent(item); + + /** + * Get the target item content element + */ + if (!targetItem) { + return; + } + + /** + * Set caret to the end of the target item + */ + focusItem(targetItem, false); + + /** + * Get target item content element + */ + const targetItemContentElement = getItemContentElement(targetItem); + + /** + * Set a new place for caret + */ + if (targetItemContentElement === null) { + return; + } + + /** + * Update target item content by merging with current item html content + */ + targetItemContentElement.insertAdjacentHTML('beforeend', currentItemContent); + + /** + * Get child list of the currentItem + */ + const currentItemChildrenList = getChildItems(item); + + /** + * If item has no children, just remove item + * Else children of the item should be prepended to the target item child list + */ + if (currentItemChildrenList.length === 0) { + /** + * Remove current item element + */ + item.remove(); + + /** + * If target item has empty child wrapper after merge, we need to remove child wrapper + * This case could be reached if the only child item of the target was merged with target + */ + removeChildWrapperIfEmpty(targetItem); + + return; + } + + /** + * Get target for child list of the currentItem + * Note that previous item and parent item could not be null at the same time + * This case is checked before + */ + const targetForChildItems = previousItem ? previousItem : parentItem!; + + const targetChildWrapper = getItemChildWrapper(targetForChildItems) ?? this.renderer.renderWrapper(false); + + /** + * Add child current item children to the target childWrapper + */ + if (previousItem) { + currentItemChildrenList.forEach((childItem) => { + targetChildWrapper.appendChild(childItem); + }); + } else { + currentItemChildrenList.forEach((childItem) => { + targetChildWrapper.prepend(childItem); + }); + } + + /** + * If we created new wrapper, then append childWrapper to the target item + */ + if (getItemChildWrapper(targetForChildItems) === null) { + targetItem.appendChild(targetChildWrapper); + } + + /** + * Remove current item element + */ + item.remove(); + } + + /** + * Add indentation to current item + * @param event - keydown + */ + private addTab(event: KeyboardEvent): void { + /** + * Prevent editor.js behaviour + */ + event.stopPropagation(); + + /** + * Prevent browser tab behaviour + */ + event.preventDefault(); + + const currentItem = this.currentItem; + + if (!currentItem) { + return; + } + + /** + * Check that maxLevel specified in config + */ + if (this.config?.maxLevel !== undefined) { + const currentItemLevel = this.currentItemLevel; + + /** + * Check that current item is not in the maximum nesting level + */ + if (currentItemLevel !== null && currentItemLevel === this.config.maxLevel) { + return; + } + } + + /** + * Check that the item has potential parent + * Previous sibling is potential parent in case of adding tab + * After adding tab current item would be moved to the previous sibling's child list + */ + const prevItem = currentItem.previousSibling; + + if (prevItem === null) { + return; + } + if (!isHtmlElement(prevItem)) { + return; + } + + const prevItemChildrenList = getItemChildWrapper(prevItem); + + /** + * If prev item has child items, just append current to them + * Else render new child wrapper for previous item + */ + if (prevItemChildrenList) { + /** + * Previous item would be appended with current item and it's sublists + * After that sublists would be moved one level back + */ + prevItemChildrenList.appendChild(currentItem); + + /** + * Get all current item child to be moved to previous nesting level + */ + const currentItemChildrenList = getChildItems(currentItem); + + /** + * Move current item sublists one level back + */ + currentItemChildrenList.forEach((child) => { + prevItemChildrenList.appendChild(child); + }); + } else { + const prevItemChildrenListWrapper = this.renderer.renderWrapper(false); + + /** + * Previous item would be appended with current item and it's sublists + * After that sublists would be moved one level back + */ + prevItemChildrenListWrapper.appendChild(currentItem); + + /** + * Get all current item child to be moved to previous nesting level + */ + const currentItemChildrenList = getChildItems(currentItem); + + /** + * Move current item sublists one level back + */ + currentItemChildrenList.forEach((child) => { + prevItemChildrenListWrapper.appendChild(child); + }); + + prevItem.appendChild(prevItemChildrenListWrapper); + } + + /** + * Remove child wrapper of the current item if it is empty after adding the tab + * This case would be reached, because after adding tab current item will have same nesting level with children + * So its child wrapper would be empty + */ + removeChildWrapperIfEmpty(currentItem); + + focusItem(currentItem, false); + } + + /** + * Convert current item to default block with passed index + * @param newBloxkIndex - optional parameter represents index, where would be inseted default block + * @param removeList - optional parameter, that represents condition, if List should be removed + */ + private convertItemToDefaultBlock(newBloxkIndex?: number, removeList?: boolean): void { + let newBlock; + + const currentItem = this.currentItem; + + const currentItemContent = currentItem !== null ? this.renderer.getItemContent(currentItem) : ''; + + if (removeList === true) { + this.api.blocks.delete(); + } + + /** + * Check that index have passed + */ + if (newBloxkIndex !== undefined) { + newBlock = this.api.blocks.insert(undefined, { text: currentItemContent }, undefined, newBloxkIndex); + } else { + newBlock = this.api.blocks.insert(); + } + + currentItem?.remove(); + this.api.caret.setToBlock(newBlock, 'start'); + } + + /** + * Convert first item of the list to default block + * This method could be called when backspace button pressed at start of the first item of the list + * First item of the list would be converted to the paragraph and first item children would be unshifted + */ + private convertFirstItemToDefaultBlock(): void { + const currentItem = this.currentItem; + + if (currentItem === null) { + return; + } + + const currentItemChildren = getChildItems(currentItem); + + /** + * Check that current item have at least one child + * If current item have no children, we can guarantee, + * that after deletion of the first item of the list, children would not be removed + */ + if (currentItemChildren.length !== 0) { + const firstChildItem = currentItemChildren[0]; + + /** + * Unshift first child item, to guarantee, that after deletion of the first item + * list will start with first level of nesting + */ + this.unshiftItem(firstChildItem); + + /** + * Set focus back to the current item after unshifting child + */ + focusItem(currentItem); + } + + /** + * Get all first level items of the list + */ + const currentItemSiblings = getSiblings(currentItem); + + const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); + + /** + * If current item has no siblings, than List is empty, and it should be deleted + */ + const removeList = currentItemSiblings === null; + + this.convertItemToDefaultBlock(currentBlockIndex, removeList); + } + + /** + * Method that calls render function of the renderer with a necessary item meta cast + * @param itemContent - content to be rendered in new item + * @param meta - meta used in list item rendering + * @returns html element of the rendered item + */ + private renderItem(itemContent: ListItem['content'], meta?: ListItem['meta']): ItemElement { + const itemMeta = meta ?? this.renderer.composeDefaultMeta(); + + switch (true) { + case this.renderer instanceof OrderedListRenderer: + return this.renderer.renderItem(itemContent, itemMeta as OrderedListItemMeta); + + case this.renderer instanceof UnorderedListRenderer: + return this.renderer.renderItem(itemContent, itemMeta as UnorderedListItemMeta); + + default: + return this.renderer.renderItem(itemContent, itemMeta as ChecklistItemMeta); + } + } + + /** + * Renders children list + * @param items - list data used in item rendering + * @param parentElement - where to append passed items + */ + private appendItems(items: ListItem[], parentElement: Element): void { + items.forEach((item) => { + const itemEl = this.renderItem(item.content, item.meta); + + parentElement.appendChild(itemEl); + + /** + * Check if there are child items + */ + if (item.items.length) { + const sublistWrapper = this.renderer?.renderWrapper(false); + + /** + * Recursively render child items + */ + this.appendItems(item.items, sublistWrapper); + + itemEl.appendChild(sublistWrapper); + } + }); + } +} diff --git a/src/index.ts b/src/index.ts index 2a15f7ad..f2281772 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,110 +1,47 @@ -import type { API, PasteConfig, ToolboxConfig } from '@editorjs/editorjs'; -import type { PasteEvent } from './types'; +import type { API, BlockAPI, PasteConfig, ToolboxConfig } from '@editorjs/editorjs'; import type { BlockToolConstructorOptions, - TunesMenuConfig, + MenuConfigItem, + ToolConfig } from '@editorjs/editorjs/types/tools'; - -import { isHtmlElement } from './utils/type-guards'; - -import * as Dom from './utils/dom'; -import Caret from './utils/caret'; -import { IconListBulleted, IconListNumbered } from '@codexteam/icons'; +import { IconListBulleted, IconListNumbered, IconChecklist } from '@codexteam/icons'; +import type { ListConfig, ListData, ListDataStyle, ListItem, OldListData } from './types/ListParams'; +import ListTabulator from './ListTabulator'; +import { CheckListRenderer, OrderedListRenderer, UnorderedListRenderer } from './ListRenderer'; +import type { ListRenderer } from './types/ListRenderer'; +import { renderToolboxInput } from './utils/renderToolboxInput'; +import { type OlCounterType, OlCounterTypesMap } from './types/OlCounterType'; /** * Build styles */ -import './../styles/index.pcss'; - -/** - * list style to make list as ordered or unordered - */ -type ListDataStyle = 'ordered' | 'unordered'; - -/** - * Output data - */ -interface ListData { - /** - * list type 'ordered' or 'unordered' - */ - style: ListDataStyle; - /** - * list of first-level elements - */ - items: ListItem[]; -} - -/** - * List item within the output data - */ -interface ListItem { - /** - * list item text content - */ - content: string; - /** - * sublist items - */ - items: ListItem[]; -} - -/** - * Tool's configuration - */ -interface NestedListConfig { - /** - * default list style: ordered or unordered - * default is unordered - */ - defaultStyle?: ListDataStyle; -} - -/** - * Constructor Params for Nested List Tool, use to pass initial data and settings - */ -export type NestedListParams = BlockToolConstructorOptions< - ListData, - NestedListConfig ->; +import './styles/list.pcss'; +import './styles/input.pcss'; +import stripNumbers from './utils/stripNumbers'; +import normalizeData from './utils/normalizeData'; +import type { PasteEvent } from './types'; +import type { OrderedListItemMeta } from './types/ItemMeta'; /** - * CSS classes for the Nested List Tool + * Constructor Params for Editorjs List Tool, use to pass initial data and settings */ -interface NestedListCssClasses { - baseBlock: string; - wrapper: string; - wrapperOrdered: string; - wrapperUnordered: string; - item: string; - itemBody: string; - itemContent: string; - itemChildren: string; - settingsWrapper: string; - settingsButton: string; - settingsButtonActive: string; -} +export type ListParams = BlockToolConstructorOptions; /** - * NestedList Tool for EditorJS + * Default class of the component used in editor */ -export default class NestedList { +export default class EditorjsList { /** * Notify core that read-only mode is supported - * - * @returns {boolean} */ - static get isReadOnlySupported(): boolean { + public static get isReadOnlySupported(): boolean { return true; } /** * Allow to use native Enter behaviour - * - * @returns {boolean} - * @public */ - static get enableLineBreaks(): boolean { + public static get enableLineBreaks(): boolean { return true; } @@ -112,1002 +49,405 @@ export default class NestedList { * Get Tool toolbox settings * icon - Tool icon's SVG * title - title to show in toolbox - * - * @returns {ToolboxConfig} - */ - static get toolbox(): ToolboxConfig { - return { - icon: IconListNumbered, - title: 'List', - }; - } - - /** - * The Editor.js API */ - private api: API; - - /** - * Is NestedList Tool read-only - */ - private readOnly: boolean; - - /** - * Tool's configuration - */ - private config?: NestedListConfig; - - /** - * Default list style - */ - private defaultListStyle?: NestedListConfig['defaultStyle']; - - /** - * Corresponds to UiNodes type from Editor.js but with wrapper being nullable - */ - private nodes: { wrapper: HTMLElement | null }; - - /** - * Tool's data - */ - private data: ListData; - - /** - * Caret helper - */ - private caret: Caret; - - /** - * Render plugin`s main Element and fill it with saved data - * - * @param {object} params - tool constructor options - * @param {ListData} params.data - previously saved data - * @param {object} params.config - user config for Tool - * @param {object} params.api - Editor.js API - * @param {boolean} params.readOnly - read-only mode flag - */ - constructor({ data, config, api, readOnly }: NestedListParams) { - /** - * HTML nodes used in tool - */ - this.nodes = { - wrapper: null, - }; - - this.api = api; - this.readOnly = readOnly; - this.config = config; - - /** - * Set the default list style from the config. - */ - this.defaultListStyle = - this.config?.defaultStyle === 'ordered' ? 'ordered' : 'unordered'; - - const initialData = { - style: this.defaultListStyle, - items: [], - }; - this.data = data && Object.keys(data).length ? data : initialData; - - /** - * Instantiate caret helper - */ - this.caret = new Caret(); - } - - /** - * Returns list tag with items - * - * @returns {Element} - * @public - */ - render(): Element { - this.nodes.wrapper = this.makeListWrapper(this.data.style, [ - this.CSS.baseBlock, - ]); - - // fill with data - if (this.data.items.length) { - this.appendItems(this.data.items, this.nodes.wrapper); - } else { - this.appendItems( - [ - { - content: '', - items: [], - }, - ], - this.nodes.wrapper - ); - } - - if (!this.readOnly) { - // detect keydown on the last item to escape List - this.nodes.wrapper.addEventListener( - 'keydown', - (event) => { - switch (event.key) { - case 'Enter': - this.enterPressed(event); - break; - case 'Backspace': - this.backspace(event); - break; - case 'Tab': - if (event.shiftKey) { - this.shiftTab(event); - } else { - this.addTab(event); - } - break; - } - }, - false - ); - } - - return this.nodes.wrapper; - } - - /** - * Creates Block Tune allowing to change the list style - * - * @public - * @returns {Array} - */ - renderSettings(): TunesMenuConfig { - const tunes = [ + public static get toolbox(): ToolboxConfig { + return [ { - name: 'unordered' as const, - label: this.api.i18n.t('Unordered'), icon: IconListBulleted, + title: 'Unordered List', + data: { + style: 'unordered', + }, }, { - name: 'ordered' as const, - label: this.api.i18n.t('Ordered'), icon: IconListNumbered, + title: 'Ordered List', + data: { + style: 'ordered', + }, }, - ]; - - return tunes.map((tune) => ({ - name: tune.name, - icon: tune.icon, - label: tune.label, - isActive: this.data.style === tune.name, - closeOnActivate: true, - onActivate: () => { - this.listStyle = tune.name; + { + icon: IconChecklist, + title: 'Checklist', + data: { + style: 'checklist', + }, }, - })); + ]; } /** * On paste sanitzation config. Allow only tags that are allowed in the Tool. - * - * @returns {PasteConfig} - paste config. + * @returns - paste config object used in editor */ - static get pasteConfig(): PasteConfig { + public static get pasteConfig(): PasteConfig { return { tags: ['OL', 'UL', 'LI'], }; } /** - * On paste callback that is fired from Editor. - * - * @param {PasteEvent} event - event with pasted data - */ - onPaste(event: PasteEvent): void { - const list = event.detail.data; - - this.data = this.pasteHandler(list); - - // render new list - const oldView = this.nodes.wrapper; - - if (oldView && oldView.parentNode) { - oldView.parentNode.replaceChild(this.render(), oldView); - } - } - - /** - * Handle UL, OL and LI tags paste and returns List data - * - * @param {HTMLUListElement|HTMLOListElement|HTMLLIElement} element - * @returns {ListData} + * Convert from text to list with import and export list to text */ - pasteHandler(element: PasteEvent['detail']['data']): ListData { - const { tagName: tag } = element; - let style: ListDataStyle = 'unordered'; - let tagToSearch: string; - - // set list style and tag to search. - switch (tag) { - case 'OL': - style = 'ordered'; - tagToSearch = 'ol'; - break; - case 'UL': - case 'LI': - style = 'unordered'; - tagToSearch = 'ul'; - } - - const data: ListData = { - style, - items: [], - }; - - // get pasted items from the html. - const getPastedItems = (parent: Element): ListItem[] => { - // get first level li elements. - const children = Array.from(parent.querySelectorAll(`:scope > li`)); - - return children.map((child) => { - // get subitems if they exist. - const subItemsWrapper = child.querySelector(`:scope > ${tagToSearch}`); - // get subitems. - const subItems = subItemsWrapper ? getPastedItems(subItemsWrapper) : []; - // get text content of the li element. - const content = child?.firstChild?.textContent || ''; + public static get conversionConfig(): { + /** + * Method that is responsible for conversion from data to string + * @param data - current list data + * @returns - contents string formed from list data + */ + export: (data: ListData) => string; + /** + * Method that is responsible for conversion from string to data + * @param content - contents string + * @returns - list data formed from contents string + */ + import: (content: string, config: ToolConfig) => ListData; + } { + return { + export: (data) => { + return EditorjsList.joinRecursive(data); + }, + import: (content, config) => { return { - content, - items: subItems, + meta: {}, + items: [ + { + content, + meta: {}, + items: [], + }, + ], + style: config?.defaultStyle !== undefined ? config.defaultStyle : 'unordered', }; - }); + }, }; - - // get pasted items. - data.items = getPastedItems(element); - - return data; } /** - * Renders children list - * - * @param {ListItem[]} items - items data to append - * @param {Element} parentItem - where to append - * @returns {void} + * Get list style name */ - appendItems(items: ListItem[], parentItem: Element): void { - items.forEach((item) => { - const itemEl = this.createItem(item.content, item.items); - - parentItem.appendChild(itemEl); - }); + private get listStyle(): ListDataStyle { + return this.data.style || this.defaultListStyle; } /** - * Renders the single item - * - * @param {string} content - item content to render - * @param {ListItem[]} [items] - children - * @returns {Element} + * Set list style + * @param style - new style to set */ - createItem(content: string, items: ListItem[] = []): Element { - const itemWrapper = Dom.make('li', this.CSS.item); - const itemBody = Dom.make('div', this.CSS.itemBody); - const itemContent = Dom.make('div', this.CSS.itemContent, { - innerHTML: content, - contentEditable: (!this.readOnly).toString(), - }); + private set listStyle(style: ListDataStyle) { + this.data.style = style; - itemBody.appendChild(itemContent); - itemWrapper.appendChild(itemBody); + this.changeTabulatorByStyle(); /** - * Append children if we have some + * Create new list element */ - if (items && items.length > 0) { - this.addChildrenList(itemWrapper, items); - } + const newListElement = this.list!.render(); - return itemWrapper; + this.listElement?.replaceWith(newListElement); + + this.listElement = newListElement; } /** - * Extracts tool's data from the DOM - * - * @returns {ListData} + * The Editor.js API */ - save(): ListData { - /** - * The method for recursive collecting of the child items - * - * @param {Element} parent - where to find items - * @returns {ListItem[]} - */ - const getItems = (parent: Element): ListItem[] => { - const children = Array.from( - parent.querySelectorAll(`:scope > .${this.CSS.item}`) - ); - - return children.map((el) => { - const subItemsWrapper = el.querySelector(`.${this.CSS.itemChildren}`); - const content = this.getItemContent(el); - const subItems = subItemsWrapper ? getItems(subItemsWrapper) : []; - - return { - content, - items: subItems, - }; - }); - }; - - return { - style: this.data.style, - items: this.nodes.wrapper ? getItems(this.nodes.wrapper) : [], - }; - } + private api: API; /** - * Append children list to passed item - * - * @param {Element} parentItem - item that should contain passed sub-items - * @param {ListItem[]} items - sub items to append + * Is Ediotrjs List Tool read-only */ - addChildrenList(parentItem: Element, items: ListItem[]): void { - const itemBody = parentItem.querySelector(`.${this.CSS.itemBody}`); - const sublistWrapper = this.makeListWrapper(undefined, [ - this.CSS.itemChildren, - ]); - - this.appendItems(items, sublistWrapper); - - if (!itemBody) { - return; - } - - itemBody.appendChild(sublistWrapper); - } + private readOnly: boolean; /** - * Creates main