Bladeren bron

Sources and tests have been merged into this package.

See: https://github.com/ckeditor/ckeditor5-typing/issues/17
Kamil Piechaczek 9 jaren geleden
bovenliggende
commit
6fc826d600

+ 33 - 0
packages/ckeditor5-typing/src/delete.js

@@ -0,0 +1,33 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Feature from '../feature.js';
+import DeleteCommand from './deletecommand.js';
+import DeleteObserver from './deleteobserver.js';
+
+/**
+ * The delete and backspace feature. Handles <kbd>Delete</kbd> and <kbd>Backspace</kbd> keys in the editor.
+ *
+ * @memberOf delete
+ * @extends ckeditor5.Feature
+ */
+export default class Delete extends Feature {
+	init() {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+
+		editingView.addObserver( DeleteObserver );
+
+		editor.commands.set( 'forwardDelete', new DeleteCommand( editor, 'FORWARD' ) );
+		editor.commands.set( 'delete', new DeleteCommand( editor, 'BACKWARD' ) );
+
+		this.listenTo( editingView, 'delete', ( evt, data ) => {
+			editor.execute( data.direction == 'FORWARD' ? 'forwardDelete' : 'delete' );
+			data.preventDefault();
+		} );
+	}
+}

+ 87 - 0
packages/ckeditor5-typing/src/deletecommand.js

@@ -0,0 +1,87 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Command from '../command/command.js';
+import Selection from '../engine/model/selection.js';
+import ChangeBuffer from './changebuffer.js';
+import count from '../utils/count.js';
+
+/**
+ * Delete command. Used by the {@link delete.Delete delete feature} to handle <kbd>Delete</kbd> and
+ * <kbd>Backspace</kbd> keys.
+ *
+ * @member delete
+ * @extends ckeditor5.command.Command
+ */
+export default class DeleteCommand extends Command {
+	/**
+	 * Creates instance of the command;
+	 *
+	 * @param {ckeditor5.Editor} editor
+	 * @param {'FORWARD'|'BACKWARD'} direction The directionality of the delete (in what direction it should
+	 * consume the content when selection is collapsed).
+	 */
+	constructor( editor, direction ) {
+		super( editor );
+
+		/**
+		 * The directionality of the delete (in what direction it should
+		 * consume the content when selection is collapsed).
+		 *
+		 * @readonly
+		 * @member {'FORWARD'|'BACKWARD'} delete.DeleteCommand#direction
+		 */
+		this.direction = direction;
+
+		/**
+		 * Delete's change buffer used to group subsequent changes into batches.
+		 *
+		 * @readonly
+		 * @private
+		 * @member {typing.ChangeBuffer} delete.DeleteCommand#buffer
+		 */
+		this._buffer = new ChangeBuffer( editor.document, editor.config.get( 'undo.step' ) );
+	}
+
+	/**
+	 * Executes the command: depending on whether the selection is collapsed or not, deletes its contents
+	 * or piece of content in the {@link delete.DeleteCommand#direction defined direction}.
+	 *
+	 * @param {Object} [options] The command options.
+	 * @param {'CHARACTER'} [options.unit='CHARACTER'] See {@link engine.model.composer.modifySelection}'s options.
+	 */
+	_doExecute( options = {} ) {
+		const doc = this.editor.document;
+
+		doc.enqueueChanges( () => {
+			const selection = Selection.createFromSelection( doc.selection );
+
+			// Try to extend the selection in the specified direction.
+			if ( selection.isCollapsed ) {
+				doc.composer.modifySelection( selection, { direction: this.direction, unit: options.unit } );
+			}
+
+			// If selection is still collapsed, then there's nothing to delete.
+			if ( selection.isCollapsed ) {
+				return;
+			}
+
+			let changeCount = 0;
+
+			selection.getFirstRange().getMinimalFlatRanges().forEach( ( range ) => {
+				changeCount += count(
+					range.getWalker( { singleCharacters: true, ignoreElementEnd: true, shallow: true } )
+				);
+			} );
+
+			doc.composer.deleteContents( this._buffer.batch, selection, { merge: true } );
+			this._buffer.input( changeCount );
+
+			doc.selection.setRanges( selection.getRanges(), selection.isBackward );
+		} );
+	}
+}

