Fix copy in FireFox (#1632)

* Fix copy in FireFox

* Add test cases

* Eslint fix

* Improve readability

* fix eslint
This commit is contained in:
George Berezhnoy 2021-04-08 22:19:49 +03:00 committed by GitHub
parent d5aaa56ca0
commit 14acef136c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 368 additions and 13 deletions

View file

@ -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).

View file

@ -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. ' +

View file

@ -167,7 +167,7 @@ export default class BlockEvents extends Module {
*
* @param {ClipboardEvent} event - clipboard event
*/
public handleCommandC(event: ClipboardEvent): Promise<void> {
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<void> {
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);
});
}
/**

View file

@ -286,7 +286,7 @@ export default class BlockSelection extends Module {
*
* @returns {Promise<void>}
*/
public async copySelectedBlocks(e: ClipboardEvent): Promise<void> {
public copySelectedBlocks(e: ClipboardEvent): Promise<void> {
/**
* 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
}
});
}
/**

View file

@ -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;
});

View file

@ -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<Subject>
/**
* 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 {

View file

@ -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': '<p><b>Some text</b></p>',
})
.should('contain.html', '<b>Some text</b>');
});
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': '<p>First block</p><p>Second block</p>',
});
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': '<h2>First block</h2><p>Second block</p>',
});
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('<p>First block</p><p>Second block</p>');
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('<p>First block</p><p>Second block</p>');
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);
});
});
});
});
});