From d280a02c7d277bf7a1ca23950f7507cedb5a679a Mon Sep 17 00:00:00 2001 From: "asamuzaK (Kazz)" Date: Sun, 12 Apr 2020 18:57:16 +0900 Subject: [PATCH] [WIP] add draft.js support Fix #123 --- src/js/content.js | 143 +++++++++++++++++++++++------- src/mjs/live-edit.js | 5 ++ test/content.test.js | 195 ++++++++++++++++++++++++++++++++++++++++- test/live-edit.test.js | 2 +- 4 files changed, 308 insertions(+), 37 deletions(-) diff --git a/src/js/content.js b/src/js/content.js index 6b77e95c..18c7f4c6 100644 --- a/src/js/content.js +++ b/src/js/content.js @@ -186,9 +186,27 @@ const matchDocUrl = arr => { /* dispatch events */ /** - * dispatch focus event + * dispatch clipboard event * @param {Object} elm - Element * @param {string} type - event type + * @param {Object} opt - init options + * @returns {void} + */ +const dispatchClipboardEvent = (elm, type, opt = { + bubbles: true, + cancelable: true, + clipboardData: null, +}) => { + if (elm && elm.nodeType === Node.ELEMENT_NODE && + isString(type) && /^(?:c(?:opy|ut)|paste)$/.test(type)) { + const evt = new ClipboardEvent(type, opt); + elm.dispatchEvent(evt); + } +}; + +/** + * dispatch focus event + * @param {Object} elm - Element * @returns {void} */ const dispatchFocusEvent = elm => { @@ -205,15 +223,27 @@ const dispatchFocusEvent = elm => { /** * dispatch input event * @param {Object} elm - element + * @param {string} type - event type + * @param {Object} opt - init options * @returns {void} */ -const dispatchInputEvent = elm => { - if (elm && elm.nodeType === Node.ELEMENT_NODE) { - const opt = { - bubbles: true, - cancelable: false, - }; - const evt = new InputEvent("input", opt); +const dispatchInputEvent = (elm, type, opt) => { + if (elm && elm.nodeType === Node.ELEMENT_NODE && + isString(type) && /^(?:before)?input$/.test(type)) { + if (!isObjectNotEmpty(opt)) { + if (type === "input") { + opt = { + bubbles: true, + cancelable: false, + }; + } else { + opt = { + bubbles: true, + cancelable: true, + }; + } + } + const evt = new InputEvent(type, opt); elm.dispatchEvent(evt); } }; @@ -1393,7 +1423,7 @@ const replaceContent = (elm, node, value, ns = nsURI.html) => { } } node.appendChild(frag); - dispatchInputEvent(elm); + dispatchInputEvent(elm, "input"); } } }; @@ -1415,7 +1445,7 @@ const replaceEditControlValue = (elm, value) => { const changed = elm.value !== value; if (changed) { elm.value = value; - dispatchInputEvent(elm); + dispatchInputEvent(elm, "input"); } } }; @@ -1431,28 +1461,74 @@ const replaceLiveEditContent = (elm, value, key) => { if (elm && elm.nodeType === Node.ELEMENT_NODE && isString(value) && liveEdit.has(key)) { const {setContent} = liveEdit.get(key); - const liveElm = elm.querySelector(setContent); - if (isEditControl(liveElm)) { - const ctrlA = { - code: "KeyA", - ctrlKey: true, - key: "a", - keyCode: KEY_CODE_A, - }; - const backSpace = { - code: "Backspace", - key: "Backspace", - keyCode: KEY_CODE_BS, - }; - dispatchFocusEvent(liveElm); - dispatchKeyboardEvent(liveElm, "keydown", ctrlA); - dispatchKeyboardEvent(liveElm, "keypress", ctrlA); - dispatchKeyboardEvent(liveElm, "keyup", ctrlA); - dispatchKeyboardEvent(liveElm, "keydown", backSpace); - dispatchKeyboardEvent(liveElm, "keypress", backSpace); - dispatchKeyboardEvent(liveElm, "keyup", backSpace); - liveElm.value = value.replace(/\u200B/g, ""); - dispatchInputEvent(liveElm); + let liveElm; + if (setContent === "self") { + liveElm = elm; + } else { + liveElm = elm.querySelector(setContent); + } + if (liveElm) { + document.activeElement !== liveElm && dispatchFocusEvent(liveElm); + // Draft.js + if (key === "draftEditor") { + const range = document.createRange(); + const sel = window.getSelection(); + const dataTrans = new DataTransfer(); + const frag = createParagraphedContent(value); + const content = new XMLSerializer().serializeToString(frag); + range.selectNodeContents(liveElm); + sel.removeAllRanges(); + sel.addRange(range); + dataTrans.setData(MIME_HTML, content); + dataTrans.setData(MIME_PLAIN, value); + // FIXME: neither paste nor beforeinput succeeds + dispatchClipboardEvent(liveElm, "paste", { + bubbles: true, + cancelable: true, + clipboardData: dataTrans, + }); + dispatchInputEvent(liveElm, "beforeinput", { + bubbles: true, + cancelable: true, + data: value, + //dataTransfer: dataTrans, + //inputType: "insertFromPaste", + inputType: "insertText", + }); + dispatchInputEvent(liveElm, "input", { + bubbles: true, + cancelable: false, + data: value, + //dataTransfer: dataTrans, + //inputType: "insertFromPaste", + inputType: "insertText", + }); + } else if (isEditControl(liveElm)) { + const ctrlA = { + code: "KeyA", + ctrlKey: true, + key: "a", + keyCode: KEY_CODE_A, + }; + const backSpace = { + code: "Backspace", + key: "Backspace", + keyCode: KEY_CODE_BS, + }; + dispatchKeyboardEvent(liveElm, "keydown", ctrlA); + dispatchKeyboardEvent(liveElm, "keypress", ctrlA); + dispatchKeyboardEvent(liveElm, "keyup", ctrlA); + dispatchKeyboardEvent(liveElm, "keydown", backSpace); + dispatchKeyboardEvent(liveElm, "keypress", backSpace); + dispatchKeyboardEvent(liveElm, "keyup", backSpace); + liveElm.value = value.replace(/\u200B/g, ""); + dispatchInputEvent(liveElm, "input", { + data: value, + bubbles: true, + cancelable: false, + inputType: "insertText", + }); + } } } }; @@ -1462,7 +1538,7 @@ const replaceLiveEditContent = (elm, value, key) => { * @param {Object} obj - sync data object * @returns {Promise.} - results of each handler */ -const syncText = async (obj = {}) => { +const syncText = (obj = {}) => { const {data, value} = obj; const func = []; if (isObjectNotEmpty(data)) { @@ -1784,6 +1860,7 @@ if (typeof module !== "undefined" && module.hasOwnProperty("exports")) { createXmlBasedDom, dataIds, determineContentProcess, + dispatchClipboardEvent, dispatchFocusEvent, dispatchInputEvent, dispatchKeyboardEvent, diff --git a/src/mjs/live-edit.js b/src/mjs/live-edit.js index 855bf795..444a27a3 100644 --- a/src/mjs/live-edit.js +++ b/src/mjs/live-edit.js @@ -13,4 +13,9 @@ export default { getContent: ".CodeMirror-line", setContent: ".CodeMirror > div > textarea", }, + draftEditor: { + className: "public-DraftEditor-content", + getContent: "[data-text=\"true\"]", + setContent: "self", + }, }; diff --git a/test/content.test.js b/test/content.test.js index 2417f03f..6f25e486 100644 --- a/test/content.test.js +++ b/test/content.test.js @@ -57,8 +57,9 @@ describe("content", () => { }; let window, document; const globalKeys = [ - "DOMTokenList", "DOMParser", "FocusEvent", "Headers", "InputEvent", - "KeyboardEvent", "Node", "NodeList", "XMLSerializer", + "ClipboardEvent", "DataTransfer", "DOMTokenList", "DOMParser", "FocusEvent", + "Headers", "InputEvent", "KeyboardEvent", "Node", "NodeList", + "XMLSerializer", ]; // NOTE: not implemented in jsdom https://github.com/jsdom/jsdom/issues/1670 const isContentEditable = elm => { @@ -94,6 +95,42 @@ describe("content", () => { global.document = document; global.fetch = sinon.stub(); for (const key of globalKeys) { + // Not implemented in jsdom + if (!window[key]) { + if (key === "ClipboardEvent") { + window[key] = class ClipboardEvent extends window.Event { + constructor(arg, initEvt) { + super(arg, initEvt); + this.clipboardData = null; + } + }; + } else if (key === "DataTransfer") { + window[key] = class DataTransfer { + constructor() { + this._items = new Map(); + this.types; + } + get types() { + return Array.from(this._items.keys()); + } + clearData(formats) { + if (Array.isArray(formats)) { + for (const format of formats) { + this._items.remove(format); + } + } else { + !formats && this._items.clear(); + } + } + getData(type) { + return this._items.get(type); + } + setData(type, value) { + this._items.set(type, value); + } + }; + } + } global[key] = window[key]; } }); @@ -309,6 +346,69 @@ describe("content", () => { }); }); + describe("dispatch clipboard event", () => { + const func = cjs.dispatchClipboardEvent; + + it("should not call function", () => { + const p = document.createElement("p"); + const text = document.createTextNode("foo"); + const spy = sinon.spy(p, "dispatchEvent"); + const body = document.querySelector("body"); + p.appendChild(text); + body.appendChild(p); + func(text); + assert.isFalse(spy.called, "called"); + }); + + it("should not call function", () => { + const p = document.createElement("p"); + const text = document.createTextNode("foo"); + const spy = sinon.spy(p, "dispatchEvent"); + const body = document.querySelector("body"); + p.appendChild(text); + body.appendChild(p); + func(text, "foo"); + assert.isFalse(spy.called, "called"); + }); + + it("should call function", () => { + const p = document.createElement("p"); + const spy = sinon.spy(p, "dispatchEvent"); + const body = document.querySelector("body"); + body.appendChild(p); + func(p, "copy", { + bubbles: true, + cancelable: true, + }); + assert.isTrue(spy.called, "called"); + }); + + it("should call function", () => { + const p = document.createElement("p"); + const spy = sinon.spy(p, "dispatchEvent"); + const body = document.querySelector("body"); + body.appendChild(p); + func(p, "cut", { + bubbles: true, + cancelable: true, + }); + assert.isTrue(spy.called, "called"); + }); + + it("should call function", () => { + const p = document.createElement("p"); + const spy = sinon.spy(p, "dispatchEvent"); + const body = document.querySelector("body"); + body.appendChild(p); + func(p, "paste", { + bubbles: true, + cancelable: true, + clipboardData: new DataTransfer(), + }); + assert.isTrue(spy.called, "called"); + }); + }); + describe("dispatch focus event", () => { const func = cjs.dispatchFocusEvent; @@ -347,12 +447,63 @@ describe("content", () => { assert.isFalse(spy.called, "called"); }); - it("should call function", () => { + it("should not call function", () => { const p = document.createElement("p"); const spy = sinon.spy(p, "dispatchEvent"); const body = document.querySelector("body"); body.appendChild(p); func(p); + assert.isFalse(spy.called, "called"); + }); + + it("should not call function", () => { + const p = document.createElement("p"); + const spy = sinon.spy(p, "dispatchEvent"); + const body = document.querySelector("body"); + body.appendChild(p); + func(p, "foo"); + assert.isFalse(spy.called, "called"); + }); + + it("should call function", () => { + const p = document.createElement("p"); + const spy = sinon.spy(p, "dispatchEvent"); + const body = document.querySelector("body"); + body.appendChild(p); + func(p, "beforeinput"); + assert.isTrue(spy.called, "called"); + }); + + it("should call function", () => { + const p = document.createElement("p"); + const spy = sinon.spy(p, "dispatchEvent"); + const body = document.querySelector("body"); + body.appendChild(p); + func(p, "beforeinput", { + bubbles: true, + cancelable: true, + }); + assert.isTrue(spy.called, "called"); + }); + + it("should call function", () => { + const p = document.createElement("p"); + const spy = sinon.spy(p, "dispatchEvent"); + const body = document.querySelector("body"); + body.appendChild(p); + func(p, "input"); + assert.isTrue(spy.called, "called"); + }); + + it("should call function", () => { + const p = document.createElement("p"); + const spy = sinon.spy(p, "dispatchEvent"); + const body = document.querySelector("body"); + body.appendChild(p); + func(p, "input", { + bubbles: true, + cancelable: false, + }); assert.isTrue(spy.called, "called"); }); }); @@ -3193,6 +3344,22 @@ describe("content", () => { setContent: ".foo > textarea", }); func(body, "bar baz", "foo"); + assert.isFalse(stub.called, "not dispatched"); + }); + + it("should not replace content", () => { + const stub = sinon.stub(); + const elm = document.createElement("div"); + const text = document.createElement("p"); + const body = document.querySelector("body"); + elm.classList.add("foo"); + elm.appendChild(text); + body.addEventListener("input", stub, true); + body.appendChild(elm); + cjs.liveEdit.set("foo", { + setContent: ".foo > p", + }); + func(body, "bar baz", "foo"); assert.isFalse(stub.called, "dispatched"); }); @@ -3212,6 +3379,28 @@ describe("content", () => { assert.isTrue(stub.called, "dispatched"); assert.strictEqual(text.value, "bar baz", "content"); }); + + it("should replace content", () => { + const stub1 = sinon.stub(); + const stub2 = sinon.stub(); + const stub3 = sinon.stub(); + const elm = document.createElement("div"); + const text = document.createElement("p"); + const body = document.querySelector("body"); + elm.classList.add("public-DraftEditor-content"); + elm.appendChild(text); + body.addEventListener("paste", stub1, true); + body.addEventListener("beforeinput", stub2, true); + body.addEventListener("input", stub3, true); + body.appendChild(elm); + cjs.liveEdit.set("draftEditor", { + setContent: "self", + }); + func(body, "bar baz", "draftEditor"); + assert.isTrue(stub1.called, "dispatched"); + assert.isTrue(stub2.called, "dispatched"); + assert.isTrue(stub3.called, "dispatched"); + }); }); describe("get target element and synchronize text", () => { diff --git a/test/live-edit.test.js b/test/live-edit.test.js index dc47c860..bb6800b9 100644 --- a/test/live-edit.test.js +++ b/test/live-edit.test.js @@ -8,7 +8,7 @@ import liveEdit from "../src/mjs/live-edit.js"; describe("live-edit", () => { it("should get key and value", () => { - const itemKeys = ["aceEditor", "codeMirror"]; + const itemKeys = ["aceEditor", "codeMirror", "draftEditor"]; const items = Object.entries(liveEdit); for (const [key, value] of items) { assert.isTrue(itemKeys.includes(key));