+ 55 - 0
packages/ckeditor5-typing/src/deleteobserver.js

@@ -0,0 +1,55 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Observer from '../engine/view/observer/observer.js';
+import DomEventData from '../engine/view/observer/domeventdata.js';
+import { keyCodes } from '../utils/keyboard.js';
+
+/**
+ * Delete observer introduces the {@link engine.view.Document#delete} event.
+ *
+ * @memberOf delete
+ * @extends engine.view.observer.Observer
+ */
+export default class DeleteObserver extends Observer {
+	constructor( document ) {
+		super( document );
+
+		document.on( 'keydown', ( evt, data ) => {
+			const deleteData = {};
+
+			if ( data.keyCode == keyCodes.delete ) {
+				deleteData.direction = 'FORWARD';
+			} else if ( data.keyCode == keyCodes.backspace ) {
+				deleteData.direction = 'BACKWARD';
+			} else {
+				return;
+			}
+
+			deleteData.unit = data.altKey ? 'WORD' : 'CHARACTER';
+
+			document.fire( 'delete', new DomEventData( document, data.domEvent, deleteData ) );
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	observe() {}
+}
+
+/**
+ * Event fired when the user tries to delete contents (e.g. presses <kbd>Delete</kbd> or <kbd>Backspace</kbd>).
+ *
+ * Note: This event is fired by the {@link delete.DeleteObserver observer}
+ * (usually registered by the {@link delete.Delete delete feature}).
+ *
+ * @event engine.view.Document#delete
+ * @param {engine.view.observer.DomEventData} data
+ * @param {'FORWARD'|'DELETE'} data.direction The direction in which the deletion should happen.
+ * @param {'CHARACTER'|'WORD'} data.unit The "amount" of content that should be deleted.
+ */

+ 59 - 0
packages/ckeditor5-typing/tests/delete.js

@@ -0,0 +1,59 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import VirtualTestEditor from '/tests/ckeditor5/_utils/virtualtesteditor.js';
+import Delete from '/ckeditor5/typing/delete.js';
+import DomEventData from '/ckeditor5/engine/view/observer/domeventdata.js';
+
+describe( 'Delete feature', () => {
+	let editor, editingView;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+				features: [ Delete ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				editingView = editor.editing.view;
+			} );
+	} );
+
+	it( 'creates two commands', () => {
+		expect( editor.commands.get( 'delete' ) ).to.have.property( 'direction', 'BACKWARD' );
+		expect( editor.commands.get( 'forwardDelete' ) ).to.have.property( 'direction', 'FORWARD' );
+	} );
+
+	it( 'listens to the editing view delete event', () => {
+		const spy = editor.execute = sinon.spy();
+		const view = editor.editing.view;
+		const domEvt = getDomEvent();
+
+		view.fire( 'delete', new DomEventData( editingView, domEvt, {
+			direction: 'FORWARD',
+			unit: 'CHARACTER'
+		} ) );
+
+		expect( spy.calledOnce ).to.be.true;
+		expect( spy.calledWithExactly( 'forwardDelete' ) ).to.be.true;
+
+		expect( domEvt.preventDefault.calledOnce ).to.be.true;
+
+		view.fire( 'delete', new DomEventData( editingView, getDomEvent(), {
+			direction: 'BACKWARD',
+			unit: 'CHARACTER'
+		} ) );
+
+		expect( spy.calledTwice ).to.be.true;
+		expect( spy.calledWithExactly( 'delete' ) ).to.be.true;
+	} );
+
+	function getDomEvent() {
+		return {
+			preventDefault: sinon.spy()
+		};
+	}
+} );

+ 115 - 0
packages/ckeditor5-typing/tests/deletecommand-integration.js

