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",