diff --git a/docs/Summary.md b/docs/Summary.md index 8771b7eaa..538c899ae 100644 --- a/docs/Summary.md +++ b/docs/Summary.md @@ -15,6 +15,7 @@ ## Component Reference +- [Custom](./reference/components/custom.md) - [Editor](./reference/components/editor.md) - [Placeholder](./reference/components/placeholder.md) diff --git a/docs/reference/Readme.md b/docs/reference/Readme.md index 260514bcb..c2f4d9982 100644 --- a/docs/reference/Readme.md +++ b/docs/reference/Readme.md @@ -4,6 +4,7 @@ This is the full reference documentation for all of the pieces of Slate, broken up into sections by type: - **Components** + - [Custom](./components/custom.md) - [Editor](./components/editor.md) - [Placeholder](./components/placeholder.md) - **Models** diff --git a/docs/reference/components/custom.md b/docs/reference/components/custom.md new file mode 100644 index 000000000..1de7401df --- /dev/null +++ b/docs/reference/components/custom.md @@ -0,0 +1,99 @@ + +# `<{Custom}>` + +Slate will render custom nodes for `Block` and `Inline` models, based on what you pass in as your schema. This allows you to completely customize the rendering behavior of your Slate editor. + +- [Properties](#properties) + - [`attributes`](#attributes) + - [`children`](#children) + - [`editor`](#editor) + - [`isSelected`](#isselected) + - [`node`](#node) + - [`parent`](#parent) + - [`readOnly`](#readonly) + - [`state`](#state) + +## Properties + +```js +<{Custom} + attributes={Object} + children={Object} + editor={Editor} + isSelected={Boolean} + node={Node} + parent={Node} + readOnly={Boolean} + state={State} +/> +``` + +### `attributes` +`Object` + +A dictionary of DOM attributes that you must attach to the main DOM element of the node you render. For example: + +```js +return ( +

{props.children}

