diff --git a/examples/basic/index.js b/examples/basic/index.js index a9a802611..2074ac764 100644 --- a/examples/basic/index.js +++ b/examples/basic/index.js @@ -1,5 +1,5 @@ -import Editor, { State } from '../..' +import Editor, { State, Raw } from '../..' import React from 'react' import ReactDOM from 'react-dom' @@ -33,7 +33,11 @@ const state = { }, { text: 'simple', - marks: ['bold'] + marks: [ + { + type: 'bold' + } + ] }, { text: ' paragraph of text.' @@ -78,14 +82,14 @@ function renderNode(node) { } function renderMark(mark) { - switch (mark) { + switch (mark.type) { case 'bold': { return { fontWeight: 'bold' } } default: { - throw new Error(`Unknown mark type "${mark}".`) + throw new Error(`Unknown mark type "${mark.type}".`) } } } @@ -97,7 +101,7 @@ function renderMark(mark) { class App extends React.Component { state = { - state: State.create(state) + state: Raw.deserialize(state) }; render() { diff --git a/lib/components/editor.js b/lib/components/editor.js index 16897513f..16edb1084 100644 --- a/lib/components/editor.js +++ b/lib/components/editor.js @@ -1,7 +1,8 @@ import Content from './content' import React from 'react' -import CORE_PLUGIN from '../plugins/core' +import State from '../models/state' +import corePlugin from '../plugins/core' /** * Editor. @@ -19,20 +20,38 @@ class Editor extends React.Component { static defaultProps = { plugins: [], - state: {} + state: new State() }; + /** + * When created, compute the plugins from `props`. + * + * @param {Object} props + */ + constructor(props) { super(props) this.state = {} this.state.plugins = this.resolvePlugins(props) } + /** + * When the `props` are updated, recompute the plugins. + * + * @param {Object} props + */ + componentWillReceiveProps(props) { const plugins = this.resolvePlugins(props) this.setState({ plugins }) } + /** + * When the `state` changes, pass through plugins, then bubble up. + * + * @param {State} state + */ + onChange(state) { if (state == this.props.state) return @@ -57,19 +76,20 @@ class Editor extends React.Component { } /** - * Handle the `keydown` event. + * When an event by `name` fires, pass it through the plugins, and update the + * state if one of them chooses to. * + * @param {String} name * @param {Event} e */ - onKeyDown(e) { + onEvent(name, e) { for (const plugin of this.state.plugins) { - if (plugin.onKeyDown) { - const newState = plugin.onKeyDown(e, this.props.state, this) - if (newState == null) continue - this.props.onChange(newState) - break - } + if (!plugin[name]) continue + const newState = plugin[name](e, this.props.state, this) + if (!newState) continue + this.props.onChange(newState) + break } } @@ -86,15 +106,31 @@ class Editor extends React.Component { renderNode={this.props.renderNode} state={this.props.state} onChange={state => this.onChange(state)} - onKeyDown={e => this.onKeyDown(e)} + onKeyDown={e => this.onEvent('keyDown', e)} /> ) } + /** + * Resolve the editor's current plugins from `props` when they change. + * + * Add a plugin made from the editor's own `props` at the beginning of the + * stack. That way, you can add a `onKeyDown` handler to the editor itself, + * and it will override all of the existing plugins. + * + * Also add the "core" functionality plugin that handles the most basic events + * for the editor, like delete characters and such. + * + * @param {Object} props + * @return {Array} plugins + */ + resolvePlugins(props) { + const { onChange, plugins, ...editorPlugin } = props return [ - ...props.plugins, - CORE_PLUGIN + editorPlugin, + ...plugins, + corePlugin ] } diff --git a/lib/components/leaf.js b/lib/components/leaf.js index 8bd6d76a0..ec35904a2 100644 --- a/lib/components/leaf.js +++ b/lib/components/leaf.js @@ -2,7 +2,6 @@ import OffsetKey from '../utils/offset-key' import React from 'react' import ReactDOM from 'react-dom' -import createOffsetKey from '../utils/create-offset-key' /** * Leaf. @@ -11,10 +10,13 @@ import createOffsetKey from '../utils/create-offset-key' class Leaf extends React.Component { static propTypes = { + marks: React.PropTypes.array.isRequired, node: React.PropTypes.object.isRequired, - range: React.PropTypes.object.isRequired, + start: React.PropTypes.number.isRequired, + end: React.PropTypes.number.isRequired, renderMark: React.PropTypes.func.isRequired, state: React.PropTypes.object.isRequired, + text: React.PropTypes.string.isRequired }; componentDidMount() { @@ -33,11 +35,8 @@ class Leaf extends React.Component { if (!selection.isFocused) return const { anchorKey, anchorOffset, focusKey, focusOffset } = selection - const { node, range } = this.props + const { node, start, end } = this.props const { key } = node - const { offset, text } = range - const start = offset - const end = offset + text.length // If neither matches, the selection doesn't start or end here, so exit. const hasStart = key == anchorKey && start <= anchorOffset && anchorOffset <= end @@ -95,13 +94,12 @@ class Leaf extends React.Component { } render() { - const { node, range } = this.props - const { text } = range + const { node, start, end, text, marks } = this.props const styles = this.renderStyles() const offsetKey = OffsetKey.stringify({ key: node.key, - start: range.offset, - end: range.offset + range.text.length + start, + end }) return ( @@ -110,14 +108,13 @@ class Leaf extends React.Component { data-offset-key={offsetKey} data-type='leaf' > - {text == '' ?
: text} + {text ||
} ) } renderStyles() { - const { range, renderMark } = this.props - const { marks } = range + const { marks, renderMark } = this.props return marks.reduce((styles, mark) => { return { ...styles, diff --git a/lib/components/text.js b/lib/components/text.js index 3817baa50..cd111783f 100644 --- a/lib/components/text.js +++ b/lib/components/text.js @@ -1,8 +1,8 @@ import Leaf from './leaf' +import OffsetKey from '../utils/offset-key' +import Raw from '../serializers/raw' import React from 'react' -import convertCharactersToRanges from '../utils/convert-characters-to-ranges' -import createOffsetKey from '../utils/create-offset-key' /** * Text. @@ -18,33 +18,47 @@ class Text extends React.Component { render() { const { node } = this.props - const { characters } = node - const ranges = convertCharactersToRanges(characters) - const leaves = ranges.length - ? ranges.map(range => this.renderLeaf(range)) - : this.renderSpacerLeaf() - return ( - {leaves} + {this.renderLeaves()} ) } + renderLeaves() { + const { node } = this.props + const { characters } = node + const ranges = Raw.serializeCharacters(characters) + return ranges.length + ? ranges.map((range) => this.renderLeaf(range)) + : this.renderSpacerLeaf() + } + renderLeaf(range) { const { node, renderMark, state } = this.props - const key = createOffsetKey(node, range) + const { marks, offset, text } = range + const start = offset + const end = offset + text.length + const offsetKey = OffsetKey.stringify({ + key: node.key, + start, + end + }) + return ( ) } diff --git a/lib/index.js b/lib/index.js index 70ee674cd..61c364794 100644 --- a/lib/index.js +++ b/lib/index.js @@ -15,6 +15,12 @@ import Selection from './models/selection' import State from './models/state' import Text from './models/text' +/** + * Serializers. + */ + +import Raw from './serializers/raw' + /** * Export. */ @@ -23,6 +29,7 @@ export default Editor export { Character, Node, + Raw, Selection, State, Text diff --git a/lib/models/character.js b/lib/models/character.js index 2cfd5b9e3..d474378bd 100644 --- a/lib/models/character.js +++ b/lib/models/character.js @@ -17,28 +17,25 @@ const CharacterRecord = new Record({ class Character extends CharacterRecord { /** - * Create a character record from a Javascript `object`. + * Create a character record with `properties`. * - * @param {Object} object + * @param {Object} properties * @return {Character} character */ - static create(object) { - return new Character({ - text: object.text, - marks: new List(object.marks) - }) + static create(properties = {}) { + return new Character(properties) } /** - * Create a list of characters from a Javascript `array`. + * Create a characters list from an array of characters. * * @param {Array} array * @return {List} characters */ - static createList(array) { - return new List(array.map(object => Character.create(object))) + static createList(array = []) { + return new List(array) } } diff --git a/lib/models/mark.js b/lib/models/mark.js new file mode 100644 index 000000000..5c023dded --- /dev/null +++ b/lib/models/mark.js @@ -0,0 +1,48 @@ + +import { List, Map, Record } from 'immutable' + +/** + * Record. + */ + +const MarkRecord = new Record({ + data: new Map(), + type: null +}) + +/** + * Mark. + */ + +class Mark extends MarkRecord { + + /** + * Create a new `Mark` with `properties`. + * + * @param {Object} properties + * @return {Mark} mark + */ + + static create(properties = {}) { + if (!properties.type) throw new Error('You must provide a `type` for the mark.') + return new Mark(properties) + } + + /** + * Create a marks list from an array of marks. + * + * @param {Array} array + * @return {List} marks + */ + + static createList(array = []) { + return new List(array) + } + +} + +/** + * Export. + */ + +export default Mark diff --git a/lib/models/node.js b/lib/models/node.js index 34882f49c..e8b6800b4 100644 --- a/lib/models/node.js +++ b/lib/models/node.js @@ -21,36 +21,30 @@ const NodeRecord = new Record({ class Node extends NodeRecord { /** - * Create a node record from a Javascript `object`. + * Create a new `Node` with `properties`. * - * @param {Object} object + * @param {Object} properties * @return {Node} node */ - static create(object) { - return new Node({ - data: new Map(object.data || {}), - key: uid(4), - nodes: Node.createMap(object.nodes || []), - type: object.type - }) + static create(properties = {}) { + if (!properties.type) throw new Error('You must pass a node `type`.') + properties.key = uid(4) + return new Node(properties) } /** - * Create an ordered map of nodes from a Javascript `array` of nodes. + * Create an ordered map of `Nodes` from an array of `Nodes`. * - * @param {Array} array - * @return {OrderedMap} nodes + * @param {Array} nodes + * @return {OrderedMap} map */ - static createMap(array) { - return new OrderedMap(array.reduce((map, object) => { - const node = object.type == 'text' - ? Text.create(object) - : Node.create(object) - map[node.key] = node + static createMap(nodes = []) { + return nodes.reduce((map, node) => { + map = map.set(node.key, node) return map - }, {})) + }, new OrderedMap()) } /** diff --git a/lib/models/selection.js b/lib/models/selection.js index 810f23362..af4645865 100644 --- a/lib/models/selection.js +++ b/lib/models/selection.js @@ -21,13 +21,14 @@ const SelectionRecord = new Record({ class Selection extends SelectionRecord { /** - * Create a new `Selection` from `attrs`. + * Create a new `Selection` with `properties`. * + * @param {Object} properties * @return {Selection} selection */ - static create(attrs) { - return new Selection(attrs) + static create(properties = {}) { + return new Selection(properties) } /** diff --git a/lib/models/state.js b/lib/models/state.js index f904c293f..712ffa820 100644 --- a/lib/models/state.js +++ b/lib/models/state.js @@ -60,17 +60,14 @@ const SELECTION_LIKE_METHODS = [ class State extends StateRecord { /** - * Create a new `State` from a Javascript `object`. + * Create a new `State` with `properties`. * - * @param {Objetc} object + * @param {Objetc} properties * @return {State} state */ - static create(attrs) { - return new State({ - nodes: Node.createMap(attrs.nodes), - selection: Selection.create(attrs.selection) - }) + static create(properties = {}) { + return new State(properties) } /** diff --git a/lib/models/text.js b/lib/models/text.js index d90cae625..194470332 100644 --- a/lib/models/text.js +++ b/lib/models/text.js @@ -19,18 +19,15 @@ const TextRecord = new Record({ class Text extends TextRecord { /** - * Create a text record from a Javascript `object`. + * Create a new `Text` with `properties`. * - * @param {Object} object + * @param {Object} properties * @return {Node} node */ - static create(attrs) { - const characters = convertRangesToCharacters(attrs.ranges || []) - return new Text({ - key: uid(4), - characters - }) + static create(properties = {}) { + properties.key = uid(4) + return new Text(properties) } /** diff --git a/lib/plugins/core.js b/lib/plugins/core.js index 1a1cf1cd5..787b5d8de 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -3,10 +3,10 @@ import keycode from 'keycode' import { IS_WINDOWS, IS_MAC } from '../utils/environment' /** - * The core plugin. + * Export. */ -const CORE_PLUGIN = { +export default { /** * The core `onKeyDown` handler. @@ -124,9 +124,3 @@ function isCommand(e) { ? e.metaKey && !e.altKey : e.ctrlKey && !e.altKey } - -/** - * Export. - */ - -export default CORE_PLUGIN diff --git a/lib/serializers/raw.js b/lib/serializers/raw.js new file mode 100644 index 000000000..b3a2f1a29 --- /dev/null +++ b/lib/serializers/raw.js @@ -0,0 +1,179 @@ + +import Character from '../models/character' +import Mark from '../models/mark' +import Node from '../models/node' +import Text from '../models/text' +import State from '../models/state' +import xor from 'lodash/xor' +import { Map } from 'immutable' + +/** + * Serialize a `state`. + * + * @param {State} state + * @return {Object} object + */ + +function serialize(state) { + return { + nodes: state.nodes.toArray().map(node => serializeNode(node)) + } +} + +/** + * Serialize a `node`. + * + * @param {Node} node + * @return {Object} object + */ + +function serializeNode(node) { + switch (node.type) { + case 'text': { + return { + type: 'text', + ranges: serializeCharacters(node.characters) + } + } + default: { + return { + type: node.type, + data: node.data.toJSON(), + nodes: node.nodes.toArray().map(node => serializeNode(node)) + } + } + } +} + +/** + * Serialize a list of `characters`. + * + * @param {List} characters + * @return {Array} + */ + +function serializeCharacters(characters) { + return characters + .toArray() + .reduce((ranges, char, i) => { + const previous = i == 0 ? null : characters.get(i - 1) + const { text } = char + const marks = char.marks.toArray().map(mark => serializeMark(mark)) + + if (previous) { + const previousMarks = previous.marks.toArray() + const diff = xor(marks, previousMarks) + if (!diff.length) { + const previousRange = ranges[ranges.length - 1] + previousRange.text += text + return ranges + } + } + + const offset = ranges.map(range => range.text).join('').length + ranges.push({ text, marks, offset }) + return ranges + }, []) +} + +/** + * Serialize a `mark`. + * + * @param {Mark} mark + * @return {Object} Object + */ + +function serializeMark(mark) { + return { + type: mark.type, + data: mark.data.toJSON() + } +} + +/** + * Deserialize a state JSON `object`. + * + * @param {Object} object + * @return {State} state + */ + +function deserialize(object) { + return State.create({ + nodes: Node.createMap(object.nodes.map(deserializeNode)) + }) +} + +/** + * Deserialize a node JSON `object`. + * + * @param {Object} object + * @return {Node} node + */ + +function deserializeNode(object) { + switch (object.type) { + case 'text': { + return Text.create({ + characters: deserializeRanges(object.ranges) + }) + } + default: { + return Node.create({ + type: object.type, + data: new Map(object.data), + nodes: Node.createMap(object.nodes.map(deserializeNode)) + }) + } + } +} + +/** + * Deserialize a JSON `array` of ranges. + * + * @param {Array} array + * @return {List} characters + */ + +function deserializeRanges(array) { + return array.reduce((characters, object) => { + const marks = object.marks || [] + const chars = object.text + .split('') + .map(char => { + return Character.create({ + text: char, + marks: Mark.createList(marks.map(deserializeMark)) + }) + }) + + return characters.push(...chars) + }, Character.createList()) +} + +/** + * Deserialize a mark JSON `object`. + * + * @param {Object} object + * @return {Mark} mark + */ + +function deserializeMark(object) { + return Mark.create({ + type: object.type, + data: new Map(object.data) + }) +} + +/** + * Export. + */ + +export default { + serialize, + serializeCharacters, + serializeMark, + serializeNode, + deserialize, + deserializeNode, + deserializeRanges +} diff --git a/lib/utils/convert-characters-to-ranges.js b/lib/utils/convert-characters-to-ranges.js deleted file mode 100644 index 67b7320c1..000000000 --- a/lib/utils/convert-characters-to-ranges.js +++ /dev/null @@ -1,33 +0,0 @@ - -import xor from 'lodash/xor' - -/** - * Convert a `characters` list to `ranges`. - * - * @param {CharacterList} characters - * @return {Array} ranges - */ - -export default function convertCharactersToRanges(characters) { - return characters - .toArray() - .reduce((ranges, char, i) => { - const previous = i == 0 ? null : characters.get(i - 1) - const { text } = char - const marks = char.marks.toArray() - - if (previous) { - const previousMarks = previous.marks.toArray() - const diff = xor(marks, previousMarks) - if (!diff.length) { - const previousRange = ranges[ranges.length - 1] - previousRange.text += text - return ranges - } - } - - const offset = ranges.map(range => range.text).join('').length - ranges.push({ text, marks, offset }) - return ranges - }, []) -} diff --git a/lib/utils/convert-ranges-to-characters.js b/lib/utils/convert-ranges-to-characters.js deleted file mode 100644 index fdeedeaef..000000000 --- a/lib/utils/convert-ranges-to-characters.js +++ /dev/null @@ -1,23 +0,0 @@ - -import Character from '../models/character' - -/** - * Convert a `characters` list to `ranges`. - * - * @param {CharacterList} characters - * @return {Array} ranges - */ - -export default function convertRangesToCharacters(ranges) { - return Character.createList(ranges.reduce((characters, range) => { - const chars = range.text - .split('') - .map(char => { - return { - text: char, - marks: range.marks - } - }) - return characters.concat(chars) - }, [])) -} diff --git a/lib/utils/create-offset-key.js b/lib/utils/create-offset-key.js deleted file mode 100644 index 5b65c93ca..000000000 --- a/lib/utils/create-offset-key.js +++ /dev/null @@ -1,16 +0,0 @@ - -/** - * Create an offset key from a `node` and a `range`. - * - * @param {Node} node - * @param {Object} range - * @property {Number} offset - * @property {String} text - * @return {String} offsetKey - */ - -export default function createOffsetKey(node, range) { - const start = range.offset - const end = range.offset + range.text.length - return `${node.key}.${start}-${end}` -}