Selaa lähdekoodia

Merge pull request #2 from ckeditor/t/1

Add autoformat feature
Szymon Kupś 9 vuotta sitten
vanhempi
sitoutus
d5d9bf99e0

+ 1 - 1
packages/ckeditor5-autoformat/gulpfile.js

@@ -19,7 +19,7 @@ const config = {
 	]
 };
 
-const ckeditor5Lint = require( 'ckeditor5-dev-lint' )( config );
+const ckeditor5Lint = require( '@ckeditor/ckeditor5-dev-lint' )( config );
 
 gulp.task( 'lint', ckeditor5Lint.lint );
 gulp.task( 'lint-staged', ckeditor5Lint.lintStaged );

+ 75 - 0
packages/ckeditor5-autoformat/src/autoformat.js

@@ -0,0 +1,75 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import AutoformatEngine from './autoformatengine.js';
+import Feature from '../core/feature.js';
+import HeadingEngine from '../heading/headingengine.js';
+import ListEngine from '../list/listengine.js';
+
+/**
+ * Includes set of predefined Autoformatting actions:
+ * * Bulleted list,
+ * * Numbered list,
+ * * Headings.
+ *
+ * @memberOf autoformat
+ * @extends core.Feature
+ */
+export default class Autoformat extends Feature {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ HeadingEngine, ListEngine ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		this._addListAutoformats();
+		this._addHeadingAutoformats();
+	}
+
+	/**
+	 * Add autoformats related to ListEngine commands.
+	 *
+	 * When typed:
+	 *
+	 * 	`* ` or `- `
+	 *		Paragraph will be changed to a bulleted list.
+	 *
+	 * 	`1. ` or `1) `
+	 *		Paragraph will be changed to a numbered list (1 can be any digit or list of digits).
+	 *
+	 * @private
+	 */
+	_addListAutoformats() {
+		new AutoformatEngine( this.editor, /^[\*\-]\s$/, 'bulletedList' );
+		new AutoformatEngine( this.editor, /^\d+[\.|)]?\s$/, 'numberedList' );
+	}
+
+	/**
+	 * Add autoformats related to HeadingEngine commands.
+	 *
+	 * When typed:
+	 *
+	 * 	`#` or `##` or `###`
+	 *		Paragraph will be changed to a corresponding heading level.
+	 *
+	 * @private
+	 */
+	_addHeadingAutoformats() {
+		new AutoformatEngine( this.editor, /^(#{1,3})\s$/, ( context ) => {
+			const { batch, match } = context;
+			const headingLevel = match[ 1 ].length;
+
+			this.editor.execute( 'heading', {
+				batch,
+				formatId: `heading${ headingLevel }`
+			} );
+		} );
+	}
+}

+ 98 - 0
packages/ckeditor5-autoformat/src/autoformatengine.js

@@ -0,0 +1,98 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Range from '../engine/model/range.js';
+import TextProxy from '../engine/model/textproxy.js';
+
+export default class AutoformatEngine {
+	/**
+	 * Creates listener triggered on `change` event in document.
+	 * Calls callback when inserted text matches regular expression or command name
+	 * if provided instead of callback.
+	 *
+	 * Examples of usage:
+	 *
+	 * To convert paragraph to heading1 when `- ` is typed, using just commmand name:
+	 *
+	 *		createAutoformat( editor, /^\- $/, 'heading1');
+	 *
+	 * To convert paragraph to heading1 when `- ` is typed, using just callback:
+	 *
+	 *		createAutoformat( editor, /^\- $/, ( context ) => {
+	 *			const { batch, match } = context;
+	 *			const headingLevel = match[ 1 ].length;
+	 *
+	 *			editor.execute( 'heading', {
+	 *				batch,
+	 *				formatId: `heading${ headingLevel }`
+	 *			} );
+	 * 		} );
+	 *
+	 * @param {core.editor.Editor} editor Editor instance.
+	 * @param {Regex} pattern Regular expression to exec on just inserted text.
+	 * @param {Function|String} callbackOrCommand Callback to execute or command to run when text is matched.
+	 * In case of providing callback it receives following parameters:
+	 * * {engine.model.Batch} batch Newly created batch for autoformat changes.
+	 * * {Object} match RegExp.exec() result of matching pattern to inserted text.
+	 */
+	constructor( editor, pattern, callbackOrCommand ) {
+		let callback;
+
+		if ( typeof callbackOrCommand == 'function' ) {
+			callback = callbackOrCommand;
+		} else {
+			// We assume that the actual command name was provided.
+			const command = callbackOrCommand;
+
+			callback = ( context ) => {
+				const { batch } = context;
+
+				// Create new batch for removal and command execution.
+				editor.execute( command, { batch } );
+			};
+		}
+
+		editor.document.on( 'change', ( event, type, changes ) => {
+			if ( type != 'insert' ) {
+				return;
+			}
+
+			// Take the first element. Typing shouldn't add more than one element at once.
+			// And if it is not typing (e.g. paste), Autoformat should not be fired.
+			const value = changes.range.getItems().next().value;
+
+			if ( !( value instanceof TextProxy ) ) {
+				return;
+			}
+
+			const textNode = value.textNode;
+			const text = textNode.data;
+
+			// Run matching only on non-empty paragraphs.
+			if ( textNode.parent.name !== 'paragraph' || !text ) {
+				return;
+			}
+
+			const match = pattern.exec( text );
+
+			if ( !match ) {
+				return;
+			}
+
+			editor.document.enqueueChanges( () => {
+				// Create new batch to separate typing batch from the Autoformat changes.
+				const batch = editor.document.batch();
+
+				// Matched range.
+				const range = Range.createFromParentsAndOffsets( textNode.parent, 0, textNode.parent, match[ 0 ].length );
+
+				// Remove matched text.
+				batch.remove( range );
+
+				callback( { batch, match } );
+			} );
+		} );
+	}
+}

