Przeglądaj źródła

Implemented modifySelection and deleteContents.

Piotrek Koszuliński 9 lat temu
rodzic
commit
8321cd41c7

+ 54 - 0
packages/ckeditor5-engine/src/treemodel/composer/deletecontents.js

@@ -0,0 +1,54 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import LivePosition from '../liveposition.js';
+import Position from '../position.js';
+import utils from '../../../utils/utils.js';
+
+/**
+ * Delete contents of the selection and merge siblings.
+ *
+ * @method engine.treeModel.composer.deleteContents
+ * @param {engine.treeModel.Batch} batch Batch to which the deltas will be added.
+ * @param {engine.treeModel.Selection} selection Selection of which the content should be deleted.
+ */
+export default function deleteContents( batch, selection ) {
+	if ( selection.isCollapsed ) {
+		return;
+	}
+
+	const startPos = selection.getFirstRange().start;
+	const endPos = LivePosition.createFromPosition( selection.getFirstRange().end );
+
+	// 1. Remove the contents.
+	batch.remove( selection.getFirstRange() );
+
+	// 2. Merge elements in the right branch to the elements in the left branch.
+	// The only reasonable (in terms of data and selection correctness) case in which we need to do that is:
+	//
+	// <heading type=1>Fo[</heading><paragraph>]ar</paragraph> => <heading type=1>Fo^ar</heading>
+	//
+	// However, the algorithm supports also merging deeper structures (up to the depth of the shallower branch),
+	// as it's hard to imagine what should actually be the default behavior. Usually, specific features will
+	// want to override that behavior anyway.
+	const endPath = endPos.path;
+	const mergeEnd = Math.min( startPos.path.length - 1, endPath.length - 1 );
+	let mergeDepth = utils.compareArrays( startPos.path, endPath );
+
+	if ( typeof mergeDepth == 'number' ) {
+		for ( ; mergeDepth < mergeEnd; mergeDepth++ ) {
+			const mergePath = startPos.path.slice( 0, mergeDepth );
+			mergePath.push( startPos.path[ mergeDepth ] + 1 );
+
+			batch.merge( new Position( endPos.root, mergePath ) );
+		}
+	}
+
+	selection.collapse( startPos );
+
+	endPos.detach();
+}

+ 88 - 0
packages/ckeditor5-engine/src/treemodel/composer/modifyselection.js

@@ -0,0 +1,88 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Position from '../position.js';
+import TreeWalker from '../treewalker.js';
+import Range from '../range.js';
+
+/**
+ * Modifies the selection. Currently the supported modifications are:
+ *
+ * * Extending. The selection focus is moved in the specified `options.direction` with a step specified in `options.unit`
+ * (defaults to `'character'`, other values are not not yet supported).
+ * Note: if you extend a forward selection in a backward direction you will in fact shrink it.
+ *
+ * @method engine.treeModel.composer.modifySelection
+ * @param {engine.treeModel.Selection} The selection to modify.
+ * @param {Object} [options]
+ * @param {'FORWARD'|'BACKWARD'} [options.direction='FORWARD'] The direction in which the selection should be modified.
+ */
+export default function modifySelection( selection, options ) {
+	if ( !options ) {
+		options = {};
+	}
+
+	const isForward = options.direction != 'BACKWARD';
+
+	const focus = selection.focus;
+	const walker = new TreeWalker( {
+		boundaries: getSearchRange( focus, isForward ),
+		singleCharacters: true
+	} );
+
+	const items = Array.from( walker );
+	let next = items[ isForward ? 'shift' : 'pop' ]();
+
+	// 1. Nothing to do here.
+	if ( !next ) {
+		return;
+	}
+
+	// 2. Consume next character.
+	if ( next.type == 'CHARACTER' ) {
+		selection.setFocus( next[ isForward ? 'nextPosition' : 'previousPosition' ] );
+
+		return;
+	}
+
+	// 3. We're entering an element, so let's consume it fully.
+	if ( next.type == ( isForward ? 'ELEMENT_START' : 'ELEMENT_END' ) ) {
+		selection.setFocus( next.item, isForward ? 'AFTER' : 'BEFORE' );
+
+		return;
+	}
+
+	// 4. We're leaving an element. That's more tricky.
+
+	next = items[ isForward ? 'shift' : 'pop' ]();
+
+	// 4.1. Nothing left, so let's stay where we were.
+	if ( !next ) {
+		return;
+	}
+
+	// 4.2. Character found after element end. Not really a valid case in our data model, but let's
+	// do something sensible and put the selection focus before that character.
+	if ( next.type == 'CHARACTER' ) {
+		selection.setFocus( next[ isForward ? 'previousPosition' : 'nextPosition' ] );
+	}
+	// 4.3. OK, we're entering a new element. So let's place there the focus.
+	else {
+		selection.setFocus( next.item, isForward ? 0 : 'END' );
+	}
+}
+
+function getSearchRange( start, isForward ) {
+	const root = start.root;
+	const searchEnd = Position.createAt( root, isForward ? 'END' : 0 );
+
+	if ( isForward ) {
+		return new Range( start, searchEnd );
+	} else {
+		return new Range( searchEnd, start );
+	}
+}

