diff --git a/src/components/modules/api/selection.ts b/src/components/modules/api/selection.ts index 53d221fbd..1172cb11d 100644 --- a/src/components/modules/api/selection.ts +++ b/src/components/modules/api/selection.ts @@ -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'; /** @@ -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(), }; } @@ -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)); + } } diff --git a/src/components/modules/blockSelection.ts b/src/components/modules/blockSelection.ts index a6edcb8c8..82fe5ff07 100644 --- a/src/components/modules/blockSelection.ts +++ b/src/components/modules/blockSelection.ts @@ -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 @@ -289,21 +298,8 @@ export default class BlockSelection extends Module { */ e.preventDefault(); - const fakeClipboard = $.make('div'); - - this.selectedBlocks.forEach((block) => { - /** - * Make
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); @@ -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
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
diff --git a/src/components/modules/crossBlockSelection.ts b/src/components/modules/crossBlockSelection.ts
index 1b18b8e6d..61843aaa3 100644
--- a/src/components/modules/crossBlockSelection.ts
+++ b/src/components/modules/crossBlockSelection.ts
@@ -1,6 +1,7 @@
import Module from '../__module';
import type Block from '../block';
import SelectionUtils from '../selection';
+import $ from '../dom';
import * as _ from '../utils';
/**
@@ -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
@@ -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 {
/**
diff --git a/test/cypress/tests/selection.cy.ts b/test/cypress/tests/selection.cy.ts
index c9017351a..6e984d308 100644
--- a/test/cypress/tests/selection.cy.ts
+++ b/test/cypress/tests/selection.cy.ts
@@ -1,4 +1,6 @@
import * as _ from '../../../src/components/utils';
+import type EditorJS from '../../../types';
+import { createEditorWithTextBlocks } from '../support/utils/createEditorWithTextBlocks';
describe('Blocks selection', () => {
beforeEach(function () {
@@ -6,7 +8,7 @@ describe('Blocks selection', () => {
});
afterEach(function () {
- if (this.editorInstance) {
+ if (this.editorInstance !== undefined) {
this.editorInstance.destroy();
}
});
@@ -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