+ 106 - 0
packages/ckeditor5-autoformat/tests/autoformat.js

@@ -0,0 +1,106 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Autoformat from '/ckeditor5/autoformat/autoformat.js';
+import Paragraph from '/ckeditor5/paragraph/paragraph.js';
+import VirtualTestEditor from '/tests/core/_utils/virtualtesteditor.js';
+import Enter from '/ckeditor5/enter/enter.js';
+import { setData, getData } from '/ckeditor5/engine/dev-utils/model.js';
+import testUtils from '/tests/core/_utils/utils.js';
+
+testUtils.createSinonSandbox();
+
+describe( 'Autoformat', () => {
+	let editor, doc, batch;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			features: [ Enter, Paragraph, Autoformat ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			doc = editor.document;
+			batch = doc.batch();
+		} );
+	} );
+
+	describe( 'Bulleted list', () => {
+		it( 'should replace asterisk with bulleted list item', () => {
+			setData( doc, '<paragraph>*[]</paragraph>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), ' ' );
+			} );
+
+			expect( getData( doc ) ).to.equal( '<listItem indent="0" type="bulleted">[]</listItem>' );
+		} );
+
+		it( 'should replace minus character with bulleted list item', () => {
+			setData( doc, '<paragraph>-[]</paragraph>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), ' ' );
+			} );
+
+			expect( getData( doc ) ).to.equal( '<listItem indent="0" type="bulleted">[]</listItem>' );
+		} );
+
+		it( 'should not replace minus character when inside bulleted list item', () => {
+			setData( doc, '<listItem indent="0" type="bulleted">-[]</listItem>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), ' ' );
+			} );
+
+			expect( getData( doc ) ).to.equal( '<listItem indent="0" type="bulleted">- []</listItem>' );
+		} );
+	} );
+
+	describe( 'Numbered list', () => {
+		it( 'should replace digit with numbered list item', () => {
+			setData( doc, '<paragraph>1.[]</paragraph>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), ' ' );
+			} );
+
+			expect( getData( doc ) ).to.equal( '<listItem indent="0" type="numbered">[]</listItem>' );
+		} );
+
+		it( 'should not replace digit character when inside numbered list item', () => {
+			setData( doc, '<listItem indent="0" type="numbered">1.[]</listItem>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), ' ' );
+			} );
+
+			expect( getData( doc ) ).to.equal( '<listItem indent="0" type="numbered">1. []</listItem>' );
+		} );
+	} );
+
+	describe( 'Heading', () => {
+		it( 'should replace hash character with heading', () => {
+			setData( doc, '<paragraph>#[]</paragraph>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), ' ' );
+			} );
+
+			expect( getData( doc ) ).to.equal( '<heading1>[]</heading1>' );
+		} );
+
+		it( 'should replace two hash characters with heading level 2', () => {
+			setData( doc, '<paragraph>##[]</paragraph>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), ' ' );
+			} );
+
+			expect( getData( doc ) ).to.equal( '<heading2>[]</heading2>' );
+		} );
+
+		it( 'should not replace minus character when inside heading', () => {
+			setData( doc, '<heading1>#[]</heading1>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), ' ' );
+			} );
+
+			expect( getData( doc ) ).to.equal( '<heading1># []</heading1>' );
+		} );
+	} );
+} );

+ 122 - 0
packages/ckeditor5-autoformat/tests/autoformatengine.js