+) +``` +```js +return ( +
+ +
+) +``` + +### `children` +`Object` + +A set of React children elements that are composed of internal Slate components that handle all of the editing logic of the editor for you. You must render these as the children of your non-void nodes. For example: + +```js +return ( +

+ {props.children} +

+) +``` + +### `editor` +`Editor` + +A reference to the Slate [``](./editor.md) instance. This allows you to retrieve the current `state` of the editor, or perform a `change` on the state. For example: + +```js +const state = editor.getState() +``` +```js +editor.change((change) => { + change.selectAll().delete() +}) +``` + +### `isSelected` +`Boolean` + +A boolean representing whether the node you are rendering is currently selected. You can use this to render a visual representation of the selection. + +### `node` +`Node` + +A reference to the [`Node`](../models/node.md) being rendered. + +### `parent` +`Node` + +A reference to the parent of the current [`Node`](../models/node.md) being rendered. + +### `readOnly` +`Boolean` + +Whether the editor is in "read-only" mode, where all of the rendering is the same, but the user is prevented from editing the editor's content. + +### `state` +`State` + +A reference to the current [`State`](../models/state.md) of the editor. diff --git a/examples/embeds/video.js b/examples/embeds/video.js index d9c8122c7..c09b86398 100644 --- a/examples/embeds/video.js +++ b/examples/embeds/video.js @@ -9,18 +9,6 @@ import React from 'react' class Video extends React.Component { - /** - * Check if the node is selected. - * - * @return {Boolean} - */ - - isSelected = () => { - const { node, state } = this.props - const isSelected = state.isFocused && state.blocks.includes(node) - return isSelected - } - /** * When the input text changes, update the `video` data on the node. * @@ -66,8 +54,8 @@ class Video extends React.Component { */ renderVideo = () => { - const video = this.props.node.data.get('video') - const isSelected = this.isSelected() + const { node, isSelected } = this.props + const video = node.data.get('video') const wrapperStyle = { position: 'relative', @@ -120,7 +108,8 @@ class Video extends React.Component { */ renderInput = () => { - const video = this.props.node.data.get('video') + const { node } = this.props + const video = node.data.get('video') return (

{props.children}

, emoji: (props) => { - const { state, node } = props + const { isSelected, node } = props const { data } = node const code = data.get('code') - const isSelected = state.selection.hasFocusIn(node) return {code} } } diff --git a/examples/images/index.js b/examples/images/index.js index 9ad01cffc..c9befda68 100644 --- a/examples/images/index.js +++ b/examples/images/index.js @@ -27,10 +27,9 @@ const defaultBlock = { const schema = { nodes: { image: (props) => { - const { node, state } = props - const active = state.isFocused && state.blocks.includes(node) + const { node, isSelected } = props const src = node.data.get('src') - const className = active ? 'active' : null + const className = isSelected ? 'active' : null return ( ) diff --git a/src/components/content.js b/src/components/content.js index 99b5b0b05..9fb9a7ab1 100644 --- a/src/components/content.js +++ b/src/components/content.js @@ -917,25 +917,41 @@ class Content extends React.Component { } /** - * Render a `node`. + * Render a `child` node of the document. * - * @param {Node} node + * @param {Node} child * @return {Element} */ - renderNode = (node) => { + renderNode = (child) => { const { editor, readOnly, schema, state } = this.props + const { document, selection } = state + const { startKey, endKey, isBlurred } = selection + let isSelected + + if (isBlurred) { + isSelected = false + } + + else { + isSelected = document.nodes + .skipUntil(n => n.kind == 'text' ? n.key == startKey : n.hasDescendant(startKey)) + .reverse() + .skipUntil(n => n.kind == 'text' ? n.key == endKey : n.hasDescendant(endKey)) + .includes(child) + } return ( ) } diff --git a/src/components/node.js b/src/components/node.js index d5e36c8fd..f18ac0cdc 100644 --- a/src/components/node.js +++ b/src/components/node.js @@ -39,6 +39,7 @@ class Node extends React.Component { static propTypes = { block: SlateTypes.block, editor: Types.object.isRequired, + isSelected: Types.bool.isRequired, node: SlateTypes.node.isRequired, parent: SlateTypes.node.isRequired, readOnly: Types.bool.isRequired, @@ -97,6 +98,8 @@ class Node extends React.Component { shouldComponentUpdate = (nextProps) => { const { props } = this const { Component } = this.state + const n = nextProps + const p = props // If the `Component` has enabled suppression of update checking, always // return true so that it can deal with update checking itself. @@ -104,49 +107,34 @@ class Node extends React.Component { // If the `readOnly` status has changed, re-render in case there is any // user-land logic that depends on it, like nested editable contents. - if (nextProps.readOnly != props.readOnly) return true + if (n.readOnly != p.readOnly) return true // If the node has changed, update. PERF: There are cases where it will have // changed, but it's properties will be exactly the same (eg. copy-paste) // which this won't catch. But that's rare and not a drag on performance, so // for simplicity we just let them through. - if (nextProps.node != props.node) return true + if (n.node != p.node) return true - // If the Node has children that aren't just Text's then allow them to decide - // If they should update it or not. - if (nextProps.node.kind != 'text' && Text.isTextList(nextProps.node.nodes) == false) return true - - // If the node is a block or inline, which can have custom renderers, we - // include an extra check to re-render if the node either becomes part of, - // or leaves, a selection. This is to make it simple for users to show a - // node's "selected" state. - if (nextProps.node.kind != 'text') { - const nodes = `${props.node.kind}s` - const isInSelection = props.state[nodes].includes(props.node) - const nextIsInSelection = nextProps.state[nodes].includes(nextProps.node) - const hasFocus = props.state.isFocused - const nextHasFocus = nextProps.state.isFocused - const selectionChanged = isInSelection != nextIsInSelection - const focusChanged = hasFocus != nextHasFocus - if (selectionChanged || focusChanged) return true - } + // If the node's selection state has changed, re-render in case there is any + // user-land logic depends on it to render. + if (n.isSelected != p.isSelected) return true // If the node is a text node, re-render if the current decorations have // changed, even if the content of the text node itself hasn't. - if (nextProps.node.kind == 'text' && nextProps.schema.hasDecorators) { - const nextDecorators = nextProps.state.document.getDescendantDecorators(nextProps.node.key, nextProps.schema) - const decorators = props.state.document.getDescendantDecorators(props.node.key, props.schema) - const nextRanges = nextProps.node.getRanges(nextDecorators) - const ranges = props.node.getRanges(decorators) - if (!nextRanges.equals(ranges)) return true + if (n.node.kind == 'text' && n.schema.hasDecorators) { + const nDecorators = n.state.document.getDescendantDecorators(n.node.key, n.schema) + const pDecorators = p.state.document.getDescendantDecorators(p.node.key, p.schema) + const nRanges = n.node.getRanges(nDecorators) + const pRanges = p.node.getRanges(pDecorators) + if (!nRanges.equals(pRanges)) return true } // If the node is a text node, and its parent is a block node, and it was // the last child of the block, re-render to cleanup extra `
` or `\n`. - if (nextProps.node.kind == 'text' && nextProps.parent.kind == 'block') { - const last = props.parent.nodes.last() - const nextLast = nextProps.parent.nodes.last() - if (props.node == last && nextProps.node != nextLast) return true + if (n.node.kind == 'text' && n.parent.kind == 'block') { + const pLast = p.parent.nodes.last() + const nLast = n.parent.nodes.last() + if (p.node == pLast && n.node != nLast) return true } // Otherwise, don't update. @@ -261,14 +249,35 @@ class Node extends React.Component { */ renderNode = (child) => { - const { block, editor, node, readOnly, schema, state } = this.props + const { block, editor, isSelected, node, readOnly, schema, state } = this.props + const { selection } = state + const { startKey, endKey } = selection + let isChildSelected + + if (!isSelected) { + isChildSelected = false + } + + else if (node.kind == 'text') { + isChildSelected = node.key == startKey || node.key == endKey + } + + else { + isChildSelected = node.nodes + .skipUntil(n => n.kind == 'text' ? n.key == startKey : n.hasDescendant(startKey)) + .reverse() + .skipUntil(n => n.kind == 'text' ? n.key == endKey : n.hasDescendant(endKey)) + .includes(child) + } + return ( { - const { editor, node, parent, readOnly, state } = this.props + const { editor, isSelected, node, parent, readOnly, state } = this.props const { Component } = this.state const children = node.nodes.map(this.renderNode).toArray() @@ -304,10 +313,11 @@ class Node extends React.Component { const element = ( diff --git a/src/models/node.js b/src/models/node.js index 77264b157..1d30ffcac 100644 --- a/src/models/node.js +++ b/src/models/node.js @@ -6,7 +6,7 @@ import Inline from './inline' import Text from './text' import direction from 'direction' import generateKey from '../utils/generate-key' -import isInRange from '../utils/is-in-range' +import isIndexInRange from '../utils/is-index-in-range' import isPlainObject from 'is-plain-object' import logger from '../utils/logger' import memoize from '../utils/memoize' @@ -460,7 +460,7 @@ class Node { .getTextsAtRange(range) .reduce((arr, text) => { const chars = text.characters - .filter((char, i) => isInRange(i, text, range)) + .filter((char, i) => isIndexInRange(i, text, range)) .toArray() return arr.concat(chars) @@ -1560,6 +1560,49 @@ class Node { return this.set('nodes', nodes) } + /** + * Check whether the node is in a `range`. + * + * @param {Selection} range + * @return {Boolean} + */ + + isInRange(range) { + range = range.normalize(this) + + const node = this + const { startKey, endKey, isCollapsed } = range + + // PERF: solve the most common cast where the start or end key are inside + // the node, for collapsed selections. + if ( + node.key == startKey || + node.key == endKey || + node.hasDescendant(startKey) || + node.hasDescendant(endKey) + ) { + return true + } + + // PERF: if the selection is collapsed and the previous check didn't return + // true, then it must be false. + if (isCollapsed) { + return false + } + + // Otherwise, look through all of the leaf text nodes in the range, to see + // if any of them are inside the node. + const texts = node.getTextsAtRange(range) + let memo = false + + texts.forEach((text) => { + if (node.hasDescendant(text.key)) memo = true + return memo + }) + + return memo + } + /** * Check whether the node is a leaf block. * diff --git a/src/utils/is-in-range.js b/src/utils/is-index-in-range.js similarity index 87% rename from src/utils/is-in-range.js rename to src/utils/is-index-in-range.js index 20cce4ad3..10aa0109e 100644 --- a/src/utils/is-in-range.js +++ b/src/utils/is-index-in-range.js @@ -8,7 +8,7 @@ * @return {Boolean} */ -function isInRange(index, text, range) { +function isIndexInRange(index, text, range) { const { startKey, startOffset, endKey, endOffset } = range if (text.key == startKey && text.key == endKey) { @@ -28,4 +28,4 @@ function isInRange(index, text, range) { * @type {Function} */ -export default isInRange +export default isIndexInRange