Photo credit: github.com/ianstormtay…
Introduction of Slate
Slate is a rich text editor development framework developed in TypeScript by Ian Storm Taylor in 2016. Slate is a fully customizable framework for rich text editors. Slate lets you build rich, intuitive editors like Medium, Dropbox Paper, or Google Docs. You can think of it as a pluggable contenteditable implementation of React. It takes inspiration from such libraries as draft.js, Prosemirror, and Quill. Some of SLATE’s more well-known users include GitBook and Wordbird.
Slate online Demo
The characteristics of
- As first-class citizens, plug-ins can completely modify editor behavior;
- The data layer is separated from the rendering layer, and updating the data triggers the rendering.
- Document data is similar to A DOM tree and can be nested;
- With atomic operation API, support collaborative editing;
- Use React as the render layer;
Introduction to the SLATE Architecture
Architecture diagram
There are four packages in the SLATE repository:
- Slate History: History plugin with undo/redo support
- Slate-hyperscript: Ability to create slates using JSX syntax
- Slate-react: View layer;
- SLATE: Core Editor abstract, defined Editor, Path, Node, Text, Operation and other basic classes, Transforms operations;
slate (model)
The SLATE Package is the heart of SLATE, defining the editor’s data models, the basic operations to manipulate those models, and methods to create editor instance objects.
Interfaces
In the intefaces directory is the data model defined by Slate, which defines editor, Element, text, path, point, range, Operation, location, and so on.
export type Node = Editor | Element | Text
export interface Element {
children: Node[]
[key: string]: unknown
}
export interface Text {
text: string
[key: string]: unknown
}
Copy the code
- EditorSlate’s top level node is
Editor
. It encapsulates all the rich text content of the document, but the most important part to the node is itschildren
Property, which contains aNode
The object tree. - The Element type contains the children attribute.
- Text The Text node is the lowest level node in the tree and contains the Text content of the document as well as any format.
Users can extend Node attributes by adding a type field to identify the Node type (Paragraph, ordered list, heading, etc.) or text attributes (italic, bold, etc.). To describe words and paragraphs in rich text.
Path (Path)
A path is the lowest level way to refer to a location. Each path is a simple array of numbers that references a node through the index of each ancestor node in the document tree:
type Path = number[]
Copy the code
Point (Point)
Point contains an offset attribute (offset) for a particular text node:
interface Point {
path: Path
offset: number
[key: string]: unknown
}
Copy the code
Document Range
Document scope is not just a point in a document, it is the content between two points.
interface Range {
anchor: Point
focus: Point
[key: string]: unknown
}
Copy the code
Anchors and focus are established through user interaction. The anchor point is not necessarily in front of the focal point. Just like in the DOM, the ordering of anchors and focal points depends on the direction (forward or backward) of the selection.
Operation
The Operation object is a low-level instruction that Slate uses to change the internal state. As the minimal abstraction of the document, Slate represents all changes as Operation. You can see the source code here.
export interface OperationInterface {
isNodeOperation: (value: any) = > value is NodeOperation
isOperation: (value: any) = > value is Operation
isOperationList: (value: any) = > value is Operation[]
isSelectionOperation: (value: any) = > value is SelectionOperation
isTextOperation: (value: any) = > value is TextOperation
inverse: (op: Operation) = > Operation
}
export const Operation: OperationInterface = {
.....
isOperation(value: any): value is Operation {
if(! isPlainObject(value)) {return false
}
switch (value.type) {
case 'insert_node':
return Path.isPath(value.path) && Node.isNode(value.node)
case 'insert_text':
return (
typeof value.offset === 'number' &&
typeof value.text === 'string' &&
Path.isPath(value.path)
)
case 'merge_node':
return (
typeof value.position === 'number' &&
Path.isPath(value.path) &&
isPlainObject(value.properties)
)
case 'move_node':
return Path.isPath(value.path) && Path.isPath(value.newPath)
case 'remove_node':
return Path.isPath(value.path) && Node.isNode(value.node)
case 'remove_text':
return (
typeof value.offset === 'number' &&
typeof value.text === 'string' &&
Path.isPath(value.path)
)
case 'set_node':
return (
Path.isPath(value.path) &&
isPlainObject(value.properties) &&
isPlainObject(value.newProperties)
)
case 'set_selection':
return (
(value.properties === null && Range.isRange(value.newProperties)) ||
(value.newProperties === null && Range.isRange(value.properties)) ||
(isPlainObject(value.properties) &&
isPlainObject(value.newProperties))
)
case 'split_node':
return (
Path.isPath(value.path) &&
typeof value.position === 'number' &&
isPlainObject(value.properties)
)
default:
return false}},... }Copy the code
As you can see from the code above, there are nine Operation types:
- Insert_node: inserts a Node. contains
Insert the location
(path),Insert the node
(node) information
case 'insert_node':
return Path.isPath(value.path) && Node.isNode(value.node)
Copy the code
- Insert_text: inserts a piece of text, where
node
(path),Insert content
(text),The offset
(offset)
case 'insert_text':
return (
typeof value.offset === 'number' &&
typeof value.text === 'string' &&
Path.isPath(value.path)
)
Copy the code
- Merge_node: Combines two nodes into one, including the Node to be merged
node
Path, position, and properties of merged nodes.
case 'merge_node':
return (
typeof value.position === 'number' &&
Path.isPath(value.path) &&
isPlainObject(value.properties)
)
Copy the code
- Move_node: indicates a move_node, which contains move_location (PATH) and move_destination (newPath) information
case 'move_node':
return Path.isPath(value.path) && Path.isPath(value.newPath)
Copy the code
- Remove_node: indicates that the Node is to be removed, including the path and Node information
case 'remove_node':
return Path.isPath(value.path) && Node.isNode(value.node)
Copy the code
- Remove_text: removes text, including the node (path), content (text), and offset (offset)
case 'remove_text':
return (
typeof value.offset === 'number' &&
typeof value.text === 'string' &&
Path.isPath(value.path)
)
Copy the code
- Set_node: Sets Node properties, including the Node path, Node to be set, and Node properties
case 'set_node':
return (
Path.isPath(value.path) &&
isPlainObject(value.properties) &&
isPlainObject(value.newProperties)
)
Copy the code
- Set_selection: Sets the selection location, including new and old node properties (properties, newProperties)
case 'set_selection':
return (
(value.properties === null && Range.isRange(value.newProperties)) ||
(value.newProperties === null && Range.isRange(value.properties)) ||
(isPlainObject(value.properties) &&
isPlainObject(value.newProperties))
)
Copy the code
- Split_node: split Node, including the Node path, Node position, and Node properties
case 'split_node':
return (
Path.isPath(value.path) &&
typeof value.position === 'number' &&
isPlainObject(value.properties)
)
Copy the code
Transforms
Transforms are auxiliary functions that operate on documents, including selection Transforms, node Transforms, text Transforms, and general Transforms. You can see the source code here.
export constTransforms: GeneralTransforms & NodeTransforms & SelectionTransforms & TextTransforms = { ... GeneralTransforms,// Operate the Operation command. NodeTransforms,// Operate the node instruction. SelectionTransforms,// Operate selection command. TextTransforms,// Manipulate text commands
}
Copy the code
GeneralTransforms is special in that it does not generate Operation, but processes the Operation. Only it can directly modify the model, and other transforms will eventually be transformed into one of the GeneralTransforms.
createEditor
Method to create an Editor instance, returning an Editor instance object that implements the Editor interface. You can see the source code here.
export const createEditor = (): Editor= > {
const editor: Editor = {
.....
}
return editor
}
Copy the code
Update the model
The process of making changes to the Model is divided into the following two steps:
- Operations are generated through a series of methods provided by Transforms
- Operation Enters the apply process
There are four main steps in the Operation Apply process:
- Record the changed dirty area
- Transform Operation
- Verify the correctness of the model
- Trigger the change callback
In the case of Transforms.inserttext, you can see the source here.
export const TextTransforms: TextTransforms = {
.....
insertText(
editor: Editor,
text: string,
options: { at? : Location voids? : boolean } = {} ):void {
Editor.withoutNormalizing(editor, () = > {
const { voids = false } = options
let { at = editor.selection } = options
.....
const { path, offset } = at
if (text.length > 0)
editor.apply({ type: 'insert_text', path, offset, text })
})
}
}
Copy the code
The end of the Transforms. InsertText module generates an Operation of type insert_text and calls the apply method of the Editor instance.
Editor. The apply method
You can see the source code here.
export const createEditor = (): Editor= > {
const editor: Editor ={
children: [].operations: [].selection: null.marks: null.isInline: () = > false.isVoid: () = > false.onChange: () = > {},
apply: (op: Operation) = > {
for (const ref of Editor.pathRefs(editor)) {
PathRef.transform(ref, op)
}
for (const ref of Editor.pointRefs(editor)) {
PointRef.transform(ref, op)
}
for (const ref of Editor.rangeRefs(editor)) {
RangeRef.transform(ref, op)
}
const set = new Set(a)const dirtyPaths: Path[] = []
const add = (path: Path | null) = > {
if (path) {
const key = path.join(', ')
if(! set.has(key)) { set.add(key) dirtyPaths.push(path) } } }const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)
for (const path of oldDirtyPaths) {
const newPath = Path.transform(path, op)
add(newPath)
}
for (const path of newDirtyPaths) {
add(path)
}
DIRTY_PATHS.set(editor, dirtyPaths)
Transforms.transform(editor, op)
editor.operations.push(op)
Editor.normalize(editor)
// Clear any formats applied to the cursor if the selection changes.
if (op.type === 'set_selection') {
editor.marks = null
}
if(! FLUSHING.get(editor)) { FLUSHING.set(editor,true)
Promise.resolve().then(() = > {
FLUSHING.set(editor, false)
editor.onChange()
editor.operations = []
})
}
},
......
}
return editor
}
Copy the code
Conversion coordinates
for (const ref of Editor.pathRefs(editor)) {
PathRef.transform(ref, op)
}
for (const ref of Editor.pointRefs(editor)) {
PointRef.transform(ref, op)
}
for (const ref of Editor.rangeRefs(editor)) {
RangeRef.transform(ref, op)
}
Copy the code
dirtyPaths
There are two generation mechanisms for dirtyPaths:
- One is before Operation Apply
oldDirtypath
- By a
getDirthPaths
Methods to obtain
const set = new Set(a)const dirtyPaths: Path[] = []
const add = (path: Path | null) = > {
if (path) {
const key = path.join(', ')
if(! set.has(key)) { set.add(key) dirtyPaths.push(path) } } }const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)
for (const path of oldDirtyPaths) {
const newPath = Path.transform(path, op)
add(newPath)
}
for (const path of newDirtyPaths) {
add(path)
}
Copy the code
Perform a change operation
Transforms.transform(editor, op)
Copy the code
Transforms (Editor, OP) Transforms an Operation.
const applyToDraft = (editor: Editor, selection: Selection, op: Operation) = > {
switch (op.type) {
...
case 'insert_text': {
const { path, offset, text } = op
if (text.length === 0) break
const node = Node.leaf(editor, path)
const before = node.text.slice(0, offset)
const after = node.text.slice(offset)
node.text = before + text + after
if (selection) {
for (const [point, key] ofRange.points(selection)) { selection[key] = Point.transform(point, op)! }}break}... }}Copy the code
Record the operation
editor.operations.push(op)
Copy the code
Data validation
Editor.normalize(editor)
Copy the code
Trigger the change callback
if(! FLUSHING.get(editor)) {// To clear operations
FLUSHING.set(editor, true)
Promise.resolve().then(() = > {
// Clean up
FLUSHING.set(editor, false)
// Notify the change callback function
editor.onChange()
/ / clearing operations
editor.operations = []
})
}
Copy the code
Model data verification
After making changes to the Model, the data of the model needs to be verified to avoid content errors. The data calibration mechanism has two main points, one is the management of dirtyPaths, the other is the withoutNormalizing mechanism.
withoutNormalizing
You can see the source code here.
export const Editor: EditorInterface = {
.....
withoutNormalizing(editor: Editor, fn: () = > void) :void {
const value = Editor.isNormalizing(editor)
NORMALIZING.set(editor, false)
try {
fn()
} finally {
NORMALIZING.set(editor, value)
}
Editor.normalize(editor)
}
}
Copy the code
export const NORMALIZING: WeakMap<Editor, boolean> = new WeakMap(a)Copy the code
You can see that this code saves the state of whether data verification is required through WeakMap.
dirtyPaths
DirtyPaths is formed using the editor.apply method
const set = new Set(a)const dirtyPaths: Path[] = []
const add = (path: Path | null) = > {
if (path) {
const key = path.join(', ')
if(! set.has(key)) { set.add(key) dirtyPaths.push(path) } } }const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)
for (const path of oldDirtyPaths) {
const newPath = Path.transform(path, op)
add(newPath)
}
for (const path of newDirtyPaths) {
add(path)
}
Copy the code
The Normalize method of editor.normalize (Editor), which creates a loop that retrieves dirty paths from the leaves of the model tree from the bottom up and calls nomalizeNode to verify that the nodes corresponding to the paths are valid.
export const Editor: EditorInterface = {
.....
normalize(
editor: Editor,
options: { force? : boolean } = {} ):void{... Editor.withoutNormalizing(editor,() = >{...const max = getDirtyPaths(editor).length * 42 // HACK: better way?
let m = 0
while(getDirtyPaths(editor).length ! = =0) {
if (m > max) {
throw new Error(`
Could not completely normalize the editor after ${max} iterations! This is usually due to incorrect normalization logic that leaves a node in an invalid state.
`)}const dirtyPath = getDirtyPaths(editor).pop()!
// If the node doesn't exist in the tree, it does not need to be normalized.
if (Node.has(editor, dirtyPath)) {
const entry = Editor.node(editor, dirtyPath)
editor.normalizeNode(entry)
}
m++
}
})
},
.....
}
Copy the code
brief
Slate.js plugin architecture
Slate’s plug-in is simply a function that returns an editor instance and modifies the editor behavior by overriding the editor instance method. Just call the plug-in function when the editor instance is created.
const withImages = editor= > {
const { isVoid } = editor
editor.isVoid = element= > {
return element.type === 'image' ? true : isVoid(element)
}
return editor
}
Copy the code
You can then use it like this:
import { createEditor } from 'slate'
const editor = withImages(createEditor())
Copy the code
slate-history
Slate-history tracks changes to the state of the SLATE value over time and enables undo and redo capabilities.
withHistory
You can see the source code here.
export const withHistory = <T extends Editor>(editor: T) => { const e = editor as T & HistoryEditor const { apply } = e e.history = { undos: [], redos: [] } e.redo = () => { const { history } = e const { redos } = history if (redos.length > 0) { const batch = redos[redos.length - 1] HistoryEditor.withoutSaving(e, () => { Editor.withoutNormalizing(e, () => { for (const op of batch) { e.apply(op) } }) }) history.redos.pop() history.undos.push(batch) } } e.undo = () => { const { history } = e const { undos } = history if (undos.length > 0) { const batch = undos[undos.length - 1] HistoryEditor.withoutSaving(e, () => { Editor.withoutNormalizing(e, () => { const inverseOps = batch.map(Operation.inverse).reverse() for (const op of inverseOps) { e.apply(op) } }) }) history.redos.push(batch) history.undos.pop() } } e.apply = (op: Operation) => { const { operations, history } = e const { undos } = history const lastBatch = undos[undos.length - 1] const lastOp = lastBatch && lastBatch[lastBatch.length - 1] const overwrite = shouldOverwrite(op, lastOp) let save = HistoryEditor.isSaving(e) let merge = HistoryEditor.isMerging(e) if (save == null) { save = shouldSave(op, lastOp) } if (save) { if (merge == null) { if (lastBatch == null) { merge = false } else if (operations.length ! == 0) { merge = true } else { merge = shouldMerge(op, lastOp) || overwrite } } if (lastBatch && merge) { if (overwrite) { lastBatch.pop() } lastBatch.push(op) } else { const batch = [op] undos.push(batch) } while (undos.length > 100) { undos.shift() } if (shouldClear(op)) { history.redos = [] } } apply(op) } return e }Copy the code
In the withHistory method, slate-history creates two arrays in the Editor to store the history operations:
e.history = { undos: [].redos: []}Copy the code
They are of type Operation[][], a two-dimensional array of operations, each of which represents a batch of operations (called batch in code) that can contain more than one Operation.
Slate-history inserts undo/redo logic before Operation’s apply process by overwriting the apply method, and then calls the original apply method.
e.apply = (op: Operation) = > {
const { operations, history } = e
const { undos } = history
const lastBatch = undos[undos.length - 1]
const lastOp = lastBatch && lastBatch[lastBatch.length - 1]
const overwrite = shouldOverwrite(op, lastOp)
let save = HistoryEditor.isSaving(e)
let merge = HistoryEditor.isMerging(e)
if (save == null) {
save = shouldSave(op, lastOp)
}
if (save) {
if (merge == null) {
if (lastBatch == null) {
merge = false
} else if(operations.length ! = =0) {
merge = true
} else {
merge = shouldMerge(op, lastOp) || overwrite
}
}
if (lastBatch && merge) {
if (overwrite) {
lastBatch.pop()
}
lastBatch.push(op)
} else {
const batch = [op]
undos.push(batch)
}
while (undos.length > 100) {
undos.shift()
}
if (shouldClear(op)) {
history.redos = []
}
}
apply(op)
}
Copy the code
Undo method
e.undo = () = > {
const { history } = e
const { undos } = history
if (undos.length > 0) {
const batch = undos[undos.length - 1]
HistoryEditor.withoutSaving(e, () = > {
Editor.withoutNormalizing(e, () = > {
const inverseOps = batch.map(Operation.inverse).reverse()
for (const op of inverseOps) {
// If the final operation is deselecting the editor, skip it. This is
if (
op === inverseOps[inverseOps.length - 1] &&
op.type === 'set_selection' &&
op.newProperties == null
) {
continue
} else {
e.apply(op)
}
}
})
})
history.redos.push(batch)
history.undos.pop()
}
}
Copy the code
Redo method
e.redo = () = > {
const { history } = e
const { redos } = history
if (redos.length > 0) {
const batch = redos[redos.length - 1]
HistoryEditor.withoutSaving(e, () = > {
Editor.withoutNormalizing(e, () = > {
for (const op of batch) {
e.apply(op)
}
})
})
history.redos.pop()
history.undos.push(batch)
}
}
Copy the code
slate-react
The React component of the Slate-React editor renders document data.
Rendering principle
The document data of Slate is a node tree structure similar to DOM. Slate-react generates the children array through recursion of this tree, and finally React renders the components in the children array to the page.
- Sets the children property of the editor instance
// https://github.com/ianstormtaylor/slate/blob/main/packages/slate-react/src/components/slate.tsx#L17
export const Slate = (props: {
editor: ReactEditor
value: Descendant[]
children: React.ReactNode
onChange: (value: Descendant[]) => void
}) = >{...const context: [ReactEditor] = useMemo(() = > {
// Set the children attribute of the editor instance to value
editor.children = value
.....
}, [key, value, ...Object.values(rest)])
.....
}
Copy the code
- The Editable component passes an editor instance to the useChildren Hooks component.
// https://github.com/ianstormtaylor/slate/blob/main/packages/slate-react/src/components/editable.tsx#L100
export const Editable = (props: EditableProps) = > {
const editor = useSlate()
....
return (
<ReadOnlyContext.Provider value={readOnly}>
<DecorateContext.Provider value={decorate}>
<Component
.
>
{useChildren({
decorations,
node: editor,
renderElement,
renderPlaceholder,
renderLeaf,
selection: editor.selection,
})}
</Component>
</DecorateContext.Provider>
</ReadOnlyContext.Provider>)}Copy the code
- UseChildren generates the render array and gives it to the React render component.
The useChildren component generates the corresponding ElementComponent or TextComponent according to the type of each Node in the children.
const useChildren = (props: { decorations: Range[] node: Ancestor renderElement? : (props: RenderElementProps) => JSX.Element renderPlaceholder: (props: RenderPlaceholderProps) => JSX.Element renderLeaf? : (props: RenderLeafProps) => JSX.Element selection: Range |null
}) = > {
const decorate = useDecorate()
const editor = useSlateStatic()
const path = ReactEditor.findPath(editor, node)
const children = []
constisLeafBlock = Element.isElement(node) && ! editor.isInline(node) && Editor.hasInlines(editor, node)for (let i = 0; i < node.children.length; i++) {
const p = path.concat(i)
const n = node.children[i] as Descendant
const key = ReactEditor.findKey(editor, n)
const range = Editor.range(editor, p)
const sel = selection && Range.intersection(range, selection)
const ds = decorate([n, p])
for (const dec of decorations) {
const d = Range.intersection(dec, range)
if (d) {
ds.push(d)
}
}
if (Element.isElement(n)) {
children.push(
<ElementComponent
decorations={ds}
element={n}
key={key.id}
renderElement={renderElement}
renderPlaceholder={renderPlaceholder}
renderLeaf={renderLeaf}
selection={sel}
/>)}else {
children.push(
<TextComponent
decorations={ds}
key={key.id}
isLast={isLeafBlock && i= = =node.children.length - 1}
parent={node}
renderPlaceholder={renderPlaceholder}
renderLeaf={renderLeaf}
text={n}
/>
)
}
NODE_TO_INDEX.set(n, i)
NODE_TO_PARENT.set(n, node)
}
return children
}
Copy the code
Website example
Custom rendering
Rendering functions renderElement and renderLeaf are passed to the Editable component. The user can decide how to render a Node in the Model by providing these two parameters. Let’s take the richText demo website as an example.
const RichTextExample = () = >{...const renderElement = useCallback(props= > <Element {. props} / >[]),const renderLeaf = useCallback(props= > <Leaf {. props} / >[]),return (
<Slate editor={editor} value={value} onChange={value= > setValue(value)}>
....
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
.
/>
</Slate>)}Copy the code
const Element = ({ attributes, children, element }) = > {
switch (element.type) {
case 'block-quote':
return <blockquote {. attributes} >{children}</blockquote>
case 'bulleted-list':
return <ul {. attributes} >{children}</ul>
case 'heading-one':
return <h1 {. attributes} >{children}</h1>
case 'heading-two':
return <h2 {. attributes} >{children}</h2>
case 'list-item':
return <li {. attributes} >{children}</li>
case 'numbered-list':
return <ol {. attributes} >{children}</ol>
default:
return <p {. attributes} >{children}</p>}}Copy the code
This demo extends the Element node’s Type attribute to render the Element as a different tag.
slate-hyperscript
Slate-hyperscript A HyperScript tool for writing SLATE documents using JSX.
conclusion
- Slate is currently in beta and some of its APIs are not “finalised” yet;
- Partial selection and input events cannot be processed because of the use of ContentedItable;
The resources
Slate Chinese Document
SLATE Architecture design analysis