diff --git a/examples/embeds/Readme.md b/examples/embeds/Readme.md new file mode 100644 index 000000000..a1810bcb8 --- /dev/null +++ b/examples/embeds/Readme.md @@ -0,0 +1,9 @@ + +# Images Example + +![](../../docs/images/images-example.png) + +This example shows you how you can use "void" nodes to render content that has no text in it, like images. + +Check out the [Examples readme](..) to see how to run it! + diff --git a/examples/embeds/index.js b/examples/embeds/index.js new file mode 100644 index 000000000..d42cb13bb --- /dev/null +++ b/examples/embeds/index.js @@ -0,0 +1,81 @@ + +import { Editor, Raw } from '../..' +import React from 'react' +import ReactDOM from 'react-dom' +import Video from './video' +import initialState from './state.json' + +/** + * Define a set of node renderers. + * + * @type {Object} + */ + +const NODES = { + video: Video +} + +/** + * The images example. + * + * @type {Component} + */ + +class Embeds extends React.Component { + + /** + * Deserialize the raw initial state. + * + * @type {Object} + */ + + state = { + state: Raw.deserialize(initialState, { terse: true }) + }; + + /** + * On change. + * + * @param {State} state + */ + + onChange = (state) => { + this.setState({ state }) + } + + /** + * Render the app. + * + * @return {Element} element + */ + + render = () => { + return ( +
+ +
+ ) + } + + /** + * Render a `node`. + * + * @param {Node} node + * @return {Element} + */ + + renderNode = (node) => { + return NODES[node.type] + } + +} + +/** + * Export. + */ + +export default Embeds diff --git a/examples/embeds/state.json b/examples/embeds/state.json new file mode 100644 index 000000000..160240a59 --- /dev/null +++ b/examples/embeds/state.json @@ -0,0 +1,32 @@ +{ + "nodes": [ + { + "kind": "block", + "type": "paragraph", + "nodes": [ + { + "kind": "text", + "text": "In addition to simple image nodes, you can actually create complex embedded nodes. For example, this one contains an input element that lets you change the video being rendered!" + } + ] + }, + { + "kind": "block", + "type": "video", + "isVoid": true, + "data": { + "video": "https://www.youtube.com/embed/FaHEusBG20c" + } + }, + { + "kind": "block", + "type": "paragraph", + "nodes": [ + { + "kind": "text", + "text": "Try it out! If you want another good video URL to try, go with: https://www.youtube.com/embed/6Ejga4kJUts" + } + ] + } + ] +} diff --git a/examples/embeds/video.js b/examples/embeds/video.js new file mode 100644 index 000000000..ceeeae1ec --- /dev/null +++ b/examples/embeds/video.js @@ -0,0 +1,122 @@ + +import React from 'react' +import { Void } from '../..' + +/** + * An video embed component. + * + * @type {Component} + */ + +class Video extends React.Component { + + /** + * When the input text changes, update the `video` data on the node. + * + * @param {Event} e + */ + + onChange = (e) => { + const video = e.target.value + const { node, state, editor } = this.props + const properties = { + data: { video } + } + + const next = state + .transform() + .setNodeByKey(node.key, properties) + .apply() + + editor.onChange(next) + } + + /** + * When clicks happen in the input, stop propagation so that the void node + * itself isn't focused, since that would unfocus the input. + * + * @type {Event} e + */ + + onClick = (e) => { + e.stopPropagation() + } + + /** + * Render. + * + * @return {Element} + */ + + render = () => { + return ( + + {this.renderVideo()} + {this.renderInput()} + + ) + } + + /** + * Render the Youtube iframe, responsively. + * + * @return {Element} + */ + + renderVideo = () => { + const video = this.props.node.data.get('video') + const wrapperStyle = { + position: 'relative', + paddingBottom: '66.66%', + paddingTop: '25px', + height: '0' + } + + const iframeStyle = { + position: 'absolute', + top: '0px', + left: '0px', + width: '100%', + height: '100%' + } + + return ( +
+ +
+ ) + } + + /** + * Render the video URL input. + * + * @return {Element} + */ + + renderInput = () => { + const video = this.props.node.data.get('video') + return ( + + ) + } + +} + +/** + * Export. + */ + +export default Video diff --git a/examples/index.css b/examples/index.css index 5fb414c59..ce344a0a6 100644 --- a/examples/index.css +++ b/examples/index.css @@ -1,5 +1,7 @@ -html { +html, +input, +textarea { font-family: 'Roboto', sans-serif; line-height: 1.4; background: #eee; @@ -41,6 +43,19 @@ td { border: 2px solid #ddd; } +input { + font-size: .85em; + width: 100%; + padding: .5em; + border: 2px solid #ddd; + background: #fafafa; +} + +input:focus { + outline: 0; + border-color: blue; +} + /** * Icons. */ diff --git a/examples/index.js b/examples/index.js index 62e40be77..c8bfd5f20 100644 --- a/examples/index.js +++ b/examples/index.js @@ -9,6 +9,7 @@ import { Router, Route, Link, IndexRedirect, hashHistory } from 'react-router' import AutoMarkdown from './auto-markdown' import CodeHighlighting from './code-highlighting' +import Embeds from './embeds' import HoveringMenu from './hovering-menu' import Images from './images' import Links from './links' @@ -67,6 +68,7 @@ class App extends React.Component { {this.renderTab('Hovering Menu', 'hovering-menu')} {this.renderTab('Links', 'links')} {this.renderTab('Images', 'images')} + {this.renderTab('Embeds', 'embeds')} {this.renderTab('Tables', 'tables')} {this.renderTab('Code Highlighting', 'code-highlighting')} {this.renderTab('Paste HTML', 'paste-html')} @@ -117,6 +119,7 @@ const router = ( + diff --git a/examples/test.html b/examples/test.html index da4f0a0a7..6633a2774 100644 --- a/examples/test.html +++ b/examples/test.html @@ -27,6 +27,10 @@

+
+
+ +
  • one
  • two
  • diff --git a/lib/components/content.js b/lib/components/content.js index fa5933979..4204c0b3b 100644 --- a/lib/components/content.js +++ b/lib/components/content.js @@ -155,6 +155,7 @@ class Content extends React.Component { onBeforeInput = (e) => { if (this.props.readOnly) return + if (isNonEditable(e)) return const data = {} this.props.onBeforeInput(e, data) @@ -169,6 +170,7 @@ class Content extends React.Component { onBlur = (e) => { if (this.props.readOnly) return if (this.tmp.isCopying) return + if (isNonEditable(e)) return const data = {} this.props.onBlur(e, data) @@ -191,6 +193,8 @@ class Content extends React.Component { */ onCompositionStart = (e) => { + if (isNonEditable(e)) return + this.tmp.isComposing = true this.tmp.compositions++ } @@ -204,6 +208,8 @@ class Content extends React.Component { */ onCompositionEnd = (e) => { + if (isNonEditable(e)) return + this.forces++ const count = this.tmp.compositions @@ -223,6 +229,8 @@ class Content extends React.Component { */ onCopy = (e) => { + if (isNonEditable(e)) return + this.tmp.isCopying = true window.requestAnimationFrame(() => { this.tmp.isCopying = false @@ -243,6 +251,7 @@ class Content extends React.Component { onCut = (e) => { if (this.props.readOnly) return + if (isNonEditable(e)) return this.tmp.isCopying = true window.requestAnimationFrame(() => { @@ -263,6 +272,8 @@ class Content extends React.Component { */ onDragEnd = (e) => { + if (isNonEditable(e)) return + this.tmp.isDragging = false this.tmp.isInternalDrag = null } @@ -274,6 +285,8 @@ class Content extends React.Component { */ onDragOver = (e) => { + if (isNonEditable(e)) return + const data = e.nativeEvent.dataTransfer // COMPAT: In Firefox, `types` is array-like. (2016/06/21) const types = Array.from(data.types) @@ -295,6 +308,8 @@ class Content extends React.Component { */ onDragStart = (e) => { + if (isNonEditable(e)) return + this.tmp.isDragging = true this.tmp.isInternalDrag = true const data = e.nativeEvent.dataTransfer @@ -318,6 +333,8 @@ class Content extends React.Component { onDrop = (e) => { if (this.props.readOnly) return + if (isNonEditable(e)) return + e.preventDefault() const { state, renderDecorations } = this.props @@ -403,6 +420,8 @@ class Content extends React.Component { */ onInput = (e) => { + if (isNonEditable(e)) return + let { state, renderDecorations } = this.props const { selection } = state const native = window.getSelection() @@ -454,6 +473,8 @@ class Content extends React.Component { onKeyDown = (e) => { if (this.props.readOnly) return + if (isNonEditable(e)) return + const key = keycode(e.which) const data = {} @@ -505,6 +526,8 @@ class Content extends React.Component { onPaste = (e) => { if (this.props.readOnly) return + if (isNonEditable(e)) return + e.preventDefault() const { clipboardData } = e @@ -557,6 +580,7 @@ class Content extends React.Component { if (this.tmp.isRendering) return if (this.tmp.isCopying) return if (this.tmp.isComposing) return + if (isNonEditable(e)) return const { state, renderDecorations } = this.props let { document, selection } = state @@ -695,6 +719,21 @@ class Content extends React.Component { } +/** + * Check if an `event` is being fired from inside a non-contentediable child + * element, in which case we'll want to ignore it. + * + * @param {Event} event + * @return {Boolean} + */ + +function isNonEditable(event) { + const { target, currentTarget } = event + const nonEditable = target.closest('[contenteditable="false"]:not([data-void="true"])') + const isContained = currentTarget.contains(nonEditable) + return isContained +} + /** * Export. */ diff --git a/lib/components/leaf.js b/lib/components/leaf.js index 83f0790a2..99a18e8c5 100644 --- a/lib/components/leaf.js +++ b/lib/components/leaf.js @@ -55,6 +55,7 @@ class Leaf extends React.Component { return true } + if (state.isBlurred) return false const { start, end } = OffsetKey.findBounds(index, props.ranges) return selection.hasEdgeBetween(node, start, end) } diff --git a/lib/components/node.js b/lib/components/node.js index 6458c3d41..5c82b2504 100644 --- a/lib/components/node.js +++ b/lib/components/node.js @@ -28,7 +28,7 @@ class Node extends React.Component { shouldComponentUpdate = (props) => { return ( props.node != this.props.node || - props.state.selection.hasEdgeIn(props.node) + (props.state.isFocused && props.state.selection.hasEdgeIn(props.node)) ) } diff --git a/lib/components/text.js b/lib/components/text.js index 35dea6cac..4175d145b 100644 --- a/lib/components/text.js +++ b/lib/components/text.js @@ -32,7 +32,7 @@ class Text extends React.Component { shouldComponentUpdate(props, state) { return ( props.node != this.props.node || - props.state.selection.hasEdgeIn(props.node) + (props.state.isFocused && props.state.selection.hasEdgeIn(props.node)) ) } diff --git a/lib/components/void.js b/lib/components/void.js index bf684d39f..5c629a180 100644 --- a/lib/components/void.js +++ b/lib/components/void.js @@ -47,7 +47,7 @@ class Void extends React.Component { shouldComponentUpdate = (props, state) => { return ( props.node != this.props.node || - props.state.selection.hasEdgeIn(props.node) + (props.state.isFocused && props.state.selection.hasEdgeIn(props.node)) ) } @@ -86,7 +86,11 @@ class Void extends React.Component { } return ( - + {this.renderSpacer()} - {children} + {children} ) diff --git a/lib/plugins/core.js b/lib/plugins/core.js index 5dc5125cb..024e9027c 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -365,6 +365,7 @@ function Plugin(options = {}) { */ function onKeyDownBackspace(e, data, state) { + // If expanded, delete regularly. if (state.isExpanded) { return state @@ -406,6 +407,7 @@ function Plugin(options = {}) { */ function onKeyDownDelete(e, data, state) { + // If expanded, delete regularly. if (state.isExpanded) { return state