Skip to content

Latest commit

 

History

History
461 lines (350 loc) · 15.2 KB

README.md

File metadata and controls

461 lines (350 loc) · 15.2 KB

@prezly/slate-lists

The best Slate lists extension out there.

Demo: https://h9xxi.csb.app/ (source code).

API inspired by https://github.com/GitbookIO/slate-edit-list.

Version License

Table of contents

Demo

Live: https://h9xxi.csb.app/

Source code: https://codesandbox.io/s/prezlyslate-lists-demo-complete-example-h9xxi

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

  • all list-related nodes have a type: string attribute (you can customize the supported string values via ListsOptions)
  • there is an assumption that a default node type to which this extension can convert list-related nodes to exists (e.g. during normalization, or unwrapping lists)

Schema

  • a list node can only contain list item nodes
  • a list item node can contain either:
    • a list item text node
    • a list item text node and a list node (in that order) (nesting lists)
  • a list node can either:
    • have no parent node
    • have a parent list item node
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: 'bulleted-list' | 'numbered-list'; // see ListsOptions to customize this
}

interface ListItemNode {
    children: [ListItemTextNode] | [ListItemTextNode, ListNode];
    type: 'list-item'; // see ListsOptions to customize this
}

interface ListItemTextNode {
    children: Node[]; // by default everything is allowed here
    type: 'list-item-text'; // see ListsOptions to customize this
}

Installation

npm

npm install --save @prezly/slate-lists

yarn

yarn add @prezly/slate-lists

User guide

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/prezlyslate-lists-user-guide-0-initial-state-9gmff?file=/src/MyEditor.tsx

import { useMemo, useState } from 'react';
import { createEditor, Node } from 'slate';
import { Editable, Slate, withReact } from 'slate-react';

const initialValue: Node[] = [{ type: 'paragraph', children: [{ text: 'Hello world!' }] }];

export const MyEditor = () => {
    const [value, setValue] = useState(initialValue);
    const editor = useMemo(() => withReact(createEditor()), []);

    return (
        <Slate editor={editor} value={value} onChange={setValue}>
            <Editable />
        </Slate>
    );
};

First you're going to want to define options that will be passed to the extension. Just create an object matching the ListsOptions interface.

Live example: https://codesandbox.io/s/prezlyslate-lists-user-guide-1-define-options-m564b?file=/src/MyEditor.tsx

 import { useMemo, useState } from 'react';
 import { createEditor, Node } from 'slate';
 import { Editable, Slate, withReact } from 'slate-react';
+import { ListsOptions } from '@prezly/slate-lists';
+
+const options: ListsOptions = {
+  defaultBlockType: 'paragraph',
+  listItemTextType: 'list-item-text',
+  listItemType: 'list-item',
+  listTypes: ['ordered-list', 'unordered-list'],
+  wrappableTypes: ['paragraph']
+};

 const initialValue: Node[] = [{ type: 'paragraph', children: [{ text: 'Hello world!' }] }];

 const MyEditor = () => {
     const [value, setValue] = useState(initialValue);
     const editor = useMemo(() => withReact(createEditor()), []);

     return (
         <Slate editor={editor} value={value} onChange={setValue}>
             <Editable />
         </Slate>
     );
 };

 export default MyEditor;

Use withLists plugin

withLists is a Slate plugin that enables normalizations which enforce schema constraints and recover from unsupported structures.

Live example: https://codesandbox.io/s/prezlyslate-lists-user-guide-2-use-withlists-plugin-5splt?file=/src/MyEditor.tsx

 import { useMemo, useState } from 'react';
 import { createEditor, Node } from 'slate';
 import { Editable, Slate, withReact } from 'slate-react';
-import { ListsOptions } from '@prezly/slate-lists';
+import { ListsOptions, withLists } from '@prezly/slate-lists';

 const options: ListsOptions = {
     defaultBlockType: 'paragraph',
     listItemTextType: 'list-item-text',
     listItemType: 'list-item',
     listTypes: ['ordered-list', 'unordered-list'],
     wrappableTypes: ['paragraph'],
 };

 const initialValue: Node[] = [{ type: 'paragraph', children: [{ text: 'Hello world!' }] }];

 export const MyEditor = () => {
     const [value, setValue] = useState(initialValue);
-    const editor = useMemo(() => withReact(createEditor()), []);
+    const baseEditor = useMemo(() => withReact(createEditor()), []);
+    const editor = useMemo(() => withLists(options)(baseEditor), [baseEditor]);

     return (
         <Slate editor={editor} value={value} onChange={setValue}>
             <Editable />
         </Slate>
     );
 };

