diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ceeb9684..16da6f8f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,7 @@ - `Fix` - Create a new block when clicked at the bottom [#1588](https://github.com/codex-team/editor.js/issues/1588). - `Fix` — Fix sanitisation problem with Inline Tools [#1631](https://github.com/codex-team/editor.js/issues/1631) +- `Fix` — Fix copy in FireFox [1625](https://github.com/codex-team/editor.js/issues/1625) - `Refactoring` - The Sanitizer module is util now. - `Refactoring` - Tooltip module is util now. - `Refactoring` — Refactoring based on LGTM [#1577](https://github.com/codex-team/editor.js/issues/1577). diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 7afedca2..4d65bdc4 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -504,7 +504,7 @@ export default class Block { /** * call Tool's method with the instance context */ - if (this.toolInstance[methodName] && this.toolInstance[methodName] instanceof Function) { + if (_.isFunction(this.toolInstance[methodName])) { if (methodName === BlockToolAPI.APPEND_CALLBACK) { _.log( '`appendCallback` hook is deprecated and will be removed in the next major release. ' + diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index 89cd5077..e7f9336c 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -167,7 +167,7 @@ export default class BlockEvents extends Module { * * @param {ClipboardEvent} event - clipboard event */ - public handleCommandC(event: ClipboardEvent): Promise { + public handleCommandC(event: ClipboardEvent): void { const { BlockSelection } = this.Editor; if (!BlockSelection.anyBlockSelected) { @@ -175,7 +175,7 @@ export default class BlockEvents extends Module { } // Copy Selected Blocks - return BlockSelection.copySelectedBlocks(event); + BlockSelection.copySelectedBlocks(event); } /** @@ -183,21 +183,26 @@ export default class BlockEvents extends Module { * * @param {ClipboardEvent} event - clipboard event */ - public async handleCommandX(event: ClipboardEvent): Promise { + public handleCommandX(event: ClipboardEvent): void { const { BlockSelection, BlockManager, Caret } = this.Editor; if (!BlockSelection.anyBlockSelected) { return; } - await BlockSelection.copySelectedBlocks(event); + BlockSelection.copySelectedBlocks(event).then(() => { + const selectionPositionIndex = BlockManager.removeSelectedBlocks(); - const selectionPositionIndex = BlockManager.removeSelectedBlocks(); + /** + * Insert default block in place of removed ones + */ + const insertedBlock = BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true); - Caret.setToBlock(BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true), Caret.positions.START); + Caret.setToBlock(insertedBlock, Caret.positions.START); - /** Clear selection */ - BlockSelection.clearSelection(event); + /** Clear selection */ + BlockSelection.clearSelection(event); + }); } /** diff --git a/src/components/modules/blockSelection.ts b/src/components/modules/blockSelection.ts index 96ada11b..682b7aeb 100644 --- a/src/components/modules/blockSelection.ts +++ b/src/components/modules/blockSelection.ts @@ -286,7 +286,7 @@ export default class BlockSelection extends Module { * * @returns {Promise} */ - public async copySelectedBlocks(e: ClipboardEvent): Promise { + public copySelectedBlocks(e: ClipboardEvent): Promise { /** * Prevent default copy */ @@ -305,15 +305,22 @@ export default class BlockSelection extends Module { fakeClipboard.appendChild(fragment); }); - const savedData = await Promise.all(this.selectedBlocks.map((block) => block.save())); - const textPlain = Array.from(fakeClipboard.childNodes).map((node) => node.textContent) .join('\n\n'); const textHTML = fakeClipboard.innerHTML; e.clipboardData.setData('text/plain', textPlain); e.clipboardData.setData('text/html', textHTML); - e.clipboardData.setData(this.Editor.Paste.MIME_TYPE, JSON.stringify(savedData)); + + return Promise + .all(this.selectedBlocks.map((block) => block.save())) + .then(savedData => { + try { + e.clipboardData.setData(this.Editor.Paste.MIME_TYPE, JSON.stringify(savedData)); + } catch (err) { + // In Firefox we can't set data in async function + } + }); } /** diff --git a/test/cypress/support/commands.ts b/test/cypress/support/commands.ts index 7b8f8da0..fc829c09 100644 --- a/test/cypress/support/commands.ts +++ b/test/cypress/support/commands.ts @@ -62,3 +62,55 @@ Cypress.Commands.add('paste', { return subject; }); + +/** + * Copy command to dispatch copy event on subject + * + * @usage + * cy.get('div').copy().then(data => {}) + */ +Cypress.Commands.add('copy', { prevSubject: true }, async (subject) => { + const clipboardData: {[type: string]: any} = {}; + + const copyEvent = Object.assign(new Event('copy', { + bubbles: true, + cancelable: true, + }), { + clipboardData: { + setData: (type: string, data: any): void => { + console.log(type, data); + clipboardData[type] = data; + }, + }, + }); + + subject[0].dispatchEvent(copyEvent); + + return clipboardData; +}); + +/** + * Cut command to dispatch cut event on subject + * + * @usage + * cy.get('div').cut().then(data => {}) + */ +Cypress.Commands.add('cut', { prevSubject: true }, async (subject) => { + const clipboardData: {[type: string]: any} = {}; + + const copyEvent = Object.assign(new Event('cut', { + bubbles: true, + cancelable: true, + }), { + clipboardData: { + setData: (type: string, data: any): void => { + console.log(type, data); + clipboardData[type] = data; + }, + }, + }); + + subject[0].dispatchEvent(copyEvent); + + return clipboardData; +}); diff --git a/test/cypress/support/index.d.ts b/test/cypress/support/index.d.ts index f675962a..7d83933e 100644 --- a/test/cypress/support/index.d.ts +++ b/test/cypress/support/index.d.ts @@ -24,6 +24,22 @@ declare global { * @param data - map with MIME type as a key and data as value */ paste(data: {[type: string]: string}): Chainable + + /** + * Copy command to dispatch copy event on subject + * + * @usage + * cy.get('div').copy().then(data => {}) + */ + copy(): Chainable<{ [type: string]: any }>; + + /** + * Cut command to dispatch cut event on subject + * + * @usage + * cy.get('div').cut().then(data => {}) + */ + cut(): Chainable<{ [type: string]: any }>; } interface ApplicationWindow { diff --git a/test/cypress/tests/copy-paste.spec.ts b/test/cypress/tests/copy-paste.spec.ts new file mode 100644 index 00000000..97f6a0ec --- /dev/null +++ b/test/cypress/tests/copy-paste.spec.ts @@ -0,0 +1,274 @@ +import Header from '../../../example/tools/header'; +import Image from '../../../example/tools/simple-image'; +import * as _ from '../../../src/components/utils'; + +describe('Copy pasting from Editor', () => { + beforeEach(() => { + if (this && this.editorInstance) { + this.editorInstance.destroy(); + } else { + cy.createEditor({ + tools: { + header: Header, + image: Image, + }, + }).as('editorInstance'); + } + }); + + context('pasting', () => { + it('should paste plain text', () => { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .paste({ + 'text/plain': 'Some plain text', + }) + .should('contain', 'Some plain text'); + }); + + it('should paste inline html data', () => { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .paste({ + 'text/html': '

Some text

', + }) + .should('contain.html', 'Some text'); + }); + + it('should paste several blocks if plain text contains new lines', () => { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .paste({ + 'text/plain': 'First block\n\nSecond block', + }); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .then(blocks => { + expect(blocks[0].textContent).to.eq('First block'); + expect(blocks[1].textContent).to.eq('Second block'); + }); + }); + + it('should paste several blocks if html contains several paragraphs', () => { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .paste({ + 'text/html': '

First block

Second block

', + }); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .then(blocks => { + expect(blocks[0].textContent).to.eq('First block'); + expect(blocks[1].textContent).to.eq('Second block'); + }); + }); + + it('should paste using custom data type', () => { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .paste({ + 'application/x-editor-js': JSON.stringify([ + { + tool: 'paragraph', + data: { + text: 'First block', + }, + }, + { + tool: 'paragraph', + data: { + text: 'Second block', + }, + }, + ]), + }); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .then(blocks => { + expect(blocks[0].textContent).to.eq('First block'); + expect(blocks[1].textContent).to.eq('Second block'); + }); + }); + + it('should parse block tags', () => { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .paste({ + 'text/html': '

First block

Second block

', + }); + + cy.get('[data-cy=editorjs]') + .get('h2.ce-header') + .should('contain', 'First block'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-paragraph') + .should('contain', 'Second block'); + }); + + it('should parse pattern', () => { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .paste({ + 'text/plain': 'https://codex.so/public/app/img/external/codex2x.png', + }); + + cy.get('[data-cy=editorjs]') + .get('img') + .should('have.attr', 'src', 'https://codex.so/public/app/img/external/codex2x.png'); + }); + }); + + context('copying', () => { + it('should copy inline fragment', () => { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .type('Some text{selectall}') + .copy() + .then(clipboardData => { + /** + * As no blocks selected, clipboard data will be empty as will be handled by browser + */ + expect(clipboardData).to.be.empty; + }); + }); + + it('should copy several blocks', () => { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .type('First block{enter}'); + + cy.get('[data-cy=editorjs') + .get('div.ce-block') + .next() + .type('Second block') + .type('{movetostart}') + .trigger('keydown', { + shiftKey: true, + keyCode: _.keyCodes.UP, + }) + .copy() + .then(clipboardData => { + expect(clipboardData['text/html']).to.eq('

First block

Second block

'); + expect(clipboardData['text/plain']).to.eq(`First block\n\nSecond block`); + + /** + * Need to wait for custom data as it is set asynchronously + */ + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(0).then(() => { + expect(clipboardData['application/x-editor-js']).not.to.be.undefined; + + const data = JSON.parse(clipboardData['application/x-editor-js']); + + expect(data[0].tool).to.eq('paragraph'); + expect(data[0].data).to.deep.eq({ text: 'First block' }); + expect(data[1].tool).to.eq('paragraph'); + expect(data[1].data).to.deep.eq({ text: 'Second block' }); + }); + }); + }); + }); + + context('cutting', () => { + it('should cut inline fragment', () => { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .type('Some text{selectall}') + .cut() + .then(clipboardData => { + /** + * As no blocks selected, clipboard data will be empty as will be handled by browser + */ + expect(clipboardData).to.be.empty; + }); + }); + + it('should cut several blocks', () => { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .type('First block{enter}'); + + cy.get('[data-cy=editorjs') + .get('div.ce-block') + .next() + .type('Second block') + .type('{movetostart}') + .trigger('keydown', { + shiftKey: true, + keyCode: _.keyCodes.UP, + }) + .cut() + .then(clipboardData => { + expect(clipboardData['text/html']).to.eq('

First block

Second block

'); + expect(clipboardData['text/plain']).to.eq(`First block\n\nSecond block`); + + /** + * Need to wait for custom data as it is set asynchronously + */ + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(0).then(() => { + expect(clipboardData['application/x-editor-js']).not.to.be.undefined; + + const data = JSON.parse(clipboardData['application/x-editor-js']); + + expect(data[0].tool).to.eq('paragraph'); + expect(data[0].data).to.deep.eq({ text: 'First block' }); + expect(data[1].tool).to.eq('paragraph'); + expect(data[1].data).to.deep.eq({ text: 'Second block' }); + }); + }); + + cy.get('[data-cy=editorjs]') + .should('not.contain', 'First block') + .should('not.contain', 'Second block'); + }); + + it('should cut lots of blocks', () => { + const numberOfBlocks = 50; + + for (let i = 0; i < numberOfBlocks; i++) { + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .last() + .click() + .type(`Block ${i}{enter}`); + } + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .first() + .click() + .type('{ctrl+A}') + .type('{ctrl+A}') + .cut() + .then((clipboardData) => { + /** + * Need to wait for custom data as it is set asynchronously + */ + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(0).then(() => { + expect(clipboardData['application/x-editor-js']).not.to.be.undefined; + + const data = JSON.parse(clipboardData['application/x-editor-js']); + + expect(data.length).to.eq(numberOfBlocks + 1); + }); + }); + }); + }); +});