Ver código fonte

Merge pull request #2 from ckeditor/t/1

Formats feature initial implementation.
Piotrek Koszuliński 9 anos atrás
pai
commit
8b85350bc2

+ 58 - 0
packages/ckeditor5-heading/src/formats.js

@@ -0,0 +1,58 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Feature from '../feature.js';
+import FormatsEngine from './formatsengine.js';
+import Model from '../ui/model.js';
+import ListDropdownController from '../ui/dropdown/list/listdropdown.js';
+import ListDropdownView from '../ui/dropdown/list/listdropdownview.js';
+import Collection from '../utils/collection.js';
+
+export default class Formats extends Feature {
+	static get requires() {
+		return [ FormatsEngine ];
+	}
+
+	init() {
+		const editor = this.editor;
+		const command = editor.commands.get( 'format' );
+		const formats = command.formats;
+		const collection = new Collection();
+
+		// Add formats to collection.
+		for ( let format of formats ) {
+			collection.add( new Model( {
+				id: format.id,
+				label: format.label
+			} ) );
+		}
+
+		// Create item list model.
+		const itemListModel = new Model( {
+			items: collection
+		} );
+
+		// Create dropdown model.
+		const dropdownModel = new Model( {
+			isEnabled: true,
+			isOn: false,
+			label: 'Formats',
+			content: itemListModel
+		} );
+
+		// Bind dropdown model to command.
+		dropdownModel.bind( 'isEnabled' ).to( command, 'isEnabled' );
+		dropdownModel.bind( 'label' ).to( command, 'value', format => format.label );
+
+		// Execute command when item from dropdown is selected.
+		this.listenTo( itemListModel, 'execute', ( evt, itemModel ) => {
+			editor.execute( 'format', itemModel.id );
+		} );
+
+		editor.ui.featureComponents.add( 'formats', ListDropdownController, ListDropdownView, dropdownModel );
+	}
+}

+ 134 - 0
packages/ckeditor5-heading/src/formatscommand.js

@@ -0,0 +1,134 @@
+/**
+ * @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 RootElement from '../engine/model/rootelement.js';
+
+export default class FormatsCommand extends Command {
+	constructor( editor, formats ) {
+		super( editor );
+
+		this.formats = formats;
+
+		this.set( 'value', this.defaultFormat );
+
+		// Listen on selection change and set current command's format to format in current selection.
+		this.listenTo( editor.document.selection, 'change', () => {
+			const position = editor.document.selection.getFirstPosition();
+			const block = findTopmostBlock( position );
+
+			if ( block ) {
+				const format = this._getFormatById( block.name );
+
+				// TODO: What should happen if format is not found?
+				this.value = format;
+			}
+		} );
+	}
+
+	/**
+	 * The default format.
+	 *
+	 * @type {Object}
+	 */
+	get defaultFormat() {
+		// See https://github.com/ckeditor/ckeditor5/issues/98.
+		return this._getFormatById( 'paragraph' );
+	}
+
+	_doExecute( formatId = this.defaultFormat.id ) {
+		// TODO: What should happen if format is not found?
+		const doc = this.editor.document;
+		const selection = doc.selection;
+		const startPosition = selection.getFirstPosition();
+		const elements = [];
+		// Storing selection ranges and direction to fix selection after renaming. See ckeditor5-engine#367.
+		const ranges = [ ...selection.getRanges() ];
+		const isSelectionBackward = selection.isBackward;
+		// If current format is same as new format - toggle already applied format back to default one.
+		const shouldRemove = ( formatId === this.value.id );
+
+		// Collect elements to change format.
+		// This implementation may not be future proof but it's satisfactory at this stage.
+		if ( selection.isCollapsed ) {
+			const block = findTopmostBlock( startPosition );
+
+			if ( block ) {
+				elements.push( block );
+			}
+		} else {
+			for ( let range of ranges ) {
+				let startBlock = findTopmostBlock( range.start );
+				const endBlock = findTopmostBlock( range.end, false );
+
+				elements.push( startBlock );
+
+				while ( startBlock !== endBlock ) {
+					startBlock = startBlock.nextSibling;
+					elements.push( startBlock );
+				}
+			}
+		}
+
+		doc.enqueueChanges( () => {
+			const batch = doc.batch();
+
+			for ( let element of elements ) {
+				// When removing applied format.
+				if ( shouldRemove ) {
+					if ( element.name === formatId ) {
+						batch.rename( this.defaultFormat.id, element );
+					}
+				}
+				// When applying new format.
+				else {
+					batch.rename( formatId, element );
+				}
+			}
+
+			// If range's selection start/end is placed directly in renamed block - we need to restore it's position
+			// after renaming, because renaming puts new element there.
+			doc.selection.setRanges( ranges, isSelectionBackward );
+		} );
+	}
+
+	/**
+	 * Returns format by given id.
+	 *
+	 * @private
+	 * @param {String} id
+	 * @returns {Object}
+	 */
+	_getFormatById( id ) {
+		return this.formats.find( item => item.id === id );
+	}
+}
+
+// Looks for topmost element from position parent to element placed in root.
+//
+// NOTE: This method does not checks schema directly - assumes that only block elements can be placed directly inside
+// root.
+//
+// @private
+// @param {engine.model.Position} position
+// @param {Boolean} [nodeAfter=true] When position is placed inside root element this will determine if element before
+// or after given position will be returned.
+// @returns {engine.model.Element}
+function findTopmostBlock( position, nodeAfter = true ) {
+	let parent = position.parent;
+
+	// If position is placed inside root - get element after/before it.
+	if ( parent instanceof RootElement ) {
+		return nodeAfter ? position.nodeAfter : position.nodeBefore;
+	}
+
+	while ( !( parent.parent instanceof RootElement ) ) {
+		parent = parent.parent;
+	}
+
+	return parent;
+}

