1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-06 15:26:34 +02:00

add code highlighting example, still slow

This commit is contained in:
Ian Storm Taylor
2016-07-06 14:05:35 -07:00
parent aa8e295a2d
commit 48573e529e
16 changed files with 420 additions and 72 deletions

View File

@@ -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 => <pre><code>{props.children}</code></pre>,
paragraph: props => <p>{props.children}</p>
}
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 (
<div className="editor">
<Editor
state={this.state.state}
renderNode={(...args) => 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 })
}}
/>
</div>
)
}
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

View File

@@ -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!"
}
]
}
]
}
]
}

View File

@@ -8,6 +8,7 @@ import { Router, Route, Link, IndexRedirect, hashHistory } from 'react-router'
*/ */
import AutoMarkdown from './auto-markdown' import AutoMarkdown from './auto-markdown'
import CodeHighlighting from './code-highlighting'
import HoveringMenu from './hovering-menu' import HoveringMenu from './hovering-menu'
import Images from './images' import Images from './images'
import Links from './links' import Links from './links'
@@ -32,7 +33,7 @@ class App extends React.Component {
render() { render() {
return ( return (
<div class="app"> <div className="app">
{this.renderTabBar()} {this.renderTabBar()}
{this.renderExample()} {this.renderExample()}
</div> </div>
@@ -55,6 +56,7 @@ class App extends React.Component {
{this.renderTab('Links', 'links')} {this.renderTab('Links', 'links')}
{this.renderTab('Images', 'images')} {this.renderTab('Images', 'images')}
{this.renderTab('Tables', 'tables')} {this.renderTab('Tables', 'tables')}
{this.renderTab('Code Highlighting', 'code-highlighting')}
{this.renderTab('Paste HTML', 'paste-html')} {this.renderTab('Paste HTML', 'paste-html')}
</div> </div>
) )
@@ -100,6 +102,7 @@ const router = (
<Route path="/" component={App}> <Route path="/" component={App}>
<IndexRedirect to="rich-text" /> <IndexRedirect to="rich-text" />
<Route path="auto-markdown" component={AutoMarkdown} /> <Route path="auto-markdown" component={AutoMarkdown} />
<Route path="code-highlighting" component={CodeHighlighting} />
<Route path="hovering-menu" component={HoveringMenu} /> <Route path="hovering-menu" component={HoveringMenu} />
<Route path="images" component={Images} /> <Route path="images" component={Images} />
<Route path="links" component={Links} /> <Route path="links" component={Links} />

View File

@@ -49,13 +49,11 @@ class Content extends React.Component {
*/ */
shouldComponentUpdate(props, state) { shouldComponentUpdate(props, state) {
// if (props.state.isNative) return false if (props.state.isNative) return false
return true return (
// return ( props.state.selection != this.props.state.selection ||
// props.state != this.props.state || props.state.document != this.props.state.document
// 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 * @param {Object} props
*/ */
componentWillMount(props) { componentWillMount() {
console.log('is rendering')
this.tmp.isRendering = true this.tmp.isRendering = true
} }
componentWillUpdate(props) { componentWillUpdate(props, state) {
console.log('is rendering')
this.tmp.isRendering = true this.tmp.isRendering = true
} }
componentDidMount() { componentDidMount() {
setTimeout(() => { setTimeout(() => {
console.log('not rendering')
this.tmp.isRendering = false this.tmp.isRendering = false
}) })
} }
componentDidUpdate() { componentDidUpdate(props, state) {
setTimeout(() => { setTimeout(() => {
console.log('not rendering')
this.tmp.isRendering = false this.tmp.isRendering = false
}) })
} }
@@ -103,11 +105,13 @@ class Content extends React.Component {
onBlur(e) { onBlur(e) {
if (this.tmp.isCopying) return if (this.tmp.isCopying) return
let { state } = this.props let { state } = this.props
let { document, selection } = state
selection = selection.merge({ isFocused: false }) state = state
state = state.merge({ selection }) .transform()
.blur()
.apply({ isNative: true })
this.onChange(state) this.onChange(state)
} }
@@ -306,16 +310,17 @@ class Content extends React.Component {
const anchor = OffsetKey.findPoint(anchorNode, anchorOffset) const anchor = OffsetKey.findPoint(anchorNode, anchorOffset)
const focus = OffsetKey.findPoint(focusNode, focusOffset) const focus = OffsetKey.findPoint(focusNode, focusOffset)
selection = selection.merge({ state = state
anchorKey: anchor.key, .transform()
anchorOffset: anchor.offset, .moveTo({
focusKey: focus.key, anchorKey: anchor.key,
focusOffset: focus.offset, anchorOffset: anchor.offset,
isFocused: true focusKey: focus.key,
}) focusOffset: focus.offset
})
.focus()
.apply({ isNative: true })
selection = selection.normalize(document)
state = state.merge({ selection })
this.onChange(state) this.onChange(state)
} }
@@ -326,6 +331,7 @@ class Content extends React.Component {
*/ */
render() { render() {
console.log('render contents')
const { state } = this.props const { state } = this.props
const { document } = state const { document } = state
const children = document.nodes const children = document.nodes
@@ -432,7 +438,7 @@ class Content extends React.Component {
*/ */
renderText(node) { renderText(node) {
const { editor, renderMark, renderNode, state } = this.props const { editor, renderMark, state } = this.props
return ( return (
<Text <Text
key={node.key} key={node.key}

View File

@@ -10,17 +10,21 @@ import corePlugin from '../plugins/core'
class Editor extends React.Component { class Editor extends React.Component {
/**
* Properties.
*/
static propTypes = { static propTypes = {
plugins: React.PropTypes.array, plugins: React.PropTypes.array,
renderDecorations: React.PropTypes.func,
renderMark: React.PropTypes.func, renderMark: React.PropTypes.func,
renderNode: React.PropTypes.func, renderNode: React.PropTypes.func,
state: React.PropTypes.object, state: React.PropTypes.object.isRequired,
onChange: React.PropTypes.func.isRequired onChange: React.PropTypes.func.isRequired
}; };
static defaultProps = { static defaultProps = {
plugins: [], plugins: []
state: new State()
}; };
/** /**
@@ -33,6 +37,7 @@ class Editor extends React.Component {
super(props) super(props)
this.state = {} this.state = {}
this.state.plugins = this.resolvePlugins(props) this.state.plugins = this.resolvePlugins(props)
this.state.state = this.resolveState(props.state)
} }
/** /**
@@ -42,8 +47,8 @@ class Editor extends React.Component {
*/ */
componentWillReceiveProps(props) { componentWillReceiveProps(props) {
const plugins = this.resolvePlugins(props) this.setState({ plugins: this.resolvePlugins(props) })
this.setState({ plugins }) this.setState({ state: this.resolveState(props.state) })
} }
/** /**
@@ -53,7 +58,7 @@ class Editor extends React.Component {
*/ */
getState() { getState() {
return this.props.state return this.state.state
} }
/** /**
@@ -63,7 +68,7 @@ class Editor extends React.Component {
*/ */
onChange(state) { onChange(state) {
if (state == this.props.state) return if (state == this.state.state) return
for (const plugin of this.state.plugins) { for (const plugin of this.state.plugins) {
if (!plugin.onChange) continue if (!plugin.onChange) continue
@@ -86,9 +91,9 @@ class Editor extends React.Component {
onEvent(name, ...args) { onEvent(name, ...args) {
for (const plugin of this.state.plugins) { for (const plugin of this.state.plugins) {
if (!plugin[name]) continue if (!plugin[name]) continue
const newState = plugin[name](...args, this.props.state, this) const newState = plugin[name](...args, this.state.state, this)
if (!newState) continue if (!newState) continue
this.props.onChange(newState) this.onChange(newState)
break break
} }
} }
@@ -96,14 +101,14 @@ class Editor extends React.Component {
/** /**
* Render the editor. * Render the editor.
* *
* @return {Component} component * @return {Element} element
*/ */
render() { render() {
return ( return (
<Content <Content
editor={this} editor={this}
state={this.props.state} state={this.state.state}
onChange={state => this.onChange(state)} onChange={state => this.onChange(state)}
renderMark={mark => this.renderMark(mark)} renderMark={mark => this.renderMark(mark)}
renderNode={node => this.renderNode(node)} renderNode={node => this.renderNode(node)}
@@ -118,13 +123,13 @@ class Editor extends React.Component {
* Render a `node`, cascading through the plugins. * Render a `node`, cascading through the plugins.
* *
* @param {Node} node * @param {Node} node
* @return {Component} component * @return {Element} element
*/ */
renderNode(node) { renderNode(node) {
for (const plugin of this.state.plugins) { for (const plugin of this.state.plugins) {
if (!plugin.renderNode) continue 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 if (component) return component
throw new Error(`No renderer found for node with type "${node.type}".`) throw new Error(`No renderer found for node with type "${node.type}".`)
} }
@@ -140,7 +145,7 @@ class Editor extends React.Component {
renderMark(mark) { renderMark(mark) {
for (const plugin of this.state.plugins) { for (const plugin of this.state.plugins) {
if (!plugin.renderMark) continue 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 if (style) return style
throw new Error(`No renderer found for mark with type "${mark.type}".`) 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
}
} }
/** /**

View File

@@ -9,6 +9,10 @@ import ReactDOM from 'react-dom'
class Leaf extends React.Component { class Leaf extends React.Component {
/**
* Properties.
*/
static propTypes = { static propTypes = {
marks: React.PropTypes.object.isRequired, marks: React.PropTypes.object.isRequired,
node: React.PropTypes.object.isRequired, node: React.PropTypes.object.isRequired,
@@ -19,6 +23,23 @@ class Leaf extends React.Component {
text: React.PropTypes.string.isRequired 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() { componentDidMount() {
this.updateSelection() this.updateSelection()
} }

View File

@@ -11,6 +11,10 @@ import { List } from 'immutable'
class Text extends React.Component { class Text extends React.Component {
/**
* Properties.
*/
static propTypes = { static propTypes = {
editor: React.PropTypes.object.isRequired, editor: React.PropTypes.object.isRequired,
node: React.PropTypes.object.isRequired, node: React.PropTypes.object.isRequired,
@@ -18,8 +22,29 @@ class Text extends React.Component {
state: React.PropTypes.object.isRequired 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() { render() {
const { node } = this.props console.log('render text:', this.props.node.key)
return ( return (
<span> <span>
{this.renderLeaves()} {this.renderLeaves()}
@@ -27,10 +52,17 @@ class Text extends React.Component {
) )
} }
/**
* Render the leaf nodes.
*
* @return {Array} leaves
*/
renderLeaves() { renderLeaves() {
const { node } = this.props const { node } = this.props
const { characters } = node const { characters, decorations } = node
const ranges = groupByMarks(characters) const ranges = groupByMarks(decorations || characters)
return ranges.map((range, i, ranges) => { return ranges.map((range, i, ranges) => {
const previous = ranges.slice(0, i) const previous = ranges.slice(0, i)
const offset = previous.size 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) { renderLeaf(range, offset) {
const { node, renderMark, state } = this.props const { node, renderMark, state } = this.props
const text = range.text const text = range.text

View File

@@ -19,20 +19,6 @@ class Void extends React.Component {
state: React.PropTypes.object.isRequired 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() { render() {
const { children, node } = this.props const { children, node } = this.props
const Tag = node.kind == 'block' ? 'div' : 'span' const Tag = node.kind == 'block' ? 'div' : 'span'

View File

@@ -58,6 +58,21 @@ const Node = {
return this.merge({ nodes }) 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`. * Recursively find all ancestor nodes by `iterator`.
* *

View File

@@ -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. * Move the focus point to the anchor point.
* *

View File

@@ -22,8 +22,7 @@ const DEFAULTS = {
document: new Document(), document: new Document(),
selection: new Selection(), selection: new Selection(),
history: new History(), history: new History(),
isNative: true, isNative: false
copiedFragment: null
} }
/** /**

View File

@@ -10,7 +10,9 @@ import { List, Record } from 'immutable'
const DEFAULTS = { const DEFAULTS = {
characters: new List(), 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 if (properties instanceof Text) return properties
properties.key = uid(4) properties.key = uid(4)
properties.characters = Character.createList(properties.characters) properties.characters = Character.createList(properties.characters)
properties.decorations = null
properties.cache = null
return new Text(properties) return new Text(properties)
} }
@@ -65,6 +69,26 @@ class Text extends Record(DEFAULTS) {
.join('') .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`. * Remove characters from the text node from `start` to `end`.
* *

View File

@@ -57,6 +57,7 @@ const SELECTION_TRANSFORMS = [
'focus', 'focus',
'moveBackward', 'moveBackward',
'moveForward', 'moveForward',
'moveTo',
'moveToAnchor', 'moveToAnchor',
'moveToEnd', 'moveToEnd',
'moveToEndOf', 'moveToEndOf',

View File

@@ -552,7 +552,7 @@ const Transforms = {
* Remove an existing `mark` to the characters at `range`. * Remove an existing `mark` to the characters at `range`.
* *
* @param {Selection} range * @param {Selection} range
* @param {Mark or String} mark * @param {Mark or String} mark (optional)
* @return {Node} node * @return {Node} node
*/ */
@@ -575,7 +575,9 @@ const Transforms = {
let characters = text.characters.map((char, i) => { let characters = text.characters.map((char, i) => {
if (!isInRange(i, text, range)) return char if (!isInRange(i, text, range)) return char
let { marks } = char let { marks } = char
marks = marks.remove(mark) marks = mark
? marks.remove(mark)
: marks.clear()
return char.merge({ marks }) return char.merge({ marks })
}) })

View File

@@ -13,8 +13,13 @@ export default {
/** /**
* The core `onBeforeInput` handler. * The core `onBeforeInput` handler.
* *
* If the current selection is collapsed, we can insert the text natively and * If the current selection is expanded, we have to re-render.
* avoid a re-render, improving performance. *
* 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 {Event} e
* @param {State} state * @param {State} state
@@ -23,14 +28,20 @@ export default {
*/ */
onBeforeInput(e, state, editor) { 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 if (isSynthenic) e.preventDefault()
.transform()
.insertText(e.data) return isSynthenic
.apply({ isNative }) ? synthetic
: transform.apply({ isNative: true })
}, },
/** /**
@@ -126,9 +137,9 @@ export default {
paste.text paste.text
.split('\n') .split('\n')
.forEach((block, i) => { .forEach((line, i) => {
if (i > 0) transform = transform.splitBlock() if (i > 0) transform = transform.splitBlock()
transform = transform.insertText(block) transform = transform.insertText(line)
}) })
return transform.apply() return transform.apply()

View File

@@ -7,10 +7,13 @@
"immutable": "^3.8.1", "immutable": "^3.8.1",
"keycode": "^2.1.2", "keycode": "^2.1.2",
"lodash": "^4.13.1", "lodash": "^4.13.1",
"react": "^15.1.0",
"ua-parser-js": "^0.7.10", "ua-parser-js": "^0.7.10",
"uid": "0.0.2" "uid": "0.0.2"
}, },
"peerDependencies": {
"react": "^0.14.0 || ^15.0.0",
"react-dom": "^0.14.0 || ^15.0.0"
},
"devDependencies": { "devDependencies": {
"babel-cli": "^6.10.1", "babel-cli": "^6.10.1",
"babel-core": "^6.9.1", "babel-core": "^6.9.1",
@@ -25,6 +28,8 @@
"exorcist": "^0.4.0", "exorcist": "^0.4.0",
"mocha": "^2.5.3", "mocha": "^2.5.3",
"mocha-phantomjs": "^4.0.2", "mocha-phantomjs": "^4.0.2",
"prismjs": "^1.5.1",
"react": "^15.2.0",
"react-dom": "^15.1.0", "react-dom": "^15.1.0",
"react-portal": "^2.2.0", "react-portal": "^2.2.0",
"react-router": "^2.5.1", "react-router": "^2.5.1",