Use withListsReact plugin

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/prezlyslate-lists-user-guide-3-use-withlistsreact-plugin-rgubg?file=/src/MyEditor.tsx

 import { useMemo, useState } from 'react';
 import { createEditor, Node } from 'slate';
 import { Editable, Slate, withReact } from 'slate-react';
-import { ListsOptions, withLists } from '@prezly/slate-lists';
+import { ListsOptions, withLists, withListsReact } from '@prezly/slate-lists';

 const options: ListsOptions = {
     defaultBlockType: 'paragraph',
     listItemTextType: 'list-item-text',
     listItemType: 'list-item',
     listTypes: ['ordered-list', 'unordered-list'],
     wrappableTypes: ['paragraph'],
 };

 const initialValue: Node[] = [{ type: 'paragraph', children: [{ text: 'Hello world!' }] }];

 const MyEditor = () => {
     const [value, setValue] = useState(initialValue);
     const baseEditor = useMemo(() => withReact(createEditor()), []);
-    const editor = useMemo(() => withLists(options)(baseEditor), [baseEditor]);
+    const editor = useMemo(() => withListsReact(withLists(options)(baseEditor)), [baseEditor]);

     return (
         <Slate editor={editor} value={value} onChange={setValue}>
             <Editable />
         </Slate>
     );
 };

 export default MyEditor;

Use Lists

It's time to pass the ListsOptions instance to Lists function. It will create an object (lists) with utilities and transforms bound to the options you passed to it. Those are the building blocks you're going to use when adding lists support to your editor. Use them to implement UI controls, keyboard shortcuts, etc.

Live example: https://codesandbox.io/s/prezlyslate-lists-user-guide-4-use-lists-v5fop?file=/src/MyEditor.tsx

 import { useMemo, useState } from 'react';
 import { createEditor, Node } from 'slate';
 import { Editable, Slate, withReact } from 'slate-react';
-import { ListsOptions, withLists, withListsReact } from '@prezly/slate-lists';
+import { Lists, ListsOptions, withLists, withListsReact } from '@prezly/slate-lists';

 const options: ListsOptions = {
     defaultBlockType: 'paragraph',
     listItemTextType: 'list-item-text',
     listItemType: 'list-item',
     listTypes: ['ordered-list', 'unordered-list'],
     wrappableTypes: ['paragraph'],
 };
+
+ const lists = Lists(options);

 const initialValue: Node[] = [{ type: 'paragraph', children: [{ text: 'Hello world!' }] }];

 export const MyEditor = () => {
     const [value, setValue] = useState(initialValue);
     const baseEditor = useMemo(() => withReact(createEditor()), []);
     const editor = useMemo(() => withListsReact(withLists(options)(baseEditor)), [baseEditor]);

     return (
         <Slate editor={editor} value={value} onChange={setValue}>
             <Editable />
         </Slate>
     );
 };

Good to go

Now you can use the API exposed on the lists instance.

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.

All options are required.

Name Type Description
defaultBlockType string Type of the node that listItemTextType will become when it is unwrapped or normalized.
listItemTextType string Type of the node representing list item text.
listItemType string Type of the node representing list item.
listTypes string[] Types of nodes representing lists. The first type will be the default type (e.g. when wrapping with lists).
wrappableTypes string[] Types of nodes that can be converted into a node representing list item text.
/**
 * Enables normalizations that enforce schema constraints and recover from unsupported cases.
 */
withLists(options: ListsOptions) => (<T extends Editor>(editor: T) => T)
/**
 * Enables Range.prototype.cloneContents monkey patch to improve pasting behavior
 * in few edge cases.
 */
withListsReact<T extends ReactEditor>(editor: T): T
/**
 * Creates an API adapter with functions bound to passed options.
 */
