Przeglądaj źródła

Implementation of soft breaks into enter plugin. Usage is optional.

Alex Eckermann 7 lat temu
rodzic
commit
048b9cb2e6

+ 48 - 0
packages/ckeditor5-enter/src/softbreak.js

@@ -0,0 +1,48 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module enter/enter
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import SoftBreakEditing from './softbreakediting';
+import SoftBreakCommand from './softbreakcommand';
+import SoftBreakObserver from './softbreakobserver';
+
+/**
+ * The Enter feature. Handles the <kbd>Enter</kbd> and <kbd>Shift + Enter</kbd> keys in the editor.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class SoftBreak extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'SoftBreak';
+	}
+
+	static get requires() {
+		return [ SoftBreakEditing ];
+	}
+
+	init() {
+		const editor = this.editor;
+		const view = editor.editing.view;
+		const viewDocument = view.document;
+
+		view.addObserver( SoftBreakObserver );
+
+		editor.commands.add( 'softbreak', new SoftBreakCommand( editor ) );
+
+		// TODO We may use the keystroke handler for that.
+		this.listenTo( viewDocument, 'softbreak', ( evt, data ) => {
+			editor.execute( 'softbreak' );
+			data.preventDefault();
+			view.scrollToTheSelection();
+		}, { priority: 'low' } );
+	}
+}

+ 96 - 0
packages/ckeditor5-enter/src/softbreakcommand.js

@@ -0,0 +1,96 @@
+/**
+ * @module enter/softbreakcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+import Range from '@ckeditor/ckeditor5-engine/src/model/range';
+
+/**
+ * Soft break command.
+ *
+ * @extends module:core/command~Command
+ */
+export default class SoftBreakCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		const model = this.editor.model;
+		const doc = model.document;
+
+		model.change( writer => {
+			softBreakAction( this.editor.model, writer, doc.selection, model.schema );
+			this.fire( 'afterExecute', { writer } );
+		} );
+	}
+}
+
+// Creates a new block in the way that the <kbd>Enter</kbd> key is expected to work.
+//
+// @param {module:engine/model~Model} model
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
+// Selection on which the action should be performed.
+// @param {module:engine/model/schema~Schema} schema
+function softBreakAction( model, writer, selection, schema ) {
+	const isSelectionEmpty = selection.isCollapsed;
+	const range = selection.getFirstRange();
+	const startElement = range.start.parent;
+	const endElement = range.end.parent;
+
+	// Don't touch the roots and other limit elements.
+	if ( schema.isLimit( startElement ) || schema.isLimit( endElement ) ) {
+		// Delete the selected content but only if inside a single limit element.
+		// Abort, when crossing limit elements boundary (e.g. <limit1>x[x</limit1>donttouchme<limit2>y]y</limit2>).
+		// This is an edge case and it's hard to tell what should actually happen because such a selection
+		// is not entirely valid.
+		if ( !isSelectionEmpty && startElement == endElement ) {
+			model.deleteContent( selection );
+		}
+
+		return;
+	}
+
+	if ( isSelectionEmpty ) {
+		insertBreak( writer, selection, range.end );
+	} else {
+		const leaveUnmerged = !( range.start.isAtStart && range.end.isAtEnd );
+		const isContainedWithinOneElement = ( startElement == endElement );
+
+		model.deleteContent( selection, { leaveUnmerged } );
+
+		if ( leaveUnmerged ) {
+			// Partially selected elements.
+			//
+			// <h>x[xx]x</h>		-> <h>x^x</h>			-> <h>x</h><h>^x</h>
+			if ( isContainedWithinOneElement ) {
+				insertBreak( writer, selection, selection.focus );
+			}
+			// Selection over multiple elements.
+			//
+			// <h>x[x</h><p>y]y<p>	-> <h>x^</h><p>y</p>	-> <h>x</h><p>^y</p>
+			else {
+				writer.setSelection( endElement, 0 );
+			}
+		}
+	}
+}
+
+function insertBreak( writer, selection, position ) {
+	let newBreak = writer.createElement( 'br' );
+	
+	if ( position.isAtEnd ) {
+		// If the position is at the end of the element then insert the break at the end
+		writer.insert( newBreak, position, 'after' );
+		writer.setSelection( newBreak, 'after' );
+	} else if ( position.isAtStart ) {
+		// If the position is at the start of element, insert break at start
+		writer.insert( newBreak, position, 'before' );
+		writer.setSelection( newBreak, 'after' );
+	} else {
+		writer.insert( newBreak, position, 'end' );
+		writer.setSelection( newBreak, 'after' );
+	}
+	
+}

