1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-07-31 20:40:19 +02:00

Add way to prevent data loss in normalizeNode (#5878)

* fix(docs): Consider passed options when overriding normalizeNode

* feat: Allow to prevent data-loss on normalizeNode

When overriding normalizeNode, you can specify a `wrapperElement`
that is used to wrap text & inline nodes which would otherwise be
deleted in the normalization path if they are not allowed.

* changeset
This commit is contained in:
Alex
2025-05-24 22:53:02 +02:00
committed by GitHub
parent ffe3f8c129
commit 11b957a441
11 changed files with 128 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
---
'slate': minor
---
Allow to prevent data-loss in normalizeNode

View File

@@ -418,7 +418,7 @@ Check if a value is a void `Element` object.
### Normalize methods
#### `normalizeNode(entry: NodeEntry, { operation }) => void`
#### `normalizeNode(entry: NodeEntry, { operation, fallbackElement }) => void`
[Normalize](../../concepts/11-normalizing.md) a Node according to the schema.

View File

@@ -90,7 +90,7 @@ Or you can even define custom "normalizations" that take place to ensure that li
```javascript
const { normalizeNode } = editor
editor.normalizeNode = entry => {
editor.normalizeNode = (entry, options) => {
const [node, path] = entry
if (Element.isElement(node) && node.type === 'link') {
@@ -98,7 +98,7 @@ editor.normalizeNode = entry => {
return
}
normalizeNode(entry)
normalizeNode(entry, options)
}
```

View File

@@ -10,7 +10,7 @@ Slate editors come with a few built-in constraints out of the box. These constra
1. **All `Element` nodes must contain at least one `Text` descendant** — even [Void Elements](./02-nodes.md#voids). If an element node does not contain any children, an empty text node will be added as its only child. This constraint exists to ensure that the selection's anchor and focus points \(which rely on referencing text nodes\) can always be placed inside any node. Without this, empty elements \(or void elements\) wouldn't be selectable.
2. **Two adjacent texts with the same custom properties will be merged.** If two adjacent text nodes have the same formatting, they're merged into a single text node with a combined text string of the two. This exists to prevent the text nodes from only ever expanding in count in the document, since both adding and removing formatting results in splitting text nodes.
3. **Block nodes can only contain other blocks, or inline and text nodes.** For example, a `paragraph` block cannot have another `paragraph` block element _and_ a `link` inline element as children at the same time. The type of children allowed is determined by the first child, and any other non-conforming children are removed. This ensures that common richtext behaviors like "splitting a block in two" function consistently.
3. **Block nodes can only contain other blocks, or inline and text nodes.** For example, a `paragraph` block cannot have another `paragraph` block element _and_ a `link` inline element as children at the same time. The type of children allowed is determined by the first child. Any other non-conforming children are tried to be converted (if possible) or removed. This ensures that common richtext behaviors like "splitting a block in two" function consistently. Conversion of block nodes is done by unwrapping the block node; conversion of inline/text nodes is performed by wrapping such nodes into a `fallbackElement` if specified in the `normalizeNode` options. The `fallbackElement` can be specified by editors overriding the `normalizeNode` function.
4. **Inline nodes cannot be the first or last child of a parent block, nor can it be next to another inline node in the children array.** If this is the case, an empty text node will be added to correct this to be in compliance with the constraint.
5. **The top-level editor node can only contain block nodes.** If any of the top-level children are inline or text nodes they will be removed. This ensures that there are always block nodes in the editor so that behaviors like "splitting a block in two" work as expected.
6. **Nodes must be JSON-serializable.** For example, avoid using `undefined` in your data model. This ensures that [operations](./05-operations.md) are also JSON-serializable, a property which is assumed by collaboration libraries.
@@ -34,7 +34,7 @@ import { Transforms, Element, Node } from 'slate'
const withParagraphs = editor => {
const { normalizeNode } = editor
editor.normalizeNode = entry => {
editor.normalizeNode = (entry, options) => {
const [node, path] = entry
// If the element is a paragraph, ensure its children are valid.
@@ -48,7 +48,7 @@ const withParagraphs = editor => {
}
// Fall back to the original `normalizeNode` to enforce other constraints.
normalizeNode(entry)
normalizeNode(entry, options)
}
return editor
@@ -135,7 +135,7 @@ For example, consider a normalization that ensured `link` elements have a valid
const withLinks = editor => {
const { normalizeNode } = editor
editor.normalizeNode = entry => {
editor.normalizeNode = (entry, options) => {
const [node, path] = entry
if (
@@ -148,7 +148,7 @@ const withLinks = editor => {
return
}
normalizeNode(entry)
normalizeNode(entry, options)
}
return editor

View File

@@ -145,11 +145,11 @@ const SlateEditor = ({ sharedType, provider }) => {
// Ensure editor always has at least 1 valid child
const { normalizeNode } = e
e.normalizeNode = entry => {
e.normalizeNode = (entry, options) => {
const [node] = entry
if (!Editor.isEditor(node) || node.children.length > 0) {
return normalizeNode(entry)
return normalizeNode(entry, options)
}
Transforms.insertNodes(editor, initialValue, { at: [0] })
@@ -369,11 +369,11 @@ const SlateEditor = ({ sharedType, provider }) => {
// Ensure editor always has at least 1 valid child
const { normalizeNode } = e
e.normalizeNode = entry => {
e.normalizeNode = (entry, options) => {
const [node] = entry
if (!Editor.isEditor(node) || node.children.length > 0) {
return normalizeNode(entry)
return normalizeNode(entry, options)
}
Transforms.insertNodes(editor, initialValue, { at: [0] })

View File

@@ -7,7 +7,8 @@ import { Editor } from '../interfaces/editor'
export const normalizeNode: WithEditorFirstArg<Editor['normalizeNode']> = (
editor,
entry
entry,
options
) => {
const [node, path] = entry
@@ -54,7 +55,14 @@ export const normalizeNode: WithEditorFirstArg<Editor['normalizeNode']> = (
// text.
if (isInlineOrText !== shouldHaveInlines) {
if (isInlineOrText) {
Transforms.removeNodes(editor, { at: path.concat(n), voids: true })
if (options?.fallbackElement) {
Transforms.wrapNodes(editor, options.fallbackElement(), {
at: path.concat(n),
voids: true,
})
} else {
Transforms.removeNodes(editor, { at: path.concat(n), voids: true })
}
} else {
Transforms.unwrapNodes(editor, { at: path.concat(n), voids: true })
}

View File

@@ -54,7 +54,13 @@ export interface BaseEditor {
isElementReadOnly: (element: Element) => boolean
isSelectable: (element: Element) => boolean
markableVoid: (element: Element) => boolean
normalizeNode: (entry: NodeEntry, options?: { operation?: Operation }) => void
normalizeNode: (
entry: NodeEntry,
options?: {
operation?: Operation
fallbackElement?: () => Element
}
) => void
onChange: (options?: { operation?: Operation }) => void
shouldNormalize: ({
iteration,

View File

@@ -25,8 +25,14 @@ describe('slate', () => {
assert.deepEqual(editor.selection, output.selection)
})
fixtures(__dirname, 'normalization', ({ module }) => {
const { input, output } = module
const { input, output, withFallbackElement } = module
const editor = withTest(input)
if (withFallbackElement) {
const { normalizeNode } = editor
editor.normalizeNode = (entry, options) => {
normalizeNode(entry, { ...options, fallbackElement: () => ({}) })
}
}
Editor.normalize(editor, { force: true })
assert.deepEqual(editor.children, output.children)
assert.deepEqual(editor.selection, output.selection)

View File

@@ -0,0 +1,33 @@
/** @jsx jsx */
import { jsx } from '../..'
export const withFallbackElement = true
export const input = (
<editor>
<block>
<block>one</block>
<inline>two</inline>
<block>three</block>
<inline>four</inline>
</block>
</editor>
)
export const output = (
<editor>
<block>
<block>one</block>
<block>
<text />
<inline>two</inline>
<text />
</block>
<block>three</block>
<block>
<text />
<inline>four</inline>
<text />
</block>
</block>
</editor>
)

View File

@@ -0,0 +1,29 @@
/** @jsx jsx */
import { jsx } from '../..'
export const withFallbackElement = true
export const input = (
<editor>
<inline>one</inline>
<block>two</block>
<inline>three</inline>
<block>four</block>
</editor>
)
export const output = (
<editor>
<block>
<text />
<inline>one</inline>
<text />
</block>
<block>two</block>
<block>
<text />
<inline>three</inline>
<text />
</block>
<block>four</block>
</editor>
)

View File

@@ -0,0 +1,25 @@
/** @jsx jsx */
import { jsx } from '../..'
export const withFallbackElement = true
export const input = (
<editor>
<text>one</text>
<block>two</block>
<text>three</text>
<block>four</block>
</editor>
)
export const output = (
<editor>
<block>
<text>one</text>
</block>
<block>two</block>
<block>
<text>three</text>
</block>
<block>four</block>
</editor>
)