+ 199 - 0
packages/ckeditor5-engine/tests/composer/deletecontents.js

@@ -0,0 +1,199 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Document from '/ckeditor5/engine/treemodel/document.js';
+import deleteContents from '/ckeditor5/engine/treemodel/composer/deletecontents.js';
+import { setData, getData } from '/tests/engine/_utils/model.js';
+
+describe( 'Delete utils', () => {
+	let document;
+
+	beforeEach( () => {
+		document = new Document();
+		document.createRoot( 'main', '$root' );
+
+		const schema = document.schema;
+
+		// Note: We used short names instead of "image", "paragraph", etc. to make the tests shorter.
+		// We could use any random names in fact, but using HTML tags may explain the tests a bit better.
+		schema.registerItem( 'img', '$inline' );
+		schema.registerItem( 'p', '$block' );
+		schema.registerItem( 'h1', '$block' );
+		schema.registerItem( 'pchild' );
+
+		schema.allow( { name: 'pchild', inside: 'p' } );
+		schema.allow( { name: '$text', attributes: [ 'bold', 'italic' ] } );
+		schema.allow( { name: 'p', attributes: [ 'align' ] } );
+	} );
+
+	describe( 'deleteContents', () => {
+		describe( 'in simple scenarios', () => {
+			test(
+				'does nothing on collapsed selection',
+				'f<selection />oo',
+				'f<selection />oo'
+			);
+
+			test(
+				'deletes single character',
+				'f<selection>o</selection>o',
+				'f<selection />o'
+			);
+
+			test(
+				'xdeletes single character (backward selection)',
+				'f<selection backward>o</selection>o',
+				'f<selection />o'
+			);
+
+			test(
+				'deletes whole text',
+				'<selection>foo</selection>',
+				'<selection />'
+			);
+
+			test(
+				'deletes whole text between nodes',
+				'<img></img><selection>foo</selection><img></img>',
+				'<img></img><selection /><img></img>'
+			);
+
+			test(
+				'deletes an element',
+				'x<selection><img></img></selection>y',
+				'x<selection />y'
+			);
+
+			test(
+				'deletes a bunch of nodes',
+				'w<selection>x<img></img>y</selection>z',
+				'w<selection />z'
+			);
+
+			test(
+				'deletes a bunch of nodes',
+				'w<selection>x<img></img>y</selection>z',
+				'w<selection />z'
+			);
+		} );
+
+		describe( 'with text attributes', () => {
+			test(
+				'deletes characters (first half has attrs)',
+				'<$text bold=true>fo<selection bold=true>o</$text>b</selection>ar',
+				'<$text bold=true>fo</$text><selection bold=true />ar'
+			);
+
+			test(
+				'deletes characters (2nd half has attrs)',
+				'fo<selection bold=true>o<$text bold=true>b</selection>ar</$text>',
+				'fo<selection /><$text bold=true>ar</$text>'
+			);
+
+			test(
+				'clears selection attrs when emptied content',
+				'<p>x</p><p><selection bold=true><$text bold=true>foo</$text></selection></p><p>y</p>',
+				'<p>x</p><p><selection /></p><p>y</p>'
+			);
+
+			test(
+				'leaves selection attributes when text contains them',
+				'<p>x<$text bold=true>a<selection bold=true>foo</selection>b</$text>y</p>',
+				'<p>x<$text bold=true>a<selection bold=true />b</$text>y</p>'
+			);
+		} );
+
+		// Note: The algorithm does not care what kind of it's merging as it knows nothing useful about these elements.
+		// In most cases it handles all elements like you'd expect to handle block elements in HTML. However,
+		// in some scenarios where the tree depth is bigger results may be hard to justify. In fact, such cases
+		// should not happen unless we're talking about lists or tables, but these features will need to cover
+		// their scenarios themselves. In all generic scenarios elements are never nested.
+		//
+		// You may also be thinking – but I don't want my elements to be merged. It means that there are some special rules,
+		// like – multiple editing hosts (cE=true/false in use) or block limit elements like <td>.
+		// Those case should, again, be handled by their specific implementations.
+		describe( 'in multi-element scenarios', () => {
+			test(
+				'do not merge when no need to',
+				'<p>x</p><p><selection>foo</selection></p><p>y</p>',
+				'<p>x</p><p><selection /></p><p>y</p>'
+			);
+
+			test(
+				'merges second element into the first one (same name)',
+				'<p>x</p><p>fo<selection>o</p><p>b</selection>ar</p><p>y</p>',
+				'<p>x</p><p>fo<selection />ar</p><p>y</p>'
+			);
+
+			test(
+				'merges second element into the first one (different name)',
+				'<p>x</p><h1>fo<selection>o</h1><p>b</selection>ar</p><p>y</p>',
+				'<p>x</p><h1>fo<selection />ar</h1><p>y</p>'
+			);
+
+			test(
+				'merges second element into the first one (different name, backward selection)',
+				'<p>x</p><h1>fo<selection backward>o</h1><p>b</selection>ar</p><p>y</p>',
+				'<p>x</p><h1>fo<selection />ar</h1><p>y</p>'
+			);
+
+			test(
+				'merges second element into the first one (different attrs)',
+				'<p>x</p><p align="l">fo<selection>o</p><p>b</selection>ar</p><p>y</p>',
+				'<p>x</p><p align="l">fo<selection />ar</p><p>y</p>'
+			);
+
+			test(
+				'merges second element to an empty first element',
+				'<p>x</p><h1><selection></h1><p>fo</selection>o</p><p>y</p>',
+				'<p>x</p><h1><selection />o</h1><p>y</p>'
+			);
+
+			test(
+				'merges elements when deep nested',
+				'<p>x<pchild>fo<selection>o</pchild></p><p><pchild>b</selection>ar</pchild>y</p>',
+				'<p>x<pchild>fo<selection />ar</pchild>y</p>'
+			);
+
+			// If you disagree with this case please read the notes before this section.
+			test(
+				'merges elements when left end deep nested',
+				'<p>x<pchild>fo<selection>o</pchild></p><p>b</selection>ary</p>',
+				'<p>x<pchild>fo<selection /></pchild>ary</p>'
+			);
+
+			// If you disagree with this case please read the notes before this section.
+			test(
+				'merges elements when right end deep nested',
+				'<p>xfo<selection>o</p><p><pchild>b</selection>ar</pchild>y<img></img></p>',
+				'<p>xfo<selection /><pchild>ar</pchild>y<img></img></p>'
+			);
+
+			test(
+				'merges elements when more content in the right branch',
+				'<p>xfo<selection>o</p><p>b</selection>a<pchild>r</pchild>y</p>',
+				'<p>xfo<selection />a<pchild>r</pchild>y</p>'
+			);
+
+			test(
+				'leaves just one element when all selected',
+				'<h1><selection>x</h1><p>foo</p><p>y</selection></p>',
+				'<h1><selection /></h1>'
+			);
+		} );
+
+		function test( title, input, output ) {
+			it( title, () => {
+				setData( document, 'main', input );
+
+				deleteContents( document.batch(), document.selection );
+
+				expect( getData( document, 'main', { selection: true } ) ).to.equal( output );
+			} );
+		}
+	} );
+} );

