diff --git a/CHANGES.md b/CHANGES.md index 4d125faf779..252c4217bd1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -25,6 +25,7 @@ API Changes: * [#1712](https://github.com/ckeditor/ckeditor-dev/issues/1712): [`extraPlugins`](https://docs.ckeditor.com/ckeditor4/latest/api/CKEDITOR_config.html#cfg-extraPlugins), [`removePlugins`](https://docs.ckeditor.com/ckeditor4/latest/api/CKEDITOR_config.html#cfg-removePlugins) and [`plugins`](https://docs.ckeditor.com/ckeditor4/latest/api/CKEDITOR_config.html#cfg-plugins) configuration options allow whitespace. * [#1724](https://github.com/ckeditor/ckeditor-dev/issues/1724): Added option to [`getClientRect`](https://docs.ckeditor.com/ckeditor4/latest/api/CKEDITOR_dom_element.html#method-getClientRect) function allowing to retrieve an absolute bounding rectangle of the element i.e. position relative to the upper-left corner of the topmost viewport. * [#1498](https://github.com/ckeditor/ckeditor-dev/issues/1498) : Added new method 'getClientRects()' to CKEDITOR.dom.range, which returns list of rects for each selected element. +* [#1993](https://github.com/ckeditor/ckeditor-dev/issues/1993): Added [`CKEDITOR.tools.throttle`](http://docs.ckeditor.com/ckeditor4/docs/#!/api/CKEDITOR_tools.html#method-throttle) function. ## CKEditor 4.9.2 diff --git a/core/tools.js b/core/tools.js index a4487f772e4..bae122fd368 100644 --- a/core/tools.js +++ b/core/tools.js @@ -579,6 +579,95 @@ }, milliseconds || 0 ); }, + /** + * Returns a wrapper that exposes an `input` function, which acts as a proxy to the given `output` function, providing a throttling. + * This proxy guarantees that the `output` function is not called more often than `minInterval`. + * + * If multiple calls occur within a single `minInterval` time, the most recent `input` call with its arguments will be used to schedule + * next `output` call, and the previous throttled calls will be discarded. + * + * The first `input` call is always executed asynchronously which means that `output` call will be executed immediately. + * + * ```javascript + * var buffer = CKEDITOR.tools.throttle( 200, function( message ) { + * console.log( message ); + * } ); + * + * buffer.input( 'foo!' ); + * // 'foo!' logged immediately. + * buffer.input( 'bar!' ); + * // Nothing logged. + * buffer.input( 'baz!' ); + * // Nothing logged. + * // … after 200ms a single 'baz!' will be logged. + * ``` + * + * Can be easily used with events: + * + * ```javascript + * var buffer = CKEDITOR.tools.throttle( 200, function( evt ) { + * console.log( evt.data.text ); + * } ); + * + * editor.on( 'key', buffer.input ); + * // Note: There is no need to bind buffer as a context. + * ``` + * + * @since 4.10.0 + * @param {Number} minInterval Minimum interval between `output` calls in milliseconds. + * @param {Function} output Function that will be executed as `output`. + * @param {Object} [contextObj] The object used to context the listener call (the `this` object). + * @returns {Object} + * @returns {Function} return.input Buffer's input method. + * Accepts parameters which will be directly passed into `output` function. + * @returns {Function} return.reset Resets buffered calls — `output` will not be executed + * until next `input` is triggered. + */ + throttle: function( minInterval, output, contextObj ) { + var scheduled, + lastOutput = 0; + + contextObj = contextObj || {}; + + return { + input: input, + reset: reset + }; + + function input() { + var args = Array.prototype.slice.call( arguments ); + + if ( scheduled ) { + clearTimeout( scheduled ); + scheduled = 0; + } + + var diff = ( new Date() ).getTime() - lastOutput; + + // If less than minInterval passed after last check, + // schedule next for minInterval after previous one. + if ( diff < minInterval ) { + scheduled = setTimeout( triggerOutput, minInterval - diff ); + } else { + triggerOutput(); + } + + function triggerOutput() { + lastOutput = ( new Date() ).getTime(); + scheduled = false; + + output.apply( contextObj, args ); + } + } + + function reset() { + if ( scheduled ) { + clearTimeout( scheduled ); + scheduled = lastOutput = 0; + } + } + }, + /** * Removes spaces from the start and the end of a string. The following * characters are removed: space, tab, line break, line feed. @@ -1189,45 +1278,50 @@ * Buffers `input` events (or any `input` calls) * and triggers `output` not more often than once per `minInterval`. * - * var buffer = CKEDITOR.tools.eventsBuffer( 200, function() { - * console.log( 'foo!' ); - * } ); + * ```javascript + * var buffer = CKEDITOR.tools.eventsBuffer( 200, function() { + * console.log( 'foo!' ); + * } ); * - * buffer.input(); - * // 'foo!' logged immediately. - * buffer.input(); - * // Nothing logged. - * buffer.input(); - * // Nothing logged. - * // ... after 200ms a single 'foo!' will be logged. + * buffer.input(); + * // 'foo!' logged immediately. + * buffer.input(); + * // Nothing logged. + * buffer.input(); + * // Nothing logged. + * // … after 200ms a single 'foo!' will be logged. + * ``` * * Can be easily used with events: * - * var buffer = CKEDITOR.tools.eventsBuffer( 200, function() { - * console.log( 'foo!' ); - * } ); + * ```javascript + * var buffer = CKEDITOR.tools.eventsBuffer( 200, function() { + * console.log( 'foo!' ); + * } ); + * + * editor.on( 'key', buffer.input ); + * // Note: There is no need to bind buffer as a context. + * ``` * - * editor.on( 'key', buffer.input ); - * // Note: There is no need to bind buffer as a context. * * @since 4.2.1 * @param {Number} minInterval Minimum interval between `output` calls in milliseconds. * @param {Function} output Function that will be executed as `output`. - * @param {Object} [scopeObj] The object used to scope the listener call (the `this` object). + * @param {Object} [contextObj] The object used to context the listener call (the `this` object). * @returns {Object} * @returns {Function} return.input Buffer's input method. * @returns {Function} return.reset Resets buffered events — `output` will not be executed * until next `input` is triggered. */ - eventsBuffer: function( minInterval, output, scopeObj ) { + eventsBuffer: function( minInterval, output, contextObj ) { var scheduled, lastOutput = 0; function triggerOutput() { lastOutput = ( new Date() ).getTime(); scheduled = false; - if ( scopeObj ) { - output.call( scopeObj ); + if ( contextObj ) { + output.call( contextObj ); } else { output(); } diff --git a/tests/core/tools.js b/tests/core/tools.js index 372b2be1231..85473f12e4d 100644 --- a/tests/core/tools.js +++ b/tests/core/tools.js @@ -602,7 +602,7 @@ }, 110 ); }, - 'test eventsBuffer contex': function() { + 'test eventsBuffer context': function() { var spy = sinon.spy(), ctxObj = {}, buffer = CKEDITOR.tools.eventsBuffer( 100, spy, ctxObj ); @@ -628,6 +628,102 @@ assert.areSame( 'ABcDeF', c( 'aBcDeF', true ) ); }, + 'test throttle': function() { + var foo = 'foo', + baz = 'baz', + inputSpy = sinon.spy(), + buffer = CKEDITOR.tools.throttle( 200, inputSpy ); + + buffer.input( foo ); + + assert.areSame( 1, inputSpy.callCount, 'Call count after the first call' ); + assert.isTrue( inputSpy.calledWithExactly( foo ), 'Call argument after the first call' ); + + buffer.input( baz ); + + assert.areSame( 1, inputSpy.callCount, 'Call count after the second call' ); + assert.isTrue( inputSpy.calledWithExactly( foo ), 'Call argument the after second call' ); + + wait( function() { + assert.areSame( 1, inputSpy.callCount, 'Call count after the second call timeout (1st)' ); + assert.isTrue( inputSpy.calledWithExactly( foo ), 'Call argument after the second call timeout (1st)' ); + + wait( function() { + assert.areSame( 2, inputSpy.callCount, 'Call count after the second call timeout (2nd)' ); + assert.isTrue( inputSpy.getCall( 1 ).calledWithExactly( baz ), 'Call argument after the second call timeout (2nd)' ); + + buffer.input( foo ); + + wait( function() { + assert.areSame( 3, inputSpy.callCount, 'Call count after the third call' ); + assert.isTrue( inputSpy.getCall( 2 ).calledWithExactly( foo ), 'Call argument after the third call' ); + + // Check that input triggered after 70ms from previous + // buffer.input will trigger output after next 140ms (200-70). + wait( function() { + buffer.input( baz ); + + assert.areSame( 3, inputSpy.callCount, 'Call count after the fourth call' ); + + wait( function() { + assert.areSame( 4, inputSpy.callCount, 'Call count after the fourth call timeout' ); + assert.isTrue( inputSpy.getCall( 3 ).calledWithExactly( baz ), 'Call argument after the fourth call timeout' ); + }, 140 ); + }, 70 ); + }, 210 ); + }, 110 ); + }, 100 ); + }, + + 'test throttle always uses the most recent argument': function() { + var input = sinon.stub(), + buffer = CKEDITOR.tools.throttle( 50, input ); + + buffer.input( 'first' ); + + assert.areSame( 1, input.callCount, 'Call count after the first call' ); + sinon.assert.calledWithExactly( input.getCall( 0 ), 'first' ); + + buffer.input( 'second' ); + + buffer.input( 'third' ); + + wait( function() { + assert.areSame( 2, input.callCount, 'Call count after the timeout' ); + sinon.assert.calledWithExactly( input.getCall( 1 ), 'third' ); + }, 100 ); + }, + + 'test throttle.reset': function() { + var inputSpy = sinon.spy(), + buffer = CKEDITOR.tools.throttle( 100, inputSpy ); + + assert.areSame( 0, inputSpy.callCount, 'Initial call count' ); + + buffer.input(); + + assert.areSame( 1, inputSpy.callCount, 'Call count after the first call' ); + + buffer.input(); + buffer.reset(); + + assert.areSame( 1, inputSpy.callCount, 'Call count after reset' ); + + buffer.input(); + + assert.areSame( 2, inputSpy.callCount, 'Call count after the second call' ); + }, + + 'test throttle context': function() { + var spy = sinon.spy(), + ctxObj = {}, + buffer = CKEDITOR.tools.throttle( 100, spy, ctxObj ); + + buffer.input(); + + assert.areSame( ctxObj, spy.getCall( 0 ).thisValue, 'callback was executed with the right context' ); + }, + 'test checkIfAnyObjectPropertyMatches': function() { var c = CKEDITOR.tools.checkIfAnyObjectPropertyMatches, r1 = /foo/, diff --git a/tests/core/tools/manual/throttle.html b/tests/core/tools/manual/throttle.html new file mode 100644 index 00000000000..c7e085b3dc3 --- /dev/null +++ b/tests/core/tools/manual/throttle.html @@ -0,0 +1,21 @@ + + + + + diff --git a/tests/core/tools/manual/throttle.md b/tests/core/tools/manual/throttle.md new file mode 100644 index 00000000000..12b902148c1 --- /dev/null +++ b/tests/core/tools/manual/throttle.md @@ -0,0 +1,15 @@ +@bender-tags: feature, 4.10.0, 1993 +@bender-ui: collapsed +@bender-ckeditor-plugins: toolbar, wysiwygarea + +1. Click button `Click me` five times as fast as possible. + +## Expected + +Button changes its color: +1. After first click to blue. +1. After last click to red. + +## Unexpected + +Button changes its color in invalid order.