Bläddra i källkod

Merge pull request #6 from ckeditor/t/1

Initial implementation
Piotr Jasiun 9 år sedan
förälder
incheckning
6ef6ffecd9

+ 50 - 0
packages/ckeditor5-enter/src/enter.js

@@ -0,0 +1,50 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Feature from '../feature.js';
+import DomEventData from '../engine/treeview/observer/domeventdata.js';
+import KeyObserver from '../engine/treeview/observer/keyobserver.js';
+import EnterCommand from './entercommand.js';
+import { keyCodes } from '../utils/keyboard.js';
+
+/**
+ * The enter feature. Handles the <kbd>Enter</kbd> and <kbd>Shift + Enter</kbd> keys in the editor.
+ *
+ * @memberOf enter
+ * @extends ckeditor5.Feature
+ */
+export default class Enter extends Feature {
+	init() {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+
+		editingView.addObserver( KeyObserver );
+
+		editor.commands.set( 'enter', new EnterCommand( editor ) );
+
+		this.listenTo( editingView, 'keydown', ( evt, data ) => {
+			if ( data.keyCode == keyCodes.enter ) {
+				editingView.fire( 'enter', new DomEventData( editingView, data.domEvent ) );
+			}
+		} );
+
+		// TODO We may use keystroke handler for that.
+		this.listenTo( editingView, 'enter', ( evt, data ) => {
+			editor.execute( 'enter' );
+			data.preventDefault();
+		} );
+	}
+}
+
+/**
+ * Event fired when the user presses <kbd>Enter</kbd>.
+ *
+ * Note: This event is fired by the {@link enter.Enter enter feature}.
+ *
+ * @event engine.treeView.TreeView#enter
+ * @param {engine.treeView.observer.DomEventData} data
+ */

+ 124 - 0
packages/ckeditor5-enter/src/entercommand.js

@@ -0,0 +1,124 @@
+/**
+ * @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 Element from '../engine/treemodel/element.js';
+import LivePosition from '../engine/treemodel/liveposition.js';
+import Position from '../engine/treemodel/position.js';
+
+/**
+ * Enter command. Used by the {@link enter.Enter enter feature} to handle the <kbd>Enter</kbd> key.
+ *
+ * @member enter
+ * @extends ckeditor5.command.Command
+ */
+export default class EnterCommand extends Command {
+	_doExecute() {
+		const doc = this.editor.document;
+
+		doc.enqueueChanges( () => {
+			enterBlock( doc.batch(), doc.selection, { defaultBlock: 'paragraph' } );
+		} );
+	}
+}
+
+/**
+ * Enters new block in the way how <kbd>Enter</kbd> is expected to work.
+ *
+ * @param {engine.treeModel.Batch} batch Batch to which the deltas will be added.
+ * @param {engine.treeModel.Selection} selection
+ * @param {Object} options
+ * @param {Boolean} options.defaultBlockName Name of the block which should be created when enter leaves
+ * another block. Usuall set to `'paragraph'`. E.g. when entering block in `<heading>foo^</heading>` the result will be
+ * `<heading>foo</heading><paragraph>^</paragraph>`.
+ */
+export function enterBlock( batch, selection, options = {} ) {
+	const defaultBlockName = options.defaultBlockName;
+	const doc = batch.doc;
+	const isSelectionEmpty = selection.isCollapsed;
+	const range = selection.getFirstRange();
+	const startElement = range.start.parent;
+	const endElement = range.end.parent;
+
+	// Don't touch the root.
+	if ( startElement.root == startElement ) {
+		if ( !isSelectionEmpty ) {
+			doc.composer.deleteContents( batch, selection );
+		}
+
+		return;
+	}
+
+	if ( isSelectionEmpty ) {
+		splitBlock( batch, selection, range.start, defaultBlockName );
+	} else {
+		const shouldMerge = range.start.isAtStart() && range.end.isAtEnd();
+		const isContainedWithinOneElement = ( startElement == endElement );
+
+		doc.composer.deleteContents( batch, selection, { merge: shouldMerge } );
+
+		// Fully selected elements.
+		//
+		// <h>[xx</h><p>yy]<p>	-> <h>^</h>				-> <p>^</p>
+		// <h>[xxyy]</h>		-> <h>^</h>				-> <p>^</p>
+		if ( shouldMerge ) {
+			// We'll lose the ref to the renamed element, so let's keep a position inside it
+			// (offsets won't change, so it will stay in place). See ckeditor5-engine#367.
+			const pos = Position.createFromPosition( selection.focus );
+			const newBlockName = getNewBlockName( doc, startElement, defaultBlockName );
+
+			if ( startElement.name != newBlockName ) {
+				batch.rename( newBlockName, startElement );
+			}
+
+			selection.collapse( pos );
+		}
+		// Partially selected elements.
+		//
+		// <h>x[xx]x</h>		-> <h>x^x</h>			-> <h>x</h><h>^x</h>
+		else if ( isContainedWithinOneElement ) {
+			splitBlock( batch, selection, selection.focus, defaultBlockName );
+		}
+		// Selection over multilpe elements.
+		//
+		// <h>x[x</h><p>y]y<p>	-> <h>x^</h><p>y</p>	-> <h>x</h><p>^y</p>
+		else {
+			selection.collapse( endElement );
+		}
+	}
+}
+
+function splitBlock( batch, selection, splitPos, defaultBlockName ) {
+	const doc = batch.doc;
+	const parent = splitPos.parent;
+
+	if ( splitPos.isAtEnd() ) {
+		const newElement = new Element( getNewBlockName( doc, parent, defaultBlockName ) );
+
+		batch.insert( Position.createAfter( parent ), newElement );
+
+		selection.collapse( newElement );
+	} else {
+		// TODO After ckeditor5-engine#340 is fixed we'll be able to base on splitPos's location.
+		const endPos = LivePosition.createFromPosition( splitPos );
+		endPos.stickiness = 'STICKS_TO_NEXT';
+
+		batch.split( splitPos );
+
+		selection.collapse( endPos );
+
+		endPos.detach();
+	}
+}
+
+function getNewBlockName( doc, startElement, defaultBlockName ) {
+	if ( doc.schema.check( { name: defaultBlockName, inside: startElement.parent.name } ) ) {
+		return defaultBlockName;
+	}
+
+	return startElement.name;
+}

