Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/components/modules/api/selection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SelectionUtils from '../../selection';
import type { Selection as SelectionAPIInterface } from '../../../../types/api';
import BlockAPI from '../../block/api';
import type { BlockAPI as BlockAPIInterface, Selection as SelectionAPIInterface } from '../../../../types/api';
import Module from '../../__module';

/**
Expand All @@ -25,6 +26,8 @@ export default class SelectionAPI extends Module {
restore: () => this.selectionUtils.restore(),
setFakeBackground: () => this.selectionUtils.setFakeBackground(),
removeFakeBackground: () => this.selectionUtils.removeFakeBackground(),
getSelectedText: (): string => this.getSelectedText(),
getSelectedBlocks: (): BlockAPIInterface[] => this.getSelectedBlocks(),
};
}

Expand All @@ -47,4 +50,32 @@ export default class SelectionAPI extends Module {
public expandToTag(node: HTMLElement): void {
this.selectionUtils.expandToTag(node);
}

/**
* Returns current native selection text or selected Blocks text
*
* @returns {string}
*/
public getSelectedText(): string {
const selection = SelectionUtils.get();

if (selection && !selection.isCollapsed && SelectionUtils.isSelectionAtEditor(selection)) {
return selection.toString();
}

if (this.Editor.BlockSelection.anyBlockSelected) {
return this.Editor.BlockSelection.selectedText;
}

return '';
}

/**
* Returns Blocks selected with Editor's cross-block selection
*
* @returns {BlockAPIInterface[]}
*/
public getSelectedBlocks(): BlockAPIInterface[] {
return this.Editor.BlockSelection.selectedBlocks.map((block) => new BlockAPI(block));
}
}
59 changes: 44 additions & 15 deletions src/components/modules/blockSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ export default class BlockSelection extends Module {
return this.Editor.BlockManager.blocks.filter((block: Block) => block.selected);
}

/**
* Return selected Blocks text joined the same way as the copy action
*
* @returns {string}
*/
public get selectedText(): string {
return this.getSelectedBlocksText(this.getSelectedBlocksFragment());
}

/**
* Flag used to define block selection
* First CMD+A defines it as true and then second CMD+A selects all Blocks
Expand Down Expand Up @@ -289,21 +298,8 @@ export default class BlockSelection extends Module {
*/
e.preventDefault();

const fakeClipboard = $.make('div');

this.selectedBlocks.forEach((block) => {
/**
* Make <p> tag that holds clean HTML
*/
const cleanHTML = clean(block.holder.innerHTML, this.sanitizerConfig);
const fragment = $.make('p');

fragment.innerHTML = cleanHTML;
fakeClipboard.appendChild(fragment);
});

const textPlain = Array.from(fakeClipboard.childNodes).map((node) => node.textContent)
.join('\n\n');
const fakeClipboard = this.getSelectedBlocksFragment();
const textPlain = this.getSelectedBlocksText(fakeClipboard);
const textHTML = fakeClipboard.innerHTML;

e.clipboardData.setData('text/plain', textPlain);
Expand Down Expand Up @@ -383,6 +379,39 @@ export default class BlockSelection extends Module {
Shortcuts.remove(this.Editor.UI.nodes.redactor, 'CMD+A');
}

/**
* Creates a sanitized HTML fragment from selected Blocks
*
* @returns {HTMLElement}
*/
private getSelectedBlocksFragment(): HTMLElement {
const fakeClipboard = $.make('div');

this.selectedBlocks.forEach((block) => {
/**
* Make <p> tag that holds clean HTML
*/
const cleanHTML = clean(block.holder.innerHTML, this.sanitizerConfig);
const fragment = $.make('p');

fragment.innerHTML = cleanHTML;
fakeClipboard.appendChild(fragment);
});

return fakeClipboard;
}

/**
* Converts selected Blocks fragment to plain text
*
* @param fragment - selected Blocks fragment
* @returns {string}
*/
private getSelectedBlocksText(fragment: HTMLElement): string {
return Array.from(fragment.childNodes).map((node) => node.textContent)
.join('\n\n');
}

