瀏覽代碼

Initial implementation.

Piotrek Koszuliński 9 年之前
父節點
當前提交
b85c512579

+ 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 { getCode } 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 == getCode( '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#enter
+ * @param {engine.treeView.observer.DomEventData} data
+ */

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

@@ -0,0 +1,109 @@
+/**
+ * @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' } );
+		} );
+	}
+}
+
+export function enterBlock( batch, selection, options = {} ) {
+	const defaultBlockName = options.defaultBlockName;
+	const doc = batch.doc;
+	const isSelectionEmpty = selection.isCollapsed;
+
+	if ( isSelectionEmpty ) {
+		const startPos = selection.focus;
+		const startElement = startPos.parent;
+
+		// Don't touch the root.
+		if ( startElement.root == startElement ) {
+			return;
+		}
+
+		split( startPos );
+	} else {
+		const range = selection.getFirstRange();
+		const startElement = range.start.parent;
+		const endElement = range.end.parent;
+		const shouldMerge = range.start.isAtStart() && range.end.isAtEnd();
+		const isContainedWithinOneElement = ( startElement == endElement );
+
+		doc.composer.deleteContents( batch, selection, { merge: shouldMerge } );
+
+		// Don't touch the root.
+		if ( startElement.root == startElement ) {
+			return;
+		}
+
+		const newBlockName = getNewBlockName( doc, startElement, defaultBlockName );
+		const needsRename = startElement.name != newBlockName;
+
+		// Fully selected elements.
+		//
+		// <h>[xx</h><p>yy]<p>	-> <h>^</h>		-> <p>^</p>
+		// <h>[xxyy]</h>		-> <h>^</h>		-> <p>^</p>
+		if ( shouldMerge ) {
+			const pos = Position.createFromPosition( selection.focus );
+
+			if ( needsRename ) {
+				batch.rename( newBlockName, startElement );
+			}
+
+			selection.collapse( pos );
+		} else if ( isContainedWithinOneElement ) {
+			split( selection.focus );
+		} else {
+			selection.collapse( endElement );
+		}
+	}
+
+	function split( splitPos ) {
+		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;
+}

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

@@ -0,0 +1,180 @@
+/**
+ * @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';
+
+describe( 'EnterCommand', () => {
+	let editor, document;
+
+	beforeEach( () => {
+		editor = new Editor();
+		document = editor.document = new Document();
+
+		editor.document.createRoot( 'main', '$root' );
+
+		const command = new EnterCommand( editor );
+		editor.commands.set( 'enter', command );
+
+		const schema = document.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( '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(
+				'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 (bacward)',
+				'<h><selection backward>abc</h><p>def</selection></p>',
+				'<p><selection /></p>',
+				{ defaultBlockName: 'p' }
+			);
+
+			it( 'uses composer.deleteContents', () => {
+				const spy = sinon.spy();
+
+				document.composer.on( 'deleteContents', spy );
+
+				setData( document, 'main', '<p><selection>x</selection></p>' );
+
+				enterBlock( document.batch(), document.selection, { defaultBlockName: 'p' } );
+
+				expect( spy.calledOnce ).to.be.true;
+			} );
+		} );
+
+		function test( title, input, output, options ) {
+			it( title, () => {
+				setData( document, 'main', input );
+
+				enterBlock( document.batch(), document.selection, options );
+
+				expect( getData( document, 'main', { selection: true } ) ).to.equal( output );
+			} );
+		}
+	} );
+} );