1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-07-31 20:40:19 +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 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 (
<div class="app">
<div className="app">
{this.renderTabBar()}
{this.renderExample()}
</div>
@@ -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')}
</div>
)
@@ -100,6 +102,7 @@ const router = (
<Route path="/" component={App}>
<IndexRedirect to="rich-text" />
<Route path="auto-markdown" component={AutoMarkdown} />
<Route path="code-highlighting" component={CodeHighlighting} />
<Route path="hovering-menu" component={HoveringMenu} />
<Route path="images" component={Images} />
<Route path="links" component={Links} />

View File

@@ -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 (
<Text
key={node.key}

View File

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

View File

@@ -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()
}

View File

@@ -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 (
<span>
{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

View File

@@ -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'

View File

@@ -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`.
*

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.
*

View File

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

View File

@@ -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`.
*

View File

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

View File

@@ -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 })
})

View File

@@ -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()

View File

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