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 ' },
{