Merge "Title: Refactor JS/CSS page handling to be more sane"
[lhc/web/wiklou.git] / resources / src / jquery / jquery.byteLimit.js
1 /**
2 * @class jQuery.plugin.byteLimit
3 */
4 ( function ( $, mw ) {
5
6 var
7 eventKeys = [
8 'keyup.byteLimit',
9 'keydown.byteLimit',
10 'change.byteLimit',
11 'mouseup.byteLimit',
12 'cut.byteLimit',
13 'paste.byteLimit',
14 'focus.byteLimit',
15 'blur.byteLimit'
16 ].join( ' ' ),
17 trimByteLength = require( 'mediawiki.String' ).trimByteLength;
18
19 /**
20 * Utility function to trim down a string, based on byteLimit
21 * and given a safe start position. It supports insertion anywhere
22 * in the string, so "foo" to "fobaro" if limit is 4 will result in
23 * "fobo", not "foba". Basically emulating the native maxlength by
24 * reconstructing where the insertion occurred.
25 *
26 * @method trimByteLength
27 * @deprecated Use `require( 'mediawiki.String' ).trimByteLength` instead.
28 * @static
29 * @param {string} safeVal Known value that was previously returned by this
30 * function, if none, pass empty string.
31 * @param {string} newVal New value that may have to be trimmed down.
32 * @param {number} byteLimit Number of bytes the value may be in size.
33 * @param {Function} [fn] See jQuery#byteLimit.
34 * @return {Object}
35 * @return {string} return.newVal
36 * @return {boolean} return.trimmed
37 */
38 mw.log.deprecate( $, 'trimByteLength', trimByteLength,
39 'Use require( \'mediawiki.String\' ).trimByteLength instead.', '$.trimByteLength' );
40
41 /**
42 * Enforces a byte limit on an input field, so that UTF-8 entries are counted as well,
43 * when, for example, a database field has a byte limit rather than a character limit.
44 * Plugin rationale: Browser has native maxlength for number of characters, this plugin
45 * exists to limit number of bytes instead.
46 *
47 * Can be called with a custom limit (to use that limit instead of the maxlength attribute
48 * value), a filter function (in case the limit should apply to something other than the
49 * exact input value), or both. Order of parameters is important!
50 *
51 * @param {number} [limit] Limit to enforce, fallsback to maxLength-attribute,
52 * called with fetched value as argument.
53 * @param {Function} [fn] Function to call on the string before assessing the length.
54 * @return {jQuery}
55 * @chainable
56 */
57 $.fn.byteLimit = function ( limit, fn ) {
58 // If the first argument is the function,
59 // set fn to the first argument's value and ignore the second argument.
60 if ( $.isFunction( limit ) ) {
61 fn = limit;
62 limit = undefined;
63 // Either way, verify it is a function so we don't have to call
64 // isFunction again after this.
65 } else if ( !fn || !$.isFunction( fn ) ) {
66 fn = undefined;
67 }
68
69 // The following is specific to each element in the collection.
70 return this.each( function ( i, el ) {
71 var $el, elLimit, prevSafeVal;
72
73 $el = $( el );
74
75 // If no limit was passed to byteLimit(), use the maxlength value.
76 // Can't re-use 'limit' variable because it's in the higher scope
77 // that would affect the next each() iteration as well.
78 // Note that we use attribute to read the value instead of property,
79 // because in Chrome the maxLength property by default returns the
80 // highest supported value (no indication that it is being enforced
81 // by choice). We don't want to bind all of this for some ridiculously
82 // high default number, unless it was explicitly set in the HTML.
83 // Also cast to a (primitive) number (most commonly because the maxlength
84 // attribute contains a string, but theoretically the limit parameter
85 // could be something else as well).
86 elLimit = Number( limit === undefined ? $el.attr( 'maxlength' ) : limit );
87
88 // If there is no (valid) limit passed or found in the property,
89 // skip this. The < 0 check is required for Firefox, which returns
90 // -1 (instead of undefined) for maxLength if it is not set.
91 if ( !elLimit || elLimit < 0 ) {
92 return;
93 }
94
95 if ( fn ) {
96 // Save function for reference
97 $el.data( 'byteLimit.callback', fn );
98 }
99
100 // Remove old event handlers (if there are any)
101 $el.off( '.byteLimit' );
102
103 if ( fn ) {
104 // Disable the native maxLength (if there is any), because it interferes
105 // with the (differently calculated) byte limit.
106 // Aside from being differently calculated (average chars with byteLimit
107 // is lower), we also support a callback which can make it to allow longer
108 // values (e.g. count "Foo" from "User:Foo").
109 // maxLength is a strange property. Removing or setting the property to
110 // undefined directly doesn't work. Instead, it can only be unset internally
111 // by the browser when removing the associated attribute (Firefox/Chrome).
112 // https://bugs.chromium.org/p/chromium/issues/detail?id=136004
113 $el.removeAttr( 'maxlength' );
114
115 } else {
116 // If we don't have a callback the bytelimit can only be lower than the charlimit
117 // (that is, there are no characters less than 1 byte in size). So lets (re-)enforce
118 // the native limit for efficiency when possible (it will make the while-loop below
119 // faster by there being less left to interate over).
120 $el.attr( 'maxlength', elLimit );
121 }
122
123 // Safe base value, used to determine the path between the previous state
124 // and the state that triggered the event handler below - and enforce the
125 // limit approppiately (e.g. don't chop from the end if text was inserted
126 // at the beginning of the string).
127 prevSafeVal = '';
128
129 // We need to listen to after the change has already happened because we've
130 // learned that trying to guess the new value and canceling the event
131 // accordingly doesn't work because the new value is not always as simple as:
132 // oldValue + String.fromCharCode( e.which ); because of cut, paste, select-drag
133 // replacements, and custom input methods and what not.
134 // Even though we only trim input after it was changed (never prevent it), we do
135 // listen on events that input text, because there are cases where the text has
136 // changed while text is being entered and keyup/change will not be fired yet
137 // (such as holding down a single key, fires keydown, and after each keydown,
138 // we can trim the previous one).
139 // See https://www.w3.org/TR/DOM-Level-3-Events/#events-keyboard-event-order for
140 // the order and characteristics of the key events.
141 $el.on( eventKeys, function () {
142 var res = trimByteLength(
143 prevSafeVal,
144 this.value,
145 elLimit,
146 fn
147 );
148
149 // Only set value property if it was trimmed, because whenever the
150 // value property is set, the browser needs to re-initiate the text context,
151 // which moves the cursor at the end the input, moving it away from wherever it was.
152 // This is a side-effect of limiting after the fact.
153 if ( res.trimmed === true ) {
154 this.value = res.newVal;
155 // Trigger a 'change' event to let other scripts attached to this node know that the value
156 // was changed. This will also call ourselves again, but that's okay, it'll be a no-op.
157 $el.trigger( 'change' );
158 }
159 // Always adjust prevSafeVal to reflect the input value. Not doing this could cause
160 // trimByteLength to compare the new value to an empty string instead of the
161 // old value, resulting in trimming always from the end (T42850).
162 prevSafeVal = res.newVal;
163 } );
164 } );
165 };
166
167 /**
168 * @class jQuery
169 * @mixins jQuery.plugin.byteLimit
170 */
171 }( jQuery, mediaWiki ) );