瀏覽代碼

Move TextWatcher from utils to typing plugin.

Maciej Gołaszewski 6 年之前
父節點
當前提交
d47f573e99

+ 1 - 1
packages/ckeditor5-typing/src/texttransformation.js

@@ -8,7 +8,7 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import TextWatcher from '@ckeditor/ckeditor5-utils/src/textwatcher';
+import TextWatcher from './textwatcher';
 
 // All named transformations.
 const TRANSFORMATIONS = {

+ 146 - 0
packages/ckeditor5-typing/src/textwatcher.js

@@ -0,0 +1,146 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module typing/textwatcher
+ */
+
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
+
+/**
+ * The text watcher feature.
+ *
+ * Fires {@link module:typing/textwatcher~TextWatcher#event:matched:data `matched:data`},
+ * {@link module:typing/textwatcher~TextWatcher#event:matched:selection `matched:selection`} and
+ * {@link module:typing/textwatcher~TextWatcher#event:unmatched `unmatched`} events on typing or selection changes.
+ *
+ * @private
+ */
+export default class TextWatcher {
+	/**
+	 * Creates a text watcher instance.
+	 * @param {module:engine/model/model~Model} model
+	 * @param {Function} testCallback The function used to match the text.
+	 */
+	constructor( model, testCallback ) {
+		this.model = model;
+		this.testCallback = testCallback;
+		this.hasMatch = false;
+
+		this._startListening();
+	}
+
+	/**
+	 * Starts listening to the editor for typing and selection events.
+	 *
+	 * @private
+	 */
+	_startListening() {
+		const model = this.model;
+		const document = model.document;
+
+		document.selection.on( 'change:range', ( evt, { directChange } ) => {
+			// Indirect changes (i.e. when the user types or external changes are applied) are handled in the document's change event.
+			if ( !directChange ) {
+				return;
+			}
+
+			// Act only on collapsed selection.
+			if ( !document.selection.isCollapsed ) {
+				if ( this.hasMatch ) {
+					this.fire( 'unmatched' );
+					this.hasMatch = false;
+				}
+
+				return;
+			}
+
+			this._evaluateTextBeforeSelection( 'selection' );
+		} );
+
+		document.on( 'change:data', ( evt, batch ) => {
+			if ( batch.type == 'transparent' ) {
+				return;
+			}
+
+			this._evaluateTextBeforeSelection( 'data' );
+		} );
+	}
+
+	/**
+	 * Checks the editor content for matched text.
+	 *
+	 * @fires matched:data
+	 * @fires matched:selection
+	 * @fires unmatched
+	 *
+	 * @private
+	 */
+	_evaluateTextBeforeSelection( suffix ) {
+		const text = this._getText();
+
+		const textHasMatch = this.testCallback( text );
+
+		if ( !textHasMatch && this.hasMatch ) {
+			/**
+			 * Fired whenever the text does not match anymore. Fired only when the text watcher found a match.
+			 *
+			 * @event unmatched
+			 */
+			this.fire( 'unmatched' );
+		}
+
+		this.hasMatch = textHasMatch;
+
+		if ( textHasMatch ) {
+			/**
+			 * Fired whenever the text watcher found a match for data changes.
+			 *
+			 * @event matched:data
+			 */
+			/**
+			 * Fired whenever the text watcher found a match for selection changes.
+			 *
+			 * @event matched:selection
+			 */
+			this.fire( `matched:${ suffix }`, { text } );
+		}
+	}
+
+	/**
+	 * Returns the text before the caret from the current selection block.
+	 *
+	 * @returns {String|undefined} The text from the block or undefined if the selection is not collapsed.
+	 * @private
+	 */
+	_getText() {
+		const model = this.model;
+		const document = model.document;
+		const selection = document.selection;
+
+		const rangeBeforeSelection = model.createRange( model.createPositionAt( selection.focus.parent, 0 ), selection.focus );
+
+		return _getText( rangeBeforeSelection );
+	}
+}
+
+// Returns the whole text from a given range by adding all data from the text nodes together.
+//
+// @param {module:engine/model/range~Range} range
+// @returns {String}
+function _getText( range ) {
+	return Array.from( range.getItems() ).reduce( ( rangeText, node ) => {
+		if ( node.is( 'softBreak' ) ) {
+			// Trim text to a softBreak.
+			return '';
+		}
+
+		return rangeText + node.data;
+	}, '' );
+}
+
+mix( TextWatcher, EmitterMixin );
+

+ 193 - 0
packages/ckeditor5-typing/tests/textwatcher.js

@@ -0,0 +1,193 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import TextWatcher from '../src/textwatcher';
+
+describe( 'TextWatcher', () => {
+	let editor, model, doc;
+	let watcher, matchedDataSpy, matchedSelectionSpy, unmatchedSpy, testCallbackStub;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+
+				testCallbackStub = sinon.stub();
+				matchedDataSpy = sinon.spy();
+				matchedSelectionSpy = sinon.spy();
+				unmatchedSpy = sinon.spy();
+
+				model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				watcher = new TextWatcher( model, testCallbackStub, () => {} );
+				watcher.on( 'matched:data', matchedDataSpy );
+				watcher.on( 'matched:selection', matchedSelectionSpy );
+				watcher.on( 'unmatched', unmatchedSpy );
+			} );
+	} );
+
+	afterEach( () => {
+		sinon.restore();
+
+		if ( editor ) {
+			return editor.destroy();
+		}
+	} );
+
+	describe( 'testCallback', () => {
+		it( 'should evaluate text before caret for data changes', () => {
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledWithExactly( testCallbackStub, 'foo @' );
+		} );
+
+		it( 'should not evaluate text for not collapsed selection', () => {
+			model.change( writer => {
+				const start = writer.createPositionAt( doc.getRoot().getChild( 0 ), 0 );
+
+				writer.setSelection( writer.createRange( start, start.getShiftedBy( 1 ) ) );
+			} );
+
+			sinon.assert.notCalled( testCallbackStub );
+		} );
+
+		it( 'should evaluate text for selection changes', () => {
+			model.change( writer => {
+				writer.setSelection( doc.getRoot().getChild( 0 ), 1 );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledWithExactly( testCallbackStub, 'f' );
+		} );
+
+		it( 'should evaluate text before caret up to <softBreak>', () => {
+			model.schema.register( 'softBreak', {
+				allowWhere: '$text',
+				isInline: true
+			} );
+
+			model.change( writer => {
+				writer.insertElement( 'softBreak', doc.selection.getFirstPosition() );
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledWithExactly( testCallbackStub, '@' );
+		} );
+
+		it( 'should not evaluate text for transparent batches', () => {
+			model.enqueueChange( 'transparent', writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.notCalled( testCallbackStub );
+		} );
+	} );
+
+	describe( 'events', () => {
+		it( 'should fire "matched:data" event when test callback returns true for model data changes', () => {
+			testCallbackStub.returns( true );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledOnce( matchedDataSpy );
+			sinon.assert.notCalled( matchedSelectionSpy );
+			sinon.assert.notCalled( unmatchedSpy );
+		} );
+
+		it( 'should fire "matched:selection" event when test callback returns true for model data changes', () => {
+			testCallbackStub.returns( true );
+
+			model.enqueueChange( 'transparent', writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			model.change( writer => {
+				writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.notCalled( matchedDataSpy );
+			sinon.assert.calledOnce( matchedSelectionSpy );
+			sinon.assert.notCalled( unmatchedSpy );
+		} );
+
+		it( 'should not fire "matched" event when test callback returns false', () => {
+			testCallbackStub.returns( false );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.notCalled( matchedDataSpy );
+			sinon.assert.notCalled( matchedSelectionSpy );
+			sinon.assert.notCalled( unmatchedSpy );
+		} );
+
+		it( 'should fire "unmatched" event when test callback returns false when it was previously matched', () => {
+			testCallbackStub.returns( true );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledOnce( matchedDataSpy );
+			sinon.assert.notCalled( unmatchedSpy );
+
+			testCallbackStub.returns( false );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledTwice( testCallbackStub );
+			sinon.assert.calledOnce( matchedDataSpy );
+			sinon.assert.calledOnce( unmatchedSpy );
+		} );
+
+		it( 'should fire "umatched" event when selection is expanded', () => {
+			testCallbackStub.returns( true );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledOnce( matchedDataSpy );
+			sinon.assert.notCalled( matchedSelectionSpy );
+			sinon.assert.notCalled( unmatchedSpy );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( doc.getRoot().getChild( 0 ), 0 );
+
+				writer.setSelection( writer.createRange( start, start.getShiftedBy( 1 ) ) );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledOnce( matchedDataSpy );
+			sinon.assert.notCalled( matchedSelectionSpy );
+			sinon.assert.calledOnce( unmatchedSpy );
+		} );
+	} );
+} );
+