@@ -0,0 +1,115 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import ModelTestEditor from '/tests/ckeditor5/_utils/modeltesteditor.js';
+import DeleteCommand from '/ckeditor5/typing/deletecommand.js';
+import UndoEngine from '/ckeditor5/undo/undoengine.js';
+import { getData, setData } from '/tests/engine/_utils/model.js';
+
+let editor, doc;
+
+beforeEach( () => {
+	return ModelTestEditor.create( {
+			features: [
+				UndoEngine
+			],
+			undo: {
+				step: 3
+			}
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			doc = editor.document;
+
+			const command = new DeleteCommand( editor, 'BACKWARD' );
+			editor.commands.set( 'delete', command );
+
+			doc.schema.registerItem( 'p', '$block' );
+			doc.schema.registerItem( 'img', '$inline' );
+			doc.schema.allow( { name: '$text', inside: 'img' } );
+		} );
+} );
+
+function assertOutput( output ) {
+	expect( getData( doc ) ).to.equal( output );
+}
+
+describe( 'DeleteCommand integration', () => {
+	it( 'deletes characters (and group changes in batches) and rollbacks', () => {
+		setData( doc, '<p>123456789<selection /></p>' );
+
+		for ( let i = 0; i < 3; ++i ) {
+			editor.execute( 'delete' );
+		}
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>123456789<selection /></p>' );
+	} );
+
+	it( 'deletes characters (and group changes in batches) and rollbacks - test step', () => {
+		setData( doc, '<p>123456789<selection /></p>' );
+
+		for ( let i = 0; i < 6; ++i ) {
+			editor.execute( 'delete' );
+		}
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>123456<selection /></p>' );
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>123456789<selection /></p>' );
+	} );
+
+	it( 'deletes elements (and group changes in batches) and rollbacks', () => {
+		setData( doc, '<p><img>1</img><img>2</img><img>3</img><img>4</img><img>5</img><img>6</img><selection /></p>' );
+
+		for ( let i = 0; i < 3; ++i ) {
+			editor.execute( 'delete' );
+		}
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p><img>1</img><img>2</img><img>3</img><img>4</img><img>5</img><img>6</img><selection /></p>' );
+	} );
+
+	it( 'merges elements (and group changes in batches) and rollbacks', () => {
+		setData( doc, '<p>123456</p><p><selection />78</p>' );
+
+		for ( let i = 0; i < 6; ++i ) {
+			editor.execute( 'delete' );
+		}
+
+		editor.execute( 'undo' );
+
+		// Deleted 6,5,4, <P> does not count.
+		// It's not the most elegant solution, but is the best if we don't want to make complicated algorithm.
+		assertOutput( '<p>123<selection />78</p>' );
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>123456</p><p><selection />78</p>' );
+	} );
+
+	it( 'merges elements (and group changes in batches) and rollbacks - non-collapsed selection', () => {
+		setData( doc, '<p>12345<selection>6</p><p>7</selection>8</p>' );
+
+		editor.execute( 'delete' );
+		editor.execute( 'delete' );
+		editor.execute( 'delete' );
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>1234<selection />8</p>' );
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>12345<selection>6</p><p>7</selection>8</p>' );
+	} );
+} );

+ 98 - 0
packages/ckeditor5-typing/tests/deletecommand.js

