diff --git a/.changeset/modern-cooks-grow.md b/.changeset/modern-cooks-grow.md new file mode 100644 index 000000000..e0a39b3a1 --- /dev/null +++ b/.changeset/modern-cooks-grow.md @@ -0,0 +1,5 @@ +--- +'slate': patch +--- + +Allow void elements to receive marks via markableVoid() diff --git a/docs/api/nodes/editor.md b/docs/api/nodes/editor.md index 321df1e10..1c56b5735 100644 --- a/docs/api/nodes/editor.md +++ b/docs/api/nodes/editor.md @@ -12,6 +12,7 @@ interface Editor { // Schema-specific node behaviors. isInline: (element: Element) => boolean isVoid: (element: Element) => boolean + markableVoid: (element: Element) => boolean normalizeNode: (entry: NodeEntry) => void onChange: () => void @@ -221,7 +222,7 @@ Options: `{at?: Location, mode?: 'highest' | 'lowest', voids?: boolean}` #### `Editor.addMark(editor: Editor, key: string, value: any) => void` -Add a custom property to the leaf text nodes in the current selection. +Add a custom property to the leaf text nodes and any nodes that `editor.markableVoid()` allows in the current selection. If the selection is currently collapsed, the marks will be added to the `editor.marks` property instead, and applied when text is inserted next. @@ -265,7 +266,7 @@ If the selection is currently expanded, it will be deleted first. #### `Editor.removeMark(editor: Editor, key: string) => void` -Remove a custom property from all of the leaf text nodes in the current selection. +Remove a custom property from all of the leaf text nodes within non-void nodes or void nodes that `editor.markableVoid()` allows in the current selection. If the selection is currently collapsed, the removal will be stored on `editor.marks` and applied to the text inserted next. @@ -423,13 +424,17 @@ Called when there is a change in the editor. ### Mark methods +#### `markableVoid: (element: Element) => boolean` + +Tells which void nodes accept Marks. Slate's default implementation returns `false`, but if some void elements support formatting, override this function to include them. + #### `addMark(key: string, value: any) => void` -Add a custom property to the leaf text nodes in the current selection. If the selection is currently collapsed, the marks will be added to the `editor.marks` property instead, and applied when text is inserted next. +Add a custom property to the leaf text nodes within non-void nodes or void nodes that `editor.markableVoid()` allows in the current selection. If the selection is currently collapsed, the marks will be added to the `editor.marks` property instead, and applied when text is inserted next. #### `removeMark(key: string) => void` -Remove a custom property from the leaf text nodes in the current selection. +Remove a custom property from the leaf text nodes within non-void nodes or void nodes that `editor.markableVoid()` allows in the current selection. ### getFragment method diff --git a/docs/api/nodes/element.md b/docs/api/nodes/element.md index 44fb63325..54c35f349 100644 --- a/docs/api/nodes/element.md +++ b/docs/api/nodes/element.md @@ -31,6 +31,10 @@ A "block" element can only be siblings with other "block" elements. An "inline" In a not "void" element, Slate handles the rendering of its `children` (e.g. in a paragraph where the `Text` and `Inline` children are rendered by Slate). In a "void" element, the `children` are rendered by the `Element`'s render code. +#### Voids That Support Marks + +Some void elements are effectively stand-ins for text, such as with the [Mentions](https://www.slatejs.org/examples/mentions) example, where the mention element renders the character's name. Users might want to format Void elements like this with bold, or set their font and size, so `editor.markableVoid` tells Slate whether or not to apply Marks to the text children of void elements. + #### Rendering Void Elements Void Elements must @@ -50,6 +54,42 @@ return ( ) ``` +For a "markable" void such as a `mention` element, marks on the empty child element can be used to determine how the void element is rendered (Slate Marks are applied only to Text leaves): + +```javascript +const Mention = ({ attributes, children, element }) => { + const selected = useSelected() + const focused = useFocused() + const style: React.CSSProperties = { + padding: '3px 3px 2px', + margin: '0 1px', + verticalAlign: 'baseline', + display: 'inline-block', + borderRadius: '4px', + backgroundColor: '#eee', + fontSize: '0.9em', + boxShadow: selected && focused ? '0 0 0 2px #B4D5FF' : 'none', + } + // See if our empty text child has any styling marks applied and apply those + if (element.children[0].bold) { + style.fontWeight = 'bold' + } + if (element.children[0].italic) { + style.fontStyle = 'italic' + } + return ( + + {children}@{element.character} + + ) +} +``` + ## Static methods ### Retrieval methods diff --git a/docs/concepts/07-editor.md b/docs/concepts/07-editor.md index 1a2eb5c53..f5caefa08 100644 --- a/docs/concepts/07-editor.md +++ b/docs/concepts/07-editor.md @@ -12,6 +12,7 @@ interface Editor { // Schema-specific node behaviors. isInline: (element: Element) => boolean isVoid: (element: Element) => boolean + markableVoid: (element: Element) => boolean normalizeNode: (entry: NodeEntry) => void onChange: () => void // Overrideable core actions. @@ -70,6 +71,20 @@ editor.insertText = text => { } ``` +If you have void "mention" elements that can accept marks like bold or italic: + +```javascript +const { isVoid, markableVoid } = editor + +editor.isVoid = element => { + return element.type === 'mention' ? true : isInline(element) +} + +editor.markableVoid = element => { + return element.type === 'mention' || markableVoid(element) +} +``` + Or you can even define custom "normalizations" that take place to ensure that links obey certain constraints: ```javascript diff --git a/packages/slate/src/create-editor.ts b/packages/slate/src/create-editor.ts index 346488b2d..b2cd9f512 100644 --- a/packages/slate/src/create-editor.ts +++ b/packages/slate/src/create-editor.ts @@ -28,6 +28,7 @@ export const createEditor = (): Editor => { marks: null, isInline: () => false, isVoid: () => false, + markableVoid: () => false, onChange: () => {}, apply: (op: Operation) => { @@ -99,14 +100,35 @@ export const createEditor = (): Editor => { }, addMark: (key: string, value: any) => { - const { selection } = editor + const { selection, markableVoid } = editor if (selection) { - if (Range.isExpanded(selection)) { + const match = (node: Node, path: Path) => { + if (!Text.isText(node)) { + return false // marks can only be applied to text + } + const [parentNode, parentPath] = Editor.parent(editor, path) + return !editor.isVoid(parentNode) || editor.markableVoid(parentNode) + } + const expandedSelection = Range.isExpanded(selection) + let markAcceptingVoidSelected = false + if (!expandedSelection) { + const [selectedNode, selectedPath] = Editor.node(editor, selection) + if (selectedNode && match(selectedNode, selectedPath)) { + const [parentNode] = Editor.parent(editor, selectedPath) + markAcceptingVoidSelected = + parentNode && editor.markableVoid(parentNode) + } + } + if (expandedSelection || markAcceptingVoidSelected) { Transforms.setNodes( editor, { [key]: value }, - { match: Text.isText, split: true } + { + match, + split: true, + voids: true, + } ) } else { const marks = { @@ -281,10 +303,28 @@ export const createEditor = (): Editor => { const { selection } = editor if (selection) { - if (Range.isExpanded(selection)) { + const match = (node: Node, path: Path) => { + if (!Text.isText(node)) { + return false // marks can only be applied to text + } + const [parentNode, parentPath] = Editor.parent(editor, path) + return !editor.isVoid(parentNode) || editor.markableVoid(parentNode) + } + const expandedSelection = Range.isExpanded(selection) + let markAcceptingVoidSelected = false + if (!expandedSelection) { + const [selectedNode, selectedPath] = Editor.node(editor, selection) + if (selectedNode && match(selectedNode, selectedPath)) { + const [parentNode] = Editor.parent(editor, selectedPath) + markAcceptingVoidSelected = + parentNode && editor.markableVoid(parentNode) + } + } + if (expandedSelection || markAcceptingVoidSelected) { Transforms.unsetNodes(editor, key, { - match: Text.isText, + match, split: true, + voids: true, }) } else { const marks = { ...(Editor.marks(editor) || {}) } diff --git a/packages/slate/src/interfaces/editor.ts b/packages/slate/src/interfaces/editor.ts index 90617d74e..9cfcef841 100644 --- a/packages/slate/src/interfaces/editor.ts +++ b/packages/slate/src/interfaces/editor.ts @@ -61,6 +61,7 @@ export interface BaseEditor { // Schema-specific node behaviors. isInline: (element: Element) => boolean isVoid: (element: Element) => boolean + markableVoid: (element: Element) => boolean normalizeNode: (entry: NodeEntry) => void onChange: () => void diff --git a/packages/slate/test/transforms/setNodes/inline/inline-void-2.tsx b/packages/slate/test/transforms/setNodes/inline/inline-void-2.tsx new file mode 100644 index 000000000..11899a79a --- /dev/null +++ b/packages/slate/test/transforms/setNodes/inline/inline-void-2.tsx @@ -0,0 +1,35 @@ +/** @jsx jsx */ +import { Editor, Transforms } from 'slate' +import { jsx } from '../../..' + +export const run = editor => { + Transforms.setNodes( + editor, + { someKey: true }, + { match: n => Editor.isInline(editor, n) } + ) +} +export const input = ( + + + word + + + + + + + +) +export const output = ( + + + word + + + + + + + +) diff --git a/packages/slate/test/transforms/setNodes/marks/mark-across-range.tsx b/packages/slate/test/transforms/setNodes/marks/mark-across-range.tsx new file mode 100644 index 000000000..f0f1e53f5 --- /dev/null +++ b/packages/slate/test/transforms/setNodes/marks/mark-across-range.tsx @@ -0,0 +1,48 @@ +/** @jsx jsx */ +// Apply a mark across a range containing text with other marks and a void +import { Editor, Transforms } from 'slate' +import { jsx } from '../../..' + +export const run = editor => { + Editor.addMark(editor, 'bold', true) +} +export const input = ( + + + + + word{' '} + + italic words + + + + + {' '} + underlined words + + + + +) +export const output = ( + + + + + word{' '} + + + italic words{' '} + + + + + + {' '} + underlined words + + + + +) diff --git a/packages/slate/test/transforms/setNodes/marks/mark-void-collapsed.tsx b/packages/slate/test/transforms/setNodes/marks/mark-void-collapsed.tsx new file mode 100644 index 000000000..2799fea24 --- /dev/null +++ b/packages/slate/test/transforms/setNodes/marks/mark-void-collapsed.tsx @@ -0,0 +1,33 @@ +/** @jsx jsx */ +// Apply a mark across a range containing text with other marks and one void that supports marks +import { Editor, Transforms } from 'slate' +import { jsx } from '../../..' + +export const run = editor => { + editor.markableVoid = node => node.markable + Editor.addMark(editor, 'bold', true) +} +export const input = ( + + + word + + + + + + + +) +export const output = ( + + + word + + + + + + + +) diff --git a/packages/slate/test/transforms/setNodes/marks/mark-void-range-hanging.tsx b/packages/slate/test/transforms/setNodes/marks/mark-void-range-hanging.tsx new file mode 100644 index 000000000..b67971c1f --- /dev/null +++ b/packages/slate/test/transforms/setNodes/marks/mark-void-range-hanging.tsx @@ -0,0 +1,51 @@ +/** @jsx jsx */ +// Apply a mark across a range containing text with other marks and some voids that support marks +import { Editor, Transforms } from 'slate' +import { jsx } from '../../..' + +export const run = editor => { + editor.markableVoid = node => node.markable + Editor.addMark(editor, 'bold', true) +} +export const input = ( + + + + + + + + + italic words + + + + + + + + +) +export const output = ( + + + + + + + + + + italic words{' '} + + + + + + + + + +) +// TODO this has to be skipped because the second void and the final empty text fail to be marked bold +export const skip = true diff --git a/packages/slate/test/transforms/setNodes/marks/mark-void-range.tsx b/packages/slate/test/transforms/setNodes/marks/mark-void-range.tsx new file mode 100644 index 000000000..8538b947a --- /dev/null +++ b/packages/slate/test/transforms/setNodes/marks/mark-void-range.tsx @@ -0,0 +1,55 @@ +/** @jsx jsx */ +// Apply a mark across a range containing text with other marks and one void that supports marks +import { Editor, Transforms } from 'slate' +import { jsx } from '../../..' + +export const run = editor => { + editor.markableVoid = node => node.markable + Editor.addMark(editor, 'bold', true) +} +export const input = ( + + + + + word{' '} + + + + + italic words + + + + + {' '} + underlined words + + + + +) +export const output = ( + + + + + word{' '} + + + + + + italic words{' '} + + + + + + {' '} + underlined words + + + + +) diff --git a/site/examples/mentions.tsx b/site/examples/mentions.tsx index 183992299..d336f50d7 100644 --- a/site/examples/mentions.tsx +++ b/site/examples/mentions.tsx @@ -19,6 +19,7 @@ const MentionExample = () => { const [index, setIndex] = useState(0) const [search, setSearch] = useState('') const renderElement = useCallback(props => , []) + const renderLeaf = useCallback(props => , []) const editor = useMemo( () => withMentions(withReact(withHistory(createEditor()))), [] @@ -101,6 +102,7 @@ const MentionExample = () => { > @@ -140,7 +142,7 @@ const MentionExample = () => { } const withMentions = editor => { - const { isInline, isVoid } = editor + const { isInline, isVoid, markableVoid } = editor editor.isInline = element => { return element.type === 'mention' ? true : isInline(element) @@ -150,6 +152,10 @@ const withMentions = editor => { return element.type === 'mention' ? true : isVoid(element) } + editor.markableVoid = element => { + return element.type === 'mention' || markableVoid(element) + } + return editor } @@ -163,6 +169,28 @@ const insertMention = (editor, character) => { Transforms.move(editor) } +// Borrow Leaf renderer from the Rich Text example. +// In a real project you would get this via `withRichText(editor)` or similar. +const Leaf = ({ attributes, children, leaf }) => { + if (leaf.bold) { + children = {children} + } + + if (leaf.code) { + children = {children} + } + + if (leaf.italic) { + children = {children} + } + + if (leaf.underline) { + children = {children} + } + + return {children} +} + const Element = props => { const { attributes, children, element } = props switch (element.type) { @@ -176,21 +204,29 @@ const Element = props => { const Mention = ({ attributes, children, element }) => { const selected = useSelected() const focused = useFocused() + const style: React.CSSProperties = { + padding: '3px 3px 2px', + margin: '0 1px', + verticalAlign: 'baseline', + display: 'inline-block', + borderRadius: '4px', + backgroundColor: '#eee', + fontSize: '0.9em', + boxShadow: selected && focused ? '0 0 0 2px #B4D5FF' : 'none', + } + // See if our empty text child has any styling marks applied and apply those + if (element.children[0].bold) { + style.fontWeight = 'bold' + } + if (element.children[0].italic) { + style.fontStyle = 'italic' + } return ( {children}@{element.character} @@ -201,9 +237,30 @@ const initialValue: Descendant[] = [ { type: 'paragraph', children: [ + { + text: 'This example shows how you might implement a simple ', + }, + { + text: '@-mentions', + bold: true, + }, { text: - 'This example shows how you might implement a simple @-mentions feature that lets users autocomplete mentioning a user by their username. Which, in this case means Star Wars characters. The mentions are rendered as void inline elements inside the document.', + ' feature that lets users autocomplete mentioning a user by their username. Which, in this case means Star Wars characters. The ', + }, + { + text: 'mentions', + bold: true, + }, + { + text: ' are rendered as ', + }, + { + text: 'void inline elements', + code: true, + }, + { + text: ' inside the document.', }, ], }, @@ -214,7 +271,7 @@ const initialValue: Descendant[] = [ { type: 'mention', character: 'R2-D2', - children: [{ text: '' }], + children: [{ text: '', bold: true }], }, { text: ' or ' }, {