+ 53 - 0
packages/ckeditor5-heading/src/formatsengine.js

@@ -0,0 +1,53 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Feature from '../feature.js';
+import BuildModelConverterFor from '../engine/conversion/model-converter-builder.js';
+import BuildViewConverterFor from '../engine/conversion/view-converter-builder.js';
+import Paragraph from '../paragraph/paragraph.js';
+import FormatsCommand from './formatscommand.js';
+
+const formats = [
+	{ id: 'paragraph', viewElement: 'p', label: 'Paragraph' },
+	{ id: 'heading1', viewElement: 'h2', label: 'Heading 1' },
+	{ id: 'heading2', viewElement: 'h3', label: 'Heading 2' },
+	{ id: 'heading3', viewElement: 'h4', label: 'Heading 3' }
+];
+
+export default class FormatsEngine extends Feature {
+	static get requires() {
+		return [ Paragraph ];
+	}
+
+	init() {
+		const editor = this.editor;
+		const data = editor.data;
+		const editing = editor.editing;
+
+		for ( let format of formats ) {
+			// Skip paragraph - it is defined in required Paragraph feature.
+			if ( format.id !== 'paragraph' ) {
+				// Schema.
+				editor.document.schema.registerItem( format.id, '$block' );
+
+				// Build converter from model to view for data and editing pipelines.
+				BuildModelConverterFor( data.modelToView, editing.modelToView )
+					.fromElement( format.id )
+					.toElement( format.viewElement );
+
+				// Build converter from view to model for data pipeline.
+				BuildViewConverterFor( data.viewToModel )
+					.fromElement( format.viewElement )
+					.toElement( format.id );
+			}
+		}
+
+		// Register command.
+		const command = new FormatsCommand( editor, formats );
+		editor.commands.set( 'format', command );
+	}
+}

+ 82 - 0
packages/ckeditor5-heading/tests/formats.js

@@ -0,0 +1,82 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import ClassicTestEditor from '/tests/ckeditor5/_utils/classictesteditor.js';
+import Formats from '/ckeditor5/formats/formats.js';
+import FormatsEngine from '/ckeditor5/formats/formatsengine.js';
+import ListDropdown from '/ckeditor5/ui/dropdown/list/listdropdown.js';
+import testUtils from '/tests/ckeditor5/_utils/utils.js';
+
+testUtils.createSinonSandbox();
+
+describe( 'Formats', () => {
+	let editor, controller;
+
+	beforeEach( () => {
+		const editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			features: [ Formats ],
+			toolbar: [ 'formats' ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			controller = editor.ui.featureComponents.create( 'formats' );
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( Formats ) ).to.be.instanceOf( Formats );
+	} );
+
+	it( 'should load FormatsEngine', () => {
+		expect( editor.plugins.get( FormatsEngine ) ).to.be.instanceOf( FormatsEngine );
+	} );
+
+	it( 'should register formats feature component', () => {
+		const controller = editor.ui.featureComponents.create( 'formats' );
+
+		expect( controller ).to.be.instanceOf( ListDropdown );
+	} );
+
+	it( 'should execute format command on model execute event', () => {
+		const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+		const controller = editor.ui.featureComponents.create( 'formats' );
+		const model = controller.model.content;
+
+		model.fire( 'execute', { id: 'paragraph', label: 'Paragraph' } );
+
+		sinon.assert.calledOnce( executeSpy );
+		sinon.assert.calledWithExactly( executeSpy, 'format', 'paragraph' );
+	} );
+
+	describe( 'model to commanad binding', () => {
+		let model, command;
+
+		beforeEach( () => {
+			model = controller.model;
+			command = editor.commands.get( 'format' );
+		} );
+
+		it( 'isEnabled', () => {
+			expect( model.isEnabled ).to.be.true;
+			command.isEnabled = false;
+			expect( model.isEnabled ).to.be.false;
+		} );
+
+		it( 'label', () => {
+			expect( model.label ).to.equal( 'Paragraph' );
+			command.value = command.formats[ 1 ];
+			expect( model.label ).to.equal( 'Heading 1' );
+		} );
+	} );
+} );