@@ -0,0 +1,98 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import ModelTestEditor from '/tests/ckeditor5/_utils/modeltesteditor.js';
+import DeleteCommand from '/ckeditor5/typing/deletecommand.js';
+import { getData, setData } from '/tests/engine/_utils/model.js';
+
+describe( 'DeleteCommand', () => {
+	let editor, doc;
+
+	beforeEach( () => {
+		return ModelTestEditor.create( )
+			.then( newEditor => {
+				editor = newEditor;
+				doc = editor.document;
+
+				const command = new DeleteCommand( editor, 'BACKWARD' );
+				editor.commands.set( 'delete', command );
+
+				doc.schema.registerItem( 'p', '$block' );
+			} );
+	} );
+
+	it( 'has direction', () => {
+		const command = new DeleteCommand( editor, 'FORWARD' );
+
+		expect( command ).to.have.property( 'direction', 'FORWARD' );
+	} );
+
+	describe( 'execute', () => {
+		it( 'uses enqueueChanges', () => {
+			const spy = sinon.spy( doc, 'enqueueChanges' );
+
+			setData( doc, '<p>foo<selection />bar</p>' );
+
+			editor.execute( 'delete' );
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
+		it( 'deletes previous character when selection is collapsed', () => {
+			setData( doc, '<p>foo<selection />bar</p>' );
+
+			editor.execute( 'delete' );
+
+			expect( getData( doc, { selection: true } ) ).to.equal( '<p>fo<selection />bar</p>' );
+		} );
+
+		it( 'deletes selection contents', () => {
+			setData( doc, '<p>fo<selection>ob</selection>ar</p>' );
+
+			editor.execute( 'delete' );
+
+			expect( getData( doc, { selection: true } ) ).to.equal( '<p>fo<selection />ar</p>' );
+		} );
+
+		it( 'merges elements', () => {
+			setData( doc, '<p>foo</p><p><selection />bar</p>' );
+
+			editor.execute( 'delete' );
+
+			expect( getData( doc, { selection: true } ) ).to.equal( '<p>foo<selection />bar</p>' );
+		} );
+
+		it( 'does not try to delete when selection is at the boundary', () => {
+			const spy = sinon.spy();
+
+			doc.composer.on( 'deleteContents', spy );
+			setData( doc, '<p><selection />foo</p>' );
+
+			editor.execute( 'delete' );
+
+			expect( getData( doc, { selection: true } ) ).to.equal( '<p><selection />foo</p>' );
+			expect( spy.callCount ).to.equal( 0 );
+		} );
+
+		it( 'passes options to modifySelection', () => {
+			const spy = sinon.spy();
+
+			doc.composer.on( 'modifySelection', spy );
+			setData( doc, '<p>foo<selection />bar</p>' );
+
+			editor.commands.get( 'delete' ).direction = 'FORWARD';
+
+			editor.execute( 'delete', { unit: 'WORD' } );
+
+			expect( spy.callCount ).to.equal( 1 );
+
+			const modifyOpts = spy.args[ 0 ][ 1 ].options;
+			expect( modifyOpts ).to.have.property( 'direction', 'FORWARD' );
+			expect( modifyOpts ).to.have.property( 'unit', 'WORD' );
+		} );
+	} );
+} );

+ 80 - 0
packages/ckeditor5-typing/tests/deleteobserver.js

@@ -0,0 +1,80 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import DeleteObserver from '/ckeditor5/typing/deleteobserver.js';
+import ViewDocument from '/ckeditor5/engine/view/document.js';
+import DomEventData from '/ckeditor5/engine/view/observer/domeventdata.js';
+import { getCode } from '/ckeditor5/utils/keyboard.js';
+
+describe( 'DeleteObserver', () => {
+	let viewDocument, observer;
+
+	beforeEach( () => {
+		viewDocument = new ViewDocument();
+		observer = viewDocument.addObserver( DeleteObserver );
+	} );
+
+	// See ckeditor/ckeditor5-enter#10.
+	it( 'can be initialized', () => {
+		expect( () => {
+			viewDocument.createRoot( document.createElement( 'div' ) );
+		} ).to.not.throw();
+	} );
+
+	describe( 'delete event', () => {
+		it( 'is fired on keydown', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'delete', spy );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: getCode( 'delete' )
+			} ) );
+
+			expect( spy.calledOnce ).to.be.true;
+
+			const data = spy.args[ 0 ][ 1 ];
+			expect( data ).to.have.property( 'direction', 'FORWARD' );
+			expect( data ).to.have.property( 'unit', 'CHARACTER' );
+		} );
+
+		it( 'is fired with a proper direction and unit', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'delete', spy );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: getCode( 'backspace' ),
+				altKey: true
+			} ) );
+
+			expect( spy.calledOnce ).to.be.true;
+
+			const data = spy.args[ 0 ][ 1 ];
+			expect( data ).to.have.property( 'direction', 'BACKWARD' );
+			expect( data ).to.have.property( 'unit', 'WORD' );
+		} );
+
+		it( 'is not fired on keydown when keyCode does not match backspace or delete', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'delete', spy );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: 1
+			} ) );
+
+			expect( spy.calledOnce ).to.be.false;
+		} );
+	} );
+
+	function getDomEvent() {
+		return {
+			preventDefault: sinon.spy()
+		};
+	}
+} );