diff --git a/examples/auto-markdown/index.js b/examples/auto-markdown/index.js index 8567ce518..789a36943 100644 --- a/examples/auto-markdown/index.js +++ b/examples/auto-markdown/index.js @@ -186,7 +186,8 @@ class App extends React.Component { onBackspace(e, state) { if (state.isCurrentlyExpanded) return if (state.currentStartOffset != 0) return - const node = state.currentWrappingNodes.first() + const node = state.currentBlockNodes.first() + if (!node) debugger if (node.type == 'paragraph') return e.preventDefault() @@ -207,7 +208,8 @@ class App extends React.Component { onEnter(e, state) { if (state.isCurrentlyExpanded) return - const node = state.currentWrappingNodes.first() + const node = state.currentBlockNodes.first() + if (!node) debugger if (state.currentStartOffset == 0 && node.length == 0) return this.onBackspace(e, state) if (state.currentEndOffset != node.length) return diff --git a/examples/auto-markdown/state.json b/examples/auto-markdown/state.json index 65a13faf7..74e6de256 100644 --- a/examples/auto-markdown/state.json +++ b/examples/auto-markdown/state.json @@ -1,10 +1,11 @@ { "nodes": [ { + "kind": "block", "type": "paragraph", "nodes": [ { - "type": "text", + "kind": "text", "ranges": [ { "text": "The editor gives you full control over the logic you can add. For example, it's fairly common to want to add markdown-like shortcuts to editors. So that, when you start a line with \"> \" you get a blockquote that looks like this:" @@ -14,10 +15,11 @@ ] }, { + "kind": "block", "type": "block-quote", "nodes": [ { - "type": "text", + "kind": "text", "ranges": [ { "text": "A wise quote." @@ -27,10 +29,11 @@ ] }, { + "kind": "block", "type": "paragraph", "nodes": [ { - "type": "text", + "kind": "text", "ranges": [ { "text": "Order when you start a line with \"## \" you get a level-two heading, like this:" @@ -40,10 +43,11 @@ ] }, { + "kind": "block", "type": "heading-two", "nodes": [ { - "type": "text", + "kind": "text", "ranges": [ { "text": "Try it out!" @@ -53,10 +57,11 @@ ] }, { + "kind": "block", "type": "paragraph", "nodes": [ { - "type": "text", + "kind": "text", "ranges": [ { "text": "Try it out for yourself! Try starting a new line with \">\", \"-\", or \"#\"s." diff --git a/examples/plain-text/index.js b/examples/plain-text/index.js index de4e3a08c..0962a2398 100644 --- a/examples/plain-text/index.js +++ b/examples/plain-text/index.js @@ -1,5 +1,5 @@ -import Editor, { Character, Document, Element, State, Text } from '../..' +import Editor, { Character, Document, Block, State, Text } from '../..' import React from 'react' import ReactDOM from 'react-dom' import state from './state.json' @@ -19,13 +19,13 @@ function deserialize(string) { }, Character.createList()) const text = Text.create({ characters }) - const texts = Element.createMap([text]) - const node = Element.create({ + const texts = Block.createMap([text]) + const node = Block.create({ type: 'paragraph', nodes: texts, }) - const nodes = Element.createMap([node]) + const nodes = Block.createMap([node]) const document = Document.create({ nodes }) const state = State.create({ document }) return state diff --git a/examples/rich-text/state.json b/examples/rich-text/state.json index dd3532c45..1e3fb93e8 100644 --- a/examples/rich-text/state.json +++ b/examples/rich-text/state.json @@ -1,10 +1,11 @@ { "nodes": [ { + "kind": "block", "type": "paragraph", "nodes": [ { - "type": "text", + "kind": "text", "ranges": [ { "text": "This is editable " @@ -47,10 +48,11 @@ ] }, { + "kind": "block", "type": "paragraph", "nodes": [ { - "type": "text", + "kind": "text", "ranges": [ { "text": "Since it's rich text, you can do things like turn a selection of text ", @@ -70,10 +72,11 @@ ] }, { + "kind": "block", "type": "block-quote", "nodes": [ { - "type": "text", + "kind": "text", "ranges": [ { "text": "A wise quote." @@ -83,10 +86,11 @@ ] }, { + "kind": "block", "type": "paragraph", "nodes": [ { - "type": "text", + "kind": "text", "ranges": [ { "text": "Try it out for yourself!" diff --git a/lib/index.js b/lib/index.js index 3c67fe22f..fad869927 100644 --- a/lib/index.js +++ b/lib/index.js @@ -10,9 +10,10 @@ export default Editor * Models. */ +export { default as Block } from './models/block' export { default as Character } from './models/character' -export { default as Element } from './models/element' export { default as Document } from './models/document' +export { default as Inline } from './models/inline' export { default as Mark } from './models/mark' export { default as Selection } from './models/selection' export { default as State } from './models/state' diff --git a/lib/models/block.js b/lib/models/block.js new file mode 100644 index 000000000..7dc547bd5 --- /dev/null +++ b/lib/models/block.js @@ -0,0 +1,97 @@ + +import Node from './node' +import uid from 'uid' +import { OrderedMap, Record } from 'immutable' + +/** + * Default properties. + */ + +const DEFAULTS = { + data: new Map(), + key: null, + nodes: new OrderedMap(), + type: null +} + +/** + * Block. + */ + +class Block extends Record(DEFAULTS) { + + /** + * Create a new `Block` with `properties`. + * + * @param {Object} properties + * @return {Block} element + */ + + static create(properties = {}) { + if (!properties.type) throw new Error('You must pass a block `type`.') + properties.key = uid(4) + let block = new Block(properties) + return block.normalize() + } + + /** + * Create an ordered map of `Blocks` from an array of `Blocks`. + * + * @param {Array} elements + * @return {OrderedMap} map + */ + + static createMap(elements = []) { + return elements.reduce((map, element) => { + return map.set(element.key, element) + }, new OrderedMap()) + } + + /** + * Get the node's kind. + * + * @return {String} kind + */ + + get kind() { + return 'block' + } + + /** + * Get the length of the concatenated text of the node. + * + * @return {Number} length + */ + + get length() { + return this.text.length + } + + /** + * Get the concatenated text `string` of all child nodes. + * + * @return {String} text + */ + + get text() { + return this.nodes + .map(node => node.text) + .join('') + } + +} + +/** + * Mix in `Node` methods. + */ + +for (const method in Node) { + Block.prototype[method] = Node[method] +} + + +/** + * Export. + */ + +export default Block diff --git a/lib/models/document.js b/lib/models/document.js index c9f696321..0dce6574b 100644 --- a/lib/models/document.js +++ b/lib/models/document.js @@ -7,7 +7,8 @@ import { OrderedMap, Record } from 'immutable' */ const DEFAULTS = { - nodes: new OrderedMap() + nodes: new OrderedMap(), + parent: null } /** @@ -24,7 +25,18 @@ class Document extends Record(DEFAULTS) { */ static create(properties = {}) { - return new Document(properties) + let document = new Document(properties) + return document.normalize() + } + + /** + * Get the node's kind. + * + * @return {String} kind + */ + + get kind() { + return 'document' } /** @@ -49,16 +61,6 @@ class Document extends Record(DEFAULTS) { .join('') } - /** - * Type. - * - * @return {String} type - */ - - get type() { - return 'document' - } - } /** diff --git a/lib/models/element.js b/lib/models/inline.js similarity index 66% rename from lib/models/element.js rename to lib/models/inline.js index bc0370dd6..5da17958c 100644 --- a/lib/models/element.js +++ b/lib/models/inline.js @@ -15,26 +15,27 @@ const DEFAULTS = { } /** - * Element. + * Inline. */ -class Element extends Record(DEFAULTS) { +class Inline extends Record(DEFAULTS) { /** - * Create a new `Element` with `properties`. + * Create a new `Inline` with `properties`. * * @param {Object} properties - * @return {Element} element + * @return {Inline} element */ static create(properties = {}) { - if (!properties.type) throw new Error('You must pass an element `type`.') + if (!properties.type) throw new Error('You must pass an inline `type`.') properties.key = uid(4) - return new Element(properties) + let inline = new Inline(properties) + return inline.normalize() } /** - * Create an ordered map of `Elements` from an array of `Elements`. + * Create an ordered map of `Inlines` from an array of `Inlines`. * * @param {Array} elements * @return {OrderedMap} map @@ -46,6 +47,16 @@ class Element extends Record(DEFAULTS) { }, new OrderedMap()) } + /** + * Get the node's kind. + * + * @return {String} kind + */ + + get kind() { + return 'inline' + } + /** * Get the length of the concatenated text of the node. * @@ -75,7 +86,7 @@ class Element extends Record(DEFAULTS) { */ for (const method in Node) { - Element.prototype[method] = Node[method] + Inline.prototype[method] = Node[method] } @@ -83,4 +94,4 @@ for (const method in Node) { * Export. */ -export default Element +export default Inline diff --git a/lib/models/node.js b/lib/models/node.js index 41d18c858..c9fc9fa17 100644 --- a/lib/models/node.js +++ b/lib/models/node.js @@ -1,6 +1,6 @@ +import Block from './block' import Character from './character' -import Element from './element' import Mark from './mark' import Selection from './selection' import Text from './text' @@ -9,8 +9,8 @@ import { List, OrderedMap, OrderedSet, Set } from 'immutable' /** * Node. * - * And interface that `Document` and `Element` both implement, to make working - * recursively easier with the tree easier. + * And interface that `Document`, `Block` and `Inline` all implement, to make + * working with the recursive node tree easier. */ const Node = { @@ -202,7 +202,7 @@ const Node = { if (shallow != null) return shallow return this.nodes - .map(node => node.type == 'text' ? null : node.findNode(iterator)) + .map(node => node.kind == 'text' ? null : node.findNode(iterator)) .filter(node => node) .first() }, @@ -217,7 +217,7 @@ const Node = { filterNodes(iterator) { const shallow = this.nodes.filter(iterator) const deep = this.nodes - .map(node => node.type == 'text' ? null : node.filterNodes(iterator)) + .map(node => node.kind == 'text' ? null : node.filterNodes(iterator)) .filter(node => node) .reduce((all, map) => { return all.concat(map) @@ -254,7 +254,7 @@ const Node = { */ getFirstTextNode() { - return this.findNode(node => node.type == 'text') || null + return this.findNode(node => node.kind == 'text') || null }, /** @@ -264,7 +264,7 @@ const Node = { */ getLastTextNode() { - const texts = this.filterNodes(node => node.type == 'text') + const texts = this.filterNodes(node => node.kind == 'text') return texts.last() || null }, @@ -335,7 +335,7 @@ const Node = { // Get all of the nodes that come before the matching child. const child = this.nodes.find((node) => { if (node == match) return true - return node.type == 'text' + return node.kind == 'text' ? false : node.hasNode(match) }) @@ -372,7 +372,7 @@ const Node = { if (shallow != null) return shallow return this.nodes - .map(node => node.type == 'text' ? null : node.getNextNode(key)) + .map(node => node.kind == 'text' ? null : node.getNextNode(key)) .filter(node => node) .first() }, @@ -396,7 +396,7 @@ const Node = { } return this.nodes - .map(node => node.type == 'text' ? null : node.getPreviousNode(key)) + .map(node => node.kind == 'text' ? null : node.getPreviousNode(key)) .filter(node => node) .first() }, @@ -412,7 +412,7 @@ const Node = { key = normalizeKey(key) // Create a new selection starting at the first text node. - const first = this.findNode(node => node.type == 'text') + const first = this.findNode(node => node.kind == 'text') const range = Selection.create({ anchorKey: first.key, anchorOffset: 0, @@ -439,7 +439,7 @@ const Node = { let node = null this.nodes.forEach((child) => { - if (child.type == 'text') return + if (child.kind == 'text') return const match = child.getParentNode(key) if (match) node = match }) @@ -455,13 +455,11 @@ const Node = { */ getTextNodeAtOffset(offset) { - let match = null - let i - - this.nodes.forEach((node) => { - if (!node.length > offset + i) return - match = node.type == 'text' ? node : node.getNodeAtOffset(offset - i) - i += node.length + let length = 0 + let texts = this.filterNodes(node => node.kind == 'text') + let match = texts.find((node) => { + length += node.length + return length >= offset }) return match @@ -477,14 +475,17 @@ const Node = { getTextNodesAtRange(range) { range = range.normalize(this) const { startKey, endKey } = range + + // If the selection isn't formed, return an empty map. if (startKey == null || endKey == null) return new OrderedMap() + // Assert that the nodes exist before searching. this.assertHasNode(startKey) this.assertHasNode(endKey) // Return the text nodes after the start offset and before the end offset. const endNode = this.getNode(endKey) - const texts = this.filterNodes(node => node.type == 'text') + const texts = this.filterNodes(node => node.kind == 'text') const afterStart = texts.skipUntil(node => node.key == startKey) const upToEnd = afterStart.takeUntil(node => node.key == endKey) let matches = upToEnd.set(endNode.key, endNode) @@ -492,22 +493,51 @@ const Node = { }, /** - * Get all of the wrapping nodes in a `range`. + * Get the closets block nodes for each text node in a `range`. * * @param {Selection} range * @return {OrderedMap} nodes */ - getWrappingNodesAtRange(range) { + getBlockNodesAtRange(range) { const node = this range = range.normalize(node) - const texts = node.getTextNodesAtRange(range) - const parents = texts.map((text) => { - return node.nodes.includes(text) ? node : node.getParentNode(text) - }) + const blocks = texts.map(text => node.getClosestBlockNode(text)) + return blocks + }, - return parents + /** + * Get the node's closest block parent node. + * + * @param {Node} node + * @return {Node} node + */ + + getClosestBlockNode(node) { + let parent = this.getParentNode(node) + + while (parent && parent.kind != 'block') { + parent = this.getParentNode(parent) + } + + return parent + }, + + /** + * Get the node's closest inline parent node. + * + * @return {Node} node + */ + + getClosestInlineNode() { + let parent = this.getParentNode(node) + + while (parent && parent.kind != 'inline') { + parent = this.getParentNode(parent) + } + + return parent }, /** @@ -524,7 +554,7 @@ const Node = { if (shallow) return true const deep = this.nodes - .map(node => node.type == 'text' ? false : node.hasNode(key)) + .map(node => node.kind == 'text' ? false : node.hasNode(key)) .some(has => has) if (deep) return true @@ -625,29 +655,31 @@ const Node = { }, /** - * Normalize the node, joining any two adjacent text child nodes. + * Normalize the node by joining any two adjacent text child nodes. * * @return {Node} node */ normalize() { let node = this - let first = node.findNode((child) => { - if (child.type != 'text') return + + // See if there are any adjacent text nodes. + let firstAdjacent = node.findNode((child) => { + if (child.kind != 'text') return const parent = node.getParentNode(child) const next = parent.getNextNode(child) - return next && next.type == 'text' + return next && next.kind == 'text' }) - // If no text node was followed by another, do nothing. - if (!first) return node + // If no text nodes are adjacent, abort. + if (!firstAdjacent) return node - // Otherwise, add the text of the second node to the first... - let parent = node.getParentNode(first) - const second = parent.getNextNode(first) - const characters = first.characters.concat(second.characters) - first = first.merge({ characters }) - parent = parent.updateNode(first) + // Fix an adjacent text node if one exists. + let parent = node.getParentNode(firstAdjacent) + const second = parent.getNextNode(firstAdjacent) + const characters = firstAdjacent.characters.concat(second.characters) + firstAdjacent = firstAdjacent.merge({ characters }) + parent = parent.updateNode(firstAdjacent) // Then remove the second node. parent = parent.removeNode(second) @@ -659,7 +691,7 @@ const Node = { node = parent } - // Finally, recurse by normalizing again. + // Recurse by normalizing again. return node.normalize() }, @@ -760,7 +792,7 @@ const Node = { // Create a brand new second element with the second set of characters. let secondText = Text.create({}) - let secondElement = Element.create({ + let secondElement = Block.create({ type: firstElement.type, data: firstElement.data }) @@ -852,7 +884,7 @@ const Node = { } const nodes = this.nodes.map((child) => { - return child.type == 'text' ? child : child.updateNode(key, node) + return child.kind == 'text' ? child : child.updateNode(key, node) }) return this.merge({ nodes }) @@ -872,7 +904,7 @@ const Node = { // Allow for the parent to by just a type. if (typeof parent == 'string') { - parent = Element.create({ type: parent }) + parent = Block.create({ type: parent }) } // Add the child to the parent's nodes. diff --git a/lib/models/selection.js b/lib/models/selection.js index d7014f931..badd03250 100644 --- a/lib/models/selection.js +++ b/lib/models/selection.js @@ -109,7 +109,7 @@ class Selection extends SelectionRecord { isAtStartOf(node) { const { startKey, startOffset } = this - const first = node.type == 'text' ? node : node.getFirstTextNode() + const first = node.kind == 'text' ? node : node.getFirstTextNode() return startKey == first.key && startOffset == 0 } @@ -122,7 +122,7 @@ class Selection extends SelectionRecord { isAtEndOf(node) { const { endKey, endOffset } = this - const last = node.type == 'text' ? node : node.getLastTextNode() + const last = node.kind == 'text' ? node : node.getLastTextNode() return endKey == last.key && endOffset == last.length } @@ -148,8 +148,8 @@ class Selection extends SelectionRecord { let focusNode = node.getNode(focusKey) // If the anchor node isn't a text node, match it to one. - if (anchorNode.type != 'text') { - anchorNode = node.getNodeAtOffset(anchorOffset) + if (anchorNode.kind != 'text') { + anchorNode = node.getTextNodeAtOffset(anchorOffset) let parent = node.getParentNode(anchorNode) let offset = parent.getNodeOffset(anchorNode) anchorOffset = anchorOffset - offset @@ -157,8 +157,8 @@ class Selection extends SelectionRecord { } // If the focus node isn't a text node, match it to one. - if (focusNode.type != 'text') { - focusNode = node.getNodeAtOffset(focusOffset) + if (focusNode.kind != 'text') { + focusNode = node.getTextNodeAtOffset(focusOffset) let parent = node.getParentNode(focusNode) let offset = parent.getNodeOffset(focusNode) focusOffset = focusOffset - offset diff --git a/lib/models/state.js b/lib/models/state.js index ab2751ddf..3e419b37e 100644 --- a/lib/models/state.js +++ b/lib/models/state.js @@ -134,13 +134,13 @@ class State extends Record(DEFAULTS) { } /** - * Get the wrapping nodes in the current selection. + * Get the block nodes in the current selection. * * @return {OrderedMap} nodes */ - get currentWrappingNodes() { - return this.document.getWrappingNodesAtRange(this.selection) + get currentBlockNodes() { + return this.document.getBlockNodesAtRange(this.selection) } /** diff --git a/lib/models/text.js b/lib/models/text.js index a8764cf97..81f22d2fc 100644 --- a/lib/models/text.js +++ b/lib/models/text.js @@ -3,19 +3,20 @@ import uid from 'uid' import { List, Record } from 'immutable' /** - * Record. + * Default properties. */ -const TextRecord = new Record({ +const DEFAULTS = { characters: new List(), - key: null -}) + key: null, + parent: null +} /** * Text. */ -class Text extends TextRecord { +class Text extends Record(DEFAULTS) { /** * Create a new `Text` with `properties`. @@ -29,6 +30,16 @@ class Text extends TextRecord { return new Text(properties) } + /** + * Get the node's kind. + * + * @return {String} kind + */ + + get kind() { + return 'text' + } + /** * Get the length of the concatenated text of the node. * @@ -51,16 +62,6 @@ class Text extends TextRecord { .join('') } - /** - * Immutable type to match other nodes. - * - * @return {String} type - */ - - get type() { - return 'text' - } - } /** diff --git a/lib/serializers/raw.js b/lib/serializers/raw.js index 3d31d3ab7..178cee7bb 100644 --- a/lib/serializers/raw.js +++ b/lib/serializers/raw.js @@ -1,10 +1,11 @@ +import Block from '../models/block' import Character from '../models/character' import Document from '../models/document' +import Inline from '../models/inline' import Mark from '../models/mark' -import Element from '../models/element' -import Text from '../models/text' import State from '../models/state' +import Text from '../models/text' import groupByMarks from '../utils/group-by-marks' import { Map } from 'immutable' @@ -29,7 +30,7 @@ function serialize(state) { */ function serializeNode(node) { - switch (node.type) { + switch (node.kind) { case 'document': { return { nodes: node.nodes.toArray().map(node => serializeNode(node)) @@ -37,15 +38,17 @@ function serializeNode(node) { } case 'text': { return { - type: 'text', + kind: node.kind, ranges: serializeCharacters(node.characters) } } - default: { + case 'block': + case 'inline': { return { - type: node.type, data: node.data.toJSON(), - nodes: node.nodes.toArray().map(node => serializeNode(node)) + kind: node.kind, + nodes: node.nodes.toArray().map(node => serializeNode(node)), + type: node.type } } } @@ -93,7 +96,7 @@ function serializeMark(mark) { function deserialize(object) { return State.create({ document: Document.create({ - nodes: Element.createMap(object.nodes.map(deserializeNode)) + nodes: Block.createMap(object.nodes.map(deserializeNode)) }) }) } @@ -106,19 +109,26 @@ function deserialize(object) { */ function deserializeNode(object) { - switch (object.type) { + switch (object.kind) { + case 'block': { + return Block.create({ + type: object.type, + data: new Map(object.data), + nodes: Block.createMap(object.nodes.map(deserializeNode)) + }) + } + case 'inline': { + return Inline.create({ + type: object.type, + data: new Map(object.data), + nodes: Inline.createMap(object.nodes.map(deserializeNode)) + }) + } case 'text': { return Text.create({ characters: deserializeRanges(object.ranges) }) } - default: { - return Element.create({ - type: object.type, - data: new Map(object.data), - nodes: Element.createMap(object.nodes.map(deserializeNode)) - }) - } } }