From ff25061ec0dc101a5ba4c03909e09ae200af66ff Mon Sep 17 00:00:00 2001 From: "Aeneas Rekkas (arekkas)" Date: Sun, 13 Nov 2016 16:53:31 +0100 Subject: [PATCH 1/4] resolve leaf component issues with focus and blur transformations - closes #297 --- src/components/leaf.js | 64 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/src/components/leaf.js b/src/components/leaf.js index d648b4306..96352b009 100644 --- a/src/components/leaf.js +++ b/src/components/leaf.js @@ -5,6 +5,24 @@ import React from 'react' import ReactDOM from 'react-dom' import getWindow from 'get-window' +/** + * Tests if the child is a descendant of parent. + * + * @param parent + * @param child + * @returns {boolean} + */ + +function isDescendant(parent, child) { + if (!child || !parent) return false + let node = child.parentNode + while (node != null) { + if (node == parent) return true + node = node.parentNode + } + return false +} + /** * Debugger. * @@ -136,9 +154,6 @@ class Leaf extends React.Component { const { state, ranges, isVoid } = this.props const { selection } = state - // If the selection is blurred we have nothing to do. - if (selection.isBlurred) return - let { anchorOffset, focusOffset } = selection const { node, index } = this.props const { start, end } = OffsetKey.findBounds(index, ranges) @@ -148,6 +163,36 @@ class Leaf extends React.Component { const hasFocus = selection.hasFocusBetween(node, start, end) if (!hasAnchor && !hasFocus) return + // We have a selection to render, so prepare a few things... + const ref = ReactDOM.findDOMNode(this) + const el = findDeepestNode(ref) + const window = getWindow(el) + + // If no selection is defined we're probably in a node environment and we can skip the selection magic. + if (!window || !window.getSelection) return + + const native = window.getSelection() + const parent = ref.closest('[contenteditable]') + + // If the selection is blurred but the DOM selection still focuses this leaf, + // we need to clean up the ranges and blur the contenteditable. + if (selection.isBlurred && (hasAnchor || hasFocus)) { + // We need to make sure that the selection from our state is up-to-date with the native selection. + // We can do this by checking if native.anchorNode is a descendant of our contenteditable parent, which is this + // slate instance. If it's not, the selection is no longer under this instance's responsibility and not further + // action is required. + if (!isDescendant(parent, native.anchorNode)) return + + // Apparently our selection is blurred, but the DOM still has ranges in the nodes we manage. To fix this, + // we blur the contenteditable and remove all ranges. + native.removeAllRanges() + if (parent) parent.blur() + return + } + + // If the selection is blurred we have nothing to do. + if (selection.isBlurred) return + // If the leaf is a void leaf, ensure that it has no width. This is due to // void nodes always rendering an empty leaf, for browser compatibility. if (isVoid) { @@ -155,10 +200,10 @@ class Leaf extends React.Component { focusOffset = 0 } - // We have a selection to render, so prepare a few things... - const el = findDeepestNode(ReactDOM.findDOMNode(this)) - const window = getWindow(el) - const native = window.getSelection() + // In firefox it is not enough to create a range, you also need to focus the contenteditable element. + function focus() { + if (parent) setTimeout(() => parent.focus(), 0) + } // If both the start and end are here, set the selection all at once. if (hasAnchor && hasFocus) { @@ -167,6 +212,7 @@ class Leaf extends React.Component { range.setStart(el, anchorOffset - start) native.addRange(range) native.extend(el, focusOffset - start) + focus() return } @@ -179,8 +225,10 @@ class Leaf extends React.Component { const range = window.document.createRange() range.setStart(el, anchorOffset - start) native.addRange(range) + focus() } else if (hasFocus) { native.extend(el, focusOffset - start) + focus() } } @@ -194,6 +242,7 @@ class Leaf extends React.Component { const range = window.document.createRange() range.setStart(el, focusOffset - start) native.addRange(range) + focus() } else if (hasAnchor) { const endNode = native.focusNode const endOffset = native.focusOffset @@ -202,6 +251,7 @@ class Leaf extends React.Component { range.setStart(el, anchorOffset - start) native.addRange(range) native.extend(endNode, endOffset) + focus() } } From d9bb0dd72c9f16ba8ac347abc217ddf023031e10 Mon Sep 17 00:00:00 2001 From: "Aeneas Rekkas (arekkas)" Date: Wed, 16 Nov 2016 13:02:58 +0100 Subject: [PATCH 2/4] move blur check to content component - closes #297 --- src/components/content.js | 58 ++++++++++++++++++++++++++++++++++++--- src/components/leaf.js | 58 +++++++-------------------------------- 2 files changed, 64 insertions(+), 52 deletions(-) diff --git a/src/components/content.js b/src/components/content.js index f81326af4..7957c5a60 100644 --- a/src/components/content.js +++ b/src/components/content.js @@ -4,6 +4,7 @@ import Debug from 'debug' import Node from './node' import OffsetKey from '../utils/offset-key' import React from 'react' +import ReactDOM from 'react-dom' import Selection from '../models/selection' import Transfer from '../utils/transfer' import TYPES from '../constants/types' @@ -28,6 +29,37 @@ const debug = Debug('slate:content') function noop() {} +/** + * Find the deepest descendant of a DOM `element`. + * + * @param {Element} node + * @return {Element} + */ + +function findDeepestNode(element) { + return element.firstChild + ? findDeepestNode(element.firstChild) + : element +} + +/** + * Tests if the child is a descendant of parent. + * + * @param parent + * @param child + * @returns {boolean} + */ + +function isDescendant(parent, child) { + if (!child || !parent) return false + let node = child.parentNode + while (node != null) { + if (node == parent) return true + node = node.parentNode + } + return false +} + /** * Content. * @@ -122,16 +154,34 @@ class Content extends React.Component { } /** - * When finished rendering, move the `isRendering` flag on next tick. + * When finished rendering, move the `isRendering` flag on next tick and clean up the DOM's activeElement + * if neccessary. * - * @param {Object} props - * @param {Object} state + * @param {Object} prevProps + * @param {Object} prevState */ - componentDidUpdate = (props, state) => { + componentDidUpdate = (prevProps, prevState) => { setTimeout(() => { this.tmp.isRendering = false }, 1) + + // If this component was focused last render, but is not now we might need to clean up the activeElement. + if (this.props.state.isBlurred && prevProps.state.isFocused) { + // Get the current selection + const ref = ReactDOM.findDOMNode(this) + const el = findDeepestNode(ref) + const window = getWindow(el) + const native = window.getSelection() + + // We need to make sure that the selection from our state is up-to-date with the native selection. + // We can do this by checking if native.anchorNode is a descendant of our this node. + if (isDescendant(ref, native.anchorNode)) { + // The selection was blurred, but the DOM selection state has not changed so we need to do this manually: + native.removeAllRanges() + if (ref) ref.blur() + } + } } /** diff --git a/src/components/leaf.js b/src/components/leaf.js index 96352b009..947296302 100644 --- a/src/components/leaf.js +++ b/src/components/leaf.js @@ -5,24 +5,6 @@ import React from 'react' import ReactDOM from 'react-dom' import getWindow from 'get-window' -/** - * Tests if the child is a descendant of parent. - * - * @param parent - * @param child - * @returns {boolean} - */ - -function isDescendant(parent, child) { - if (!child || !parent) return false - let node = child.parentNode - while (node != null) { - if (node == parent) return true - node = node.parentNode - } - return false -} - /** * Debugger. * @@ -154,6 +136,9 @@ class Leaf extends React.Component { const { state, ranges, isVoid } = this.props const { selection } = state + // If the selection is blurred we have nothing to do. + if (selection.isBlurred) return + let { anchorOffset, focusOffset } = selection const { node, index } = this.props const { start, end } = OffsetKey.findBounds(index, ranges) @@ -163,36 +148,6 @@ class Leaf extends React.Component { const hasFocus = selection.hasFocusBetween(node, start, end) if (!hasAnchor && !hasFocus) return - // We have a selection to render, so prepare a few things... - const ref = ReactDOM.findDOMNode(this) - const el = findDeepestNode(ref) - const window = getWindow(el) - - // If no selection is defined we're probably in a node environment and we can skip the selection magic. - if (!window || !window.getSelection) return - - const native = window.getSelection() - const parent = ref.closest('[contenteditable]') - - // If the selection is blurred but the DOM selection still focuses this leaf, - // we need to clean up the ranges and blur the contenteditable. - if (selection.isBlurred && (hasAnchor || hasFocus)) { - // We need to make sure that the selection from our state is up-to-date with the native selection. - // We can do this by checking if native.anchorNode is a descendant of our contenteditable parent, which is this - // slate instance. If it's not, the selection is no longer under this instance's responsibility and not further - // action is required. - if (!isDescendant(parent, native.anchorNode)) return - - // Apparently our selection is blurred, but the DOM still has ranges in the nodes we manage. To fix this, - // we blur the contenteditable and remove all ranges. - native.removeAllRanges() - if (parent) parent.blur() - return - } - - // If the selection is blurred we have nothing to do. - if (selection.isBlurred) return - // If the leaf is a void leaf, ensure that it has no width. This is due to // void nodes always rendering an empty leaf, for browser compatibility. if (isVoid) { @@ -200,6 +155,13 @@ class Leaf extends React.Component { focusOffset = 0 } + // We have a selection to render, so prepare a few things... + const ref = ReactDOM.findDOMNode(this) + const el = findDeepestNode(ref) + const window = getWindow(el) + const native = window.getSelection() + const parent = ref.closest('[contenteditable]') + // In firefox it is not enough to create a range, you also need to focus the contenteditable element. function focus() { if (parent) setTimeout(() => parent.focus(), 0) From 3aff3cbf91fa03da4588b1c7e6a25c527871996b Mon Sep 17 00:00:00 2001 From: Ian Storm Taylor Date: Wed, 16 Nov 2016 17:25:27 -0800 Subject: [PATCH 3/4] add focus-blur example --- examples/focus-blur/Readme.md | 8 ++ examples/focus-blur/index.js | 145 +++++++++++++++++++++++++++++++++ examples/focus-blur/state.json | 24 ++++++ examples/index.css | 3 +- examples/index.js | 3 + 5 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 examples/focus-blur/Readme.md create mode 100644 examples/focus-blur/index.js create mode 100644 examples/focus-blur/state.json diff --git a/examples/focus-blur/Readme.md b/examples/focus-blur/Readme.md new file mode 100644 index 000000000..04b913be4 --- /dev/null +++ b/examples/focus-blur/Readme.md @@ -0,0 +1,8 @@ + +# Links Example + +![](../../docs/images/links-example.png) + +This example shows you how you can wrap text in "inline" nodes to associate metadata, like an `href`, with a piece of text. This is how you'd add links to Slate, but it's also how you might add hashtags, at-mentions, and many more inline features! + +Check out the [Examples readme](..) to see how to run it! diff --git a/examples/focus-blur/index.js b/examples/focus-blur/index.js new file mode 100644 index 000000000..311be90d6 --- /dev/null +++ b/examples/focus-blur/index.js @@ -0,0 +1,145 @@ + +import { Editor, Mark, Raw } from '../..' +import React from 'react' +import ReactDOM from 'react-dom' +import initialState from './state.json' +import isUrl from 'is-url' +import { Map } from 'immutable' + +/** + * Define a schema. + * + * @type {Object} + */ + +const schema = { + nodes: { + paragraph: props =>

