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('@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('@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); + }); + }); +}); diff --git a/types/api/selection.d.ts b/types/api/selection.d.ts index 606d03733..f6480968d 100644 --- a/types/api/selection.d.ts +++ b/types/api/selection.d.ts @@ -1,3 +1,5 @@ +import {BlockAPI} from './block'; + /** * Describes methods for work with Selections */ @@ -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[]; }