+ 53 - 0
packages/ckeditor5-enter/src/softbreakediting.js

@@ -0,0 +1,53 @@
+/**
+ * @module enter/softbreakediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+// import {
+// 	viewFigureToModel,
+// 	modelToViewAttributeConverter,
+// 	srcsetAttributeConverter
+// } from './converters';
+
+import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
+
+// import SoftBreakCommand from './softbreakcommand';
+
+/**
+ * The soft-break enter plugin.
+ * It registers `<br>` as an element in the document schema.
+ * It also registers converters for editing and data pipelines.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class SoftBreakEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+		const t = editor.t;
+		const conversion = editor.conversion;
+
+		// Configure schema.
+		schema.register( 'br', {
+			isObject: false,
+			isBlock: false,
+			allowWhere: '$text'
+		} );
+
+		conversion.elementToElement( { model: 'br', view: 'br' } )
+
+		conversion.for( 'downcast' )
+			.add( downcastElementToElement( {
+				model: 'br',
+				view: ( modelElement, viewWriter ) => viewWriter.createEmptyElement( 'br' )
+			} ) );
+
+	}
+}

+ 56 - 0
packages/ckeditor5-enter/src/softbreakobserver.js

@@ -0,0 +1,56 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module enter/enterobserver
+ */
+
+import Observer from '@ckeditor/ckeditor5-engine/src/view/observer/observer';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+/**
+ * Enter observer introduces the {@link module:engine/view/document~Document#event:enter} event.
+ *
+ * @extends module:engine/view/observer~Observer
+ */
+export default class SoftBreakObserver extends Observer {
+	constructor( view ) {
+		super( view );
+
+		const document = this.document;
+
+		document.on( 'keydown', ( evt, data ) => {
+			if ( this.isEnabled && data.keyCode == keyCodes.enter && data.shiftKey ) {
+				// Save the event object to check later if it was stopped or not.
+				let event;
+				document.once( 'softbreak', evt => ( event = evt ), { priority: 'highest' } );
+
+				document.fire( 'softbreak', new DomEventData( document, data.domEvent ) );
+
+				// Stop `keydown` event if `enter` event was stopped.
+				// https://github.com/ckeditor/ckeditor5/issues/753
+				if ( event && event.stop.called ) {
+					evt.stop();
+				}
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	observe() {}
+}
+
+/**
+ * Event fired when the user presses the <kbd>Enter</kbd> key.
+ *
+ * Note: This event is fired by the {@link module:enter/enterobserver~EnterObserver observer}
+ * (usually registered by the {@link module:enter/enter~Enter Enter feature}).
+ *
+ * @event module:engine/view/document~Document#event:enter
+ * @param {module:engine/view/observer/domeventdata~DomEventData} data
+ */

+ 57 - 0
packages/ckeditor5-enter/tests/softbreak.js

@@ -0,0 +1,57 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import SoftBreak from '../src/softbreak';
+import SoftBreakCommand from '../src/softbreakcommand';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+
+describe( 'Soft Break feature', () => {
+	let editor, viewDocument;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ SoftBreak ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				viewDocument = editor.editing.view.document;
+			} );
+	} );
+
+	it( 'creates the commands', () => {
+		expect( editor.commands.get( 'softbreak' ) ).to.be.instanceof( SoftBreakCommand );
+	} );
+
+	it( 'listens to the editing view softbreak event', () => {
+		const spy = editor.execute = sinon.spy();
+		const domEvt = getDomEvent();
+
+		viewDocument.fire( 'softbreak', new DomEventData( viewDocument, domEvt ) );
+
+		expect( spy.calledOnce ).to.be.true;
+		expect( spy.calledWithExactly( 'softbreak' ) ).to.be.true;
+
+		expect( domEvt.preventDefault.calledOnce ).to.be.true;
+	} );
+
+	it( 'scrolls the editing document to the selection after executing the command', () => {
+		const domEvt = getDomEvent();
+		const executeSpy = editor.execute = sinon.spy();
+		const scrollSpy = sinon.stub( editor.editing.view, 'scrollToTheSelection' );
+
+		viewDocument.fire( 'softbreak', new DomEventData( viewDocument, domEvt ) );
+
+		sinon.assert.calledOnce( scrollSpy );
+		sinon.assert.callOrder( executeSpy, scrollSpy );
+	} );
+
+	function getDomEvent() {
+		return {
+			preventDefault: sinon.spy()
+		};
+	}
+} );

