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.