From 6bcac30e64d06cd74920ed002c9f4bb7e1ec6617 Mon Sep 17 00:00:00 2001 From: Ian Storm Taylor Date: Mon, 20 Jun 2016 12:57:31 -0700 Subject: [PATCH] fixing lots of marks logic --- examples/richtext/index.css | 2 +- examples/richtext/index.js | 16 +- lib/index.js | 1 + lib/models/character.js | 4 +- lib/models/mark.js | 10 +- lib/models/node.js | 364 +++++++++++++++++++++++++++++++++--- lib/models/state.js | 84 ++++++++- lib/models/transform.js | 6 +- lib/serializers/raw.js | 2 +- 9 files changed, 428 insertions(+), 61 deletions(-) diff --git a/examples/richtext/index.css b/examples/richtext/index.css index b966a6b44..e38de6985 100644 --- a/examples/richtext/index.css +++ b/examples/richtext/index.css @@ -21,7 +21,7 @@ p { .menu { margin: 0 -10px; - padding: 1px 0 9px 7px; + padding: 1px 0 9px 8px; border-bottom: 2px solid #eee; margin-bottom: 10px; } diff --git a/examples/richtext/index.js b/examples/richtext/index.js index e3ab24d27..0c615b035 100644 --- a/examples/richtext/index.js +++ b/examples/richtext/index.js @@ -1,8 +1,7 @@ -import Editor from '../..' +import Editor, { Mark, Raw } from '../..' import React from 'react' import ReactDOM from 'react-dom' -import { Raw } from '../..' /** * State. @@ -72,13 +71,7 @@ class App extends React.Component { isMarkActive(type) { const { state } = this.state const { document, selection } = state - const { startKey, startOffset } = selection - const startNode = document.getNode(startKey) - if (!startNode) return false - - const { characters } = startNode - const character = characters.get(startOffset) - const { marks } = character + const marks = document.getMarksAtRange(selection) return marks.some(mark => mark.type == type) } @@ -88,13 +81,14 @@ class App extends React.Component { let { state } = this.state const { marks } = state const isActive = this.isMarkActive(type) + const mark = Mark.create({ type }) state = state .transform() - [isActive ? 'unmark' : 'mark']() + [isActive ? 'unmark' : 'mark'](mark) .apply() - this.onChange(state) + this.setState({ state }) } render() { diff --git a/lib/index.js b/lib/index.js index d7f1ef01f..b9cab64bd 100644 --- a/lib/index.js +++ b/lib/index.js @@ -13,6 +13,7 @@ export default Editor export { default as Character } from './models/character' export { default as Element } from './models/element' export { default as Document } from './models/document' +export { default as Mark } from './models/mark' export { default as Selection } from './models/selection' export { default as State } from './models/state' export { default as Text } from './models/text' diff --git a/lib/models/character.js b/lib/models/character.js index d474378bd..bae165305 100644 --- a/lib/models/character.js +++ b/lib/models/character.js @@ -1,12 +1,12 @@ -import { List, Record } from 'immutable' +import { List, Record, Set } from 'immutable' /** * Record. */ const CharacterRecord = new Record({ - marks: new List(), + marks: new Set(), text: '' }) diff --git a/lib/models/mark.js b/lib/models/mark.js index 5c023dded..adfda2d42 100644 --- a/lib/models/mark.js +++ b/lib/models/mark.js @@ -1,5 +1,5 @@ -import { List, Map, Record } from 'immutable' +import { Map, Record, Set } from 'immutable' /** * Record. @@ -29,14 +29,14 @@ class Mark extends MarkRecord { } /** - * Create a marks list from an array of marks. + * Create a marks set from an array of marks. * * @param {Array} array - * @return {List} marks + * @return {Set} marks */ - static createList(array = []) { - return new List(array) + static createSet(array = []) { + return new Set(array) } } diff --git a/lib/models/node.js b/lib/models/node.js index 284284e5b..d2f98d194 100644 --- a/lib/models/node.js +++ b/lib/models/node.js @@ -3,7 +3,7 @@ import Character from './character' import Element from './element' import Selection from './selection' import Text from './text' -import { OrderedMap } from 'immutable' +import { List, OrderedMap, Set } from 'immutable' /** * Node. @@ -14,6 +14,16 @@ import { OrderedMap } from 'immutable' const Node = { + /** + * Assert that the node has a child by `key`. + * + * @param {String or Node} key + */ + + assertHasNode(key) { + if (!this.hasNode(key)) throw new Error('Could not find that child node.') + }, + /** * Delete everything in a `range`. * @@ -29,8 +39,8 @@ const Node = { // Make sure the children exist. const { startKey, startOffset, endKey, endOffset } = range - if (!node.hasNode(startKey)) throw new Error('Could not find that start node.') - if (!node.hasNode(endKey)) throw new Error('Could not find that end node.') + node.assertHasNode(startKey) + node.assertHasNode(endKey) let startNode = node.getNode(startKey) @@ -212,6 +222,66 @@ const Node = { return deep }, + /** + * Get a list of the characters in a `range`. + * + * @param {Selection} range + * @return {List} characters + */ + + getCharactersAtRange(range) { + const texts = this.getTextNodesAtRange(range) + let list = new List() + + texts.forEach((text) => { + let { characters } = text + characters = characters.filter((char, i) => isInRange(i, text, range)) + list = list.concat(characters) + }) + + return list + }, + + /** + * Get a set of the marks in a `range`. + * + * @param {Selection} range + * @return {Set} marks + */ + + getMarksAtRange(range) { + const { startKey, startOffset, endKey } = range + + // If the selection isn't set, return nothing. + if (startKey == null || endKey == null) return new Set() + + // If the range is collapsed, and at the start of the node, check the + // previous text node. + if (range.isCollapsed && startOffset == 0) { + const previous = this.getPreviousTextNode(startKey) + if (!previous) return new Set() + const char = text.characters.get(previous.length - 1) + return char.marks + } + + // If the range is collapsed, check the character before the start. + if (range.isCollapsed) { + const text = this.getNode(startKey) + const char = text.characters.get(range.startOffset - 1) + return char.marks + } + + // Otherwise, get a set of the marks for each character in the range. + const characters = this.getCharactersAtRange(range) + let set = new Set() + + characters.forEach((char) => { + set = set.union(char.marks) + }) + + return set + }, + /** * Get a child node by `key`. * @@ -220,9 +290,41 @@ const Node = { */ getNode(key) { + key = normalizeKey(key) return this.findNode(node => node.key == key) || null }, + /** + * Get the child text node at an `offset`. + * + * @param {String} offset + * @return {Node or Null} + */ + + getNodeOffset(key) { + this.assertHasNode(key) + const match = this.getNode(key) + + // 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' + ? false + : node.hasNode(match) + }) + + const befores = this.nodes.takeUntil(node => node.key == child.key) + + // Calculate the offset of the nodes before the matching child. + const offset = befores.map(child => child.length) + + // If the child's parent is this node, return the offset of all of the nodes + // before it, otherwise recurse. + return this.nodes.has(match.key) + ? offset + : offset + child.getNodeOffset(key) + }, + /** * Get the child node after the one by `key`. * @@ -231,9 +333,7 @@ const Node = { */ getNextNode(key) { - if (typeof key != 'string') { - key = key.key - } + key = normalizeKey(key) const shallow = this.nodes .skipUntil(node => node.key == key) @@ -256,9 +356,7 @@ const Node = { */ getPreviousNode(key) { - if (typeof key != 'string') { - key = key.key - } + key = normalizeKey(key) const matches = this.nodes.get(key) @@ -274,6 +372,30 @@ const Node = { .first() }, + /** + * Get the previous text node by `key`. + * + * @param {String or Node} key + * @return {Node or Null} + */ + + getPreviousTextNode(key) { + key = normalizeKey(key) + + // Create a new selection starting at the first text node. + const first = this.findNode(node => node.type == 'text') + const range = Selection.create({ + anchorKey: first.key, + anchorOffset: 0, + focusKey: key, + focusOffset: 0 + }) + + const texts = this.getTextNodesAtRange() + const previous = texts.get(text.size - 2) + return previous + }, + /** * Get the parent of a child node by `key`. * @@ -282,9 +404,7 @@ const Node = { */ getParentNode(key) { - if (typeof key != 'string') { - key = key.key - } + key = normalizeKey(key) if (this.nodes.get(key)) return this let node = null @@ -299,13 +419,13 @@ const Node = { }, /** - * Get the child text node at `offset`. + * Get the child text node at an `offset`. * * @param {String} offset * @return {Node or Null} */ - getNodeAtOffset(offset) { + getTextNodeAtOffset(offset) { let match = null let i @@ -318,6 +438,81 @@ const Node = { return match }, + /** + * Get the child text nodes after an `offset`. + * + * @param {String} offset + * @return {OrderedMap} matches + */ + + getTextNodesAfterOffset(offset) { + let matches = new OrderedMap() + let i + + this.nodes.forEach((child) => { + if (child.length <= offset + i) return + + matches = child.type == 'text' + ? matches.set(child.key, child) + : matches.concat(child.getTextNodesAfterOffset(offset - i)) + + i += child.length + }) + + return matches + }, + + /** + * Get the child text nodes before an `offset`. + * + * @param {String} offset + * @return {OrderedMap} matches + */ + + getTextNodesBeforeOffset(offset) { + let matches = new OrderedMap() + let i + + this.nodes.forEach((child) => { + if (child.length > offset + i) return + + matches = child.type == 'text' + ? matches.set(child.key, child) + : matches.concat(child.getTextNodesBeforeOffset(offset - i)) + + i += child.length + }) + + return matches + }, + + /** + * Get all of the text nodes in a `range`. + * + * @param {Selection} range + * @return {OrderedMap} nodes + */ + + getTextNodesAtRange(range) { + const { startKey, endKey } = range + if (startKey == null || endKey == null) return new OrderedMap() + + this.assertHasNode(startKey) + this.assertHasNode(endKey) + + // Convert the start and end nodes to offsets. + const startNode = this.getNode(startKey) + const endNode = this.getNode(endKey) + const startOffset = this.getNodeOffset(startNode) + const endOffset = this.getNodeOffset(endNode) + + // Return the text nodes after the start offset and before the end offset. + const afterStart = this.getTextNodesAfterOffset(startOffset) + const beforeEnd = this.getTextNodesBeforeOffset(endOffset) + const between = afterStart.filter(node => beforeEnd.includes(node)) + return between + }, + /** * Recursively check if a child node exists by `key`. * @@ -326,9 +521,7 @@ const Node = { */ hasNode(key) { - if (typeof key != 'string') { - key = key.key - } + key = normalizeKey(key) const shallow = this.nodes.has(key) if (shallow) return true @@ -346,7 +539,7 @@ const Node = { * * @param {Selection} range * @param {String} text - * @return {Document} node + * @return {Node} node */ insertTextAtRange(range, text) { @@ -362,9 +555,11 @@ const Node = { let startNode = node.getNode(startKey) let { characters } = startNode - // Create a list of the new characters, with the right marks. - const marks = characters.has(startOffset) - ? characters.get(startOffset).marks + // Create a list of the new characters, with the marks from the previous + // character if one exists. + const prevOffset = startOffset - 1 + const marks = characters.has(prevOffset) + ? characters.get(prevOffset).marks : null const newCharacters = text.split('').reduce((list, char) => { @@ -387,6 +582,44 @@ const Node = { return node.normalize() }, + /** + * Add a new `mark` to the characters at `range`. + * + * @param {Selection} range + * @param {Mark} mark + * @return {Node} node + */ + + markAtRange(range, mark) { + let node = this + + // When the range is collapsed, do nothing. + if (range.isCollapsed) return node + + // Otherwise, find each of the text nodes within the range. + const { startKey, startOffset, endKey, endOffset } = range + let texts = node.getTextNodesAtRange(range) + + // Apply the mark to each of the text nodes's matching characters. + texts = texts.map((text) => { + let characters = text.characters.map((char, i) => { + if (!isInRange(i, text, range)) return char + let { marks } = char + marks = marks.add(mark) + return char.merge({ marks }) + }) + + return text.merge({ characters }) + }) + + // Update each of the text nodes. + texts.forEach((text) => { + node = node.updateNode(text) + }) + + return node + }, + /** * Normalize the node, joining any two adjacent text child nodes. * @@ -430,14 +663,14 @@ const Node = { * Push a new `node` onto the map of nodes. * * @param {String or Node} key - * @param {Node} node + * @param {Node} node (optional) * @return {Node} node */ pushNode(key, node) { - if (typeof key != 'string') { + if (arguments.length == 1) { node = key - key = node.key + key = normalizeKey(key) } let nodes = this.nodes.set(key, node) @@ -452,9 +685,7 @@ const Node = { */ removeNode(key) { - if (typeof key != 'string') { - key = key.key - } + key = normalizeKey(key) let nodes = this.nodes.remove(key) return this.merge({ nodes }) @@ -520,18 +751,55 @@ const Node = { return node.normalize() }, + /** + * Remove an existing `mark` to the characters at `range`. + * + * @param {Selection} range + * @param {Mark} mark + * @return {Node} node + */ + + unmarkAtRange(range, mark) { + let node = this + + // When the range is collapsed, do nothing. + if (range.isCollapsed) return node + + // Otherwise, find each of the text nodes within the range. + let texts = node.getTextNodesAtRange(range) + + // Apply the mark to each of the text nodes's matching characters. + texts = texts.map((text) => { + let characters = text.characters.map((char, i) => { + if (!isInRange(i, text, range)) return char + let { marks } = char + marks = marks.remove(mark) + return char.merge({ marks }) + }) + + return text.merge({ characters }) + }) + + // Update each of the text nodes. + texts.forEach((text) => { + node = node.updateNode(text) + }) + + return node + }, + /** * Set a new value for a child node by `key`. * * @param {String or Node} key - * @param {Node} node + * @param {Node} node (optional) * @return {Node} node */ updateNode(key, node) { - if (typeof key != 'string') { + if (arguments.length == 1) { node = key - key = node.key + key = normalizeKey(key) } if (this.nodes.get(key)) { @@ -548,6 +816,42 @@ const Node = { } +/** + * Normalize a `key`, from a key string or a node. + * + * @param {String or Node} key + * @return {String} key + */ + +function normalizeKey(key) { + if (typeof key == 'string') return key + return key.key +} + +/** + * Check if an `index` of a `text` node is in a `range`. + * + * @param {Number} index + * @param {Text} text + * @param {Selection} range + * @return {Set} characters + */ + +function isInRange(index, text, range) { + const { startKey, startOffset, endKey, endOffset } = range + let matcher + + if (text.key == startKey && text.key == endKey) { + return startOffset <= index && index < endOffset + } else if (text.key == startKey) { + return startOffset <= index + } else if (text.key == endKey) { + return index < endOffset + } else { + return true + } +} + /** * Export. */ diff --git a/lib/models/state.js b/lib/models/state.js index 2679a350b..435ca7355 100644 --- a/lib/models/state.js +++ b/lib/models/state.js @@ -17,7 +17,7 @@ const History = new Record({ * Default properties. */ -const DEFAULT_PROPERTIES = { +const DEFAULTS = { document: new Document(), selection: new Selection(), history: new History(), @@ -25,10 +25,10 @@ const DEFAULT_PROPERTIES = { } /** - * Document-like methods, that should be mixed into the `State` prototype. + * Node-like methods that should be mixed into the `State` prototype. */ -const DOCUMENT_LIKE_METHODS = [ +const NODE_LIKE_METHODS = [ 'deleteAtRange', 'deleteBackwardAtRange', 'deleteForwardAtRange', @@ -40,12 +40,12 @@ const DOCUMENT_LIKE_METHODS = [ * State. */ -class State extends Record(DEFAULT_PROPERTIES) { +class State extends Record(DEFAULTS) { /** * Create a new `State` with `properties`. * - * @param {Objetc} properties + * @param {Object} properties * @return {State} state */ @@ -53,6 +53,39 @@ class State extends Record(DEFAULT_PROPERTIES) { return new State(properties) } + /** + * Get the characters in the current selection. + * + * @return {List} characters + */ + + get characters() { + const { document, selection } = this + return document.getCharactersAtRange(selection) + } + + /** + * Get the marks of the current selection. + * + * @return {Set} marks + */ + + get marks() { + const { document, selection } = this + return document.getMarksAtRange(selection) + } + + /** + * Get the text nodes in the current selection. + * + * @return {OrderedMap} nodes + */ + + get textNodes() { + const { document, selection } = this + return document.getTextNodesAtRange(selection) + } + /** * Return a new `Transform` with the current state as a starting point. * @@ -60,11 +93,12 @@ class State extends Record(DEFAULT_PROPERTIES) { */ transform() { - return new Transform({ state: this }) + const state = this + return new Transform({ state }) } /** - * Delete a single character. + * Delete at the current selection. * * @return {State} state */ @@ -145,7 +179,7 @@ class State extends Record(DEFAULT_PROPERTIES) { } /** - * Insert a `text` string at the current cursor position. + * Insert a `text` string at the current selection. * * @param {String} text * @return {State} state @@ -163,7 +197,22 @@ class State extends Record(DEFAULT_PROPERTIES) { } /** - * Split at a the current cursor position. + * Add a `mark` to the characters in the current selection. + * + * @param {Mark} mark + * @return {State} state + */ + + mark(mark) { + let state = this + let { document, selection } = state + document = document.markAtRange(selection, mark) + state = state.merge({ document }) + return state + } + + /** + * Split the node at the current selection. * * @return {State} state */ @@ -188,13 +237,28 @@ class State extends Record(DEFAULT_PROPERTIES) { return state } + /** + * Remove a `mark` to the characters in the current selection. + * + * @param {Mark} mark + * @return {State} state + */ + + unmark(mark) { + let state = this + let { document, selection } = state + document = document.unmarkAtRange(selection, mark) + state = state.merge({ document }) + return state + } + } /** * Mix in node-like methods. */ -DOCUMENT_LIKE_METHODS.forEach((method) => { +NODE_LIKE_METHODS.forEach((method) => { State.prototype[method] = function (...args) { let { document } = this document = document[method](...args) diff --git a/lib/models/transform.js b/lib/models/transform.js index 82c1e50a0..5b8d2336a 100644 --- a/lib/models/transform.js +++ b/lib/models/transform.js @@ -44,8 +44,12 @@ const TRANSFORM_TYPES = [ 'deleteForwardAtRange', 'insertText', 'insertTextAtRange', + 'mark', + 'markAtRange', 'split', - 'splitAtRange' + 'splitAtRange', + 'unmark', + 'unmarkAtRange' ] /** diff --git a/lib/serializers/raw.js b/lib/serializers/raw.js index 5410193e1..3d31d3ab7 100644 --- a/lib/serializers/raw.js +++ b/lib/serializers/raw.js @@ -137,7 +137,7 @@ function deserializeRanges(array) { .map(char => { return Character.create({ text: char, - marks: Mark.createList(marks.map(deserializeMark)) + marks: Mark.createSet(marks.map(deserializeMark)) }) })