+ 80 - 0
packages/ckeditor5-enter/tests/enter.js

@@ -0,0 +1,80 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Editor from '/ckeditor5/editor.js';
+import StandardCreator from '/ckeditor5/creator/standardcreator.js';
+import Enter from '/ckeditor5/enter/enter.js';
+import EnterCommand from '/ckeditor5/enter/entercommand.js';
+import DomEventData from '/ckeditor5/engine/treeview/observer/domeventdata.js';
+import { getCode } from '/ckeditor5/utils/keyboard.js';
+
+describe( 'Enter feature', () => {
+	let editor, editingView;
+
+	beforeEach( () => {
+		editor = new Editor( null, {
+			creator: StandardCreator,
+			features: [ Enter ]
+		} );
+
+		return editor.init()
+			.then( () => {
+				editor.document.createRoot( 'main' );
+				editingView = editor.editing.view;
+			} );
+	} );
+
+	it( 'creates the commands', () => {
+		expect( editor.commands.get( 'enter' ) ).to.be.instanceof( EnterCommand );
+	} );
+
+	it( 'listens to the editing view enter event', () => {
+		const spy = editor.execute = sinon.spy();
+		const view = editor.editing.view;
+		const domEvt = getDomEvent();
+
+		view.fire( 'enter', new DomEventData( editingView, domEvt ) );
+
+		expect( spy.calledOnce ).to.be.true;
+		expect( spy.calledWithExactly( 'enter' ) ).to.be.true;
+
+		expect( domEvt.preventDefault.calledOnce ).to.be.true;
+	} );
+
+	describe( 'enter event', () => {
+		it( 'is fired on keydown', () => {
+			const view = editor.editing.view;
+			const spy = sinon.spy();
+
+			view.on( 'enter', spy );
+
+			view.fire( 'keydown', new DomEventData( editingView, getDomEvent(), {
+				keyCode: getCode( 'enter' )
+			} ) );
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+		it( 'is not fired on keydown when keyCode does not match enter', () => {
+			const view = editor.editing.view;
+			const spy = sinon.spy();
+
+			view.on( 'enter', spy );
+
+			view.fire( 'keydown', new DomEventData( editingView, getDomEvent(), {
+				keyCode: 1
+			} ) );
+
+			expect( spy.calledOnce ).to.be.false;
+		} );
+	} );
+
+	function getDomEvent() {
+		return {
+			preventDefault: sinon.spy()
+		};
+	}
+} );

+ 198 - 0
packages/ckeditor5-enter/tests/entercommand.js

@@ -0,0 +1,198 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Editor from '/ckeditor5/editor.js';
+import Document from '/ckeditor5/engine/treemodel/document.js';
+import { default as EnterCommand, enterBlock } from '/ckeditor5/enter/entercommand.js';
+import { getData, setData } from '/tests/engine/_utils/model.js';
+
+let editor, doc;
+
+beforeEach( () => {
+	editor = new Editor();
+	doc = editor.document = new Document();
+
+	doc.createRoot( 'main', '$root' );
+
+	const command = new EnterCommand( editor );
+	editor.commands.set( 'enter', command );
+
+	const schema = doc.schema;
+
+	// Note: We could use real names like 'paragraph', but that would make test patterns too long.
+	// Plus, this is actually a good test that the algorithm can be used for any model.
+	schema.registerItem( 'img', '$inline' );
+	schema.registerItem( 'p', '$block' );
+	schema.registerItem( 'h', '$block' );
+} );
+
+describe( 'EnterCommand', () => {
+	it( 'enters a block using enqueueChanges', () => {
+		const spy = sinon.spy( doc, 'enqueueChanges' );
+
+		setData( doc, 'main', '<p>foo<selection /></p>' );
+
+		editor.execute( 'enter' );
+
+		expect( getData( doc, 'main' ) ).to.equal( '<p>foo</p><p></p>' );
+		expect( spy.calledOnce ).to.be.true;
+	} );
+} );
+
+describe( 'enterBlock', () => {
+	describe( 'collapsed selection', () => {
+		test(
+			'does nothing in the root',
+			'foo<selection />bar',
+			'foo<selection />bar',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'splits block',
+			'<p>x</p><p>foo<selection />bar</p><p>y</p>',
+			'<p>x</p><p>foo</p><p><selection />bar</p><p>y</p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'splits block (other than default)',
+			'<p>x</p><h>foo<selection />bar</h><p>y</p>',
+			'<p>x</p><h>foo</h><h><selection />bar</h><p>y</p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'splits block at the end',
+			'<p>x</p><p>foo<selection /></p><p>y</p>',
+			'<p>x</p><p>foo</p><p><selection /></p><p>y</p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'splits block at the beginning',
+			'<p>x</p><p><selection />foo</p><p>y</p>',
+			'<p>x</p><p></p><p><selection />foo</p><p>y</p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'splits block at the beginning (other than default)',
+			'<p>x</p><h><selection />foo</h><p>y</p>',
+			'<p>x</p><h></h><h><selection />foo</h><p>y</p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'creates default block when leaving other block',
+			'<h>foo<selection /></h><p>x</p>',
+			'<h>foo</h><p><selection /></p><p>x</p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'does not rename when default block is not allowed',
+			'<h>foo<selection /></h><p>x</p>',
+			'<h>foo</h><h><selection /></h><p>x</p>',
+			{ defaultBlockName: 'xxx' }
+		);
+
+		test(
+			'inserts new block after empty one',
+			'<p>x</p><p><selection /></p><p>y</p>',
+			'<p>x</p><p></p><p><selection /></p><p>y</p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'inserts new block after empty one (other than default)',
+			'<p>x</p><h><selection /></h><p>y</p>',
+			'<p>x</p><h></h><p><selection /></p><p>y</p>',
+			{ defaultBlockName: 'p' }
+		);
+	} );
+
+	describe( 'non-collapsed selection', () => {
+		test(
+			'only deletes the content when directly in the root',
+			'fo<selection>ob</selection>ar',
+			'fo<selection />ar',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'deletes text and splits',
+			'<p>ab<selection>cd</selection>ef</p><p>ghi</p>',
+			'<p>ab</p><p><selection />ef</p><p>ghi</p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'deletes text and splits (other than default)',
+			'<h>ab<selection>cd</selection>ef</h>',
+			'<h>ab</h><h><selection />ef</h>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'places selection in the 2nd element',
+			'<h>ab<selection>c</h><p>d</selection>ef</p><p>ghi</p>',
+			'<h>ab</h><p><selection />ef</p><p>ghi</p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'leaves one empty element after one was fully selected',
+			'<p>x</p><p><selection>abcdef</selection></p><p>y</p>',
+			'<p>x</p><p><selection /></p><p>y</p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'leaves one (default) empty element after one was fully selected',
+			'<h><selection>abcdef</selection></h>',
+			'<p><selection /></p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'leaves one (default) empty element after two were fully selected',
+			'<h><selection>abc</h><p>def</selection></p>',
+			'<p><selection /></p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		test(
+			'leaves one (default) empty element after two were fully selected (backward)',
+			'<h><selection backward>abc</h><p>def</selection></p>',
+			'<p><selection /></p>',
+			{ defaultBlockName: 'p' }
+		);
+
+		it( 'uses composer.deleteContents', () => {
+			const spy = sinon.spy();
+
+			doc.composer.on( 'deleteContents', spy );
+
+			setData( doc, 'main', '<p><selection>x</selection></p>' );
+
+			enterBlock( doc.batch(), doc.selection, { defaultBlockName: 'p' } );
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+	} );
+
+	function test( title, input, output, options ) {
+		it( title, () => {
+			setData( doc, 'main', input );
+
+			enterBlock( doc.batch(), doc.selection, options );
+
+			expect( getData( doc, 'main', { selection: true } ) ).to.equal( output );
+		} );
+	}
+} );