+ 136 - 0
packages/ckeditor5-heading/tests/formatscommand.js

@@ -0,0 +1,136 @@
+/**
+ * @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 FormatsCommand from '/ckeditor5/formats/formatscommand.js';
+import Range from '/ckeditor5/engine/model/range.js';
+import { setData, getData } from '/tests/engine/_utils/model.js';
+
+const formats = [
+	{ id: 'paragraph', viewElement: 'p', default: true },
+	{ id: 'heading1', viewElement: 'h2' },
+	{ id: 'heading2', viewElement: 'h3' },
+	{ id: 'heading3', viewElement: 'h4' }
+];
+
+describe( 'FormatsCommand', () => {
+	let editor, document, command, root;
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				document = editor.document;
+				command = new FormatsCommand( editor, formats );
+				const schema = document.schema;
+
+				for ( let format of formats ) {
+					schema.registerItem( format.id, '$block' );
+				}
+
+				schema.registerItem( 'b', '$inline' );
+				root = document.getRoot();
+			} );
+	} );
+
+	afterEach( () => {
+		command.destroy();
+	} );
+
+	describe( 'value', () => {
+		for ( let format of formats ) {
+			test( format );
+		}
+
+		function test( format ) {
+			it( `equals ${ format.id } when collapsed selection is placed inside ${ format.id } element`, () => {
+				setData( document, `<${ format.id }>foobar</${ format.id }>` );
+				const element = root.getChild( 0 );
+				document.selection.addRange( Range.createFromParentsAndOffsets( element, 3, element, 3 ) );
+
+				expect( command.value ).to.equal( format );
+			} );
+		}
+	} );
+
+	describe( '_doExecute', () => {
+		describe( 'collapsed selection', () => {
+			let convertTo = formats[ formats.length - 1 ];
+
+			for ( let format of formats ) {
+				test( format, convertTo );
+				convertTo = format;
+			}
+
+			it( 'uses paragraph as default value', () => {
+				setData( document, '<heading1>foo<selection />bar</heading1>' );
+				command._doExecute();
+
+				expect( getData( document ) ).to.equal( '<paragraph>foo<selection />bar</paragraph>' );
+			} );
+
+			it( 'converts to default format when executed with already applied format', () => {
+				setData( document, '<heading1>foo<selection />bar</heading1>' );
+				command._doExecute( 'heading1' );
+
+				expect( getData( document ) ).to.equal( '<paragraph>foo<selection />bar</paragraph>' );
+			} );
+
+			it( 'converts topmost blocks', () => {
+				setData( document, '<heading1><b>foo<selection /></b>bar</heading1>' );
+				command._doExecute( 'heading1' );
+
+				expect( getData( document ) ).to.equal( '<paragraph><b>foo<selection /></b>bar</paragraph>' );
+			} );
+
+			function test( from, to ) {
+				it( `converts ${ from.id } to ${ to.id } on collapsed selection`, () => {
+					setData( document, `<${ from.id }>foo<selection />bar</${ from.id }>` );
+					command._doExecute( to.id );
+
+					expect( getData( document ) ).to.equal( `<${ to.id }>foo<selection />bar</${ to.id }>` );
+				} );
+			}
+		} );
+
+		describe( 'non-collapsed selection', () => {
+			let convertTo = formats[ formats.length - 1 ];
+
+			for ( let format of formats ) {
+				test( format, convertTo );
+				convertTo = format;
+			}
+
+			it( 'converts all elements where selection is applied', () => {
+				setData( document, '<heading1>foo<selection></heading1><heading2>bar</heading2><heading2></selection>baz</heading2>' );
+				command._doExecute( 'paragraph' );
+
+				expect( getData( document ) ).to.equal(
+					'<paragraph>foo<selection></paragraph><paragraph>bar</paragraph><paragraph></selection>baz</paragraph>'
+				);
+			} );
+
+			it( 'resets to default value all elements with same format', () => {
+				setData( document, '<heading1>foo<selection></heading1><heading1>bar</heading1><heading2>baz</heading2></selection>' );
+				command._doExecute( 'heading1' );
+
+				expect( getData( document ) ).to.equal(
+					'<paragraph>foo<selection></paragraph><paragraph>bar</paragraph><heading2>baz</heading2></selection>'
+				);
+			} );
+
+			function test( from, to ) {
+				it( `converts ${ from.id } to ${ to.id } on non-collapsed selection`, () => {
+					setData( document, `<${ from.id }>foo<selection>bar</${ from.id }><${ from.id }>baz</selection>qux</${ from.id }>` );
+					command._doExecute( to.id );
+
+					expect( getData( document ) ).to.equal( `<${ to.id }>foo<selection>bar</${ to.id }><${ to.id }>baz</selection>qux</${ to.id }>` );
+				} );
+			}
+		} );
+	} );
+} );

+ 77 - 0
packages/ckeditor5-heading/tests/formatsengine.js

@@ -0,0 +1,77 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import FormatsEngine from '/ckeditor5/formats/formatsengine.js';
+import Paragraph from '/ckeditor5/paragraph/paragraph.js';
+import VirtualTestEditor from '/tests/ckeditor5/_utils/virtualtesteditor.js';
+import FormatsCommand from '/ckeditor5/formats/formatscommand.js';
+import { getData } from '/tests/engine/_utils/model.js';
+
+describe( 'FormatsEngine', () => {
+	let editor, document;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			features: [ FormatsEngine ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			document = editor.document;
+		} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( FormatsEngine ) ).to.be.instanceOf( FormatsEngine );
+	} );
+
+	it( 'should load paragraph feature', () => {
+		expect( editor.plugins.get( Paragraph ) ).to.be.instanceOf( Paragraph );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( document.schema.hasItem( 'heading1' ) ).to.be.true;
+		expect( document.schema.hasItem( 'heading2' ) ).to.be.true;
+		expect( document.schema.hasItem( 'heading3' ) ).to.be.true;
+
+		expect( document.schema.check( { name: 'heading1', inside: '$root' } ) ).to.be.true;
+		expect( document.schema.check( { name: '$inline', inside: 'heading1' } ) ).to.be.true;
+
+		expect( document.schema.check( { name: 'heading2', inside: '$root' } ) ).to.be.true;
+		expect( document.schema.check( { name: '$inline', inside: 'heading2' } ) ).to.be.true;
+
+		expect( document.schema.check( { name: 'heading3', inside: '$root' } ) ).to.be.true;
+		expect( document.schema.check( { name: '$inline', inside: 'heading3' } ) ).to.be.true;
+	} );
+
+	it( 'should register format command', () => {
+		expect( editor.commands.has( 'format' ) ).to.be.true;
+		const command = editor.commands.get( 'format' );
+
+		expect( command ).to.be.instanceOf( FormatsCommand );
+	} );
+
+	it( 'should convert heading1', () => {
+		editor.setData( '<h2>foobar</h2>' );
+
+		expect( getData( document, { withoutSelection: true } ) ).to.equal( '<heading1>foobar</heading1>' );
+		expect( editor.getData() ).to.equal( '<h2>foobar</h2>' );
+	} );
+
+	it( 'should convert heading2', () => {
+		editor.setData( '<h3>foobar</h3>' );
+
+		expect( getData( document, { withoutSelection: true } ) ).to.equal( '<heading2>foobar</heading2>' );
+		expect( editor.getData() ).to.equal( '<h3>foobar</h3>' );
+	} );
+
+	it( 'should convert heading3', () => {
+		editor.setData( '<h4>foobar</h4>' );
+
+		expect( getData( document, { withoutSelection: true } ) ).to.equal( '<heading3>foobar</heading3>' );
+		expect( editor.getData() ).to.equal( '<h4>foobar</h4>' );
+	} );
+} );

+ 10 - 0
packages/ckeditor5-heading/tests/manual/formats.html

@@ -0,0 +1,10 @@
+<head>
+	<link rel="stylesheet" href="%APPS_DIR%ckeditor/build/amd/theme/ckeditor.css">
+</head>
+
+<div id="editor">
+	<h2>Heading 1</h2>
+	<h3>Heading 2</h3>
+	<h4>Heading 3</h4>
+	<p>Paragraph</p>
+</div>

+ 21 - 0
packages/ckeditor5-heading/tests/manual/formats.js

@@ -0,0 +1,21 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console:false */
+
+'use strict';
+
+import ClassicEditor from '/ckeditor5/creator-classic/classic.js';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	features: [ 'delete', 'enter', 'typing', 'paragraph', 'formats' ],
+	toolbar: [ 'formats' ]
+} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 10 - 0
packages/ckeditor5-heading/tests/manual/formats.md

@@ -0,0 +1,10 @@
+@bender-ui: collapsed
+
+## Formats
+
+1. The data should be loaded with three headings and one paragraph.
+2. Put selection inside each block and check if dropdown label is changing properly.
+3. Play with formatting:
+	- Switch formats using dropdown.
+	- Apply same format twice to same block to see if its switching with paragraph.
+	- Put selection that spans across multiple blocks and switch formats.