Hello, world!
+const Highlight = ({ value }) => ( +-); +Highlight.propTypes = { + value: PropTypes.string.isRequired, +}; + export default Highlight; diff --git a/src/demo/components/Highlight.test.js b/src/demo/components/Highlight.test.js index 4983f4a9..9435863c 100644 --- a/src/demo/components/Highlight.test.js +++ b/src/demo/components/Highlight.test.js @@ -4,14 +4,12 @@ import Highlight from "./Highlight"; describe("Highlight", () => { it("renders", () => { - expect( - shallow(+
{value}
), - ).toMatchSnapshot(); + expect(shallow( )).toMatchSnapshot(); }); it("onCopy", () => { document.execCommand = jest.fn(); - const wrapper = shallow( ); + const wrapper = shallow( ); wrapper.find("button").simulate("click"); diff --git a/src/demo/components/__snapshots__/App.test.js.snap b/src/demo/components/__snapshots__/App.test.js.snap index 065d2d24..db2853a7 100644 --- a/src/demo/components/__snapshots__/App.test.js.snap +++ b/src/demo/components/__snapshots__/App.test.js.snap @@ -7,5 +7,8 @@ exports[`App renders 1`] = ` + `; diff --git a/src/demo/components/__snapshots__/DemoEditor.test.js.snap b/src/demo/components/__snapshots__/DemoEditor.test.js.snap index ca92bc64..5f98b6ee 100644 --- a/src/demo/components/__snapshots__/DemoEditor.test.js.snap +++ b/src/demo/components/__snapshots__/DemoEditor.test.js.snap @@ -112,9 +112,22 @@ exports[`DemoEditor #extended works 1`] = ` > 📷 + + `; +exports[`DemoEditor blockRenderer IMAGE 1`] = ` `; + exports[`DemoEditor renders 1`] = ` 📷 + +`; diff --git a/src/demo/components/__snapshots__/Highlight.test.js.snap b/src/demo/components/__snapshots__/Highlight.test.js.snap index 8e9bdd1d..c5ea8f07 100644 --- a/src/demo/components/__snapshots__/Highlight.test.js.snap +++ b/src/demo/components/__snapshots__/Highlight.test.js.snap @@ -2,7 +2,6 @@ exports[`Highlight renders 1`] = `Copy -`; diff --git a/src/demo/index.js b/src/demo/index.js index 238b0c26..864e7950 100644 --- a/src/demo/index.js +++ b/src/demo/index.js @@ -9,7 +9,6 @@ import "./utils/typography.css"; import "./utils/layout.css"; import "./utils/objects.css"; -import "prismjs/themes/prism.css"; import "draft-js/dist/Draft.css"; import "./components/header.css"; diff --git a/src/demo/utils/DraftUtils.js b/src/demo/utils/DraftUtils.js new file mode 100644 index 00000000..bb742fe6 --- /dev/null +++ b/src/demo/utils/DraftUtils.js @@ -0,0 +1,26 @@ +import { EditorState, Modifier, RichUtils } from "draft-js"; + +const addLineBreak = (editorState) => { + const content = editorState.getCurrentContent(); + const selection = editorState.getSelection(); + + if (selection.isCollapsed()) { + return RichUtils.insertSoftNewline(editorState); + } + + let newContent = Modifier.removeRange(content, selection, "forward"); + const fragment = newContent.getSelectionAfter(); + const block = newContent.getBlockForKey(fragment.getStartKey()); + newContent = Modifier.insertText( + newContent, + fragment, + "\n", + block.getInlineStyleAt(fragment.getStartOffset()), + null, + ); + return EditorState.push(editorState, newContent, "insert-fragment"); +}; + +export default { + addLineBreak, +}; diff --git a/src/demo/utils/DraftUtils.test.js b/src/demo/utils/DraftUtils.test.js new file mode 100644 index 00000000..46e68a09 --- /dev/null +++ b/src/demo/utils/DraftUtils.test.js @@ -0,0 +1,56 @@ +import { EditorState, convertFromRaw } from "draft-js"; + +import DraftUtils from "./DraftUtils"; + +describe("DraftUtils", () => { + describe("#addLineBreak", () => { + it("works, collapsed", () => { + const contentState = convertFromRaw({ + entityMap: {}, + blocks: [ + { + key: "b0ei9", + text: "test", + type: "header-two", + }, + ], + }); + const editorState = EditorState.createWithContent(contentState); + + expect( + DraftUtils.addLineBreak(editorState) + .getCurrentContent() + .getFirstBlock() + .getText(), + ).toBe("\ntest"); + }); + + it("works, non-collapsed", () => { + const contentState = convertFromRaw({ + entityMap: {}, + blocks: [ + { + key: "a", + text: "test", + type: "header-two", + }, + ], + }); + let editorState = EditorState.createWithContent(contentState); + const selection = editorState.getSelection().merge({ + anchorKey: "a", + focusKey: "a", + anchorOffset: 0, + focusOffset: 2, + }); + editorState = EditorState.forceSelection(editorState, selection); + + expect( + DraftUtils.addLineBreak(editorState) + .getCurrentContent() + .getFirstBlock() + .getText(), + ).toBe("\nst"); + }); + }); +}); diff --git a/src/lib/api/__snapshots__/copypaste.test.js.snap b/src/lib/api/__snapshots__/copypaste.test.js.snap new file mode 100644 index 00000000..d97a63f6 --- /dev/null +++ b/src/lib/api/__snapshots__/copypaste.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`copypaste copy/cut listener works 1`] = `"+
"`; diff --git a/src/lib/api/copypaste.js b/src/lib/api/copypaste.js new file mode 100644 index 00000000..69e42d0d --- /dev/null +++ b/src/lib/api/copypaste.js @@ -0,0 +1,104 @@ +// @flow +import type { ElementRef } from "react"; +import { + Editor, + EditorState, + Modifier, + convertToRaw, + convertFromRaw, + ContentState, +} from "draft-js"; +import getFragmentFromSelection from "draft-js/lib/getFragmentFromSelection"; + +// Custom attribute to store Draft.js content in the HTML clipboard. +const FRAGMENT_ATTR = "data-draftjs-conductor-fragment"; + +/** + * Overrides the default copy/cut behavior, adding the serialised Draft.js content to the clipboard data. + * See also https://github.com/basecamp/trix/blob/62145978f352b8d971cf009882ba06ca91a16292/src/trix/controllers/input_controller.coffee#L415-L422 + * We serialise the editor content within HTML, not as a separate mime type, because Draft.js only allows access + * to HTML in its paste event handler. + */ +const draftEditorCopyListener = (ref: ElementRef, e: Object) => { + const selection = window.getSelection(); + // Get clipboard content like Draft.js would. + // https://github.com/facebook/draft-js/blob/37989027063ccc8279bfdc99a813b857549512a6/src/component/handlers/edit/editOnCopy.js#L34 + const fragment = getFragmentFromSelection(ref._latestEditorState); + + // Override the default behavior if there is a selection, and content, and clipboardData is supported (IE11 is out). + if (selection.rangeCount > 0 && fragment && e.clipboardData) { + const content = ContentState.createFromBlockArray(fragment.toArray()); + const serialisedContent = JSON.stringify(convertToRaw(content)); + + // Create a temporary element to store the selection’s HTML. + // See also Rangy's implementation: https://github.com/timdown/rangy/blob/1e55169d2e4d1d9458c2a87119addf47a8265276/src/core/domrange.js#L515-L520. + const fragmentElt = document.createElement("div"); + // Modern browsers only support a single range. + fragmentElt.appendChild(selection.getRangeAt(0).cloneContents()); + fragmentElt.setAttribute(FRAGMENT_ATTR, serialisedContent); + + e.clipboardData.setData("text/plain", selection.toString()); + e.clipboardData.setData("text/html", fragmentElt.outerHTML); + + e.preventDefault(); + } +}; + +export const registerCopySource = (ref: ElementRef ) => { + const editorElt = ref.editor; + const onCopy = draftEditorCopyListener.bind(null, ref); + + editorElt.addEventListener("copy", onCopy); + editorElt.addEventListener("cut", onCopy); + + return { + unregister() { + editorElt.removeEventListener("copy", onCopy); + editorElt.removeEventListener("cut", onCopy); + }, + }; +}; + +/** + * Handles pastes coming from Draft.js editors set up to serialise + * their Draft.js content within the HTML. + * This SHOULD NOT be used for stripPastedStyles editor. + */ +export const handleDraftEditorPastedText = ( + html: ?string, + editorState: EditorState, +) => { + // Plain-text pastes are better handled by Draft.js. + if (!html) { + return false; + } + + const doc = new DOMParser().parseFromString(html, "text/html"); + const fragmentElt = doc.querySelector(`[${FRAGMENT_ATTR}]`); + + // Handle the paste if it comes from draftjs-conductor. + if (fragmentElt) { + const fragmentAttr = fragmentElt.getAttribute(FRAGMENT_ATTR); + let rawContent; + + try { + // If JSON parsing fails, leave paste handling to Draft.js. + // There is no reason for this to happen, unless the clipboard was altered somehow. + // $FlowFixMe + rawContent = JSON.parse(fragmentAttr); + } catch (error) { + return false; + } + + const fragment = convertFromRaw(rawContent).getBlockMap(); + + const content = Modifier.replaceWithFragment( + editorState.getCurrentContent(), + editorState.getSelection(), + fragment, + ); + return EditorState.push(editorState, content, "insert-fragment"); + } + + return false; +}; diff --git a/src/lib/api/copypaste.test.js b/src/lib/api/copypaste.test.js new file mode 100644 index 00000000..047e5b7a --- /dev/null +++ b/src/lib/api/copypaste.test.js @@ -0,0 +1,198 @@ +import { + EditorState, + convertFromRaw, + convertToRaw, + ContentState, +} from "draft-js"; +import { registerCopySource, handleDraftEditorPastedText } from "./copypaste"; + +jest.mock("draft-js/lib/generateRandomKey", () => () => "a"); +jest.mock("draft-js/lib/getFragmentFromSelection", () => () => ({ + toArray() {}, +})); + +describe("copypaste", () => { + describe("registerCopySource", () => { + it("registers and unregisters works for copy", () => { + const editor = document.createElement("div"); + + const copySource = registerCopySource({ + editor, + _latestEditorState: EditorState.createEmpty(), + }); + + window.getSelection = jest.fn(); + editor.dispatchEvent(new Event("copy")); + expect(window.getSelection).toHaveBeenCalled(); + + copySource.unregister(); + + window.getSelection = jest.fn(); + editor.dispatchEvent(new Event("cut")); + expect(window.getSelection).not.toHaveBeenCalled(); + }); + + it("works for cut", () => { + const editor = document.createElement("div"); + + const copySource = registerCopySource({ + editor, + _latestEditorState: EditorState.createEmpty(), + }); + + window.getSelection = jest.fn(); + editor.dispatchEvent(new Event("cut")); + expect(window.getSelection).toHaveBeenCalled(); + + copySource.unregister(); + + window.getSelection = jest.fn(); + editor.dispatchEvent(new Event("cut")); + expect(window.getSelection).not.toHaveBeenCalled(); + }); + }); + + /** + * jsdom does not implement the DOM selection API, we have to do a lot of overriding. + */ + describe("copy/cut listener", () => { + it("no selection", () => { + const editor = document.createElement("div"); + + registerCopySource({ + editor, + _latestEditorState: EditorState.createEmpty(), + }); + + window.getSelection = jest.fn(() => { + return { + rangeCount: 0, + }; + }); + + const event = new Event("copy"); + event.clipboardData = {}; + event.preventDefault = jest.fn(); + editor.dispatchEvent(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("no clipboardData, IE11", () => { + const editor = document.createElement("div"); + + registerCopySource({ + editor, + _latestEditorState: EditorState.createEmpty(), + }); + + window.getSelection = jest.fn(() => { + return { + rangeCount: 1, + }; + }); + + const event = new Event("copy"); + event.preventDefault = jest.fn(); + editor.dispatchEvent(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("works", (done) => { + const editor = document.createElement("div"); + + registerCopySource({ + editor, + _latestEditorState: EditorState.createEmpty(), + }); + + const content = { + blocks: [ + { + key: "a", + type: "unstyled", + text: "test", + }, + ], + entityMap: {}, + }; + + jest + .spyOn(ContentState, "createFromBlockArray") + .mockImplementationOnce(() => { + return convertFromRaw(content); + }); + + window.getSelection = jest.fn(() => { + return { + rangeCount: 1, + toString: () => "toString selection", + getRangeAt() { + return { + cloneContents() { + return document.createElement("div"); + }, + }; + }, + }; + }); + + const event = new Event("copy"); + event.preventDefault = jest.fn(); + event.clipboardData = { + setData(type, data) { + if (type === "text/plain") { + expect(data).toBe("toString selection"); + } else if (type === "text/html") { + expect(data).toMatchSnapshot(); + done(); + } + }, + }; + editor.dispatchEvent(event); + }); + }); + + describe("handleDraftEditorPastedText", () => { + it("no HTML", () => { + const editorState = EditorState.createEmpty(); + expect(handleDraftEditorPastedText(null, editorState)).toBe(false); + }); + + it("HTML from other app", () => { + const editorState = EditorState.createEmpty(); + const html = ` Hello, world!
`; + expect(handleDraftEditorPastedText(html, editorState)).toBe(false); + }); + + it("HTML from draftjs-conductor", () => { + const content = { + blocks: [ + { + data: {}, + depth: 0, + entityRanges: [], + inlineStyleRanges: [], + key: "a", + text: "hello,\nworld!", + type: "unstyled", + }, + ], + entityMap: {}, + }; + let editorState = EditorState.createEmpty(); + const html = ``; + editorState = handleDraftEditorPastedText(html, editorState); + expect(convertToRaw(editorState.getCurrentContent())).toEqual(content); + }); + + it("invalid JSON", () => { + const editorState = EditorState.createEmpty(); + const html = `Hello, world!
`; + expect(handleDraftEditorPastedText(html, editorState)).toBe(false); + }); + }); +}); diff --git a/src/lib/index.js b/src/lib/index.js index 78c5b68f..f1a37b3a 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -14,3 +14,8 @@ export { generateListNestingStyles, blockDepthStyleFn, }; + +export { + registerCopySource, + handleDraftEditorPastedText, +} from "./api/copypaste"; diff --git a/src/lib/index.test.js b/src/lib/index.test.js index 79d9a527..e3030f68 100644 --- a/src/lib/index.test.js +++ b/src/lib/index.test.js @@ -4,6 +4,8 @@ import { DRAFT_DEFAULT_DEPTH_CLASS, generateListNestingStyles, blockDepthStyleFn, + registerCopySource, + handleDraftEditorPastedText, } from "./index"; const pkg = require("../../package.json"); @@ -24,4 +26,9 @@ describe(pkg.name, () => { expect(generateListNestingStyles).toBeDefined()); it("blockDepthStyleFn", () => expect(blockDepthStyleFn).toBeDefined()); + + it("registerCopySource", () => expect(registerCopySource).toBeDefined()); + + it("handleDraftEditorPastedText", () => + expect(handleDraftEditorPastedText).toBeDefined()); });Hello, world!