diff --git a/examples/rich-text/index.js b/examples/rich-text/index.js index 878118efa..c6660ace2 100644 --- a/examples/rich-text/index.js +++ b/examples/rich-text/index.js @@ -1,7 +1,8 @@ -import { Editor, Mark, Raw } from '../..' +import { Editor, Mark, Raw, Utils } from '../..' import React from 'react' import initialState from './state.json' +import keycode from 'keycode' /** * Node renderers. @@ -55,42 +56,6 @@ class RichText extends React.Component { state: Raw.deserialize(initialState) }; - hasMark = (type) => { - const { state } = this.state - return state.marks.some(mark => mark.type == type) - } - - hasBlock = (type) => { - const { state } = this.state - return state.blocks.some(node => node.type == type) - } - - onClickMark = (e, type) => { - e.preventDefault() - const isActive = this.hasMark(type) - let { state } = this.state - - state = state - .transform() - [isActive ? 'unmark' : 'mark'](type) - .apply() - - this.setState({ state }) - } - - onClickBlock = (e, type) => { - e.preventDefault() - const isActive = this.hasBlock(type) - let { state } = this.state - - state = state - .transform() - .setBlock(isActive ? 'paragraph' : type) - .apply() - - this.setState({ state }) - } - render = () => { return (
@@ -146,6 +111,7 @@ class RichText extends React.Component { renderNode={this.renderNode} renderMark={this.renderMark} onChange={this.onChange} + onKeyDown={this.onKeyDown} />
) @@ -159,6 +125,16 @@ class RichText extends React.Component { return MARKS[mark.type] } + hasMark = (type) => { + const { state } = this.state + return state.marks.some(mark => mark.type == type) + } + + hasBlock = (type) => { + const { state } = this.state + return state.blocks.some(node => node.type == type) + } + onChange = (state) => { console.groupCollapsed('Change!') console.log('Document:', state.document.toJS()) @@ -168,6 +144,63 @@ class RichText extends React.Component { this.setState({ state }) } + onKeyDown = (e, state) => { + if (!Utils.Key.isCommand(e)) return + const key = keycode(e.which) + let mark + + switch (key) { + case 'b': + mark = 'bold' + break + case 'i': + mark = 'italic' + break + case 'u': + mark = 'underlined' + break + case '`': + mark = 'code' + break + default: + return + } + + state = state + .transform() + [this.hasMark(mark) ? 'unmark' : 'mark'](mark) + .apply() + + e.preventDefault() + return state + } + + onClickMark = (e, type) => { + e.preventDefault() + const isActive = this.hasMark(type) + let { state } = this.state + + state = state + .transform() + [isActive ? 'unmark' : 'mark'](type) + .apply() + + this.setState({ state }) + } + + onClickBlock = (e, type) => { + e.preventDefault() + const isActive = this.hasBlock(type) + let { state } = this.state + + state = state + .transform() + .setBlock(isActive ? 'paragraph' : type) + .apply() + + this.setState({ state }) + } + } /** diff --git a/examples/rich-text/state.json b/examples/rich-text/state.json index 1e3fb93e8..12983598d 100644 --- a/examples/rich-text/state.json +++ b/examples/rich-text/state.json @@ -65,7 +65,7 @@ } ] },{ - "text": ", or add a semanticlly rendered block quote in the middle of the page, like this:" + "text": ", or add a semantically rendered block quote in the middle of the page, like this:" } ] } diff --git a/lib/components/content.js b/lib/components/content.js index 4a524d91d..debf7eafa 100644 --- a/lib/components/content.js +++ b/lib/components/content.js @@ -1,11 +1,18 @@ +import Key from '../utils/key' import OffsetKey from '../utils/offset-key' +import Raw from '../serializers/raw' import React from 'react' import Text from './text' import Void from './void' import keycode from 'keycode' -import { Raw } from '..' -import { isCommand, isWindowsCommand } from '../utils/event' +import { IS_FIREFOX } from '../utils/environment' + +/** + * Noop. + */ + +function noop() {} /** * Content. @@ -63,20 +70,10 @@ class Content extends React.Component { * @param {Object} props */ - componentWillMount = () => { - this.tmp.isRendering = true - } - componentWillUpdate = (props, state) => { this.tmp.isRendering = true } - componentDidMount = () => { - setTimeout(() => { - this.tmp.isRendering = false - }) - } - componentDidUpdate = (props, state) => { setTimeout(() => { this.tmp.isRendering = false @@ -214,10 +211,10 @@ class Content extends React.Component { (key == 'enter') || (key == 'backspace') || (key == 'delete') || - (key == 'b' && isCommand(e)) || - (key == 'i' && isCommand(e)) || - (key == 'y' && isWindowsCommand(e)) || - (key == 'z' && isCommand(e)) + (key == 'b' && Key.isCommand(e)) || + (key == 'i' && Key.isCommand(e)) || + (key == 'y' && Key.isWindowsCommand(e)) || + (key == 'z' && Key.isCommand(e)) ) { e.preventDefault() } @@ -234,7 +231,7 @@ class Content extends React.Component { onPaste = (e) => { e.preventDefault() const data = e.clipboardData - const { types } = data + const types = Array.from(data.types) const paste = {} // Handle files. @@ -308,13 +305,13 @@ class Content extends React.Component { state = state .transform() + .focus() .moveTo({ anchorKey: anchor.key, anchorOffset: anchor.offset, focusKey: focus.key, focusOffset: focus.offset }) - .focus() .apply({ isNative: true }) this.onChange(state) @@ -350,6 +347,7 @@ class Content extends React.Component { onKeyDown={this.onKeyDown} onPaste={this.onPaste} onSelect={this.onSelect} + onKeyUp={noop} > {children} diff --git a/lib/components/leaf.js b/lib/components/leaf.js index a44aaa3be..e4491633c 100644 --- a/lib/components/leaf.js +++ b/lib/components/leaf.js @@ -27,17 +27,22 @@ class Leaf extends React.Component { * Should component update? * * @param {Object} props - * @param {Object} state * @return {Boolean} shouldUpdate */ - shouldComponentUpdate(props, state) { - return ( + shouldComponentUpdate(props) { + const { start, end, node, state } = props + const { selection } = state + + const should = ( + selection.hasEdgeBetween(node, start, end) || props.start != this.props.start || props.end != this.props.end || props.text != this.props.text || props.marks != this.props.marks ) + + return should } componentDidMount() { @@ -55,19 +60,19 @@ class Leaf extends React.Component { // If the selection is not focused we have nothing to do. if (!selection.isFocused) return - const { anchorKey, anchorOffset, focusKey, focusOffset } = selection + const { anchorOffset, focusOffset } = selection const { node, start, end } = this.props - const { key } = node // If neither matches, the selection doesn't start or end here, so exit. - const hasStart = key == anchorKey && start <= anchorOffset && anchorOffset <= end - const hasEnd = key == focusKey && start <= focusOffset && focusOffset <= end + const hasStart = selection.hasStartBetween(node, start, end) + const hasEnd = selection.hasEndBetween(node, start, end) if (!hasStart && !hasEnd) return // We have a selection to render, so prepare a few things... const native = window.getSelection() const el = ReactDOM.findDOMNode(this).firstChild + // If both the start and end are here, set the selection all at once. if (hasStart && hasEnd) { native.removeAllRanges() diff --git a/lib/components/text.js b/lib/components/text.js index 5b66f997d..480359761 100644 --- a/lib/components/text.js +++ b/lib/components/text.js @@ -1,6 +1,5 @@ import Leaf from './leaf' -import OffsetKey from '../utils/offset-key' import React from 'react' import groupByMarks from '../utils/group-by-marks' import { List } from 'immutable' @@ -32,6 +31,7 @@ class Text extends React.Component { shouldComponentUpdate(props, state) { return ( + props.state.selection.hasEdgeIn(props.node) || props.node.decorations != this.props.node.decorations || props.node.characters != this.props.node.characters ) @@ -67,7 +67,7 @@ class Text extends React.Component { const offset = previous.size ? previous.map(r => r.text).join('').length : 0 - return this.renderLeaf(range, offset) + return this.renderLeaf(range, i, offset) }) } @@ -75,25 +75,21 @@ class Text extends React.Component { * Render a single leaf node given a `range` and `offset`. * * @param {Object} range + * @param {Number} index * @param {Number} offset * @return {Element} leaf */ - renderLeaf(range, offset) { + renderLeaf(range, index, offset) { const { node, renderMark, state } = this.props const text = range.text const marks = range.marks const start = offset const end = offset + text.length - const offsetKey = OffsetKey.stringify({ - key: node.key, - start, - end - }) return ( n.key == this.anchorKey) + } + + /** + * Check whether focus point of the selection is at the end of a `node`. + * + * @param {Node} node + * @return {Boolean} + */ + + hasFocusAtEndOf(node) { + const last = node.kind == 'text' ? node : node.getTextNodes().last() + return this.focusKey == last.key && this.focusOffset == last.length + } + + /** + * Check whether focus point of the selection is at the start of a `node`. + * + * @param {Node} node + * @return {Boolean} + */ + + hasFocusAtStartOf(node) { + const first = node.kind == 'text' ? node : node.getTextNodes().first() + return this.focusKey == first.key && this.focusOffset == 0 + } + + /** + * Check whether the focus edge of a selection is in a `node` and at an + * offset between `start` and `end`. + * + * @param {Node} node + * @param {Number} start + * @param {Number} end + * @return {Boolean} + */ + + hasFocusBetween(node, start, end) { + return ( + this.hasFocusIn(node) && + start <= this.focusOffset && + this.focusOffset <= end + ) + } + + /** + * Check whether the focus edge of a selection is in a `node`. + * + * @param {Node} node + * @return {Boolean} + */ + + hasFocusIn(node) { + const nodes = node.kind == 'text' ? [node] : node.getTextNodes() + return nodes.some(n => n.key == this.focusKey) + } + /** * Check whether the selection is at the start of a `node`. * @@ -141,38 +268,6 @@ class Selection extends new Record(DEFAULTS) { return this.isCollapsed && endKey == last.key && endOffset == last.length } - /** - * Check whether the selection has an edge at the start of a `node`. - * - * @param {Node} node - * @return {Boolean} hasEdgeAtStart - */ - - hasEdgeAtStartOf(node) { - const { startKey, startOffset, endKey, endOffset } = this - const first = node.kind == 'text' ? node : node.getTextNodes().first() - return ( - (startKey == first.key && startOffset == 0) || - (endKey == first.key && endOffset == 0) - ) - } - - /** - * Check whether the selection has an edge at the end of a `node`. - * - * @param {Node} node - * @return {Boolean} hasEdgeAtEnd - */ - - hasEdgeAtEndOf(node) { - const { startKey, startOffset, endKey, endOffset } = this - const last = node.kind == 'text' ? node : node.getTextNodes().last() - return ( - (startKey == last.key && startOffset == last.length) || - (endKey == last.key && endOffset == last.length) - ) - } - /** * Normalize the selection, relative to a `node`, ensuring that the anchor * and focus nodes of the selection always refer to leaf text nodes. @@ -221,36 +316,6 @@ class Selection extends new Record(DEFAULTS) { : anchorIndex > focusIndex } - // If the selection is expanded and has an edge on a void block, move it. - // if (isExpanded) { - // let anchorBlock = node.getClosestBlock(anchorNode) - // let focusBlock = node.getClosestBlock(focusNode) - - // if (anchorBlock.isVoid) { - // while (anchorBanchorBlock.isVoid) { - // anchorBlock = isBackward - // ? node.getPreviousBlock(anchorBlock) - // : node.getNextBlock(anchorBlock) - // } - - // anchorNode = isBackward - // ? anchorBlock.getTextNodes().last() - // : anchorBlock.getTextNodes().first() - // anchorOffset = isBackward - // ? anchorNode.length - // : 0 - // } - - // else if (focusBlock.isVoid) { - // focusNode = isBackward - // ? node.getNextBlock(focusBlock).getTextNodes().first() - // : node.getPreviousBlock(focusBlock).getTextNodes().last() - // focusOffset = isBackward - // ? 0 - // : focusNode.length - // } - // } - // Merge in any updated properties. return selection.merge({ anchorKey: anchorNode.key, @@ -285,17 +350,6 @@ class Selection extends new 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. * @@ -324,46 +378,6 @@ class Selection extends new Record(DEFAULTS) { }) } - /** - * Move the end point to the start point. - * - * @return {Selection} selection - */ - - moveToStart() { - return this.isBackward - ? this.merge({ - anchorKey: this.focusKey, - anchorOffset: this.focusOffset, - isBackward: false - }) - : this.merge({ - focusKey: this.anchorKey, - focusOffset: this.anchorOffset, - isBackward: false - }) - } - - /** - * Move the start point to the end point. - * - * @return {Selection} selection - */ - - moveToEnd() { - return this.isBackward - ? this.merge({ - focusKey: this.anchorKey, - focusOffset: this.anchorOffset, - isBackward: false - }) - : this.merge({ - anchorKey: this.focusKey, - anchorOffset: this.focusOffset, - isBackward: false - }) - } - /** * Move to the start of a `node`. * @@ -502,6 +516,41 @@ class Selection extends new Record(DEFAULTS) { } +/** + * Add start, end and edge convenience methods. + */ + +START_END_METHODS.concat(EDGE_METHODS).forEach((pattern) => { + const [ p, s ] = pattern.split('%') + const anchor = `${p}Anchor${s}` + const edge = `${p}Edge${s}` + const end = `${p}End${s}` + const focus = `${p}Focus${s}` + const start = `${p}Start${s}` + + Selection.prototype[start] = function (...args) { + return this.isBackward + ? this[focus](...args) + : this[anchor](...args) + } + + Selection.prototype[end] = function (...args) { + return this.isBackward + ? this[anchor](...args) + : this[focus](...args) + } + + if (!EDGE_METHODS.includes(pattern)) return + + Selection.prototype[edge] = function (...args) { + return this[anchor](...args) || this[focus](...args) + } +}) + +/** + * Add edge methods. + */ + /** * Export. */ diff --git a/lib/models/state.js b/lib/models/state.js index eeda0b8ec..531ac29c1 100644 --- a/lib/models/state.js +++ b/lib/models/state.js @@ -519,6 +519,31 @@ class State extends new Record(DEFAULTS) { return state } + /** + * Move the selection to a specific anchor and focus point. + * + * @param {Object} properties + * @return {State} state + */ + + moveTo(properties) { + let state = this + let { document, selection } = state + + // Pass in properties, and force `isBackward` to be re-resolved. + selection = selection.merge({ + anchorKey: properties.anchorKey, + anchorOffset: properties.anchorOffset, + focusKey: properties.focusKey, + focusOffset: properties.focusOffset, + isBackward: null + }) + + selection = selection.normalize(document) + state = state.merge({ selection }) + return state + } + /** * Move the selection to the start of the previous block. * diff --git a/lib/models/transform.js b/lib/models/transform.js index a1c0e164e..f185da26b 100644 --- a/lib/models/transform.js +++ b/lib/models/transform.js @@ -57,7 +57,6 @@ const SELECTION_TRANSFORMS = [ 'focus', 'moveBackward', 'moveForward', - 'moveTo', 'moveToAnchor', 'moveToEnd', 'moveToEndOf', @@ -78,6 +77,7 @@ const STATE_TRANSFORMS = [ 'insertFragment', 'insertText', 'mark', + 'moveTo', 'moveToStartOfPreviousBlock', 'moveToEndOfPreviousBlock', 'moveToStartOfNextBlock', diff --git a/lib/plugins/core.js b/lib/plugins/core.js index af7687985..e0f8520c9 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -1,7 +1,7 @@ +import Key from '../utils/key' import React from 'react' import keycode from 'keycode' -import { isCommand, isCtrl, isWindowsCommand, isWord } from '../utils/event' import { IS_WINDOWS, IS_MAC } from '../utils/environment' /** @@ -93,13 +93,13 @@ export default { } case 'backspace': { - return isWord(e) + return Key.isWord(e) ? transform.backspaceWord().apply() : transform.deleteBackward().apply() } case 'delete': { - return isWord(e) + return Key.isWord(e) ? transform.deleteWord().apply() : transform.deleteForward().apply() } @@ -137,13 +137,13 @@ export default { } case 'y': { - if (!isWindowsCommand(e)) return + if (!Key.isWindowsCommand(e)) return return transform.redo() } case 'z': { - if (!isCommand(e)) return - return IS_MAC && e.shiftKey + if (!Key.isCommand(e)) return + return IS_MAC && Key.isShift(e) ? transform.redo() : transform.undo() } diff --git a/lib/utils/environment.js b/lib/utils/environment.js index 3c594a932..22582c593 100644 --- a/lib/utils/environment.js +++ b/lib/utils/environment.js @@ -16,3 +16,16 @@ export const IS_MAC = process.browser && new Parser().getOS().name == 'Mac OS' export const IS_SAFARI = process.browser && browser.name == 'safari' export const IS_UBUNTU = process.browser && new Parser().getOS().name == 'Ubuntu' export const IS_WINDOWS = process.browser && new Parser().getOS().name.includes('Windows') + +export default { + IS_ANDROID, + IS_CHROME, + IS_EDGE, + IS_FIREFOX, + IS_IE, + IS_IOS, + IS_MAC, + IS_SAFARI, + IS_UBUNTU, + IS_WINDOWS +} diff --git a/lib/utils/event.js b/lib/utils/key.js similarity index 69% rename from lib/utils/event.js rename to lib/utils/key.js index 1b9e6906c..7a757ed3d 100644 --- a/lib/utils/event.js +++ b/lib/utils/key.js @@ -2,16 +2,27 @@ import { IS_MAC, IS_WINDOWS } from './environment' /** - * Does an `e` have the word-level modifier? + * Does an `e` have the alt modifier? * * @param {Event} e * @return {Boolean} */ -export function isWord(e) { +function isAlt(e) { + return e.altKey +} + +/** + * Does an `e` have the command modifier? + * + * @param {Event} e + * @return {Boolean} + */ + +function isCommand(e) { return IS_MAC - ? e.altKey - : e.ctrlKey + ? e.metaKey && !e.altKey + : e.ctrlKey && !e.altKey } /** @@ -21,10 +32,21 @@ export function isWord(e) { * @return {Boolean} */ -export function isCtrl(e) { +function isCtrl(e) { return e.ctrlKey && !e.altKey } +/** + * Does an `e` have the Mac command modifier? + * + * @param {Event} e + * @return {Boolean} + */ + +function isMacCommand(e) { + return IS_MAC && isCommand(e) +} + /** * Does an `e` have the option modifier? * @@ -32,7 +54,7 @@ export function isCtrl(e) { * @return {Boolean} */ -export function isOption(e) { +function isOption(e) { return IS_MAC && e.altKey } @@ -43,34 +65,10 @@ export function isOption(e) { * @return {Boolean} */ -export function isShift(e) { +function isShift(e) { return e.shiftKey } -/** - * Does an `e` have the command modifier? - * - * @param {Event} e - * @return {Boolean} - */ - -export function isCommand(e) { - return IS_MAC - ? e.metaKey && !e.altKey - : e.ctrlKey && !e.altKey -} - -/** - * Does an `e` have the Mac command modifier? - * - * @param {Event} e - * @return {Boolean} - */ - -export function isMacCommand(e) { - return IS_MAC && isCommand(e) -} - /** * Does an `e` have the Windows command modifier? * @@ -78,6 +76,34 @@ export function isMacCommand(e) { * @return {Boolean} */ -export function isWindowsCommand(e) { +function isWindowsCommand(e) { return IS_WINDOWS && isCommand(e) } + +/** + * Does an `e` have the word-level modifier? + * + * @param {Event} e + * @return {Boolean} + */ + +function isWord(e) { + return IS_MAC + ? e.altKey + : e.ctrlKey +} + +/** + * Export. + */ + +export default { + isAlt, + isCommand, + isCtrl, + isMacCommand, + isOption, + isShift, + isWindowsCommand, + isWord +} diff --git a/package.json b/package.json index 6291d7df2..20759022c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "license": "MIT", "repository": "git://github.com/ianstormtaylor/slate.git", - "main": "./dist/index.js", + "main": "./lib/index.js", "scripts": { "prepublish": "make dist", "test": "make check" @@ -32,6 +32,7 @@ "babelify": "^7.3.0", "browserify": "^13.0.1", "component-type": "^1.2.1", + "draft-js": "^0.7.0", "eslint": "^3.0.1", "eslint-plugin-import": "^1.10.2", "eslint-plugin-react": "^5.2.2",