+ 206 - 0
packages/ckeditor5-enter/tests/softbreakcommand.js

@@ -0,0 +1,206 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import SoftBreakCommand from '../src/softbreakcommand';
+import SoftBreakEditing from '../src/softbreakediting';
+import InsertDelta from '@ckeditor/ckeditor5-engine/src/model/delta/insertdelta';
+import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'SoftBreakCommand', () => {
+	let editor, model, doc, schema, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create( {
+			plugins: [ SoftBreakEditing ]
+		} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+
+				command = new SoftBreakCommand( editor );
+				editor.commands.add( 'softbreak', command );
+
+				schema = model.schema;
+
+				// TODO softbreakediting include
+
+				// 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.register( 'img', { allowWhere: '$block' } );
+				schema.register( 'p', {
+					inheritAllFrom: '$block',
+					allowIn: 'blockLimit'
+				} );
+				schema.register( 'h', { inheritAllFrom: '$block' } );
+				schema.register( 'inlineLimit', {
+					allowIn: 'p',
+					isLimit: true
+				} );
+				schema.register( 'blockLimit', {
+					allowIn: '$root',
+					isLimit: true
+				} );
+				schema.extend( '$text', {
+					allowIn: [ 'inlineLimit', '$root' ]
+				} );
+			} );
+	} );
+
+	describe( 'SoftBreakCommand', () => {
+		it( 'soft breaks a block using parent batch', () => {
+			setData( model, '<p>foo[]</p>' );
+
+			model.change( writer => {
+				expect( writer.batch.deltas ).to.length( 0 );
+				editor.execute( 'softbreak' );
+				expect( writer.batch.deltas ).to.length.above( 0 );
+			} );
+		} );
+
+		it( 'creates InsertDelta if soft break is at the beginning of block', () => {
+			setData( model, '<p>[]foo</p>' );
+
+			editor.execute( 'softbreak' );
+
+			const deltas = Array.from( doc.history.getDeltas() );
+
+			expect( deltas[ deltas.length - 1 ] ).to.be.instanceof( InsertDelta );
+		} );
+
+		it( 'creates InsertDelta if soft break is at the end of block', () => {
+			setData( model, '<p>foo[]</p>' );
+
+			editor.execute( 'softbreak' );
+
+			const deltas = Array.from( doc.history.getDeltas() );
+
+			expect( deltas[ deltas.length - 1 ] ).to.be.instanceof( InsertDelta );
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		describe( 'collapsed selection', () => {
+			test(
+				'does nothing in the root',
+				'foo[]bar',
+				'foo[]bar'
+			);
+
+			test(
+				'inserts inside block',
+				'<p>x</p><p>foo[]bar</p><p>y</p>',
+				'<p>x</p><p>foo<br></br>[]bar</p><p>y</p>'
+			);
+
+			test(
+				'inserts at the end of block',
+				'<p>x</p><p>foo[]</p><p>y</p>',
+				'<p>x</p><p>foo<br></br>[]</p><p>y</p>'
+			);
+
+			test(
+				'inserts at the beginning of block',
+				'<p>x</p><p>[]foo</p><p>y</p>',
+				'<p>x</p><p><br></br>[]foo</p><p>y</p>',
+			);
+
+			// test(
+			// 	'inserts new block after empty one',
+			// 	'<p>x</p><p>[]</p><p>y</p>',
+			// 	'<p>x</p><p></p><p>[]</p><p>y</p>'
+			// );
+		} );
+
+		describe( 'non-collapsed selection', () => {
+			test(
+				'only deletes the content when directly in the root',
+				'fo[ob]ar',
+				'fo[]ar'
+			);
+
+			test(
+				'deletes text and adds break',
+				'<p>ab[cd]ef</p><p>ghi</p>',
+				'<p>ab<br></br>[]ef</p><p>ghi</p>'
+			);
+
+			test(
+				'places selection in the 2nd element',
+				'<h>ab[c</h><p>d]ef</p><p>ghi</p>',
+				'<h>ab</h><p>[]ef</p><p>ghi</p>'
+			);
+
+			test(
+				'leaves one empty element after one was fully selected',
+				'<p>x</p><p>[abcdef]</p><p>y</p>',
+				'<p>x</p><p>[]</p><p>y</p>'
+			);
+
+			test(
+				'leaves one empty element after two were fully selected',
+				'<p>[abc</p><p>def]</p>',
+				'<p>[]</p>'
+			);
+
+			test(
+				'should not break inline limit elements - collapsed',
+				'<p><inlineLimit>foo[]bar</inlineLimit></p>',
+				'<p><inlineLimit>foo[]bar</inlineLimit></p>'
+			);
+
+			test(
+				'should not break inline limit elements',
+				'<p><inlineLimit>foo[bar]baz</inlineLimit></p>',
+				'<p><inlineLimit>foo[]baz</inlineLimit></p>'
+			);
+
+			test(
+				'should not break inline limit elements - selection partially inside',
+				'<p><inlineLimit>ba[r</inlineLimit></p><p>f]oo</p>',
+				'<p><inlineLimit>ba[r</inlineLimit></p><p>f]oo</p>'
+			);
+
+			test(
+				'should break paragraph in blockLimit',
+				'<blockLimit><p>foo[]bar</p></blockLimit>',
+				'<blockLimit><p>foo<br></br>[]bar</p></blockLimit>'
+			);
+
+			it( 'leaves one empty element after two were fully selected (backward)', () => {
+				setData( model, '<p>[abc</p><p>def]</p>' );
+				// @TODO: Add option for setting selection direction to model utils.
+				doc.selection._lastRangeBackward = true;
+
+				command.execute();
+
+				expect( getData( model ) ).to.equal( '<p>[]</p>' );
+			} );
+
+			it( 'uses DataController.deleteContent', () => {
+				const spy = sinon.spy();
+
+				editor.model.on( 'deleteContent', spy );
+
+				setData( model, '<p>[x]</p>' );
+
+				command.execute();
+
+				expect( spy.calledOnce ).to.be.true;
+			} );
+		} );
+
+		function test( title, input, output ) {
+			it( title, () => {
+				setData( model, input );
+
+				command.execute();
+
+				expect( getData( model ) ).to.equal( output );
+			} );
+		}
+	} );
+} );

