From 082fb53633408575b22e932d70266f189ee2c8c0 Mon Sep 17 00:00:00 2001 From: Justin Weiss Date: Mon, 11 Dec 2017 06:56:38 -0800 Subject: [PATCH] Add a `copyFragment` helper for plugin onCut/onCopy functions (#1429) * Add a `copyFragment` helper for plugin onCut/onCopy functions * Rename `copyFragment` to `cloneFragment` * Fix a missing clone-fragment reference --- docs/Readme.md | 1 + docs/reference/slate-react/utils.md | 35 +++++ packages/slate-react/src/index.js | 3 + packages/slate-react/src/plugins/after.js | 127 +----------------- .../slate-react/src/utils/clone-fragment.js | 127 ++++++++++++++++++ 5 files changed, 169 insertions(+), 124 deletions(-) create mode 100644 packages/slate-react/src/utils/clone-fragment.js diff --git a/docs/Readme.md b/docs/Readme.md index 42922eefd..453f77763 100644 --- a/docs/Readme.md +++ b/docs/Readme.md @@ -55,6 +55,7 @@ - [Plugins](./reference/slate-react/plugins.md) - [Custom Nodes](./reference/slate-react/custom-nodes.md) - [Core Plugins](./reference/slate-react/core-plugins.md) +- [cloneFragment](./reference/slate-react/utils.md) - [findDOMNode](./reference/slate-react/utils.md) - [findDOMRange](./reference/slate-react/utils.md) - [findNode](./reference/slate-react/utils.md) diff --git a/docs/reference/slate-react/utils.md b/docs/reference/slate-react/utils.md index fe5369eb0..1990b8fd0 100644 --- a/docs/reference/slate-react/utils.md +++ b/docs/reference/slate-react/utils.md @@ -3,6 +3,7 @@ ```js import { + cloneFragment, findDOMNode, findDOMRange, findNode, @@ -18,6 +19,40 @@ React-specific utility functions for Slate that may be useful in certain use cas ## Functions +### `cloneFragment` +`cloneFragment(event: DOMEvent|ReactEvent, value: Value, fragment: Document)` + +During a cut or copy event, sets `fragment` as the Slate document fragment to be copied. + +```js +function onCopy(event, change, editor) { + const { value } = change + const fragment = // ... create a fragment from a set of nodes ... + + if (fragment) { + cloneFragment(event, value, fragment) + return true + } +} +``` + +Note that calling `cloneFragment` should be the last thing you do in your event handler. If you change the window selection after calling `cloneFragment`, the browser may copy the wrong content. If you need to perform an action after calling `cloneFragment`, wrap it in `requestAnimationFrame`: + +```js +function onCut(event, change, editor) { + const { value } = change + const fragment = // ... create a fragment from a set of nodes ... + + if (fragment) { + cloneFragment(event, value, fragment) + window.requestAnimationFrame(() => { + editor.change(change => change.delete()) + }) + return true + } +} +``` + ### `findDOMNode` `findDOMNode(node: Node) => DOMElement` diff --git a/packages/slate-react/src/index.js b/packages/slate-react/src/index.js index ca8b0c0a9..a91cdbe76 100644 --- a/packages/slate-react/src/index.js +++ b/packages/slate-react/src/index.js @@ -1,5 +1,6 @@ import Editor from './components/editor' +import cloneFragment from './utils/clone-fragment' import findDOMNode from './utils/find-dom-node' import findDOMRange from './utils/find-dom-range' import findNode from './utils/find-node' @@ -16,6 +17,7 @@ import setEventTransfer from './utils/set-event-transfer' export { Editor, + cloneFragment, findDOMNode, findDOMRange, findNode, @@ -27,6 +29,7 @@ export { export default { Editor, + cloneFragment, findDOMNode, findDOMRange, findNode, diff --git a/packages/slate-react/src/plugins/after.js b/packages/slate-react/src/plugins/after.js index 36a6c7770..b38aacf2b 100644 --- a/packages/slate-react/src/plugins/after.js +++ b/packages/slate-react/src/plugins/after.js @@ -9,6 +9,7 @@ import { Block, Inline, Text } from 'slate' import EVENT_HANDLERS from '../constants/event-handlers' import HOTKEYS from '../constants/hotkeys' import Content from '../components/content' +import cloneFragment from '../utils/clone-fragment' import findDOMNode from '../utils/find-dom-node' import findNode from '../utils/find-node' import findPoint from '../utils/find-point' @@ -16,7 +17,6 @@ import findRange from '../utils/find-range' import getEventRange from '../utils/get-event-range' import getEventTransfer from '../utils/get-event-transfer' import setEventTransfer from '../utils/set-event-transfer' -import { IS_CHROME, IS_SAFARI } from '../constants/environment' /** * Debug. @@ -102,7 +102,7 @@ function AfterPlugin() { function onCopy(event, change, editor) { debug('onCopy', { event }) - onCutOrCopy(event, change) + cloneFragment(event, change.value) } /** @@ -116,7 +116,7 @@ function AfterPlugin() { function onCut(event, change, editor) { debug('onCut', { event }) - onCutOrCopy(event, change) + cloneFragment(event, change.value) const window = getWindow(event.target) // Once the fake cut content has successfully been added to the clipboard, @@ -139,127 +139,6 @@ function AfterPlugin() { }) } - /** - * On cut or copy. - * - * @param {Event} event - * @param {Change} change - * @param {Editor} editor - */ - - function onCutOrCopy(event, change, editor) { - const window = getWindow(event.target) - const native = window.getSelection() - const { value } = change - const { startKey, endKey, startText, endBlock, endInline } = value - const isVoidBlock = endBlock && endBlock.isVoid - const isVoidInline = endInline && endInline.isVoid - const isVoid = isVoidBlock || isVoidInline - - // If the selection is collapsed, and it isn't inside a void node, abort. - if (native.isCollapsed && !isVoid) return - - // Create a fake selection so that we can add a Base64-encoded copy of the - // fragment to the HTML, to decode on future pastes. - const { fragment } = value - const encoded = Base64.serializeNode(fragment) - const range = native.getRangeAt(0) - let contents = range.cloneContents() - let attach = contents.childNodes[0] - - // If the end node is a void node, we need to move the end of the range from - // the void node's spacer span, to the end of the void node's content. - if (isVoid) { - const r = range.cloneRange() - const n = isVoidBlock ? endBlock : endInline - const node = findDOMNode(n, window) - r.setEndAfter(node) - contents = r.cloneContents() - attach = contents.childNodes[contents.childNodes.length - 1].firstChild - } - - // COMPAT: in Safari and Chrome when selecting a single marked word, - // marks are not preserved when copying. - // If the attatched is not void, and the startKey and endKey is the same, - // check if there is marks involved. If so, set the range start just before the - // startText node - if ((IS_CHROME || IS_SAFARI) && !isVoid && startKey === endKey) { - const hasMarks = startText.characters - .slice(value.selection.anchorOffset, value.selection.focusOffset) - .filter(char => char.marks.size !== 0) - .size !== 0 - if (hasMarks) { - const r = range.cloneRange() - const node = findDOMNode(startText, window) - r.setStartBefore(node) - contents = r.cloneContents() - attach = contents.childNodes[contents.childNodes.length - 1].firstChild - } - } - - // Remove any zero-width space spans from the cloned DOM so that they don't - // show up elsewhere when pasted. - const zws = [].slice.call(contents.querySelectorAll('[data-slate-zero-width]')) - zws.forEach(zw => zw.parentNode.removeChild(zw)) - - // COMPAT: In Chrome and Safari, if the last element in the selection to - // copy has `contenteditable="false"` the copy will fail, and nothing will - // be put in the clipboard. So we remove them all. (2017/05/04) - if (IS_CHROME || IS_SAFARI) { - const els = [].slice.call(contents.querySelectorAll('[contenteditable="false"]')) - els.forEach(el => el.removeAttribute('contenteditable')) - } - - // Set a `data-slate-fragment` attribute on a non-empty node, so it shows up - // in the HTML, and can be used for intra-Slate pasting. If it's a text - // node, wrap it in a `` so we have something to set an attribute on. - if (attach.nodeType == 3) { - const span = window.document.createElement('span') - - // COMPAT: In Chrome and Safari, if we don't add the `white-space` style - // then leading and trailing spaces will be ignored. (2017/09/21) - span.style.whiteSpace = 'pre' - - span.appendChild(attach) - contents.appendChild(span) - attach = span - } - - attach.setAttribute('data-slate-fragment', encoded) - - // Add the phony content to the DOM, and select it, so it will be copied. - const body = window.document.querySelector('body') - const div = window.document.createElement('div') - div.setAttribute('contenteditable', true) - div.style.position = 'absolute' - div.style.left = '-9999px' - - // COMPAT: In Firefox, the viewport jumps to find the phony div, so it - // should be created at the current scroll offset with `style.top`. - // The box model attributes which can interact with 'top' are also reset. - div.style.border = '0px' - div.style.padding = '0px' - div.style.margin = '0px' - div.style.top = `${window.pageYOffset || window.document.documentElement.scrollTop}px` - - div.appendChild(contents) - body.appendChild(div) - - // COMPAT: In Firefox, trying to use the terser `native.selectAllChildren` - // throws an error, so we use the older `range` equivalent. (2016/06/21) - const r = window.document.createRange() - r.selectNodeContents(div) - native.removeAllRanges() - native.addRange(r) - - // Revert to the previous selection right after copying. - window.requestAnimationFrame(() => { - body.removeChild(div) - native.removeAllRanges() - native.addRange(range) - }) - } - /** * On drag end. * diff --git a/packages/slate-react/src/utils/clone-fragment.js b/packages/slate-react/src/utils/clone-fragment.js new file mode 100644 index 000000000..1c254d579 --- /dev/null +++ b/packages/slate-react/src/utils/clone-fragment.js @@ -0,0 +1,127 @@ + +import Base64 from 'slate-base64-serializer' + +import findDOMNode from './find-dom-node' +import getWindow from 'get-window' +import { IS_CHROME, IS_SAFARI } from '../constants/environment' + +/** + * Prepares a Slate document fragment to be copied to the clipboard. + * + * @param {Event} event + * @param {Value} value + * @param {Document} [fragment] + */ + +function cloneFragment(event, value, fragment = value.fragment) { + const window = getWindow(event.target) + const native = window.getSelection() + const { startKey, endKey, startText, endBlock, endInline } = value + const isVoidBlock = endBlock && endBlock.isVoid + const isVoidInline = endInline && endInline.isVoid + const isVoid = isVoidBlock || isVoidInline + + // If the selection is collapsed, and it isn't inside a void node, abort. + if (native.isCollapsed && !isVoid) return + + // Create a fake selection so that we can add a Base64-encoded copy of the + // fragment to the HTML, to decode on future pastes. + const encoded = Base64.serializeNode(fragment) + const range = native.getRangeAt(0) + let contents = range.cloneContents() + let attach = contents.childNodes[0] + + // If the end node is a void node, we need to move the end of the range from + // the void node's spacer span, to the end of the void node's content. + if (isVoid) { + const r = range.cloneRange() + const n = isVoidBlock ? endBlock : endInline + const node = findDOMNode(n, window) + r.setEndAfter(node) + contents = r.cloneContents() + attach = contents.childNodes[contents.childNodes.length - 1].firstChild + } + + // COMPAT: in Safari and Chrome when selecting a single marked word, + // marks are not preserved when copying. + // If the attatched is not void, and the startKey and endKey is the same, + // check if there is marks involved. If so, set the range start just before the + // startText node + if ((IS_CHROME || IS_SAFARI) && !isVoid && startKey === endKey) { + const hasMarks = startText.characters + .slice(value.selection.anchorOffset, value.selection.focusOffset) + .filter(char => char.marks.size !== 0) + .size !== 0 + if (hasMarks) { + const r = range.cloneRange() + const node = findDOMNode(startText, window) + r.setStartBefore(node) + contents = r.cloneContents() + attach = contents.childNodes[contents.childNodes.length - 1].firstChild + } + } + + // Remove any zero-width space spans from the cloned DOM so that they don't + // show up elsewhere when pasted. + const zws = [].slice.call(contents.querySelectorAll('[data-slate-zero-width]')) + zws.forEach(zw => zw.parentNode.removeChild(zw)) + + // COMPAT: In Chrome and Safari, if the last element in the selection to + // copy has `contenteditable="false"` the copy will fail, and nothing will + // be put in the clipboard. So we remove them all. (2017/05/04) + if (IS_CHROME || IS_SAFARI) { + const els = [].slice.call(contents.querySelectorAll('[contenteditable="false"]')) + els.forEach(el => el.removeAttribute('contenteditable')) + } + + // Set a `data-slate-fragment` attribute on a non-empty node, so it shows up + // in the HTML, and can be used for intra-Slate pasting. If it's a text + // node, wrap it in a `` so we have something to set an attribute on. + if (attach.nodeType == 3) { + const span = window.document.createElement('span') + + // COMPAT: In Chrome and Safari, if we don't add the `white-space` style + // then leading and trailing spaces will be ignored. (2017/09/21) + span.style.whiteSpace = 'pre' + + span.appendChild(attach) + contents.appendChild(span) + attach = span + } + + attach.setAttribute('data-slate-fragment', encoded) + + // Add the phony content to the DOM, and select it, so it will be copied. + const body = window.document.querySelector('body') + const div = window.document.createElement('div') + div.setAttribute('contenteditable', true) + div.style.position = 'absolute' + div.style.left = '-9999px' + + // COMPAT: In Firefox, the viewport jumps to find the phony div, so it + // should be created at the current scroll offset with `style.top`. + // The box model attributes which can interact with 'top' are also reset. + div.style.border = '0px' + div.style.padding = '0px' + div.style.margin = '0px' + div.style.top = `${window.pageYOffset || window.document.documentElement.scrollTop}px` + + div.appendChild(contents) + body.appendChild(div) + + // COMPAT: In Firefox, trying to use the terser `native.selectAllChildren` + // throws an error, so we use the older `range` equivalent. (2016/06/21) + const r = window.document.createRange() + r.selectNodeContents(div) + native.removeAllRanges() + native.addRange(r) + + // Revert to the previous selection right after copying. + window.requestAnimationFrame(() => { + body.removeChild(div) + native.removeAllRanges() + native.addRange(range) + }) +} + +export default cloneFragment