/**
* First CMD+A selects all input content by native behaviour,
* next CMD+A keypress selects all blocks
Expand Down
16 changes: 15 additions & 1 deletion src/components/modules/crossBlockSelection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Module from '../__module';
import type Block from '../block';
import SelectionUtils from '../selection';
import $ from '../dom';
import * as _ from '../utils';

/**
Expand Down Expand Up @@ -143,6 +144,7 @@ export default class CrossBlockSelection extends Module {
*/
private enableCrossBlockSelection(event: MouseEvent): void {
const { UI } = this.Editor;
const target = event.target;

/**
* Each mouse down on must disable selectAll state
Expand All @@ -151,10 +153,22 @@ export default class CrossBlockSelection extends Module {
this.Editor.BlockSelection.clearSelection(event);
}

if (!(target instanceof Element)) {
this.Editor.BlockSelection.clearSelection(event);

return;
}

/**
* If mouse down is performed inside the editor, we should watch CBS
*/
if (UI.nodes.redactor.contains(event.target as Node)) {
if (UI.nodes.redactor.contains(target)) {
const startsInsideEditable = target.closest($.allInputsSelector) !== null;

if (startsInsideEditable) {
return;
}

this.watchSelection(event);
} else {
/**
Expand Down
104 changes: 103 additions & 1 deletion test/cypress/tests/selection.cy.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import * as _ from '../../../src/components/utils';
import type EditorJS from '../../../types';
import { createEditorWithTextBlocks } from '../support/utils/createEditorWithTextBlocks';

describe('Blocks selection', () => {
beforeEach(function () {
cy.createEditor({}).as('editorInstance');
});

afterEach(function () {
if (this.editorInstance) {
if (this.editorInstance !== undefined) {
this.editorInstance.destroy();
}
});
Expand All @@ -33,3 +35,103 @@ describe('Blocks selection', () => {
.should('not.have.class', '.ce-block--selected');
});
});

describe('Native multiblock text selection', () => {
beforeEach(function () {
createEditorWithTextBlocks([
'Alpha start text',
'finish Omega',
]).as('editorInstance');
});

afterEach(function () {
if (this.editorInstance !== undefined) {
this.editorInstance.destroy();
}
});

it('should not convert an editable text drag into whole-block selection', () => {
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.then(($paragraphs) => {
const firstParagraph = $paragraphs[0];
const secondParagraph = $paragraphs[1];
const { ownerDocument } = firstParagraph;
const selection = ownerDocument.getSelection();
const range = ownerDocument.createRange();
const view = ownerDocument.defaultView;
const firstTextNode = firstParagraph.firstChild;
const secondTextNode = secondParagraph.firstChild;

if (!firstTextNode || !secondTextNode || !selection || !view) {
throw new Error('Selection test fixture is not ready');
}

range.setStart(firstTextNode, 6);
range.setEnd(secondTextNode, 6);
selection.removeAllRanges();
selection.addRange(range);

firstParagraph.dispatchEvent(new view.MouseEvent('mousedown', {
bubbles: true,
button: 0,
}));
secondParagraph.dispatchEvent(new view.MouseEvent('mouseover', {
bubbles: true,
relatedTarget: firstParagraph,
}));

expect(selection.rangeCount).to.eq(1);
expect(selection.toString()).to.contain('start text');
});

cy.get('[data-cy=editorjs]')
.find('.ce-block--selected')
.should('not.exist');

cy.get<EditorJS>('@editorInstance')
.then((editor) => {
expect(editor.selection.getSelectedText()).to.contain('start text');
expect(editor.selection.getSelectedBlocks()).to.have.length(0);
});
});

it('should expose selected text and blocks for cross-block selection', () => {
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.then(($blocks) => {
const firstBlock = $blocks[0];
const secondBlock = $blocks[1];
const { defaultView } = firstBlock.ownerDocument;

if (!defaultView) {
throw new Error('Block selection test fixture is not ready');
}

firstBlock.dispatchEvent(new defaultView.MouseEvent('mousedown', {
bubbles: true,
button: 0,
}));
secondBlock.dispatchEvent(new defaultView.MouseEvent('mouseover', {
bubbles: true,
relatedTarget: firstBlock,
}));
secondBlock.dispatchEvent(new defaultView.MouseEvent('mouseup', {
bubbles: true,
}));
});

cy.get('[data-cy=editorjs]')
.find('.ce-block--selected')
.should('have.length', 2);

cy.get<EditorJS>('@editorInstance')
.then((editor) => {
const selectedBlocks = editor.selection.getSelectedBlocks();

expect(editor.selection.getSelectedText()).to.eq('Alpha start text\n\nfinish Omega');
expect(selectedBlocks.map((block) => block.name)).to.deep.eq(['paragraph', 'paragraph']);
expect(selectedBlocks.every((block) => block.selected)).to.eq(true);
});
});
});
12 changes: 12 additions & 0 deletions types/api/selection.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {BlockAPI} from './block';

/**
* Describes methods for work with Selections
*/
Expand Down Expand Up @@ -38,4 +40,14 @@ export interface Selection {
* Restore saved selection range
*/
restore(): void;

/**
* Returns text from current native selection or Editor's selected Blocks
*/
getSelectedText(): string;

/**
* Returns Blocks selected with Editor's cross-block selection
*/
getSelectedBlocks(): BlockAPI[];
}