+ 237 - 0
packages/ckeditor5-engine/tests/composer/modifyselection.js

@@ -0,0 +1,237 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Document from '/ckeditor5/engine/treemodel/document.js';
+import modifySelection from '/ckeditor5/engine/treemodel/composer/modifyselection.js';
+import { setData, getData } from '/tests/engine/_utils/model.js';
+
+describe( 'Delete utils', () => {
+	let document;
+
+	beforeEach( () => {
+		document = new Document();
+		document.createRoot( 'main', '$root' );
+	} );
+
+	describe( 'modifySelection', () => {
+		describe( 'unit=character', () => {
+			describe( 'within element', () => {
+				test(
+					'does nothing on empty content',
+					'<selection />',
+					'<selection />'
+				);
+
+				test(
+					'does nothing on empty content (with empty element)',
+					'<p><selection /></p>',
+					'<p><selection /></p>'
+				);
+
+				test(
+					'does nothing on empty content (backward)',
+					'<selection />',
+					'<selection />',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'does nothing on root boundary',
+					'<p>foo<selection /></p>',
+					'<p>foo<selection /></p>'
+				);
+
+				test(
+					'does nothing on root boundary (backward)',
+					'<p><selection />foo</p>',
+					'<p><selection />foo</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends one character forward',
+					'<p>f<selection />oo</p>',
+					'<p>f<selection>o</selection>o</p>'
+				);
+
+				test(
+					'extends one character backward',
+					'<p>fo<selection />o</p>',
+					'<p>f<selection backward>o</selection>o</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends one character forward (non-collapsed)',
+					'<p>f<selection>o</selection>obar</p>',
+					'<p>f<selection>oo</selection>bar</p>'
+				);
+
+				test(
+					'extends one character backward (non-collapsed)',
+					'<p>foob<selection backward>a</selection>r</p>',
+					'<p>foo<selection backward>ba</selection>r</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends to element boundary',
+					'<p>fo<selection />o</p>',
+					'<p>fo<selection>o</selection></p>'
+				);
+
+				test(
+					'extends to element boundary (backward)',
+					'<p>f<selection />oo</p>',
+					'<p><selection backward>f</selection>oo</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'shrinks forward selection (to collapsed)',
+					'<p>foo<selection>b</selection>ar</p>',
+					'<p>foo<selection />bar</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'shrinks backward selection (to collapsed)',
+					'<p>foo<selection backward>b</selection>ar</p>',
+					'<p>foob<selection />ar</p>'
+				);
+
+				test(
+					'extends one element forward',
+					'<p>f<selection /><img></img>oo</p>',
+					'<p>f<selection><img></img></selection>oo</p>'
+				);
+
+				test(
+					'extends one non-empty element forward',
+					'<p>f<selection /><img>x</img>oo</p>',
+					'<p>f<selection><img>x</img></selection>oo</p>'
+				);
+
+				test(
+					'extends one element backward',
+					'<p>fo<img></img><selection />o</p>',
+					'<p>fo<selection backward><img></img></selection>o</p>',
+					{ direction: 'BACKWARD' }
+				);
+			} );
+
+			describe( 'beyond element', () => {
+				test(
+					'extends over boundary of empty elements',
+					'<p><selection /></p><p></p><p></p>',
+					'<p><selection></p><p></selection></p><p></p>'
+				);
+
+				test(
+					'extends over boundary of empty elements (backward)',
+					'<p></p><p></p><p><selection /></p>',
+					'<p></p><p><selection backward></p><p></selection></p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends over boundary of non-empty elements',
+					'<p>a<selection /></p><p>bcd</p>',
+					'<p>a<selection></p><p></selection>bcd</p>'
+				);
+
+				test(
+					'extends over boundary of non-empty elements (backward)',
+					'<p>a</p><p><selection />bcd</p>',
+					'<p>a<selection backward></p><p></selection>bcd</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends over character after boundary',
+					'<p>a<selection></p><p></selection>bcd</p>',
+					'<p>a<selection></p><p>b</selection>cd</p>'
+				);
+
+				test(
+					'extends over character after boundary (backward)',
+					'<p>abc<selection backward></p><p></selection>d</p>',
+					'<p>ab<selection backward>c</p><p></selection>d</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends over boundary when next element has nested elements',
+					'<p>a<selection /></p><p><x>bcd</x></p>',
+					'<p>a<selection></p><p></selection><x>bcd</x></p>'
+				);
+
+				test(
+					'extends over element when next element has nested elements',
+					'<p>a<selection></p><p></selection><x>bcd</x>ef</p>',
+					'<p>a<selection></p><p><x>bcd</x></selection>ef</p>'
+				);
+
+				test(
+					'extends over element when next node is a text',
+					'<p>a<selection /></p>bc',
+					'<p>a<selection></p></selection>bc'
+				);
+
+				test(
+					'extends over element when next node is a text (backward)',
+					'ab<p><selection />c</p>',
+					'ab<selection backward><p></selection>c</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'shrinks over boundary of empty elements',
+					'<p><selection backward></p><p></selection></p>',
+					'<p></p><p><selection /></p>'
+				);
+
+				test(
+					'shrinks over boundary of empty elements (backward)',
+					'<p><selection></p><p></selection></p>',
+					'<p><selection /></p><p></p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'shrinks over boundary of non-empty elements',
+					'<p>a<selection backward></p><p></selection>b</p>',
+					'<p>a</p><p><selection />b</p>'
+				);
+
+				test(
+					'shrinks over boundary of non-empty elements (backward)',
+					'<p>a<selection></p><p></selection>b</p>',
+					'<p>a<selection /></p><p>b</p>',
+					{ direction: 'BACKWARD' }
+				);
+			} );
+		} );
+
+		test(
+			'updates selection attributes',
+			'<p><$text bold=true>foo</$text><selection>b</selection></p>',
+			'<p><$text bold=true>foo</$text><selection bold=true />b</p>',
+			{ direction: 'BACKWARD' }
+		);
+	} );
+
+	function test( title, input, output, options ) {
+		it( title, () => {
+			setData( document, 'main', input );
+
+			modifySelection( document.selection, options );
+
+			expect( getData( document, 'main', { selection: true } ) ).to.equal( output );
+		} );
+	}
+} );