{props.children}

+ } +} + +/** + * The focus and blur example. + * + * @type {Component} + */ + +class FocusBlur 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 }) + } + + /** + * Apply a focus or blur transform by `name` after a `timeout`. + * + * @param {String} name + * @param {Number} timeout + */ + + onClick = (e, name, timeout = 0) => { + e.preventDefault() + + setTimeout(() => { + const state = this.state.state + .transform() + [name]() + .apply() + + this.setState({ state }) + }, timeout) + } + + /** + * Generate focus and blur button handlers. + * + * @param {Event} e + */ + + onClickFocus = e => this.onClick(e, 'focus') + onClickFocusDelay = e => this.onClick(e, 'focus', 3000) + onClickBlur = e => this.onClick(e, 'blur') + onClickBlurDelay = e => this.onClick(e, 'blur', 3000) + + /** + * Render the app. + * + * @return {Element} element + */ + + render = () => { + return ( +
+ {this.renderToolbar()} + {this.renderEditor()} +
+ ) + } + + /** + * Render the toolbar. + * + * @return {Element} element + */ + + renderToolbar = () => { + return ( +
+ + done Focus + + + timer Focus + + + done Blur + + + timer Blur + +
+ ) + } + + /** + * Render the editor. + * + * @return {Element} element + */ + + renderEditor = () => { + return ( +
+ +
+ ) + } + +} + +/** + * Export. + */ + +export default FocusBlur diff --git a/examples/focus-blur/state.json b/examples/focus-blur/state.json new file mode 100644 index 000000000..7338c109b --- /dev/null +++ b/examples/focus-blur/state.json @@ -0,0 +1,24 @@ +{ + "nodes": [ + { + "kind": "block", + "type": "paragraph", + "nodes": [ + { + "kind": "text", + "text": "This is a testing ground for focusing and blurring in Slate." + } + ] + }, + { + "kind": "block", + "type": "paragraph", + "nodes": [ + { + "kind": "text", + "text": "You can use the toolbar buttons above to focus and blur immediately, or after a short delay." + } + ] + } + ] +} diff --git a/examples/index.css b/examples/index.css index ce2ab9406..94ddda65f 100644 --- a/examples/index.css +++ b/examples/index.css @@ -79,6 +79,7 @@ input:focus { .material-icons { font-size: 18px; + vertical-align: text-bottom; } /** @@ -126,7 +127,7 @@ input:focus { } .menu > * + * { - margin-left: 10px; + margin-left: 15px; } .button { diff --git a/examples/index.js b/examples/index.js index cb938132b..5b88ab660 100644 --- a/examples/index.js +++ b/examples/index.js @@ -11,6 +11,7 @@ import AutoMarkdown from './auto-markdown' import CodeHighlighting from './code-highlighting' import Embeds from './embeds' import Emojis from './emojis' +import FocusBlur from './focus-blur' import HoveringMenu from './hovering-menu' import Iframes from './iframes' import Images from './images' @@ -81,6 +82,7 @@ class App extends React.Component { {this.renderTab('RTL', 'rtl')} {this.renderTab('Plugins', 'plugins')} {this.renderTab('Iframes', 'iframes')} + {this.renderTab('Focus & Blur', 'focus-blur')} ) } @@ -129,6 +131,7 @@ const router = ( + From f7428cc93ccf740d764253f602ddacc4dc61ab4e Mon Sep 17 00:00:00 2001 From: Ian Storm Taylor Date: Wed, 16 Nov 2016 17:26:12 -0800 Subject: [PATCH 4/4] fix style, refactor noop --- src/components/content.js | 61 +++++-------------------------- src/components/editor.js | 9 +---- src/components/leaf.js | 77 ++++++++++++++++++++------------------- src/components/void.js | 9 +---- src/utils/noop.js | 14 +++++++ 5 files changed, 64 insertions(+), 106 deletions(-) create mode 100644 src/utils/noop.js diff --git a/src/components/content.js b/src/components/content.js index 7957c5a60..a2eb6f4ff 100644 --- a/src/components/content.js +++ b/src/components/content.js @@ -11,6 +11,7 @@ import TYPES from '../constants/types' import getWindow from 'get-window' import includes from 'lodash/includes' import keycode from 'keycode' +import noop from '../utils/noop' import { IS_FIREFOX, IS_MAC } from '../constants/environment' /** @@ -21,45 +22,6 @@ import { IS_FIREFOX, IS_MAC } from '../constants/environment' const debug = Debug('slate:content') -/** - * Noop. - * - * @type {Function} - */ - -function noop() {} - -/** - * Find the deepest descendant of a DOM `element`. - * - * @param {Element} node - * @return {Element} - */ - -function findDeepestNode(element) { - return element.firstChild - ? findDeepestNode(element.firstChild) - : element -} - -/** - * Tests if the child is a descendant of parent. - * - * @param parent - * @param child - * @returns {boolean} - */ - -function isDescendant(parent, child) { - if (!child || !parent) return false - let node = child.parentNode - while (node != null) { - if (node == parent) return true - node = node.parentNode - } - return false -} - /** * Content. * @@ -154,8 +116,8 @@ class Content extends React.Component { } /** - * When finished rendering, move the `isRendering` flag on next tick and clean up the DOM's activeElement - * if neccessary. + * When finished rendering, move the `isRendering` flag on next tick and + * clean up the DOM's activeElement if neccessary. * * @param {Object} prevProps * @param {Object} prevState @@ -166,21 +128,16 @@ class Content extends React.Component { this.tmp.isRendering = false }, 1) - // If this component was focused last render, but is not now we might need to clean up the activeElement. + // If the state is blurred now, but was focused before, and the DOM still + // has a node inside the editor selected, we need to blur it. if (this.props.state.isBlurred && prevProps.state.isFocused) { - // Get the current selection - const ref = ReactDOM.findDOMNode(this) - const el = findDeepestNode(ref) + const el = ReactDOM.findDOMNode(this) const window = getWindow(el) const native = window.getSelection() + if (!el.contains(native.anchorNode)) return - // We need to make sure that the selection from our state is up-to-date with the native selection. - // We can do this by checking if native.anchorNode is a descendant of our this node. - if (isDescendant(ref, native.anchorNode)) { - // The selection was blurred, but the DOM selection state has not changed so we need to do this manually: - native.removeAllRanges() - if (ref) ref.blur() - } + native.removeAllRanges() + el.blur() } } diff --git a/src/components/editor.js b/src/components/editor.js index 68680b590..0c9b70c9c 100644 --- a/src/components/editor.js +++ b/src/components/editor.js @@ -6,6 +6,7 @@ import React from 'react' import Schema from '../models/schema' import State from '../models/state' import isReactComponent from '../utils/is-react-component' +import noop from '../utils/noop' import typeOf from 'type-of' /** @@ -14,14 +15,6 @@ import typeOf from 'type-of' const debug = Debug('slate:editor') -/** - * Noop. - * - * @type {Function} - */ - -function noop() {} - /** * Event handlers to mix in to the editor. * diff --git a/src/components/leaf.js b/src/components/leaf.js index 947296302..d1c24368b 100644 --- a/src/components/leaf.js +++ b/src/components/leaf.js @@ -162,9 +162,10 @@ class Leaf extends React.Component { const native = window.getSelection() const parent = ref.closest('[contenteditable]') - // In firefox it is not enough to create a range, you also need to focus the contenteditable element. + // COMPAT: In Firefox, it's not enough to create a range, you also need to + // focus the contenteditable element. (2016/11/16) function focus() { - if (parent) setTimeout(() => parent.focus(), 0) + if (parent) setTimeout(() => parent.focus()) } // If both the start and end are here, set the selection all at once. @@ -175,45 +176,45 @@ class Leaf extends React.Component { native.addRange(range) native.extend(el, focusOffset - start) focus() - return } - // If the selection is forward, we can set things in sequence. In - // the first leaf to render, reset the selection and set the new start. And - // then in the second leaf to render, extend to the new end. - if (selection.isForward) { - if (hasAnchor) { - native.removeAllRanges() - const range = window.document.createRange() - range.setStart(el, anchorOffset - start) - native.addRange(range) - focus() - } else if (hasFocus) { - native.extend(el, focusOffset - start) - focus() - } - } - - // Otherwise, if the selection is backward, we need to hack the order a bit. - // In the first leaf to render, set a phony start anchor to store the true - // end position. And then in the second leaf to render, set the start and - // extend the end to the stored value. + // Otherwise we need to set the selection across two different leaves. else { - if (hasFocus) { - native.removeAllRanges() - const range = window.document.createRange() - range.setStart(el, focusOffset - start) - native.addRange(range) - focus() - } else if (hasAnchor) { - const endNode = native.focusNode - const endOffset = native.focusOffset - native.removeAllRanges() - const range = window.document.createRange() - range.setStart(el, anchorOffset - start) - native.addRange(range) - native.extend(endNode, endOffset) - focus() + // If the selection is forward, we can set things in sequence. In the + // first leaf to render, reset the selection and set the new start. And + // then in the second leaf to render, extend to the new end. + if (selection.isForward) { + if (hasAnchor) { + native.removeAllRanges() + const range = window.document.createRange() + range.setStart(el, anchorOffset - start) + native.addRange(range) + } else if (hasFocus) { + native.extend(el, focusOffset - start) + focus() + } + } + + // Otherwise, if the selection is backward, we need to hack the order a bit. + // In the first leaf to render, set a phony start anchor to store the true + // end position. And then in the second leaf to render, set the start and + // extend the end to the stored value. + else { + if (hasFocus) { + native.removeAllRanges() + const range = window.document.createRange() + range.setStart(el, focusOffset - start) + native.addRange(range) + } else if (hasAnchor) { + const endNode = native.focusNode + const endOffset = native.focusOffset + native.removeAllRanges() + const range = window.document.createRange() + range.setStart(el, anchorOffset - start) + native.addRange(range) + native.extend(endNode, endOffset) + focus() + } } } diff --git a/src/components/void.js b/src/components/void.js index b727c35be..2abc602c4 100644 --- a/src/components/void.js +++ b/src/components/void.js @@ -5,16 +5,9 @@ import OffsetKey from '../utils/offset-key' import React from 'react' import ReactDOM from 'react-dom' import keycode from 'keycode' +import noop from '../utils/noop' import { IS_FIREFOX } from '../constants/environment' -/** - * Noop. - * - * @type {Function} - */ - -function noop() {} - /** * Void. * diff --git a/src/utils/noop.js b/src/utils/noop.js new file mode 100644 index 000000000..2ef804c31 --- /dev/null +++ b/src/utils/noop.js @@ -0,0 +1,14 @@ + +/** + * Noop. + * + * @return {Undefined} + */ + +function noop() {} + +/** + * Export. + */ + +export default noop