From 48573e529eea56670a852981bc1262d62b68bf83 Mon Sep 17 00:00:00 2001 From: Ian Storm Taylor Date: Wed, 6 Jul 2016 14:05:35 -0700 Subject: [PATCH] add code highlighting example, still slow --- examples/code-highlighting/index.js | 147 ++++++++++++++++++++++++++ examples/code-highlighting/state.json | 46 ++++++++ examples/index.js | 5 +- lib/components/content.js | 54 +++++----- lib/components/editor.js | 59 ++++++++--- lib/components/leaf.js | 21 ++++ lib/components/text.js | 46 +++++++- lib/components/void.js | 14 --- lib/models/node.js | 15 +++ lib/models/selection.js | 11 ++ lib/models/state.js | 3 +- lib/models/text.js | 26 ++++- lib/models/transform.js | 1 + lib/models/transforms.js | 6 +- lib/plugins/core.js | 31 ++++-- package.json | 7 +- 16 files changed, 420 insertions(+), 72 deletions(-) create mode 100644 examples/code-highlighting/index.js create mode 100644 examples/code-highlighting/state.json diff --git a/examples/code-highlighting/index.js b/examples/code-highlighting/index.js new file mode 100644 index 000000000..cb2e96c5a --- /dev/null +++ b/examples/code-highlighting/index.js @@ -0,0 +1,147 @@ + +import Editor, { Mark, Raw, Selection } from '../..' +import Prism from 'prismjs' +import React from 'react' +import keycode from 'keycode' +import state from './state.json' + +/** + * Node and mark renderers. + */ + +const NODES = { + code: props =>
{props.children}
, + paragraph: props =>

{props.children}

+} + +const MARKS = { + 'highlight-comment': { + opacity: '0.33' + }, + 'highlight-keyword': { + fontWeight: 'bold' + }, + 'highlight-punctuation': { + opacity: '0.75' + } +} + +/** + * Example. + * + * @type {Component} CodeHighlighting + */ + +class CodeHighlighting extends React.Component { + + state = { + state: Raw.deserialize(state) + }; + + onKeyDown(e, state, editor) { + const key = keycode(e.which) + if (key != 'enter') return + const { startBlock } = state + if (startBlock.type != 'code') return + + let transform = state.transform() + if (state.isExpanded) transform = transform.delete() + transform = transform.insertText('\n') + + return transform.apply() + } + + render() { + return ( +
+ this.renderNode(...args)} + renderMark={(...args) => this.renderMark(...args)} + renderDecorations={(...args) => this.renderDecorations(...args)} + onKeyDown={(...args) => this.onKeyDown(...args)} + onChange={(state) => { + console.groupCollapsed('Change!') + console.log('Document:', state.document.toJS()) + console.log('Selection:', state.selection.toJS()) + console.log('Content:', Raw.serialize(state)) + console.groupEnd() + this.setState({ state }) + }} + /> +
+ ) + } + + renderNode(node) { + return NODES[node.type] + } + + renderMark(mark) { + return MARKS[mark.type] || {} + } + + renderDecorations(text, state, editor) { + let characters = text.characters + const { document } = state + const block = document.getClosestBlock(text) + if (block.type != 'code') return characters + + const string = text.text + console.log('render decorations:', string) + const grammar = Prism.languages.javascript + 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 + } + + // renderDecorations(text) { + // const { state } = this.state + // const { document } = state + // const block = document.getClosestBlock(text) + // if (block.type != 'code') return + + // const string = text.text + // if (cache[string]) return cache[string] + + // const grammar = Prism.languages.javascript + // const tokens = Prism.tokenize(string, grammar) + // const ranges = tokens.map((token) => { + // return typeof token == 'string' + // ? { text: token } + // : { + // text: token.content, + // marks: [{ type: token.type }] + // } + // }) + + // return cached[string] = ranges + // } +} + +/** + * Export. + */ + +export default CodeHighlighting diff --git a/examples/code-highlighting/state.json b/examples/code-highlighting/state.json new file mode 100644 index 000000000..8521a08ee --- /dev/null +++ b/examples/code-highlighting/state.json @@ -0,0 +1,46 @@ +{ + "nodes": [ + { + "kind": "block", + "type": "paragraph", + "nodes": [ + { + "kind": "text", + "ranges": [ + { + "text": "There are certain behaviors that require rendering dynamic marks on string of text, like rendering code highlighting. For example:" + } + ] + } + ] + }, + { + "kind": "block", + "type": "code", + "nodes": [ + { + "kind": "text", + "ranges": [ + { + "text": "// A simple FizzBuzz implementation.\nfor (var i = 1; i <= 100; i++) {\n if (i % 15 == 0) {\n console.log('Fizz Buzz');\n } else if (i % 5 == 0) {\n console.log('Buzz');\n } else if (i % 3 == 0) {\n console.log('Fizz');\n } else {\n console.log(i);\n }\n}" + } + ] + } + ] + }, + { + "kind": "block", + "type": "paragraph", + "nodes": [ + { + "kind": "text", + "ranges": [ + { + "text": "Try it out for yourself!" + } + ] + } + ] + } + ] +} diff --git a/examples/index.js b/examples/index.js index df82fa2ff..6bd1b63ce 100644 --- a/examples/index.js +++ b/examples/index.js @@ -8,6 +8,7 @@ import { Router, Route, Link, IndexRedirect, hashHistory } from 'react-router' */ import AutoMarkdown from './auto-markdown' +import CodeHighlighting from './code-highlighting' import HoveringMenu from './hovering-menu' import Images from './images' import Links from './links' @@ -32,7 +33,7 @@ class App extends React.Component { render() { return ( -
+
{this.renderTabBar()} {this.renderExample()}
@@ -55,6 +56,7 @@ class App extends React.Component { {this.renderTab('Links', 'links')} {this.renderTab('Images', 'images')} {this.renderTab('Tables', 'tables')} + {this.renderTab('Code Highlighting', 'code-highlighting')} {this.renderTab('Paste HTML', 'paste-html')}
) @@ -100,6 +102,7 @@ const router = ( + diff --git a/lib/components/content.js b/lib/components/content.js index 0e9f588c3..3888fa483 100644 --- a/lib/components/content.js +++ b/lib/components/content.js @@ -49,13 +49,11 @@ class Content extends React.Component { */ shouldComponentUpdate(props, state) { - // if (props.state.isNative) return false - return true - // return ( - // props.state != this.props.state || - // props.state.selection != this.props.state.selection || - // props.state.document != this.props.state.document - // ) + if (props.state.isNative) return false + return ( + props.state.selection != this.props.state.selection || + props.state.document != this.props.state.document + ) } /** @@ -65,22 +63,26 @@ class Content extends React.Component { * @param {Object} props */ - componentWillMount(props) { + componentWillMount() { + console.log('is rendering') this.tmp.isRendering = true } - componentWillUpdate(props) { + componentWillUpdate(props, state) { + console.log('is rendering') this.tmp.isRendering = true } componentDidMount() { setTimeout(() => { + console.log('not rendering') this.tmp.isRendering = false }) } - componentDidUpdate() { + componentDidUpdate(props, state) { setTimeout(() => { + console.log('not rendering') this.tmp.isRendering = false }) } @@ -103,11 +105,13 @@ class Content extends React.Component { onBlur(e) { if (this.tmp.isCopying) return - let { state } = this.props - let { document, selection } = state - selection = selection.merge({ isFocused: false }) - state = state.merge({ selection }) + + state = state + .transform() + .blur() + .apply({ isNative: true }) + this.onChange(state) } @@ -306,16 +310,17 @@ class Content extends React.Component { const anchor = OffsetKey.findPoint(anchorNode, anchorOffset) const focus = OffsetKey.findPoint(focusNode, focusOffset) - selection = selection.merge({ - anchorKey: anchor.key, - anchorOffset: anchor.offset, - focusKey: focus.key, - focusOffset: focus.offset, - isFocused: true - }) + state = state + .transform() + .moveTo({ + anchorKey: anchor.key, + anchorOffset: anchor.offset, + focusKey: focus.key, + focusOffset: focus.offset + }) + .focus() + .apply({ isNative: true }) - selection = selection.normalize(document) - state = state.merge({ selection }) this.onChange(state) } @@ -326,6 +331,7 @@ class Content extends React.Component { */ render() { + console.log('render contents') const { state } = this.props const { document } = state const children = document.nodes @@ -432,7 +438,7 @@ class Content extends React.Component { */ renderText(node) { - const { editor, renderMark, renderNode, state } = this.props + const { editor, renderMark, state } = this.props return ( this.onChange(state)} renderMark={mark => this.renderMark(mark)} renderNode={node => this.renderNode(node)} @@ -118,13 +123,13 @@ class Editor extends React.Component { * Render a `node`, cascading through the plugins. * * @param {Node} node - * @return {Component} component + * @return {Element} element */ renderNode(node) { for (const plugin of this.state.plugins) { if (!plugin.renderNode) continue - const component = plugin.renderNode(node, this.props.state, this) + const component = plugin.renderNode(node, this.state.state, this) if (component) return component throw new Error(`No renderer found for node with type "${node.type}".`) } @@ -140,7 +145,7 @@ class Editor extends React.Component { renderMark(mark) { for (const plugin of this.state.plugins) { if (!plugin.renderMark) continue - const style = plugin.renderMark(mark, this.props.state, this) + const style = plugin.renderMark(mark, this.state.state, this) if (style) return style throw new Error(`No renderer found for mark with type "${mark.type}".`) } @@ -169,6 +174,32 @@ 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 + } + }) + + state = state.merge({ document }) + return state + } + } /** diff --git a/lib/components/leaf.js b/lib/components/leaf.js index 669644d3e..0b465a7e9 100644 --- a/lib/components/leaf.js +++ b/lib/components/leaf.js @@ -9,6 +9,10 @@ import ReactDOM from 'react-dom' class Leaf extends React.Component { + /** + * Properties. + */ + static propTypes = { marks: React.PropTypes.object.isRequired, node: React.PropTypes.object.isRequired, @@ -19,6 +23,23 @@ class Leaf extends React.Component { text: React.PropTypes.string.isRequired }; + /** + * Should component update? + * + * @param {Object} props + * @param {Object} state + * @return {Boolean} shouldUpdate + */ + + shouldComponentUpdate(props, state) { + return ( + props.start != this.props.start || + props.end != this.props.end || + props.text != this.props.text || + props.marks != this.props.marks + ) + } + componentDidMount() { this.updateSelection() } diff --git a/lib/components/text.js b/lib/components/text.js index 90a83d0e9..040e5b89d 100644 --- a/lib/components/text.js +++ b/lib/components/text.js @@ -11,6 +11,10 @@ import { List } from 'immutable' class Text extends React.Component { + /** + * Properties. + */ + static propTypes = { editor: React.PropTypes.object.isRequired, node: React.PropTypes.object.isRequired, @@ -18,8 +22,29 @@ class Text extends React.Component { state: React.PropTypes.object.isRequired }; + /** + * Should the component update? + * + * @param {Object} props + * @param {Object} state + * @return {Boolean} shouldUpdate + */ + + shouldComponentUpdate(props, state) { + return ( + props.node.decorations != this.props.node.decorations || + props.node.characters != this.props.node.characters + ) + } + + /** + * Render. + * + * @return {Element} element + */ + render() { - const { node } = this.props + console.log('render text:', this.props.node.key) return ( {this.renderLeaves()} @@ -27,10 +52,17 @@ class Text extends React.Component { ) } + /** + * Render the leaf nodes. + * + * @return {Array} leaves + */ + renderLeaves() { const { node } = this.props - const { characters } = node - const ranges = groupByMarks(characters) + const { characters, decorations } = node + const ranges = groupByMarks(decorations || characters) + return ranges.map((range, i, ranges) => { const previous = ranges.slice(0, i) const offset = previous.size @@ -40,6 +72,14 @@ class Text extends React.Component { }) } + /** + * Render a single leaf node given a `range` and `offset`. + * + * @param {Object} range + * @param {Number} offset + * @return {Element} leaf + */ + renderLeaf(range, offset) { const { node, renderMark, state } = this.props const text = range.text diff --git a/lib/components/void.js b/lib/components/void.js index 799ac54bf..8cd4ed657 100644 --- a/lib/components/void.js +++ b/lib/components/void.js @@ -19,20 +19,6 @@ class Void extends React.Component { state: React.PropTypes.object.isRequired }; - // onClick(e) { - // e.preventDefault() - // let { editor, node, state } = this.props - // let text = node.getTextNodes().first() - - // state = state - // .transform() - // .moveToStartOf(text) - // .focus() - // .apply() - - // editor.onChange(state) - // } - render() { const { children, node } = this.props const Tag = node.kind == 'block' ? 'div' : 'span' diff --git a/lib/models/node.js b/lib/models/node.js index ea112098a..88c5fd952 100644 --- a/lib/models/node.js +++ b/lib/models/node.js @@ -58,6 +58,21 @@ const Node = { return this.merge({ nodes }) }, + /** + * Decorate all of the text nodes with a `decorator` function. + * + * @param {Function} decorator + * @return {Node} node + */ + + decorateTexts(decorator) { + return this.mapDescendants((child) => { + return child.kind == 'text' + ? child.decorateCharacters(decorator) + : child + }) + }, + /** * Recursively find all ancestor nodes by `iterator`. * diff --git a/lib/models/selection.js b/lib/models/selection.js index e607871d9..178153236 100644 --- a/lib/models/selection.js +++ b/lib/models/selection.js @@ -285,6 +285,17 @@ class Selection extends Record(DEFAULTS) { }) } + /** + * Move the selection to a specific anchor and focus point. + * + * @param {Object} properties + * @return {Selection} selection + */ + + moveTo(properties) { + return this.merge(properties) + } + /** * Move the focus point to the anchor point. * diff --git a/lib/models/state.js b/lib/models/state.js index 1ff730ec5..1b403fb3d 100644 --- a/lib/models/state.js +++ b/lib/models/state.js @@ -22,8 +22,7 @@ const DEFAULTS = { document: new Document(), selection: new Selection(), history: new History(), - isNative: true, - copiedFragment: null + isNative: false } /** diff --git a/lib/models/text.js b/lib/models/text.js index b93b22c01..4e5f0862c 100644 --- a/lib/models/text.js +++ b/lib/models/text.js @@ -10,7 +10,9 @@ import { List, Record } from 'immutable' const DEFAULTS = { characters: new List(), - key: null + decorations: null, + key: null, + cache: null } /** @@ -30,6 +32,8 @@ class Text extends Record(DEFAULTS) { if (properties instanceof Text) return properties properties.key = uid(4) properties.characters = Character.createList(properties.characters) + properties.decorations = null + properties.cache = null return new Text(properties) } @@ -65,6 +69,26 @@ class Text extends 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 = decorator(this) + if (decorations == characters) return this + + return this.merge({ + cache: characters, + decorations, + }) + } + /** * Remove characters from the text node from `start` to `end`. * diff --git a/lib/models/transform.js b/lib/models/transform.js index ef4b17d1b..f0de04491 100644 --- a/lib/models/transform.js +++ b/lib/models/transform.js @@ -57,6 +57,7 @@ const SELECTION_TRANSFORMS = [ 'focus', 'moveBackward', 'moveForward', + 'moveTo', 'moveToAnchor', 'moveToEnd', 'moveToEndOf', diff --git a/lib/models/transforms.js b/lib/models/transforms.js index 11070fcb2..3438a1cd0 100644 --- a/lib/models/transforms.js +++ b/lib/models/transforms.js @@ -552,7 +552,7 @@ const Transforms = { * Remove an existing `mark` to the characters at `range`. * * @param {Selection} range - * @param {Mark or String} mark + * @param {Mark or String} mark (optional) * @return {Node} node */ @@ -575,7 +575,9 @@ const Transforms = { let characters = text.characters.map((char, i) => { if (!isInRange(i, text, range)) return char let { marks } = char - marks = marks.remove(mark) + marks = mark + ? marks.remove(mark) + : marks.clear() return char.merge({ marks }) }) diff --git a/lib/plugins/core.js b/lib/plugins/core.js index f0d285f24..32e6a6de2 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -13,8 +13,13 @@ export default { /** * The core `onBeforeInput` handler. * - * If the current selection is collapsed, we can insert the text natively and - * avoid a re-render, improving performance. + * If the current selection is expanded, we have to re-render. + * + * If the next state resolves a new list of decorations for any of its text + * nodes, we have to re-render. + * + * Otherwise, we can allow the default, native text insertion, avoiding a + * re-render for improved performance. * * @param {Event} e * @param {State} state @@ -23,14 +28,20 @@ export default { */ onBeforeInput(e, state, editor) { - const isNative = state.isCollapsed + const transform = state.transform().insertText(e.data) + const synthetic = transform.apply() + const resolved = editor.resolveState(synthetic) - if (!isNative) e.preventDefault() + const isSynthenic = ( + state.isExpanded || + !resolved.equals(synthetic) + ) - return state - .transform() - .insertText(e.data) - .apply({ isNative }) + if (isSynthenic) e.preventDefault() + + return isSynthenic + ? synthetic + : transform.apply({ isNative: true }) }, /** @@ -126,9 +137,9 @@ export default { paste.text .split('\n') - .forEach((block, i) => { + .forEach((line, i) => { if (i > 0) transform = transform.splitBlock() - transform = transform.insertText(block) + transform = transform.insertText(line) }) return transform.apply() diff --git a/package.json b/package.json index 271887506..9868a11e6 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,13 @@ "immutable": "^3.8.1", "keycode": "^2.1.2", "lodash": "^4.13.1", - "react": "^15.1.0", "ua-parser-js": "^0.7.10", "uid": "0.0.2" }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.0", + "react-dom": "^0.14.0 || ^15.0.0" + }, "devDependencies": { "babel-cli": "^6.10.1", "babel-core": "^6.9.1", @@ -25,6 +28,8 @@ "exorcist": "^0.4.0", "mocha": "^2.5.3", "mocha-phantomjs": "^4.0.2", + "prismjs": "^1.5.1", + "react": "^15.2.0", "react-dom": "^15.1.0", "react-portal": "^2.2.0", "react-router": "^2.5.1",