diff --git a/_develop/webpack.config.js b/_develop/webpack.config.js index dbe7eb1201..a96edee143 100644 --- a/_develop/webpack.config.js +++ b/_develop/webpack.config.js @@ -85,7 +85,6 @@ const tsRules = { loader: 'ts-loader', options: { compilerOptions: { - declaration: false, module: 'es6', sourceMap: true, target: 'es6', diff --git a/blots/scroll.js b/blots/scroll.js index aa810342ed..32b6e89dee 100644 --- a/blots/scroll.js +++ b/blots/scroll.js @@ -38,6 +38,10 @@ class Scroll extends ScrollBlot { this.emitter.emit(Emitter.events.SCROLL_BLOT_UNMOUNT, blot); } + emitEmbedUpdate(blot, change) { + this.emitter.emit(Emitter.events.SCROLL_EMBED_UPDATE, blot, change); + } + deleteAt(index, length) { const [first, offset] = this.line(index); const [last] = this.line(index + length); @@ -170,7 +174,7 @@ class Scroll extends ScrollBlot { } mutations = mutations.filter(({ target }) => { const blot = this.find(target, true); - return blot && blot.scroll === this; + return blot && !blot.updateContent; }); if (mutations.length > 0) { this.emitter.emit(Emitter.events.SCROLL_BEFORE_UPDATE, source, mutations); @@ -180,6 +184,15 @@ class Scroll extends ScrollBlot { this.emitter.emit(Emitter.events.SCROLL_UPDATE, source, mutations); } } + + updateEmbedAt(index, key, change) { + // Currently it only supports top-level embeds (BlockEmbed). + // We can update `ParentBlot` in parchment to support inline embeds. + const [blot] = this.descendant(b => b instanceof BlockEmbed, index); + if (blot && blot.statics.blotName === key) { + blot.updateContent(change); + } + } } Scroll.blotName = 'scroll'; Scroll.className = 'ql-editor'; diff --git a/core/editor.js b/core/editor.js index 94f6af97a3..661e1c25f7 100644 --- a/core/editor.js +++ b/core/editor.js @@ -55,6 +55,12 @@ class Editor { scrollLength += length; } else { deleteDelta.push(op); + + if (op.retain !== null && typeof op.retain === 'object') { + const key = Object.keys(op.retain)[0]; + if (key == null) return index; + this.scroll.updateEmbedAt(index, key, op.retain[key]); + } } Object.keys(attributes).forEach(name => { this.scroll.formatAt(index, length, name, attributes[name]); diff --git a/core/emitter.js b/core/emitter.js index 0f9ba4f06a..fa4495c763 100644 --- a/core/emitter.js +++ b/core/emitter.js @@ -51,6 +51,7 @@ Emitter.events = { SCROLL_BLOT_UNMOUNT: 'scroll-blot-unmount', SCROLL_OPTIMIZE: 'scroll-optimize', SCROLL_UPDATE: 'scroll-update', + SCROLL_EMBED_UPDATE: 'scroll-embed-update', SELECTION_CHANGE: 'selection-change', TEXT_CHANGE: 'text-change', }; diff --git a/core/quill.js b/core/quill.js index f24718b3cf..413875b12f 100644 --- a/core/quill.js +++ b/core/quill.js @@ -23,8 +23,8 @@ class Quill { logger.level(limit); } - static find(node) { - return instances.get(node) || globalRegistry.find(node); + static find(node, bubble = false) { + return instances.get(node) || globalRegistry.find(node, bubble); } static import(name) { @@ -109,6 +109,22 @@ class Quill { source, ); }); + this.emitter.on(Emitter.events.SCROLL_EMBED_UPDATE, (blot, delta) => { + const oldRange = this.selection.lastRange; + const [newRange] = this.selection.getRange(); + const selectionInfo = + oldRange && newRange ? { oldRange, newRange } : undefined; + modify.call( + this, + () => { + const change = new Delta() + .retain(blot.offset(this)) + .retain({ [blot.statics.blotName]: delta }); + return this.editor.update(change, [], selectionInfo); + }, + Quill.sources.USER, + ); + }); if (html) { const contents = this.clipboard.convert({ html: `${html}


`, @@ -609,7 +625,7 @@ function shiftRange(range, index, length, source) { if (range == null) return null; let start; let end; - if (index instanceof Delta) { + if (index && typeof index.transformPosition === 'function') { [start, end] = [range.index, range.index + range.length].map(pos => index.transformPosition(pos, source !== Emitter.sources.USER), ); diff --git a/modules/keyboard.js b/modules/keyboard.js index 76bacc8de2..2ba8a84c05 100644 --- a/modules/keyboard.js +++ b/modules/keyboard.js @@ -116,6 +116,8 @@ class Keyboard extends Module { ); const matches = bindings.filter(binding => Keyboard.match(evt, binding)); if (matches.length === 0) return; + const blot = Quill.find(evt.target, true); + if (blot && blot.scroll !== this.quill.scroll) return; const range = this.quill.getSelection(); if (range == null || !this.quill.hasFocus()) return; const [line, offset] = this.quill.getLine(range.index); diff --git a/modules/tableEmbed.js b/modules/tableEmbed.js new file mode 100644 index 0000000000..7f3c3f47b5 --- /dev/null +++ b/modules/tableEmbed.js @@ -0,0 +1,222 @@ +import Delta from 'quill-delta'; +import Module from '../core/module'; + +const parseCellIdentity = identity => { + const parts = identity.split(':'); + return [Number(parts[0]) - 1, Number(parts[1]) - 1]; +}; + +const stringifyCellIdentity = (row, column) => `${row + 1}:${column + 1}`; + +export const composePosition = (delta, index) => { + let newIndex = index; + const thisIter = Delta.Op.iterator(delta.ops); + let offset = 0; + while (thisIter.hasNext() && offset <= newIndex) { + const length = thisIter.peekLength(); + const nextType = thisIter.peekType(); + thisIter.next(); + switch (nextType) { + case 'delete': + if (length > newIndex - offset) { + return null; + } + newIndex -= length; + break; + case 'insert': + newIndex += length; + offset += length; + break; + default: + offset += length; + break; + } + } + return newIndex; +}; + +const compactCellData = ({ content, attributes }) => { + const data = {}; + if (content.length() > 0) { + data.content = content.ops; + } + if (attributes && Object.keys(attributes).length > 0) { + data.attributes = attributes; + } + return Object.keys(data).length > 0 ? data : null; +}; + +const compactTableData = ({ rows, columns, cells }) => { + const data = {}; + if (rows.length() > 0) { + data.rows = rows.ops; + } + + if (columns.length() > 0) { + data.columns = columns.ops; + } + + if (Object.keys(cells).length) { + data.cells = cells; + } + + return data; +}; + +const reindexCellIdentities = (cells, { rows, columns }) => { + const reindexedCells = {}; + Object.keys(cells).forEach(identity => { + let [row, column] = parseCellIdentity(identity); + + row = composePosition(rows, row); + column = composePosition(columns, column); + + if (row !== null && column !== null) { + const newPosition = stringifyCellIdentity(row, column); + reindexedCells[newPosition] = cells[identity]; + } + }, false); + return reindexedCells; +}; + +export const tableHandler = { + compose(a, b, keepNull) { + const rows = new Delta(a.rows || []).compose(new Delta(b.rows || [])); + const columns = new Delta(a.columns || []).compose( + new Delta(b.columns || []), + ); + + const cells = reindexCellIdentities(a.cells || {}, { + rows: new Delta(b.rows || []), + columns: new Delta(b.columns || []), + }); + + Object.keys(b.cells || {}).forEach(identity => { + const aCell = cells[identity] || {}; + const bCell = b.cells[identity]; + + const content = new Delta(aCell.content || []).compose( + new Delta(bCell.content || []), + ); + + const attributes = Delta.AttributeMap.compose( + aCell.attributes, + bCell.attributes, + keepNull, + ); + + const cell = compactCellData({ content, attributes }); + if (cell) { + cells[identity] = cell; + } else { + delete cells[identity]; + } + }); + + return compactTableData({ rows, columns, cells }); + }, + transform(a, b, priority) { + const aDeltas = { + rows: new Delta(a.rows || []), + columns: new Delta(a.columns || []), + }; + + const bDeltas = { + rows: new Delta(b.rows || []), + columns: new Delta(b.columns || []), + }; + + const rows = aDeltas.rows.transform(bDeltas.rows, priority); + const columns = aDeltas.columns.transform(bDeltas.columns, priority); + + const cells = reindexCellIdentities(b.cells || {}, { + rows: bDeltas.rows.transform(aDeltas.rows, !priority), + columns: bDeltas.columns.transform(aDeltas.columns, !priority), + }); + + Object.keys(a.cells || {}).forEach(identity => { + let [row, column] = parseCellIdentity(identity); + row = composePosition(rows, row); + column = composePosition(columns, column); + + if (row !== null && column !== null) { + const newIdentity = stringifyCellIdentity(row, column); + + const aCell = a.cells[identity]; + const bCell = cells[newIdentity]; + if (bCell) { + const content = new Delta(aCell.content || []).transform( + new Delta(bCell.content || []), + priority, + ); + + const attributes = Delta.AttributeMap.transform( + aCell.attributes, + bCell.attributes, + priority, + ); + + const cell = compactCellData({ content, attributes }); + if (cell) { + cells[newIdentity] = cell; + } else { + delete cells[newIdentity]; + } + } + } + }); + + return compactTableData({ rows, columns, cells }); + }, + invert(change, base) { + const rows = new Delta(change.rows || []).invert( + new Delta(base.rows || []), + ); + const columns = new Delta(change.columns || []).invert( + new Delta(base.columns || []), + ); + const cells = reindexCellIdentities(change.cells || {}, { + rows, + columns, + }); + Object.keys(cells).forEach(identity => { + const changeCell = cells[identity] || {}; + const baseCell = (base.cells || {})[identity] || {}; + const content = new Delta(changeCell.content || []).invert( + new Delta(baseCell.content || []), + ); + const attributes = Delta.AttributeMap.invert( + changeCell.attributes, + baseCell.attributes, + ); + const cell = compactCellData({ content, attributes }); + if (cell) { + cells[identity] = cell; + } else { + delete cells[identity]; + } + }); + + // Cells may be removed when their row or column is removed + // by row/column deltas. We should add them back. + Object.keys(base.cells || {}).forEach(identity => { + const [row, column] = parseCellIdentity(identity); + if ( + composePosition(new Delta(change.rows || []), row) === null || + composePosition(new Delta(change.columns || []), column) === null + ) { + cells[identity] = base.cells[identity]; + } + }); + + return compactTableData({ rows, columns, cells }); + }, +}; + +class TableEmbed extends Module { + static register() { + Delta.registerEmbed('table-embed', tableHandler); + } +} + +export default TableEmbed; diff --git a/package-lock.json b/package-lock.json index a57f16cfcc..f124c6f76b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -348,6 +348,28 @@ "js-tokens": "^4.0.0" } }, + "@babel/node": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/@babel/node/-/node-7.14.2.tgz", + "integrity": "sha512-QB/C+Kl6gIYpTjZ/hcZj+chkiAVGcgSHuR849cdNvNJBz4VztO2775/o2ge8imB94EAsLcgkrdWH/3+UIVv1TA==", + "dev": true, + "requires": { + "@babel/register": "^7.13.16", + "commander": "^4.0.1", + "core-js": "^3.2.1", + "node-environment-flags": "^1.0.5", + "regenerator-runtime": "^0.13.4", + "v8flags": "^3.1.1" + }, + "dependencies": { + "commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true + } + } + }, "@babel/parser": { "version": "7.9.4", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.4.tgz", @@ -963,6 +985,19 @@ "esutils": "^2.0.2" } }, + "@babel/register": { + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.13.16.tgz", + "integrity": "sha512-dh2t11ysujTwByQjXNgJ48QZ2zcXKQVdV8s0TbeMI0flmtGWCdTwK9tJiACHXPLmncm5+ktNn/diojA45JE4jg==", + "dev": true, + "requires": { + "clone-deep": "^4.0.1", + "find-cache-dir": "^2.0.0", + "make-dir": "^2.1.0", + "pirates": "^4.0.0", + "source-map-support": "^0.5.16" + } + }, "@babel/runtime": { "version": "7.9.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz", @@ -2423,6 +2458,16 @@ "unset-value": "^1.0.0" } }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, "callsite": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", @@ -2603,6 +2648,17 @@ "wrap-ansi": "^5.1.0" } }, + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -2837,6 +2893,12 @@ "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", "dev": true }, + "core-js": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.13.1.tgz", + "integrity": "sha512-JqveUc4igkqwStL2RTRn/EPFGBOfEZHxJl/8ej1mXJR75V3go2mFF4bmUYkEIT1rveHKnkUlcJX/c+f1TyIovQ==", + "dev": true + }, "core-js-compat": { "version": "3.6.4", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.4.tgz", @@ -5662,6 +5724,17 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, "get-stdin": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz", @@ -5843,6 +5916,12 @@ "function-bind": "^1.1.1" } }, + "has-bigints": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", + "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", + "dev": true + }, "has-binary2": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz", @@ -6596,6 +6675,12 @@ "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", "dev": true }, + "is-bigint": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.2.tgz", + "integrity": "sha512-0JV5+SOCQkIdzjBK9buARcV804Ddu7A0Qet6sHi3FimE9ne6m4BGQZfRn+NZiXbBk4F4XmHfDZIipLj9pX8dSA==", + "dev": true + }, "is-binary-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", @@ -6605,6 +6690,15 @@ "binary-extensions": "^1.0.0" } }, + "is-boolean-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.1.tgz", + "integrity": "sha512-bXdQWkECBUIAcCkeH1unwJLIpZYaa5VvuygSyS/c2lf719mTKZDU5UdDRlpd01UjADgmW8RfqaP+mRaVPdr/Ng==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -6704,6 +6798,12 @@ "is-extglob": "^2.1.1" } }, + "is-negative-zero": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz", + "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==", + "dev": true + }, "is-number": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", @@ -6724,6 +6824,12 @@ } } }, + "is-number-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.5.tgz", + "integrity": "sha512-RU0lI/n95pMoUKu9v1BZP5MBcZuNSVJkMkAG2dJqC4z2GlkGUNeH68SuHuBKBD/XFe+LHZ+f9BKkLET60Niedw==", + "dev": true + }, "is-path-cwd": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", @@ -8183,6 +8289,24 @@ } } }, + "node-environment-flags": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.6.tgz", + "integrity": "sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==", + "dev": true, + "requires": { + "object.getownpropertydescriptors": "^2.0.3", + "semver": "^5.7.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, "node-forge": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz", @@ -8220,6 +8344,12 @@ "vm-browserify": "0.0.4" } }, + "node-modules-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", + "integrity": "sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=", + "dev": true + }, "node-releases": { "version": "1.1.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.53.tgz", @@ -8510,6 +8640,135 @@ } } }, + "object.getownpropertydescriptors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.2.tgz", + "integrity": "sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.2" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.3.tgz", + "integrity": "sha512-nQIr12dxV7SSxE6r6f1l3DtAeEYdsGpps13dR0TwJg1S8gyp4ZPgy3FZcHBgbiQqnoqSTb+oC+kO4UQ0C/J8vw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "is-callable": "^1.2.3", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.3", + "is-string": "^1.0.6", + "object-inspect": "^1.10.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "dev": true + }, + "is-callable": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", + "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==", + "dev": true + }, + "is-regex": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.3.tgz", + "integrity": "sha512-qSVXFz28HM7y+IWX6vLCsexdlvzT1PJNFSBuaQLQ5o0IEw8UDYW6/2+eCMVyIsbM8CNLX2a/QWmSpyxYEHY7CQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-symbols": "^1.0.2" + } + }, + "is-string": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.6.tgz", + "integrity": "sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w==", + "dev": true + }, + "is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "object-inspect": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz", + "integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + } + } + }, "object.pick": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", @@ -8780,9 +9039,9 @@ } }, "parchment": { - "version": "2.0.0-dev.2", - "resolved": "https://registry.npmjs.org/parchment/-/parchment-2.0.0-dev.2.tgz", - "integrity": "sha512-4fgRny4pPISoML08Zp7poi52Dff3E2G1ORTi2D/acJ/RiROdDAMDB6VcQNfBcmehrX5Wixp6dxh6JjLyE5yUNQ==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-2.0.0.tgz", + "integrity": "sha512-lLq8Q43ZcH3lfhY5F22F8lSkWay7nQMWeIpAIwywe1NFd5tWkhH7cyboM71MXz+00ZTGR86UM+0K9cGIx/jR7g==" }, "parent-module": { "version": "1.0.1", @@ -8988,6 +9247,15 @@ "pinkie": "^2.0.0" } }, + "pirates": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.1.tgz", + "integrity": "sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA==", + "dev": true, + "requires": { + "node-modules-regexp": "^1.0.0" + } + }, "pkg-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", @@ -9457,8 +9725,8 @@ "dev": true }, "quill-delta": { - "version": "4.2.2", - "resolved": "github:quilljs/delta#e5517726f6665e293e851457b1cc0c7a17576e50", + "version": "github:quilljs/delta#87cd1e6de795eb29abe79a29429ca3b126dc9031", + "from": "github:quilljs/delta#87cd1e6de795eb29abe79a29429ca3b126dc9031", "requires": { "fast-diff": "1.2.0", "lodash.clonedeep": "^4.5.0", @@ -10259,6 +10527,15 @@ "safe-buffer": "^5.0.1" } }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -12023,6 +12300,26 @@ "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==", "dev": true }, + "unbox-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", + "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has-bigints": "^1.0.1", + "has-symbols": "^1.0.2", + "which-boxed-primitive": "^1.0.2" + }, + "dependencies": { + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "dev": true + } + } + }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", @@ -12258,6 +12555,15 @@ "integrity": "sha512-CNmdbwQMBjwr9Gsmohvm0pbL954tJrNzf6gWL3K+QMQf00PF7ERGrEiLgjuU3mKreLC2MeGhUsNV9ybTbLgd3w==", "dev": true }, + "v8flags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz", + "integrity": "sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg==", + "dev": true, + "requires": { + "homedir-polyfill": "^1.0.1" + } + }, "validate-npm-package-license": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.3.tgz", @@ -12978,6 +13284,36 @@ "isexe": "^2.0.0" } }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "dependencies": { + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "dev": true + }, + "is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + } + } + }, "which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", diff --git a/package.json b/package.json index 53492be397..442460a88a 100644 --- a/package.json +++ b/package.json @@ -36,11 +36,12 @@ "lodash.clonedeep": "^4.5.0", "lodash.isequal": "^4.5.0", "lodash.merge": "^4.5.0", - "parchment": "2.0.0-dev.2", - "quill-delta": "4.2.2" + "parchment": "^2.0.0", + "quill-delta": "github:quilljs/delta#87cd1e6de795eb29abe79a29429ca3b126dc9031" }, "devDependencies": { "@babel/core": "^7.9.0", + "@babel/node": "^7.14.2", "@babel/preset-env": "^7.9.5", "babel-loader": "^8.1.0", "babel-plugin-istanbul": "^6.0.0", @@ -155,10 +156,11 @@ "develop": "npm run start", "lint": "eslint blots core formats modules themes ui test", "start": "npm run build:webpack; bundle exec foreman start -f _develop/procfile", - "test": "npm run test:unit", - "test:all": "npm run test:unit; npm run test:functional", + "test": "npm run test:unit; npm run test:random", + "test:all": "npm run test:unit; npm run test:functional; npm run test:random", "test:functional": "./_develop/scripts/puppeteer.sh", "test:unit": "npm run build; karma start _develop/karma.config.js", + "test:random": "babel-node ./node_modules/.bin/jasmine test/random.js --presets=@babel/preset-env", "test:coverage": "webpack --env.coverage --config _develop/webpack.config.js; karma start _develop/karma.config.js --reporters coverage", "travis": "npm run lint && karma start _develop/karma.config.js --reporters dots,saucelabs" }, diff --git a/test/random.js b/test/random.js new file mode 100644 index 0000000000..81ed72ec30 --- /dev/null +++ b/test/random.js @@ -0,0 +1,165 @@ +import Delta from 'quill-delta'; +import TableEmbed from '../modules/tableEmbed'; + +// Random testing in order to find unknown issues. + +const random = choices => { + if (typeof choices === 'number') { + return Math.floor(Math.random() * choices); + } + return choices[random(choices.length)]; +}; + +const getRandomRowColumnId = () => { + const characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + return new Array(8) + .fill(0) + .map(() => characters.charAt(Math.floor(Math.random() * characters.length))) + .join(''); +}; + +const attachAttributes = obj => { + const getRandomAttributes = () => { + const attributeCount = random([1, 4, 8]); + const allowedAttributes = ['align', 'background', 'color', 'font']; + const allowedValues = ['center', 'red', 'left', 'uppercase']; + const attributes = {}; + new Array(attributeCount).fill(0).forEach(() => { + attributes[random(allowedAttributes)] = random(allowedValues); + }); + return attributes; + }; + if (random([true, false])) { + obj.attributes = getRandomAttributes(); + } + return obj; +}; + +const getRandomCellContent = () => { + const opCount = random([1, 2, 3]); + const delta = new Delta(); + new Array(opCount).fill(0).forEach(() => { + delta.push( + attachAttributes({ + insert: new Array(random(10) + 1) + .fill(0) + .map(() => random(['a', 'b', 'c', 'c', 'e', 'f', 'g'])) + .join(''), + }), + ); + }); + return delta.ops; +}; + +const getRandomChange = base => { + const table = {}; + const dimension = { + rows: new Delta(base.ops[0].insert['table-embed'].rows || []).length(), + columns: new Delta( + base.ops[0].insert['table-embed'].columns || [], + ).length(), + }; + ['rows', 'columns'].forEach(field => { + const baseLength = dimension[field]; + const action = random(['insert', 'delete', 'retain']); + const delta = new Delta(); + switch (action) { + case 'insert': + delta.retain(random(baseLength + 1)); + delta.push( + attachAttributes({ insert: { id: getRandomRowColumnId() } }), + ); + break; + case 'delete': + if (baseLength >= 1) { + delta.retain(random(baseLength)); + delta.delete(1); + } + break; + case 'retain': + if (baseLength >= 1) { + delta.retain(random(baseLength)); + delta.push(attachAttributes({ retain: 1 })); + } + break; + default: + break; + } + if (delta.length() > 0) { + table[field] = delta.ops; + } + }); + + const updateCellCount = random([0, 1, 2, 3]); + new Array(updateCellCount).fill(0).forEach(() => { + const row = random(dimension.rows); + const column = random(dimension.columns); + const cellIdentityToModify = `${row + 1}:${column + 1}`; + table.cells = { + [cellIdentityToModify]: attachAttributes({ + content: getRandomCellContent(), + }), + }; + }); + return new Delta([attachAttributes({ retain: { 'table-embed': table } })]); +}; + +const getRandomRowColumnInsert = count => { + return new Delta( + new Array(count) + .fill(0) + .map(() => attachAttributes({ insert: { id: getRandomRowColumnId() } })), + ).ops; +}; + +const getRandomBase = () => { + const rowCount = random([0, 1, 2, 3]); + const columnCount = random([0, 1, 2]); + const cellCount = random([0, 1, 2, 3, 4, 5]); + + const table = {}; + if (rowCount) table.rows = getRandomRowColumnInsert(rowCount); + if (columnCount) table.columns = getRandomRowColumnInsert(columnCount); + if (cellCount) { + const cells = {}; + new Array(cellCount).fill(0).forEach(() => { + const row = random(rowCount); + const column = random(columnCount); + const identity = `${row + 1}:${column + 1}`; + const cell = attachAttributes({}); + if (random([true, false])) { + cell.content = getRandomCellContent(); + } + if (Object.keys(cell).length) { + cells[identity] = cell; + } + }); + if (Object.keys(cells).length) table.cells = cells; + } + return new Delta([{ insert: { 'table-embed': table } }]); +}; + +const runTestCase = () => { + const base = getRandomBase(); + const change = getRandomChange(base); + expect(base).toEqual(base.compose(change).compose(change.invert(base))); + + const anotherChange = getRandomChange(base); + expect(change.compose(change.transform(anotherChange, true))).toEqual( + anotherChange.compose(anotherChange.transform(change)), + ); +}; + +describe('random tests', () => { + beforeAll(() => { + TableEmbed.register(); + }); + + it('delta', () => { + for (let i = 0; i < 20; i += 1) { + for (let j = 0; j < 1000; j += 1) { + runTestCase(); + } + } + }); +}); diff --git a/test/unit.js b/test/unit.js index 64d2529545..a6bc9c9a6d 100644 --- a/test/unit.js +++ b/test/unit.js @@ -30,6 +30,7 @@ import './unit/modules/history'; import './unit/modules/keyboard'; import './unit/modules/syntax'; import './unit/modules/table'; +import './unit/modules/tableEmbed'; import './unit/modules/toolbar'; import './unit/ui/picker'; diff --git a/test/unit/modules/tableEmbed.js b/test/unit/modules/tableEmbed.js new file mode 100644 index 0000000000..670692bce5 --- /dev/null +++ b/test/unit/modules/tableEmbed.js @@ -0,0 +1,601 @@ +import Delta from 'quill-delta'; +import TableEmbed from '../../../modules/tableEmbed'; + +describe('Delta', () => { + beforeAll(() => { + TableEmbed.register(); + }); + + describe('compose', () => { + it('adds a row', () => { + const base = new Delta([ + { + insert: { + 'table-embed': { + rows: [ + { insert: { id: '11111111' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '22222222' } }, + { insert: { id: '33333333' }, attributes: { width: 30 } }, + { insert: { id: '44444444' } }, + ], + cells: { + '1:2': { + content: [{ insert: 'Hello' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]); + + const change = new Delta([ + { + retain: { 'table-embed': { rows: [{ insert: { id: '55555555' } }] } }, + }, + ]); + + expect(base.compose(change)).toEqual( + new Delta([ + { + insert: { + 'table-embed': { + rows: [ + { insert: { id: '55555555' } }, + { insert: { id: '11111111' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '22222222' } }, + { insert: { id: '33333333' }, attributes: { width: 30 } }, + { insert: { id: '44444444' } }, + ], + cells: { + '2:2': { + content: [{ insert: 'Hello' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]), + ); + }); + + it('adds two rows', () => { + const base = new Delta([ + { + insert: { + 'table-embed': { + rows: [ + { insert: { id: '11111111' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '22222222' } }, + { insert: { id: '33333333' }, attributes: { width: 30 } }, + { insert: { id: '44444444' } }, + ], + cells: { + '1:2': { + content: [{ insert: 'Hello' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]); + + const change = new Delta([ + { + retain: { + 'table-embed': { + rows: [ + { insert: { id: '55555555' } }, + { insert: { id: '66666666' } }, + ], + }, + }, + }, + ]); + + expect(base.compose(change)).toEqual( + new Delta([ + { + insert: { + 'table-embed': { + rows: [ + { insert: { id: '55555555' } }, + { insert: { id: '66666666' } }, + { insert: { id: '11111111' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '22222222' } }, + { insert: { id: '33333333' }, attributes: { width: 30 } }, + { insert: { id: '44444444' } }, + ], + cells: { + '3:2': { + content: [{ insert: 'Hello' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]), + ); + }); + + it('adds a row and changes cell content', () => { + const base = new Delta([ + { + insert: { + 'table-embed': { + rows: [ + { insert: { id: '11111111' } }, + { insert: { id: '22222222' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '33333333' } }, + { insert: { id: '44444444' }, attributes: { width: 30 } }, + { insert: { id: '55555555' } }, + ], + cells: { + '2:2': { content: [{ insert: 'Hello' }] }, + '2:3': { content: [{ insert: 'World' }] }, + }, + }, + }, + }, + ]); + + const change = new Delta([ + { + retain: { + 'table-embed': { + rows: [{ insert: { id: '66666666' } }], + cells: { + '3:2': { attributes: { align: 'right' } }, + '3:3': { content: [{ insert: 'Hello ' }] }, + }, + }, + }, + }, + ]); + + expect(base.compose(change)).toEqual( + new Delta([ + { + insert: { + 'table-embed': { + rows: [ + { insert: { id: '66666666' } }, + { insert: { id: '11111111' } }, + { insert: { id: '22222222' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '33333333' } }, + { insert: { id: '44444444' }, attributes: { width: 30 } }, + { insert: { id: '55555555' } }, + ], + cells: { + '3:2': { + content: [{ insert: 'Hello' }], + attributes: { align: 'right' }, + }, + '3:3': { content: [{ insert: 'Hello World' }] }, + }, + }, + }, + }, + ]), + ); + }); + + it('deletes a column', () => { + const base = new Delta([ + { + insert: { + 'table-embed': { + rows: [ + { insert: { id: '11111111' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '22222222' } }, + { insert: { id: '33333333' }, attributes: { width: 30 } }, + { insert: { id: '44444444' } }, + ], + cells: { + '1:2': { + content: [{ insert: 'Hello' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]); + + const change = new Delta([ + { + retain: { + 'table-embed': { + columns: [{ retain: 1 }, { delete: 1 }], + }, + }, + }, + ]); + + expect(base.compose(change)).toEqual( + new Delta([ + { + insert: { + 'table-embed': { + rows: [ + { insert: { id: '11111111' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '22222222' } }, + { insert: { id: '44444444' } }, + ], + }, + }, + }, + ]), + ); + }); + + it('removes a cell attributes', () => { + const base = new Delta([ + { + insert: { + 'table-embed': { + cells: { '1:2': { attributes: { align: 'center' } } }, + }, + }, + }, + ]); + + const change = new Delta([ + { + retain: { + 'table-embed': { + cells: { '1:2': { attributes: { align: null } } }, + }, + }, + }, + ]); + + expect(base.compose(change)).toEqual( + new Delta([{ insert: { 'table-embed': {} } }]), + ); + }); + + it('removes all rows', () => { + const base = new Delta([ + { + insert: { 'table-embed': { rows: [{ insert: { id: '11111111' } }] } }, + }, + ]); + + const change = new Delta([ + { retain: { 'table-embed': { rows: [{ delete: 1 }] } } }, + ]); + + expect(base.compose(change)).toEqual( + new Delta([{ insert: { 'table-embed': {} } }]), + ); + }); + }); + + describe('transform', () => { + it('transform rows and columns', () => { + const change1 = new Delta([ + { + retain: { + 'table-embed': { + rows: [ + { insert: { id: '11111111' } }, + { insert: { id: '22222222' } }, + { insert: { id: '33333333' }, attributes: { height: 100 } }, + ], + columns: [ + { insert: { id: '44444444' }, attributes: { width: 100 } }, + { insert: { id: '55555555' } }, + { insert: { id: '66666666' } }, + ], + }, + }, + }, + ]); + + const change2 = new Delta([ + { + retain: { + 'table-embed': { + rows: [{ delete: 1 }, { retain: 1, attributes: { height: 50 } }], + columns: [ + { delete: 1 }, + { retain: 2, attributes: { width: 40 } }, + ], + }, + }, + }, + ]); + + expect(change1.transform(change2)).toEqual( + new Delta([ + { + retain: { + 'table-embed': { + rows: [ + { retain: 3 }, + { delete: 1 }, + { retain: 1, attributes: { height: 50 } }, + ], + columns: [ + { retain: 3 }, + { delete: 1 }, + { retain: 2, attributes: { width: 40 } }, + ], + }, + }, + }, + ]), + ); + }); + + it('transform cells', () => { + const change1 = new Delta([ + { + retain: { + 'table-embed': { + rows: [{ insert: { id: '22222222' } }], + cells: { + '8:1': { + content: [{ insert: 'Hello 8:1!' }], + }, + '21:2': { + content: [{ insert: 'Hello 21:2!' }], + }, + }, + }, + }, + }, + ]); + + const change2 = new Delta([ + { + retain: { + 'table-embed': { + rows: [{ delete: 1 }], + cells: { + '6:1': { + content: [{ insert: 'Hello 6:1!' }], + }, + '52:8': { + content: [{ insert: 'Hello 52:8!' }], + }, + }, + }, + }, + }, + ]); + + expect(change1.transform(change2)).toEqual( + new Delta([ + { + retain: { + 'table-embed': { + rows: [{ retain: 1 }, { delete: 1 }], + cells: { + '7:1': { + content: [{ insert: 'Hello 6:1!' }], + }, + '53:8': { + content: [{ insert: 'Hello 52:8!' }], + }, + }, + }, + }, + }, + ]), + ); + }); + + it('transform cell attributes', () => { + const change1 = new Delta([ + { + retain: { + 'table-embed': { + cells: { '8:1': { attributes: { align: 'right' } } }, + }, + }, + }, + ]); + + const change2 = new Delta([ + { + retain: { + 'table-embed': { + cells: { '8:1': { attributes: { align: 'left' } } }, + }, + }, + }, + ]); + + expect(change1.transform(change2)).toEqual( + new Delta([ + { + retain: { + 'table-embed': { + cells: { '8:1': { attributes: { align: 'left' } } }, + }, + }, + }, + ]), + ); + + expect(change1.transform(change2, true)).toEqual( + new Delta([{ retain: { 'table-embed': {} } }]), + ); + }); + }); + + describe('invert', () => { + it('reverts rows and columns', () => { + const base = new Delta([ + { + insert: { + 'table-embed': { + rows: [ + { insert: { id: '11111111' } }, + { insert: { id: '22222222' } }, + ], + columns: [ + { insert: { id: '33333333' } }, + { insert: { id: '44444444' }, attributes: { width: 100 } }, + ], + }, + }, + }, + ]); + + const change = new Delta([ + { + retain: { + 'table-embed': { + rows: [{ delete: 1 }], + columns: [{ retain: 1 }, { delete: 1 }], + }, + }, + }, + ]); + + expect(change.invert(base)).toEqual( + new Delta([ + { + retain: { + 'table-embed': { + rows: [{ insert: { id: '11111111' } }], + columns: [ + { retain: 1 }, + { insert: { id: '44444444' }, attributes: { width: 100 } }, + ], + }, + }, + }, + ]), + ); + }); + + it('inverts cell content', () => { + const base = new Delta([ + { + insert: { + 'table-embed': { + rows: [ + { insert: { id: '11111111' } }, + { insert: { id: '22222222' } }, + ], + columns: [ + { insert: { id: '33333333' } }, + { insert: { id: '44444444' } }, + ], + cells: { + '1:2': { + content: [{ insert: 'Hello 1:2' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]); + const change = new Delta([ + { + retain: { + 'table-embed': { + rows: [{ insert: { id: '55555555' } }], + cells: { + '2:2': { + content: [{ retain: 6 }, { insert: '2' }, { delete: 1 }], + }, + }, + }, + }, + }, + ]); + expect(change.invert(base)).toEqual( + new Delta([ + { + retain: { + 'table-embed': { + rows: [{ delete: 1 }], + cells: { + '1:2': { + content: [{ retain: 6 }, { insert: '1' }, { delete: 1 }], + }, + }, + }, + }, + }, + ]), + ); + }); + + it('inverts cells removed by row/column delta', () => { + const base = new Delta([ + { + insert: { + 'table-embed': { + rows: [ + { insert: { id: '11111111' } }, + { insert: { id: '22222222' } }, + ], + columns: [ + { insert: { id: '33333333' } }, + { insert: { id: '44444444' } }, + ], + cells: { + '1:2': { + content: [{ insert: 'content' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]); + const change = new Delta([ + { + retain: { + 'table-embed': { + columns: [{ retain: 1 }, { delete: 1 }], + }, + }, + }, + ]); + expect(change.invert(base)).toEqual( + new Delta([ + { + retain: { + 'table-embed': { + columns: [{ retain: 1 }, { insert: { id: '44444444' } }], + cells: { + '1:2': { + content: [{ insert: 'content' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]), + ); + }); + }); +}); diff --git a/test/unit/utils/delta.js b/test/unit/utils/delta.js new file mode 100644 index 0000000000..d690c4919d --- /dev/null +++ b/test/unit/utils/delta.js @@ -0,0 +1,516 @@ +import Delta from 'quill-delta'; + +describe('Delta', () => { + describe('compose', () => { + it('adds a row', () => { + const base = new Delta([ + { + insert: { + table: { + rows: [ + { insert: { id: '11111111' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '22222222' } }, + { insert: { id: '33333333' }, attributes: { width: 30 } }, + { insert: { id: '44444444' } }, + ], + cells: { + '1:2': { + content: [{ insert: 'Hello' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]); + + const change = new Delta([ + { retain: { table: { rows: [{ insert: { id: '55555555' } }] } } }, + ]); + + expect(base.compose(change)).toEqual( + new Delta([ + { + insert: { + table: { + rows: [ + { insert: { id: '55555555' } }, + { insert: { id: '11111111' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '22222222' } }, + { insert: { id: '33333333' }, attributes: { width: 30 } }, + { insert: { id: '44444444' } }, + ], + cells: { + '2:2': { + content: [{ insert: 'Hello' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]), + ); + }); + + it('adds a row and changes cell content', () => { + const base = new Delta([ + { + insert: { + table: { + rows: [ + { insert: { id: '11111111' } }, + { insert: { id: '22222222' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '33333333' } }, + { insert: { id: '44444444' }, attributes: { width: 30 } }, + { insert: { id: '55555555' } }, + ], + cells: { + '2:2': { content: [{ insert: 'Hello' }] }, + '2:3': { content: [{ insert: 'World' }] }, + }, + }, + }, + }, + ]); + + const change = new Delta([ + { + retain: { + table: { + rows: [{ insert: { id: '66666666' } }], + cells: { + '3:2': { attributes: { align: 'right' } }, + '3:3': { content: [{ insert: 'Hello ' }] }, + }, + }, + }, + }, + ]); + + expect(base.compose(change)).toEqual( + new Delta([ + { + insert: { + table: { + rows: [ + { insert: { id: '66666666' } }, + { insert: { id: '11111111' } }, + { insert: { id: '22222222' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '33333333' } }, + { insert: { id: '44444444' }, attributes: { width: 30 } }, + { insert: { id: '55555555' } }, + ], + cells: { + '3:2': { + content: [{ insert: 'Hello' }], + attributes: { align: 'right' }, + }, + '3:3': { content: [{ insert: 'Hello World' }] }, + }, + }, + }, + }, + ]), + ); + }); + + it('deletes a column', () => { + const base = new Delta([ + { + insert: { + table: { + rows: [ + { insert: { id: '11111111' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '22222222' } }, + { insert: { id: '33333333' }, attributes: { width: 30 } }, + { insert: { id: '44444444' } }, + ], + cells: { + '1:2': { + content: [{ insert: 'Hello' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]); + + const change = new Delta([ + { + retain: { + table: { + columns: [{ retain: 1 }, { delete: 1 }], + }, + }, + }, + ]); + + expect(base.compose(change)).toEqual( + new Delta([ + { + insert: { + table: { + rows: [ + { insert: { id: '11111111' }, attributes: { height: 20 } }, + ], + columns: [ + { insert: { id: '22222222' } }, + { insert: { id: '44444444' } }, + ], + }, + }, + }, + ]), + ); + }); + + it('removes a cell attributes', () => { + const base = new Delta([ + { + insert: { + table: { cells: { '1:2': { attributes: { align: 'center' } } } }, + }, + }, + ]); + + const change = new Delta([ + { + retain: { + table: { cells: { '1:2': { attributes: { align: null } } } }, + }, + }, + ]); + + expect(base.compose(change)).toEqual( + new Delta([{ insert: { table: {} } }]), + ); + }); + + it('removes all rows', () => { + const base = new Delta([ + { insert: { table: { rows: [{ insert: { id: '11111111' } }] } } }, + ]); + + const change = new Delta([ + { retain: { table: { rows: [{ delete: 1 }] } } }, + ]); + + expect(base.compose(change)).toEqual( + new Delta([{ insert: { table: {} } }]), + ); + }); + }); + + describe('transform', () => { + it('transform rows and columns', () => { + const change1 = new Delta([ + { + retain: { + table: { + rows: [ + { insert: { id: '11111111' } }, + { insert: { id: '22222222' } }, + { insert: { id: '33333333' }, attributes: { height: 100 } }, + ], + columns: [ + { insert: { id: '44444444' }, attributes: { width: 100 } }, + { insert: { id: '55555555' } }, + { insert: { id: '66666666' } }, + ], + }, + }, + }, + ]); + + const change2 = new Delta([ + { + retain: { + table: { + rows: [{ delete: 1 }, { retain: 1, attributes: { height: 50 } }], + columns: [ + { delete: 1 }, + { retain: 2, attributes: { width: 40 } }, + ], + }, + }, + }, + ]); + + expect(change1.transform(change2)).toEqual( + new Delta([ + { + retain: { + table: { + rows: [ + { retain: 3 }, + { delete: 1 }, + { retain: 1, attributes: { height: 50 } }, + ], + columns: [ + { retain: 3 }, + { delete: 1 }, + { retain: 2, attributes: { width: 40 } }, + ], + }, + }, + }, + ]), + ); + }); + + it('transform cells', () => { + const change1 = new Delta([ + { + retain: { + table: { + rows: [{ insert: { id: '22222222' } }], + cells: { + '8:1': { + content: [{ insert: 'Hello 8:1!' }], + }, + '21:2': { + content: [{ insert: 'Hello 21:2!' }], + }, + }, + }, + }, + }, + ]); + + const change2 = new Delta([ + { + retain: { + table: { + rows: [{ delete: 1 }], + cells: { + '6:1': { + content: [{ insert: 'Hello 6:1!' }], + }, + '52:8': { + content: [{ insert: 'Hello 52:8!' }], + }, + }, + }, + }, + }, + ]); + + expect(change1.transform(change2)).toEqual( + new Delta([ + { + retain: { + table: { + rows: [{ retain: 1 }, { delete: 1 }], + cells: { + '7:1': { + content: [{ insert: 'Hello 6:1!' }], + }, + '53:8': { + content: [{ insert: 'Hello 52:8!' }], + }, + }, + }, + }, + }, + ]), + ); + }); + + it('transform cell attributes', () => { + const change1 = new Delta([ + { + retain: { + table: { cells: { '8:1': { attributes: { align: 'right' } } } }, + }, + }, + ]); + + const change2 = new Delta([ + { + retain: { + table: { cells: { '8:1': { attributes: { align: 'left' } } } }, + }, + }, + ]); + + expect(change1.transform(change2)).toEqual( + new Delta([ + { + retain: { + table: { cells: { '8:1': { attributes: { align: 'left' } } } }, + }, + }, + ]), + ); + + expect(change1.transform(change2, true)).toEqual( + new Delta([{ retain: { table: {} } }]), + ); + }); + }); + + describe('invert', () => { + it('reverts rows and columns', () => { + const base = new Delta([ + { + insert: { + table: { + rows: [ + { insert: { id: '11111111' } }, + { insert: { id: '22222222' } }, + ], + columns: [ + { insert: { id: '33333333' } }, + { insert: { id: '44444444' }, attributes: { width: 100 } }, + ], + }, + }, + }, + ]); + + const change = new Delta([ + { + retain: { + table: { + rows: [{ remove: { id: '22222222' } }], + columns: [{ retain: 1 }, { delete: 1 }], + }, + }, + }, + ]); + + expect(change.invert(base)).toEqual( + new Delta([ + { + retain: { + table: { + columns: [ + { retain: 1 }, + { insert: { id: '44444444' }, attributes: { width: 100 } }, + ], + }, + }, + }, + ]), + ); + }); + + it('inverts cell content', () => { + const base = new Delta([ + { + insert: { + table: { + rows: [ + { insert: { id: '11111111' } }, + { insert: { id: '22222222' } }, + ], + columns: [ + { insert: { id: '33333333' } }, + { insert: { id: '44444444' } }, + ], + cells: { + '1:2': { + content: [{ insert: 'Hello 1:2' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]); + const change = new Delta([ + { + retain: { + table: { + rows: [{ insert: { id: '55555555' } }], + cells: { + '2:2': { + content: [{ retain: 6 }, { insert: '2' }, { delete: 1 }], + }, + }, + }, + }, + }, + ]); + expect(change.invert(base)).toEqual( + new Delta([ + { + retain: { + table: { + rows: [{ delete: 1 }], + cells: { + '1:2': { + content: [{ retain: 6 }, { insert: '1' }, { delete: 1 }], + }, + }, + }, + }, + }, + ]), + ); + }); + + it('inverts cells removed by row/column delta', () => { + const base = new Delta([ + { + insert: { + table: { + rows: [ + { insert: { id: '11111111' } }, + { insert: { id: '22222222' } }, + ], + columns: [ + { insert: { id: '33333333' } }, + { insert: { id: '44444444' } }, + ], + cells: { + '1:2': { + content: [{ insert: 'content' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]); + const change = new Delta([ + { + retain: { + table: { + columns: [{ retain: 1 }, { delete: 1 }], + }, + }, + }, + ]); + expect(change.invert(base)).toEqual( + new Delta([ + { + retain: { + table: { + columns: [{ retain: 1 }, { insert: { id: '44444444' } }], + cells: { + '1:2': { + content: [{ insert: 'content' }], + attributes: { align: 'center' }, + }, + }, + }, + }, + }, + ]), + ); + }); + }); +});