+ 102 - 0
packages/ckeditor5-enter/tests/softbreakobserver.js

@@ -0,0 +1,102 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document */
+
+import View from '@ckeditor/ckeditor5-engine/src/view/view';
+import SoftBreakObserver from '../src/softbreakobserver';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import createViewRoot from '@ckeditor/ckeditor5-engine/tests/view/_utils/createroot';
+import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+describe( 'SoftBreakObserver', () => {
+	let view, viewDocument;
+
+	beforeEach( () => {
+		view = new View();
+		viewDocument = view.document;
+		view.addObserver( SoftBreakObserver );
+	} );
+
+	// See #10.
+	it( 'can be initialized', () => {
+		expect( () => {
+			createViewRoot( viewDocument );
+			view.attachDomRoot( document.createElement( 'div' ) );
+		} ).to.not.throw();
+	} );
+
+	describe( 'enter event', () => {
+		it( 'is fired on keydown', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'softbreak', spy );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: getCode( 'enter' ),
+				shiftKey: true
+			} ) );
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
+		it( 'is not fired on keydown when keyCode does not match enter', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'softbreak', spy );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: 1
+			} ) );
+
+			expect( spy.calledOnce ).to.be.false;
+		} );
+		
+		it( 'is not fired on keydown when keyCode does not match shift+enter', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'softbreak', spy );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: 'enter',
+				shiftKey: false
+			} ) );
+
+			expect( spy.calledOnce ).to.be.false;
+		} );
+
+		it( 'should stop keydown event when shift+enter event is stopped', () => {
+			const keydownSpy = sinon.spy();
+			viewDocument.on( 'keydown', keydownSpy );
+			viewDocument.on( 'softbreak', evt => evt.stop() );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: getCode( 'enter' ),
+				shiftKey: true
+			} ) );
+
+			sinon.assert.notCalled( keydownSpy );
+		} );
+
+		it( 'should not stop keydown event when shift+enter event is not stopped', () => {
+			const keydownSpy = sinon.spy();
+			viewDocument.on( 'keydown', keydownSpy );
+			viewDocument.on( 'softbreak', evt => evt.stop() );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: getCode( 'x' ),
+				shiftKey: true
+			} ) );
+
+			sinon.assert.calledOnce( keydownSpy );
+		} );
+	} );
+
+	function getDomEvent() {
+		return {
+			preventDefault: sinon.spy()
+		};
+	}
+} );