diff --git a/.changeset/red-mice-mate.md b/.changeset/red-mice-mate.md new file mode 100644 index 000000000..8063e26f0 --- /dev/null +++ b/.changeset/red-mice-mate.md @@ -0,0 +1,5 @@ +--- +'slate': minor +--- + +Allow to prevent data-loss in normalizeNode diff --git a/docs/api/nodes/editor.md b/docs/api/nodes/editor.md index c19f8ca86..6e36bd484 100644 --- a/docs/api/nodes/editor.md +++ b/docs/api/nodes/editor.md @@ -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. diff --git a/docs/concepts/07-editor.md b/docs/concepts/07-editor.md index 790cc2cd2..9d03e18a7 100644 --- a/docs/concepts/07-editor.md +++ b/docs/concepts/07-editor.md @@ -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) } ``` diff --git a/docs/concepts/11-normalizing.md b/docs/concepts/11-normalizing.md index 4f4c204a7..0ab5905fb 100644 --- a/docs/concepts/11-normalizing.md +++ b/docs/concepts/11-normalizing.md @@ -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 diff --git a/docs/walkthroughs/07-enabling-collaborative-editing.md b/docs/walkthroughs/07-enabling-collaborative-editing.md index a3f8cc11c..b79a82326 100644 --- a/docs/walkthroughs/07-enabling-collaborative-editing.md +++ b/docs/walkthroughs/07-enabling-collaborative-editing.md @@ -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] }) diff --git a/packages/slate/src/core/normalize-node.ts b/packages/slate/src/core/normalize-node.ts index ba704d9a1..18e4c8cd4 100644 --- a/packages/slate/src/core/normalize-node.ts +++ b/packages/slate/src/core/normalize-node.ts @@ -7,7 +7,8 @@ import { Editor } from '../interfaces/editor' export const normalizeNode: WithEditorFirstArg = ( editor, - entry + entry, + options ) => { const [node, path] = entry @@ -54,7 +55,14 @@ export const normalizeNode: WithEditorFirstArg = ( // 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 }) } diff --git a/packages/slate/src/interfaces/editor.ts b/packages/slate/src/interfaces/editor.ts index b22174f6e..4a7cbc159 100644 --- a/packages/slate/src/interfaces/editor.ts +++ b/packages/slate/src/interfaces/editor.ts @@ -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, diff --git a/packages/slate/test/index.js b/packages/slate/test/index.js index 08cdc06e1..0d610728b 100644 --- a/packages/slate/test/index.js +++ b/packages/slate/test/index.js @@ -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) diff --git a/packages/slate/test/normalization/block/remove-inline-with-wrapping.tsx b/packages/slate/test/normalization/block/remove-inline-with-wrapping.tsx new file mode 100644 index 000000000..a1212c531 --- /dev/null +++ b/packages/slate/test/normalization/block/remove-inline-with-wrapping.tsx @@ -0,0 +1,33 @@ +/** @jsx jsx */ +import { jsx } from '../..' + +export const withFallbackElement = true + +export const input = ( + + + one + two + three + four + + +) +export const output = ( + + + one + + + two + + + three + + + four + + + + +) diff --git a/packages/slate/test/normalization/editor/remove-inline-with-wrapping.tsx b/packages/slate/test/normalization/editor/remove-inline-with-wrapping.tsx new file mode 100644 index 000000000..01db74d25 --- /dev/null +++ b/packages/slate/test/normalization/editor/remove-inline-with-wrapping.tsx @@ -0,0 +1,29 @@ +/** @jsx jsx */ +import { jsx } from '../..' + +export const withFallbackElement = true + +export const input = ( + + one + two + three + four + +) +export const output = ( + + + + one + + + two + + + three + + + four + +) diff --git a/packages/slate/test/normalization/editor/remove-text-with-wrapping.tsx b/packages/slate/test/normalization/editor/remove-text-with-wrapping.tsx new file mode 100644 index 000000000..c9d443a65 --- /dev/null +++ b/packages/slate/test/normalization/editor/remove-text-with-wrapping.tsx @@ -0,0 +1,25 @@ +/** @jsx jsx */ +import { jsx } from '../..' + +export const withFallbackElement = true + +export const input = ( + + one + two + three + four + +) +export const output = ( + + + one + + two + + three + + four + +)