diff --git a/.eslintrc b/.eslintrc index 57c7c0b4e..df7882be4 100644 --- a/.eslintrc +++ b/.eslintrc @@ -44,6 +44,7 @@ "no-array-constructor": "error", "no-class-assign": "error", "no-const-assign": "error", + "no-console": "warn", "no-debugger": "warn", "no-dupe-args": "error", "no-dupe-class-members": "error", diff --git a/examples/code-highlighting/index.js b/examples/code-highlighting/index.js index 681377e52..00eed5659 100644 --- a/examples/code-highlighting/index.js +++ b/examples/code-highlighting/index.js @@ -129,12 +129,11 @@ class CodeHighlighting extends React.Component { * Render decorations on `text` nodes inside code blocks. * * @param {Text} text + * @param {Block} block * @return {Characters} */ - renderDecorations = (text, state) => { - const { document } = state - const block = document.getClosestBlock(text) + renderDecorations = (text, block) => { if (block.type != 'code') return text.characters let characters = text.characters.asMutable() diff --git a/lib/components/content.js b/lib/components/content.js index e67fdaa0e..ffe2ca860 100644 --- a/lib/components/content.js +++ b/lib/components/content.js @@ -42,6 +42,7 @@ class Content extends React.Component { onPaste: React.PropTypes.func.isRequired, onSelect: React.PropTypes.func.isRequired, readOnly: React.PropTypes.bool.isRequired, + renderDecorations: React.PropTypes.func.isRequired, renderMark: React.PropTypes.func.isRequired, renderNode: React.PropTypes.func.isRequired, spellCheck: React.PropTypes.bool.isRequired, @@ -114,6 +115,36 @@ class Content extends React.Component { }) } + /** + * Get a point from a native selection's DOM `element` and `offset`. + * + * @param {Element} element + * @param {Number} offset + * @return {Object} + */ + + getPoint(element, offset) { + const offsetKey = OffsetKey.findKey(element, offset) + const ranges = this.getRanges(offsetKey.key) + const point = OffsetKey.findPoint(offsetKey, ranges) + return point + } + + /** + * Get the ranges for a text node by `key`. + * + * @param {String} key + * @return {List} + */ + + getRanges(key) { + const { state, renderDecorations } = this.props + const node = state.document.getDescendant(key) + const block = state.document.getClosestBlock(node) + const ranges = node.getDecoratedRanges(block, renderDecorations) + return ranges + } + /** * On before input, bubble up. * @@ -333,7 +364,7 @@ class Content extends React.Component { if (this.props.readOnly) return e.preventDefault() - const { state } = this.props + const { state, renderDecorations } = this.props const { selection } = state const data = e.nativeEvent.dataTransfer const drop = {} @@ -355,7 +386,7 @@ class Content extends React.Component { const startNode = range.startContainer const startOffset = range.startOffset - const point = OffsetKey.findPoint(startNode, startOffset, state) + const point = this.getPoint(startNode, startOffset) const target = Selection.create({ anchorKey: point.key, anchorOffset: point.offset, @@ -418,16 +449,16 @@ class Content extends React.Component { */ onInput = (e) => { - let { state } = this.props + let { state, renderDecorations } = this.props const { selection } = state const native = window.getSelection() const { anchorNode, anchorOffset, focusOffset } = native - let { textContent } = anchorNode - const offsetKey = OffsetKey.findKey(anchorNode) - const { key, index } = OffsetKey.parse(offsetKey) - const { start, end } = OffsetKey.findBounds(key, index, state) - const range = OffsetKey.findRange(anchorNode, state) + const point = this.getPoint(anchorNode, anchorOffset) + const { key, index, start, end } = point + const ranges = this.getRanges(key) + const range = ranges.get(index) const { text, marks } = range + let { textContent } = anchorNode // COMPAT: If the DOM text ends in a new line, we will have added one to // account for browsers collapsing a single one, so remove it. @@ -564,7 +595,52 @@ class Content extends React.Component { if (this.tmp.isCopying) return if (this.tmp.isComposing) return - this.props.onSelect(e) + const { state, renderDecorations } = this.props + let { document, selection } = state + const native = window.getSelection() + const select = {} + + // If there are no ranges, the editor was blurred natively. + if (!native.rangeCount) { + select.selection = selection.merge({ isFocused: false }) + select.isNative = true + } + + // Otherwise, determine the Slate selection from the native one. + else { + const { anchorNode, anchorOffset, focusNode, focusOffset } = native + const anchor = this.getPoint(anchorNode, anchorOffset) + const focus = this.getPoint(focusNode, focusOffset) + + // COMPAT: In Firefox, and potentially other browsers, sometimes a select + // event will fire that resolves to the same location as the current + // selection, so we can ignore it. + if ( + anchor.key == selection.anchorKey && + anchor.offset == selection.anchorOffset && + focus.key == selection.focusKey && + focus.offset == selection.focusOffset + ) { + return + } + + // If the native selection is inside text nodes, we can trust the native + // state and not need to re-render. + select.isNative = ( + anchorNode.nodeType == 3 && + focusNode.nodeType == 3 + ) + + select.selection = selection.merge({ + anchorKey: anchor.key, + anchorOffset: anchor.offset, + focusKey: focus.key, + focusOffset: focus.offset, + isFocused: true + }) + } + + this.props.onSelect(e, select) } /** @@ -637,15 +713,16 @@ class Content extends React.Component { */ renderNode = (node) => { - const { editor, renderMark, renderNode, state } = this.props + const { editor, renderDecorations, renderMark, renderNode, state } = this.props return ( ) } diff --git a/lib/components/editor.js b/lib/components/editor.js index 3e2e5647c..5b2a4a532 100644 --- a/lib/components/editor.js +++ b/lib/components/editor.js @@ -62,7 +62,7 @@ class Editor extends React.Component { this.tmp = {} this.state = {} this.state.plugins = this.resolvePlugins(props) - this.state.state = this.resolveState(props.state) + this.state.state = props.state } /** @@ -72,13 +72,11 @@ class Editor extends React.Component { */ componentWillReceiveProps = (props) => { + this.state.state = props.state + if (props.plugins != this.props.plugins) { this.setState({ plugins: this.resolvePlugins(props) }) } - - if (props.state != this.props.state) { - this.setState({ state: this.resolveState(props.state) }) - } } /** @@ -232,6 +230,7 @@ class Editor extends React.Component { onPaste={this.onPaste} onSelect={this.onSelect} readOnly={this.props.readOnly} + renderDecorations={this.renderDecorations} renderMark={this.renderMark} renderNode={this.renderNode} spellCheck={this.props.spellCheck} @@ -242,18 +241,21 @@ class Editor extends React.Component { } /** - * Render a `node`, cascading through the plugins. + * Render the decorations for a `text`, cascading through the plugins. * - * @param {Node} node - * @return {Element} element + * @param {Block} text + * @param {Block} block + * @return {Object} style */ - renderNode = (node) => { + renderDecorations = (text, block) => { for (const plugin of this.state.plugins) { - if (!plugin.renderNode) continue - const component = plugin.renderNode(node, this.state.state, this) - if (component) return component + if (!plugin.renderDecorations) continue + const style = plugin.renderDecorations(text, block, this.state.state, this) + if (style) return style } + + return text.characters } /** @@ -274,6 +276,21 @@ class Editor extends React.Component { return {} } + /** + * Render a `node`, cascading through the plugins. + * + * @param {Node} node + * @return {Element} element + */ + + renderNode = (node) => { + for (const plugin of this.state.plugins) { + if (!plugin.renderNode) continue + const component = plugin.renderNode(node, this.state.state, this) + if (component) return component + } + } + /** * Resolve the editor's current plugins from `props` when they change. * @@ -298,34 +315,6 @@ class Editor extends React.Component { ] } - /** - * Resolve the editor's current state from `props` when they change. - * - * This is where we handle decorating the text nodes with the decorator - * functions, so that they are always accounted for when rendering. - * - * @param {State} state - * @return {State} state - */ - - resolveState = (state) => { - const { plugins } = this.state - let { document } = state - - document = document.decorateTexts((text) => { - for (const plugin of plugins) { - if (!plugin.renderDecorations) continue - const characters = plugin.renderDecorations(text, state, this) - if (characters) return characters - } - - return text.characters - }) - - state = state.merge({ document }) - return state - } - } /** diff --git a/lib/components/leaf.js b/lib/components/leaf.js index b0f820671..e5cf4d24c 100644 --- a/lib/components/leaf.js +++ b/lib/components/leaf.js @@ -17,6 +17,7 @@ class Leaf extends React.Component { index: React.PropTypes.number.isRequired, marks: React.PropTypes.object.isRequired, node: React.PropTypes.object.isRequired, + ranges: React.PropTypes.object.isRequired, renderMark: React.PropTypes.func.isRequired, state: React.PropTypes.object.isRequired, text: React.PropTypes.string.isRequired @@ -54,7 +55,7 @@ class Leaf extends React.Component { return true } - const { start, end } = OffsetKey.findBounds(node.key, index, state) + const { start, end } = OffsetKey.findBounds(index, props.ranges) return selection.hasEdgeBetween(node, start, end) } @@ -67,15 +68,15 @@ class Leaf extends React.Component { } updateSelection() { - const { state } = this.props + const { state, ranges } = this.props const { selection } = state - // If the selection is not focused we have nothing to do. - if (!selection.isFocused) return + // If the selection is blurred we have nothing to do. + if (selection.isBlurred) return const { anchorOffset, focusOffset } = selection const { node, index } = this.props - const { start, end } = OffsetKey.findBounds(node.key, index, state) + const { start, end } = OffsetKey.findBounds(index, ranges) // If neither matches, the selection doesn't start or end here, so exit. const hasAnchor = selection.hasAnchorBetween(node, start, end) diff --git a/lib/components/node.js b/lib/components/node.js index 6b05e9740..9369dd03d 100644 --- a/lib/components/node.js +++ b/lib/components/node.js @@ -15,6 +15,7 @@ class Node extends React.Component { static propTypes = { editor: React.PropTypes.object.isRequired, node: React.PropTypes.object.isRequired, + renderDecorations: React.PropTypes.func.isRequired, renderMark: React.PropTypes.func.isRequired, renderNode: React.PropTypes.func.isRequired, state: React.PropTypes.object.isRequired @@ -65,15 +66,16 @@ class Node extends React.Component { */ renderNode = (node) => { - const { editor, renderMark, renderNode, state } = this.props + const { editor, renderDecorations, renderMark, renderNode, state } = this.props return ( ) } @@ -118,12 +120,13 @@ class Node extends React.Component { */ renderText = () => { - const { node, editor, renderMark, state } = this.props + const { node, editor, renderDecorations, renderMark, state } = this.props return ( diff --git a/lib/components/text.js b/lib/components/text.js index b6ab80764..35dea6cac 100644 --- a/lib/components/text.js +++ b/lib/components/text.js @@ -16,6 +16,7 @@ class Text extends React.Component { static propTypes = { editor: React.PropTypes.object.isRequired, node: React.PropTypes.object.isRequired, + renderDecorations: React.PropTypes.func.isRequired, renderMark: React.PropTypes.func.isRequired, state: React.PropTypes.object.isRequired }; @@ -30,9 +31,8 @@ class Text extends React.Component { shouldComponentUpdate(props, state) { return ( - props.state.selection.hasEdgeIn(props.node) || - props.node.decorations != this.props.node.decorations || - props.node.characters != this.props.node.characters + props.node != this.props.node || + props.state.selection.hasEdgeIn(props.node) ) } @@ -58,28 +58,30 @@ class Text extends React.Component { */ renderLeaves() { - const { node } = this.props - const ranges = node.getDecoratedRanges() + const { node, state, renderDecorations } = this.props + const block = state.document.getClosestBlock(node) + const ranges = node.getDecoratedRanges(block, renderDecorations) return ranges.map((range, i, original) => { const previous = original.slice(0, i) const offset = previous.size ? previous.map(r => r.text).join('').length : 0 - return this.renderLeaf(range, i, offset) + return this.renderLeaf(ranges, range, i, offset) }) } /** * Render a single leaf node given a `range` and `offset`. * - * @param {Object} range + * @param {List} ranges + * @param {Range} range * @param {Number} index * @param {Number} offset * @return {Element} leaf */ - renderLeaf(range, index, offset) { + renderLeaf(ranges, range, index, offset) { const { node, renderMark, state } = this.props const text = range.text const marks = range.marks @@ -92,6 +94,7 @@ class Text extends React.Component { node={node} text={text} marks={marks} + ranges={ranges} renderMark={renderMark} /> ) diff --git a/lib/components/void.js b/lib/components/void.js index 06c6a6b7e..e4de309ba 100644 --- a/lib/components/void.js +++ b/lib/components/void.js @@ -8,10 +8,16 @@ import keycode from 'keycode' /** * Void. + * + * @type {Component} */ class Void extends React.Component { + /** + * Property types. + */ + static propTypes = { children: React.PropTypes.any.isRequired, className: React.PropTypes.string, @@ -21,17 +27,53 @@ class Void extends React.Component { style: React.PropTypes.object }; + /** + * Default properties. + */ + static defaultProps = { style: {} } - shouldComponentUpdate = (props) => { + /** + * Should the component update? + * + * @param {Object} props + * @param {Object} state + * @return {Boolean} + */ + + shouldComponentUpdate = (props, state) => { return ( props.node != this.props.node || props.state.selection.hasEdgeIn(props.node) ) } + /** + * When one of the wrapper elements it clicked, select the void node. + * + * @param {Event} e + */ + + onClick = (e) => { + e.preventDefault() + const { state, node, editor } = this.props + const next = state + .transform() + .moveToRangeOf(node) + .focus() + .apply() + + editor.onChange(next) + } + + /** + * Render. + * + * @return {Element} + */ + render = () => { const { children, node, className, style } = this.props const Tag = node.kind == 'block' ? 'div' : 'span' @@ -43,7 +85,7 @@ class Void extends React.Component { } return ( - + {this.renderSpacer()} - {children} + {children} ) } + /** + * Render a fake spacer leaf, which will catch the cursor when it the void + * node is navigated to with the arrow keys. Having this spacer there means + * the browser continues to manage the selection natively, so it keeps track + * of the right offset when moving across the block. + * + * @return {Element} + */ + renderSpacer = () => { const style = { position: 'absolute', @@ -70,9 +121,16 @@ class Void extends React.Component { ) } + /** + * Render a fake leaf. + * + * @return {Element} + */ + renderLeaf = () => { const { node, state } = this.props const child = node.getTexts().first() + const ranges = child.getRanges() const text = '' const marks = Mark.createSet() const index = 0 @@ -88,6 +146,7 @@ class Void extends React.Component { key={offsetKey} state={state} node={child} + ranges={ranges} index={index} text={text} marks={marks} @@ -95,14 +154,16 @@ class Void extends React.Component { ) } + /** + * Render a fake leaf mark. + * + * @return {Object} + */ + renderLeafMark = (mark) => { return {} } - renderLeafRefs = (el) => { - this.leaf = el - } - } /** diff --git a/lib/models/node.js b/lib/models/node.js index ec0c63ad2..9051bb7af 100644 --- a/lib/models/node.js +++ b/lib/models/node.js @@ -903,21 +903,23 @@ const Node = { desc = desc.merge({ nodes }) } - if (desc.kind == 'text') { + if (desc.kind == 'text' && !removals.has(desc.key)) { let next = node.getNextSibling(desc) // ...that there are no adjacent text nodes. - while (next && next.kind == 'text') { - const characters = desc.characters.concat(next.characters) - desc = desc.merge({ characters }) - removals = removals.add(next.key) - next = node.getNextSibling(next) + if (next && next.kind == 'text') { + while (next && next.kind == 'text') { + const characters = desc.characters.concat(next.characters) + desc = desc.merge({ characters }) + removals = removals.add(next.key) + next = node.getNextSibling(next) + } } // ...that there are no extra empty text nodes. - if (desc.length == 0) { + else if (desc.length == 0) { const parent = node.getParent(desc) - if (parent.nodes.size != 1) removals = removals.add(desc.key) + if (parent.nodes.size > 1) removals = removals.add(desc.key) } } diff --git a/lib/models/text.js b/lib/models/text.js index 96c759a0c..cddbc9668 100644 --- a/lib/models/text.js +++ b/lib/models/text.js @@ -102,44 +102,29 @@ class Text extends new Record(DEFAULTS) { .join('') } - /** - * Decorate the text node's characters with a `decorator` function. - * - * @param {Function} decorator - * @return {Text} text - */ - - decorateCharacters(decorator) { - let { characters, cache } = this - if (characters == cache) return this - - const decorations = this.getDecoratedCharacters(decorator) - if (decorations == characters) return this - - return this.merge({ - cache: characters, - decorations, - }) - } - /** * Get the decorated characters. * + * @param {Block} block + * @param {Function} decorator * @return {List} characters */ - getDecoratedCharacters(decorator) { - return decorator(this) + getDecoratedCharacters(block, decorator) { + return decorator(this, block) } /** * Get the decorated characters grouped by marks. * + * @param {Block} block + * @param {Function} decorator * @return {List} ranges */ - getDecoratedRanges() { - return this.getRangesForCharacters(this.decorations || this.characters) + getDecoratedRanges(block, decorator) { + const decorations = this.getDecoratedCharacters(block, decorator) + return this.getRangesForCharacters(decorations) } /** diff --git a/lib/plugins/core.js b/lib/plugins/core.js index 4a145320c..3fa94af80 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -87,26 +87,47 @@ function Plugin(options = {}) { */ onBeforeInput(e, state, editor) { - const transform = state.transform().insertText(e.data) - const synthetic = transform.apply() - const resolved = editor.resolveState(synthetic) + const { renderDecorations } = editor + const { startOffset, startText, startBlock } = state + + // Determine what the characters would be if natively inserted. + const prev = startText.getDecoratedCharacters(startBlock, renderDecorations) + const char = prev.get(startOffset) + const chars = prev + .slice(0, startOffset) + .push(char.merge({ text: e.data })) + .concat(prev.slice(startOffset)) + + // Determine what the characters should be, if not natively inserted. + let next = state + .transform() + .insertText(e.data) + .apply() + + const nextText = next.startText + const nextBlock = next.startBlock + const nextChars = nextText.getDecoratedCharacters(nextBlock, renderDecorations) // We do not have to re-render if the current selection is collapsed, the // current node is not empty, there are no marks on the cursor, and the - // new state has the same decorations as the current one. + // natively inserted characters would be the same as the non-native. const isNative = ( state.isCollapsed && state.startText.text != '' && state.cursorMarks == null && - resolved.equals(synthetic) + chars.equals(nextChars) ) - state = isNative - ? transform.apply({ isNative }) - : synthetic + // Add the `isNative` flag directly, so we don't have to re-transform. + if (isNative) { + next = next.merge({ isNative }) + } + // If not native, prevent default so that the DOM remains untouched. if (!isNative) e.preventDefault() - return state + + // Return the new state. + return next }, /** @@ -303,56 +324,18 @@ function Plugin(options = {}) { * The core `onSelect` handler. * * @param {Event} e + * @param {Object} select * @param {State} state * @param {Editor} editor * @return {State or Null} */ - onSelect(e, state, editor) { - let { document, selection } = state - const native = window.getSelection() - - // If there are no ranges, the editor was blurred natively. - if (!native.rangeCount) { - return state - .transform() - .blur() - .apply({ isNative: true }) - } - - // Calculate the Slate-specific selection based on the native one. - const { anchorNode, anchorOffset, focusNode, focusOffset } = native - const anchor = OffsetKey.findPoint(anchorNode, anchorOffset, state) - const focus = OffsetKey.findPoint(focusNode, focusOffset, state) - - // COMPAT: In Firefox, and potentially other browsers, sometimes a select - // event will fire that resolves to the same location as the current - // selection, so we can ignore it. - if ( - anchor.key == selection.anchorKey && - anchor.offset == selection.anchorOffset && - focus.key == selection.focusKey && - focus.offset == selection.focusOffset - ) { - return - } - - // If the native selection is inside text nodes, we can trust the native - // state and not need to re-render. - const isNative = ( - anchorNode.nodeType == 3 && - focusNode.nodeType == 3 - ) - + onSelect(e, select, state, editor) { + const { selection, isNative } = select return state .transform() + .moveTo(selection) .focus() - .moveTo({ - anchorKey: anchor.key, - anchorOffset: anchor.offset, - focusKey: focus.key, - focusOffset: focus.offset - }) .apply({ isNative }) }, diff --git a/lib/utils/offset-key.js b/lib/utils/offset-key.js index 65c5826bf..df8def3bc 100644 --- a/lib/utils/offset-key.js +++ b/lib/utils/offset-key.js @@ -13,17 +13,14 @@ const ATTRIBUTE = 'data-offset-key' const SELECTOR = `[${ATTRIBUTE}]` /** - * Find the start and end bounds from a node's `key` and `index`. + * Find the start and end bounds from an `offsetKey` and `ranges`. * - * @param {String} key * @param {Number} index - * @param {State} state + * @param {List} ranges * @return {Object} */ -function findBounds(key, index, state) { - const text = state.document.assertDescendant(key) - const ranges = text.getDecoratedRanges() +function findBounds(index, ranges) { const range = ranges.get(index) const start = ranges .slice(0, index) @@ -41,33 +38,26 @@ function findBounds(key, index, state) { * From a `element`, find the closest parent's offset key. * * @param {Element} element - * @return {String or Null} - */ - -function findKey(element) { - if (element.nodeType == 3) element = element.parentNode - const parent = element.closest(SELECTOR) - if (!parent) return null - return parent.getAttribute(ATTRIBUTE) -} - -/** - * Find the selection point from an `element`, `offset`, and `state`. - * - * @param {Element} element - * @param {Offset} offset - * @param {State} state + * @param {Number} offset * @return {Object} */ -function findPoint(element, offset, state) { - let offsetKey = findKey(element) +function findKey(element, offset) { + if (element.nodeType == 3) element = element.parentNode + + const parent = element.closest(SELECTOR) + const children = element.querySelectorAll(SELECTOR) + let offsetKey + + // Get the key from a parent if one exists. + if (parent) { + offsetKey = parent.getAttribute(ATTRIBUTE) + } // COMPAT: In Firefox, and potentially other browsers, when performing a // "select all" action, a parent element is selected instead of the text. In // this case, we need to select the proper inner text nodes. (2016/07/26) - if (!offsetKey) { - const children = element.querySelectorAll(SELECTOR) + else if (children.length) { let child = children[0] if (offset != 0) { @@ -78,8 +68,44 @@ function findPoint(element, offset, state) { offsetKey = child.getAttribute(ATTRIBUTE) } - const { key, index } = parse(offsetKey) - const { start, end } = findBounds(key, index, state) + // Otherwise, for void node scenarios, a cousin element will be selected, and + // we need to select the first text node cousin we can find. + else { + while (element = element.parentNode) { + const cousin = element.querySelector(SELECTOR) + if (!cousin) continue + offsetKey = cousin.getAttribute(ATTRIBUTE) + offset = cousin.textContent.length + break + } + } + + // If we still didn't find an offset key, error. This is a bug. + if (!offsetKey) { + throw new Error(`Unable to find offset key for ${element} with offset "${offset}".`) + } + + // Parse the offset key. + const parsed = parse(offsetKey) + + return { + key: parsed.key, + index: parsed.index, + offset + } +} + +/** + * Find the selection point from an `offsetKey` and `ranges`. + * + * @param {Object} offsetKey + * @param {List} ranges + * @return {Object} + */ + +function findPoint(offsetKey, ranges) { + let { key, index, offset } = offsetKey + const { start, end } = findBounds(index, ranges) // Don't let the offset be outside of the start and end bounds. offset = start + offset @@ -88,27 +114,13 @@ function findPoint(element, offset, state) { return { key, + index, + start, + end, offset } } -/** - * Find the range from an `element`. - * - * @param {Element} element - * @param {State} state - * @return {Range} - */ - -function findRange(element, state) { - const offsetKey = findKey(element) - const { key, index } = parse(offsetKey) - const text = state.document.getDescendant(key) - const ranges = text.getDecoratedRanges() - const range = ranges.get(index) - return range -} - /** * Parse an offset key `string`. * @@ -147,7 +159,6 @@ export default { findBounds, findKey, findPoint, - findRange, parse, stringify }