From dbcb9e531f42476e8a9b43287dd82047e415c022 Mon Sep 17 00:00:00 2001 From: Ian Storm Taylor Date: Sat, 13 Aug 2016 19:38:59 -0700 Subject: [PATCH] add rendering of decorators from schema --- examples/code-highlighting/index.js | 76 ++++++++---------- lib/components/content.js | 50 ++++++------ lib/components/editor.js | 20 ----- lib/components/node.js | 24 +++--- lib/models/node.js | 41 +++++++++- lib/models/schema.js | 21 +++++ lib/models/text.js | 116 ++++++++++++++-------------- lib/plugins/core.js | 10 +-- 8 files changed, 189 insertions(+), 169 deletions(-) diff --git a/examples/code-highlighting/index.js b/examples/code-highlighting/index.js index 91278f48a..db5cddfcd 100644 --- a/examples/code-highlighting/index.js +++ b/examples/code-highlighting/index.js @@ -55,7 +55,39 @@ function CodeBlock(props) { const schema = { nodes: { - code: CodeBlock + code: { + component: CodeBlock, + decorator: (block, text) => { + let characters = text.characters.asMutable() + const language = block.data.get('language') + const string = text.text + const grammar = Prism.languages[language] + const tokens = Prism.tokenize(string, grammar) + let offset = 0 + + for (const token of tokens) { + if (typeof token == 'string') { + offset += token.length + continue + } + + const length = offset + token.content.length + const type = `highlight-${token.type}` + + for (let i = offset; i < length; i++) { + let char = characters.get(i) + let { marks } = char + marks = marks.add(Mark.create({ type })) + char = char.merge({ marks }) + characters = characters.set(i, char) + } + + offset = length + } + + return characters.asImmutable() + } + } }, marks: { 'highlight-comment': { @@ -131,7 +163,6 @@ class CodeHighlighting extends React.Component { @@ -139,47 +170,6 @@ class CodeHighlighting extends React.Component { ) } - /** - * Render decorations on `text` nodes inside code blocks. - * - * @param {Text} text - * @param {Block} block - * @return {Characters} - */ - - renderDecorations = (text, block) => { - if (block.type != 'code') return text.characters - - let characters = text.characters.asMutable() - const language = block.data.get('language') - const string = text.text - const grammar = Prism.languages[language] - const tokens = Prism.tokenize(string, grammar) - let offset = 0 - - for (const token of tokens) { - if (typeof token == 'string') { - offset += token.length - continue - } - - const length = offset + token.content.length - const type = `highlight-${token.type}` - - for (let i = offset; i < length; i++) { - let char = characters.get(i) - let { marks } = char - marks = marks.add(Mark.create({ type })) - char = char.merge({ marks }) - characters = characters.set(i, char) - } - - offset = length - } - - return characters.asImmutable() - } - } /** diff --git a/lib/components/content.js b/lib/components/content.js index 4ea5a47ea..f24caf6da 100644 --- a/lib/components/content.js +++ b/lib/components/content.js @@ -55,7 +55,6 @@ 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, schema: React.PropTypes.object, spellCheck: React.PropTypes.bool.isRequired, state: React.PropTypes.object.isRequired, @@ -141,27 +140,18 @@ class Content extends React.Component { */ getPoint(element, offset) { + const { state, editor } = this.props + const { document } = state + const schema = editor.getSchema() const offsetKey = OffsetKey.findKey(element, offset) - const ranges = this.getRanges(offsetKey.key) + const { key } = offsetKey + const node = document.getDescendant(key) + const decorators = document.getDescendantDecorators(key, schema) + const ranges = node.getRanges(decorators) 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. * @@ -372,7 +362,7 @@ class Content extends React.Component { e.preventDefault() const window = getWindow(e.target) - const { state, renderDecorations } = this.props + const { state } = this.props const { selection } = state const { dataTransfer, x, y } = e.nativeEvent const transfer = new Transfer(dataTransfer) @@ -429,14 +419,23 @@ class Content extends React.Component { debug('onInput') const window = getWindow(e.target) - let { state, renderDecorations } = this.props - const { selection } = state + + // Get the selection point. const native = window.getSelection() const { anchorNode, anchorOffset, focusOffset } = native const point = this.getPoint(anchorNode, anchorOffset) const { key, index, start, end } = point - const ranges = this.getRanges(key) + + // Get the range in question. + const { state, editor } = this.props + const { document, selection } = state + const schema = editor.getSchema() + const decorators = document.getDescendantDecorators(key, schema) + const node = document.getDescendant(key) + const ranges = node.getRanges(decorators) const range = ranges.get(index) + + // Get the text information. const isLast = index == ranges.size - 1 const { text, marks } = range let { textContent } = anchorNode @@ -457,7 +456,7 @@ class Content extends React.Component { const after = selection.collapseToEnd().moveForward(delta) // Create an updated state with the text replaced. - state = state + const next = state .transform() .moveTo({ anchorKey: key, @@ -471,7 +470,7 @@ class Content extends React.Component { .apply() // Change the current state. - this.onChange(state) + this.onChange(next) } /** @@ -562,7 +561,7 @@ class Content extends React.Component { if (isNonEditable(e)) return const window = getWindow(e.target) - const { state, renderDecorations } = this.props + const { state } = this.props let { document, selection } = state const native = window.getSelection() const data = {} @@ -683,7 +682,7 @@ class Content extends React.Component { */ renderNode = (node) => { - const { editor, renderDecorations, schema, state } = this.props + const { editor, schema, state } = this.props return ( ) } diff --git a/lib/components/editor.js b/lib/components/editor.js index 8dad464a3..4ec4b777f 100644 --- a/lib/components/editor.js +++ b/lib/components/editor.js @@ -62,7 +62,6 @@ class Editor extends React.Component { placeholderStyle: React.PropTypes.object, plugins: React.PropTypes.array, readOnly: React.PropTypes.bool, - renderDecorations: React.PropTypes.func, schema: React.PropTypes.object, spellCheck: React.PropTypes.bool, state: React.PropTypes.object.isRequired, @@ -264,7 +263,6 @@ class Editor extends React.Component { editor={this} onChange={this.onChange} readOnly={this.props.readOnly} - renderDecorations={this.renderDecorations} schema={this.state.schema} spellCheck={this.props.spellCheck} state={this.state.state} @@ -273,24 +271,6 @@ class Editor extends React.Component { ) } - /** - * Render the decorations for a `text`, cascading through the plugins. - * - * @param {Block} text - * @param {Block} block - * @return {Object} - */ - - renderDecorations = (text, block) => { - for (const plugin of this.state.plugins) { - if (!plugin.renderDecorations) continue - const style = plugin.renderDecorations(text, block, this.state.state, this) - if (style) return style - } - - return text.characters - } - /** * Resolve the editor's current plugins from `props` when they change. * diff --git a/lib/components/node.js b/lib/components/node.js index 9261dbd13..89ac39326 100644 --- a/lib/components/node.js +++ b/lib/components/node.js @@ -31,13 +31,11 @@ class Node extends React.Component { */ static propTypes = { - block: React.PropTypes.object, editor: React.PropTypes.object.isRequired, node: React.PropTypes.object.isRequired, - renderDecorations: React.PropTypes.func.isRequired, schema: React.PropTypes.object.isRequired, state: React.PropTypes.object.isRequired - }; + } /** * Constructor. @@ -122,8 +120,8 @@ class Node extends React.Component { props.node.kind == 'text' && props.block != this.props.block ) { - const nextRanges = props.node.getDecoratedRanges(props.block, props.renderDecorations) - const ranges = this.props.node.getDecoratedRanges(this.props.block, this.props.renderDecorations) + const nextRanges = props.node.getRanges(props.decorators) + const ranges = this.props.node.getRanges(this.props.decorators) if (!ranges.equals(nextRanges)) return true } @@ -213,17 +211,13 @@ class Node extends React.Component { */ renderNode = (child) => { - const { editor, node, renderDecorations, schema, state } = this.props - const block = node.kind == 'block' ? node : this.props.block return ( ) } @@ -279,8 +273,10 @@ class Node extends React.Component { */ renderText = () => { - const { node, block, renderDecorations } = this.props - const ranges = node.getDecoratedRanges(block, renderDecorations) + const { node, schema, state } = this.props + const { document } = state + const decorators = document.getDescendantDecorators(node.key, schema) + const ranges = node.getRanges(decorators) let offset = 0 const leaves = ranges.map((range, i, original) => { diff --git a/lib/models/node.js b/lib/models/node.js index 7fe49664c..3ae72a31e 100644 --- a/lib/models/node.js +++ b/lib/models/node.js @@ -349,6 +349,39 @@ const Node = { return schema.__getComponent(this) }, + /** + * Get the decorations for the node from a `schema`. + * + * @param {Schema} schema + * @return {Array} + */ + + getDecorators(schema) { + return schema.__getDecorators(this) + }, + + /** + * Get the decorations for a descendant by `key` given a `schema`. + * + * @param {String} key + * @param {Schema} schema + * @return {Array} + */ + + getDescendantDecorators(key, schema) { + const descendant = this.assertDescendant(key) + let child = this.getHighestChild(key) + let decorators = [] + + while (child != descendant) { + decorators = decorators.concat(child.getDecorators(schema)) + child = child.getHighestChild(key) + } + + decorators = decorators.concat(descendant.getDecorators(schema)) + return decorators + }, + /** * Get a descendant node by `key`. * @@ -1104,9 +1137,9 @@ const Node = { memoize(Node, [ 'assertChild', 'assertDescendant', - 'findDescendant', 'filterDescendants', 'filterDescendantsDeep', + 'findDescendant', 'getBlocks', 'getBlocksAtRange', 'getCharactersAtRange', @@ -1121,8 +1154,10 @@ memoize(Node, [ 'getClosestBlock', 'getClosestInline', 'getComponent', - 'getDescendant', + 'getDecorators', 'getDepth', + 'getDescendant', + 'getDescendantDecorators', 'getFragmentAtRange', 'getFurthest', 'getFurthestBlock', @@ -1137,9 +1172,9 @@ memoize(Node, [ 'getOffset', 'getOffsetAtRange', 'getParent', + 'getPreviousBlock', 'getPreviousSibling', 'getPreviousText', - 'getPreviousBlock', 'getTextAtOffset', 'getTextDirection', 'getTexts', diff --git a/lib/models/schema.js b/lib/models/schema.js index 6b7cf1923..9058a8e4c 100644 --- a/lib/models/schema.js +++ b/lib/models/schema.js @@ -155,6 +155,27 @@ class Schema extends new Record(DEFAULTS) { return match.component } + /** + * Return the decorators for an `object`. + * + * This method is private, because it should always be called on one of the + * often-changing immutable objects instead, since it will be memoized for + * much better performance. + * + * @param {Mixed} object + * @return {Array} + */ + + __getDecorators(object) { + return this.rules + .filter(rule => rule.match(object) && rule.decorator) + .map((rule) => { + return (text) => { + return rule.decorator(object, text) + } + }) + } + /** * Validate an `object` against the schema, returning the failing rule and * reason if the object is invalid, or void if it's valid. diff --git a/lib/models/text.js b/lib/models/text.js index 0d0f7ab24..56aa2195c 100644 --- a/lib/models/text.js +++ b/lib/models/text.js @@ -99,84 +99,85 @@ class Text extends new Record(DEFAULTS) { } /** - * Get the decorated characters. + * Derive a set of decorated characters with `decorators`. * - * @param {Block} block - * @param {Function} decorator - * @return {List} characters + * @param {Array} decorators + * @return {List} */ - getDecoratedCharacters(block, decorator) { - return decorator(this, block) + getDecorations(decorators) { + const node = this + let { characters } = node + if (characters.size == 0) return characters + + for (const decorator of decorators) { + const decorateds = decorator(node) + characters = characters.merge(decorateds) + } + + return characters } /** - * Get the decorated characters grouped by marks. + * Get the decorations for the node from a `schema`. * - * @param {Block} block - * @param {Function} decorator - * @return {List} ranges + * @param {Schema} schema + * @return {Array} */ - getDecoratedRanges(block, decorator) { - const decorations = this.getDecoratedCharacters(block, decorator) - return this.getRangesForCharacters(decorations) - } - - /** - * Get the characters grouped by marks. - * - * @return {List} ranges - */ - - getRanges() { - return this.getRangesForCharacters(this.characters) + getDecorators(schema) { + return schema.__getDecorators(this) } /** * Derive the ranges for a list of `characters`. * - * @param {List} characters + * @param {Array || Void} decorators (optional) * @return {List} */ - getRangesForCharacters(characters) { + getRanges(decorators = []) { + const node = this + const list = new List() + let characters = this.getDecorations(decorators) + + // If there are no characters, return one empty range. if (characters.size == 0) { - let ranges = new List() - ranges = ranges.push(new Range()) - return ranges + return list.push(new Range()) } - return characters - .toList() - .reduce((ranges, char, i) => { - const { marks, text } = char + // Convert the now-decorated characters into ranges. + const ranges = characters.reduce((memo, char, i) => { + const { marks, text } = char - // The first one can always just be created. - if (i == 0) { - return ranges.push(new Range({ text, marks })) - } + // The first one can always just be created. + if (i == 0) { + return memo.push(new Range({ text, marks })) + } - // Otherwise, compare to the previous and see if a new range should be - // created, or whether the text should be added to the previous range. - const previous = characters.get(i - 1) - const prevMarks = previous.marks - const added = marks.filterNot(mark => prevMarks.includes(mark)) - const removed = prevMarks.filterNot(mark => marks.includes(mark)) - const isSame = !added.size && !removed.size + // Otherwise, compare to the previous and see if a new range should be + // created, or whether the text should be added to the previous range. + const previous = characters.get(i - 1) + const prevMarks = previous.marks + const added = marks.filterNot(mark => prevMarks.includes(mark)) + const removed = prevMarks.filterNot(mark => marks.includes(mark)) + const isSame = !added.size && !removed.size - // If the marks are the same, add the text to the previous range. - if (isSame) { - const index = ranges.size - 1 - let prevRange = ranges.get(index) - let prevText = prevRange.get('text') - prevRange = prevRange.set('text', prevText += text) - return ranges.set(index, prevRange) - } + // If the marks are the same, add the text to the previous range. + if (isSame) { + const index = memo.size - 1 + let prevRange = memo.get(index) + let prevText = prevRange.get('text') + prevRange = prevRange.set('text', prevText += text) + return memo.set(index, prevRange) + } - // Otherwise, create a new range. - return ranges.push(new Range({ text, marks })) - }, new List()) + // Otherwise, create a new range. + return memo.push(new Range({ text, marks })) + }, list) + + // Return the ranges. + return ranges } /** @@ -246,11 +247,10 @@ class Text extends new Record(DEFAULTS) { */ memoize(Text.prototype, [ - 'getDecoratedCharacters', - 'getDecoratedRanges', + 'getDecorations', + 'getDecorators', 'getRanges', - 'getRangesForCharacters', - 'validate' + 'validate', ]) /** diff --git a/lib/plugins/core.js b/lib/plugins/core.js index 3f61cea8e..065d3a944 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -160,11 +160,12 @@ function Plugin(options = {}) { */ function onBeforeInput(e, data, state, editor) { - const { renderDecorations } = editor - const { startOffset, startText, startBlock } = state + const { document, startKey, startOffset, startText } = state // Determine what the characters would be if natively inserted. - const prevChars = startText.getDecoratedCharacters(startBlock, renderDecorations) + const schema = editor.getSchema() + const decorators = document.getDescendantDecorators(startKey, schema) + const prevChars = startText.getDecorations(decorators) const prevChar = prevChars.get(startOffset - 1) const char = Character.create({ text: e.data, @@ -183,8 +184,7 @@ function Plugin(options = {}) { .apply() const nextText = next.startText - const nextBlock = next.startBlock - const nextChars = nextText.getDecoratedCharacters(nextBlock, renderDecorations) + const nextChars = nextText.getDecorations(decorators) // 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