Chris@0: /** Chris@0: * @file Chris@0: * Defines the Drupal JavaScript API. Chris@0: */ Chris@0: Chris@0: /** Chris@0: * A jQuery object, typically the return value from a `$(selector)` call. Chris@0: * Chris@0: * Holds an HTMLElement or a collection of HTMLElements. Chris@0: * Chris@0: * @typedef {object} jQuery Chris@0: * Chris@0: * @prop {number} length=0 Chris@0: * Number of elements contained in the jQuery object. Chris@0: */ Chris@0: Chris@0: /** Chris@0: * Variable generated by Drupal that holds all translated strings from PHP. Chris@0: * Chris@0: * Content of this variable is automatically created by Drupal when using the Chris@0: * Interface Translation module. It holds the translation of strings used on Chris@0: * the page. Chris@0: * Chris@0: * This variable is used to pass data from the backend to the frontend. Data Chris@0: * contained in `drupalSettings` is used during behavior initialization. Chris@0: * Chris@0: * @global Chris@0: * Chris@0: * @var {object} drupalTranslations Chris@0: */ Chris@0: Chris@0: /** Chris@0: * Global Drupal object. Chris@0: * Chris@0: * All Drupal JavaScript APIs are contained in this namespace. Chris@0: * Chris@0: * @global Chris@0: * Chris@0: * @namespace Chris@0: */ Chris@0: window.Drupal = { behaviors: {}, locale: {} }; Chris@0: Chris@0: // JavaScript should be made compatible with libraries other than jQuery by Chris@0: // wrapping it in an anonymous closure. Chris@17: (function(Drupal, drupalSettings, drupalTranslations) { Chris@0: /** Chris@0: * Helper to rethrow errors asynchronously. Chris@0: * Chris@0: * This way Errors bubbles up outside of the original callstack, making it Chris@0: * easier to debug errors in the browser. Chris@0: * Chris@0: * @param {Error|string} error Chris@0: * The error to be thrown. Chris@0: */ Chris@17: Drupal.throwError = function(error) { Chris@0: setTimeout(() => { Chris@0: throw error; Chris@0: }, 0); Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Custom error thrown after attach/detach if one or more behaviors failed. Chris@0: * Initializes the JavaScript behaviors for page loads and Ajax requests. Chris@0: * Chris@0: * @callback Drupal~behaviorAttach Chris@0: * Chris@0: * @param {HTMLDocument|HTMLElement} context Chris@0: * An element to detach behaviors from. Chris@0: * @param {?object} settings Chris@0: * An object containing settings for the current context. It is rarely used. Chris@0: * Chris@0: * @see Drupal.attachBehaviors Chris@0: */ Chris@0: Chris@0: /** Chris@0: * Reverts and cleans up JavaScript behavior initialization. Chris@0: * Chris@0: * @callback Drupal~behaviorDetach Chris@0: * Chris@0: * @param {HTMLDocument|HTMLElement} context Chris@0: * An element to attach behaviors to. Chris@0: * @param {object} settings Chris@0: * An object containing settings for the current context. Chris@0: * @param {string} trigger Chris@0: * One of `'unload'`, `'move'`, or `'serialize'`. Chris@0: * Chris@0: * @see Drupal.detachBehaviors Chris@0: */ Chris@0: Chris@0: /** Chris@0: * @typedef {object} Drupal~behavior Chris@0: * Chris@0: * @prop {Drupal~behaviorAttach} attach Chris@0: * Function run on page load and after an Ajax call. Chris@0: * @prop {Drupal~behaviorDetach} detach Chris@0: * Function run when content is serialized or removed from the page. Chris@0: */ Chris@0: Chris@0: /** Chris@0: * Holds all initialization methods. Chris@0: * Chris@0: * @namespace Drupal.behaviors Chris@0: * Chris@0: * @type {Object.} Chris@0: */ Chris@0: Chris@0: /** Chris@0: * Defines a behavior to be run during attach and detach phases. Chris@0: * Chris@0: * Attaches all registered behaviors to a page element. Chris@0: * Chris@0: * Behaviors are event-triggered actions that attach to page elements, Chris@0: * enhancing default non-JavaScript UIs. Behaviors are registered in the Chris@0: * {@link Drupal.behaviors} object using the method 'attach' and optionally Chris@0: * also 'detach'. Chris@0: * Chris@0: * {@link Drupal.attachBehaviors} is added below to the `jQuery.ready` event Chris@0: * and therefore runs on initial page load. Developers implementing Ajax in Chris@0: * their solutions should also call this function after new page content has Chris@0: * been loaded, feeding in an element to be processed, in order to attach all Chris@0: * behaviors to the new content. Chris@0: * Chris@0: * Behaviors should use `var elements = Chris@0: * $(context).find(selector).once('behavior-name');` to ensure the behavior is Chris@0: * attached only once to a given element. (Doing so enables the reprocessing Chris@0: * of given elements, which may be needed on occasion despite the ability to Chris@0: * limit behavior attachment to a particular element.) Chris@0: * Chris@0: * @example Chris@0: * Drupal.behaviors.behaviorName = { Chris@0: * attach: function (context, settings) { Chris@0: * // ... Chris@0: * }, Chris@0: * detach: function (context, settings, trigger) { Chris@0: * // ... Chris@0: * } Chris@0: * }; Chris@0: * Chris@0: * @param {HTMLDocument|HTMLElement} [context=document] Chris@0: * An element to attach behaviors to. Chris@0: * @param {object} [settings=drupalSettings] Chris@0: * An object containing settings for the current context. If none is given, Chris@0: * the global {@link drupalSettings} object is used. Chris@0: * Chris@0: * @see Drupal~behaviorAttach Chris@0: * @see Drupal.detachBehaviors Chris@0: * Chris@0: * @throws {Drupal~DrupalBehaviorError} Chris@0: */ Chris@17: Drupal.attachBehaviors = function(context, settings) { Chris@0: context = context || document; Chris@0: settings = settings || drupalSettings; Chris@0: const behaviors = Drupal.behaviors; Chris@0: // Execute all of them. Chris@17: Object.keys(behaviors || {}).forEach(i => { Chris@14: if (typeof behaviors[i].attach === 'function') { Chris@0: // Don't stop the execution of behaviors in case of an error. Chris@0: try { Chris@0: behaviors[i].attach(context, settings); Chris@17: } catch (e) { Chris@0: Drupal.throwError(e); Chris@0: } Chris@0: } Chris@14: }); Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Detaches registered behaviors from a page element. Chris@0: * Chris@0: * Developers implementing Ajax in their solutions should call this function Chris@0: * before page content is about to be removed, feeding in an element to be Chris@0: * processed, in order to allow special behaviors to detach from the content. Chris@0: * Chris@0: * Such implementations should use `.findOnce()` and `.removeOnce()` to find Chris@0: * elements with their corresponding `Drupal.behaviors.behaviorName.attach` Chris@0: * implementation, i.e. `.removeOnce('behaviorName')`, to ensure the behavior Chris@0: * is detached only from previously processed elements. Chris@0: * Chris@0: * @param {HTMLDocument|HTMLElement} [context=document] Chris@0: * An element to detach behaviors from. Chris@0: * @param {object} [settings=drupalSettings] Chris@0: * An object containing settings for the current context. If none given, Chris@0: * the global {@link drupalSettings} object is used. Chris@0: * @param {string} [trigger='unload'] Chris@0: * A string containing what's causing the behaviors to be detached. The Chris@0: * possible triggers are: Chris@0: * - `'unload'`: The context element is being removed from the DOM. Chris@0: * - `'move'`: The element is about to be moved within the DOM (for example, Chris@0: * during a tabledrag row swap). After the move is completed, Chris@0: * {@link Drupal.attachBehaviors} is called, so that the behavior can undo Chris@0: * whatever it did in response to the move. Many behaviors won't need to Chris@0: * do anything simply in response to the element being moved, but because Chris@0: * IFRAME elements reload their "src" when being moved within the DOM, Chris@0: * behaviors bound to IFRAME elements (like WYSIWYG editors) may need to Chris@0: * take some action. Chris@0: * - `'serialize'`: When an Ajax form is submitted, this is called with the Chris@0: * form as the context. This provides every behavior within the form an Chris@0: * opportunity to ensure that the field elements have correct content Chris@0: * in them before the form is serialized. The canonical use-case is so Chris@0: * that WYSIWYG editors can update the hidden textarea to which they are Chris@0: * bound. Chris@0: * Chris@0: * @throws {Drupal~DrupalBehaviorError} Chris@0: * Chris@0: * @see Drupal~behaviorDetach Chris@0: * @see Drupal.attachBehaviors Chris@0: */ Chris@17: Drupal.detachBehaviors = function(context, settings, trigger) { Chris@0: context = context || document; Chris@0: settings = settings || drupalSettings; Chris@0: trigger = trigger || 'unload'; Chris@0: const behaviors = Drupal.behaviors; Chris@0: // Execute all of them. Chris@17: Object.keys(behaviors || {}).forEach(i => { Chris@14: if (typeof behaviors[i].detach === 'function') { Chris@0: // Don't stop the execution of behaviors in case of an error. Chris@0: try { Chris@0: behaviors[i].detach(context, settings, trigger); Chris@17: } catch (e) { Chris@0: Drupal.throwError(e); Chris@0: } Chris@0: } Chris@14: }); Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Encodes special characters in a plain-text string for display as HTML. Chris@0: * Chris@0: * @param {string} str Chris@0: * The string to be encoded. Chris@0: * Chris@0: * @return {string} Chris@0: * The encoded string. Chris@0: * Chris@0: * @ingroup sanitization Chris@0: */ Chris@17: Drupal.checkPlain = function(str) { Chris@17: str = str Chris@17: .toString() Chris@0: .replace(/&/g, '&') Chris@12: .replace(//g, '>') Chris@0: .replace(/"/g, '"') Chris@12: .replace(/'/g, '''); Chris@0: return str; Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Replaces placeholders with sanitized values in a string. Chris@0: * Chris@0: * @param {string} str Chris@0: * A string with placeholders. Chris@0: * @param {object} args Chris@0: * An object of replacements pairs to make. Incidences of any key in this Chris@0: * array are replaced with the corresponding value. Based on the first Chris@0: * character of the key, the value is escaped and/or themed: Chris@0: * - `'!variable'`: inserted as is. Chris@0: * - `'@variable'`: escape plain text to HTML ({@link Drupal.checkPlain}). Chris@0: * - `'%variable'`: escape text and theme as a placeholder for user- Chris@0: * submitted content ({@link Drupal.checkPlain} + Chris@0: * `{@link Drupal.theme}('placeholder')`). Chris@0: * Chris@0: * @return {string} Chris@0: * The formatted string. Chris@0: * Chris@0: * @see Drupal.t Chris@0: */ Chris@17: Drupal.formatString = function(str, args) { Chris@0: // Keep args intact. Chris@0: const processedArgs = {}; Chris@0: // Transform arguments before inserting them. Chris@17: Object.keys(args || {}).forEach(key => { Chris@14: switch (key.charAt(0)) { Chris@14: // Escaped only. Chris@14: case '@': Chris@14: processedArgs[key] = Drupal.checkPlain(args[key]); Chris@14: break; Chris@0: Chris@14: // Pass-through. Chris@14: case '!': Chris@14: processedArgs[key] = args[key]; Chris@14: break; Chris@0: Chris@14: // Escaped and placeholder. Chris@14: default: Chris@14: processedArgs[key] = Drupal.theme('placeholder', args[key]); Chris@14: break; Chris@0: } Chris@14: }); Chris@0: Chris@0: return Drupal.stringReplace(str, processedArgs, null); Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Replaces substring. Chris@0: * Chris@0: * The longest keys will be tried first. Once a substring has been replaced, Chris@0: * its new value will not be searched again. Chris@0: * Chris@0: * @param {string} str Chris@0: * A string with placeholders. Chris@0: * @param {object} args Chris@0: * Key-value pairs. Chris@0: * @param {Array|null} keys Chris@0: * Array of keys from `args`. Internal use only. Chris@0: * Chris@0: * @return {string} Chris@0: * The replaced string. Chris@0: */ Chris@17: Drupal.stringReplace = function(str, args, keys) { Chris@0: if (str.length === 0) { Chris@0: return str; Chris@0: } Chris@0: Chris@0: // If the array of keys is not passed then collect the keys from the args. Chris@0: if (!Array.isArray(keys)) { Chris@14: keys = Object.keys(args || {}); Chris@0: Chris@0: // Order the keys by the character length. The shortest one is the first. Chris@0: keys.sort((a, b) => a.length - b.length); Chris@0: } Chris@0: Chris@0: if (keys.length === 0) { Chris@0: return str; Chris@0: } Chris@0: Chris@0: // Take next longest one from the end. Chris@0: const key = keys.pop(); Chris@0: const fragments = str.split(key); Chris@0: Chris@0: if (keys.length) { Chris@0: for (let i = 0; i < fragments.length; i++) { Chris@0: // Process each fragment with a copy of remaining keys. Chris@0: fragments[i] = Drupal.stringReplace(fragments[i], args, keys.slice(0)); Chris@0: } Chris@0: } Chris@0: Chris@0: return fragments.join(args[key]); Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Translates strings to the page language, or a given language. Chris@0: * Chris@0: * See the documentation of the server-side t() function for further details. Chris@0: * Chris@0: * @param {string} str Chris@0: * A string containing the English text to translate. Chris@0: * @param {Object.} [args] Chris@0: * An object of replacements pairs to make after translation. Incidences Chris@0: * of any key in this array are replaced with the corresponding value. Chris@0: * See {@link Drupal.formatString}. Chris@0: * @param {object} [options] Chris@0: * Additional options for translation. Chris@0: * @param {string} [options.context=''] Chris@0: * The context the source string belongs to. Chris@0: * Chris@0: * @return {string} Chris@0: * The formatted string. Chris@0: * The translated string. Chris@0: */ Chris@17: Drupal.t = function(str, args, options) { Chris@0: options = options || {}; Chris@0: options.context = options.context || ''; Chris@0: Chris@0: // Fetch the localized version of the string. Chris@17: if ( Chris@17: typeof drupalTranslations !== 'undefined' && Chris@17: drupalTranslations.strings && Chris@17: drupalTranslations.strings[options.context] && Chris@17: drupalTranslations.strings[options.context][str] Chris@17: ) { Chris@0: str = drupalTranslations.strings[options.context][str]; Chris@0: } Chris@0: Chris@0: if (args) { Chris@0: str = Drupal.formatString(str, args); Chris@0: } Chris@0: return str; Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Returns the URL to a Drupal page. Chris@0: * Chris@0: * @param {string} path Chris@0: * Drupal path to transform to URL. Chris@0: * Chris@0: * @return {string} Chris@0: * The full URL. Chris@0: */ Chris@17: Drupal.url = function(path) { Chris@0: return drupalSettings.path.baseUrl + drupalSettings.path.pathPrefix + path; Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Returns the passed in URL as an absolute URL. Chris@0: * Chris@0: * @param {string} url Chris@0: * The URL string to be normalized to an absolute URL. Chris@0: * Chris@0: * @return {string} Chris@0: * The normalized, absolute URL. Chris@0: * Chris@0: * @see https://github.com/angular/angular.js/blob/v1.4.4/src/ng/urlUtils.js Chris@0: * @see https://grack.com/blog/2009/11/17/absolutizing-url-in-javascript Chris@0: * @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L53 Chris@0: */ Chris@17: Drupal.url.toAbsolute = function(url) { Chris@0: const urlParsingNode = document.createElement('a'); Chris@0: Chris@0: // Decode the URL first; this is required by IE <= 6. Decoding non-UTF-8 Chris@0: // strings may throw an exception. Chris@0: try { Chris@0: url = decodeURIComponent(url); Chris@17: } catch (e) { Chris@0: // Empty. Chris@0: } Chris@0: Chris@0: urlParsingNode.setAttribute('href', url); Chris@0: Chris@0: // IE <= 7 normalizes the URL when assigned to the anchor node similar to Chris@0: // the other browsers. Chris@0: return urlParsingNode.cloneNode(false).href; Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Returns true if the URL is within Drupal's base path. Chris@0: * Chris@0: * @param {string} url Chris@0: * The URL string to be tested. Chris@0: * Chris@0: * @return {bool} Chris@0: * `true` if local. Chris@0: * Chris@0: * @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L58 Chris@0: */ Chris@17: Drupal.url.isLocal = function(url) { Chris@0: // Always use browser-derived absolute URLs in the comparison, to avoid Chris@0: // attempts to break out of the base path using directory traversal. Chris@0: let absoluteUrl = Drupal.url.toAbsolute(url); Chris@17: let { protocol } = window.location; Chris@0: Chris@0: // Consider URLs that match this site's base URL but use HTTPS instead of HTTP Chris@0: // as local as well. Chris@0: if (protocol === 'http:' && absoluteUrl.indexOf('https:') === 0) { Chris@0: protocol = 'https:'; Chris@0: } Chris@17: let baseUrl = `${protocol}//${ Chris@17: window.location.host Chris@17: }${drupalSettings.path.baseUrl.slice(0, -1)}`; Chris@0: Chris@0: // Decoding non-UTF-8 strings may throw an exception. Chris@0: try { Chris@0: absoluteUrl = decodeURIComponent(absoluteUrl); Chris@17: } catch (e) { Chris@0: // Empty. Chris@0: } Chris@0: try { Chris@0: baseUrl = decodeURIComponent(baseUrl); Chris@17: } catch (e) { Chris@0: // Empty. Chris@0: } Chris@0: Chris@0: // The given URL matches the site's base URL, or has a path under the site's Chris@0: // base URL. Chris@0: return absoluteUrl === baseUrl || absoluteUrl.indexOf(`${baseUrl}/`) === 0; Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Formats a string containing a count of items. Chris@0: * Chris@0: * This function ensures that the string is pluralized correctly. Since Chris@0: * {@link Drupal.t} is called by this function, make sure not to pass Chris@0: * already-localized strings to it. Chris@0: * Chris@0: * See the documentation of the server-side Chris@0: * \Drupal\Core\StringTranslation\TranslationInterface::formatPlural() Chris@0: * function for more details. Chris@0: * Chris@0: * @param {number} count Chris@0: * The item count to display. Chris@0: * @param {string} singular Chris@0: * The string for the singular case. Please make sure it is clear this is Chris@0: * singular, to ease translation (e.g. use "1 new comment" instead of "1 Chris@0: * new"). Do not use @count in the singular string. Chris@0: * @param {string} plural Chris@0: * The string for the plural case. Please make sure it is clear this is Chris@0: * plural, to ease translation. Use @count in place of the item count, as in Chris@0: * "@count new comments". Chris@0: * @param {object} [args] Chris@0: * An object of replacements pairs to make after translation. Incidences Chris@0: * of any key in this array are replaced with the corresponding value. Chris@0: * See {@link Drupal.formatString}. Chris@0: * Note that you do not need to include @count in this array. Chris@0: * This replacement is done automatically for the plural case. Chris@0: * @param {object} [options] Chris@0: * The options to pass to the {@link Drupal.t} function. Chris@0: * Chris@0: * @return {string} Chris@0: * A translated string. Chris@0: */ Chris@17: Drupal.formatPlural = function(count, singular, plural, args, options) { Chris@0: args = args || {}; Chris@0: args['@count'] = count; Chris@0: Chris@0: const pluralDelimiter = drupalSettings.pluralDelimiter; Chris@17: const translations = Drupal.t( Chris@17: singular + pluralDelimiter + plural, Chris@17: args, Chris@17: options, Chris@17: ).split(pluralDelimiter); Chris@0: let index = 0; Chris@0: Chris@0: // Determine the index of the plural form. Chris@17: if ( Chris@17: typeof drupalTranslations !== 'undefined' && Chris@17: drupalTranslations.pluralFormula Chris@17: ) { Chris@17: index = Chris@17: count in drupalTranslations.pluralFormula Chris@17: ? drupalTranslations.pluralFormula[count] Chris@17: : drupalTranslations.pluralFormula.default; Chris@17: } else if (args['@count'] !== 1) { Chris@0: index = 1; Chris@0: } Chris@0: Chris@0: return translations[index]; Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Encodes a Drupal path for use in a URL. Chris@0: * Chris@0: * For aesthetic reasons slashes are not escaped. Chris@0: * Chris@0: * @param {string} item Chris@0: * Unencoded path. Chris@0: * Chris@0: * @return {string} Chris@0: * The encoded path. Chris@0: */ Chris@17: Drupal.encodePath = function(item) { Chris@0: return window.encodeURIComponent(item).replace(/%2F/g, '/'); Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Generates the themed representation of a Drupal object. Chris@0: * Chris@0: * All requests for themed output must go through this function. It examines Chris@0: * the request and routes it to the appropriate theme function. If the current Chris@0: * theme does not provide an override function, the generic theme function is Chris@0: * called. Chris@0: * Chris@0: * @example Chris@0: * To retrieve the HTML for text that should be emphasized and Chris@0: * displayed as a placeholder inside a sentence. Chris@0: * Drupal.theme('placeholder', text); Chris@0: * Chris@0: * @namespace Chris@0: * Chris@0: * @param {function} func Chris@0: * The name of the theme function to call. Chris@0: * @param {...args} Chris@0: * Additional arguments to pass along to the theme function. Chris@0: * Chris@0: * @return {string|object|HTMLElement|jQuery} Chris@0: * Any data the theme function returns. This could be a plain HTML string, Chris@0: * but also a complex object. Chris@0: */ Chris@17: Drupal.theme = function(func, ...args) { Chris@0: if (func in Drupal.theme) { Chris@14: return Drupal.theme[func](...args); Chris@0: } Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Formats text for emphasized display in a placeholder inside a sentence. Chris@0: * Chris@0: * @param {string} str Chris@0: * The text to format (plain-text). Chris@0: * Chris@0: * @return {string} Chris@0: * The formatted text (html). Chris@0: */ Chris@17: Drupal.theme.placeholder = function(str) { Chris@0: return `${Drupal.checkPlain(str)}`; Chris@0: }; Chris@17: })(Drupal, window.drupalSettings, window.drupalTranslations);