@@ -0,0 +1,122 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import AutoformatEngine from '/ckeditor5/autoformat/autoformatengine.js';
+import Paragraph from '/ckeditor5/paragraph/paragraph.js';
+import VirtualTestEditor from '/tests/core/_utils/virtualtesteditor.js';
+import Enter from '/ckeditor5/enter/enter.js';
+import { setData, getData } from '/ckeditor5/engine/dev-utils/model.js';
+import testUtils from '/tests/core/_utils/utils.js';
+import Command from '/ckeditor5/core/command/command.js';
+
+testUtils.createSinonSandbox();
+
+describe( 'AutoformatEngine', () => {
+	let editor, doc, batch;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			features: [ Enter, Paragraph ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			doc = editor.document;
+			batch = doc.batch();
+		} );
+	} );
+
+	describe( 'Command name', () => {
+		it( 'should run a command when the pattern is matched', () => {
+			const spy = testUtils.sinon.spy();
+			editor.commands.set( 'testCommand', new TestCommand( editor, spy ) );
+			new AutoformatEngine( editor, /^[\*]\s$/, 'testCommand' );
+
+			setData( doc, '<paragraph>*[]</paragraph>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), ' ' );
+			} );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should remove found pattern', () => {
+			const spy = testUtils.sinon.spy();
+			editor.commands.set( 'testCommand', new TestCommand( editor, spy ) );
+			new AutoformatEngine( editor, /^[\*]\s$/, 'testCommand' );
+
+			setData( doc, '<paragraph>*[]</paragraph>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), ' ' );
+			} );
+
+			sinon.assert.calledOnce( spy );
+			expect( getData( doc ) ).to.equal( '<paragraph>[]</paragraph>' );
+		} );
+	} );
+
+	describe( 'Callback', () => {
+		it( 'should run callback when the pattern is matched', () => {
+			const spy = testUtils.sinon.spy();
+			new AutoformatEngine( editor, /^[\*]\s$/, spy );
+
+			setData( doc, '<paragraph>*[]</paragraph>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), ' ' );
+			} );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should ignore other delta operations', () => {
+			const spy = testUtils.sinon.spy();
+			new AutoformatEngine( editor, /^[\*]\s/, spy );
+
+			setData( doc, '<paragraph>*[]</paragraph>' );
+			doc.enqueueChanges( () => {
+				batch.remove( doc.selection.getFirstRange() );
+			} );
+
+			sinon.assert.notCalled( spy );
+		} );
+
+		it( 'should stop if there is no text to run matching on', () => {
+			const spy = testUtils.sinon.spy();
+			new AutoformatEngine( editor, /^[\*]\s/, spy );
+
+			setData( doc, '<paragraph>[]</paragraph>' );
+			doc.enqueueChanges( () => {
+				batch.insert( doc.selection.getFirstPosition(), '' );
+			} );
+
+			sinon.assert.notCalled( spy );
+		} );
+	} );
+} );
+
+/**
+ * Dummy command to execute.
+ */
+class TestCommand extends Command {
+	/**
+	 * Creates an instance of the command.
+	 *
+	 * @param {core.editor.Editor} editor Editor instance.
+	 * @param {Function} onExecuteCallback _doExecute call hook
+	 */
+	constructor( editor, onExecuteCallback ) {
+		super( editor );
+
+		this.onExecute = onExecuteCallback;
+	}
+
+	/**
+	 * Executes command.
+	 *
+	 * @protected
+	 */
+	_doExecute() {
+		this.onExecute();
+	}
+}

+ 7 - 0
packages/ckeditor5-autoformat/tests/manual/autoformat.html

@@ -0,0 +1,7 @@
+<head>
+	<link rel="stylesheet" href="%APPS_DIR%ckeditor/build/modules/amd/theme/ckeditor.css">
+</head>
+
+<div id="editor">
+	<p></p>
+</div>

+ 19 - 0
packages/ckeditor5-autoformat/tests/manual/autoformat.js

@@ -0,0 +1,19 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console:false, window, document */
+
+import ClassicEditor from '/ckeditor5/editor-classic/classic.js';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	features: [ 'enter', 'typing', 'paragraph', 'undo', 'basic-styles/bold', 'basic-styles/italic', 'heading', 'list', 'autoformat' ],
+	toolbar: [ 'bold', 'italic', 'undo', 'redo', 'headings', 'numberedList', 'bulletedList' ]
+} )
+.then( editor => {
+	window.editor = editor;
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 15 - 0
packages/ckeditor5-autoformat/tests/manual/autoformat.md

@@ -0,0 +1,15 @@
+@bender-ui: collapsed
+@bender-tags: autoformat
+
+## Autoformat
+
+1. Type `#` and press space to replace current paragraph with the heading.
+
+2. Type `*` or `-` and press space to replace current paragraph with list item.
+
+3. Type number from the range **1-3** to replace current paragraph with numbered list item.
+
+4. For every autoformat pattern: Undo until you'll see just the pattern (e.g. `- `). Typing should be then possible  without triggering autoformatting again.
+
+5. Typing a different pattern in already converted block **must not** trigger autoformatting. For example, typing `- ` in heading should not convert heading to list.
+