@prezly/slate-lists
The best Slate lists extension out there.
Demo: https://veu8mp.csb.app/ (source code).
API inspired by https://github.com/GitbookIO/slate-edit-list.

Table of contents
Demo
Live: https://ocbkit.csb.app/
Source code: https://codesandbox.io/s/prezly-slate-lists-demo-ocbkit?file=/src/MyEditor.tsx
Features
- Nested lists
- Customizable list types (ordered, bulleted, dashed - anything goes)
- Transformations support multiple list items in selection
- Normalizations recover from invalid structure (helpful when pasting)
- Merges sibling lists of same type
Range.prototype.cloneContents
monkey patch to improve edge cases that occur when copying lists
Constraints
-
There are two types of lists: ordered and unordered.
You can initialize the plugin to work with any project-level data model via ListsSchema
.
-
There is an assumption that there is a default text node type to which the plugin can convert list-related nodes
(e.g. during normalization, or unwrapping lists). Normally, this is a paragraph block, but it's up to you.
Schema
As TypeScript interfaces...
Sometimes code can be better than words. Here are example TypeScript interfaces that describe the above schema (some schema rules are not expressible in TypeScript, so please treat it just as a quick overview).
import { Node } from 'slate';
interface ListNode {
children: ListItemNode[];
type: 'ordered-list' | 'unordered-list';
}
interface ListItemNode {
children: [ListItemTextNode] | [ListItemTextNode, ListNode];
type: 'list-item';
}
interface ListItemTextNode {
children: Node[];
type: 'list-item-text';
}
Installation
npm
npm install --save @prezly/slate-lists
yarn
yarn add @prezly/slate-lists
User guide
0. Basic Editor
Let's start with a minimal Slate + React example which we will be adding lists support to.
Nothing interesting here just yet.
Live example: https://codesandbox.io/s/prezly-slate-lists-user-guide-0-basic-editor-veu8mp?file=/src/MyEditor.tsx
import { useMemo, useState } from 'react';
import { createEditor, BaseElement, Descendant } from 'slate';
import { withHistory } from 'slate-history';
import { Editable, ReactEditor, RenderElementProps, Slate, withReact } from 'slate-react';
declare module 'slate' {
interface CustomTypes {
Element: { type: Type } & BaseElement;
}
}
enum Type {
PARAGRAPH = 'paragraph',
}
function renderElement({ element, attributes, children }: RenderElementProps) {
switch (element.type) {
case Type.PARAGRAPH:
return <p {...attributes}>{children}</p>;
}
}
const initialValue: Descendant[] = [{ type: Type.PARAGRAPH, children: [{ text: 'Hello world!' }] }];
export function MyEditor() {
const [value, setValue] = useState(initialValue);
const editor = useMemo(() => withHistory(withReact(createEditor() as ReactEditor)), []);
return (
<Slate editor={editor} value={value} onChange={setValue}>
<Editable renderElement={renderElement} />
</Slate>
);
}
1. Define Lists Model
First, you're going to want to define the model to power the lists functionality.
You'll need at least three additional node types:
- List Node
- List Item Node
- List Item Text Node
Additionally, you may want to split List Node into two types: ordered list and unordered list. Or alternatively,
achieve the split with an additional node property (like listNode: ListType
).
In this example, for the sake of simplicity, we'll use two different node types:
- Ordered List Node
- Unordered List Node
Live example: https://codesandbox.io/s/prezly-slate-lists-user-guide-1-list-nodes-model-qyepe4?file=/src/MyEditor.tsx
import { useMemo, useState } from 'react';
import { createEditor, BaseElement, Descendant } from 'slate';
import { withHistory } from 'slate-history';
import { Editable, ReactEditor, RenderElementProps, Slate, withReact } from 'slate-react';
declare module 'slate' {
interface CustomTypes {
Element: { type: Type } & BaseElement;
}
}
enum Type {
PARAGRAPH = 'paragraph',
+ ORDERED_LIST = 'ordered-list',
+ UNORDERED_LIST = 'unordered-list',
+ LIST_ITEM = 'list-item',
+ LIST_ITEM_TEXT = 'list-item-text',
}
function renderElement({ element, attributes, children }: RenderElementProps) {
switch (element.type) {
case Type.PARAGRAPH:
return <p {...attributes}>{children}</p>;
+ case Type.ORDERED_LIST:
+ return <ol {...attributes}>{children}</ol>;
+ case Type.UNORDERED_LIST:
+ return <ul {...attributes}>{children}</ul>;
+ case Type.LIST_ITEM:
+ return <li {...attributes}>{children}</li>;
+ case Type.LIST_ITEM_TEXT:
+ return <div {...attributes}>{children}</div>;
}
}
const initialValue: Descendant[] = [
{ type: Type.PARAGRAPH, children: [{ text: 'Hello world!' }] },
+ {
+ type: Type.ORDERED_LIST,
+ children: [
+ {
+ type: Type.LIST_ITEM,
+ children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'One' }] }],
+ },
+ {
+ type: Type.LIST_ITEM,
+ children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Two' }] }],
+ },
+ {
+ type: Type.LIST_ITEM,
+ children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Three' }] }],
+ },
+ ],
+ },
+ {
+ type: Type.UNORDERED_LIST,
+ children: [
+ {
+ type: Type.LIST_ITEM,
+ children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Red' }] }],
+ },
+ {
+ type: Type.LIST_ITEM,
+ children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Green' }] }],
+ },
+ {
+ type: Type.LIST_ITEM,
+ children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Blue' }] }],
+ },
+ ],
+ },
];
export function MyEditor() {
const [value, setValue] = useState(initialValue);
const editor = useMemo(() => withHistory(withReact(createEditor() as ReactEditor)), []);
return (
<Slate editor={editor} value={value} onChange={setValue}>
<Editable renderElement={renderElement} />
</Slate>
);
}
2. Define lists plugin schema and connect withLists
plugin to your model
withLists
is a Slate plugin
that enables normalizations
which enforce schema constraints and recover from unsupported structures.
Live example: https://codesandbox.io/s/prezly-slate-lists-user-guide-2-add-withlists-plugin-r2xscj?file=/src/MyEditor.tsx
import { useMemo, useState } from 'react';
import { createEditor, BaseElement, Descendant, Element, Node } from 'slate';
import { withHistory } from 'slate-history';
import { Editable, ReactEditor, RenderElementProps, Slate, withReact } from 'slate-react';
+import { ListType, withLists } from '@prezly/slate-lists';
declare module 'slate' {
interface CustomTypes {
Element: { type: Type } & BaseElement;
}
}
enum Type {
PARAGRAPH = 'paragraph',
ORDERED_LIST = 'ordered-list',
UNORDERED_LIST = 'unordered-list',
LIST_ITEM = 'list-item',
LIST_ITEM_TEXT = 'list-item-text',
}
+const withListsPlugin = withLists({
+ isConvertibleToListTextNode(node: Node) {
+ return Element.isElementType(node, Type.PARAGRAPH);
+ },
+ isDefaultTextNode(node: Node) {
+ return Element.isElementType(node, Type.PARAGRAPH);
+ },
+ isListNode(node: Node, type?: ListType) {
+ if (type) {
+ return Element.isElementType(node, type);
+ }
+ return (
+ Element.isElementType(node, Type.ORDERED_LIST) ||
+ Element.isElementType(node, Type.UNORDERED_LIST)
+ );
+ },
+ isListItemNode(node: Node) {
+ return Element.isElementType(node, Type.LIST_ITEM);
+ },
+ isListItemTextNode(node: Node) {
+ return Element.isElementType(node, Type.LIST_ITEM_TEXT);
+ },
+ createDefaultTextNode(props = {}) {
+ return { children: [{ text: '' }], ...props, type: Type.PARAGRAPH };
+ },
+ createListNode(type: ListType = ListType.UNORDERED, props = {}) {
+ const nodeType = type === ListType.ORDERED ? Type.ORDERED_LIST : Type.UNORDERED_LIST;
+ return { children: [{ text: '' }], ...props, type: nodeType };
+ },
+ createListItemNode(props = {}) {
+ return { children: [{ text: '' }], ...props, type: Type.LIST_ITEM };
+ },
+ createListItemTextNode(props = {}) {
+ return { children: [{ text: '' }], ...props, type: Type.LIST_ITEM_TEXT };
+ },
+});
function renderElement({ element, attributes, children }: RenderElementProps) {
switch (element.type) {
case Type.PARAGRAPH:
return <p {...attributes}>{children}</p>;
case Type.ORDERED_LIST:
return <ol {...attributes}>{children}</ol>;
case Type.UNORDERED_LIST:
return <ul {...attributes}>{children}</ul>;
case Type.LIST_ITEM:
return <li {...attributes}>{children}</li>;
case Type.LIST_ITEM_TEXT:
return <div {...attributes}>{children}</div>;
}
}
const initialValue: Descendant[] = [
{ type: Type.PARAGRAPH, children: [{ text: 'Hello world!' }] },
{
type: Type.ORDERED_LIST,
children: [
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'One' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Two' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Three' }] }],
},
],
},
{
type: Type.UNORDERED_LIST,
children: [
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Red' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Green' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Blue' }] }],
},
],
},
];
export function MyEditor() {
const [value, setValue] = useState(initialValue);
+ const editor = useMemo(() => withListsPlugin(withHistory(withReact(createEditor() as ReactEditor))), []);
return (
<Slate editor={editor} value={value} onChange={setValue}>
<Editable renderElement={renderElement} />
</Slate>
);
}
withListsReact
is useful on the client-side -
it's a Slate plugin - that overrides editor.setFragmentData
.
It enables Range.prototype.cloneContents
monkey patch to improve copying behavior in some edge cases.
Live example: https://codesandbox.io/s/prezly-slate-lists-user-guide-3-add-withlistsreact-plugin-t81im0?file=/src/MyEditor.tsx
import { useMemo, useState } from 'react';
import { createEditor, BaseElement, Descendant, Element, Node } from 'slate';
import { withHistory } from 'slate-history';
import { Editable, ReactEditor, RenderElementProps, Slate, withReact } from 'slate-react';
+import { ListType, withLists, withListsReact } from '@prezly/slate-lists';
declare module 'slate' {
interface CustomTypes {
Element: { type: Type } & BaseElement;
}
}
enum Type {
PARAGRAPH = 'paragraph',
ORDERED_LIST = 'ordered-list',
UNORDERED_LIST = 'unordered-list',
LIST_ITEM = 'list-item',
LIST_ITEM_TEXT = 'list-item-text',
}
const withListsPlugin = withLists({
isConvertibleToListTextNode(node: Node) {
return Element.isElementType(node, Type.PARAGRAPH);
},
isDefaultTextNode(node: Node) {
return Element.isElementType(node, Type.PARAGRAPH);
},
isListNode(node: Node, type: ListType) {
if (type) {
return Element.isElementType(node, type);
}
return (
Element.isElementType(node, Type.ORDERED_LIST) ||
Element.isElementType(node, Type.UNORDERED_LIST)
);
},
isListItemNode(node: Node) {
return Element.isElementType(node, Type.LIST_ITEM);
},
isListItemTextNode(node: Node) {
return Element.isElementType(node, Type.LIST_ITEM_TEXT);
},
createDefaultTextNode(props = {}) {
return { children: [{ text: '' }], ...props, type: Type.PARAGRAPH };
},
createListNode(type: ListType = ListType.UNORDERED, props = {}) {
const nodeType = type
return { children: [{ text: '' }], ...props, type: nodeType };
},
createListItemNode(props = {}) {
return { children: [{ text: '' }], ...props, type: Type.LIST_ITEM };
},
createListItemTextNode(props = {}) {
return { children: [{ text: '' }], ...props, type: Type.LIST_ITEM_TEXT };
},
});
function renderElement({ element, attributes, children }: RenderElementProps) {
switch (element.type) {
case Type.PARAGRAPH:
return <p {...attributes}>{children}</p>;
case Type.ORDERED_LIST:
return <ol {...attributes}>{children}</ol>;
case Type.UNORDERED_LIST:
return <ul {...attributes}>{children}</ul>;
case Type.LIST_ITEM:
return <li {...attributes}>{children}</li>;
case Type.LIST_ITEM_TEXT:
return <div {...attributes}>{children}</div>;
}
}
const initialValue: Descendant[] = [
{ type: Type.PARAGRAPH, children: [{ text: 'Hello world!' }] },
{
type: Type.ORDERED_LIST,
children: [
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'One' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Two' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Three' }] }],
},
],
},
{
type: Type.UNORDERED_LIST,
children: [
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Red' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Green' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Blue' }] }],
},
],
},
];
export function MyEditor() {
const [value, setValue] = useState(initialValue);
const editor = useMemo(
+ () => withListsReact(withListsPlugin(withHistory(withReact(createEditor() as ReactEditor)))),
[],
);
return (
<Slate editor={editor} value={value} onChange={setValue}>
<Editable renderElement={renderElement} />
</Slate>
);
}
4. Add onKeyDown
handler
@prezly/slate-lists
comes with a pre-defined onKeyDown
handler to implement keyboard interactions for lists.
For example, pressing Tab
in a list will indent the current list item one level deeper.
Pressing Shift+Tab
will raise the current list item one level up.
Live example: https://codesandbox.io/s/prezly-slate-lists-user-guide-4-add-onkeydown-handler-5wpqxv?file=/src/MyEditor.tsx
import { useMemo, useState } from 'react';
import { createEditor, BaseElement, Descendant, Element, Node } from 'slate';
import { withHistory } from 'slate-history';
import { Editable, ReactEditor, RenderElementProps, Slate, withReact } from 'slate-react';
+import { ListType, onKeyDown, withLists, withListsReact } from '@prezly/slate-lists';
declare module 'slate' {
interface CustomTypes {
Element: { type: Type } & BaseElement;
}
}
enum Type {
PARAGRAPH = 'paragraph',
ORDERED_LIST = 'ordered-list',
UNORDERED_LIST = 'unordered-list',
LIST_ITEM = 'list-item',
LIST_ITEM_TEXT = 'list-item-text',
}
const withListsPlugin = withLists({
isConvertibleToListTextNode(node: Node) {
return Element.isElementType(node, Type.PARAGRAPH);
},
isDefaultTextNode(node: Node) {
return Element.isElementType(node, Type.PARAGRAPH);
},
isListNode(node: Node, type: ListType) {
if (type) {
return Element.isElementType(node, type);
}
return (
Element.isElementType(node, Type.ORDERED_LIST) ||
Element.isElementType(node, Type.UNORDERED_LIST)
);
},
isListItemNode(node: Node) {
return Element.isElementType(node, Type.LIST_ITEM);
},
isListItemTextNode(node: Node) {
return Element.isElementType(node, Type.LIST_ITEM_TEXT);
},
createDefaultTextNode(props = {}) {
return { children: [{ text: '' }], ...props, type: Type.PARAGRAPH };
},
createListNode(type: ListType = ListType.UNORDERED, props = {}) {
const nodeType = type
return { children: [{ text: '' }], ...props, type: nodeType };
},
createListItemNode(props = {}) {
return { children: [{ text: '' }], ...props, type: Type.LIST_ITEM };
},
createListItemTextNode(props = {}) {
return { children: [{ text: '' }], ...props, type: Type.LIST_ITEM_TEXT };
},
});
function renderElement({ element, attributes, children }: RenderElementProps) {
switch (element.type) {
case Type.PARAGRAPH:
return <p {...attributes}>{children}</p>;
case Type.ORDERED_LIST:
return <ol {...attributes}>{children}</ol>;
case Type.UNORDERED_LIST:
return <ul {...attributes}>{children}</ul>;
case Type.LIST_ITEM:
return <li {...attributes}>{children}</li>;
case Type.LIST_ITEM_TEXT:
return <div {...attributes}>{children}</div>;
default:
return <div {...attributes}>{children}</div>;
}
}
const initialValue: Descendant[] = [
{ type: Type.PARAGRAPH, children: [{ text: 'Hello world!' }] },
{
type: Type.ORDERED_LIST,
children: [
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'One' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Two' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Three' }] }],
},
],
},
{
type: Type.UNORDERED_LIST,
children: [
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Red' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Green' }] }],
},
{
type: Type.LIST_ITEM,
children: [{ type: Type.LIST_ITEM_TEXT, children: [{ text: 'Blue' }] }],
},
],
},
];
export function MyEditor() {
const [value, setValue] = useState(initialValue);
const editor = useMemo(
() => withListsReact(withListsPlugin(withHistory(withReact(createEditor() as ReactEditor)))),
[],
);
return (
<Slate editor={editor} value={value} onChange={setValue}>
<Editable
+ onKeyDown={(event) => onKeyDown(editor, event)}
renderElement={renderElement}
/>
</Slate>
);
}
Good to go
Now you can use the API exposed on the ListsEditor
functions.
Be sure to check the complete usage example.
API
There are JSDocs for all core functionality.
Only core API is documented although all utility functions are exposed. Should you ever need anything beyond the core API, please have a look at src/index.ts
to see what's available.
Lists schema wires the Lists plugin to your project-level defined Slate model.
It is designed with 100% customization in mind, not depending on any specific node types, or non-core interfaces.
isConvertibleToListTextNode | Check if a node can be converted to a list item text node. |
isDefaultTextNode | Check if a node is a plain default text node, that list item text node will become when it is unwrapped or normalized. |
isListNode | Check if a node is representing a list. |
isListItemNode | Check if a node is representing a list item. |
isListItemTextNode | Check if a node is representing a list item text. |
createDefaultTextNode | Create a plain default text node. List item text nodes become these when unwrapped or normalized. |
createListNode | Create a new list node of the given type. |
createListItemNode | Create a new list item node. |
createListItemTextNode | Create a new list item text node. |
ListsEditor is an instance of Slate Editor
, extends with ListsSchema
methods:
type ListsEditor = Editor & ListsSchema;
withLists(schema: ListsSchema) => (<T extends Editor>(editor: T) => T & ListsEditor)
withListsReact<T extends ReactEditor & ListsEditor>(editor: T): T
ListsEditor
is a namespace export with all list-related editor utility functions.
Most of them require an instance of ListsEditor
as the first argument.
Here are the functions methods:
canDeleteBackward(editor: ListsEditor) => boolean
decreaseDepth(editor: ListsEditor) => void
decreaseListItemDepth(editor: ListsEditor, listItemPath: Path) => void
getListItemsInRange(editor: ListsEditor, at: Range | null | undefined) => NodeEntry<Node>[]
getListsInRange(editor: ListsEditor, at: Range | null | undefined) => NodeEntry<Node>[]
getListType(node: Node) => string
getNestedList(editor: ListsEditor, listItemPath: Path) => NodeEntry<Element> | null
getParentList(editor: ListsEditor, listItemPath: Path) => NodeEntry<Element> | null
getParentListItem(editor: ListsEditor, listItemPath: Path) => NodeEntry<Element> | null
increaseDepth(editor: ListsEditor) => void
increaseListItemDepth(editor: ListsEditor, listItemPath: Path) => void
isCursorInEmptyListItem(editor: ListsEditor) => boolean
isCursorAtStartOfListItem(editor: ListsEditor) => boolean
isListItemContainingText(editor: ListsEditor, node: Node) => boolean
moveListItemsToAnotherList(editor: ListsEditor, parameters: { at: NodeEntry<Node>; to: NodeEntry<Node>; }) => void
moveListToListItem(editor: ListsEditor, parameters: { at: NodeEntry<Node>; to: NodeEntry<Node>; }) => void
setListType(editor: ListsEditor, listType: string) => void
splitListItem(editor: ListsEditor) => void
unwrapList(editor: ListsEditor) => void
wrapInList(editor: ListsEditor, listType: ListType) => void
Brought to you by Prezly.