介绍
SlateJs是一个完全可定制的框架,用于构建富文本编辑器。它可让您构建丰富、直观的编辑器,例如Medium、Dropbox Paper或Google Docs中的编辑器——这些编辑器正在成为网络应用程序的筹码——而您的代码库不会陷入复杂性的泥潭。
它可以做到这一点,因为它的所有逻辑都是用一系列插件实现的,所以你永远不会被“核心”中的内容所限制。
contenteditable
您可以将其视为构建在React之上的可插拔实现。它的灵感来自Draft.js、Prosemirror和Quill等库。为什么要使用Slate?
在创建 Slate 之前,我尝试了很多其他的富文本库—— Draft.js、Prosemirror、Quill等。我发现虽然让简单的示例工作起来很容易,但一旦你开始尝试构建类似的东西Medium、Dropbox Paper或Google Docs,你遇到了更深层次的问题……
- 编辑器的“模式”是硬编码的,很难定制。像粗体和斜体这样的东西是开箱即用的,但是评论,嵌入,甚至更多特定领域的需求呢?
- 以编程方式转换文档非常复杂。以用户身份编写可能会奏效,但对构建高级行为至关重要的程序化更改是不必要的复杂。
- 序列化为 HTML、Markdown 等似乎是事后才想到的。将文档转换为 HTML 或 Markdown 等简单的事情涉及编写大量样板代码,这似乎是非常常见的用例。
- 重新发明视图层似乎效率低下且受到限制。大多数编辑都采用了自己的观点,而不是使用像 React 这样的现有技术,所以你必须学习一个带有新“陷阱”的全新系统。
- 协作编辑不是预先设计的。通常,编辑器对数据的内部表示使其无法用于实时、协作编辑用例,除非基本上重写编辑器。
- 存储库是整体的,不小且可重复使用。许多编辑器的代码库通常没有公开可能被开发人员重复使用的内部工具,导致不得不重新发明轮子。
- 构建复杂的嵌套文档是不可能的。许多编辑器是围绕简单的“平面”文档设计的,使得表格、嵌入和标题等内容难以推理,有时甚至是不可能的。
当然,并不是每个编辑器都会出现所有这些问题,但是如果您尝试使用其他编辑器,您可能会遇到类似的问题。为了绕过他们 API 的限制并实现您所追求的用户体验,您必须求助于非常 hacky 的东西。有些经验是完全不可能实现的。
如果这听起来很熟悉,您可能会喜欢 Slate。
这让我想到了 Slate 如何解决所有这些问题……
Slatejs设计原则
Slate 试图通过以下几个原则来解决“为什么? ”的问题:
- 一流的插件。Slate 最重要的部分是插件是一流的实体。这意味着您可以完全自定义编辑体验,构建像 Medium 或 Dropbox 这样的复杂编辑器,而无需与库的假设作斗争。
- 无模式核心。Slate 的核心逻辑很少假设您将要编辑的数据的模式,这意味着当您需要超越最基本的用例时,库中没有任何假设会绊倒您。
- 嵌套文档模型。Slate 使用的文档模型是嵌套的递归树,就像 DOM 本身一样。这意味着可以为高级用例创建复杂的组件,如表格或嵌套块引号。但也很容易通过只使用一个层次结构来保持简单。
- 与 DOM 平行。Slate 的数据模型基于 DOM — 文档是一个嵌套树,它使用选择和范围,并且公开所有标准事件处理程序。这意味着诸如表格或嵌套块引号之类的高级行为是可能的。您可以在 DOM 中执行的几乎所有操作,都可以在 Slate 中执行。
- 直观的命令。Slate 文档是使用“命令”进行编辑的,这些命令被设计为高级且非常直观的读写,因此自定义功能尽可能具有表现力。这极大地提高了您推理代码的能力。
- 协作就绪的数据模型。Slate 使用的数据模型(特别是如何将操作应用于文档)旨在允许协作编辑在顶部进行分层,因此如果您决定让您的编辑器协作,则无需重新考虑所有内容。
- 清除“核心”界限。借助插件优先架构和无模式核心,“核心”和“自定义”之间的界限变得更加清晰,这意味着核心体验不会在边缘情况下陷入困境。
示例
1、 纯文本示例
import React, { useMemo } from 'react' import { createEditor, Descendant } from 'slate' import { Slate, Editable, withReact } from 'slate-react' import { withHistory } from 'slate-history' const PlainTextExample = () => { const editor = useMemo(() => withHistory(withReact(createEditor())), []) return ( <Slate editor={editor} value={initialValue}> <Editable placeholder="Enter some plain text..." /> </Slate> ) } const initialValue: Descendant[] = [ { type: 'paragraph', children: [ { text: 'This is editable plain text, just like a <textarea>!' }, ], }, ] export default PlainTextExample
2、富文本示范
import React, { useCallback, useMemo } from 'react' import isHotkey from 'is-hotkey' import { Editable, withReact, useSlate, Slate } from 'slate-react' import { Editor, Transforms, createEditor, Descendant, Element as SlateElement, } from 'slate' import { withHistory } from 'slate-history' import { Button, Icon, Toolbar } from '../components' const HOTKEYS = { 'mod+b': 'bold', 'mod+i': 'italic', 'mod+u': 'underline', 'mod+`': 'code', } const LIST_TYPES = ['numbered-list', 'bulleted-list'] const TEXT_ALIGN_TYPES = ['left', 'center', 'right', 'justify'] const RichTextExample = () => { const renderElement = useCallback(props => <Element {...props} />, []) const renderLeaf = useCallback(props => <Leaf {...props} />, []) const editor = useMemo(() => withHistory(withReact(createEditor())), []) return ( <Slate editor={editor} value={initialValue}> <Toolbar> <MarkButton format="bold" icon="format_bold" /> <MarkButton format="italic" icon="format_italic" /> <MarkButton format="underline" icon="format_underlined" /> <MarkButton format="code" icon="code" /> <BlockButton format="heading-one" icon="looks_one" /> <BlockButton format="heading-two" icon="looks_two" /> <BlockButton format="block-quote" icon="format_quote" /> <BlockButton format="numbered-list" icon="format_list_numbered" /> <BlockButton format="bulleted-list" icon="format_list_bulleted" /> <BlockButton format="left" icon="format_align_left" /> <BlockButton format="center" icon="format_align_center" /> <BlockButton format="right" icon="format_align_right" /> <BlockButton format="justify" icon="format_align_justify" /> </Toolbar> <Editable renderElement={renderElement} renderLeaf={renderLeaf} placeholder="Enter some rich text…" spellCheck autoFocus onKeyDown={event => { for (const hotkey in HOTKEYS) { if (isHotkey(hotkey, event as any)) { event.preventDefault() const mark = HOTKEYS[hotkey] toggleMark(editor, mark) } } }} /> </Slate> ) } const toggleBlock = (editor, format) => { const isActive = isBlockActive( editor, format, TEXT_ALIGN_TYPES.includes(format) ? 'align' : 'type' ) const isList = LIST_TYPES.includes(format) Transforms.unwrapNodes(editor, { match: n => !Editor.isEditor(n) && SlateElement.isElement(n) && LIST_TYPES.includes(n.type) && !TEXT_ALIGN_TYPES.includes(format), split: true, }) let newProperties: Partial<SlateElement> if (TEXT_ALIGN_TYPES.includes(format)) { newProperties = { align: isActive ? undefined : format, } } else { newProperties = { type: isActive ? 'paragraph' : isList ? 'list-item' : format, } } Transforms.setNodes<SlateElement>(editor, newProperties) if (!isActive && isList) { const block = { type: format, children: [] } Transforms.wrapNodes(editor, block) } } const toggleMark = (editor, format) => { const isActive = isMarkActive(editor, format) if (isActive) { Editor.removeMark(editor, format) } else { Editor.addMark(editor, format, true) } } const isBlockActive = (editor, format, blockType = 'type') => { const { selection } = editor if (!selection) return false const [match] = Array.from( Editor.nodes(editor, { at: Editor.unhangRange(editor, selection), match: n => !Editor.isEditor(n) && SlateElement.isElement(n) && n[blockType] === format, }) ) return !!match } const isMarkActive = (editor, format) => { const marks = Editor.marks(editor) return marks ? marks[format] === true : false } const Element = ({ attributes, children, element }) => { const style = { textAlign: element.align } switch (element.type) { case 'block-quote': return ( <blockquote style={style} {...attributes}> {children} </blockquote> ) case 'bulleted-list': return ( <ul style={style} {...attributes}> {children} </ul> ) case 'heading-one': return ( <h1 style={style} {...attributes}> {children} </h1> ) case 'heading-two': return ( <h2 style={style} {...attributes}> {children} </h2> ) case 'list-item': return ( <li style={style} {...attributes}> {children} </li> ) case 'numbered-list': return ( <ol style={style} {...attributes}> {children} </ol> ) default: return ( <p style={style} {...attributes}> {children} </p> ) } } const Leaf = ({ attributes, children, leaf }) => { if (leaf.bold) { children = <strong>{children}</strong> } if (leaf.code) { children = <code>{children}</code> } if (leaf.italic) { children = <em>{children}</em> } if (leaf.underline) { children = <u>{children}</u> } return <span {...attributes}>{children}</span> } const BlockButton = ({ format, icon }) => { const editor = useSlate() return ( <Button active={isBlockActive( editor, format, TEXT_ALIGN_TYPES.includes(format) ? 'align' : 'type' )} onMouseDown={event => { event.preventDefault() toggleBlock(editor, format) }} > <Icon>{icon}</Icon> </Button> ) } const MarkButton = ({ format, icon }) => { const editor = useSlate() return ( <Button active={isMarkActive(editor, format)} onMouseDown={event => { event.preventDefault() toggleMark(editor, format) }} > <Icon>{icon}</Icon> </Button> ) } const initialValue: Descendant[] = [ { type: 'paragraph', children: [ { text: 'This is editable ' }, { text: 'rich', bold: true }, { text: ' text, ' }, { text: 'much', italic: true }, { text: ' better than a ' }, { text: '<textarea>', code: true }, { text: '!' }, ], }, { type: 'paragraph', children: [ { text: "Since it's rich text, you can do things like turn a selection of text ", }, { text: 'bold', bold: true }, { text: ', or add a semantically rendered block quote in the middle of the page, like this:', }, ], }, { type: 'block-quote', children: [{ text: 'A wise quote.' }], }, { type: 'paragraph', align: 'center', children: [{ text: 'Try it out for yourself!' }], }, ] export default RichTextExample
3、MarkDown示范
import Prism from 'prismjs' import React, { useCallback, useMemo } from 'react' import { Slate, Editable, withReact } from 'slate-react' import { Text, createEditor, Descendant } from 'slate' import { withHistory } from 'slate-history' import { css } from '@emotion/css' // eslint-disable-next-line ;Prism.languages.markdown=Prism.languages.extend("markup",{}),Prism.languages.insertBefore("markdown","prolog",{blockquote:{pattern:/^>(?:[\t ]*>)*/m,alias:"punctuation"},code:[{pattern:/^(?: {4}|\t).+/m,alias:"keyword"},{pattern:/``.+?``|`[^`\n]+`/,alias:"keyword"}],title:[{pattern:/\w+.*(?:\r?\n|\r)(?:==+|--+)/,alias:"important",inside:{punctuation:/==+$|--+$/}},{pattern:/(^\s*)#+.+/m,lookbehind:!0,alias:"important",inside:{punctuation:/^#+|#+$/}}],hr:{pattern:/(^\s*)([*-])([\t ]*){2,}(?=\s*$)/m,lookbehind:!0,alias:"punctuation"},list:{pattern:/(^\s*)(?:[*+-]|\d+\.)(?=[\t ].)/m,lookbehind:!0,alias:"punctuation"},"url-reference":{pattern:/!?\[[^\]]+\]:[\t ]+(?:\S+|<(?:\\.|[^>\\])+>)(?:[\t ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)))?/,inside:{variable:{pattern:/^(!?\[)[^\]]+/,lookbehind:!0},string:/(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\))$/,punctuation:/^[\[\]!:]|[<>]/},alias:"url"},bold:{pattern:/(^|[^\\])(\*\*|__)(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?/,lookbehind:!0,inside:{punctuation:/^\*\*|^__|\*\*$|__$/}},italic:{pattern:/(^|[^\\])([*_])(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?/,lookbehind:!0,inside:{punctuation:/^[*_]|[*_]$/}},url:{pattern:/!?\[[^\]]+\](?:\([^\s)]+(?:[\t ]+"(?:\\.|[^"\\])*")?\)| ?\[[^\]\n]*\])/,inside:{variable:{pattern:/(!?\[)[^\]]+(?=\]$)/,lookbehind:!0},string:{pattern:/"(?:\\.|[^"\\])*"(?=\)$)/}}}}),Prism.languages.markdown.bold.inside.url=Prism.util.clone(Prism.languages.markdown.url),Prism.languages.markdown.italic.inside.url=Prism.util.clone(Prism.languages.markdown.url),Prism.languages.markdown.bold.inside.italic=Prism.util.clone(Prism.languages.markdown.italic),Prism.languages.markdown.italic.inside.bold=Prism.util.clone(Prism.languages.markdown.bold); // prettier-ignore const MarkdownPreviewExample = () => { const renderLeaf = useCallback(props => <Leaf {...props} />, []) const editor = useMemo(() => withHistory(withReact(createEditor())), []) const decorate = useCallback(([node, path]) => { const ranges = [] if (!Text.isText(node)) { return ranges } const getLength = token => { if (typeof token === 'string') { return token.length } else if (typeof token.content === 'string') { return token.content.length } else { return token.content.reduce((l, t) => l + getLength(t), 0) } } const tokens = Prism.tokenize(node.text, Prism.languages.markdown) let start = 0 for (const token of tokens) { const length = getLength(token) const end = start + length if (typeof token !== 'string') { ranges.push({ [token.type]: true, anchor: { path, offset: start }, focus: { path, offset: end }, }) } start = end } return ranges }, []) return ( <Slate editor={editor} value={initialValue}> <Editable decorate={decorate} renderLeaf={renderLeaf} placeholder="Write some markdown..." /> </Slate> ) } const Leaf = ({ attributes, children, leaf }) => { return ( <span {...attributes} className={css` font-weight: ${leaf.bold && 'bold'}; font-style: ${leaf.italic && 'italic'}; text-decoration: ${leaf.underlined && 'underline'}; ${leaf.title && css` display: inline-block; font-weight: bold; font-size: 20px; margin: 20px 0 10px 0; `} ${leaf.list && css` padding-left: 10px; font-size: 20px; line-height: 10px; `} ${leaf.hr && css` display: block; text-align: center; border-bottom: 2px solid #ddd; `} ${leaf.blockquote && css` display: inline-block; border-left: 2px solid #ddd; padding-left: 10px; color: #aaa; font-style: italic; `} ${leaf.code && css` font-family: monospace; background-color: #eee; padding: 3px; `} `} > {children} </span> ) } const initialValue: Descendant[] = [ { type: 'paragraph', children: [ { text: 'Slate is flexible enough to add **decorations** that can format text based on its content. For example, this editor has **Markdown** preview decorations on it, to make it _dead_ simple to make an editor with built-in Markdown previewing.', }, ], }, { type: 'paragraph', children: [{ text: '## Try it out!' }], }, { type: 'paragraph', children: [{ text: 'Try it out for yourself!' }], }, ] export default MarkdownPreviewExample
4、更多
更多请移步GitHub:https://github.com/ianstormtaylor/slate/edit/main/site/examples