Lists(options: ListsOptions) => :ListsApiAdapter:

Note: :ListsApiAdapter: is actually an implicit interface (ReturnType<typeof Lists>).

Here are its methods:

/**
 * Returns true when editor.deleteBackward() is safe to call (it won't break the structure).
 */
canDeleteBackward(editor: Editor) => boolean

/**
 * Decreases nesting depth of all "list-items" in the current selection.
 * All "list-items" in the root "list" will become "default" nodes.
 */
decreaseDepth(editor: Editor) => void

/**
 * Decreases nesting depth of "list-item" at a given Path.
 */
decreaseListItemDepth(editor: Editor, listItemPath: Path) => void

/**
 * Returns all "list-items" in a given Range.
 * @param at defaults to current selection if not specified
 */
getListItemsInRange(editor: Editor, at: Range | null | undefined) => NodeEntry<Node>[]

/**
 * Returns all "lists" in a given Range.
 * @param at defaults to current selection if not specified
 */
getListsInRange(editor: Editor, at: Range | null | undefined) => NodeEntry<Node>[]

/**
 * Returns the "type" of a given list node.
 */
getListType(node: Node) => string

/**
 * Returns "list" node nested in "list-item" at a given path.
 * Returns null if there is no nested "list".
 */
getNestedList(editor: Editor, listItemPath: Path) => NodeEntry<Element> | null

/**
 * Returns parent "list" node of "list-item" at a given path.
 * Returns null if there is no parent "list".
 */
getParentList(editor: Editor, listItemPath: Path) => NodeEntry<Element> | null

/**
 * Returns parent "list-item" node of "list-item" at a given path.
 * Returns null if there is no parent "list-item".
 */
getParentListItem(editor: Editor, listItemPath: Path) => NodeEntry<Element> | null

/**
 * Increases nesting depth of all "list-items" in the current selection.
 * All nodes matching options.wrappableTypes in the selection will be converted to "list-items" and wrapped in a "list".
 */
increaseDepth(editor: Editor) => void

/**
 * Increases nesting depth of "list-item" at a given Path.
 */
increaseListItemDepth(editor: Editor, listItemPath: Path) => void

/**
 * Returns true when editor has collapsed selection and the cursor is in an empty "list-item".
 */
isCursorInEmptyListItem(editor: Editor) => boolean

/**
 * Returns true when editor has collapsed selection and the cursor is at the beginning of a "list-item".
 */
isCursorAtStartOfListItem(editor: Editor) => boolean

/**
 * Checks whether node.type is an Element matching any of options.listTypes.
 */
isList(node: Node) => node is Element

/**
 * Checks whether node.type is an Element matching options.listItemType.
 */
isListItem(node: Node) => node is Element

/**
 * Checks whether node.type is an Element matching options.listItemTextType.
 */
isListItemText(node: Node) => node is Element

/**
 * Returns true if given "list-item" node contains a non-empty "list-item-text" node.
 */
listItemContainsText(editor: Editor, node: Node) => boolean

/**
 * Moves all "list-items" from one "list" to the end of another "list".
 */
moveListItemsToAnotherList(editor: Editor, parameters: { at: NodeEntry<Node>; to: NodeEntry<Node>; }) => void

/**
 * Nests (moves) given "list" in a given "list-item".
 */
moveListToListItem(editor: Editor, parameters: { at: NodeEntry<Node>; to: NodeEntry<Node>; }) => void

/**
 * Sets "type" of all "list" nodes in the current selection.
 */
setListType(editor: Editor, listType: string) => void

/**
 * Collapses the current selection (by removing everything in it) and if the cursor
 * ends up in a "list-item" node, it will break that "list-item" into 2 nodes, splitting
 * the text at the cursor location.
 */
splitListItem(editor: Editor) => void

/**
 * Unwraps all "list-items" in the current selection.
 * No list be left in the current selection.
 */
unwrapList(editor: Editor) => void

/**
 * All nodes matching options.wrappableTypes in the current selection
 * will be converted to "list-items" and wrapped in "lists".
 */
wrapInList(editor: Editor, listType: string) => void

Brought to you by Prezly.